aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-20 09:28:08 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-20 09:28:08 +0200
commit0f94c388880972f8e665ed01a6b38190bcc61499 (patch)
tree7f684dcd07c19eab798a5a8837677faf0556a58d
parent286e362bd02e7ef4130cccb36fbeb16b4fbb5a56 (diff)
downloadcolitur-0f94c388880972f8e665ed01a6b38190bcc61499.tar.gz
colitur-0f94c388880972f8e665ed01a6b38190bcc61499.zip
feat(cli): --lang and --raw, colitur lang, colitur config
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.
-rw-r--r--bin/main.ml628
-rw-r--r--lib/render/emit_ics.ml13
-rw-r--r--man/colitur.1322
-rw-r--r--test/cli.t252
4 files changed, 1091 insertions, 124 deletions
diff --git a/bin/main.ml b/bin/main.ml
index b2eef28..0a09a63 100644
--- a/bin/main.ml
+++ b/bin/main.ml
@@ -131,41 +131,20 @@ let data_dir () =
end
| _ -> if has_data installed then installed else build_tree
-(* TEMPORARY BRIDGE (naming-and-config Task 5): [View.of_days] now takes
- [~lang], but real CLI flag/config resolution (--lang, --raw,
- Colitur_naming.Config) is Task 6's own scope, not this one's. Rather than
- leave `emit`/`table`/`publish` showing bare slugs as their "resolved
- name" -- which is precisely the defect this whole branch exists to fix --
- this loads the shipped Latin table by default, using EXACTLY the probe
- order [data_dir] above already uses (installed prefix, then the build
- tree), so an installed binary finds its language file the same way it
- finds its calendar data. Task 6 replaces this wholesale with
- `--lang`/`--raw`/config resolution and per-command defaults; nothing
- here is meant to survive that task unchanged. A missing or malformed
- language file degrades to [Lang.raw] (name = slug) rather than crashing
- the CLI -- Task 6 is what makes that case a proper, reported error. *)
+(* [lang_dir] mirrors [data_dir]'s own probe order exactly -- installed
+ prefix first, then the build tree -- because a language file must resolve
+ the same way calendar data does, or an installed binary could find one
+ and not the other (exactly the defect a prior fix round found: lang/ had
+ no install rule at all, and the installed binary silently fell back to
+ raw slugs with no error). Accepted only if la.ini is actually readable
+ there, matching [data_dir]'s own "a candidate counts only if the data is
+ really there" discipline. *)
let lang_dir () =
let prefix = Filename.dirname (Filename.dirname Sys.executable_name) in
let installed = List.fold_left Filename.concat prefix [ "share"; "colitur"; "lang" ] in
if Sys.file_exists (Filename.concat installed "la.ini") then installed
else Filename.concat prefix "lang"
-let default_lang =
- lazy
- (let path = Filename.concat (lang_dir ()) "la.ini" in
- match open_in_bin path with
- | exception Sys_error _ -> Colitur_naming.Lang.raw
- | ic -> (
- match
- Fun.protect ~finally:(fun () -> close_in_noerr ic) (fun () ->
- really_input_string ic (in_channel_length ic))
- with
- | exception Sys_error _ -> Colitur_naming.Lang.raw
- | text -> (
- match Colitur_naming.Lang.of_string text with
- | Ok t -> t
- | Error _ -> Colitur_naming.Lang.raw)))
-
(* Loads the universal sanctoral layer and applies the one hand-authored
overlay over it (data/ef/adjustments.sexp -- see that file's own header):
[Overlay.apply]'s diagnostics are never silently dropped (Overlay.mli),
@@ -244,7 +223,26 @@ let load_ef_commons () =
| Error e -> Error (Printf.sprintf "failed to load %s: %s" path e)
| Ok commons -> Ok commons
-let day_line (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t)
+(* [~lang] resolves the observed slug to a display NAME, appended as a new
+ LAST field rather than substituted into the slug's own position: a name
+ contains spaces (Latin and English both), and inserting it where the slug
+ used to sit would break every fixed-position field after it (rank,
+ colour, the commemoration tail) for anyone parsing this line by column --
+ the same reasoning that keeps `readings` a separate command rather than
+ extra columns on `day`. [slug] itself is therefore untouched by [lang] in
+ this row, exactly as [Colitur_render.View]'s own [slug] field is.
+
+ The trailing field is present only when the resolved name actually
+ DIFFERS from the slug -- not gated on [--raw] as a special case, but as a
+ direct consequence of [Lang.raw] being the identity table (lang.mli):
+ under [--raw], [name = slug] always, so the field is always absent and
+ this row is BYTE-IDENTICAL to what it printed before this feature
+ existed, with no `if raw then ...` branch anywhere in this function. The
+ same holds for any language file that has genuinely no entry for a given
+ slug -- a miss also returns the slug (lang.mli), so an untranslated day
+ quietly gets no trailing field rather than a field that redundantly
+ repeats the slug it already printed. *)
+let day_line ~lang (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t)
=
let t = d.Colitur_kernel.Liturgical_day.temporal in
let cel = d.Colitur_kernel.Liturgical_day.observed in
@@ -257,14 +255,16 @@ let day_line (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kerne
let commemorations =
String.concat "" (List.map commemoration_suffix d.Colitur_kernel.Liturgical_day.commemorations)
in
- Printf.printf "%s %s %s %s %s %s %s%s\n" (D.to_iso8601 d.Colitur_kernel.Liturgical_day.date)
+ let slug_s = Colitur_kernel.Slug.to_string cel.Colitur_kernel.Celebration.slug in
+ let name = Colitur_naming.Lang.celebration lang slug_s in
+ let name_suffix = if name = slug_s then "" else " " ^ name in
+ Printf.printf "%s %s %s %s %s %s %s%s%s\n" (D.to_iso8601 d.Colitur_kernel.Liturgical_day.date)
(D.weekday_to_string t.Colitur_kernel.Temporal.weekday)
(Rite_ef.Vocab_ef.season_to_string t.Colitur_kernel.Temporal.season)
- week
- (Colitur_kernel.Slug.to_string cel.Colitur_kernel.Celebration.slug)
+ week slug_s
(Rite_ef.Vocab_ef.rank_to_string cel.Colitur_kernel.Celebration.rank)
(Colitur_kernel.Colour.to_string cel.Colitur_kernel.Celebration.colour)
- commemorations
+ commemorations name_suffix
(* The reading citations for a day, as its own row shape rather than extra
columns on [day_line]'s.
@@ -293,7 +293,14 @@ let day_line (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kerne
"citations-unresolved" checks assert exactly one First and one Gospel on
every day of every year 1583..9999 -- but the CLI must not assume a
guarantee the kernel makes about DATA rather than about types. *)
-let readings_line (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t)
+(* [~lang]: same append-only rule as [day_line] above, using this row's own
+ " | " field separator (chosen there precisely because a citation may
+ contain spaces or commas) rather than a plain space -- a resolved name
+ can equally contain spaces, and " | " is what already keeps this row's
+ fields unambiguous. Present only when the resolved name differs from the
+ slug, for the identical reason [day_line] gives: under [--raw] this row
+ is therefore byte-identical to what it printed before. *)
+let readings_line ~lang (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t)
=
let cel = d.Colitur_kernel.Liturgical_day.observed in
let part_ref p =
@@ -305,11 +312,15 @@ let readings_line (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_
| Some c -> c.Colitur_kernel.Citation.reference
| None -> "-"
in
- Printf.printf "%s %s | %s | %s\n"
+ let slug_s = Colitur_kernel.Slug.to_string cel.Colitur_kernel.Celebration.slug in
+ let name = Colitur_naming.Lang.celebration lang slug_s in
+ let name_suffix = if name = slug_s then "" else " | " ^ name in
+ Printf.printf "%s %s | %s | %s%s\n"
(D.to_iso8601 d.Colitur_kernel.Liturgical_day.date)
- (Colitur_kernel.Slug.to_string cel.Colitur_kernel.Celebration.slug)
+ slug_s
(part_ref Colitur_kernel.Citation.First)
(part_ref Colitur_kernel.Citation.Gospel)
+ name_suffix
(* One civil year, Jan 1 - Dec 31, matching [temporal_report]'s own scan --
NOT one liturgical year: [Colitur_kernel.Calendar.year] resolves a single
@@ -402,8 +413,8 @@ let resolved_year_days ~overlays y =
let resolved_year_report ~line ~overlays y =
List.iter line (resolved_year_days ~overlays y)
-let day_report ~overlays y = resolved_year_report ~line:day_line ~overlays y
-let readings_report ~overlays y = resolved_year_report ~line:readings_line ~overlays y
+let day_report ~lang ~overlays y = resolved_year_report ~line:(day_line ~lang) ~overlays y
+let readings_report ~lang ~overlays y = resolved_year_report ~line:(readings_line ~lang) ~overlays y
(* Task 8: `colitur emit` -- the five template-family emitters built in
Tasks 5-7, wired to a year RANGE rather than a single year, because a
@@ -454,7 +465,7 @@ let check_dtstamp = function
s;
exit 2
-let emit_report ~format ~overlays ~dtstamp ~from_y ~to_y =
+let emit_report ~lang ~format ~overlays ~dtstamp ~from_y ~to_y =
check_dtstamp dtstamp;
if from_y > to_y then begin
Printf.eprintf "colitur: --from %d is after --to %d\n" from_y to_y;
@@ -463,7 +474,7 @@ let emit_report ~format ~overlays ~dtstamp ~from_y ~to_y =
for y = from_y to to_y do
let days = resolved_year_days ~overlays y in
let v =
- Colitur_render.View.of_days ~lang:(Lazy.force default_lang) ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days
+ Colitur_render.View.of_days ~lang ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days
in
match format with
| "csv" ->
@@ -538,6 +549,98 @@ let read_file path =
Ok s)
with exn -> Error (Printf.sprintf "cannot read template %s: %s" path (Printexc.to_string exn)))
+(* The config file supplies DEFAULTS for --lang, --overlay, --template and
+ --format when the corresponding flag is absent -- Colitur_naming.Config
+ owns precedence (flag > config > default). Location follows the XDG base
+ directory convention: $XDG_CONFIG_HOME/colitur/config.ini, or
+ ~/.config/colitur/config.ini when that variable is unset or blank. An
+ unresolvable HOME (neither variable set) means no config path at all --
+ [load_config] below treats that exactly like a missing file, not an
+ error, since colitur without any config must keep working. *)
+let config_path () =
+ match Sys.getenv_opt "XDG_CONFIG_HOME" with
+ | Some d when String.trim d <> "" -> Filename.concat d "colitur/config.ini"
+ | _ -> (
+ match Sys.getenv_opt "HOME" with
+ | Some h -> Filename.concat h ".config/colitur/config.ini"
+ | None -> "")
+
+(* A config file is OPTIONAL: none at [config_path ()] behaves exactly as
+ colitur always has (Colitur_naming.Config.empty). A file that EXISTS but
+ fails to read or parse is fatal -- a config the user wrote and colitur
+ cannot honour is not something to silently carry on past. An unknown key
+ or section is reported but never fatal (Config.mli's own contract): a
+ config written for a newer colitur must still work on an older one, but a
+ silently-ignored typo is how a setting the user believes is active quietly
+ does nothing. Two separate warnings, not one, so a misspelled SECTION
+ (e.g. [deafults]) reads differently from a misspelled KEY inside a
+ recognised one -- Config.mli documents exactly this distinction. *)
+let load_config () =
+ let p = config_path () in
+ if p = "" || not (Sys.file_exists p) then Colitur_naming.Config.empty
+ else
+ match read_file p with
+ | Error _ ->
+ Printf.eprintf "colitur: cannot read config file %s\n" p;
+ exit 2
+ | Ok text -> (
+ match Colitur_naming.Config.of_string text with
+ | Error msg ->
+ Printf.eprintf "colitur: %s: %s\n" p msg;
+ exit 2
+ | Ok c ->
+ List.iter
+ (fun k -> Printf.eprintf "colitur: %s: unknown setting %S (ignored)\n" p k)
+ (Colitur_naming.Config.unknown_keys c);
+ List.iter
+ (fun s -> Printf.eprintf "colitur: %s: unknown section [%s] (ignored)\n" p s)
+ (Colitur_naming.Config.unknown_sections c);
+ c)
+
+(* 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. [raw] takes priority
+ unconditionally and short-circuits everything else -- [--raw] IS
+ [Lang.raw], the identity table, handed to the caller directly, rather
+ than a second "is this raw" case threaded through every call site
+ downstream (View.of_days, day_line, readings_line all just take a
+ [Lang.t] and do not know or care whether it came from [--raw] or a real
+ file). *)
+let load_lang ~raw ~flag ~config =
+ if raw then Colitur_naming.Lang.raw
+ else
+ let code, _src = Colitur_naming.Config.resolve ~flag ~config ~default:"la" in
+ let path =
+ if String.contains code '/' || Filename.check_suffix code ".ini" then code
+ else Filename.concat (lang_dir ()) (code ^ ".ini")
+ in
+ match read_file path with
+ | Error _ ->
+ Printf.eprintf "colitur: no language %S (looked in %s); try: colitur lang --list\n" code
+ (lang_dir ());
+ exit 2
+ | Ok text -> (
+ match Colitur_naming.Lang.of_string text with
+ | Error msg ->
+ Printf.eprintf "colitur: %s: %s\n" path msg;
+ exit 2
+ | Ok t -> (
+ (* Chain to the declared fallback, so a partial translation shows
+ its fallback language rather than bare slugs. A fallback that
+ itself fails to load or parse does not take [t] down with it
+ -- [t] is already a good table; losing only the fallback field
+ is better than losing the whole language over a defect in a
+ file [t] merely NAMES. *)
+ match Colitur_naming.Lang.fallback_code t with
+ | None -> t
+ | Some fb -> (
+ match read_file (Filename.concat (lang_dir ()) (fb ^ ".ini")) with
+ | Error _ -> t
+ | Ok ftext -> (
+ match Colitur_naming.Lang.of_string ftext with
+ | Error _ -> t
+ | Ok base -> Colitur_naming.Lang.with_fallback t base))))
+
let extension path =
match String.rindex_opt path '.' with
| Some i -> String.sub path i (String.length path - i)
@@ -548,7 +651,7 @@ let extension path =
output (unescaped LaTeX/HTML metacharacters) that looks fine until it does
not -- the same "never silently substitute" discipline [data_dir]'s own
[COLITUR_DATA_DIR] handling documents above. *)
-let table_report ~template ~flavour_opt ~overlays y =
+let table_report ~lang ~template ~flavour_opt ~overlays y =
let flavour =
match flavour_opt with
| Some name -> (
@@ -573,7 +676,7 @@ let table_report ~template ~flavour_opt ~overlays y =
exit 2
| Ok src -> (
let days = resolved_year_days ~overlays y in
- let v = Colitur_render.View.of_days ~lang:(Lazy.force default_lang) ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days in
+ let v = Colitur_render.View.of_days ~lang ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days in
match Colitur_render.Template.render_string ~flavour src v with
| Error e ->
(* The template is user input; a parse failure is reported with the
@@ -760,7 +863,7 @@ let index_html ~from_y ~to_y =
</body></html>\n";
Buffer.contents b
-let publish_report ~from_y ~to_y ~out ~overlays ~dtstamp ~prune =
+let publish_report ~lang ~from_y ~to_y ~out ~overlays ~dtstamp ~prune =
check_dtstamp dtstamp;
if from_y > to_y then begin
Printf.eprintf "colitur: --from %d is after --to %d\n" from_y to_y;
@@ -792,7 +895,7 @@ let publish_report ~from_y ~to_y ~out ~overlays ~dtstamp ~prune =
in
for y = from_y to to_y do
let days = resolved_year_days ~overlays y in
- let v = Colitur_render.View.of_days ~lang:(Lazy.force default_lang) ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days in
+ let v = Colitur_render.View.of_days ~lang ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days in
let ys = string_of_int y in
emit ("ef/" ^ ys ^ ".json") (Colitur_render.Emit_json.year v);
emit ("ef/" ^ ys ^ ".csv") (Colitur_render.Emit_csv.year v);
@@ -805,7 +908,7 @@ let publish_report ~from_y ~to_y ~out ~overlays ~dtstamp ~prune =
(fun d ->
let iso = D.to_iso8601 d.Colitur_kernel.Liturgical_day.date in
let mm = String.sub iso 5 2 and dd = String.sub iso 8 2 in
- let one = Colitur_render.View.of_days ~lang:(Lazy.force default_lang) ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y [ d ] in
+ let one = Colitur_render.View.of_days ~lang ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y [ d ] in
emit (Printf.sprintf "ef/%s/%s/%s.json" ys mm dd) (Colitur_render.Emit_json.year one))
days
done;
@@ -866,20 +969,26 @@ usage:
colitur temporal <year> the temporal cycle, one line per day
colitur day <year> the resolved day identity, one line per day
colitur readings <year> the Mass reading citations, one line per day
- colitur day|readings <year> --overlay FILE [--overlay FILE ...]
+ colitur day|readings <year> [--overlay FILE ...] [--lang CODE|FILE] [--raw]
colitur emit --format csv|json|sexp|xml|ics --from Y --to Y
- [--overlay FILE ...] [--dtstamp S]
+ [--overlay FILE ...] [--dtstamp S] [--lang CODE|FILE] [--raw]
render a resolved year range through one of five emitters
colitur table --year Y --template FILE [--flavour X] [--overlay FILE ...]
+ [--lang CODE|FILE] [--raw]
colitur render --template FILE --year Y [--flavour X] [--overlay FILE ...]
+ [--lang CODE|FILE] [--raw]
compute year Y and render it through FILE, a logic-less
Mustache-family template; table and render are the same
operation, two names (see "rendering" below)
colitur publish --from Y --to Y --out DIR [--overlay FILE ...] [--prune]
- [--dtstamp S]
+ [--dtstamp S] [--lang CODE|FILE] [--raw]
write the static tree: per-year csv/json/xml/ics, one JSON
file per day, the schema and a generated index (see
"publish" below)
+ colitur lang --list which language files this build can find
+ colitur lang --dump CODE a language's full key set, in INI form, on stdout
+ colitur lang --check FILE what a language file is missing, and any typo'd key
+ colitur config --show every setting, its value and where it came from
colitur new-overlay print a starter overlay file to stdout
colitur convert FILE.ini flat INI overlay -> S-expression, on stdout
colitur check FILE ... load an overlay, say what it does, exit 2 if not
@@ -890,14 +999,21 @@ usage:
31 December of that year, not a liturgical year.
output formats:
- day date weekday season week slug rank colour [+commemoration ...]
+ day date weekday season week slug rank colour [+commemoration ...] [name]
2026-04-05 sunday paschaltide 1 ef-easter-sunday class-1 white
- readings date slug | Epistle | Gospel
+ readings date slug | Epistle | Gospel [| name]
2026-12-25 ef-nativity | Heb 1:1-12 | John 1:1-14
A citation contains spaces, so readings uses " | " between its fields while
day stays space-separated; that is why they are separate commands rather
- than extra columns.
+ than extra columns. The same reasoning is why the resolved display NAME,
+ present by default (see "naming" below), is appended as the LAST field
+ rather than substituted for the slug shown above: a name may itself
+ contain spaces, and inserting it earlier in the row would break every
+ fixed-position field that follows it. It is present only when it differs
+ from the slug already shown -- under --raw, or any language with no entry
+ for that particular day, the trailing field is simply absent, which is
+ what makes --raw byte-identical to this program's pre-naming output.
emit one schema (season, week, slug, rank, colour, subject, names,
citations, commemorations), rendered five ways: csv (RFC 4180,
@@ -942,6 +1058,44 @@ overlays:
(Nth_weekday (month M) (nth N) (weekday W)) with N negative to
count from the end of the month.
+naming:
+ --lang CODE|FILE applies to `day`, `readings`, `emit`, `table`, `render`
+ and `publish`. A CODE (e.g. `la`, `en`) is looked up as
+ <lang-dir>/CODE.ini; a value containing '/' or ending ".ini" is
+ read as a literal path instead. Default `la`, overridable by a
+ config file (see below). An unknown language is a hard ERROR
+ naming what is available (try `colitur lang --list`), never a
+ silent fallback to Latin -- a booklet quietly printed in the
+ wrong language is worse than one that refuses to print. A
+ language file may declare `fallback = CODE` in its [meta]
+ section, so a partial translation shows its fallback language
+ for the keys it does not itself carry, rather than bare slugs.
+
+ --raw restores every command's pre-naming output: `name` (and every
+ other localised field `emit`'s schema carries) equals the bare
+ machine slug, exactly as if no language had ever been resolved.
+ This is not a special case threaded through the naming code --
+ it is `colitur lang`'s own identity table, under which every
+ lookup echoes its key back unchanged.
+
+ colitur lang --list which language files this build can find, and
+ each one's own declared fallback, if any.
+ colitur lang --dump CODE the named language's full key set, in INI
+ form, on stdout -- a starting point for a new
+ translation, or a way to diff two versions of
+ one.
+ colitur lang --check FILE what a language file is MISSING (a real slug
+ with no entry) and, separately, any entry
+ naming a slug that does not exist at all (a
+ typo, silently dead otherwise) -- exits 1 if
+ anything is unknown, so it fits a Makefile or
+ a pre-commit hook.
+ colitur config --show every effective setting -- lang, overlay,
+ template, format -- its resolved value, and
+ where it came from: `flag`, `config` or
+ `default`. See colitur-config(5) for the
+ config file's location and full precedence.
+
rendering:
--template FILE (required on `table`/`render`) is a logic-less Mustache-
family template: {{placeholder}}, {{#section}}...{{/section}},
@@ -953,10 +1107,11 @@ rendering:
slug, rank, colour, subject, names, citations, commemorations),
reshaped into a booklet (`days`) and a month grid (`weeks`, with
padding cells for the leading/trailing blanks); see
- colitur-templates(5) for the full field list, the syntax and the
- scope-shadowing hazard (an inner key silently loses to an outer
- key of the same name -- a bare {{name.la}} inside a day resolves
- to the enclosing MONTH's name, not the day's own).
+ colitur-templates(5) for the full field list, the syntax, and
+ the one remaining scope hazard (`num` -- both a month and a week
+ carry it, and only the innermost one is ever meant). `name` is a
+ plain resolved string, not a lang-keyed object, so it carries no
+ equivalent hazard of its own.
--flavour X selects how interpolated VALUES are escaped (never the
template's own literal markup, which is the author's). One of:
@@ -1033,10 +1188,11 @@ exit status:
Reading references only (e.g. "Jn 3:16"); never scripture text.
See colitur(1) for the full description and the sources it computes against,
-colitur-overlay(5) for the overlay file format in full, and
+colitur-overlay(5) for the overlay file format in full,
colitur-templates(5) for the template format in full -- the syntax, the
-scope-shadowing hazard, the six flavours' escaping, and the full view-model
-field reference.|}
+remaining scope hazard, the six flavours' escaping, and the full view-model
+field reference -- and colitur-config(5) for the config file's location and
+precedence in full.|}
let print_help () =
print_endline help_text;
@@ -1047,7 +1203,8 @@ let usage () =
"colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur \
readings <year> | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template \
FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | \
- colitur check FILE | colitur new-overlay (try: colitur --help)";
+ colitur lang --list|--dump CODE|--check FILE | colitur config --show | colitur check FILE | \
+ colitur new-overlay (try: colitur --help)";
exit 2
let with_year ys f =
@@ -1077,9 +1234,14 @@ let with_year ys f =
`table`/`render` take one year and one template file, never a range or a
repeatable list. *)
(* [out] (Task 12, `publish`) is single-valued like [format]/[year]/etc.
- [prune] is the one plain boolean flag in this whole record -- every other
- field here takes a value, but [--prune] does not, so it cannot reuse the
- `"--flag" :: v :: rest` shape the value-taking flags share below. *)
+ [prune] is a plain boolean flag -- every other field up to here takes a
+ value, but [--prune] does not, so it cannot reuse the `"--flag" :: v ::
+ rest` shape the value-taking flags share below. *)
+(* [lang]/[raw] (Task 6): [lang] is single-valued like [format]/[template]/
+ etc (a language CODE or a file path); [raw] is a second plain boolean,
+ the same shape as [prune]. [dump]/[check]/[list]/[show] (Task 7,
+ `colitur lang`/`colitur config`) follow the identical two shapes --
+ [dump]/[check] each take one value, [list]/[show] are bare. *)
type parsed_args = {
overlays : string list;
format : string option;
@@ -1091,6 +1253,12 @@ type parsed_args = {
flavour : string option;
out : string option;
prune : bool;
+ lang : string option;
+ raw : bool;
+ dump : string option;
+ check : string option;
+ list : bool;
+ show : bool;
positional : string list;
}
@@ -1116,6 +1284,15 @@ let parse_args argv =
| "--out" :: v :: rest -> go { acc with out = Some v } rest
| [ "--out" ] -> Error "--out needs a directory path"
| "--prune" :: rest -> go { acc with prune = true } rest
+ | "--lang" :: v :: rest -> go { acc with lang = Some v } rest
+ | [ "--lang" ] -> Error "--lang needs a language code or file path"
+ | "--raw" :: rest -> go { acc with raw = true } rest
+ | "--dump" :: v :: rest -> go { acc with dump = Some v } rest
+ | [ "--dump" ] -> Error "--dump needs a language code"
+ | "--check" :: v :: rest -> go { acc with check = Some v } rest
+ | [ "--check" ] -> Error "--check needs a file path"
+ | "--list" :: rest -> go { acc with list = true } rest
+ | "--show" :: rest -> go { acc with show = true } rest
(* The recognised bare flags pass through as positional words for the
dispatch below to match; anything else beginning with '-' is rejected
rather than silently treated as a command or a year. *)
@@ -1128,7 +1305,8 @@ let parse_args argv =
in
go
{ overlays = []; format = None; from_y = None; to_y = None; dtstamp = None; year = None;
- template = None; flavour = None; out = None; prune = false; positional = [] }
+ template = None; flavour = None; out = None; prune = false; lang = None; raw = false;
+ dump = None; check = None; list = false; show = false; positional = [] }
argv
(* Sibling to [reject_overlays_for]: `emit`'s own four flags have no meaning
@@ -1186,6 +1364,29 @@ let reject_publish_flags_for cmd ~out ~prune =
exit 2
end
+(* Sibling to the four rejectors above: `--lang`/`--raw` (Task 6) have
+ meaning only on the commands that resolve display names --
+ `day`/`readings`/`emit`/`table`/`render`/`publish` -- and not on
+ `easter`/`temporal` (read no sanctoral data, exactly like `--overlay`),
+ `check`/`convert`/`new-overlay` (operate on overlay files, not a
+ rendered calendar), or `lang`/`config` themselves (which take their OWN
+ flags, `--dump`/`--check`/`--list`/`--show`, disjoint from these). *)
+let reject_lang_for cmd ~lang ~raw =
+ if lang <> None || raw then begin
+ Printf.eprintf "colitur: --lang/--raw have no effect on `%s`; refusing rather than ignoring them\n" cmd;
+ exit 2
+ end
+
+(* Sibling again: `--dump`/`--check`/`--list`/`--show` (Task 7) belong only
+ to `colitur lang` and `colitur config` respectively. *)
+let reject_lang_subcommand_flags_for cmd ~dump ~check ~list ~show =
+ if dump <> None || check <> None || list || show then begin
+ Printf.eprintf
+ "colitur: --dump/--check/--list/--show have no effect on `%s`; refusing rather than ignoring them\n"
+ cmd;
+ exit 2
+ end
+
(* `colitur check FILE...` -- load a user overlay, apply it to the real
shipped calendar, and say what it did, without printing a year of output.
@@ -1290,6 +1491,184 @@ let convert_report path =
print_string sexp;
exit 0)
+(* `colitur lang --list` -- what language files this build can find, from
+ [lang_dir ()], the same probe [load_lang] itself uses. Each is opened and
+ parsed (not merely listed by filename) so a malformed file is flagged
+ here rather than only failing later when someone actually tries to use
+ it. *)
+let lang_list () =
+ let dir = lang_dir () in
+ match Sys.readdir dir with
+ | exception Sys_error _ ->
+ Printf.eprintf "colitur: no language directory at %s\n" dir;
+ exit 2
+ | files ->
+ Array.sort compare files;
+ Array.iter
+ (fun f ->
+ if Filename.check_suffix f ".ini" then begin
+ let code = Filename.remove_extension f in
+ match read_file (Filename.concat dir f) with
+ | Ok t -> (
+ match Colitur_naming.Lang.of_string t with
+ | Ok l ->
+ Printf.printf "%-6s %s\n" code
+ (match Colitur_naming.Lang.fallback_code l with
+ | Some fb -> "(falls back to " ^ fb ^ ")"
+ | None -> "")
+ | Error e -> Printf.printf "%-6s (unreadable: %s)\n" code e)
+ | Error e -> Printf.printf "%-6s (unreadable: %s)\n" code e
+ end)
+ files
+
+(* Every slug the engine can actually emit -- the observed office AND every
+ commemoration/transfer, exactly the four-field walk
+ test/test_lang_coverage.ml's own [slugs_of_day] performs -- over the same
+ 2020-2045 window that test measures against. This is [colitur lang
+ --check]'s own reference set: a language file is judged against what the
+ engine can really produce, not against an arbitrarily chosen sample. *)
+let all_known_slugs () =
+ match load_ef_data () with
+ | Error msg ->
+ Printf.eprintf "colitur: %s\n" msg;
+ exit 2
+ | Ok (layer, lectionary, commons) ->
+ let context = Rite_ef.context ~lectionary ~commons in
+ let slug (c : _ Colitur_kernel.Celebration.t) =
+ Colitur_kernel.Slug.to_string c.Colitur_kernel.Celebration.slug
+ in
+ let slugs_of_day
+ (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t) =
+ slug d.Colitur_kernel.Liturgical_day.observed
+ :: List.map (fun (c, _priv) -> slug c) d.Colitur_kernel.Liturgical_day.commemorations
+ @ (match d.Colitur_kernel.Liturgical_day.transferred_in with
+ | None -> []
+ | Some c -> [ slug c ])
+ @ List.map (fun (c, _date) -> slug c) d.Colitur_kernel.Liturgical_day.transferred_out
+ in
+ let seen = Hashtbl.create 1024 in
+ for y = 2020 to 2045 do
+ Array.iter
+ (fun d -> List.iter (fun s -> Hashtbl.replace seen s ()) (slugs_of_day d))
+ (Colitur_kernel.Calendar.year context layer y)
+ done;
+ Hashtbl.fold (fun k () acc -> k :: acc) seen [] |> List.sort compare
+
+(* `colitur lang --dump CODE` -- the named language's full key set, in INI
+ form, on stdout for redirection. [Lang.keys] already returns every
+ (section-qualified key, value) pair SORTED as one flat list (lang.mli):
+ because every key sharing a section also shares that section's own
+ string prefix, and no two section names are a prefix of one another, the
+ sort keeps every section's own keys contiguous -- so a single pass
+ emitting a fresh `[section]` header each time the prefix changes
+ reconstructs proper INI blocks, in a FIXED (alphabetical) order, with no
+ second grouping step. Two dumps of the same table are therefore
+ byte-identical, which is what makes `--dump` diffable across two
+ versions of a translation. *)
+let lang_dump code =
+ let path = Filename.concat (lang_dir ()) (code ^ ".ini") in
+ match read_file path with
+ | Error _ ->
+ Printf.eprintf "colitur: no language %S (looked in %s); try: colitur lang --list\n" code
+ (lang_dir ());
+ exit 2
+ | Ok text -> (
+ match Colitur_naming.Lang.of_string text with
+ | Error msg ->
+ Printf.eprintf "colitur: %s: %s\n" path msg;
+ exit 2
+ | Ok t ->
+ Printf.printf "[meta]\nlang = %s\n" (Colitur_naming.Lang.code t);
+ (match Colitur_naming.Lang.fallback_code t with
+ | Some fb -> Printf.printf "fallback = %s\n" fb
+ | None -> ());
+ let cur = ref "" in
+ List.iter
+ (fun (qk, v) ->
+ match String.index_opt qk '.' with
+ | None -> ()
+ | Some i ->
+ let section = String.sub qk 0 i in
+ let key = String.sub qk (i + 1) (String.length qk - i - 1) in
+ if section <> !cur then begin
+ print_newline ();
+ Printf.printf "[%s]\n" section;
+ cur := section
+ end;
+ Printf.printf "%s = %s\n" key v)
+ (Colitur_naming.Lang.keys t))
+
+(* `colitur lang --check FILE` -- the difference between "you may write a
+ language file" and "you can": without this, a translator has to
+ reverse-engineer the key set from source. Reports what is MISSING
+ (a real slug the file has no entry for) AND what is UNKNOWN (a
+ [celebration] key matching no real slug at all -- a typo, or a slug from
+ a version of colitur this file was not written against) -- a key
+ matching nothing is silently dead, and its author would otherwise never
+ learn why their translation does not appear. Exits 2 only on UNKNOWN: a
+ partial file (some slugs missing) is exactly the shippable, in-progress
+ state [Lang.with_fallback] exists for, but a typo naming nothing is
+ always worth flagging as a failure, the same "never silently dead"
+ standard [Config.unknown_keys] holds a config file to. *)
+let lang_check path =
+ match read_file path with
+ | Error msg ->
+ Printf.eprintf "colitur: %s\n" msg;
+ exit 2
+ | Ok text -> (
+ match Colitur_naming.Lang.of_string text with
+ | Error msg ->
+ Printf.eprintf "colitur: %s: %s\n" path msg;
+ exit 2
+ | Ok t ->
+ let known = all_known_slugs () in
+ let have =
+ List.filter_map
+ (fun (k, _) ->
+ match String.index_opt k '.' with
+ | Some i when String.sub k 0 i = "celebration" ->
+ Some (String.sub k (i + 1) (String.length k - i - 1))
+ | _ -> None)
+ (Colitur_naming.Lang.keys t)
+ in
+ let missing = List.filter (fun s -> not (List.mem s have)) known in
+ let unknown = List.filter (fun s -> not (List.mem s known)) have in
+ List.iter (fun s -> Printf.printf "missing: %s\n" s) (List.sort compare missing);
+ List.iter (fun s -> Printf.printf "unknown slug: %s\n" s) (List.sort compare unknown);
+ Printf.printf "%s: %d of %d celebrations named, %d missing, %d unknown\n" path
+ (List.length known - List.length missing)
+ (List.length known) (List.length missing) (List.length unknown);
+ if unknown <> [] then exit 1)
+
+(* `colitur config --show` -- each effective setting, its resolved value,
+ and where it came from (`flag`/`config`/`default`), via
+ [Config.resolve]. There is deliberately no separate "provenance"
+ function: [resolve] already returns the source alongside the value
+ (config.mli), and a second entry point recomputing it independently
+ would let the two disagree. [lang]/[template]/[format] are scalars, each
+ resolved the same way `colitur day`/`table`/`emit` would resolve them
+ given these SAME command-line flags (so `config --show --lang fr` shows
+ exactly what a real `--lang fr` run would use); [overlay] is a list, so
+ it has no single "value" to resolve -- shown as one line per effective
+ entry instead, with its own source. *)
+let config_show ~lang_flag ~template_flag ~format_flag ~overlays_flag config =
+ let cpath = config_path () in
+ Printf.printf "config file: %s (%s)\n" cpath
+ (if cpath <> "" && Sys.file_exists cpath then "exists" else "not found");
+ let scalar name flag cfgval default =
+ let v, src = Colitur_naming.Config.resolve ~flag ~config:cfgval ~default in
+ Printf.printf "%-10s %-24s (%s)\n" name v src
+ in
+ scalar "lang" lang_flag (Colitur_naming.Config.lang config) "la";
+ scalar "template" template_flag (Colitur_naming.Config.template config) "(none)";
+ scalar "format" format_flag (Colitur_naming.Config.format config) "(none)";
+ (match overlays_flag with
+ | _ :: _ as l -> List.iter (fun o -> Printf.printf "%-10s %-24s (%s)\n" "overlay" o "flag") l
+ | [] -> (
+ match Colitur_naming.Config.overlays config with
+ | [] -> Printf.printf "%-10s %-24s (%s)\n" "overlay" "(none)" "default"
+ | l -> List.iter (fun o -> Printf.printf "%-10s %-24s (%s)\n" "overlay" o "config") l))
+
(* `colitur new-overlay` -- a starter file on stdout, for redirection.
Deliberately printed rather than written: the user picks the path, and a
command that creates files where it likes is a worse citizen. Every value is
@@ -1350,22 +1729,51 @@ let () =
| Error msg ->
Printf.eprintf "colitur: %s\n" msg;
usage ()
- | Ok { overlays; format; from_y; to_y; dtstamp; year; template; flavour; out; prune; positional } -> (
+ | Ok { overlays; format; from_y; to_y; dtstamp; year; template; flavour; out; prune; lang;
+ raw; dump; check; list; show; positional } -> (
let reject_emit = reject_emit_flags_for ~format ~from_y ~to_y ~dtstamp in
let reject_table = reject_table_flags_for ~year ~template ~flavour in
let reject_publish = reject_publish_flags_for ~out ~prune in
+ let reject_lang = reject_lang_for ~lang ~raw in
+ let reject_lang_sub = reject_lang_subcommand_flags_for ~dump ~check ~list ~show in
+ (* Loaded once, unconditionally: a config file the user wrote and
+ colitur cannot honour (missing HOME aside, [load_config] treats
+ that as "no config" rather than an error) is worth surfacing on
+ EVERY invocation, `--help` included, not only the commands that
+ happen to consult it -- the same "never silently ignored"
+ discipline [COLITUR_DATA_DIR] already gets. *)
+ let config = load_config () in
+ (* Resolved only inside the branches that actually consume it, as a
+ thunk rather than eagerly here: [load_lang] can [exit 2] (an
+ unknown language, a malformed file), and a command that never asked
+ for naming at all (`easter`, `check`, ...) must not be able to fail
+ on account of a language it never uses -- those commands reject
+ [--lang]/[--raw] outright instead, via [reject_lang] above. *)
+ let resolved_lang () = load_lang ~raw ~flag:lang ~config:(Colitur_naming.Config.lang config) in
+ (* Config supplies a DEFAULT overlay list only when NO --overlay was
+ given at all -- not merged with a partial CLI list -- so the
+ precedence stays exactly flag > config > default, the same
+ direction every other setting resolves in, rather than a list
+ merge whose ordering nothing documents. *)
+ let effective_overlays =
+ if overlays = [] then Colitur_naming.Config.overlays config else overlays
+ in
match positional with
| [ ("-h" | "--help" | "help") ] ->
reject_overlays_for "--help" overlays;
reject_emit "--help";
reject_table "--help";
reject_publish "--help";
+ reject_lang "--help";
+ reject_lang_sub "--help";
print_help ()
| [ ("-V" | "--version" | "version") ] ->
reject_overlays_for "--version" overlays;
reject_emit "--version";
reject_table "--version";
reject_publish "--version";
+ reject_lang "--version";
+ reject_lang_sub "--version";
print_endline version;
exit 0
| [ "easter"; ys ] ->
@@ -1373,46 +1781,110 @@ let () =
reject_emit "easter";
reject_table "easter";
reject_publish "easter";
+ reject_lang "easter";
+ reject_lang_sub "easter";
with_year ys easter_report
| [ "temporal"; ys ] ->
reject_overlays_for "temporal" overlays;
reject_emit "temporal";
reject_table "temporal";
reject_publish "temporal";
+ reject_lang "temporal";
+ reject_lang_sub "temporal";
with_year ys temporal_report
| "check" :: (_ :: _ as files) ->
reject_overlays_for "check" overlays;
reject_emit "check";
reject_table "check";
reject_publish "check";
+ reject_lang "check";
+ reject_lang_sub "check";
check_report files
| [ "convert"; path ] ->
reject_overlays_for "convert" overlays;
reject_emit "convert";
reject_table "convert";
reject_publish "convert";
+ reject_lang "convert";
+ reject_lang_sub "convert";
convert_report path
| [ "new-overlay" ] ->
reject_overlays_for "new-overlay" overlays;
reject_emit "new-overlay";
reject_table "new-overlay";
reject_publish "new-overlay";
+ reject_lang "new-overlay";
+ reject_lang_sub "new-overlay";
print_string new_overlay_template;
exit 0
+ | [ "lang" ] -> (
+ reject_overlays_for "lang" overlays;
+ reject_emit "lang";
+ reject_table "lang";
+ reject_publish "lang";
+ reject_lang "lang";
+ if show then begin
+ Printf.eprintf "colitur: --show has no effect on `lang`; refusing rather than ignoring it\n";
+ exit 2
+ end;
+ match (list, dump, check) with
+ | true, None, None -> lang_list ()
+ | false, Some code, None -> lang_dump code
+ | false, None, Some path -> lang_check path
+ | false, None, None ->
+ Printf.eprintf "colitur: lang requires one of --list, --dump CODE or --check FILE\n";
+ exit 2
+ | _ ->
+ Printf.eprintf "colitur: lang takes only one of --list, --dump CODE or --check FILE\n";
+ exit 2)
+ | [ "config" ] ->
+ (* Unlike every other subcommand's own rejector, `config --show`
+ deliberately ACCEPTS --lang/--template/--format/--overlay: they
+ are the very settings it previews the resolution of (so
+ `config --show --lang fr` reports exactly what a real `--lang
+ fr` run on any other command would resolve to), so none of
+ [reject_lang]/[reject_overlays_for]/the format half of
+ [reject_emit] apply here. Everything with no meaning for a
+ config preview is still refused, not silently ignored. *)
+ reject_table "config";
+ reject_publish "config";
+ if from_y <> None || to_y <> None || dtstamp <> None then begin
+ Printf.eprintf
+ "colitur: --from/--to/--dtstamp have no effect on `config`; refusing rather than ignoring them\n";
+ exit 2
+ end;
+ if raw then begin
+ Printf.eprintf "colitur: --raw has no effect on `config`; refusing rather than ignoring it\n";
+ exit 2
+ end;
+ if dump <> None || check <> None || list then begin
+ Printf.eprintf
+ "colitur: --dump/--check/--list have no effect on `config`; refusing rather than ignoring them\n";
+ exit 2
+ end;
+ if not show then begin
+ 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
| [ "day"; ys ] ->
reject_emit "day";
reject_table "day";
reject_publish "day";
- with_year ys (day_report ~overlays)
+ reject_lang_sub "day";
+ with_year ys (day_report ~lang:(resolved_lang ()) ~overlays:effective_overlays)
| [ "readings"; ys ] ->
reject_emit "readings";
reject_table "readings";
reject_publish "readings";
- with_year ys (readings_report ~overlays)
+ reject_lang_sub "readings";
+ with_year ys (readings_report ~lang:(resolved_lang ()) ~overlays:effective_overlays)
| [ "emit" ] -> (
reject_table "emit";
reject_publish "emit";
- match format with
+ reject_lang_sub "emit";
+ match (match format with Some f -> Some f | None -> Colitur_naming.Config.format config) with
| None ->
Printf.eprintf "colitur: emit requires --format csv|json|sexp|xml|ics\n";
exit 2
@@ -1422,12 +1894,16 @@ let () =
Printf.eprintf "colitur: emit requires --from YEAR and --to YEAR\n";
exit 2
| Some from_ys, Some to_ys ->
+ let lang_t = resolved_lang () in
with_year from_ys (fun from_y ->
- with_year to_ys (fun to_y -> emit_report ~format ~overlays ~dtstamp ~from_y ~to_y))))
+ with_year to_ys (fun to_y ->
+ emit_report ~lang:lang_t ~format ~overlays:effective_overlays ~dtstamp ~from_y
+ ~to_y))))
| [ ("table" | "render") as cmd ] -> (
reject_emit cmd;
reject_publish cmd;
- match template with
+ reject_lang_sub cmd;
+ match (match template with Some t -> Some t | None -> Colitur_naming.Config.template config) with
| None ->
Printf.eprintf "colitur: %s requires --year YEAR and --template FILE\n" cmd;
exit 2
@@ -1436,11 +1912,15 @@ let () =
| None ->
Printf.eprintf "colitur: %s requires --year YEAR and --template FILE\n" cmd;
exit 2
- | Some ys -> with_year ys (fun y -> table_report ~template ~flavour_opt:flavour ~overlays y)
+ | Some ys ->
+ let lang_t = resolved_lang () in
+ with_year ys (fun y ->
+ table_report ~lang:lang_t ~template ~flavour_opt:flavour ~overlays:effective_overlays y)
))
| [ "publish" ] -> (
reject_table "publish";
reject_format_for "publish" format;
+ reject_lang_sub "publish";
match out with
| None ->
Printf.eprintf "colitur: publish requires --out DIR\n";
@@ -1451,6 +1931,7 @@ let () =
Printf.eprintf "colitur: publish requires --from YEAR and --to YEAR\n";
exit 2
| Some from_ys, Some to_ys ->
+ let lang_t = resolved_lang () in
with_year from_ys (fun from_y ->
with_year to_ys (fun to_y ->
(* [publish_report] writes many files across a whole
@@ -1468,7 +1949,10 @@ let () =
(stdlib, not the Unix module) wraps the same
underlying errno in [Sys_error] instead -- both
are real on this path, so both are caught. *)
- try publish_report ~from_y ~to_y ~out ~overlays ~dtstamp ~prune with
+ try
+ publish_report ~lang:lang_t ~from_y ~to_y ~out ~overlays:effective_overlays
+ ~dtstamp ~prune
+ with
| Unix.Unix_error (e, fn, arg) ->
Printf.eprintf "colitur: %s: %s: %s\n" fn arg (Unix.error_message e);
exit 2
diff --git a/lib/render/emit_ics.ml b/lib/render/emit_ics.ml
index 4e06eae..b212930 100644
--- a/lib/render/emit_ics.ml
+++ b/lib/render/emit_ics.ml
@@ -57,9 +57,18 @@ let event b ~rite ~dtstamp d =
if iso <> "" then begin
(* [name] is the view's own resolved display string (view.ml) -- no
further fallback needed here: under [Lang.raw] it already equals
- [slug], which is what a miss used to require picking by hand. *)
+ [slug], which is what a miss used to require picking by hand.
+
+ [rank_name]/[colour_name], not the bare [rank]/[colour]: those two
+ carry the kernel's own unlocalised strings ("class-1", "white"),
+ exactly the raw-slug shape [name] itself moved away from -- SUMMARY
+ is a human-facing calendar entry, and printing "(class-1, white)"
+ beside a properly resolved name (e.g. "II classis, albus") was the
+ one field this emitter had not yet been updated to localise; the
+ JSON/CSV/XML emitters already expose both pairs and a template author
+ already has to choose the localised one deliberately. *)
let name = s d "name" in
- let summary = Printf.sprintf "%s (%s, %s)" name (s d "rank") (s d "colour") in
+ let summary = Printf.sprintf "%s (%s, %s)" name (s d "rank_name") (s d "colour_name") in
let desc =
String.concat "\n"
(List.filter (fun x -> x <> "")
diff --git a/man/colitur.1 b/man/colitur.1
index 005cc0a..b9ce1d1 100644
--- a/man/colitur.1
+++ b/man/colitur.1
@@ -9,8 +9,9 @@ colitur \- deterministic liturgical calendar and lectionary engine (Roman rite,
.B colitur
.BR day | readings
.I YEAR
-.BI \-\-overlay " FILE"
-.RI [ ... ]
+.RB [ \-\-overlay " FILE" " ...]"
+.RB [ \-\-lang " CODE\(brFILE" ]
+.RB [ \-\-raw ]
.br
.B colitur
.B emit
@@ -19,6 +20,8 @@ colitur \- deterministic liturgical calendar and lectionary engine (Roman rite,
.BI \-\-to " YEAR"
.RB [ \-\-overlay " FILE" " ...]"
.RB [ \-\-dtstamp " STAMP" ]
+.RB [ \-\-lang " CODE\(brFILE" ]
+.RB [ \-\-raw ]
.br
.B colitur
.BR table | render
@@ -26,6 +29,8 @@ colitur \- deterministic liturgical calendar and lectionary engine (Roman rite,
.BI \-\-template " FILE"
.RB [ \-\-flavour " FLAVOUR" ]
.RB [ \-\-overlay " FILE" " ...]"
+.RB [ \-\-lang " CODE\(brFILE" ]
+.RB [ \-\-raw ]
.br
.B colitur
.B publish
@@ -35,6 +40,16 @@ colitur \- deterministic liturgical calendar and lectionary engine (Roman rite,
.RB [ \-\-overlay " FILE" " ...]"
.RB [ \-\-prune ]
.RB [ \-\-dtstamp " STAMP" ]
+.RB [ \-\-lang " CODE\(brFILE" ]
+.RB [ \-\-raw ]
+.br
+.B colitur
+.B lang
+.RB { \-\-list | \-\-dump " CODE" | \-\-check " FILE" }
+.br
+.B colitur
+.B config
+.B \-\-show
.br
.B colitur
.BR \-h | \-\-help
@@ -145,6 +160,41 @@ validate a calendar against the rubrics, and cannot: see
.B OVERLAYS
below.
.TP
+.B lang \-\-list
+List the language files this build can find, and each one's own declared
+.BR fallback ,
+if any.
+.TP
+.BI lang " " \-\-dump " CODE"
+Print the named language's full key set, in INI form, to standard output
+\(em a starting point for a new translation, or a way to diff two versions
+of one. See
+.B NAMING
+below.
+.TP
+.BI lang " " \-\-check " FILE"
+Report what a language file is
+.B MISSING
+(a real slug the engine can produce with no entry for it) and, separately,
+any entry naming a slug that does not exist at all \(em a typo, which would
+otherwise be silently dead. Exits
+.B 1
+if anything is unknown, so it composes into a Makefile or a pre\-commit
+hook, the same way
+.B check
+does for an overlay. See
+.B NAMING
+below.
+.TP
+.B config \-\-show
+Print every effective setting \(em
+.IR lang ", " overlay ", " template ", " format
+\(em its resolved value, and where it came from:
+.BR flag ", " config " or " default .
+See
+.BR colitur\-config (5)
+for the config file's location and precedence in full.
+.TP
.BI \-\-overlay " FILE"
Apply a user calendar on top of the shipped one. Repeatable and ordered;
.BR day ", " readings ", " emit ", " table ", " render " and " publish
@@ -222,6 +272,31 @@ own extension. See
.B RENDERING
below.
.TP
+.BI \-\-lang " CODE\(brFILE"
+.RB ( day ", " readings ", " emit ", " table ", " render " and " publish " only)"
+Resolve display names through this language instead of the default.
+.I CODE
+(e.g.
+.BR la ", " en )
+is looked up as a file in the installed language directory;
+a value containing
+.B /
+or ending
+.B .ini
+is read as a literal path instead. Default
+.BR la ,
+overridable by a config file. An unknown language is a hard error naming
+what is available, never a silent fallback to Latin. See
+.B NAMING
+below.
+.TP
+.B \-\-raw
+.RB ( day ", " readings ", " emit ", " table ", " render " and " publish " only)"
+Restore the pre\-naming output: every display name equals its bare machine
+slug. See
+.B NAMING
+below.
+.TP
.BR \-h ", " \-\-help
Print a usage summary to standard output and exit 0.
.TP
@@ -231,7 +306,7 @@ Print the version and exit 0.
.SS day
.RS
.nf
-date weekday season week slug rank colour [+commemoration ...]
+date weekday season week slug rank colour [+commemoration ...] [name]
.fi
.RE
.PP
@@ -239,19 +314,40 @@ Space\-separated, with one
.BI + slug
suffix per admitted commemoration. A
.B \-
-in the week column means the day carries no week number.
+in the week column means the day carries no week number. The resolved
+display
+.I name
+(see
+.B NAMING
+below) is appended LAST, after any commemorations, rather than substituted
+for
+.I slug
+above: a name may itself contain spaces, and inserting it earlier in the row
+would break every fixed\-position field that follows it \(em the same
+mechanical reason
+.B readings
+is a separate command rather than extra columns on
+.B day
+(see below). It is present only when it differs from
+.I slug
+already shown; under
+.BR \-\-raw ,
+or a language with no entry for that particular day, the trailing field is
+simply absent \(em not merely empty \(em which is what makes
+.B \-\-raw
+byte\-identical to this program's pre\-naming output.
.RS
.nf
-2026\-04\-05 sunday paschaltide 1 ef\-easter\-sunday class\-1 white
-2026\-11\-02 monday time\-after\-pentecost 23 commemoration\-of\-all\-souls class\-1 black
-2057\-03\-26 monday lent 3 annunciation\-of\-the\-blessed\-virgin\-mary class\-1 white +ef\-lent\-3\-monday
+2026\-04\-05 sunday paschaltide 1 ef\-easter\-sunday class\-1 white Dominica Resurrectionis
+2026\-11\-02 monday time\-after\-pentecost 23 commemoration\-of\-all\-souls class\-1 black In Commemoratione Omnium Fidelium Defunctorum
+2057\-03\-26 monday lent 3 annunciation\-of\-the\-blessed\-virgin\-mary class\-1 white +ef\-lent\-3\-monday In Annuntiatione B. Mariae Virg.
.fi
.RE
.SS readings
.RS
.nf
-date slug | Epistle | Gospel
+date slug | Epistle | Gospel [| name]
.fi
.RE
.PP
@@ -265,12 +361,20 @@ rather than extra columns on
.BR day :
appended there, no field number could recover where the Epistle ended. A
.B \-
-in either citation field means none was resolved.
+in either citation field means none was resolved. The resolved display
+.I name
+is appended as a fourth,
+.RB \(lq " | " \(rq \-delimited
+field on the same "present only when it differs from
+.IR slug "" \(cq
+terms as
+.BR day 's
+own trailing field, above.
.RS
.nf
-2026\-12\-25 ef\-nativity | Heb 1:1\-12 | John 1:1\-14
-2038\-03\-06 sts\-felicitas\-perpetua | Ecclus 51:1\-8, 12 | Matt 13:44\-52
+2026\-12\-25 ef\-nativity | Heb 1:1\-12 | John 1:1\-14 | In Nativitate Domini
+2038\-03\-06 sts\-felicitas\-perpetua | Ecclus 51:1\-8, 12 | Matt 13:44\-52 | Ss. Perpetuae et Felicitatis Mm.
.fi
.RE
.PP
@@ -279,12 +383,17 @@ Both reports are one line per day and ordered by date, so they compose with
.BR awk (1)
and
.BR join (1)
-in the ordinary way.
+in the ordinary way. Pass
+.B \-\-raw
+to restore the pre\-naming byte\-exact output of either report \(em no
+trailing field at all \(em for a script written against it before this
+feature existed.
.SH EMIT
.BI "colitur emit " \-\-format " FMT " \-\-from " YEAR " \-\-to " YEAR"
renders the same resolved day \(em season, week, slug, rank, colour,
-subject, Latin and English names, citations, commemorations \(em through one
-of five emitters, for every day in the inclusive civil\-year range
+subject, the resolved display name in the active language, citations,
+commemorations \(em through one of five emitters, for every day in the
+inclusive civil\-year range
.IR FROM .. TO .
Every emitter consumes one shared view of the data, so all five describe
exactly the same fields.
@@ -300,10 +409,20 @@ behave as expected.
.nf
.B colitur emit \-\-format csv \-\-from 2026 \-\-to 2026 | head \-2
-date,rite,season,week,slug,rank,colour,subject,name_la,name_en,first,gospel,comms
-2026\-01\-01,ef,christmastide,,ef\-circumcision,class\-1,white,temporal,,,Titus 2:11\-15,Luke 2:21,
+date,rite,season,season_name,week,slug,name,weekday,rank,rank_name,colour,colour_name,subject,first,gospel,comms
+2026\-01\-01,ef,christmastide,Tempus Nativitatis,,ef\-circumcision,In Octava Nativitatis Domini,Feria V,class\-1,I classis,white,albus,temporal,Titus 2:11\-15,Luke 2:21,
.fi
.RE
+.PP
+.IR name ", " season_name ", " rank_name " and " colour_name
+are the resolved display strings in the active language (see
+.B NAMING
+below);
+.IR slug ", " season ", " rank " and " colour
+stay the kernel's own unlocalised keys, unaffected by
+.BR \-\-lang / \-\-raw ,
+so a script can key off the stable machine value while a human reads the
+localised one beside it.
.TP
.B json
One JSON object per requested year, concatenated. Shape pinned by
@@ -437,8 +556,9 @@ out of its flavour's escaping.
.PP
The template renders against the same schema
.B emit
-uses (season, week, slug, rank, colour, subject, names, citations,
-commemorations), reshaped for two artefacts from one model: a flat booklet
+uses (season, week, slug, rank, colour, subject, the resolved display name,
+citations, commemorations), reshaped for two artefacts from one model: a
+flat booklet
(the
.B days
list, one entry per day of the year) and a month grid (the
@@ -455,10 +575,24 @@ for the full syntax, the escaping table per flavour, the complete
view\-model field reference, and \(em before writing a template of any
complexity \(em its
.B SCOPE AND LOOKUP
-section: an inner key silently loses to an outer key of the same name (a bare
-.B {{name.la}}
-inside a day resolves to the enclosing MONTH's name, not the day's own),
-which has produced wrong output in this project's own templates.
+section: a
+.B month
+and a
+.B week
+both carry a
+.I num
+field, and a bare
+.B {{num}}
+read from inside
+.B {{#days}}
+silently climbs to the enclosing week's own value, not the month's. A
+similar hazard around
+.B name
+existed before the view model's naming rework and is now unrepresentable
+\(em
+.I name
+is a plain resolved string, with no dotted path left for a partial match to
+fall back through.
.SS Flavours
.BI \-\-flavour
controls how interpolated
@@ -718,6 +852,131 @@ calendar. None of them can vouch for a file you supply. A directive naming a
slug that does not exist prints a warning to standard error and the run
continues, so a typo in a local calendar is visible rather than silent; a file
that fails to load is fatal.
+.SH NAMING
+.TP
+.BI \-\-lang " CODE\(brFILE"
+Resolve every display name \(em a day's
+.IR name ,
+its localised
+.IR weekday / rank_name / colour_name / season_name ,
+each month's own name, and the fixed
+.I term
+vocabulary a template routes through \(em through this language instead of
+the default. Accepted on
+.BR day ", " readings ", " emit ", " table ", " render " and " publish ;
+refused elsewhere, the same discipline
+.B \-\-overlay
+gets.
+.I CODE
+(e.g.
+.BR la ", " en )
+is looked up as
+.IR <lang\-dir> / CODE .ini ;
+a value containing
+.B /
+or ending
+.B .ini
+is read as a literal file path instead. Default
+.BR la ,
+overridable by
+.I lang
+in the config file (see
+.BR colitur\-config (5)),
+itself overridden by
+.BR \-\-lang .
+.PP
+.B An unknown language is a hard error naming what is available, never a
+.B silent fallback to Latin:
+a booklet quietly printed in the wrong language is worse than one that
+refuses to print.
+.RS
+.nf
+
+.B colitur day 2027 \-\-lang xx
+colitur: no language "xx" (looked in .../share/colitur/lang); try: colitur lang \-\-list
+.fi
+.RE
+.PP
+A language file may declare
+.B fallback " = " CODE
+in its
+.I [meta]
+section (both shipped files show the shape;
+.I lang/en.ini
+declares
+.BR "fallback = la" ).
+A slug the active language does not itself name still resolves through the
+fallback chain, so a partial translation is usable from its first line
+rather than only once it is complete.
+.TP
+.B \-\-raw
+Restore every command's pre\-naming output: a day's
+.I name
+(and every other localised field the
+.B emit
+schema carries) equals the bare machine
+.IR slug ,
+exactly as if no language had ever been resolved. This is not a special
+case threaded through the naming code \(em the identity table under
+.RB ( raw )
+is an ordinary language table like any other, under which every lookup
+echoes its key back unchanged, so
+.B \-\-raw
+and a real language file share the same code path throughout.
+.B day
+and
+.B readings
+under
+.B \-\-raw
+are byte\-identical to this program's own pre\-naming output; every existing
+script built against that output therefore needs one flag, not a rewrite.
+.TP
+.B lang \-\-list
+.TQ
+.BI lang " " \-\-dump " CODE"
+.TQ
+.BI lang " " \-\-check " FILE"
+What make "anyone can write a language file" true rather than merely
+permitted \(em the same idea the overlay system already established with
+.B new\-overlay
+and
+.BR check .
+.RS
+.nf
+
+.B colitur lang \-\-dump la > my\-lang.ini
+.B $EDITOR my\-lang.ini
+.B colitur lang \-\-check my\-lang.ini
+.B colitur day 2026 \-\-lang ./my\-lang.ini
+.fi
+.RE
+.PP
+.B \-\-check
+reports what is
+.B MISSING
+(a real slug the engine can produce, with no entry for it in the file) and,
+separately, any entry naming a slug that does not exist at all \(em a typo,
+which would otherwise be silently dead, its author never learning why their
+own name never appears. Exits
+.B 1
+if anything is unknown, so it composes into a Makefile or a pre\-commit
+hook. The reference slug set is the same one
+.BR "colitur lang \-\-check" 's
+own coverage test walks: the observed office AND every commemoration and
+transfer the engine can produce, over a fixed multi\-year window \(em not
+merely a hand\-picked sample.
+.TP
+.B config \-\-show
+Print every effective setting \(em
+.IR lang ", " overlay ", " template ", " format
+\(em its resolved value, and where it came from:
+.BR flag ", " config " or " default ,
+via the same resolver every other command uses (there is deliberately no
+separate "provenance" function, so the two cannot disagree). Also prints
+the config file's own path and whether it exists. See
+.BR colitur\-config (5)
+for the file's location, its precedence in full, and every setting it
+recognises.
.SH ENVIRONMENT
.TP
.B COLITUR_DATA_DIR
@@ -790,6 +1049,17 @@ Days carrying at least one commemoration:
.fi
.RE
.PP
+Names are Latin by default; render in English instead, or fall back to the
+pre\-naming, slugs\-only output a script written before this feature existed
+still expects:
+.RS
+.nf
+
+.B colitur day 2026 \-\-lang en | head \-1
+.B colitur day 2026 \-\-raw | head \-1
+.fi
+.RE
+.PP
A local calendar on top of the universal one:
.RS
.nf
@@ -858,8 +1128,12 @@ shapes and worked examples.
for the template format used by
.BR table ", " render " and " publish
\(em the four syntax forms, the six flavours and their escaping, the full
-view\-model field reference, and the scope\-shadowing hazard a template author
-will hit.
+view\-model field reference, and the one remaining scope hazard a template
+author can still hit.
+.PP
+.BR colitur\-config (5)
+for the config file's location, the flag > config > default precedence, and
+every setting it recognises.
.PP
.BR lectio (1)
.SH LICENSE
diff --git a/test/cli.t b/test/cli.t
index dfe5caa..610ce5b 100644
--- a/test/cli.t
+++ b/test/cli.t
@@ -17,7 +17,7 @@ A year outside the supported domain is rejected (exit 2):
No/garbage arguments give a usage error (exit 2):
$ colitur
- colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | colitur check FILE | colitur new-overlay (try: colitur --help)
+ colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | colitur lang --list|--dump CODE|--check FILE | colitur config --show | colitur check FILE | colitur new-overlay (try: colitur --help)
[2]
The EF temporal cycle for a year, one line per day:
@@ -53,20 +53,20 @@ Easter is the observed day exactly once, and carries no commemoration (an
impeded I class day admits at most one PRIVILEGED commemoration, RG 111, and
nothing outranks Easter to be impeded by it in the first place):
- $ colitur day 2026 | grep -c '^2026-04-05 '
+ $ colitur day 2026 --raw | grep -c '^2026-04-05 '
1
- $ colitur day 2026 | grep '^2026-04-05 '
+ $ colitur day 2026 --raw | grep '^2026-04-05 '
2026-04-05 sunday paschaltide 1 ef-easter-sunday class-1 white
Ash Wednesday: I class (RG 91 entry 7), violet, no numbered week (it falls 4
days before Lent I's own origin -- rite_ef/temporal_ef.ml's [week]):
- $ colitur day 2026 | grep '^2026-02-18 '
+ $ colitur day 2026 --raw | grep '^2026-02-18 '
2026-02-18 wednesday lent - ef-ash-wednesday class-1 violet
Christmas: I class, white (RG 91 entry 1):
- $ colitur day 2026 | grep '^2026-12-25 '
+ $ colitur day 2026 --raw | grep '^2026-12-25 '
2026-12-25 friday christmastide - ef-nativity class-1 white
All Souls (2 Nov, RG 91 entry 8) end to end: 2 Nov 2025 is a Sunday (verified
@@ -77,11 +77,11 @@ itself is not re-asserted here, already covered by test_temporal_ef.ml); RG
95 then transfers All Souls (I class, not a Sunday, not a vigil) and RG 96
places it on 3 Nov, the next day that is not I or II class:
- $ colitur day 2025 | grep -c 'commemoration-of-all-souls'
+ $ colitur day 2025 --raw | grep -c 'commemoration-of-all-souls'
1
- $ colitur day 2025 | grep '^2025-11-02 ' | sed -E 's/ [0-9]+ ef-time-after-pentecost-sunday-[0-9]+ / <week> ef-time-after-pentecost-sunday-<n> /'
+ $ colitur day 2025 --raw | grep '^2025-11-02 ' | sed -E 's/ [0-9]+ ef-time-after-pentecost-sunday-[0-9]+ / <week> ef-time-after-pentecost-sunday-<n> /'
2025-11-02 sunday time-after-pentecost <week> ef-time-after-pentecost-sunday-<n> class-2 green
- $ colitur day 2025 | grep '^2025-11-03 '
+ $ colitur day 2025 --raw | grep '^2025-11-03 '
2025-11-03 monday time-after-pentecost 21 commemoration-of-all-souls class-1 black
(week 21: Pentecost 2025 is 8 June (colitur easter 2025); 8 Jun - 3 Nov is
@@ -93,9 +93,9 @@ All Souls observed directly on 2 Nov in a year where it does not fall on a
Sunday: 2 Nov 2026 is a Monday (1 Jan 2026 is a Thursday, same day-of-year
306 offset, (4+305) mod 7 = 1 = Monday):
- $ colitur day 2026 | grep -c 'commemoration-of-all-souls'
+ $ colitur day 2026 --raw | grep -c 'commemoration-of-all-souls'
1
- $ colitur day 2026 | grep '^2026-11-02 '
+ $ colitur day 2026 --raw | grep '^2026-11-02 '
2026-11-02 monday time-after-pentecost 23 commemoration-of-all-souls class-1 black
(week 23: Pentecost 2026 is 24 May (colitur easter 2026); 24 May - 2 Nov is
@@ -109,9 +109,9 @@ RG 96 target -- not the Monday after Low Sunday -- is what governs, since
the general walk never crosses Easter. Before this fix the unconditional
reading sent it to 30 April (Easter + 8) instead:
- $ colitur day 2057 | grep '^2057-03-26 '
+ $ colitur day 2057 --raw | grep '^2057-03-26 '
2057-03-26 monday lent 3 annunciation-of-the-blessed-virgin-mary class-1 white +ef-lent-3-monday
- $ colitur day 2057 | grep -c 'annunciation-of-the-blessed-virgin-mary'
+ $ colitur day 2057 --raw | grep -c 'annunciation-of-the-blessed-virgin-mary'
1
A year outside the supported domain is rejected (exit 2):
@@ -138,7 +138,7 @@ sabbato", assigned to Christmas through the Purification (RG 309(a)). It is
NOT the Christmastide feria's Mass, which is what colitur said before that
rule was implemented.
- $ colitur readings 2026 | head -3
+ $ colitur readings 2026 --raw | head -3
2026-01-01 ef-circumcision | Titus 2:11-15 | Luke 2:21
2026-01-02 ef-christmas-1-friday | Titus 2:11-15 | Luke 2:21
2026-01-03 ef-christmas-1-saturday | Titus 3:4-7 | Luke 2:15-20
@@ -160,16 +160,16 @@ test_golden.ml pins against the photographic scans (each literal there was
read out of the Missal before being compared with colitur -- these assert
that the CLI path agrees with the library path, nothing more):
- $ colitur readings 2038 | grep '^2038-03-08 '
+ $ colitur readings 2038 --raw | grep '^2038-03-08 '
2038-03-08 john-of-god | Ecclus 31:8-11 | Matt 22:34-46
- $ colitur readings 2026 | grep '^2026-02-23 '
+ $ colitur readings 2026 --raw | grep '^2026-02-23 '
2026-02-23 ef-lent-1-monday | Ezech 34:11-16 | Matt 25:31-46
- $ colitur readings 2025 | grep '^2025-12-01 '
+ $ colitur readings 2025 --raw | grep '^2025-12-01 '
2025-12-01 ef-advent-1-monday | Rom 13:11-14 | Luke 21:25-33
- $ colitur readings 2038 | grep '^2038-03-06 '
+ $ colitur readings 2038 --raw | grep '^2038-03-06 '
2038-03-06 sts-felicitas-perpetua | Ecclus 51:1-8, 12 | Matt 13:44-52
The Advent line above is the ferial resumption (chain step 3) and is the one
@@ -190,7 +190,7 @@ rather than the guarantee itself:
`colitur day`'s own format is unaffected by any of the above:
- $ colitur day 2026 | head -1
+ $ colitur day 2026 --raw | head -1
2026-01-01 thursday christmastide - ef-circumcision class-1 white
A year outside the supported domain is rejected (exit 2):
@@ -280,14 +280,14 @@ four while appearing merely to add a local feast.
A fixed-date local patron, observed on its own day:
- $ colitur day 2026 --overlay fixtures/overlay-example-diocesan.sexp | grep '^2026-07-11 '
+ $ colitur day 2026 --overlay fixtures/overlay-example-diocesan.sexp --raw | grep '^2026-07-11 '
2026-07-11 saturday time-after-pentecost 6 example-local-patron class-3 white +pius-i
A MOVABLE one -- the capability the Date_spec work existed to unlock. "The
first Sunday of October" is the shape a patronal or dedication feast usually
takes, and it lands on a different civil date every year:
- $ for y in 2026 2027 2028; do colitur day $y --overlay fixtures/overlay-example-diocesan.sexp | grep 'example-dedication'; done
+ $ for y in 2026 2027 2028; do colitur day $y --overlay fixtures/overlay-example-diocesan.sexp --raw | grep 'example-dedication'; done
2026-10-04 sunday time-after-pentecost 19 example-dedication class-1 white +ef-time-after-pentecost-sunday-19
2027-10-03 sunday time-after-pentecost 20 example-dedication class-1 white +ef-time-after-pentecost-sunday-20
2028-10-01 sunday time-after-pentecost 17 example-dedication class-1 white +ef-time-after-pentecost-sunday-17
@@ -295,7 +295,7 @@ takes, and it lands on a different civil date every year:
The shipped calendar is untouched without the flag -- the same date, no local
feast, the ordinary Sunday restored:
- $ colitur day 2026 | grep '^2026-10-04 '
+ $ colitur day 2026 --raw | grep '^2026-10-04 '
2026-10-04 sunday time-after-pentecost 19 ef-time-after-pentecost-sunday-19 class-2 green
And the shipped overlay's own entries survive alongside the user's, which is
@@ -327,14 +327,14 @@ A flag needing a value, given none:
$ colitur day 2026 --overlay
colitur: --overlay needs a file path
- colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | colitur check FILE | colitur new-overlay (try: colitur --help)
+ colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | colitur lang --list|--dump CODE|--check FILE | colitur config --show | colitur check FILE | colitur new-overlay (try: colitur --help)
[2]
An unknown option is rejected rather than treated as a positional word:
$ colitur day 2026 --diocese
colitur: unknown option --diocese
- colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | colitur check FILE | colitur new-overlay (try: colitur --help)
+ colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | colitur lang --list|--dump CODE|--check FILE | colitur config --show | colitur check FILE | colitur new-overlay (try: colitur --help)
[2]
The shipped example overlay is runnable documentation, and it must actually
@@ -383,7 +383,7 @@ own output before emitting it. The full pipeline, INI to a resolved day:
p.sexp: ok -- overlay my-parish, 1 directive(s): 1 add, 0 suppress, 0 replace, 0 edit
every directive found its target
add our-patron
- $ colitur day 2026 --overlay p.sexp | grep '^2026-07-11'
+ $ colitur day 2026 --overlay p.sexp --raw | grep '^2026-07-11'
2026-07-11 saturday time-after-pentecost 6 our-patron class-3 white +pius-i
What the INI form cannot express is refused by name, not dropped silently:
@@ -413,7 +413,7 @@ An overlay feast competes under the ordinary rules: the Benedictine Transitus
is I class and takes 21 March, with the Lenten feria commemorated rather than
displaced silently.
- $ colitur day 2026 --overlay ben.sexp | grep '^2026-03-21'
+ $ colitur day 2026 --overlay ben.sexp --raw | grep '^2026-03-21'
2026-03-21 saturday lent 4 transitus-of-our-holy-father-benedict class-1 white +ef-lent-4-saturday
CSV emits a header and one row per day:
@@ -501,12 +501,90 @@ silently ignored, the same discipline --overlay already gets:
day and readings are untouched:
- $ colitur day 2027 | head -1
+ $ colitur day 2027 --raw | head -1
2027-01-01 friday christmastide - ef-circumcision class-1 white
+ $ colitur readings 2027 --raw | head -1
+ 2027-01-01 ef-circumcision | Titus 2:11-15 | Luke 2:21
+
+Names are Latin by default (Task 6): `day` gains a trailing display name and
+`readings` a trailing " | name" field, appended rather than substituted into
+the slug's own position -- a name contains spaces, and inserting it there
+would break every fixed-position field after it for anyone parsing this row
+by column, the same reason `readings` is a separate command in the first
+place. 1 January 2027 is the Circumcision, kept within the Nativity octave:
+
+ $ colitur day 2027 | head -2
+ 2027-01-01 friday christmastide - ef-circumcision class-1 white In Octava Nativitatis Domini
+ 2027-01-02 saturday christmastide - ef-christmas-1-saturday class-4 white Officium sanctae Mariae in sabbato
+
$ colitur readings 2027 | head -1
+ 2027-01-01 ef-circumcision | Titus 2:11-15 | Luke 2:21 | In Octava Nativitatis Domini
+
+--raw restores the old byte-exact output -- no trailing field at all, not
+merely an empty one, because [Lang.raw] is the identity table (lang.mli):
+the resolved name equals the slug already printed, so day_line/readings_line
+omit the field entirely rather than repeat the slug a second time. This is
+not a special case keyed on --raw itself; it is a direct consequence of the
+identity table, verified against the pre-branch binary in the task report
+rather than merely asserted:
+
+ $ colitur day 2027 --raw | head -1
+ 2027-01-01 friday christmastide - ef-circumcision class-1 white
+
+ $ colitur readings 2027 --raw | head -1
2027-01-01 ef-circumcision | Titus 2:11-15 | Luke 2:21
+A language file by path (--lang accepts a bare code OR a file path -- a
+value containing '/' or ending ".ini" is read literally rather than looked
+up in the installed language directory). The synthetic file below declares
+`fallback = la`, so every slug it does not name itself still resolves
+through the chain to la.ini's real name, and only the one it DOES name
+shows the override:
+
+ $ printf '[meta]\nlang = xx\nfallback = la\n[celebration]\nef-circumcision = TEST FEAST\n' > lang-xx.ini
+ $ colitur day 2027 --lang ./lang-xx.ini | head -1
+ 2027-01-01 friday christmastide - ef-circumcision class-1 white TEST FEAST
+
+An unknown language is a usage 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. The exact "(looked in ...)" path is
+not pinned here -- it names the cram sandbox's own build tree, the same
+reason the data-directory fallback test above is not reproduced literally
+either:
+
+ $ colitur day 2027 --lang nonexistent 2>&1 | grep -c 'no language "nonexistent"'
+ 1
+ $ colitur day 2027 --lang nonexistent >/dev/null 2>&1; echo "exit=$?"
+ exit=2
+
+A malformed language file is an error, not a crash:
+
+ $ printf '[celebration\nbroken' > bad-lang.ini
+ $ colitur day 2027 --lang ./bad-lang.ini
+ colitur: ./bad-lang.ini: line 1: "[celebration" looks like a section header but does not end with ']'
+ [2]
+
+--lang/--raw have no effect on a command that reads no display names at
+all, refused rather than silently ignored, the same discipline --overlay
+already gets:
+
+ $ colitur easter 2026 --lang la
+ colitur: --lang/--raw have no effect on `easter`; refusing rather than ignoring them
+ [2]
+
+ $ colitur temporal 2026 --raw
+ colitur: --lang/--raw have no effect on `temporal`; refusing rather than ignoring them
+ [2]
+
+The ICS SUMMARY line is localised too (found while reviewing the published
+site: it used to read the kernel's own unlocalised "(class-1, white)" where
+the JSON beside it already said "II classis"/"albus" -- emit_ics.ml was
+reading the wrong pair of view fields):
+
+ $ colitur emit --format ics --from 2027 --to 2027 | grep '^SUMMARY' | head -1
+ SUMMARY:In Octava Nativitatis Domini (I classis\, albus)
+
A minimal inline template renders -- table computes and renders in one
process (2 January 2027 is a Saturday, not a Sunday, so Holy Name Sunday
falls on the 3rd, not the 2nd, that year):
@@ -1098,3 +1176,125 @@ over-broad: it still prunes normally when stale.
$ test -d /tmp/pub-sec/ef/2028 || echo pruned-2028
pruned-2028
+
+`colitur lang` and `colitur config` (Task 7) -- what makes "anyone can write
+a language file" true rather than merely permitted, the same idea the
+overlay system already established with `new-overlay` and `check`.
+
+`--list` shows what language files this build can find:
+
+ $ colitur lang --list
+ en (falls back to la)
+ la
+
+`--dump CODE` scaffolds a complete file on stdout, sections in a fixed order
+so two dumps of the same table are byte-identical:
+
+ $ colitur lang --dump la > d.ini
+ $ head -4 d.ini
+ [meta]
+ lang = la
+
+ [celebration]
+ $ grep -c '^ef-epiphany ' d.ini
+ 1
+
+A dump round-trips -- it just IS la.ini's own content, so it covers every
+slug the engine can produce over 2020-2045:
+
+ $ colitur lang --check d.ini
+ d.ini: 725 of 725 celebrations named, 0 missing, 0 unknown
+
+`--check` reports what is MISSING (a real slug with no entry):
+
+ $ printf '[meta]\nlang = zz\n[celebration]\nef-epiphany = Test\n' > partial.ini
+ $ colitur lang --check partial.ini | tail -1
+ partial.ini: 1 of 725 celebrations named, 724 missing, 0 unknown
+
+`--check` REJECTS an unknown slug (exit 1), so a typo is visible rather than
+silently dead -- its author would otherwise never learn why the name they
+wrote never appears:
+
+ $ printf '[meta]\nlang = zz\n[celebration]\nnot-a-real-slug = X\n' > typo.ini
+ $ colitur lang --check typo.ini | grep 'unknown slug'
+ unknown slug: not-a-real-slug
+ $ colitur lang --check typo.ini >/dev/null 2>&1; echo "exit=$?"
+ exit=1
+
+`lang` requires exactly one of --list/--dump/--check:
+
+ $ colitur lang
+ colitur: lang requires one of --list, --dump CODE or --check FILE
+ [2]
+
+ $ colitur lang --list --dump la
+ colitur: lang takes only one of --list, --dump CODE or --check FILE
+ [2]
+
+`--dump`/`--check`/`--list`/`--show` have no effect on any other command,
+refused rather than silently ignored, the same discipline --overlay/--lang
+already get:
+
+ $ colitur day 2027 --dump la
+ colitur: --dump/--check/--list/--show have no effect on `day`; refusing rather than ignoring them
+ [2]
+
+`config --show` reports each effective setting, its resolved value, and
+where it came from -- flag, config or default -- via Config.resolve; there
+is deliberately no separate provenance function. Run through an isolated
+XDG_CONFIG_HOME so this does not depend on whatever the person running the
+suite happens to have in their own home directory (the same reason the
+data-directory fallback case earlier in this file is not reproduced
+literally either):
+
+ $ XDG_CONFIG_HOME=xdg-test colitur config --show
+ config file: xdg-test/colitur/config.ini (not found)
+ lang la (default)
+ template (none) (default)
+ format (none) (default)
+ overlay (none) (default)
+
+A flag on the SAME command line previews exactly what it would resolve to
+on any other command -- `overlay` has no single scalar value, so it is
+listed one line per effective entry instead:
+
+ $ XDG_CONFIG_HOME=xdg-test colitur config --show --lang fr --overlay mine.sexp
+ config file: xdg-test/colitur/config.ini (not found)
+ lang fr (flag)
+ template (none) (default)
+ format (none) (default)
+ overlay mine.sexp (flag)
+
+A real config file supplies a default that a command with no explicit flag
+then uses. An unrecognised key and an unrecognised section are each warned
+about and ignored, never silently dropped and never fatal on their own --
+two separate warnings, so a misspelled section (`[deafults]`) reads
+differently from a misspelled key inside a recognised one:
+
+ $ mkdir -p xdg-test/colitur
+ $ printf '[defaults]\nlang = en\noverlay = /nonexistent/parish.sexp\nbogus = 1\n\n[deafults]\nlang = xx\n' > xdg-test/colitur/config.ini
+ $ XDG_CONFIG_HOME=xdg-test colitur config --show
+ config file: xdg-test/colitur/config.ini (exists)
+ lang en (config)
+ template (none) (default)
+ format (none) (default)
+ overlay /nonexistent/parish.sexp (config)
+ colitur: xdg-test/colitur/config.ini: unknown setting "bogus" (ignored)
+ colitur: xdg-test/colitur/config.ini: unknown section [deafults] (ignored)
+
+`config` requires --show:
+
+ $ colitur config
+ colitur: config requires --show
+ [2]
+
+A config file that EXISTS but fails to parse is fatal -- a config the user
+wrote and colitur cannot honour is not something to carry on past silently
+-- and this is true for every command, not only `config` itself, because
+the file is loaded once, unconditionally, before dispatch:
+
+ $ mkdir -p xdg-bad/colitur
+ $ printf '[defaults\nbroken' > xdg-bad/colitur/config.ini
+ $ XDG_CONFIG_HOME=xdg-bad colitur day 2027 --raw
+ colitur: xdg-bad/colitur/config.ini: line 1: "[defaults" looks like a section header but does not end with ']'
+ [2]