aboutsummaryrefslogtreecommitdiff
Commit message (Collapse)AuthorAgeFilesLines
* docs(naming): the config man page, and the field referenceLukasz Kasprzak2026-08-204-203/+698
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | colitur-config.5 documents the file's location, the flag > config > default precedence, every setting, and that an unknown key is warned about rather than fatal. Its worked example is a real run against the shipped example overlay, pasted rather than hand-typed -- including one genuine gotcha found while verifying it: overlay/template values are not shell-expanded, so a bare "~/..." written into the config file fails to load; the full path must be written out instead. colitur-templates.5's field reference is updated for the view change -- every field checked against lib/render/view.ml and schema/day-v1.json, not transcribed from memory or an earlier version of this page -- and its worked example is pasted from a real `colitur table` run. Its scope-shadowing warning about {{name.la}} is REMOVED and replaced with a short note that the hazard is now unrepresentable, because name is a plain resolved string with no dotted path left to fall back through. The num collision between a month and a week (both carry num, and a bare {{num}} read from inside a day silently climbs to the week's own value, not the month's) still exists and is demonstrated with a real render, not merely asserted. A man page describing a hazard that was fixed is as wrong as one that omits a real hazard. Two further stale mentions of the removed {{name.la}} hazard, and two places describing the schema as "Latin and English names" rather than one resolved display name, were found in colitur.1 while cross-checking and fixed alongside the primary rewrite; its own CSV example in EMIT was also still the pre-Task-5 column list (name_la/name_en, no *_name columns) and is now the real, verified header. lang/ already installs beside templates/ and schema/ (a prior hotfix); colitur-config.5 is added to install/uninstall/man/doc in the Makefile so `make install`/`make doc` cover it too.
* feat(cli): --lang and --raw, colitur lang, colitur configLukasz Kasprzak2026-08-204-124/+1091
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Names are Latin by default now; --raw restores the old byte-exact output, verified against a pre-branch binary built from a clean HEAD checkout, not merely asserted. day and readings gain a trailing display-name field (day's own name suffix, readings' own " | name" suffix) rather than a substitution in the slug's existing position: a name contains spaces and would otherwise break the space-separated columns, the same reasoning that already keeps readings a separate command. The field is present only when it differs from the slug already shown, which is a direct consequence of Lang.raw being the identity table (no lookup ever misses), not a special case threaded through day_line/readings_line -- under --raw, or any language with no entry for a given day, the trailing field is simply absent, byte for byte what this project printed before naming existed. An unknown language is an error naming what is available, never a silent fallback to Latin: a booklet quietly printed in the wrong language is worse than one that refuses to print. A declared fallback still chains, so a partial translation shows its fallback language rather than slugs. lang_dir mirrors data_dir's probe order exactly -- installed prefix, then build tree -- because an installed binary that finds calendar data but no language files is the failure this would otherwise ship. Config (lang/overlay/template/format) is loaded once, unconditionally, before dispatch: a config file that exists but fails to parse is fatal on every command, not only the ones that consult it, because a config the user wrote and colitur cannot honour is not something to silently carry on past. An unknown key or unknown section is reported and ignored, worded differently so a misspelled section reads differently from a misspelled key inside a recognised one. overlay's config default applies only when zero --overlay flags were given, never merged with a partial CLI list, so precedence stays flag > config > default in one direction throughout. colitur lang --list/--dump/--check and colitur config --show are what make "anyone can write a language file" true rather than merely permitted, the same pattern the overlay system already established with new-overlay and check. --check reports what is missing AND rejects an unknown slug (exit 1), because a key matching nothing is silently dead and its author would otherwise never learn why their own name does not appear. --dump emits sections in a fixed order (Lang.keys' own global sort keeps every section's keys contiguous), so two dumps of the same table are byte-identical. config --show resolves through the same Config.resolve every other command uses -- deliberately no separate provenance function, so the two cannot disagree -- and previews exactly what flags given on the same command line would resolve to anywhere else. One authorized change outside this task's own file list: emit_ics.ml's SUMMARY line was reading the unlocalised rank/colour pair ("class-1, white") beside an already-localised name, where the JSON schema right next to it correctly carries rank_name/colour_name ("II classis"/"albus"). Found while reviewing the published site; fixed at the source.
* fix(templates): clear the wall calendar's class label of the border, add siglaLukasz Kasprzak2026-08-206-133/+997
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Problem 1: grid.tex's \daycell pushed the rank/class line to the exact bottom edge of its fixed-height parbox with \vfill, the same edge \hline draws the cell's bottom rule along, so 'I classis'/'IV classis'/'II classis' printed as if sliced by the rule. Fixed by nesting a second, shorter parbox (\cellh minus a new \cellpad, 1.5mm) inside the unchanged \cellh-tall outer one, so the \vfill only ever reaches to \cellpad above the rule, never touching it. \strut on both trailing lines belt-and-suspenders the fix for lines with no descenders. Problem 2: each cell now also carries the day's Epistle and Gospel references (view fields first/gospel), joined by a centred dot/bullet rather than repeated Ep./Ev. labels -- the same compact convention ordo.tex already uses between rank_name and colour_name. A grid cell is only ~3.5cm wide and Holy Week's own citations run 20-30 characters each, so class label and sigla get their own \tiny line apiece rather than being crammed onto one line and shrunk past readability; \cellh leaves about eleven \tiny lines of room, so two short lines cost little. Both first and gospel are wrapped in their own conditional section so a day missing either prints no stray separator (defensive: every day in the shipped EF lectionary has both, but nothing guarantees that of a future rite or overlay). grid.ms gains the same sigla, on their own .br line, using tbl's existing w() wrap rather than any manual line-splitting -- it was never subject to problem 1 (tbl auto-sizes row height, no fixed-height clipping risk) and never carried a class label to begin with, so none was added here. grid.html gains the sigla as a small, muted block-level span; unlike the two paper flavours it isn't boxed onto a fixed physical page, so the browser just grows the cell rather than needing any line-budget compromise. Verified with two pdflatex passes and groff -ms -t -Tpdf against all twelve 2027 months: zero Overfull warnings, 7 columns every row, class label and sigla both fully clear of every rule. test/golden/grid-2027.* regenerated through test_render_golden.ml's own render path (View.of_days + Template.render_string, the EN-with-LA-fallback lang table), not the CLI, per this project's own standing trap about the two tables differing.
* fix(install): ship lang/ so installed colitur resolves namesLukasz Kasprzak2026-08-192-2/+19
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | la.ini and en.ini (725 Latin/English liturgical names -- the entire point of the naming-and-config work just merged) had no dune install rule, so make install / dune install never copied them to <prefix>/share/colitur/lang/. bin/main.ml's lang_dir() probes exactly that installed path first, falling back to a build-tree layout that also does not exist for an installed binary -- so every installed user silently got raw slugs (ef-circumcision) and untranslated terms (epistle/gospel) instead of resolved names, with no error at all. The defect existed because nobody had run the installed binary from a directory with no colitur source tree above it: dune exec and dune test always resolve lang/ via the build tree, materialized as a side effect of test/dune's cram deps, so every in-tree check stayed green while the installed artifact was broken. Add lang/dune, following schema/dune's exact pattern: an install stanza placing la.ini and en.ini at <prefix>/share/colitur/lang/, matching where bin/main.ml's lang_dir() already looked. No code change was needed there -- only the missing install rule. Update Makefile's install target help text and summary echo to mention lang alongside ef/templates/schema. Verified end to end: uninstalled, removed lang/dune, rebuilt and reinstalled to reproduce the bug from an outside directory (~/tmp, colitur table showed ef-circumcision/epistle); restored lang/dune, reinstalled, and confirmed the same command now shows the resolved Latin name and rubric terms, that ~/.local/share/colitur/lang/ contains both files, and that colitur emit --format csv shows a real name in the name column. lang/la.ini and lang/en.ini are untouched. Full suite: dune test --force, 495 tests, 0 failures.
* feat: naming, localisation and the rebuilt printed outputLukasz Kasprzak2026-08-1950-7492/+14056
|\ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | colitur computed the calendar correctly and could not say what it had computed. A printed ordo read ef-septuagesima-sunday-2 where a reader expects Dominica in Sexagesima, and the wall calendar showed slugs in every cell. lib/naming a language table and a config file, both pure and total, parsing the INI reader Overlay_ini already had lang/ la.ini and en.ini -- 725 names, every one transcribed from the 1962 Missal and citing the line it came from templates the ordo rebuilt as an A5 booklet: one week per page, a table of contents, framed days, a colour swatch; the wall calendar now fills its sheet instead of a quarter of it tools check_citations.py verifies all 400 citations resolve, with 33 self-tests of its own Names resolve through lang -> declared fallback -> the slug, so a partial translation is usable from its first line and the fully degraded case is the old output rather than a blank page. colitur day and colitur readings are BYTE-IDENTICAL to before, verified against main rather than asserted; --lang, --raw and the lang/config subcommands are still to come, and man/colitur-templates.5 still documents the pre-naming view, so writing a custom template needs the source until that lands.
| * test(golden): refresh ordo-2027 goldens for the newly named commemorationsLukasz Kasprzak2026-08-196-570/+570
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The six ordo booklet goldens (tex, ms, html, adoc, md, txt) were stale, not broken: the commemoration-naming task landed 120 Latin names for sanctoral slugs, so the render path test_render_golden.ml exercises now prints real names (e.g. 'St. Telesphorus Pope and Martyr', or 'S. Felicis' via en.ini's own la.ini fallback where English has no entry) where the committed goldens still pinned the raw slug ('telesphorus-pope-and-martyr', 'felicis'). Regenerated through the same path test_render_golden.ml uses -- en.ini chained to la.ini via Test_view.view_of, never the CLI's Latin-only default -- with temporary env-gated scaffolding in the test file, used once and reverted before this commit. Every changed line is a 1:1 slug-to-name substitution; file structure, line counts, and everything else are untouched (570 insertions / 570 deletions across the six files). Verified before committing: no unrendered {{ }} tags, none of the 332 known sanctoral/adjustment slugs leak into any of the six files, all twelve month headings and all 365 day entries are present in each. Full suite (495 tests), check-templates, and check-citations all still pass.
| * feat(lang): walk commemorations and transfers, name every slug they surfaceLukasz Kasprzak2026-08-193-28/+601
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | test_lang_coverage.ml's own coverage test used to walk only the OBSERVED day of each date (one Celebration.t per Liturgical_day.t). A liturgical day also carries a second stream of slugs -- commemorations (kept when the observed day does not fully displace a losing candidate, RG 108-111) and transfers (an impeded I/II-class feast moved to a later date, RG 96-98) -- and nothing here ever looked at them. The test asserted coverage of what it happened to WALK, not of what the engine can EMIT, so it passed green while the ordo booklet printed raw slugs ("Commemoratio canute-martyr", "Commemoratio maur-abbot", "Commemoratio peter"). test_lang_coverage.ml now walks observed, every entry in commemorations, transferred_in, and every entry in transferred_out. That extension turned up 120 slugs with no Latin name across 2020-2045, now added to lang/la.ini: 112 are data/ef/sanctoral.sexp companions the temporal-only walk never reached; 5 (barbara, commemoration-of-st-peter, commemoration-of-the-seven-sorrows, major-litanies, rogation-wednesday) are data/ef/adjustments.sexp's own hand-authored Add directives; 3 (ef-nativity-octave-day-2/3/4) are temporal days with no calendarium heading to transcribe, built by the same pattern days 5-7 already use. Seven of the sanctoral names are cited to docs/research/scan1.txt rather than LT.txt: the 2006 web-capture transcription silently drops several real commemorations that both photographic scans carry (donatus, romanus, eusebius-confessor, naboris-et-felicis, sts-gervasius-and-protasius, sts-felix-and-adauctus, and praxedis-virginis -- the last for a spurious ligature LT.txt introduces, "Praxedis" confirmed correct against both scans). The same 120 names, extracted verbatim from sanctoral.sexp's and adjustments.sexp's own English fields rather than retyped, are added to lang/en.ini. make check-citations: 400 LT.txt citations checked, 0 wrong, 0 malformed, 0 cannot verify. Teeth proved by deleting canute-martyr's own name (a Commemoration_only slug that can never be the observed day, only ever a commemoration) and confirming the coverage test fails naming exactly that slug, then restoring it.
| * feat(templates): the wall calendar fills its pageLukasz Kasprzak2026-08-196-648/+733
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The previous version occupied about the top quarter of an A4 page and left the rest blank, and printed slugs. Cell height is now computed from the text height (\textheight-18mm over 6 rows -- every month has 5 or 6 Sunday-started weeks) rather than left to the table's natural size, which is what made it float at the top. Measured: January 2027's last row of content now reaches y=566pt of a 595pt-tall page, not a quarter of the way down. Real names replace the raw slug in every cell (the observed day's own resolved display name, a plain string per View.of_days), and each day's rank name is shown too, so a printed page reads as a calendar rather than machine keys. Weekday headings come from the view as a localised list rather than a hard-coded Dom/Lun/Mar row, so a translated calendar needs no template edit -- and because the engine rejects an empty tag path, the list carries named {name; last} fields rather than bare values. grid.ms and grid.html carry the same two changes (real names, localised weekday headings) for their own flavours. Verified with pdflatex/groff: 12 pages, A4 landscape, every row exactly 7 cells (6 ampersands), zero warnings. Goldens regenerated through the test harness's own render path, not the CLI, whose default language table differs from the test's.
| * docs(templates): document the ordo booklet's required two-pass buildLukasz Kasprzak2026-08-192-3/+13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A LaTeX table of contents needs two pdflatex passes -- the first pass leaves every entry showing '??', the second resolves the \pageref values. A user hit exactly this and reported it as a bug; the template was fine, the instructions were not. README's rendering example now runs pdflatex twice for ordo.tex (or names latexmk -pdf as the one-shot alternative) and notes the wall calendar needs only one pass, having no cross-references of its own. make check-templates now runs pdflatex twice per LaTeX template too, so the target exercises what a user actually has to do rather than silently passing on a single, incomplete pass.
| * fix(templates): reserve room for the ordo booklet's running headerLukasz Kasprzak2026-08-192-2/+28
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | geometry's top=11mm/bottom=12mm seated the text block without accounting for fancyhdr's own header/footer, which live OUTSIDE that block by default -- pushing the running header ('Ordo 2027 . ef') partly above the physical page edge (measured: yMin -5.5pt on a 0..595pt page) and the footer to within 1pt of the bottom edge. The user hit this and reported the header as clipped. Adds includehead/includefoot to the geometry options (a5paper, top=10mm, bottom=10mm) so the header/footer live inside the margins instead, with headsep/footskip set explicitly (3mm/7mm) rather than left at article's defaults -- those defaults alone would still overflow a 10mm margin and silently added 20 extra pages to the whole booklet by shrinking every day box. Verified: two pdflatex passes produce a 65-page A5 PDF with the full 'Ordo 2027 . ef' header intact on every page and zero pdflatex warnings. Golden regenerated through the test harness's own render path (English-with-Latin-fallback), not the CLI, which defaults to Latin only and would otherwise pin output the tests never produce.
| * feat(templates): the ordo booklet, rebuiltLukasz Kasprzak2026-08-1912-6732/+8385
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A5, one week per page, with a table of contents, each day in a framed box and a colour swatch so the page is scannable without reading. Real names throughout, and every fixed string routed through the term vocabulary so a translated booklet needs no template edit. The previous version was A4, printed slugs, ran days together with no visual separation, had no contents, and gave the liturgical colour only as the word white. It was not usable as a printed booklet. lib/render/view.ml already carried month_num/month_name on each week object from Task 5, so no kernel/view change was needed here -- the template uses those instead of a parent path the engine cannot express. Box padding, margins and secondary-line font size were tightened past the brief's own starting values: at the brief's sizes a week with several long commemoration names (e.g. Feb 21-27, three of seven days carrying one) overflowed onto a second physical page, breaking one-week-per-page. Verified by measuring page count against the known week count (63 weeks in 2027) until every content page carried exactly one week, not by eye. The header comments of all six flavours were themselves a trap the brief warns about: writing double braces to NAME a template field inside a LaTeX %, groff .\", HTML <!-- -->, or AsciiDoc // comment gets parsed as a real tag by this brace-only engine, not treated as inert prose. An early draft's own comments did this and produced 'empty tag path' parse errors and a silently-unclosed section; every comment in all six templates is now written without ever typing two braces in a row. The other five flavours keep their existing structure; only the dead {{#name}}{{la}}{{^la}}{{slug}}{{/la}}{{/name}} idiom is replaced with a plain {{name}} (extended to comms entries too, which carry their own resolved name), and the fixed labels (Ordo, Epistle, Gospel, Commemoration) now come from {{term.*}}. Golden regeneration could not follow the brief's own `colitur table` shell-out literally: the CLI's current default language is Latin-only (bin/main.ml's Task-5 bridge), while test_render_golden.ml renders through Test_view's English-primary/Latin-fallback table, so the two produce different text for the same slug. The goldens were regenerated through the test's own render path instead (a temporary env-gated block in test_render_golden.ml, reverted before this commit), so they agree with what the suite actually computes. All six ordo golden tests pass; the three grid ones are Task 9's scope. make check-templates passes with zero warnings. The CLI-rendered PDF is A5 (148x210mm), 65 pages -- 2 pages of title/contents plus exactly one page per week (63), verified with no overflow.
| * docs(lang): clean lang/la.ini against the hardened citation checkerLukasz Kasprzak2026-08-191-51/+93
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Ran the round-2 hardened check_citations.py against lang/la.ini and fixed every WRONG and MALFORMED finding. Baseline before this task: 12 WRONG, 42 CANNOT VERIFY (round-1 tool). After the round-2 tool alone (data unchanged): 9 WRONG, 10 MALFORMED, 1 CANNOT VERIFY -- the rarity rule cleared 41 of the 42 CANNOT VERIFY entries by itself, as expected. After this cleanup: 0 WRONG, 0 MALFORMED, 0 CANNOT VERIFY, 292 citations checked (up from 272 -- several wide ranges became more granular, individually verified citations). What changed, all in comments, no `key = value` entry touched: - Three genuine wrong citations found and corrected: advent's own citation was pointing at christmastide's real heading (LT.txt:8631, "Tempus Nativitatis") 22 lines off from its own (LT.txt:8609) -- christmastide had never had a working citation of its own at all, the right line sitting unused inside advent's corrective note. Restructured so each entry cites its own real line, including giving time-after-epiphany its own citation (LT.txt:8668) for the first time. - Ten MALFORMED wide ranges (introduced by round 1's own tolerance removal, never capped until round 2) replaced with precise per-entry citations -- mostly comma lists of exact bare line numbers, since each TOC section lists one heading per line; two are legitimate multi-line wraps kept as capped explicit ranges (a 2-line Nativity-octave heading split by an unrelated saint's day; Christ the King's own in-body heading split across a page-number line). - Six citation-shaped substrings that were never genuine provenance claims lost their "LT.txt:" prefix (now read "line NNNN"), each with an inline note explaining why: two were corrective prose quoting a historical WRONG value ("previously cited LT.txt:8631/12459, which is..."), three were contextual pointers to a NEARBY but different heading used for explanation, and one discloses an unmodelled alternate wording. None of these ever claimed to be this entry's own heading; writing them as "LT.txt:N" only let the checker mistake documentation for a claim. - Two genuinely correct citations left permanently unable to pass an automated word-overlap check, for reasons orthogonal to correctness (disclosed in check_citations.py's own docstring as an accepted trade-off) also lost their "LT.txt:" prefix, each verified by hand and noted as corroborating rather than primary: class-1..4's "classis" (the only distinctive word in "I classis" etc., 500+ occurrences across LT.txt -- RG 8 is the primary source); Corpus Christi's and Holy Name's own TOC lines, both abbreviated ("Ss.mi"/"Ss.mae") where every entry below spells the same title out in full, and neither has a spelled-out occurrence anywhere else in this partial 2006 web-capture transcription to cite instead (Holy Name also has a stronger primary source already: temporal_ef.ml's own scan-verified string). No rule was weakened to reach zero: every de-prefixed reference was verified by hand against the transcription first, and none of them was ever wrong -- each was either documentation, context, or evidence the checker's own disclosed word-overlap/rarity design cannot confirm. Claude-Session: https://claude.ai/code/session_017ZBxCCRM2ojnBupp3SBxV9
| * fix(tools): close five more ways to defeat check_citations.pyLukasz Kasprzak2026-08-192-145/+782
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Round 1 hardened check_citations.py against its own self-poisoning bug; a review defeated it again. Five fixes, in the order they were found: 1. PATTERN silenced a whole comment block, not just the entry it was attached to -- a wrong citation on a DIFFERENT, unmarked entry in the same block (e.g. [season]'s own back-to-back trailing-comment style) was never checked at all. Fixed by scoping PATTERN with the identical leading/trailing pooling rule citations already use: an entry is excluded only by its own marker, never a neighbour's. 2. Explicit per-citation ranges (introduced in round 1 to replace a blanket +-2-line tolerance) had no upper bound, reintroducing the same defect at a much larger radius (LT.txt:8600-8650 passed if the text appeared anywhere in fifty lines). Capped at MAX_RANGE_WIDTH (3 lines); anything wider is reported MALFORMED, naming the entry and the width, instead of silently accepted. 3. The "pool too thin to verify" gate counted words, not rarity -- it flagged 11 genuinely correct citations (short Latin hagionyms with only one non-stopword) CANNOT VERIFY, while a match on nothing but "classis" (507 occurrences) passed freely alongside three siblings. Replaced with a frequency table over the whole LT.txt corpus: a token's evidence is 1/(times seen), an item's evidence is its single rarest matched token (not a sum -- summing would let several merely-common words add up to "enough" between them, the same shape as the self-poisoning bug). 4. "LT.txt:12,459" (a comma typo for one number) parsed as two unrelated bare citations, 12 and 459, either of which could coincidentally match while the intended line was never checked. Detected as a thousands-separator-typo shape (a 1-2 digit token immediately followed by an exactly-3-digit one -- the only way a real LT.txt line number, which never exceeds 5 digits, splits under one comma) and rejected as malformed. 5. The self-test suite overstated its own coverage: of round 1's seven fixture cases, only two actually failed against the pre-round-1 script. Every test is now labelled REGRESSION or CHARACTERISATION, each verified by direct replay against the named prior version rather than asserted -- 14 of 33 are genuine regression tests. Both of the review's own defeats (block-wide PATTERN silencing, the 50-line range) are reproduced as dedicated fixtures and confirmed caught; both are also confirmed to slip through the pre-round-2 tool unchanged. Claude-Session: https://claude.ai/code/session_017ZBxCCRM2ojnBupp3SBxV9
| * feat(render): names reach the view and every emitterLukasz Kasprzak2026-08-1913-107/+320
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The view's name is now the RESOLVED display string and slug is untouched, so machine formats carry both -- a script keeps the stable key, a human reads the name. name is a plain string, not a lang-keyed object. That removes the shadowing hazard outright: a dotted {{name.la}} used to fall back WHOLESALE to the enclosing month's name.la and print Ianuarius on every unnamed day, which is how a printed booklet came to show the month where the feast belonged. weekday, season, rank and colour all gain localised companions, because a calendar in a language needs more than feast names, and templates gain a term vocabulary so fixed strings need no template edit to translate. Asserted over a whole year: no day renders its slug as its name. Beyond the brief's own code sample: - comm_value's own name is now resolved through the same lang.celebration table too (not only the observed day's), because Task 8's own ordo template interpolates a plain {{name}} inside {{#comms}} -- an Obj there would render silently blank. A commemoration slug without Latin coverage still degrades to the slug, same as everywhere else in this system; that is a lang/la.ini DATA gap (113 of 327 sanctoral slugs, measured), not a regression this task introduced. - bin/main.ml's emit/table/publish call sites needed ~lang to compile at all, which is collateral from the of_days signature change, not this task's own file list. Rather than pass Lang.raw and ship the very slug-as-name defect this branch exists to fix, they load the shipped Latin table by the same probe order data_dir() already uses -- a deliberate, commented BRIDGE that Task 6 replaces wholesale with real --lang/--raw/config resolution. bin/dune gained colitur_naming accordingly. - test/cli.t needed two related fixes to stay green: the CSV header/row example, and a table/LaTeX escaping demonstration that relied on the kernel's own English name for Sts Peter & Paul -- gone from the view now that name resolves through lang tables only, and the Missal's own Latin spells the feast with et, never an ampersand. Escaping itself is still proved live on 2035 data in test_emit.ml. - Both schemas gained the new day/week/top-level keys (season_name, weekday, rank_name, colour_name, term, weekday_headings, month_num, month_name), not only the name shape change; schema/colitur-v1.xsd verified against real emitted XML via xmllint (make check-schema). Render/golden's 9 cases (the shipped ordo/grid templates, all six flavours) now fail as expected: their old {{name.la}} / {{#name}}... idiom finds nothing on a plain string. That is Tasks 8/9's own scope to rewrite, per the plan's own pre-flight conflict scan -- not fixed here, and not silently pinned by regenerating goldens off broken output. 495 tests run (490 + 5 new), 486 pass; the 9 failures are exactly Render/golden's ordo/grid cases.
| * feat(lang): Latin sanctoral names, and EnglishLukasz Kasprzak2026-08-194-24/+1301
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Sanctoral names are transcribed from the Missal's own calendarium and kept in the GENITIVE, as the Missal prints them -- noted in the file so nobody corrects them to the nominative. English reuses the 327 names already in data/ef/sanctoral.sexp rather than retyping them, and declares fallback = la, so an untranslated day in an English booklet shows Latin rather than a slug. The test asserts the FALLBACK works rather than that en.ini is exhaustive: that is what makes a partial translation shippable from its first line. Coverage now demands a name for every slug the engine can emit across 2020-2045, temporal and sanctoral alike.
| * fix(tools): harden check_citations.py against its own self-poisoning bugLukasz Kasprzak2026-08-195-94/+535
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Reproduced the defect: reintroducing the exact historical citation bug (pointing class-1's citation back at LT.txt:12459, the value a prior fix round corrected away from) made the tool report "147 citations checked, 0 look wrong". The mechanism was that the corrective comment documenting the old bug quotes the wrong historical value, and the checker pooled every quoted phrase from the whole surrounding comment block, so citing the wrong line matched the comment explaining why it was wrong. Four changes: 1. The word pool for a citation is now scoped to the entry(ies) it is attached to only -- never to quoted text elsewhere in the comment. This is the direct fix for the self-poisoning bug. 2. A citation whose pool has fewer than two distinctive words (Latin liturgical headings are short and stopword-heavy) cannot discriminate the right line from a wrong nearby one. Such a citation is now reported CANNOT VERIFY and fails the target, instead of silently passing. 3. The blanket +-2-line tolerance is gone. A bare "LT.txt:N" is checked at line N only; a heading that genuinely wraps must say so explicitly as "LT.txt:N-M". The allowance moves into the data, where it is visible. 4. The tool gets its own test suite, tools/test_check_citations.py, with a synthetic fixture covering: a correct citation, off-by-one and off-by-three mismatches, an explicit wrap range, a degenerate pool, a PATTERN-marked entry with no citation, and a dedicated regression test for the self-poisoning case itself. Wired into `dune test` via a new (rule (alias runtest) ...) in tools/dune (a plain (test ...) stanza cannot run a Python script), so it runs with the rest of the suite, not only as a `make` target. Added a --file/--lt-file override to check_citations.py so the tool (and its own tests) can point at a fixture without touching the real lang/la.ini or docs/research/LT.txt. Confirmed the "SKIPPED, exit 0" behaviour for a missing docs/research/LT.txt is unchanged. tools/__pycache__/ (a stray artefact of this script, previously untracked and ungitignored) is now in .gitignore. Measured against the current lang/la.ini (another task is still landing its sanctoral entries on this branch): 15 of 275 citations now look wrong and 42 more cannot be verified, both far above the 0 the unhardened tool reported. Not fixed here -- the data pass is separate, once the sanctoral entries land.
| * fix(lang): correct two wrong citation lines, add check-citationsLukasz Kasprzak2026-08-193-17/+281
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two of la.ini's LT.txt:<n> citations pointed at the wrong line -- the Latin itself was right, only the pinned line was wrong: - advent cited LT.txt:8631 ("Tempus Nativitatis"); the real "Tempus Adventus" heading is at 8609. - ef-christ-the-king and [rank]'s own citation both pointed near "Dominica ultima Octobris" (12459) when the text they actually quote, "D.NI NOSTRI JESU CHRISTI REGIS" and "I classis", sits two and three lines further down, at 12461 and 12462. ef-christmas-sunday-0 was marked PATTERN but LT.txt:8644 is the identical string verbatim -- relabelled as a direct citation, not constructed. Added tools/check_citations.py and `make check-citations`: for every LT.txt:<n> citation outside a PATTERN block, confirms a +-2-line window around line n actually contains the Latin text the citation claims, rather than trusting each of the 38 citations by hand. Follows check-schema/check-templates' own precedent -- docs/ is gitignored, so the target prints SKIPPED loudly and exits 0 when docs/research/LT.txt is absent, never a silent pass. The checker's own teeth are proven three ways: replayed against the pre-fix file it independently re-derives both corrections above; a fresh mutation (redirecting one citation to an unrelated line) is caught and reverted; the fixed file passes clean, 147 citations checked, 0 wrong.
| * feat(lang): Latin temporal names from the MissalLukasz Kasprzak2026-08-194-0/+792
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Every entry is transcribed from the 1962 Missal's own propers headings in docs/research/LT.txt and cites where it came from; names constructed by following a neighbouring pattern are marked as such, so a reader can tell transcription from inference. The coverage test is the point of this commit. It walks every day of 2020-2045 and fails naming any slug with no Latin name -- the test that would have caught the original defect, where a printed booklet said ef-septuagesima-sunday-2 because nothing asserted that names exist. The three Triduum names reuse the exact strings temporal_ef.ml already carries, so the engine and the language file cannot disagree.
| * fix(naming): config.ml merges every [defaults] block, like lang.mlLukasz Kasprzak2026-08-193-21/+88
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | config.ml and lang.ml both parse the INI format through the same reader, Colitur_kernel.Overlay_ini.parse_sections, but resolved a repeated [section] header oppositely: lang.ml folds over every section sharing a name, while config.ml used List.find_opt and silently discarded every [defaults] block after the first. Two modules parsing one file format must not disagree about what a duplicate section header means. of_string now folds a single accumulator across every section named [defaults], in file order, matching lang.ml's of_string shape. A scalar key (lang/template/format) repeated across two blocks resolves to the later value, consistent with the existing within-section last-wins rule; overlay keeps accumulating across every block, not only the first; and unknown_sections still excludes every [defaults] block, merged or not, since merging it is the point. config.mli's lang doc comment is extended to say the last-wins rule holds across block boundaries too, cross-referencing lang.ml's own duplicate-section policy so the two do not drift again unnoticed.
| * fix(naming): config fix round 1 -- unknown sections, O(n) accumulateLukasz Kasprzak2026-08-193-24/+75
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | F1: test_unknown_key_is_reported_not_fatal never asserted unknown_keys itself, only that parsing survives -- a no-op accumulator passed it. Now asserts the key is actually collected. F2: a misspelled section name, e.g. [deafults], was silently discarded -- Ok empty, lang and everything else gone, nothing reported. That is the highest-value typo this feature exists to catch. Any section other than [defaults] is now collected into a new Config.unknown_sections, kept separate from unknown_keys so the CLI can word the two warnings differently. Still non-fatal: a newer colitur's added section must not break an older binary. F3: overlays and unknown_keys accumulated with '@ [v]' per line, O(n^2) over the field count. Cons during the fold, List.rev once at the end. F4: documented that lang/template/format are last-wins on a repeated key, the opposite direction from Overlay_ini.get's first-wins over the same section type.
| * fix(naming): merge duplicate [section] blocks in the language tableLukasz Kasprzak2026-08-193-5/+66
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | F1 (review round 1): of_string's find took only the FIRST section of a given name (List.find_opt), so a second [celebration] block anywhere in the file was silently dropped in its entirety -- reproduced with two blocks (a in the first, b in the second): b resolved to the slug fallback "b", not its real value. This is a data-loss footgun aimed squarely at what happens next: Tasks 3/4 write a 595-entry, hand-edited la.ini, and appending a second [celebration] block is the natural way to paste in a new batch of names. Worse, the failure surfaces nowhere near its cause -- a coverage check reports the dropped slugs as missing a Latin name, with nothing pointing at the parser. find now folds over every section sharing the name, in file order, so all blocks merge. This also settles which value wins when the same key appears in two different blocks: later in the file wins, consistent with the existing within-one-block behaviour (unchanged, still last SM.add wins) and with what a reader expects when appending to an INI file. lang.mli now documents both duplicate policies explicitly, and notes they run OPPOSITE to Overlay_ini.get's first-match (List.assoc_opt) over the same section.fields shape -- undocumented before, and a latent trap since the two modules read the same section type but resolve a duplicate key in opposite directions. Three tests added: two [celebration] blocks both resolve (the F1 regression), a key repeated across two blocks resolves to the later block, and a key repeated within one block still resolves to the later line (confirms unchanged behaviour). Confirmed the regression test fails against the pre-fix code (b resolves to "b", the slug fallback) and passes after.
| * feat(naming): the config fileLukasz Kasprzak2026-08-194-0/+134
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Owns precedence and provenance and nothing else, and never reads the filesystem, so it is as testable as the language table. resolve returns the value AND its source, because a setting that silently comes from a file the user forgot about is worse than no setting at all -- config --show can then say where each effective value came from. overlay accumulates rather than last-wins: a user has more than one. An unknown key is reported, never fatal. A config written for a newer colitur must still work on an older one, but silently dropping a line the user wrote is how a typo becomes invisible.
| * feat(naming): the language tableLukasz Kasprzak2026-08-197-1/+239
|/ | | | | | | | | | | | | | | | | | | | | | | | | | | Maps strings to strings and nothing else -- no calendars, no dates, no filesystem. That is what lets every command use it without the kernel learning about presentation. Every lookup is total, and a miss returns THE KEY rather than the empty string. A partial translation is therefore usable from its first line, and the fully-degraded case is exactly today's output (bare slugs) rather than a blank page. --raw is a real identity table, not a special case threaded through every call site: one value the whole program passes around. Reuses Overlay_ini's INI reader rather than growing a second one that would drift in its comment, quoting and trimming rules; parse_sections is exposed in the .mli for that, with no behaviour change. Fixes one defect found while running the brief's own tests rather than transcribing them blind: weekday's internal lookup key is an English day-name word (month's is already the numeral string), so on a miss it echoed that word instead of the documented numeral, breaking both the 0=Sunday convention and Lang.raw's own identity contract for weekday. weekday/month now fall back to string_of_int n directly on a miss instead of through get's generic echo-the-search-key path; month is byte-identical since its key already equals string_of_int n.
* feat: output, rendering and publishingLukasz Kasprzak2026-08-1954-43/+21217
|\ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Gives colitur a publishable exit. Until now its only output was terminal rows; it can now print an ordo booklet and a wall calendar, publish an iCalendar feed people subscribe to, and serve a static JSON/XML API. lib/render escaping (six flavours + RFC 5545 folding), a deliberately logic-less template engine, the view model, and five emitters (CSV, JSON, XML, iCalendar, S-expression) CLI emit, table, render, publish -- all accepting --overlay templates ordo booklet in six flavours, wall grid in three schema day-v1.json and colitur-v1.xsd, the published contract man colitur-templates.5, plus colitur.1 updates The view model is why the engine can stay logic-less: a month grid needs leading blank cells, week bucketing and an in-month test, and a logic-less template can compute none of it. Shaping the data in OCaml keeps the engine safe for untrusted templates and makes the grid trivial. Formats split by whether correctness is mechanical. Presentation goes through templates; iCalendar and XML get dedicated emitters, because folding, exclusive DTEND, stable UIDs and schema fidelity are rules a template cannot enforce and each fails silently in a subscriber's client rather than loudly at generation. publish is deterministic and non-destructive: two runs produce a byte-identical tree, and --prune removes only files a previous run created, refusing any manifest entry that escapes the output directory. No new dependencies. The kernel and rite modules are untouched, and colitur day and colitur readings remain byte-identical.
| * docs(templates): the scope-fallback hazard headline was backwardsLukasz Kasprzak2026-08-191-2/+8
| | | | | | | | | | | | | | | | | | | | | | | | It read "an inner key silently loses to an outer key of the SAME NAME" -- false: the document's own num entry, two paragraphs below, shows the opposite (inside {{#weeks}}, a bare {{num}} is the week's own number, correctly, because the inner scope wins). The real hazard, matching the body text and the name example that were already correct: a DOTTED path that resolves only part way inward (the day's own name object exists but lacks the key the path needs) falls back WHOLESALE to an outer scope of the same name, not a bare key losing outright. Corrected the headline only; re-linted clean with groff -man -Tutf8 -ww -z.
| * fix(cli): guard publish's IO, validate --dtstamp, and list all commandsLukasz Kasprzak2026-08-192-5/+98
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | publish's own mkdir_p/write_file (unlike every other IO path on this branch) were unguarded: an unwritable --out parent raised a bare Unix.Unix_error(EACCES,...) and an --out naming an existing file raised ENOTDIR, both as uncaught exceptions with a stack trace rather than the project's one-line "colitur: ..." form. The same defect class commit 6bd741b already fixed once for template reads -- --out is user input too. Fixed by wrapping the whole publish_report call (not each write_file site) in one handler for Unix.Unix_error and Sys_error, mirroring why that earlier fix guarded the whole read and not only the open. Added a cram case using a read-only directory inside the test's own cram sandbox, not /tmp, so a failed cleanup cannot leave an unwritable directory behind in a shared location. --dtstamp was the only user string reaching output unescaped and unvalidated: "--dtstamp hello" silently emitted an invalid "DTSTAMP:hello", and a value carrying its own CRLF injected extra lines into every VEVENT. Fixed by rejecting anything not matching RFC 5545's UTC form (8 digits, 'T', 6 digits, 'Z') before either emit or publish does anything else, one line to stderr, exit 2. usage() was byte-unchanged from before the branch and listed only the six pre-existing commands, omitting all four commands this branch added (emit, table, render, publish). Added them; the three cram pins of the exact usage string are updated to match.
| * fix(templates): grid.ms wall calendar dropped four of seven columnsLukasz Kasprzak2026-08-193-95/+1128
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | pdftotext -layout of the shipped grid.ms showed only 3-4 of 7 columns and roughly a third of each month's day numbers: tbl's plain columns (no w()) size to their widest single entry and never wrap, so a long fallback slug (some run past 40 characters) forced every column that wide, the table ran far past the page, and whatever fell past the physical edge was gone, not merely ugly. groff exited 0 throughout (warnings, not errors), so make check-templates reported OK on a broken artefact. Fixed both halves. (a) The table now fits: true landscape via gropdf's own -P-pa4l (an in-document Xpapersize=a4l escape was tried and rejected -- it does not rotate the page in this groff), ms's own title macro widened back out after narrowing the line length for the title text (a second, independent way the original lost its width, found by reading s.tmac), and every column rewritten as a genuine tbl text-block (T{/T}, not a plain w() cell -- w() alone does not wrap, confirmed against tbl's own generated troff code) so long, hyphenated slugs wrap at their own hyphens instead of forcing the column wider. (b) check-templates now captures groff's stderr per template and fails the target if it is non-empty, rather than trusting groff's exit code. Verified: 0 warnings (was 12), pdftotext -layout shows all 7 columns and every day number for all 12 months (was 3-4 columns, ~12-23 of each month's day numbers). The golden fixture is regenerated: 0 "{{", 12 month headings, exactly one Ianuarius, and each week's block (now spread across several physical lines by the T{/T} wrap) carries exactly 6 tabs joining its 7 cells.
| * test(emit): replace two vacuous CSV/XML assertions with real onesLukasz Kasprzak2026-08-191-11/+103
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The live-data CSV assertion inspected only the first field's length, always the 10-char ISO date, which can never contain a comma -- it could not fail no matter what emit_csv.ml did with the rest of the row. Mutation-proved: replacing emit_csv.ml's escape_field call with identity left every test green while the real output emitted a 14-field row against a 13-column header. Fixed by parsing the row as RFC 4180 actually requires (a small quote-aware splitter) and asserting the field count matches the header, plus asserting the quoted substring appears literally. The XML suite asserted Escape.Xml's correctness in isolation but never that emit_xml.ml actually calls it on every interpolated value. Bypassing one escape call at the name-element site left all tests green while real 2035 output (Sts. Fabian & Sebastian, 20 January) emitted a bare '&' that xmllint rejects. Fixed by adding a live-data test against the 2035 fixture asserting an escaped ampersand is present and no bare one remains. Both new assertions were run against their named mutations and confirmed to redden before being reverted.
| * docs(templates): fix a false claim in the worked example (F1)Lukasz Kasprzak2026-08-191-13/+96
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The worked minimal template iterated the top-level, flat days list and claimed the naive {{name.la}} form would print the enclosing month's name there. It would not: with no {{#months}} wrapping it, the flat days list has no month anywhere on the scope stack, so the naive form on an unnamed day resolves to nothing, not to the month. Reproduced against the live engine before touching the page: the flat shape renders empty; the identical naive form nested inside {{#months}} genuinely does print the month's name on both unnamed days. The example now nests days inside months -- the shape every shipped template actually uses, and the shape the hazard needs to fire -- with a second, verified rendering showing the collision for real, and a closing note stating plainly that the flat shape does not reproduce it. SCOPE AND LOOKUP's own name-collision paragraph gained the same nesting precondition it was missing. Both rendered blocks were checked mechanically: extracted verbatim from the page source and diffed against a fresh colitur table run over the exact templates shown, byte for byte. One cosmetic fix along the way: a transition sentence embedded the full safe-idiom string inline via .B, which groff's justifier stretched into visibly wide gaps when rendered. Reworded to reference the idiom shown above instead of repeating it.
| * fix(cli): publish --prune refuses a manifest entry that escapes --outLukasz Kasprzak2026-08-192-9/+138
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | CRITICAL: .colitur-manifest lives INSIDE the tree publish writes into -- the very tree this feature exists to have committed into a git repo. A manifest entry with a ".." path component, or an absolute path, let --prune Sys.remove/Unix.rmdir a file OUTSIDE --out. No attacker is required: an ordinary bad merge, a conflict resolved the wrong way, or a hand-edit of that file is enough to plant such an entry, and publish's own stated contract -- it never deletes a file it does not own -- broke outright the moment one was present. Two independent checks, both required, applied before every deletion: - structural (manifest_entry_is_safe): reject an entry that is absolute or has a ".." path COMPONENT, by splitting on '/' and comparing components, not by substring-matching ".." (which would wrongly reject a legitimate name like foo..bar). - containment (resolves_under): resolve both --out and the candidate with Unix.realpath (closing a symlink-inside-out gap the structural check alone would miss) and verify the candidate is a genuine path descendant of --out, not merely a string with the same prefix. Applied at both the file-deletion loop and prune_empty_dirs' own directory removals. A rejected entry is skipped with a one-line stderr warning; publish completes rather than aborting -- a corrupted manifest must not make the tool itself unusable. test/cli.t reproduces the exact canary scenario (a ".." entry surviving deletion of a file outside --out), an absolute-path entry, and a legitimate dotted filename (no .. component) still pruning normally, alongside the existing --prune coverage.
| * docs(render): template reference, install rules, typesetting checkLukasz Kasprzak2026-08-197-15/+821
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | colitur-templates.5 documents the four syntax forms, the six flavours and their escaping, and the full view-model field reference. It states plainly that there are no partials, no raw form and no expression evaluation -- a template is data, never a program. It documents two real hazards found during this build, not theoretical ones: the outward scope fallback silently shadowing an inner name/num key with an outer one of the same name (with the safe {{#name}}...{{^la}} idiom), and the engine's lack of host-comment awareness (a {{...}} inside a LaTeX %, groff .\" or HTML <!-- --> comment is still parsed as a tag). It also states the limitation rather than hiding it: AsciiDoc and Markdown are not escaped, so a feast name containing * or _ renders as emphasis. templates/ and schema/ now install into <prefix>/share/colitur/, matching data/ef/, via new install stanzas; colitur-templates.5 installs to man5 beside colitur-overlay.5. Verified against a scratch prefix: the installed binary resolves both from the prefix, not the source tree, when run from an unrelated working directory. make check-templates typesets every shipped template through pdflatex and groff when they are installed, and prints SKIPPED loudly when they are not. Golden tests prove templates render; only this proves they typeset. A silent skip would read as a pass. Fixed a real doc/help drift while here: bin/main.ml's --help still said --overlay was accepted on day and readings only, three commands out of date (emit, table/render and publish all accept it too), disagreeing with the man page's own OVERLAYS section, which carried the identical stale line. Both are corrected; --overlay's own behaviour is unchanged.
| * feat(cli): colitur publish -- the static treeLukasz Kasprzak2026-08-196-8/+888
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Writes ef/<year>.{json,csv,xml,ics}, one JSON per day, the schema and a generated index. That tree is the API: any web server or git repo serves it, and nothing runs at request time. Deterministic: publishing twice is byte-identical, asserted in cli.t. That is what makes publishing into a git repo safe -- the diff shows only real change, and you review it before pushing. Non-destructive: a manifest records exactly the files this tool wrote, so --prune can only remove files a previous run created. A file you put in the output directory yourself is never touched, with or without --prune. Asserted in both directions. Pruning a stale file also removes any directory it leaves empty behind it (e.g. an old year's own ef/<year>/ tree), stopping at --out itself -- without this, a pruned year's own directory would survive empty and test -d would still see it. schema/day-v1.json is resolved the same prefix-relative way data/ef's own sexp files are (installed vs build-tree, probed rather than assumed), never from cwd, and a missing schema fails with one line on stderr before anything is written rather than emitting an empty file. Needed schema/day-v1.json wired into the root dune file's default alias and into test/dune's cram deps -- unlike data/ and templates/, nothing made dune mirror schema/ into the build tree before this. unix is added to bin/dune's libraries for mkdir_p; it ships with the compiler, so colitur.opam and dune-project are unchanged.
| * feat(templates): wall calendar grid in LaTeX, groff and HTMLLukasz Kasprzak2026-08-198-2/+752
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Three flavours, not six: a month grid in Markdown or plain text is a worse artefact than the booklet already is, and shipping a template we would not use ourselves is maintenance with no reader. These are the first templates to use weeks and in_month, so this is where the view model earns its keep -- the booklet and the grid come from one model with no second code path. Every cell carries a per-cell 'last' boolean (already in the view). A table row needs a separator BETWEEN cells and the engine deliberately has no 'unless last' construct; the rule is shape the data, not the template. Without it the LaTeX grid emits eight columns for seven cells and pdflatex rejects the file. Day cells resolve their label as {{#name}}{{la}}{{^la}}{{slug}}{{/la}} {{/name}}, never a bare {{name.la}}: the enclosing month object has its own name.la, and the engine's scope lookup falls back outward, so a bare dotted lookup would render the month's own Latin name on every day lacking one -- a wall calendar where every day reads "January". Verified on the goldens: Ianuarius appears exactly once per file, the month heading, never as a day label.
| * fix(cli): guard the whole template read, not only the openLukasz Kasprzak2026-08-192-5/+35
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | read_file guarded open_in_bin but left in_channel_length and really_input_string unguarded, so a path that opens but cannot be read as bytes -- a directory -- escaped as an uncaught Sys_error and crashed the program, leaking the open channel on every failure path. A template is user input; it must never crash the program. Wrap the whole read in Fun.protect so the channel closes on every path (success, exception, early return), matching the close-on-every-path pattern already used in the test suite. The missing-file message stays exactly as before; a read failure after a successful open now carries the exception text, the same path: exception shape Layer.load and Overlay.load already use. New cram case points --template at a directory (the sandbox's own cwd, not /tmp) and asserts one stderr line and exit 2, not a crash.
| * feat(templates): ordo booklet in six flavours, pinned by goldensLukasz Kasprzak2026-08-1915-2/+14874
| | | | | | | | | | | | | | | | | | | | | | | | | | | | LaTeX and groff are the print paths; HTML carries a print stylesheet; AsciiDoc, Markdown and plain text are the plain-consumer paths. AsciiDoc and Markdown use flavour none, and say so in a comment: their metacharacters are context-dependent and escaping them aggressively produces worse output than not escaping. The consequence is real and documented -- a feast name containing * renders as emphasis. Golden tests pin all six byte-for-byte for 2027. They prove the templates RENDER, not that they TYPESET; compiling needs TeX and groff, which is Task 13's opt-in make check-templates.
| * feat(cli): colitur table and renderLukasz Kasprzak2026-08-193-3/+428
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Computes and renders in one process. There is deliberately no stdin-fed render: honouring the pipe would need a JSON parser we would have to write, purely to serialise and immediately re-parse our own view -- a second hand-rolled component and a second place for the contract to drift, for no benefit. colitur emit --format json | jq still composes. An unknown extension with no --flavour is an error naming the six valid flavours, never a silent fallback to none: guessing wrong produces malformed output that looks fine until it does not. A malformed template reports the parser's own reason and exits 2. A template is user input; it must never crash the program.
| * fix(render): omit DTEND at the domain's own last day, 9999-12-31Lukasz Kasprzak2026-08-192-3/+86
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | F1: Date.add_days is UNBOUNDED (date.mli) -- only Date.make enforces 1583..9999 -- and Date.to_iso8601 pads but never truncates, so 9999-12-31's naive successor formatted as "10000-01-01", and compact turned that into a 9-digit, non-conformant DATE on the last VEVENT of year 9999. Confirmed at the source before fixing, and reproduced against real `colitur emit --format ics --from 9999 --to 9999` output (DTEND;VALUE=DATE:100000101) before touching any code. RFC 5545 section 3.6.1: a VEVENT with a DATE-valued DTSTART and neither DTEND nor DURATION has an implicit one-day duration, so omitting DTEND for that one event is the standard's own correct answer, not a workaround. dtend_of re-derives the successor's year/month/day and re-validates them through Date.make -- the one function that actually enforces the domain -- before trusting the string; None means the caller omits the DTEND line entirely. F2 (minor, same function): documented next_day's own Error branch as dead-but-silent on shipped data (event's iso <> "" guard is the only caller and always parses) -- behaviour unchanged, comment only. Two new tests: the domain's last VEVENT (DTSTART 99991231) has no DTEND line at all; every DTEND anywhere in a 9999 feed is exactly 8 digits (the general form of the bug, catches a regression anywhere else in the domain too). Existing 2027/2028 DTEND-arithmetic assertions untouched and still pass. Mutation-proved: both new tests fail against the pre-fix code (9-digit DTEND value caught verbatim), pass after.
| * feat(cli): colitur emit -- csv, json, sexp, xml, icsLukasz Kasprzak2026-08-194-21/+359
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Reuses resolved_year_report's existing two-liturgical-year indexing rather than copying it: that walk owns the civil-vs-liturgical span reasoning, and a second copy would drift. It is refactored to return the days, with the printer layered on top, so day and readings behave identically -- which cli.t proves byte-for-byte. CSV emits one header for a whole multi-year run, not one per year. A reversed range is a usage error rather than silently empty output. Asserted in cli.t: two ics runs are byte-identical, because nothing in the path reads a clock.
| * feat(render): iCalendar emitter, RFC 5545Lukasz Kasprzak2026-08-194-1/+184
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Not a template job: folding, escaping, exclusive DTEND and stable UIDs are rules a logic-less template cannot enforce, and each fails silently in a subscriber's client rather than loudly at generation. DTEND is EXCLUSIVE for an all-day event (section 3.6.1). Wrong here shows every event a day short, everywhere. UIDs are YYYYMMDD-<rite>@colitur and stable across regenerations (section 3.8.4.7). Wrong here duplicates the whole year in every subscriber's phone, months later. Every line is CRLF-terminated and folded at 75 octets (section 3.1). No RRULE: a liturgical calendar is not a recurrence rule. Asserted, so nobody optimises it later. DTSTAMP is a parameter, not a clock read. RFC 5545 requires it and the obvious implementation reads the wall clock -- which violates the kernel's determinism rule and would make two feeds from identical data differ byte-for-byte, defeating reproducible builds and any reviewable diff on a published tree. Corrected one test literal against real engine output: DTSTAMP is a per-VEVENT property (section 3.8.7.2), not calendar-level, so the default-value line count is 365 (every event), not 1. Mutation-tested: a non-exclusive DTEND reddens the suite.
| * feat(render): XML emitter and schemaLukasz Kasprzak2026-08-196-2/+151
| | | | | | | | | | | | | | | | | | | | | | | | | | Element-per-field; attributes carry identity only and there is no mixed content, so a consumer's XPath never has to distinguish the two. Schema validation is an opt-in make check-schema via xmllint, not an in-suite assertion: validating XSD needs an XML library and the dependency list is frozen. It prints SKIPPED loudly when xmllint is absent, because a silent skip reads as a pass. The suite asserts well-formedness properties directly instead. This corrects the design spec, which claimed in-test validation.
| * fix(render): remove rank_label -- it duplicated name verbatimLukasz Kasprzak2026-08-191-2/+6
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | view.ml's rank_label field was a byte-for-byte copy of the celebration's name (names_value cel.Celebration.names), not a localized rank label at all -- the kernel has no per-language rank names to draw one from, so there was no honest value to put there. Nothing consumed it: no template in the plan, no test, no other code referenced it. Removed from both day_value and padding_cell so the two key sets stay identical (23 keys each, verified). schema/day-v1.json already described 23 keys and needed no change -- it now matches the emitted output exactly. schema/day-v1.json is a published contract: once a phone subscribes or a site fetches this, removing a field is a breaking /v2/ change. The time to remove a field that lies about its own contents is before anyone can depend on it, not after.
| * feat(render): CSV and JSON emitters, and the published contractLukasz Kasprzak2026-08-197-1/+262
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Both consume the VIEW, not the kernel, so every emitter and every template describe exactly the same fields -- there is one vocabulary, not five. CSV is RFC 4180: a field with a comma is quoted. That is live on real data, not hypothetical -- 'St. Joseph, Spouse of the Bl. Virgin Mary' would otherwise split into two columns. JSON is hand-rolled because the dependency list is frozen and escaping is the only subtlety. Control characters below 0x20 are \u-escaped per RFC 8259 section 7. There are no numbers in the view, deliberately: a consumer never has to guess whether week is 2 or "2". schema/day-v1.json pins the shape. Once a phone subscribes or a site fetches this, it is a promise to strangers -- adding a field is minor, renaming one means /v2/.
| * feat(render): the view modelLukasz Kasprzak2026-08-195-1/+355
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Shapes a civil year of resolved days into the value a template renders against. This layer is why the engine can stay logic-less: a month grid needs leading blank cells, week bucketing and an in-month test, and a logic-less template can compute none of it. Both weeks and days are offered at every level -- the booklet walks days, the grid walks weeks -- so the two artefacts cannot drift. Colours are six booleans, not hex: hex bakes a presentation policy into the engine, and LaTeX, groff and HTML each want a different colour expression. Asserted: exactly one of the six is true on every day of a whole year, so a template keying off them can never get none or two. Padding cells carry every field a real day carries, empty, so a template never hits a missing key mid-grid.
| * feat(render): template renderer with mandatory escapingLukasz Kasprzak2026-08-194-1/+145
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Every interpolated value is escaped for the template's flavour; the template's own literal text never is, because that is the author's markup. There is no raw form, so a template cannot opt out. Scope is a stack with outward fallback, so a grid template can reach the year number from inside a week without the view duplicating it into every cell. A missing key renders empty -- the one deliberate silence, so a template survives a rite that does not set every optional field. Mutation-tested: dropping the Escape.apply call reddens the data-cannot-escape-flavour case.
| * fix(render): reject empty tag paths, sharpen the raw-form testLukasz Kasprzak2026-08-192-7/+42
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | F1: test_no_raw_or_partial_form's first assertion only excluded one literal shape (Ok [Var ["{name"]]), so it could not actually catch a future raw/unescaped constructor under a different name. Replace it with an assertion of the real parse result for {{{name}}} (Ok [Var ["{name"]; Text "}"]), documented behaviour rather than a guarantee this test cannot check -- the real guarantee is structural: node has exactly four constructors and none of them is raw. F2: {{.}}, {{#}}, {{^}} and {{/}} used to parse to a Var/Section/ Inverted with an empty path, reachable but never designed. This engine has no "current context" for a bare dot to mean, so a bare-dot or empty-sigil path is now a parse error at lex time, covering all four sigil forms via one path helper. The existing "empty tag {{}}" branch is unchanged and still reachable (a fully empty body is a distinct case from a sigil with an empty path).
| * feat(render): logic-less template parserLukasz Kasprzak2026-08-194-1/+177
| | | | | | | | | | | | | | | | | | | | | | Placeholders, sections, inverted sections, comments. Nothing else: no partials, no lambdas, no expression evaluation, no raw form. A template is data, never a program, which is what keeps an untrusted template safe. Errors rather than silence on a malformed template: an unterminated tag, an unclosed section, a mismatched close and a partial all return Error. Swallowing '{{name' as text is how a typo becomes invisible missing output in a printed booklet.
| * fix(render): make fold_ics total on arbitrary octet stringsLukasz Kasprzak2026-08-192-8/+42
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | fold_ics's UTF-8 backoff loop could back `cut` all the way down to `pos` on 74+ consecutive continuation bytes (0x80-0xBF), producing a zero-length chunk and recursing on the identical position forever -- not producible by valid UTF-8, whose longest continuation run is 3, but the kernel's own totality requirement covers arbitrary octet strings, not only valid ones. When backoff finds no boundary inside the window, cut hard at the limit instead, so forward progress is unconditional. test_fold_never_splits_utf8 previously asserted only that unfolding reproduced the original bytes, a property folding preserves at any cut position and therefore blind to a boundary violation. It now also asserts the named property directly: no continuation chunk may start with a UTF-8 continuation byte. A new regression test feeds fold_ics 100 consecutive continuation bytes and asserts it terminates with every line at or under 75 octets.
| * feat(render): per-flavour escaping and RFC 5545 line foldingLukasz Kasprzak2026-08-196-2/+216
|/ | | | | | | | | | | | | | | Six flavours: latex, groff, html, xml, ics, none. Markdown, AsciiDoc and plain text map to none deliberately -- their metacharacters are context-dependent and escaping them aggressively produces worse output than not escaping. An unrecognised extension returns None rather than falling back to none: guessing the flavour wrong produces malformed output that looks fine until it does not. Folding backs off to a non-continuation byte, so a fold never splits a UTF-8 sequence -- the failure mode that would corrupt Polish and Latin names in a published feed.
* test(differential): C14 and C15 closed -- only slug vocabulary remainsLukasz Kasprzak2026-08-184-54/+59
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | lectio adopted the last two Missal corrections colitur argued: C14 3 -> 0 RG 96(a)/97/98. lectio resolved each impeded I-class feast's transfer independently, so in 2008, 2035 and 2046 St Joseph and the Annunciation both walked onto the Monday after Low Sunday and Joseph, losing there, was observed on no day of those years at all. It now resolves the year's transfers as a set: the Annunciation takes the Monday as its sedes propria, Joseph the Tuesday. C15 7 -> 0 The Holy Family propers' own 13-January rubric, "sine commemoratione Baptismatis D.N.I.C.". lectio kept the Baptism as the observed office on all seven such years in 2005-2050. Those were the last two BEHAVIOUR classes. What is left is C1 (138) and C6 (119), both pure slug vocabulary. Measured across the whole fixture, applying only the season normalisation the test already applies: 579 of 16801 days differ, and every one of them differs on the SLUG ALONE. Zero days differ on season, rank, colour, Epistle or Gospel. (The test's own norm_slug table already maps 322 of the 579; the 257 left are C1 and C6. The field profile is the same either way.) So the two engines now agree on every liturgically meaningful field on every day of 2005-2050. The residue is what the two projects call things -- ef-christmas-2-friday against ef-time-after-epiphany-1-friday for the same day, same Mass. Aligning it means renaming lectio's slugs, and those are keys: its lectionary, clectio's generated tables and any user overlay are built on them. Left as vocabulary rather than forced. Fixture refreshed against lectio e713da2; 13 rows changed, colitur agrees with all of them. 24 divergence classes at the start of this work, 2 now.
* test(differential): C31 closed -- 4 classes left, 257 of 267 rows are namingLukasz Kasprzak2026-08-184-131/+107
| | | | | | | | | | | | | | | | | | | | | | | | | | The ferias between Epiphany and the first Sunday after it repeat Epiphany's own Mass in lectio now; they had been taking the Mass of the Sunday that follows them. C31 98 -> 0 C1 40 -> 138 grew, and changed character C1 is now SLUG VOCABULARY ONLY, and its note says so. Season, rank, colour and both citations agree with colitur on every day of the January window; what differs is the identifier -- ef-christmas-2-thursday against ef-time-after-epiphany-1-thursday for the same day with the same Mass. C6 is the same shape. That is 257 of the 267 remaining rows. Aligning the vocabularies would mean renaming lectio's slugs, and those are keys: its own lectionary, clectio's generated tables and any user overlay are built on them. Recorded as vocabulary rather than closed, because the rows do differ -- just not in anything a reader of either engine's output sees. Measured for 2026: season, rank and colour agree on all 365 days; 14 days differ and every one differs on the slug alone. 5 classes -> 4, agreement 98.4%.