summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-19 15:29:33 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-19 15:29:33 +0200
commit6d367ab8e90f6e713d262a7f19fb908b28d4796a (patch)
treef5377f426ebe5ba32b7a8532850eafc9c103b9f9
parent92559825f9ad3e0f751d2c2022ffe000f23358ef (diff)
downloadcolitur-6d367ab8e90f6e713d262a7f19fb908b28d4796a.tar.gz
colitur-6d367ab8e90f6e713d262a7f19fb908b28d4796a.zip
feat(render): names reach the view and every emitter
The view's name is now the RESOLVED display string and slug is untouched, so machine formats carry both -- a script keeps the stable key, a human reads the name. name is a plain string, not a lang-keyed object. That removes the shadowing hazard outright: a dotted {{name.la}} used to fall back WHOLESALE to the enclosing month's name.la and print Ianuarius on every unnamed day, which is how a printed booklet came to show the month where the feast belonged. weekday, season, rank and colour all gain localised companions, because a calendar in a language needs more than feast names, and templates gain a term vocabulary so fixed strings need no template edit to translate. Asserted over a whole year: no day renders its slug as its name. Beyond the brief's own code sample: - comm_value's own name is now resolved through the same lang.celebration table too (not only the observed day's), because Task 8's own ordo template interpolates a plain {{name}} inside {{#comms}} -- an Obj there would render silently blank. A commemoration slug without Latin coverage still degrades to the slug, same as everywhere else in this system; that is a lang/la.ini DATA gap (113 of 327 sanctoral slugs, measured), not a regression this task introduced. - bin/main.ml's emit/table/publish call sites needed ~lang to compile at all, which is collateral from the of_days signature change, not this task's own file list. Rather than pass Lang.raw and ship the very slug-as-name defect this branch exists to fix, they load the shipped Latin table by the same probe order data_dir() already uses -- a deliberate, commented BRIDGE that Task 6 replaces wholesale with real --lang/--raw/config resolution. bin/dune gained colitur_naming accordingly. - test/cli.t needed two related fixes to stay green: the CSV header/row example, and a table/LaTeX escaping demonstration that relied on the kernel's own English name for Sts Peter & Paul -- gone from the view now that name resolves through lang tables only, and the Missal's own Latin spells the feast with et, never an ampersand. Escaping itself is still proved live on 2035 data in test_emit.ml. - Both schemas gained the new day/week/top-level keys (season_name, weekday, rank_name, colour_name, term, weekday_headings, month_num, month_name), not only the name shape change; schema/colitur-v1.xsd verified against real emitted XML via xmllint (make check-schema). Render/golden's 9 cases (the shipped ordo/grid templates, all six flavours) now fail as expected: their old {{name.la}} / {{#name}}... idiom finds nothing on a plain string. That is Tasks 8/9's own scope to rewrite, per the plan's own pre-flight conflict scan -- not fixed here, and not silently pinned by regenerating goldens off broken output. 495 tests run (490 + 5 new), 486 pass; the 9 failures are exactly Render/golden's ordo/grid cases.
-rw-r--r--bin/dune2
-rw-r--r--bin/main.ml43
-rw-r--r--lib/render/dune2
-rw-r--r--lib/render/emit_csv.ml16
-rw-r--r--lib/render/emit_ics.ml12
-rw-r--r--lib/render/emit_xml.ml17
-rw-r--r--lib/render/view.ml121
-rw-r--r--lib/render/view.mli18
-rw-r--r--schema/colitur-v1.xsd17
-rw-r--r--schema/day-v1.json58
-rw-r--r--test/cli.t23
-rw-r--r--test/test_emit.ml6
-rw-r--r--test/test_view.ml92
13 files changed, 320 insertions, 107 deletions
diff --git a/bin/dune b/bin/dune
index 856dafe..28c04d7 100644
--- a/bin/dune
+++ b/bin/dune
@@ -6,4 +6,4 @@
; colitur.opam's frozen depends, only a new library this executable links
; against. Used by Task 12's [mkdir_p] (colitur publish, recursive
; directory creation) and nowhere else.
- (libraries colitur_kernel rite_ef colitur_render unix))
+ (libraries colitur_kernel rite_ef colitur_render colitur_naming unix))
diff --git a/bin/main.ml b/bin/main.ml
index 6f64582..b2eef28 100644
--- a/bin/main.ml
+++ b/bin/main.ml
@@ -131,6 +131,41 @@ 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. *)
+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),
@@ -428,7 +463,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 ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days
+ Colitur_render.View.of_days ~lang:(Lazy.force default_lang) ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days
in
match format with
| "csv" ->
@@ -538,7 +573,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 ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days 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
match Colitur_render.Template.render_string ~flavour src v with
| Error e ->
(* The template is user input; a parse failure is reported with the
@@ -757,7 +792,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 ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days 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 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);
@@ -770,7 +805,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 ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y [ d ] 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
emit (Printf.sprintf "ef/%s/%s/%s.json" ys mm dd) (Colitur_render.Emit_json.year one))
days
done;
diff --git a/lib/render/dune b/lib/render/dune
index 7880188..548cf43 100644
--- a/lib/render/dune
+++ b/lib/render/dune
@@ -1,5 +1,5 @@
(library
(name colitur_render)
- (libraries colitur_kernel sexplib)
+ (libraries colitur_kernel colitur_naming sexplib)
(preprocess
(pps ppx_sexp_conv)))
diff --git a/lib/render/emit_csv.ml b/lib/render/emit_csv.ml
index b478265..415a684 100644
--- a/lib/render/emit_csv.ml
+++ b/lib/render/emit_csv.ml
@@ -16,11 +16,15 @@ let escape_field s =
let get v k = match v with T.Obj kvs -> List.assoc_opt k kvs | _ -> None
let s v k = match get v k with Some (T.Str x) -> x | _ -> ""
-let nested v a b = match get v a with Some inner -> s inner b | None -> ""
+(* [name] is now the single RESOLVED display string (view.ml), not a
+ lang-keyed object -- the [name_la]/[name_en] pair this replaces carried
+ the kernel's own [Celebration.names], a different, unlocalised source.
+ Machine formats still carry [slug] alongside it, so a script keeps the
+ stable key and a human reads the name. *)
let columns =
- [ "date"; "rite"; "season"; "week"; "slug"; "rank"; "colour"; "subject";
- "name_la"; "name_en"; "first"; "gospel"; "comms" ]
+ [ "date"; "rite"; "season"; "season_name"; "week"; "slug"; "name"; "weekday";
+ "rank"; "rank_name"; "colour"; "colour_name"; "subject"; "first"; "gospel"; "comms" ]
let row ~rite d =
let comms =
@@ -30,9 +34,9 @@ let row ~rite d =
in
String.concat ","
(List.map escape_field
- [ s d "iso"; rite; s d "season"; s d "week"; s d "slug"; s d "rank";
- s d "colour"; s d "subject"; nested d "name" "la"; nested d "name" "en";
- s d "first"; s d "gospel"; comms ])
+ [ s d "iso"; rite; s d "season"; s d "season_name"; s d "week"; s d "slug";
+ s d "name"; s d "weekday"; s d "rank"; s d "rank_name"; s d "colour";
+ s d "colour_name"; s d "subject"; s d "first"; s d "gospel"; comms ])
let year v =
let rite = s v "rite" in
diff --git a/lib/render/emit_ics.ml b/lib/render/emit_ics.ml
index 41efb95..4e06eae 100644
--- a/lib/render/emit_ics.ml
+++ b/lib/render/emit_ics.ml
@@ -55,14 +55,10 @@ let line b l = Buffer.add_string b (Escape.fold_ics l)
let event b ~rite ~dtstamp d =
let iso = s d "iso" in
if iso <> "" then begin
- let name =
- match get d "name" with
- | Some (T.Obj kvs) -> (
- match List.assoc_opt "en" kvs with
- | Some (T.Str x) when x <> "" -> x
- | _ -> ( match List.assoc_opt "la" kvs with Some (T.Str x) -> x | _ -> s d "slug"))
- | _ -> s d "slug"
- in
+ (* [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. *)
+ let name = s d "name" in
let summary = Printf.sprintf "%s (%s, %s)" name (s d "rank") (s d "colour") in
let desc =
String.concat "\n"
diff --git a/lib/render/emit_xml.ml b/lib/render/emit_xml.ml
index 4e4b51a..3f1ca32 100644
--- a/lib/render/emit_xml.ml
+++ b/lib/render/emit_xml.ml
@@ -11,20 +11,19 @@ let el b name value =
let day b d =
Buffer.add_string b (" <day date=\"" ^ escape (s d "iso") ^ "\">\n");
el b "season" (s d "season");
+ el b "season_name" (s d "season_name");
el b "week" (s d "week");
el b "slug" (s d "slug");
+ (* The resolved display name. A single element, no [lang] attribute: the
+ view's own [name] is now one resolved string, not a lang-keyed object
+ (view.ml), so there is exactly one to emit rather than one per language. *)
+ el b "name" (s d "name");
+ el b "weekday" (s d "weekday");
el b "rank" (s d "rank");
+ el b "rank_name" (s d "rank_name");
el b "colour" (s d "colour");
+ el b "colour_name" (s d "colour_name");
el b "subject" (s d "subject");
- (match get d "name" with
- | Some (T.Obj kvs) ->
- List.iter
- (fun (lang, v) ->
- match v with
- | T.Str x -> Buffer.add_string b (" <name lang=\"" ^ escape lang ^ "\">" ^ escape x ^ "</name>\n")
- | _ -> ())
- kvs
- | _ -> ());
(match get d "comms" with
| Some (T.List l) ->
List.iter (fun c -> Buffer.add_string b (" <commemoration>" ^ escape (s c "slug") ^ "</commemoration>\n")) l
diff --git a/lib/render/view.ml b/lib/render/view.ml
index 733aa45..bbb8c7d 100644
--- a/lib/render/view.ml
+++ b/lib/render/view.ml
@@ -1,18 +1,10 @@
module K = Colitur_kernel
module T = Template
+module Lang = Colitur_naming.Lang
let str s = T.Str s
let bool b = T.Bool b
-let month_names =
- [| ("Ianuarius", "January"); ("Februarius", "February"); ("Martius", "March");
- ("Aprilis", "April"); ("Maius", "May"); ("Iunius", "June");
- ("Iulius", "July"); ("Augustus", "August"); ("September", "September");
- ("October", "October"); ("November", "November"); ("December", "December") |]
-
-let names_value (n : K.Names.t) =
- T.Obj (List.map (fun (l, s) -> (K.Lang.to_string l, str s)) (K.Names.to_list n))
-
let dow_int = function
| K.Date.Sun -> 0 | K.Date.Mon -> 1 | K.Date.Tue -> 2 | K.Date.Wed -> 3
| K.Date.Thu -> 4 | K.Date.Fri -> 5 | K.Date.Sat -> 6
@@ -24,55 +16,78 @@ let citation_ref cits part =
| Some c -> c.K.Citation.reference
| None -> ""
-let comm_value (c, priv) =
+(* A commemoration's own name resolves through the SAME [lang.celebration]
+ table as the observed day's -- a commemoration's slug is drawn from the
+ identical sanctoral/temporal pool, not a second vocabulary. Templates
+ (the ordo booklet, Task 8) interpolate this as a plain [{{name}}] inside
+ [{{#comms}}], so it must be a string here too, not the kernel's own
+ lang-keyed [Celebration.names] object -- the same reasoning [day_value]'s
+ own [name] follows below, applied consistently rather than left as a
+ second, differently-shaped name field a template author would have to
+ remember. *)
+let comm_value ~lang (c, priv) =
+ let slug_s = K.Slug.to_string c.K.Celebration.slug in
T.Obj
- [ ("slug", str (K.Slug.to_string c.K.Celebration.slug));
- ("name", names_value c.K.Celebration.names);
+ [ ("slug", str slug_s);
+ ("name", str (Lang.celebration lang slug_s));
("privileged", bool (priv = K.Precedence.Privileged)) ]
(* A padding cell: present so a grid row always has seven entries, and flagged
so a template can render it blank. Every field a real day has is present and
- empty, so a template never hits a missing key on a padding cell. *)
+ empty, so a template never hits a missing key on a padding cell -- the same
+ key SET as [day_value], not merely the same shape by coincidence. *)
let padding_cell dow =
T.Obj
[ ("iso", str ""); ("dom", str ""); ("dow", str (string_of_int dow));
("in_month", bool false);
- ("season", str ""); ("week", str ""); ("slug", str "");
- ("name", T.Obj []); ("rank", str "");
- ("colour", str "");
+ ("season", str ""); ("season_name", str "");
+ ("week", str ""); ("slug", str "");
+ ("name", str ""); ("weekday", str "");
+ ("rank", str ""); ("rank_name", str "");
+ ("colour", str ""); ("colour_name", str "");
("is_white", bool false); ("is_red", bool false); ("is_green", bool false);
("is_violet", bool false); ("is_rose", bool false); ("is_black", bool false);
("subject", str ""); ("comms", T.List []);
("transferred_in", T.List []); ("transferred_out", T.List []);
("first", str ""); ("gospel", str ""); ("last", bool false) ]
-let day_value ~vocab (d : ('s, 'r) K.Liturgical_day.t) =
+let day_value ~lang ~vocab (d : ('s, 'r) K.Liturgical_day.t) =
let date = d.K.Liturgical_day.date in
let tmp = d.K.Liturgical_day.temporal in
let cel = d.K.Liturgical_day.observed in
let colour = cel.K.Celebration.colour in
let is c = bool (colour = c) in
+ let slug_s = K.Slug.to_string cel.K.Celebration.slug in
+ let rank_s = vocab.K.Vocab.rank_to_string cel.K.Celebration.rank in
+ let colour_s = K.Colour.to_string colour in
+ let season_s = vocab.K.Vocab.season_to_string tmp.K.Temporal.season in
T.Obj
[ ("iso", str (K.Date.to_iso8601 date));
("dom", str (string_of_int (K.Date.day date)));
("dow", str (string_of_int (dow_int (K.Date.weekday date))));
("in_month", bool true);
- ("season", str (vocab.K.Vocab.season_to_string tmp.K.Temporal.season));
+ ("season", str season_s);
+ ("season_name", str (Lang.season lang season_s));
("week", str (match tmp.K.Temporal.week with Some w -> string_of_int w | None -> ""));
- ("slug", str (K.Slug.to_string cel.K.Celebration.slug));
- ("name", names_value cel.K.Celebration.names);
- (* [rank] is the kernel's own class string ("class-1"); there is
- deliberately no separate localized rank label here -- the kernel
- has no per-language rank names to draw one from, and a field
- whose contents cannot honestly differ from [name] should not
- exist just to exist. Do not re-add one until the kernel can. *)
- ("rank", str (vocab.K.Vocab.rank_to_string cel.K.Celebration.rank));
- ("colour", str (K.Colour.to_string colour));
+ ("slug", str slug_s);
+ (* The resolved display name. A plain string, not a lang-keyed object:
+ a dotted {{name.la}} used to fall back WHOLESALE to the enclosing
+ month's own name.la and print "Ianuarius" on unnamed days. One
+ string removes that hazard entirely -- there is no dotted path left
+ for a partial match to climb out of. Under [Lang.raw] this equals
+ [slug] exactly (every lookup in the identity table echoes its key),
+ which is what makes [--raw] output byte-stable. *)
+ ("name", str (Lang.celebration lang slug_s));
+ ("weekday", str (Lang.weekday lang (dow_int (K.Date.weekday date))));
+ ("rank", str rank_s);
+ ("rank_name", str (Lang.rank lang rank_s));
+ ("colour", str colour_s);
+ ("colour_name", str (Lang.colour lang colour_s));
("is_white", is K.Colour.White); ("is_red", is K.Colour.Red);
("is_green", is K.Colour.Green); ("is_violet", is K.Colour.Violet);
("is_rose", is K.Colour.Rose); ("is_black", is K.Colour.Black);
("subject", str (K.Subject.to_string cel.K.Celebration.subject));
- ("comms", T.List (List.map comm_value d.K.Liturgical_day.commemorations));
+ ("comms", T.List (List.map (comm_value ~lang) d.K.Liturgical_day.commemorations));
( "transferred_in",
T.List
(match d.K.Liturgical_day.transferred_in with
@@ -102,7 +117,14 @@ let set_last cells =
(fun i c -> match c with T.Obj kvs -> T.Obj (("last", bool (i = 6)) :: List.remove_assoc "last" kvs) | v -> v)
cells
-let weeks_of_month ~first_dow day_values =
+(* [month_num]/[month_name] are carried onto every WEEK object because the
+ engine has no {{../}} parent-path syntax: a nested {{num}} inside a week
+ silently finds the WEEK's own number, never the month's, so a template
+ that needs the month (the ordo booklet, Task 8, whose weeks span a
+ {{#months}}{{#weeks}} nesting) has no other way to reach it. Shaping the
+ data here, rather than inventing template syntax, is the same call the
+ [last] flag above already made. *)
+let weeks_of_month ~first_dow ~month_num ~month_name day_values =
let lead = List.init first_dow (fun i -> padding_cell i) in
let cells = lead @ day_values in
let rec chunk acc = function
@@ -118,11 +140,35 @@ let weeks_of_month ~first_dow day_values =
chunk (week :: acc) tl
in
List.mapi
- (fun i w -> T.Obj [ ("num", str (string_of_int (i + 1))); ("days", T.List (set_last w)) ])
+ (fun i w ->
+ T.Obj
+ [ ("num", str (string_of_int (i + 1)));
+ ("month_num", str month_num);
+ ("month_name", str month_name);
+ ("days", T.List (set_last w)) ])
(chunk [] cells)
-let of_days ~vocab ~rite ~year days =
- let dvs = List.map (fun d -> (d, day_value ~vocab d)) days in
+(* The [term] vocabulary a template routes every fixed string through
+ ({{term.epistle}}, {{term.week}}, ...) so a translated booklet needs no
+ template edit. The key list is the vocabulary's own fixed, closed set
+ (lang/*.ini's own [term] section, Task 1/3) -- not open like [celebration],
+ so it is named here rather than invented a second time from a wildcard
+ enumeration. *)
+let term_keys = [ "ordo"; "contents"; "epistle"; "lesson"; "gospel"; "commemoration"; "week" ]
+
+let term_value lang = T.Obj (List.map (fun k -> (k, str (Lang.term lang k))) term_keys)
+
+(* A localised grid header row: {name; last} objects, not bare strings --
+ the engine rejects an empty tag path ({{.}}) as a parse error, so a
+ template walking this list needs a named field to interpolate. Always
+ Sunday-first (index 0), matching [padding_cell]'s own [dow] numbering and
+ every week this view builds. *)
+let weekday_headings lang =
+ T.List
+ (List.init 7 (fun i -> T.Obj [ ("name", str (Lang.weekday lang i)); ("last", bool (i = 6)) ]))
+
+let of_days ~lang ~vocab ~rite ~year days =
+ let dvs = List.map (fun d -> (d, day_value ~lang ~vocab d)) days in
let months =
List.init 12 (fun i ->
let m = i + 1 in
@@ -135,15 +181,18 @@ let of_days ~vocab ~rite ~year days =
| (d, _) :: _ -> dow_int (K.Date.weekday d.K.Liturgical_day.date)
| [] -> 0
in
- let la, en = month_names.(i) in
+ let month_num = string_of_int m in
+ let month_name = Lang.month lang m in
T.Obj
- [ ("num", str (string_of_int m));
- ("name", T.Obj [ ("la", str la); ("en", str en) ]);
+ [ ("num", str month_num);
+ ("name", str month_name);
("days", T.List day_values);
- ("weeks", T.List (weeks_of_month ~first_dow day_values)) ])
+ ("weeks", T.List (weeks_of_month ~first_dow ~month_num ~month_name day_values)) ])
in
T.Obj
[ ("rite", str rite);
("year", str (string_of_int year));
+ ("term", term_value lang);
+ ("weekday_headings", weekday_headings lang);
("months", T.List months);
("days", T.List (List.map snd dvs)) ]
diff --git a/lib/render/view.mli b/lib/render/view.mli
index fb48590..81bfc26 100644
--- a/lib/render/view.mli
+++ b/lib/render/view.mli
@@ -15,10 +15,24 @@
different colour expression. Exactly one of the six is true on every day. *)
val of_days :
+ lang:Colitur_naming.Lang.t ->
vocab:('s, 'r) Colitur_kernel.Vocab.t ->
rite:string ->
year:int ->
('s, 'r) Colitur_kernel.Liturgical_day.t list ->
Template.value
-(** [of_days ~vocab ~rite ~year days] where [days] is one civil year, 1 January
- to 31 December, in order. Pure and total. *)
+(** [of_days ~lang ~vocab ~rite ~year days] where [days] is one civil year,
+ 1 January to 31 December, in order. Pure and total.
+
+ [lang] resolves every display string -- a day's [name], its localised
+ [weekday]/[rank_name]/[colour_name]/[season_name], each month's [name],
+ and the top-level [term] vocabulary and [weekday_headings]. [name] is a
+ PLAIN STRING, not the old lang-keyed object: a dotted [{{name.la}}]
+ reference that misses falls back WHOLESALE to the enclosing scope (a
+ month's own [name.la]), which is how a printed booklet came to show
+ "Ianuarius" in place of a feast with no Latin name. A plain string has no
+ dotted path to fall back through, so that hazard is unrepresentable.
+ [slug] is untouched by [lang] -- it stays the stable machine key,
+ identical between any two calls that differ only in [lang]. Pass
+ {!Colitur_naming.Lang.raw} for the pre-naming behaviour, under which
+ [name] equals [slug] exactly (this is what CLI [--raw] uses). *)
diff --git a/schema/colitur-v1.xsd b/schema/colitur-v1.xsd
index 46af9a9..df11c3d 100644
--- a/schema/colitur-v1.xsd
+++ b/schema/colitur-v1.xsd
@@ -10,20 +10,19 @@
<xs:complexType>
<xs:sequence>
<xs:element name="season" type="xs:string"/>
+ <xs:element name="season_name" type="xs:string"/>
<xs:element name="week" type="xs:string"/>
<xs:element name="slug" type="xs:string"/>
+ <!-- The resolved display name: a single element, no
+ lang attribute. The view's own name is now one
+ resolved string, not a value per language. -->
+ <xs:element name="name" type="xs:string"/>
+ <xs:element name="weekday" type="xs:string"/>
<xs:element name="rank" type="xs:string"/>
+ <xs:element name="rank_name" type="xs:string"/>
<xs:element name="colour" type="xs:string"/>
+ <xs:element name="colour_name" type="xs:string"/>
<xs:element name="subject" type="xs:string"/>
- <xs:element name="name" minOccurs="0" maxOccurs="unbounded">
- <xs:complexType>
- <xs:simpleContent>
- <xs:extension base="xs:string">
- <xs:attribute name="lang" type="xs:string" use="required"/>
- </xs:extension>
- </xs:simpleContent>
- </xs:complexType>
- </xs:element>
<xs:element name="commemoration" type="xs:string" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="citation" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
diff --git a/schema/day-v1.json b/schema/day-v1.json
index b48133a..5a7537c 100644
--- a/schema/day-v1.json
+++ b/schema/day-v1.json
@@ -3,10 +3,21 @@
"$id": "https://colitur/schema/day-v1.json",
"title": "colitur liturgical year, v1",
"type": "object",
- "required": ["rite", "year", "months", "days"],
+ "required": ["rite", "year", "term", "weekday_headings", "months", "days"],
"properties": {
"rite": { "type": "string" },
"year": { "type": "string", "pattern": "^[0-9]{4}$" },
+ "term": { "type": "object", "additionalProperties": { "type": "string" }, "description": "fixed vocabulary (ordo, contents, epistle, lesson, gospel, commemoration, week) in the active language" },
+ "weekday_headings": {
+ "type": "array",
+ "minItems": 7,
+ "maxItems": 7,
+ "items": {
+ "type": "object",
+ "required": ["name", "last"],
+ "properties": { "name": { "type": "string" }, "last": { "type": "boolean" } }
+ }
+ },
"months": { "type": "array", "items": { "$ref": "#/$defs/month" } },
"days": { "type": "array", "items": { "$ref": "#/$defs/day" } }
},
@@ -16,36 +27,43 @@
"required": ["num", "name", "days", "weeks"],
"properties": {
"num": { "type": "string" },
- "name": { "type": "object", "additionalProperties": { "type": "string" } },
+ "name": { "type": "string", "description": "the resolved display name, e.g. \"Ianuarius\"" },
"days": { "type": "array", "items": { "$ref": "#/$defs/day" } },
"weeks": { "type": "array", "items": { "$ref": "#/$defs/week" } }
}
},
"week": {
"type": "object",
- "required": ["num", "days"],
+ "required": ["num", "month_num", "month_name", "days"],
"properties": {
- "num": { "type": "string" },
- "days": { "type": "array", "minItems": 7, "maxItems": 7, "items": { "$ref": "#/$defs/day" } }
+ "num": { "type": "string" },
+ "month_num": { "type": "string", "description": "the enclosing month's num, carried here because the engine has no parent-path syntax" },
+ "month_name": { "type": "string", "description": "the enclosing month's resolved name, same reason" },
+ "days": { "type": "array", "minItems": 7, "maxItems": 7, "items": { "$ref": "#/$defs/day" } }
}
},
"day": {
"type": "object",
- "required": ["iso", "dom", "dow", "in_month", "season", "week", "slug",
- "name", "rank", "colour", "is_white", "is_red", "is_green",
- "is_violet", "is_rose", "is_black", "subject", "comms",
- "transferred_in", "transferred_out", "first", "gospel", "last"],
+ "required": ["iso", "dom", "dow", "in_month", "season", "season_name", "week",
+ "slug", "name", "weekday", "rank", "rank_name", "colour", "colour_name",
+ "is_white", "is_red", "is_green", "is_violet", "is_rose", "is_black",
+ "subject", "comms", "transferred_in", "transferred_out", "first",
+ "gospel", "last"],
"properties": {
- "iso": { "type": "string", "description": "empty on a grid padding cell" },
- "dom": { "type": "string" },
- "dow": { "type": "string", "description": "0 = Sunday" },
- "in_month": { "type": "boolean", "description": "false = grid padding cell" },
- "season": { "type": "string" },
- "week": { "type": "string", "description": "empty when the rite numbers no week here" },
- "slug": { "type": "string" },
- "name": { "type": "object", "additionalProperties": { "type": "string" } },
- "rank": { "type": "string" },
- "colour": { "type": "string", "enum": ["white","red","green","violet","rose","black",""] },
+ "iso": { "type": "string", "description": "empty on a grid padding cell" },
+ "dom": { "type": "string" },
+ "dow": { "type": "string", "description": "0 = Sunday" },
+ "in_month": { "type": "boolean", "description": "false = grid padding cell" },
+ "season": { "type": "string" },
+ "season_name": { "type": "string", "description": "the resolved season name in the active language" },
+ "week": { "type": "string", "description": "empty when the rite numbers no week here" },
+ "slug": { "type": "string", "description": "the stable machine key -- unaffected by the active language" },
+ "name": { "type": "string", "description": "the resolved display name; under --raw this equals slug" },
+ "weekday": { "type": "string", "description": "the resolved weekday name in the active language" },
+ "rank": { "type": "string" },
+ "rank_name": { "type": "string", "description": "the resolved rank name in the active language" },
+ "colour": { "type": "string", "enum": ["white","red","green","violet","rose","black",""] },
+ "colour_name": { "type": "string", "description": "the resolved colour name in the active language" },
"is_white": { "type": "boolean" }, "is_red": { "type": "boolean" },
"is_green": { "type": "boolean" }, "is_violet":{ "type": "boolean" },
"is_rose": { "type": "boolean" }, "is_black": { "type": "boolean" },
@@ -57,7 +75,7 @@
"required": ["slug", "name", "privileged"],
"properties": {
"slug": { "type": "string" },
- "name": { "type": "object", "additionalProperties": { "type": "string" } },
+ "name": { "type": "string", "description": "the resolved display name, same table as the day's own name" },
"privileged": { "type": "boolean" }
}
}
diff --git a/test/cli.t b/test/cli.t
index 80c8078..dfe5caa 100644
--- a/test/cli.t
+++ b/test/cli.t
@@ -419,8 +419,8 @@ displaced silently.
CSV emits a header and one row per day:
$ colitur emit --format csv --from 2027 --to 2027 | head -2
- date,rite,season,week,slug,rank,colour,subject,name_la,name_en,first,gospel,comms
- 2027-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
+ 2027-01-01,ef,christmastide,Tempus Nativitatis,,ef-circumcision,In Octava Nativitatis Domini,Feria VI,class-1,I classis,white,albus,temporal,Titus 2:11-15,Luke 2:21,
$ colitur emit --format csv --from 2027 --to 2027 | wc -l
366
@@ -522,12 +522,21 @@ render is the same operation under the name the design used:
2027-01-01 ef-circumcision
2027-01-02 ef-christmas-1-saturday
-Flavour is inferred from the extension and escapes data -- Sts. Peter &
-Paul (29 June) and its vigil are the only two 2035 entries whose English
-name needs LaTeX escaping:
+Flavour is inferred from the extension. `name` is the RESOLVED display
+string now (view.ml), not the old lang-keyed object -- there is no more
+`.en`/`.la` to reach, so a plain `{{name}}` is the only correct form; the
+old `{{name.en}}` dotted lookup on today's plain string simply finds
+nothing, on purpose (this is the change that also removes the shadowing
+hazard: a miss on a plain string has no dotted path left to fall back
+through). Names are Latin here (no --lang until the CLI wiring task), and
+the Missal's own Latin spells Peter and Paul's feast with "et", never an
+ampersand, so this no longer doubles as an escaping demonstration --
+that property is proved live on 2035 data in test_emit.ml instead
+(test_xml_escapes_live_data), where the English table both name sources
+still share does carry one:
- $ printf '{{#days}}{{name.en}}\n{{/days}}' > /tmp/t.tex
- $ colitur table --year 2035 --template /tmp/t.tex | grep -c 'Peter \\& Paul'
+ $ printf '{{#days}}{{name}}\n{{/days}}' > /tmp/t.tex
+ $ colitur table --year 2035 --template /tmp/t.tex | grep -c 'Ss\. Petri et Pauli'
2
An unknown extension with no --flavour is an error, not a silent fallback:
diff --git a/test/test_emit.ml b/test/test_emit.ml
index a22eaaf..0a40054 100644
--- a/test/test_emit.ml
+++ b/test/test_emit.ml
@@ -61,7 +61,7 @@ let test_csv_header_and_rows () =
let lines = String.split_on_char '\n' out |> List.filter (fun l -> l <> "") in
Alcotest.(check int) "366 lines: header + 365 days" 366 (List.length lines);
Alcotest.(check string) "header"
- "date,rite,season,week,slug,rank,colour,subject,name_la,name_en,first,gospel,comms"
+ "date,rite,season,season_name,week,slug,name,weekday,rank,rank_name,colour,colour_name,subject,first,gospel,comms"
(List.hd lines);
Alcotest.(check bool) "first row is 1 January" true
(String.length (List.nth lines 1) > 10 && String.sub (List.nth lines 1) 0 10 = "2027-01-01")
@@ -86,8 +86,8 @@ let test_csv_quotes_commas () =
that escapes its quoting produces 14 fields, not 13. Also assert the
quoted substring appears literally, byte for byte -- belt and braces,
and closer to what a human reviewing the CSV would actually look for. *)
- Alcotest.(check int) "the row has exactly 13 fields, same as the header"
- 13 (List.length (parse_csv_row joseph));
+ Alcotest.(check int) "the row has exactly 16 fields, same as the header"
+ 16 (List.length (parse_csv_row joseph));
Alcotest.(check bool) "Joseph's comma-bearing name is quoted whole, not split" true
(contains ~needle:"\"St. Joseph, Spouse of the Bl. Virgin Mary\"" joseph)
diff --git a/test/test_view.ml b/test/test_view.ml
index e595357..b49b090 100644
--- a/test/test_view.ml
+++ b/test/test_view.ml
@@ -24,8 +24,29 @@ let days_of_year y =
done;
List.rev !out
+(* Shared across this file and test_emit.ml: the ENGLISH table, chained to
+ Latin (en.ini's own [meta] fallback = la), so a slug en.ini does not name
+ directly still resolves through the chain rather than degrading to its
+ slug. English (not Latin, and not Lang.raw) is deliberate: it keeps the
+ emitter tests' own literal expectations -- "St. Joseph, Spouse of the Bl.
+ Virgin Mary" (a comma, for CSV quoting), "Sts. Fabian & Sebastian" (an
+ ampersand, for XML escaping) -- byte-identical to lang/en.ini's own
+ [celebration] entries, verified by grep against the shipped file rather
+ than assumed. *)
+let read_lang path =
+ let ic = open_in_bin path in
+ let s = really_input_string ic (in_channel_length ic) in
+ close_in ic;
+ match Colitur_naming.Lang.of_string s with
+ | Ok t -> t
+ | Error e -> Alcotest.failf "%s: %s" path e
+
+let default_lang =
+ lazy (Colitur_naming.Lang.with_fallback (read_lang "../lang/en.ini") (read_lang "../lang/la.ini"))
+
let view_of y =
- V.of_days ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y (days_of_year y)
+ V.of_days ~lang:(Lazy.force default_lang) ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y
+ (days_of_year y)
let get path v =
let rec go v = function
@@ -110,10 +131,79 @@ let test_exactly_one_colour_flag () =
if n <> 1 then Alcotest.failf "%s has %d colour flags set" (as_str (get [ "iso" ] d)) n)
(as_list (get [ "days" ] v))
+(* Padding cells and real days must carry the SAME key set: a template that
+ walks a grid row must never hit a missing key on a padding cell. Compares
+ the sorted key lists of a real day and a padding cell (the first cell of
+ January 2027's first week -- 1 Jan 2027 is a Friday, so that cell IS a
+ padding cell, see test_padding_cells_are_flagged above). *)
+let keys_of = function
+ | T.Obj kvs -> List.sort compare (List.map fst kvs)
+ | _ -> Alcotest.fail "expected an object"
+
+let test_padding_and_real_share_key_set () =
+ let v = view_of 2027 in
+ let jan = List.hd (as_list (get [ "months" ] v)) in
+ let first_week = List.hd (as_list (get [ "weeks" ] jan)) in
+ let cells = as_list (get [ "days" ] first_week) in
+ let padding = List.find (fun c -> not (as_bool (get [ "in_month" ] c))) cells in
+ let real = List.find (fun c -> as_bool (get [ "in_month" ] c)) cells in
+ Alcotest.(check (list string)) "padding and real days have the same key set" (keys_of real)
+ (keys_of padding)
+
+let latin () =
+ let ic = open_in_bin "../lang/la.ini" in
+ let s = really_input_string ic (in_channel_length ic) in
+ close_in ic;
+ match Colitur_naming.Lang.of_string s with
+ | Ok t -> t | Error e -> Alcotest.failf "la.ini: %s" e
+
+let view_named y = V.of_days ~lang:(latin ()) ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y (days_of_year y)
+
+(* The defect this whole branch exists to fix: no rendered day may show a slug
+ where a name exists. Asserted over a whole year, not a sample. *)
+let test_no_day_shows_a_slug () =
+ let v = view_named 2027 in
+ List.iter
+ (fun d ->
+ let name = as_str (get [ "name" ] d) and slug = as_str (get [ "slug" ] d) in
+ if name = slug then Alcotest.failf "%s renders its slug as its name" (as_str (get [ "iso" ] d));
+ if name = "" then Alcotest.failf "%s has an empty name" (as_str (get [ "iso" ] d)))
+ (as_list (get [ "days" ] v))
+
+let test_slug_is_unchanged_by_naming () =
+ let raw = V.of_days ~lang:Colitur_naming.Lang.raw ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:2027 (days_of_year 2027) in
+ let named = view_named 2027 in
+ List.iter2
+ (fun a b -> Alcotest.(check string) "slug identical" (as_str (get [ "slug" ] a)) (as_str (get [ "slug" ] b)))
+ (as_list (get [ "days" ] raw)) (as_list (get [ "days" ] named))
+
+(* Under --raw the name IS the slug: that is what makes raw output byte-stable. *)
+let test_raw_name_equals_slug () =
+ let raw = V.of_days ~lang:Colitur_naming.Lang.raw ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:2027 (days_of_year 2027) in
+ List.iter
+ (fun d -> Alcotest.(check string) "raw" (as_str (get [ "slug" ] d)) (as_str (get [ "name" ] d)))
+ (as_list (get [ "days" ] raw))
+
+(* Weekday, month, season, rank and colour must localise too -- a calendar in a
+ language needs more than feast names. *)
+let test_vocabularies_localise () =
+ let v = view_named 2027 in
+ let jan = List.hd (as_list (get [ "months" ] v)) in
+ Alcotest.(check string) "month name" "Ianuarius" (as_str (get [ "name" ] jan));
+ let d1 = List.hd (as_list (get [ "days" ] v)) in
+ Alcotest.(check string) "weekday" "Feria VI" (as_str (get [ "weekday" ] d1));
+ Alcotest.(check string) "rank" "I classis" (as_str (get [ "rank_name" ] d1));
+ Alcotest.(check string) "colour" "albus" (as_str (get [ "colour_name" ] d1))
+
let suite =
( "View",
[ Alcotest.test_case "year shape" `Quick test_year_shape;
Alcotest.test_case "weeks flatten to days" `Quick test_weeks_flatten_to_days;
Alcotest.test_case "padding cells flagged" `Quick test_padding_cells_are_flagged;
+ Alcotest.test_case "padding and real days share key set" `Quick test_padding_and_real_share_key_set;
+ Alcotest.test_case "no day shows a slug" `Quick test_no_day_shows_a_slug;
+ Alcotest.test_case "slug unchanged by naming" `Quick test_slug_is_unchanged_by_naming;
+ Alcotest.test_case "raw name equals slug" `Quick test_raw_name_equals_slug;
+ Alcotest.test_case "vocabularies localise" `Quick test_vocabularies_localise;
Alcotest.test_case "day fields" `Quick test_day_fields;
Alcotest.test_case "exactly one colour flag" `Quick test_exactly_one_colour_flag ] )