Skip to content

dindondanapp/tabulascript

Repository files navigation

@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/tabulascript

No 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. RRULE can 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/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.

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 Massestype_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 mass

Internally, 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/12 or dom — 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 day

Times 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 start

No block comments. // inside [notes] is preserved verbatim.

API reference

parse(source, env)

function parse(source: string, env: Env): TabulaAST

Source → 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[]>,
): TabulaAST

Returns 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:conformance

The 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.ts

Contributing

Contributions are welcome. The package is pure and dependency-light, so the loop is fast:

pnpm install
pnpm test        # unit + conformance
pnpm typecheck
pnpm lint

Guidelines:

  • Behaviour is locked by the conformance corpus. Any change to parsing or evaluation must keep pnpm test:conformance green. New behaviour should come with a synthetic source in test/conformance/synthetic/ and unit coverage — see test/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 in spec/semantics.md before changing one; don't silently "fix" an intentional quirk.
  • The AST is the contract. Edits to src/types.ts must be reflected in spec/ast-schema.json (pnpm spec), and any breaking shape change bumps TabulaAST.version.
  • Keep it isomorphic: no Node-only APIs, no Date, no I/O in src/.

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-tags

License

MIT © DinDonDan

About

TypeScript reference implementation of TabulaScript — the DSL DinDonDan uses to describe church mass timetables.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors