Skip to content

i18n Locales

This guide covers the locale system for toText(): the built-in English and German packs, how to pass a locale, and how to implement a custom locale.

Locale packs control the language used by toText(). Each locale is a separate subpath import, so bundles only include the locales they use: importing rrule-ts/locales/de without using it adds zero bytes to a bundle that tree-shakes.

Import pathExportLanguage
rrule-ts/locales/enenEnglish (default)
rrule-ts/locales/dedeGerman

The English locale is the default. toText(options) without a TextOptions argument uses it automatically. You do not need to import rrule-ts/locales/en unless you want the LocalePack type or want to reference it explicitly.

import { toText } from 'rrule-ts/text'
import { de } from 'rrule-ts/locales/de'
toText({ freq: 'DAILY' }, { locale: de })
// 'jeden Tag'
import { toText } from 'rrule-ts/text'
import { en } from 'rrule-ts/locales/en'
toText({ freq: 'DAILY' }, { locale: en })
// 'every day'

The German locale handles grammatical gender (jeden/jede/jedes) and uses period-suffix ordinals (1., 2., 3.) instead of English suffixes (1st, 2nd, 3rd).

import { toText } from 'rrule-ts/text'
import { de } from 'rrule-ts/locales/de'
// Frequency intervals
toText({ freq: 'DAILY' }, { locale: de })
// 'jeden Tag'
toText({ freq: 'DAILY', interval: 2 }, { locale: de })
// 'alle 2 Tage'
toText({ freq: 'WEEKLY' }, { locale: de })
// 'jede Woche'
toText({ freq: 'WEEKLY', interval: 3 }, { locale: de })
// 'alle 3 Wochen'
toText({ freq: 'MONTHLY' }, { locale: de })
// 'jeden Monat'
toText({ freq: 'YEARLY' }, { locale: de })
// 'jedes Jahr'
// BYDAY: weekdays on a weekly rule
toText(
{
freq: 'WEEKLY',
byDay: [
{ weekday: 'MO', ordinal: undefined },
{ weekday: 'WE', ordinal: undefined },
{ weekday: 'FR', ordinal: undefined },
],
},
{ locale: de }
)
// 'jede Woche am Montag, Mittwoch und Freitag'
// (German uses 'und' without an Oxford comma)
// Ordinal BYDAY in MONTHLY context
toText({ freq: 'MONTHLY', byDay: [{ weekday: 'TU', ordinal: 2 }] }, { locale: de })
// 'jeden Monat am 2. Dienstag'
toText({ freq: 'MONTHLY', byDay: [{ weekday: 'FR', ordinal: -1 }] }, { locale: de })
// 'jeden Monat am letzten Freitag'
// COUNT terminator
toText({ freq: 'DAILY', count: 5 }, { locale: de })
// 'jeden Tag, 5-mal'
// BYMONTH (single)
toText({ freq: 'YEARLY', byMonth: [3] }, { locale: de })
// 'jedes Jahr im März'
// BYMONTH (multiple)
toText({ freq: 'YEARLY', byMonth: [6, 12] }, { locale: de })
// 'jedes Jahr in Juni und Dezember'
// Complex rule fallback
toText({ freq: 'YEARLY', byWeekNo: [10] }, { locale: de })
// '(komplexe Wiederholungsregel)'
import type { LocalePack, LocaleTokens } from 'rrule-ts/locales/en'

A LocalePack is a plain object with static data tables and formatter functions. The interface:

