aboutsummaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/kernel/overlay_ini.mli9
-rw-r--r--lib/naming/config.ml80
-rw-r--r--lib/naming/config.mli49
-rw-r--r--lib/naming/dune3
-rw-r--r--lib/naming/lang.ml109
-rw-r--r--lib/naming/lang.mli60
-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
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). *)