@dindondanapp/tabulascript
v0.1.0
Published
TabulaScript — reference TypeScript implementation of the DSL used by DinDonDan to describe church mass timetables. Pure, isomorphic, zero runtime dependencies.
Maintainers
Readme
@dindondanapp/tabulascript
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", … } ] }Install
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.
Why TabulaScript?
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.
Design principles
- 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.
What it is, what it isn't
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.
Language tour
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.
Info blocks
#phone: 0123 456789
#website: example.org [St. Peter's parish]
#schedule page: https://example.org/scheduleEach #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.
Timetable blocks and cases
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.
Nested case trees (AND chains)
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 massInternally, each leaf carries the FLATTENED AND chain of its ancestors. Branch lines never emit cases; only leaves do.
Conditions: ranges, ORs, holidays, eves
- 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.
The ! suffix — "weekday only"
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.
Time slots
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 dayTimes are normalised to canonical HH:MM at parse time (zero-padded). Notes survive into the AST verbatim.
Operators
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).
Wildcards in dates
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
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.
Comments
// This is a comment — only when '//' is the first non-space prefix.
Masses:
mon: 18:00 // NOT a comment — only at line startNo block comments. // inside [notes] is preserved verbatim.
API reference
parse(source, env)
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.
applyOverrides(ast, overrides)
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.).
buildOverride(...specs)
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()evaluate(ast, env, range)
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.
CivilDate helpers
{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.
Localisation
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.
Custom holiday calendars
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.
Worked example: Ambrosian Lent suspension
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.Spec docs
| 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. |
Conformance
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.
Scripts
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.tsContributing
Contributions 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/.
Versioning and publishing
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-tagsLicense
MIT © DinDonDan
