npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

rrule-i18n

v0.2.0

Published

Turn a parsed RRULE (RFC 5545) into grammatically correct, human-readable text in multiple languages.

Downloads

543

Readme

rrule-i18n

npm version bundle size coverage CI license

Turn a parsed RFC 5545 recurrence rule (FREQ=...;INTERVAL=...) into grammatically correct, human-readable text in multiple languages.

import { rruleToText } from 'rrule-i18n';
import { ruLocale } from 'rrule-i18n/locales/ru';

rruleToText('FREQ=DAILY;INTERVAL=1'); // "Every day"
rruleToText('FREQ=DAILY;INTERVAL=1', { locale: ruLocale }); // "Каждый день"
rruleToText('FREQ=MONTHLY;BYDAY=MO;BYSETPOS=1', { locale: ruLocale }); // "Каждый месяц в первый понедельник"

Why not just use rrule's own toText()?

rrule is a great engine for parsing, building, and serializing recurrence rules, and it ships a toText() method -- but that method only speaks English, and its extension point (toText(gettext?, language?)) is a word-for-word substitution ("Monday" -> "понедельник"). That's not enough for languages with real grammar:

  • Numeral agreement: 1 день / 2 дня / 5 дней / 11 дней -- Russian isn't n % 10, 11-14 are always the "many" form.
  • Gender agreement: в первый понедельник (masculine) vs. в первую среду (feminine) -- the ordinal and the weekday noun have to agree, so they have to be produced together, not as two independent tokens.
  • Case changes by construction: в январе (prepositional) but по понедельникам (dative plural) -- the same word takes a different case depending on the surrounding phrase.
  • Idioms: FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR collapses to "on weekdays" / "по будням", not "every day on Monday, Tuesday, Wednesday, Thursday and Friday".

rrule-i18n doesn't patch rrule -- it builds text itself from the rule's parsed fields, with each language implemented as a self-contained plugin that returns whole, already-agreeing phrases (not single words to interpolate). rrule remains a (peer) dependency purely as the RFC 5545 parser for RRule.fromString().

Install

npm install rrule-i18n rrule

