Reference TypeScript implementation of TabulaScript — the DSL DinDonDan uses to describe church mass timetables. Pure, isomorphic, zero runtime dependencies.
import { parse, evaluate, type Env } from "@dindondanapp/tabulascript";
// The host supplies an Env: block/info name registries plus any
// precomputed liturgical date variables. Here, the bare minimum.
const env: Env = {
timetableBlocks: { 1: { names: ["Masses"], interval: false } },
infoBlocks: {},
variables: {},
};
// Source is line-oriented; nesting uses TAB indentation (none needed here).
const source = [
"Masses:",
"mon-fri: 18:00",
"sat: 17:00",
"sun: 8:00, 10:30, 19:00",
].join("\n");
const ast = parse(source, env);
const days = evaluate(ast, env, {
start: { y: 2026, m: 5, d: 18 }, // Mon
end: { y: 2026, m: 5, d: 24 }, // Sun
});
// days["2026-05-24"] (a Sunday) →
// { 1: [ { start: "08:00", … }, { start: "10:30", … }, { start: "19:00", … } ] }The package is published to the public npm registry. Install it with your package manager of choice:
npm add @dindondanapp/tabulascript
# or: pnpm add @dindondanapp/tabulascript
# or: yarn add @dindondanapp/tabulascriptNo authentication or registry configuration is required. The source is MIT-licensed and public; you can also self-host from a git checkout (pnpm install && pnpm build).
Node ≥ 20. ESM-only. Zero runtime dependencies. No DOM. No JS Date. Safe to import in a React Server Component, a browser bundle, a Cloudflare Worker, or a CLI script.
TabulaScript was built for DinDonDan, an app for finding the times of Masses and other religious services, where it represents each church's schedule in our backend. We wrote a language for the job because church schedules turn out to be unusually hard to pin down: they are rarely identical from one week to the next, and a far-from-negligible share of them follow patterns that ordinary schedule formats were never designed to express.
Software has solid, compact vocabularies for regular timetables — GTFS for public transit, the iCalendar recurrence rule (RRULE, RFC 5545) for calendar events, the OpenStreetMap opening_hours syntax for business hours. They all express "every Tuesday at 18:00" or "Mon–Fri, 09:00–17:00" cleanly. A parish year tends to break them:
- Movable feasts. Easter shifts every year, and a whole web of dates moves with it — Ash Wednesday, Palm Sunday, Pentecost, Corpus Christi.
RRULEcan place "the first Sunday of the month" but cannot compute Easter; GTFS would have you enumerate the exception dates by hand, year after year. - Movable seasons. Lent, Eastertide, Advent, a summer timetable — stretches of the year whose start and end may themselves be movable, and which overlay and override the ordinary schedule for as long as they are in effect.
- Alternation and nesting. "First Sunday of the month," week-on/week-off confession times, a Saturday vigil that only runs in winter — conditions that combine and compound quickly.
You can force some of this into the existing formats — by precomputing every date and enumerating it, or by regenerating the rules each liturgical year — but the result stops resembling the schedule a person would actually write down. So we built a small language whose source is the schedule, paired with a pure evaluator that expands it into a concrete day-by-day calendar for any date range, with the movable dates and holiday calendars supplied by the host.
- Concise. Any church's schedule should fit in as few lines as possible — never by enumerating individual dates.
- Close to how you'd write it by hand. The syntax mirrors how someone would jot a schedule down in plain language; in the simplest cases the TabulaScript source is almost exactly what you would have written on paper anyway.
- Localizable. Keywords resolve per language, so a schedule can be authored in Italian, English, or any supported locale — whatever is most natural for the people maintaining it — instead of forcing everyone through English.
- Adaptable to its environment. Movable dates (Easter and everything anchored to it) and the holiday/eve calendars are injected by the host, so they can be overridden, extended, or swapped per country, per diocese, or per deployment without touching the source.
Is a pure parser + evaluator for the TabulaScript DSL. Given a source string and an Env, it produces a typed AST and (separately) a per-day timetable map for a given date range. Includes locale-aware keyword tables for all 10 DinDonDan languages, a builder for host-injected overrides (Ambrosian Lent and friends), and TZ-free civil-date arithmetic.
Isn't a database adapter, an HTTP server, or a UI component. There is no I/O of any kind — no network, no filesystem, no JS Date. The host application owns DB access, request handling, liturgical-date computation, and rendering; this package only turns a source string plus an Env into a typed AST and a per-day timetable map.
Source files are line-oriented and tab-indented. Lines starting with # are info blocks; lines ending with : introduce timetable blocks; lines inside a block describe cases that map conditions to time slots.
#phone: 0123 456789
#website: example.org [St. Peter's parish]
#schedule page: https://example.org/schedule
Each #name: value [detail] line binds a value (and optional bracketed detail) to a host-defined info type. The label is matched case-insensitively against Env.infoBlocks[id].names — so #phone and #telefono can both resolve to the same info_id.
Masses:
mon-fri: 18:00
sat: 17:00*
sun: 8:00, 10:30, 19:00 [with the sung Te Deum]
A label ending in : opens a block (here Masses → type_id = 1, resolved via Env.timetableBlocks). Each subsequent line is a condition: times pair. Conditions on the LEFT of :, times on the RIGHT. Block labels also localise: Masses: and Misas: resolve to the same id if their names are in the env.
Conditions stack with tab indentation. A line ending in : (no times) is a branch; children inherit its condition.
Masses:
mon-fri:
1/12-31/12: 19:00 # weekday range AND a date range
easter-42-easter-2: # Lent: the range from Easter-42 to Easter-2
hol: 8:00, 10:30 # Sundays during Lent get an extra mass
Internally, each leaf carries the FLATTENED AND chain of its ancestors. Branch lines never emit cases; only leaves do.
- Weekday range:
mon-fri,lun-ven,mo-fr— works across all 10 default locales. - Date range:
25/12-6/1— inclusive, wraps around year boundary. - Single date / weekday:
25/12ordom— point interval. - Comma = OR:
mon, wed, fri: 18:00— Mon, Wed, Fri. - Holiday wildcard:
hol,dom,sun,dim,fes,feiertag, … (any locale's "Sunday or civic holiday") → matches Sundays + the holiday calendar. - Eve wildcard:
eve,sab,sat,vig,vigilia, … → matches Saturdays + the eve calendar.
dom!, sun!, hol!, fes! resolve to the Sunday weekday, NOT to the holiday wildcard. So dom: 8:00 matches every Sunday AND every civic holiday; dom!: 8:00 matches only calendar Sundays. There is no equivalent !-suffix on the eve side.
sat: 17:00 # point time
tue: 18:00-19:30 # interval (only on blocks with allow_interval=true)
thu: 18:00 [with the rosary] # bracketed note
fri: 18:00* # trailing flag
wed: - # empty leaf — no mass that day
Times are normalised to canonical HH:MM at parse time (zero-padded). Notes survive into the AST verbatim.
Four binary operators, all at one precedence level, left-associative:
| Op | Meaning |
|---|---|
+ |
add N days to a date |
- |
subtract N days from a date |
> |
first occurrence of a weekday on or after a date |
< |
last occurrence of a weekday on or before a date |
Masses:
easter + 49: 11:00 # Pentecost
hol > 1/*: 10:00 # first Sunday of each month
hol < easter - 7: 10:30 # Palm Sunday (Sunday before Easter)
> and < REQUIRE a weekday on the LHS; a non-weekday LHS is a parse-time error. + and - require an integer day count on the RHS.
- does double duty as the range separator (mon-fri, 25/12-6/1) and the subtract-days operator. They are told apart by what follows the -: a - before a bare integer subtracts days (easter-7), while any other - separates the two ends of a range. So easter-42-easter-2 is the range from Easter−42 to Easter−2, and easter-easter+6 is the range from Easter to Easter+6 (the Easter octave).
d/m/y, where a * in any position is a wildcard that matches any value there:
*/6/*: 10:00 # every day in June (day = any, month = June)
1/6/*: 10:00 # June 1st only (a single day each year)
1/*/*: 10:00 # the 1st of every month
31/*/*: 19:00 # the last day of every month (d==31 → last-of-month)
Any out-of-range day is clamped to the actual last day of its month, rather than spilling into the next one. So 31/2 and 30/2 both become Feb 28 (or 29 in a leap year), and 31/4 becomes Apr 30. The d == 31 "last day of the month" idiom is just the common case of this rule.
Variables come from the env (Env.variables[name][year]) — typically liturgical dates the host pre-computes (Easter, Ascension, Pentecost, Corpus Christi, etc.):
Masses:
easter: 11:00 [Easter Sunday]
easter + 49: 11:00 [Pentecost]
hol > easter + 60: 10:00 [Corpus Christi]
A variable name that collides with any keyword alias is rejected at parse time, rather than silently shadowing the keyword.
// This is a comment — only when '//' is the first non-space prefix.
Masses:
mon: 18:00 // NOT a comment — only at line start
No block comments. // inside [notes] is preserved verbatim.
function parse(source: string, env: Env): TabulaASTSource → AST. Validates env.variables names against the keyword index up front (no silent shadowing). Throws TabulaError { message, line? } on any syntax error, with 1-indexed line numbers.
function applyOverrides(
ast: TabulaAST,
overrides: Record<TypeId, Case[]>,
): TabulaASTReturns a NEW AST with override cases appended AFTER user cases per type_id. Combined with the last-matching-case-wins evaluation rule, overrides take precedence. Used by hosts to inject liturgical-calendar logic (Ambrosian Lent suspends Friday masses, etc.).
function buildOverride(...specs: OverrideSpec[]): Record<TypeId, Case[]>
interface OverrideSpec {
typeId: TypeId;
conditions: Condition[]; // AND chain; use the helpers below
times: TimeSlot[]; // [] to suspend the block
}A stable wrapper around Case construction. Prefer this over hand-rolling Case nodes — the AST shape may evolve; this surface stays.
Condition helpers (all return Condition):
dateRangeCondition(from: CivilDate, to: CivilDate)
dateCondition(date: CivilDate)
weekdayCondition(day: CanonicalWeekday)
weekdayRangeCondition(from: CanonicalWeekday, to: CanonicalWeekday)
holidayCondition()
eveCondition()function evaluate(
ast: TabulaAST,
env: Env,
range: { start: CivilDate; end: CivilDate },
): DayMap
type DayMap = Record<string /* "YYYY-MM-DD" */, Record<TypeId, TimeSlot[]>>Iterates [start, end] inclusively, producing one entry per day (possibly empty if no block matches). Last-matching-case-wins per block (the seam that makes overrides work). Pure — no I/O, no Date.
{y, m, d} records and the small set of helpers that operate on them — civilDate(y,m,d), addDays, compare, equals, weekday, daysInMonth, isLeapYear, toEpochDay, fromEpochDay, formatISO. All deterministic, no TZ. Used internally by the evaluator; exported for host convenience.
Block labels (Env.timetableBlocks and Env.infoBlocks) are multi-name registries: the same type_id can carry localised display names, and the parser matches case-insensitively. Weekday/holiday/eve keyword recognition works the same way via Env.keywords (defaults to defaultKeywords, which ships aliases for all 10 supported locales — it, en, es, fr, de, pt, pl, ar, zh, ja).
// All four parse identically when default keywords are active:
Messe: Masses: Misas: Messen:
lun-ven: … mon-fri: … lun-vie: … mo-fr: …
Env.locale is an optional ISO 639-1 hint that affects future locale-aware error messages but does NOT restrict what the parser recognises.
To restrict to one locale: pass a narrower Env.keywords with only that language's aliases.
Env.holidays / Env.eves accept any HolidayCalendar:
import type { HolidayCalendar } from "@dindondanapp/tabulascript";
const polishHolidays: HolidayCalendar = {
fixedDates: [
{ m: 1, d: 1 }, // Nowy Rok
{ m: 1, d: 6 }, // Trzech Króli
{ m: 5, d: 1 }, // Święto Pracy
{ m: 5, d: 3 }, // Święto Konstytucji
{ m: 8, d: 15 }, // Wniebowzięcie
{ m: 11, d: 1 }, // Wszystkich Świętych
{ m: 11, d: 11 }, // Święto Niepodległości
{ m: 12, d: 25 }, // Boże Narodzenie
{ m: 12, d: 26 }, // Drugi dzień Świąt
],
weekdays: new Set([6]), // Sundays
};Default is the Italian civic set (Sundays + 1/1, 6/1, 15/8, 1/11, 8/12, 25/12 for holidays; Saturdays + 31/12, 5/1, 14/8, 31/10, 7/12, 24/12 for eves). Pass nothing to inherit the default.
import { parse, evaluate, applyOverrides, buildOverride,
dateRangeCondition, weekdayCondition } from "@dindondanapp/tabulascript";
const easter2026 = { y: 2026, m: 4, d: 5 };
const lentStart = { y: 2026, m: 2, d: 22 }; // -42 days from Easter
const goodFriday = { y: 2026, m: 4, d: 3 }; // -2 days from Easter
const overrides = buildOverride({
typeId: 1, // Masses
conditions: [
dateRangeCondition(lentStart, goodFriday),
weekdayCondition("fri"),
],
times: [], // suspend
});
const ast = parse(churchSource, env);
const merged = applyOverrides(ast, overrides);
const days = evaluate(merged, env, { start: lentStart, end: goodFriday });
// Every Friday in [lentStart, goodFriday] now has an empty Masses slot.| File | What it says |
|---|---|
spec/grammar.ebnf |
Syntactic structure — every production in EBNF. |
spec/ast-schema.json |
JSON Schema for the AST (generated from src/types.ts). |
spec/semantics.md |
What each construct means at evaluation time. |
Behaviour is validated against a synthetic golden corpus. test/conformance/synthetic/ contains hand-curated .tabula sources covering every parser path and every documented edge case, plus a manifest that maps each source to a range and optional override set. The committed test/conformance/fixtures/*.json are the expected ASTs + day-maps for those sources; both inputs and outputs are checked in and pnpm test:conformance runs the parser/evaluator against them.
pnpm test:conformanceThe current commit ships 28 synthetic fixtures; 56/56 conformance tests pass (28 AST + 28 day-map). The corpus is small by design — each fixture targets a specific surface (operator, edge case, indentation pattern, multilingual aliasing, etc.), so coverage is high per fixture. The committed fixtures are the source of truth; see test/conformance/fixtures/README.md for how to add a case.
pnpm install
pnpm typecheck # tsc --noEmit (strict)
pnpm test # vitest run (unit + conformance)
pnpm test:unit # unit tests only
pnpm test:conformance # corpus tests only
pnpm lint # biome check .
pnpm format # biome format --write .
pnpm build # tsc -p tsconfig.build.json → dist/
pnpm spec # regenerate spec/ast-schema.json from src/types.tsContributions are welcome. The package is pure and dependency-light, so the loop is fast:
pnpm install
pnpm test # unit + conformance
pnpm typecheck
pnpm lintGuidelines:
- Behaviour is locked by the conformance corpus. Any change to parsing or evaluation must keep
pnpm test:conformancegreen. New behaviour should come with a synthetic source intest/conformance/synthetic/and unit coverage — seetest/conformance/fixtures/README.md. - Some edge cases are intentional. Several behaviours (e.g.
d == 31→ last day of month, last-matching-case-wins) are deliberate and locked by tests. Document the rationale inspec/semantics.mdbefore changing one; don't silently "fix" an intentional quirk. - The AST is the contract. Edits to
src/types.tsmust be reflected inspec/ast-schema.json(pnpm spec), and any breaking shape change bumpsTabulaAST.version. - Keep it isomorphic: no Node-only APIs, no
Date, no I/O insrc/.
Semantic versioning. The AST version field gates host compatibility — bump the AST version on breaking shape changes. While the package is pre-1.0:
- Minor for feature additions (new functions, new AST node kinds, new env fields).
- Patch for bug fixes and doc-only changes.
- Major bump to 1.0 signals the AST schema is locked.
CHANGELOG.md records every release.
Publish flow (members of dindondanapp with the right token):
pnpm version <patch|minor|major>
pnpm publish # prepublishOnly hook runs build + test + lint
git push --follow-tagsMIT © DinDonDan