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.
1. Start from the template
Section titled “1. Start from the template”The fastest path is the official template:
# copy templates/calendaryjs-plugin out of the calendaryjs repo, then:npm installnpm testOr scaffold by hand: a plugin is just a package that depends on calendaryjs as a
peer dependency and exports a factory returning a CalendaryPlugin.
2. The plugin shape
Section titled “2. The plugin shape”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 castgenerate(event, year) — the contract
Section titled “generate(event, year) — the contract”This is the workhorse, and it has three rules that aren’t obvious:
-
yearis a bucketing hint, not a filter. The engine callsgeneratefor 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 fromyearis 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. -
Return plain, local-time
Dates. The engine formats each with local getters (getFullYear/getMonth/getDate) intoYYYY-MM-DD— timezone-neutral by design. Build dates withnew Date(year, month0, day), notnew Date(Date.UTC(…)), or you’ll be a day off in negative-UTC zones. -
You don’t re-implement the shared fields.
startDate/endDate/count/excludeYears/exceptions/overrideDates/priorityare 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.
validate(event) — your config guard
Section titled “validate(event) — your config guard”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.
3. Test it
Section titled “3. Test it”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.
4. Optional: enrich each day
Section titled “4. Optional: enrich each day”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" } }), });}5. Name it so it’s discoverable
Section titled “5. Name it so it’s discoverable”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"topackage.jsonkeywords. 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" },}6. Publish
Section titled “6. Publish”npm publishThat’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.