Skip to content

Authoring a plugin

A plugin teaches calendaryjs a new event type (a recurrence rule), and can optionally enrich each day with extra data or register named formulas. Anyone can publish one — official and community plugins use the exact same shape.

The fastest path is the official template:

Terminal window
# copy templates/calendaryjs-plugin out of the calendaryjs repo, then:
npm install
npm test

Or scaffold by hand: a plugin is just a package that depends on calendaryjs as a peer dependency and exports a factory returning a CalendaryPlugin.

Declare an interface for your event type — consumers type their configs with it and they flow into addGroup / load with no cast:

import type { BaseEventProperties, CalendaryPlugin } from "calendaryjs";
// Your event type. Extend BaseEventProperties so id/title/priority/… come for free.
export interface MyEvent extends BaseEventProperties {
type: "my-thing";
month: number;
day: number;
}
export function myPlugin(): CalendaryPlugin {
return {
name: "calendaryjs-plugin-my-thing",
version: "0.1.0",
eventTypes: {
"my-thing": {
validate: (event): boolean => (event as Partial<MyEvent>).type === "my-thing",
generate: (event, year): Date[] => {
const e = event as MyEvent;
return [new Date(year, e.month - 1, e.day)];
},
},
},
};
}

Use it:

import { calendary } from "calendaryjs";
import { myPlugin, type MyEvent } from "calendaryjs-plugin-my-thing";
const cal = calendary().use(myPlugin());
const events: MyEvent[] = [{ type: "my-thing", id: "x", title: "X", month: 6, day: 1 }];
cal.addGroup({ id: "g", events }); // no cast

This is the workhorse, and it has three rules that aren’t obvious:

  1. year is a bucketing hint, not a filter. The engine calls generate for the queried year and its neighbours (year − 1, year, year + 1), then files each date under the calendar year it actually falls in. So an occurrence that crosses Jan 1 / Dec 31 lands on the right day automatically — don’t special-case it. (A date more than a year from year is dropped; keep yours within ±1 year.)

    Worked example — ISO week 1’s Monday can fall in the previous December:

    generate: (event, year) => [isoWeekMonday(year, event.week)];
    // generate(event, 2026) → 2025-12-29 (ISO week 1 of 2026).
    // The engine files it under 2025, so it shows on Dec 29, 2025 — correct.
    // Querying calendar-year 2026 won't list it, because that Monday isn't in 2026.
  2. Return plain, local-time Dates. The engine formats each with local getters (getFullYear/getMonth/getDate) into YYYY-MM-DD — timezone-neutral by design. Build dates with new Date(year, month0, day), not new Date(Date.UTC(…)), or you’ll be a day off in negative-UTC zones.

  3. You don’t re-implement the shared fields. startDate / endDate / count / excludeYears / exceptions / overrideDates / priority are applied centrally to every event type — return your natural dates and the engine filters, reschedules, and orders them. Your handler owns only the recurrence math.

addGroup / load run validate on each config and throw if it returns false — so a malformed event fails fast, with a clear error, instead of silently producing wrong or missing dates later. Return true for the shapes you accept, checking the fields generate depends on.

calendaryjs/testing runs your plugin through the real engine, so you assert on the dates a query would actually show. Test at this level — calling generate() in isolation misses the year-bucketing above:

import { datesIn } from "calendaryjs/testing";
import { myPlugin, type MyEvent } from "./index";
test("fires on the given month/day", () => {
const e: MyEvent = { type: "my-thing", id: "x", title: "X", month: 6, day: 1 };
expect(datesIn(myPlugin(), [e], { from: "2026-01-01", to: "2026-12-31" })).toEqual([
"2026-06-01",
]);
});

datesIn(plugin, events, range) returns the YYYY-MM-DD dates; occurrencesIn(…) returns the full CalendarEvents. Both accept one plugin or an array.

To attach data to every CalendarDay (like a lunar or Hijri date), register a day enricher in install() and augment the CalendarDay type:

declare module "calendaryjs" {
interface CalendarDay {
myInfo?: { foo: string };
}
}
// inside the plugin:
install(calendary) {
calendary.registerDayEnricher({
name: "my-thing",
priority: 10,
enrich: (day) => ({ ...day, myInfo: { foo: "bar" } }),
});
}

This is what makes your plugin show up in the plugins directory:

  • Package name: calendaryjs-plugin-<name> (e.g. calendaryjs-plugin-fiscal-year).
  • Keyword: add "calendaryjs-plugin" to package.json keywords. The directory crawls npm for this keyword — no registration step.
  • Peer dependency: "calendaryjs": ">=0.1.0" (your plugin works with whatever version the user already installed).
{
"name": "calendaryjs-plugin-fiscal-year",
"keywords": ["calendaryjs-plugin", "calendar", "fiscal"],
"peerDependencies": { "calendaryjs": ">=0.1.0" },
}
Terminal window
npm publish

That’s it — no scope, no custom CLI, no registry to sign up for. Within a day the plugins directory picks it up via the calendaryjs-plugin keyword, alongside its npm download count.

See Plugins for how plugins are loaded and discovered.