diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-19 23:48:35 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-19 23:48:35 +0200 |
| commit | ecadd969e1d96918820a1fdac0cf0d520d96ba06 (patch) | |
| tree | a06ed1160bfd28ae4578d9445242e48ac9ac3ea4 /lib | |
| parent | 6762ce46af3cb12bc6ae37cda762c5d95add7903 (diff) | |
| parent | 329d07b49e397cb65ab52e7ca019b47313027136 (diff) | |
| download | colitur-ecadd969e1d96918820a1fdac0cf0d520d96ba06.tar.gz colitur-ecadd969e1d96918820a1fdac0cf0d520d96ba06.zip | |
feat: naming, localisation and the rebuilt printed output
colitur computed the calendar correctly and could not say what it had
computed. A printed ordo read ef-septuagesima-sunday-2 where a reader
expects Dominica in Sexagesima, and the wall calendar showed slugs in
every cell.
lib/naming a language table and a config file, both pure and total,
parsing the INI reader Overlay_ini already had
lang/ la.ini and en.ini -- 725 names, every one transcribed
from the 1962 Missal and citing the line it came from
templates the ordo rebuilt as an A5 booklet: one week per page, a
table of contents, framed days, a colour swatch; the wall
calendar now fills its sheet instead of a quarter of it
tools check_citations.py verifies all 400 citations resolve,
with 33 self-tests of its own
Names resolve through lang -> declared fallback -> the slug, so a partial
translation is usable from its first line and the fully degraded case is
the old output rather than a blank page.
colitur day and colitur readings are BYTE-IDENTICAL to before, verified
against main rather than asserted; --lang, --raw and the lang/config
subcommands are still to come, and man/colitur-templates.5 still
documents the pre-naming view, so writing a custom template needs the
source until that lands.
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/kernel/overlay_ini.mli | 9 | ||||
| -rw-r--r-- | lib/naming/config.ml | 80 | ||||
| -rw-r--r-- | lib/naming/config.mli | 49 | ||||
| -rw-r--r-- | lib/naming/dune | 3 | ||||
| -rw-r--r-- | lib/naming/lang.ml | 109 | ||||
| -rw-r--r-- | lib/naming/lang.mli | 60 | ||||
| -rw-r--r-- | lib/render/dune | 2 | ||||
| -rw-r--r-- | lib/render/emit_csv.ml | 16 | ||||
| -rw-r--r-- | lib/render/emit_ics.ml | 12 | ||||
| -rw-r--r-- | lib/render/emit_xml.ml | 17 | ||||
| -rw-r--r-- | lib/render/view.ml | 121 | ||||
| -rw-r--r-- | lib/render/view.mli | 18 |
12 files changed, 434 insertions, 62 deletions
diff --git a/lib/kernel/overlay_ini.mli b/lib/kernel/overlay_ini.mli index bace93b..321cac1 100644 --- a/lib/kernel/overlay_ini.mli +++ b/lib/kernel/overlay_ini.mli @@ -32,6 +32,15 @@ Dates take three forms, matching {!Date_spec}: [MM-DD], [easter+N] or [easter-N], and [mon/day/nth] such as [oct/sun/1] or [oct/sun/-1]. *) +(** One [section] of a flat INI file. Exposed so other libraries (the language + and config files) reuse this reader rather than growing a second one that + would drift in its comment, quoting and trimming rules. *) +type section = { name : string; fields : (string * string) list } + +(** Split INI text into sections. [\[section\]] headers, [key = value] lines, + ';' and '#' comments, blank lines ignored. Never raises. *) +val parse_sections : string -> (section list, string) result + (** [parse ~rank_of_string text] is the overlay [text] denotes. [rank_of_string] is supplied by the rite, exactly as [Overlay.load] takes diff --git a/lib/naming/config.ml b/lib/naming/config.ml new file mode 100644 index 0000000..7d87d62 --- /dev/null +++ b/lib/naming/config.ml @@ -0,0 +1,80 @@ +module OI = Colitur_kernel.Overlay_ini + +type t = { + lang : string option; + overlays : string list; + template : string option; + format : string option; + unknown_keys : string list; + unknown_sections : string list; +} + +let empty = + { lang = None; overlays = []; template = None; format = None; unknown_keys = []; + unknown_sections = [] } + +let lang t = t.lang +let overlays t = t.overlays +let template t = t.template +let format t = t.format +let unknown_keys t = t.unknown_keys +let unknown_sections t = t.unknown_sections + +let of_string text = + match OI.parse_sections text with + | Error e -> Error e + | Ok sections -> + (* Any section that is not [defaults] is unrecognised -- including a + plain typo such as [deafults] -- and must be REPORTED, never + silently dropped: that is precisely the failure this feature exists + to surface. *) + let unknown_sections = + List.filter_map + (fun (s : OI.section) -> if s.OI.name = "defaults" then None else Some s.OI.name) + sections + in + (* Merge EVERY section named [defaults], not just the first: [lang.ml]'s + [of_string] was fixed this morning to fold over all matching + sections rather than take [List.find_opt]'s first match, because a + hand-edited file WILL grow duplicate headers as a user appends to it + over time. Both modules parse the same reader + ([Overlay_ini.parse_sections]) over the same file format, so they + must not disagree about what a duplicate [section] header means -- + taking only the first [defaults] block here silently discarded every + later one, with nothing pointing back at the parser. Folding a + single accumulator across every matching section, in file order, + keeps this consistent with the within-section behaviour below (last + [k]-match wins): a scalar key repeated across two blocks resolves to + the LATER value, and [overlay] keeps accumulating across every + block, not only its first. *) + let defaults_sections = + List.filter (fun (s : OI.section) -> s.OI.name = "defaults") sections + in + let acc = + (* Cons then reverse once at the very end, not `@ [v]` per line: the + latter is O(n^2) over the field count, a real hang on a + machine-generated file with many overlay lines. Reversing only + after every section has been folded (not once per section) is + what keeps [overlay] and [unknown_keys] in file order across + block boundaries, not merely within one block. *) + List.fold_left + (fun acc (s : OI.section) -> + List.fold_left + (fun acc (k, v) -> + match k with + | "lang" -> { acc with lang = Some v } + | "template" -> { acc with template = Some v } + | "format" -> { acc with format = Some v } + (* accumulates: a user has more than one overlay *) + | "overlay" -> { acc with overlays = v :: acc.overlays } + | other -> { acc with unknown_keys = other :: acc.unknown_keys }) + acc s.OI.fields) + empty defaults_sections + in + let acc = { acc with overlays = List.rev acc.overlays; unknown_keys = List.rev acc.unknown_keys } in + Ok { acc with unknown_sections } + +let resolve ~flag ~config ~default = + match flag with + | Some v -> (v, "flag") + | None -> ( match config with Some v -> (v, "config") | None -> (default, "default")) diff --git a/lib/naming/config.mli b/lib/naming/config.mli new file mode 100644 index 0000000..a84f95b --- /dev/null +++ b/lib/naming/config.mli @@ -0,0 +1,49 @@ +(** The config file: what the user wants by default, and where each value came + from. + + Owns precedence and provenance and nothing else. Never reads the filesystem + -- callers hand it text -- so it is as testable as the language table. + + A config file is OPTIONAL. With none, colitur behaves exactly as it does + without this feature, except that names resolve through the default + language. *) + +type t + +val empty : t +val of_string : string -> (t, string) result + +(** [lang], [template] and [format] are each set from a single field. A + repeated key is LAST-WINS -- the opposite direction from + {!Colitur_kernel.Overlay_ini.get}'s first-wins over the same [section] + type -- because the natural reading of a config file a user edited by + hand and appended to is "the bottom line is the one that took effect". + This holds whether the repeat is within one [\[defaults\]] block or + across two of them: every section named [defaults] is merged, not only + the first, the same duplicate-section policy {!Lang.of_string} documents + for its own sections -- the two modules read the same underlying format + and must not disagree about what a repeated header means. *) +val lang : t -> string option + +val overlays : t -> string list +val template : t -> string option +val format : t -> string option + +(** Keys present in the [\[defaults\]] section that this build does not + understand. Reported, never fatal: a config written for a newer colitur + must still work on an older one, but silently ignoring a line the user + wrote is how a typo becomes invisible. *) +val unknown_keys : t -> string list + +(** Section names other than [\[defaults\]], reported separately from + {!unknown_keys} so the CLI can word the two warnings differently (a + misspelled section, e.g. [\[deafults\]], versus a misspelled key inside a + recognised one). Also never fatal, and never silent: a section this build + does not recognise is exactly the highest-value typo this feature exists + to catch, because it silently discards the whole section -- [lang] and + everything else in it -- with no other way for the user to notice. *) +val unknown_sections : t -> string list + +(** [resolve ~flag ~config ~default] returns [(value, source)] with source one of + ["flag"], ["config"], ["default"]. Precedence is flag > config > default. *) +val resolve : flag:string option -> config:string option -> default:string -> string * string diff --git a/lib/naming/dune b/lib/naming/dune new file mode 100644 index 0000000..8af06c5 --- /dev/null +++ b/lib/naming/dune @@ -0,0 +1,3 @@ +(library + (name colitur_naming) + (libraries colitur_kernel)) diff --git a/lib/naming/lang.ml b/lib/naming/lang.ml new file mode 100644 index 0000000..147a49b --- /dev/null +++ b/lib/naming/lang.ml @@ -0,0 +1,109 @@ +module OI = Colitur_kernel.Overlay_ini + +module SM = Map.Make (String) + +type table = string SM.t + +type t = { + code : string; + fallback_code : string option; + celebration : table; + weekday : table; + month : table; + season : table; + rank : table; + colour : table; + term : table; + chain : t option; (** consulted when this table misses *) +} + +let empty_table = SM.empty + +let rec lookup t sel key = + match SM.find_opt key (sel t) with + | Some v -> Some v + | None -> ( match t.chain with Some b -> lookup b sel key | None -> None) + +(* A miss returns the KEY, never "". See lang.mli for why. *) +let get t sel key = match lookup t sel key with Some v -> v | None -> key + +let celebration t k = get t (fun x -> x.celebration) k +let season t k = get t (fun x -> x.season) k +let rank t k = get t (fun x -> x.rank) k +let colour t k = get t (fun x -> x.colour) k +let term t k = get t (fun x -> x.term) k + +let weekday_key = [| "sunday"; "monday"; "tuesday"; "wednesday"; "thursday"; "friday"; "saturday" |] + +(* weekday/month deliberately do NOT go through [get]: their SM key is a + presentation detail (an English day-name word for weekday, a numeral for + month), not the value a miss should degrade to. [get]'s generic + echo-the-search-key fallback would leak that word ("sunday") out of an + empty/raw table instead of the documented numeral -- [month] never showed + the bug because its key already equals [string_of_int n], but [weekday] + did, and it broke [Lang.raw]'s own identity contract (0 = Sunday, not + "sunday"). Falling back to [string_of_int n] directly keeps [month] + byte-identical and fixes [weekday]. *) +let weekday t n = + if n < 0 || n > 6 then string_of_int n + else match lookup t (fun x -> x.weekday) weekday_key.(n) with Some v -> v | None -> string_of_int n + +let month t n = + if n < 1 || n > 12 then string_of_int n + else match lookup t (fun x -> x.month) (string_of_int n) with Some v -> v | None -> string_of_int n + +let code t = t.code +let fallback_code t = t.fallback_code + +let raw = + { code = "raw"; fallback_code = None; celebration = empty_table; weekday = empty_table; + month = empty_table; season = empty_table; rank = empty_table; colour = empty_table; + term = empty_table; chain = None } + +let with_fallback t base = { t with chain = Some base } + +let of_string text = + match OI.parse_sections text with + | Error e -> Error e + | Ok sections -> + (* Merge EVERY section sharing [name], not just the first: a hand-edited + 595-entry language file (Tasks 3/4's la.ini) WILL grow duplicate + [section] headers as contributors append entries over time -- a + second [celebration] block is the natural way to paste in a new + batch of names. Taking only the first match (the original + [List.find_opt] here) silently dropped every later block; the + failure then surfaces as a coverage report claiming those slugs have + "no Latin name", with nothing pointing back at the parser. Folding + over all matching sections, in file order, keeps this consistent + with the existing within-section behaviour below (last [SM.add] + wins): a key repeated across two blocks resolves to the later one, + exactly what a reader expects when appending to an INI file. *) + let find name = + List.fold_left + (fun m (s : OI.section) -> + if s.OI.name = name then List.fold_left (fun m (k, v) -> SM.add k v m) m s.OI.fields else m) + empty_table sections + in + let meta = find "meta" in + (match SM.find_opt "lang" meta with + | None -> Error "language file has no [meta] lang = <code>" + | Some code -> + Ok + { code; + fallback_code = SM.find_opt "fallback" meta; + celebration = find "celebration"; + weekday = find "weekday"; + month = find "month"; + season = find "season"; + rank = find "rank"; + colour = find "colour"; + term = find "term"; + chain = None }) + +let keys t = + let qualify prefix m = SM.bindings m |> List.map (fun (k, v) -> (prefix ^ "." ^ k, v)) in + List.concat + [ qualify "celebration" t.celebration; qualify "weekday" t.weekday; + qualify "month" t.month; qualify "season" t.season; qualify "rank" t.rank; + qualify "colour" t.colour; qualify "term" t.term ] + |> List.sort compare diff --git a/lib/naming/lang.mli b/lib/naming/lang.mli new file mode 100644 index 0000000..36931d4 --- /dev/null +++ b/lib/naming/lang.mli @@ -0,0 +1,60 @@ +(** A language table: strings to strings, nothing more. + + Knows nothing about calendars, dates or rites, and never touches the + filesystem -- callers hand it text. That is what lets every command use it + without the kernel learning about presentation. + + Every lookup is TOTAL. A key with no entry returns THE KEY ITSELF, never the + empty string: a partial translation must be usable from its first line, and + an untranslated day must still print something a reader can act on. This is + also why the pre-naming output (bare slugs) is exactly what an empty table + produces -- the degraded case is the old behaviour, not a blank page. *) + +type t + +(** Parse INI text. Never raises. [Error] on a malformed file or a missing + [\[meta\] lang]. + + Two duplicate policies, both LAST-WINS: + - A section name repeated in the file (e.g. two [\[celebration\]] + blocks) has ALL of its blocks merged, not only the first -- a + 595-entry hand-edited language file WILL grow duplicate section + headers as contributors append entries over time, and dropping a + later block would silently lose real translations. + - Where the same key appears more than once -- within one block or + across two of them -- the value from further down the file wins. + + Both read the same order a reader would: later in the file overrides + earlier. This is the OPPOSITE direction from + {!Colitur_kernel.Overlay_ini.get} ([List.assoc_opt], first match) over + the very same [section.fields] shape -- the two modules resolve a + duplicate key in opposite directions, so do not assume one's behaviour + from the other's. *) +val of_string : string -> (t, string) result + +val code : t -> string +val fallback_code : t -> string option + +(** [with_fallback t base] resolves through [t] first, then [base], then the key. *) +val with_fallback : t -> t -> t + +(** The identity table: every lookup returns its key. This is what [--raw] uses, + so raw output is one table passed around rather than a special case threaded + through every call site. *) +val raw : t + +val celebration : t -> string -> string +val season : t -> string -> string +val rank : t -> string -> string +val colour : t -> string -> string +val term : t -> string -> string + +(** [weekday t n], 0 = Sunday. Out-of-range [n] returns [string_of_int n]. *) +val weekday : t -> int -> string + +(** [month t n], 1 = January. Out-of-range [n] returns [string_of_int n]. *) +val month : t -> int -> string + +(** Every (section-qualified key, value) pair, sorted. Used by [lang --dump] and + [lang --check]. Keys are qualified as e.g. ["celebration.ef-epiphany"]. *) +val keys : t -> (string * string) list 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). *) |