rrule is a peer dependency -- you need it installed alongside this package (it's what parses RRULE strings). If you only ever pass already-parsed options objects, you still need it installed for the package to load, but you'll never call into it directly.

Quick start

import { rruleToText, tryRruleToText } from 'rrule-i18n';

rruleToText('FREQ=WEEKLY;BYDAY=MO,WE,FR');
// "Every week on Monday, Wednesday and Friday"

rruleToText('FREQ=MONTHLY;BYMONTHDAY=-1');
// "Every month on the last day of the month"

rruleToText('FREQ=YEARLY;BYMONTH=3;BYDAY=1SU');
// "In March on the 1st Sunday"

// Never throws -- returns null instead of throwing on bad input.
tryRruleToText('not a valid rrule'); // null
tryRruleToText(null); // null

Using a locale other than English

English ships in the core package for zero-config use. Every other bundled locale lives at its own subpath so it can be tree-shaken away if you don't use it:

import { rruleToText, registerLocale } from 'rrule-i18n';
import { ruLocale } from 'rrule-i18n/locales/ru';

// Option A: pass the locale object directly -- no registration needed.
rruleToText('FREQ=DAILY', { locale: ruLocale }); // "Каждый день"

// Option B: register it once (e.g. at app startup), then refer to it by code.
registerLocale(ruLocale);
rruleToText('FREQ=DAILY', { locale: 'ru' }); // "Каждый день"

API

rruleToText(rule, options?): string

Converts a rule to text. Throws RRuleTextError if rule can't be parsed or turned into text (invalid RRULE string, missing/out-of-range freq, null/undefined input, etc.).

tryRruleToText(rule, options?): string | null

Same as rruleToText, but never throws -- returns null for any invalid input (null, undefined, an empty string, an unparsable RRULE string, ...).

rule: RRuleLike

Any of:

  • a raw RRULE string, with or without a leading RRULE: prefix: "FREQ=DAILY;INTERVAL=2"
  • an already-parsed options object: { freq: 3, interval: 2 } (using rrule's Frequency codes: YEARLY=0, MONTHLY=1, WEEKLY=2, DAILY=3, HOURLY=4, MINUTELY=5, SECONDLY=6)
  • an object structurally compatible with an RRule instance from the rrule package (i.e. anything with an .options property) -- so you can pass an RRule instance straight through: rruleToText(myRRuleInstance)

options: RRuleTextOptions

| Option | Type | Default | Description | | ---------------- | ---------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------- | | locale | string \| RRuleLocale | 'en' | A registered locale code, or a locale object (see above). | | fallbackLocale | string | 'en' | Locale code to fall back to when locale is a string that isn't registered. | | dateFormatter | (date: Date, locale: string) => string | Intl.DateTimeFormat(locale, { dateStyle: 'long' }) | Used to format the date in UNTIL clauses. | | capitalize | boolean | true | Capitalize the first letter of the resulting sentence. |

registerLocale(locale: RRuleLocale): void

Registers a locale so it can be referenced later by its code string. Not required if you always pass the locale object directly.

getLocale(code: string): RRuleLocale | undefined

Looks up a previously-registered locale.

RRuleTextError

The error class thrown by rruleToText (and caught internally by tryRruleToText).

Supported RRULE fields

All seven FREQ values (YEARLY, MONTHLY, WEEKLY, DAILY, HOURLY, MINUTELY, SECONDLY), plus:

  • INTERVAL
  • COUNT
  • UNTIL
  • BYDAY, including numeric-prefixed shorthand (1MO, -1FR) and the general BYDAY + BYSETPOS form ("the Nth weekday")
  • BYMONTHDAY, including negative values ("the Nth-to-last day of the month")
  • BYMONTH
  • WKST (accepted, but intentionally has no effect on the generated text -- it only affects which dates an occurrence falls on when INTERVAL > 1, not how the rule reads in prose; no calendar UI narrates it either)
  • BYYEARDAY (day-of-year, including negative/from-end-of-year values) and BYWEEKNO (ISO week-of-year, including negative values).

The Angular pipe adapter

There isn't one. tryRruleToText is already a plain, side-effect-free function, so wrapping it in a one-line pure pipe (transform(rule, locale) { return tryRruleToText(rule, { locale }) ?? ''; }) in your own app is simpler and more transparent than depending on this package to ship Angular-specific code (and to keep pace with your Angular version).

Adding a new locale

The core engine (rrule-i18n's main entry) has no language-specific text anywhere in it -- it only knows how to normalize a rule's fields and call methods on whatever RRuleLocale object it's given. Adding a language is purely additive: write a new file, don't touch the core.

  1. Create src/locales/<code>.ts implementing the RRuleLocale interface:
import type { RRuleLocale } from 'rrule-i18n';

export const myLocale: RRuleLocale = {
  code: 'xx',
  frequencyPhrase(freq, interval) {
    /* "every day" / "every 2 days" */
  },
  weekdaysPhrase(days) {
    /* "on Monday, Wednesday and Friday"; special-case the full Mon-Fri set */
  },
  nthWeekdayPhrase(pos, weekday) {
    /* "on the first Monday", agreement is this method's job */
  },
  monthsPhrase(months) {
    /* "in January and June" */
  },
  monthdaysPhrase(days) {
    /* "on the 1st and 15th" / "on the last day of the month" */
  },
  until(date, dateFormatter) {
    /* "until " + dateFormatter(date, 'xx') */
  },
  count(n) {
    /* "for 10 times" */
  },
  join(parts) {
    /* combine the non-null clause fragments into one sentence */
  },
};

Each method must return a complete, already-agreeing phrase fragment (including whatever preposition or case ending your language needs) -- never a bare word meant to be substituted into a fixed template. That's the whole point: the core engine calls these methods and concatenates their results via join(), it never touches individual words.

  1. Register it, either by consumers calling registerLocale(myLocale) themselves, or by publishing it at its own subpath the same way ru is (rrule-i18n/locales/xx) if you're contributing it back to this package -- add one entry to the exports map in package.json and one entry to tsdown.config.ts's entry map. The core package (src/index.ts) is never modified.

  2. Test it against the same fixture matrix used for en/ru in tests/fixtures.ts -- every FREQ, INTERVAL edge case your language cares about (numeral agreement, if any), BYDAY (plain, "the Nth weekday", and the full-business-week idiom), BYMONTHDAY, BYMONTH, COUNT, and UNTIL.

tests/registry.test.ts has a minimal example (a fake xx locale registered entirely inside a test) proving a third locale really does work end-to-end through registerLocale() without any core changes.

Notable differences from rrule's own toText()

rrule-i18n's English output intentionally differs from rrule.toText() in a few places -- documented (and regression-tested against rrule itself) in tests/regression.test.ts:

  • BYDAY + BYSETPOS (e.g. "the first Monday of the month") always includes the ordinal. rrule's own toText() drops it entirely ("every month on Monday") -- this is the single biggest gap this package exists to fix.
  • Lists use a natural "a, b and c" join instead of rrule's comma-only "a, b, c".
  • YEARLY + BYMONTH renders as "in January and June" rather than "every January and June".
  • SECONDLY is fully supported; rrule.toText() doesn't support it at all.

Development

npm install
npm run typecheck   # tsc --noEmit
npm run lint        # eslint . (type-aware, flat config)
npm run format      # prettier --write .
npm run build       # tsdown -> ESM (.mjs) + CJS (.cjs) + .d.mts/.d.cts in dist/
npm test            # vitest run --coverage (threshold: 95%)

Building requires Node.js ^22.18.0 || >=24.11.0 (tsdown's own requirement); the published package itself only requires Node >=18 at runtime (see engines in package.json).

Coding style, enforced by ESLint: arrow function expressions instead of function declarations, explicit import type/inline type modifiers for type-only imports (verbatimModuleSyntax in tsconfig.json), and typescript-eslint's type-aware recommended + stylistic rule sets. tsconfig.json also enables exactOptionalPropertyTypes, noUncheckedIndexedAccess, noImplicitReturns, noFallthroughCasesInSwitch, noUnusedLocals, and noUnusedParameters.

License

MIT