interface LocalePack {
/** Locale identifier, e.g. 'en' or 'de'. */
id: string
/**
* Weekday display names in RFC 5545 order.
* Index 0 = Monday (MO) through index 6 = Sunday (SU).
*/
weekdays: [string, string, string, string, string, string, string]
/**
* Month display names.
* Index 0 = January through index 11 = December.
*/
months: [
string,
string,
string,
string,
string,
string,
string,
string,
string,
string,
string,
string,
]
/** Fixed-string connective tokens. */
tokens: LocaleTokens
/** Render an interval phrase. n=1 -> singular; n>1 -> plural with count. */
formatInterval(n: number, freq: Frequency): string
/** Render an ordinal for BYDAY or BYSETPOS. */
formatOrdinal(n: number): string
/** Render a COUNT value. */
formatCount(n: number): string
/** Join a list with locale-appropriate conjunction. */
formatList(items: string[]): string
/** Render an UNTIL date as a human-readable string. */
formatDate(date: RRuleUntil): string
/**
* Render a "Nth-to-last day" phrase for negative BYMONTHDAY below -1.
* n is the absolute value (n >= 2).
*/
formatNthToLastDay(n: number): string
/**
* Render the adjectival "nth-to-last" ordinal for negative BYDAY ordinals below -1.
* n is the absolute value (n >= 2). Combined with a weekday name by toText.
*/
formatNthToLastOrdinal(n: number): string
/**
* Returned when a rule cannot be expressed naturally.
* EN: '(complex recurrence rule)'. DE: '(komplexe Wiederholungsregel)'.
*/
complexRuleFallback: string
}
interface LocaleTokens {
/** Precedes BYDAY or BYMONTHDAY clause. EN: 'on', DE: 'am' */
on: string
/** Precedes BYHOUR time clause. EN: 'at', DE: 'um' */
at: string
/** Precedes a multi-month BYMONTH clause. EN: 'in', DE: 'in' */
in: string
/** Precedes a single-month BYMONTH clause. EN: 'in', DE: 'im' */
inSingular: string
/** Precedes the UNTIL date. EN: 'until', DE: 'bis' */
until: string
/** Between 'on' and an ordinal. EN: 'the', DE: '' (empty) */
the: string
/** The -1 ordinal in adjectival BYDAY position. EN: 'last', DE: 'letzten' */
last: string
/** Rendered for BYMONTHDAY=-1. EN: 'last day', DE: 'letzten Tag' */
lastDay: string
/** Used for BYSETPOS=-1 + Mon-Fri pattern. EN: 'weekday', DE: 'Werktag' */
weekday: string
}

Locale packs are imported per-locale. Each subpath export (rrule-ts/locales/en, rrule-ts/locales/de) is a standalone module with no re-export of other locales. A bundler (esbuild, Rollup, webpack) that tree-shakes will include only the locales you import.

// Only de is included in the bundle; en is not imported, so it is not included.
import { de } from 'rrule-ts/locales/de'
import { toText } from 'rrule-ts/text'
toText({ freq: 'DAILY' }, { locale: de })

A locale is pure data plus formatter functions. No library internals need to change. To add a French locale, for example:

import type { LocalePack } from 'rrule-ts/locales/en'
import type { Frequency } from 'rrule-ts'
const FR_MONTHS: LocalePack['months'] = [
'janvier',
'février',
'mars',
'avril',
'mai',
'juin',
'juillet',
'août',
'septembre',
'octobre',
'novembre',
'décembre',
]
export const fr: LocalePack = {
id: 'fr',
weekdays: ['lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche'],
months: FR_MONTHS,
tokens: {
on: 'le',
at: 'à',
in: 'en',
inSingular: 'en',
until: "jusqu'au",
the: '',
last: 'dernier',
lastDay: 'dernier jour',
weekday: 'jour ouvrable',
},
formatInterval(n, freq) {
const singular: Record<Frequency, string> = {
SECONDLY: 'chaque seconde',
MINUTELY: 'chaque minute',
HOURLY: 'chaque heure',
DAILY: 'chaque jour',
WEEKLY: 'chaque semaine',
MONTHLY: 'chaque mois',
YEARLY: 'chaque année',
}
const plural: Record<Frequency, string> = {
SECONDLY: 'secondes',
MINUTELY: 'minutes',
HOURLY: 'heures',
DAILY: 'jours',
WEEKLY: 'semaines',
MONTHLY: 'mois',
YEARLY: 'ans',
}
if (n === 1) return singular[freq]
return `tous les ${n} ${plural[freq]}`
},
formatOrdinal(n) {
if (n === 1) return '1er'
return `${Math.abs(n)}e`
},
formatCount(n) {
return `${n} fois`
},
formatList(items) {
if (items.length === 0) return ''
if (items.length === 1) return items[0]
if (items.length === 2) return `${items[0]} et ${items[1]}`
return `${items.slice(0, -1).join(', ')} et ${items[items.length - 1]}`
},
formatDate(date) {
// Minimal implementation; expand as needed
const d = date as Temporal.PlainDate
return `${d.day} ${FR_MONTHS[d.month - 1]} ${d.year}`
},
formatNthToLastDay(n) {
return `${n}e jour avant la fin`
},
formatNthToLastOrdinal(n) {
return `${n}e avant dernier`
},
complexRuleFallback: '(règle de récurrence complexe)',
}

Then pass it to toText():

import { toText } from 'rrule-ts/text'
import { fr } from './locales/fr'
toText({ freq: 'DAILY' }, { locale: fr })
// 'chaque jour'
toText({ freq: 'WEEKLY', interval: 2 }, { locale: fr })
// 'tous les 2 semaines'