From 055deaedd6bc1acb770bd96b7da8f985ab0f4f0b Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Fri, 21 Aug 2026 12:19:43 +0200 Subject: feat(config): flavour is settable, and an INI overlay is diagnosed The escaping flavour was the one rendering setting with no config key, so a template whose extension says nothing needed the flag every run. Unset still means infer, which config --show reports as (infer) rather than (none). Backfills the 0.8.0 and 0.9.0 changelog entries. Both were tagged by hand rather than through 'make release', which is precisely the guard that would have refused a release with no changelog line. --- CHANGELOG.md | 9 +++++++++ bin/main.ml | 20 ++++++++++++++++---- lib/naming/config.ml | 5 ++++- lib/naming/config.mli | 10 ++++++++++ test/cli.t | 19 +++++++++++++++++++ test/test_config.ml | 19 +++++++++++++++++++ 6 files changed, 77 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc47dfc..f970978 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ One line per release. Versions are git tags (`vX.Y.Z`). +## [0.10.0] - 2026-08-21 +- Two configuration gaps and a diagnosis. **`flavour` is now a config key.** It was the one rendering setting with no way to set it once: `template` and `format` had keys, the escaping flavour did not, so a template whose extension says nothing about its output needed the flag on every invocation. It is normally inferred from the extension and unset still means "infer", which is why `config --show` reports `(infer)` rather than `(none)` — an unset flavour is not an absence. There is deliberately no silent fallback, because guessing it wrong yields malformed output rather than ugly output. **An INI overlay handed to `--overlay` is diagnosed rather than left to fail.** The INI form is a SOURCE format that `colitur convert` turns into the S-expression one; feeding it directly failed deep in the sexp reader with "more than one S-expression in file", naming neither the cause nor the cure — and the shipped examples under `data/ef/examples/` are exactly what someone would try it with. The detector's own first version capped its scan at 40 lines while `poland.ini`'s comment header is 43, so it silently never fired on the very file it was written for. Also: the README's about page carried three false claims — that older tags are not downloadable (true for one morning), that the OF form exists (`lib/rites/` holds `rite_ef` alone), and suite timings from a much smaller suite — and gains a status section stating not just the five validation layers but what they cannot see, since the differential and the oracle share one data lineage. 570 tests. + +## [0.9.0] - 2026-08-20 +- An audit of the shipped program, and the fixes it found. **The citation parser accepted OCaml integer-literal syntax**, so `Luke 1_1:5` read as chapter ELEVEN and `+5` as 5 — a transcription typo silently becoming a *different chapter*, reachable through any user overlay. Numbers are now plain digits and positive, and a descending range is rejected. **Four pairs of different books shared a full title**: 1 and 2 Corinthians both rendered "Epistola ad Corinthios", as did Thessalonians, Timothy and Peter — 108 citations in 2027 alone that a reader cannot resolve to a book. This was the Kings defect fixed one release earlier and simply not generalised. A test now forbids it, while allowing the case where two ids *are* the same book under different numbering, which a tradition relates. **Spec §8.5 went from recorded to delivered**: shipped styles did not re-parse their own rendered output — 32 of 52 Latin abbreviations and 49 of 52 full titles failed — so a citation copied out of colitur's own output into an overlay was passed through untouched and printed in the wrong language, silently. Every shipped name is now a registered spelling and `split_book` learned multi-word titles. **Overlay errors stopped naming OCaml source files at the reader**: the five-path rewriter is replaced by a generic one applied at every load path, so "rank: is not one of the allowed values (at Class9)" replaces a raw `Of_sexp_error` dump. Also: the `new-overlay` scaffold shows `citations` at the right nesting level (it documented the field without showing it, and the nesting is the single easiest thing to get wrong); `config --show` validates before printing rather than exiting 2 after five rows; error messages no longer echo whole file lines, which copied an unrelated file's contents to stderr when a flag pointed at one; and a month answers to both spellings of its own name, since an unknown template key renders as the empty string and the mistake produced a silently blank heading. `--raw` output is byte-identical to 0.8.0 across the whole domain. 569 tests. + +## [0.8.0] - 2026-08-20 +- Output, naming, and configurable citations — the release that made everything a reader sees a file rather than a value in code. **Rendering**: a logic-less template engine (Mustache subset, no partials, no lambdas, no code execution) with *mandatory* per-flavour escaping — `latex typst groff html xml ics none` — plus five emitters (`csv json sexp xml ics`, the last RFC 5545 with stable UIDs and 75-octet folding), `colitur table`/`render`/`emit`/`publish`, and eleven shipped templates: an ordo booklet in six flavours and a wall calendar in four. **Naming**: language tables and a config file, so slugs stop reaching the page — `ef-septuagesima-sunday-2` was printing in a booklet because nothing asserted coverage. Latin names for the whole temporal cycle and sanctoral, transcribed from the Missal, with a citation checker wired into the suite; `--raw` keeps the bare-slug form. The coverage test at first walked only the *observed* day and so never saw commemorations or transfers, which is exactly how "Commemoratio canute-martyr" reached a printed page. **Citations became configurable**: parsed into structure and re-rendered, so book names, abbreviations, punctuation and numbering tradition are each a file — `[bible]` and `[sigla]` sections in a language file, `lang/traditions.ini` for Vulgate-versus-modern numbering, three config keys and flags. Naming and renumbering are deliberately separate mechanisms; conflating them is how a citation ends up naming the wrong book. The design's own survey of "the shipped data" had read one of three citation-bearing files and missed twenty-one book tokens — `sanctoral.sexp` carries more citations than the lectionary does — and the fact was already written down in `commons.sexp`'s header, which called the inconsistency "cosmetic" because nothing parsed those strings. **Differential closure**: lectio adopted eight corrections argued from the Missal, so on those days the shared lineage is broken and colitur is no longer the outlier; the fixture was regenerated and seven divergence classes closed. Also: `publish --prune` refused a manifest entry escaping `--out` (a real traversal, found by canary), `fold_ics` was made total on arbitrary octets, and four vacuous assertions were replaced with real ones. 559 tests. + ## [0.7.0] - 2026-08-18 - A flat INI form for overlays, a reference man page, and two real local calendars. **`colitur convert FILE.ini`** transpiles a flat, section-per-slug format to the S-expression one — `status`, `subject` and `layer` all default, so an ordinary local saint's feast says only what distinguishes it, and dates flatten to `MM-DD`, `easter+N` and `oct/sun/-1`. It is a front door, not a second data model: it parses to exactly the `Overlay.t` the sexp form does, and a test asserts the two produce identical values. It is deliberately less expressive — `Add`, `Suppress`, single-field `Edit` — and refuses `Replace`, multi-field and citation edits *by name* rather than dropping them. **The conversion verifies its own output**: the emitted text is parsed back with the same function that loads an overlay and must mean what the INI said, or nothing is written — a transpiler emitting valid-but-wrong sexp is the failure a convenience format invites, and `colitur check` could never catch it. That check was wrong on the first attempt in exactly that way, re-serialising the parsed value instead of parsing the emitted text; a mutation corrupting the renderer sailed through it, and the fix makes the same mutation redden two tests. **`colitur-overlay(5)`** documents the format in full — every directive, every field, the three date shapes — and, more usefully, *how an overlay is applied*: load, merge, resolve, emit, with the point made explicit that overlay entries take part in precedence on equal terms, so "my feast does not appear" is almost always a lost contest rather than a load failure. **Two real calendars ship** beside the invented example: **Poland**, 17 entries from the *Calendarium Perpetuum pro Dioecesium Poloniae* (1964), with three unrecoverable classes marked as inferences and a warning about the 1921/1934 *Proprium Poloniae* still bound into many missals; and **Benedictine**, two entries and a comment explaining why — the monastic rite grades feasts Duplex and Semiduplex below II class, where the Roman calendar has III and IV, so most Benedictine propers cannot be expressed here at all, and rather than invent a mapping the source never states, that file lists two dozen excluded feasts by name. Both are examples, not authorities. 405 tests. diff --git a/bin/main.ml b/bin/main.ml index c915255..a7120ad 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -1974,8 +1974,8 @@ let lang_check path = language code or path, not a closed set, and [config --show] does not validate [lang] either (that only happens when a command actually loads it via [load_lang]). *) -let config_show ~lang_flag ~template_flag ~format_flag ~overlays_flag ~sigla_style_flag ~sigla_book_flag - ~sigla_tradition_flag config = +let config_show ~lang_flag ~template_flag ~format_flag ~flavour_flag ~overlays_flag ~sigla_style_flag + ~sigla_book_flag ~sigla_tradition_flag config = (* VALIDATE BEFORE PRINTING ANYTHING. A usage error used to surface halfway down the table, so `config --show --sigla-book bogus` exited 2 having already written five rows to stdout -- a caller redirecting @@ -2001,6 +2001,9 @@ let config_show ~lang_flag ~template_flag ~format_flag ~overlays_flag ~sigla_sty let lang_value = scalar "lang" lang_flag (Colitur_naming.Config.lang config) "la" in let _ = scalar "template" template_flag (Colitur_naming.Config.template config) "(none)" in let _ = scalar "format" format_flag (Colitur_naming.Config.format config) "(none)" in + (* "(infer)" rather than "(none)": an unset flavour is not an absence, it + means the flavour comes from the template's own extension. *) + let _ = scalar "flavour" flavour_flag (Colitur_naming.Config.flavour config) "(infer)" in (* Default is the resolved LANGUAGE, not a literal "la": a booklet that asked for --lang fr and named no --sigla-style of its own gets French citations too, not a silent switch back to Latin punctuation. *) @@ -2244,7 +2247,8 @@ let () = Printf.eprintf "colitur: config requires --show\n"; exit 2 end; - config_show ~lang_flag:lang ~template_flag:template ~format_flag:format ~overlays_flag:overlays + config_show ~lang_flag:lang ~template_flag:template ~format_flag:format + ~flavour_flag:flavour ~overlays_flag:overlays ~sigla_style_flag:sigla_style ~sigla_book_flag:sigla_book ~sigla_tradition_flag:sigla_tradition config | [ "day"; ys ] -> @@ -2308,7 +2312,15 @@ let () = ~sigla_tradition_flag:sigla_tradition ~config in with_year ys (fun y -> - table_report ~lang:lang_t ~sigla ~template ~flavour_opt:flavour + table_report ~lang:lang_t ~sigla ~template + (* flag > config > infer from the template's own + extension. None here still means "infer", which is + the usual case, so this is an option-or rather than + a Config.resolve with a default. *) + ~flavour_opt: + (match flavour with + | Some _ -> flavour + | None -> Colitur_naming.Config.flavour config) ~overlays:effective_overlays y) )) | [ "publish" ] -> ( diff --git a/lib/naming/config.ml b/lib/naming/config.ml index 068b40a..4077da0 100644 --- a/lib/naming/config.ml +++ b/lib/naming/config.ml @@ -5,6 +5,7 @@ type t = { overlays : string list; template : string option; format : string option; + flavour : string option; sigla_style : string option; sigla_book : string option; sigla_tradition : string option; @@ -13,13 +14,14 @@ type t = { } let empty = - { lang = None; overlays = []; template = None; format = None; sigla_style = None; + { lang = None; overlays = []; template = None; format = None; flavour = None; sigla_style = None; sigla_book = None; sigla_tradition = None; unknown_keys = []; unknown_sections = [] } let lang t = t.lang let overlays t = t.overlays let template t = t.template let format t = t.format +let flavour t = t.flavour let sigla_style t = t.sigla_style let sigla_book t = t.sigla_book let sigla_tradition t = t.sigla_tradition @@ -71,6 +73,7 @@ let of_string text = | "lang" -> { acc with lang = Some v } | "template" -> { acc with template = Some v } | "format" -> { acc with format = Some v } + | "flavour" -> { acc with flavour = Some v } | "sigla_style" -> { acc with sigla_style = Some v } | "sigla_book" -> { acc with sigla_book = Some v } | "sigla_tradition" -> { acc with sigla_tradition = Some v } diff --git a/lib/naming/config.mli b/lib/naming/config.mli index a5205c4..0dda906 100644 --- a/lib/naming/config.mli +++ b/lib/naming/config.mli @@ -29,6 +29,16 @@ val overlays : t -> string list val template : t -> string option val format : t -> string option +(** The template ESCAPING flavour ([latex] | [typst] | [groff] | [html] | + [xml] | [ics] | [none]). + + Only consulted when the flavour cannot be inferred from the template's + own extension, which is the usual case -- a template named [.tex] needs + no setting. It exists for the template whose extension says nothing, + and getting it wrong produces output that is malformed rather than + merely ugly, so there is deliberately no silent fallback. *) +val flavour : t -> string option + (** Which citation style to render a reference in: a language CODE, looked up the same way {!lang} is, or a path -- but a DIFFERENT axis from {!lang}: a language file's own [\[sigla\]] section IS a style diff --git a/test/cli.t b/test/cli.t index b2c2263..2e9a0e2 100644 --- a/test/cli.t +++ b/test/cli.t @@ -1270,6 +1270,20 @@ slug the engine can produce over 2020-2045: partial.ini: 1 of 725 celebrations named, 724 missing, 0 unknown partial.ini: 0 of 104 book names, 104 missing +`flavour` decides how a template's output is ESCAPED. It is normally inferred +from the template's own extension, so the key exists for the template whose +extension says nothing -- and getting it wrong yields malformed output rather +than ugly output, which is why there is no silent fallback: + + $ printf '{{#days}}{{name}}\n{{/days}}' > t.noext + $ colitur table --year 2027 --template t.noext + colitur: cannot infer a flavour from ".noext"; pass --flavour latex|typst|groff|html|xml|ics|none + [2] + + $ mkdir -p xdg-fl/colitur && printf '[defaults]\nflavour = html\n' > xdg-fl/colitur/config.ini + $ XDG_CONFIG_HOME=xdg-fl colitur table --year 2027 --template t.noext | head -1 + In Octava Nativitatis Domini + An INI overlay handed to `--overlay` is DIAGNOSED, not left to fail inside the sexp reader. The INI form is a source format that `colitur convert` turns into the S-expression one; feeding it directly otherwise produced "more than one @@ -1322,6 +1336,7 @@ literally either): lang la (default) template (none) (default) format (none) (default) + flavour (infer) (default) sigla_style la (default) sigla_book abbr (default) sigla_tradition vulgate (default) @@ -1336,6 +1351,7 @@ listed one line per effective entry instead: lang fr (flag) template (none) (default) format (none) (default) + flavour (infer) (default) sigla_style fr (default) sigla_book abbr (default) sigla_tradition vulgate (default) @@ -1354,6 +1370,7 @@ differently from a misspelled key inside a recognised one: lang en (config) template (none) (default) format (none) (default) + flavour (infer) (default) sigla_style en (default) sigla_book abbr (default) sigla_tradition vulgate (default) @@ -1374,6 +1391,7 @@ the config file the earlier examples left behind: lang la (default) template (none) (default) format (none) (default) + flavour (infer) (default) sigla_style pl (flag) sigla_book full (flag) sigla_tradition modern (flag) @@ -1410,6 +1428,7 @@ sidesteps that same non-portability: lang la (default) template (none) (default) format (none) (default) + flavour (infer) (default) sigla_style la (default) sigla_book abbr (default) sigla_tradition nonsense (flag) diff --git a/test/test_config.ml b/test/test_config.ml index d348d9b..1dc1382 100644 --- a/test/test_config.ml +++ b/test/test_config.ml @@ -105,6 +105,23 @@ let test_two_defaults_blocks_report_no_unknown_sections () = (* Task 8: sigla_style/sigla_book/sigla_tradition read the same way lang/template/format do, in the same [\[defaults\]] section -- no new section, and every key recognised (so [unknown_keys] stays empty). *) +(* The escaping flavour is usually inferred from the template's extension, so + this key exists for the template whose extension says nothing. Getting it + wrong yields malformed output rather than ugly output, which is why there + is no silent fallback and why it is worth being settable rather than + passable only per-run. *) +let test_flavour_is_read () = + match C.of_string "[defaults]\nflavour = groff\n" with + | Error e -> Alcotest.failf "parse: %s" e + | Ok c -> + Alcotest.(check (option string)) "flavour" (Some "groff") (C.flavour c); + Alcotest.(check (list string)) "nothing unknown" [] (C.unknown_keys c) + +let test_flavour_absent_is_none () = + match C.of_string "[defaults]\nlang = en\n" with + | Error e -> Alcotest.failf "parse: %s" e + | Ok c -> Alcotest.(check bool) "unset" true (C.flavour c = None) + let test_sigla_keys_are_read () = let text = "[defaults]\nsigla_style = la\nsigla_book = full\nsigla_tradition = modern\n" @@ -149,6 +166,8 @@ let suite = `Quick test_overlays_accumulate_across_blocks; Alcotest.test_case "two defaults blocks report no unknown sections" `Quick test_two_defaults_blocks_report_no_unknown_sections; + Alcotest.test_case "flavour is read" `Quick test_flavour_is_read; + Alcotest.test_case "flavour absent is none" `Quick test_flavour_absent_is_none; Alcotest.test_case "sigla keys are read" `Quick test_sigla_keys_are_read; Alcotest.test_case "empty config sigla keys are none" `Quick test_empty_config_sigla_keys_are_none; Alcotest.test_case "sigla key repeated across blocks last wins" -- cgit v1.3