diff options
Diffstat (limited to 'lib/render')
| -rw-r--r-- | lib/render/dune | 5 | ||||
| -rw-r--r-- | lib/render/emit_csv.ml | 44 | ||||
| -rw-r--r-- | lib/render/emit_csv.mli | 9 | ||||
| -rw-r--r-- | lib/render/emit_ics.ml | 107 | ||||
| -rw-r--r-- | lib/render/emit_ics.mli | 15 | ||||
| -rw-r--r-- | lib/render/emit_json.ml | 47 | ||||
| -rw-r--r-- | lib/render/emit_json.mli | 10 | ||||
| -rw-r--r-- | lib/render/emit_xml.ml | 48 | ||||
| -rw-r--r-- | lib/render/emit_xml.mli | 8 | ||||
| -rw-r--r-- | lib/render/escape.ml | 95 | ||||
| -rw-r--r-- | lib/render/escape.mli | 28 | ||||
| -rw-r--r-- | lib/render/template.ml | 151 | ||||
| -rw-r--r-- | lib/render/template.mli | 37 | ||||
| -rw-r--r-- | lib/render/view.ml | 149 | ||||
| -rw-r--r-- | lib/render/view.mli | 24 |
15 files changed, 777 insertions, 0 deletions
diff --git a/lib/render/dune b/lib/render/dune new file mode 100644 index 0000000..7880188 --- /dev/null +++ b/lib/render/dune @@ -0,0 +1,5 @@ +(library + (name colitur_render) + (libraries colitur_kernel sexplib) + (preprocess + (pps ppx_sexp_conv))) diff --git a/lib/render/emit_csv.ml b/lib/render/emit_csv.ml new file mode 100644 index 0000000..b478265 --- /dev/null +++ b/lib/render/emit_csv.ml @@ -0,0 +1,44 @@ +(* emit_csv.ml *) +module T = Template + +let escape_field s = + let needs = + String.exists (fun c -> c = ',' || c = '"' || c = '\n' || c = '\r') s + in + if not needs then s + else begin + let b = Buffer.create (String.length s + 8) in + Buffer.add_char b '"'; + String.iter (fun c -> if c = '"' then Buffer.add_string b "\"\"" else Buffer.add_char b c) s; + Buffer.add_char b '"'; + Buffer.contents b + end + +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 -> "" + +let columns = + [ "date"; "rite"; "season"; "week"; "slug"; "rank"; "colour"; "subject"; + "name_la"; "name_en"; "first"; "gospel"; "comms" ] + +let row ~rite d = + let comms = + match get d "comms" with + | Some (T.List l) -> String.concat " " (List.map (fun c -> s c "slug") l) + | _ -> "" + 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 ]) + +let year v = + let rite = s v "rite" in + let days = match get v "days" with Some (T.List l) -> l | _ -> [] in + let b = Buffer.create (64 * 400) in + Buffer.add_string b (String.concat "," columns); + Buffer.add_char b '\n'; + List.iter (fun d -> Buffer.add_string b (row ~rite d); Buffer.add_char b '\n') days; + Buffer.contents b diff --git a/lib/render/emit_csv.mli b/lib/render/emit_csv.mli new file mode 100644 index 0000000..262f660 --- /dev/null +++ b/lib/render/emit_csv.mli @@ -0,0 +1,9 @@ +(* emit_csv.mli *) +(** One row per day, RFC 4180. Consumes the VIEW, not the kernel, so every + emitter describes exactly the same fields as every template. *) + +(** RFC 4180 section 2: quote a field containing a comma, a quote or a newline; + double an embedded quote. Exposed for testing. *) +val escape_field : string -> string + +val year : Template.value -> string diff --git a/lib/render/emit_ics.ml b/lib/render/emit_ics.ml new file mode 100644 index 0000000..41efb95 --- /dev/null +++ b/lib/render/emit_ics.ml @@ -0,0 +1,107 @@ +(* emit_ics.ml *) +module T = Template + +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 compact iso = (* "2027-01-13" -> "20270113" *) + String.concat "" (String.split_on_char '-' iso) + +(* Civil-date successor without touching the kernel: parse, add, re-format via + Colitur_kernel.Date, which is already total and validated over 1583..9999. *) +let next_day iso = + match Colitur_kernel.Date.of_iso8601 iso with + | Ok d -> Colitur_kernel.Date.to_iso8601 (Colitur_kernel.Date.add_days d 1) + (* Dead on shipped data: [event]'s own [iso <> ""] guard is the only + caller, and every real [iso] it passes came from [Date.to_iso8601] in + the first place, so it always parses. Left total rather than raising + (the kernel/render determinism rule), but a caller relying on this + branch would silently get DTEND == DTSTART -- a zero-length event -- + with no diagnostic, if it were ever actually reached. Documented, not + fixed: nothing exercises it. *) + | Error _ -> iso + +(* RFC 5545 section 3.6.1: a VEVENT with a DATE-valued DTSTART and NEITHER + DTEND NOR DURATION has an implicit duration of exactly one day, so + omitting DTEND for the domain's own last day is the standard's own + correct way to say precisely what we mean -- not a workaround. + + Needed because [Date.add_days] is itself UNBOUNDED (date.mli: "may + denote a year outside 1583..9999 -- only [make] enforces the domain"): + the successor of 9999-12-31 is a real [Date.t] that [next_day] above + happily renders as "10000-01-01" (date.ml's [to_iso8601] pads with + [Printf.sprintf "%04d-..."] but never truncates), which [compact] would + turn into a 9-digit, non-conformant DATE. [Date.make] is the ONE kernel + function that actually enforces 1583..9999 (date.mli), so this takes + [next_day]'s own successor string, re-derives its year/month/day, and + re-validates THOSE through [make] before trusting the string at all. + [None] means "one day past the domain's own last day"; the caller omits + DTEND entirely rather than clamp, truncate, or fall back to DURATION. *) +let dtend_of iso = + let next = next_day iso in + match String.split_on_char '-' next with + | [ y; m; d ] -> ( + match (int_of_string_opt y, int_of_string_opt m, int_of_string_opt d) with + | Some year, Some month, Some day -> ( + match Colitur_kernel.Date.make ~year ~month ~day with + | Ok _ -> Some next + | Error _ -> None) + | _ -> None) + | _ -> None + +let esc x = Escape.apply Escape.Ics x +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 + let summary = Printf.sprintf "%s (%s, %s)" name (s d "rank") (s d "colour") in + let desc = + String.concat "\n" + (List.filter (fun x -> x <> "") + [ (if s d "first" <> "" then "Epistle " ^ s d "first" else ""); + (if s d "gospel" <> "" then "Gospel " ^ s d "gospel" else "") ]) + in + line b "BEGIN:VEVENT"; + (* RFC 5545 section 3.8.4.7: the UID must be stable across regenerations, + or every subscriber gets a duplicate of the whole year. *) + line b (Printf.sprintf "UID:%s-%s@colitur" (compact iso) rite); + line b ("DTSTAMP:" ^ dtstamp); + line b ("DTSTART;VALUE=DATE:" ^ compact iso); + (* RFC 5545 section 3.6.1: DTEND is EXCLUSIVE for an all-day event -- and + omitted entirely, rather than emitted malformed, for the one event + whose successor falls outside the engine's own 1583..9999 domain + (see [dtend_of] above). *) + (match dtend_of iso with + | Some next -> line b ("DTEND;VALUE=DATE:" ^ compact next) + | None -> ()); + line b ("SUMMARY:" ^ esc summary); + if desc <> "" then line b ("DESCRIPTION:" ^ esc desc); + line b "TRANSP:TRANSPARENT"; + line b "END:VEVENT" + end + +let year ?dtstamp ?calname v = + let rite = s v "rite" in + let y = s v "year" in + let dtstamp = match dtstamp with Some x -> x | None -> y ^ "0101T000000Z" in + let calname = match calname with Some x -> x | None -> "colitur " ^ rite ^ " " ^ y in + let b = Buffer.create (256 * 1024) in + line b "BEGIN:VCALENDAR"; + line b "VERSION:2.0"; + line b "PRODID:-//colitur//liturgical calendar//EN"; + line b "CALSCALE:GREGORIAN"; + line b "METHOD:PUBLISH"; + (* Non-standard but universally honoured; without it clients show the URL. *) + line b ("X-WR-CALNAME:" ^ esc calname); + (match get v "days" with Some (T.List l) -> List.iter (event b ~rite ~dtstamp) l | _ -> ()); + line b "END:VCALENDAR"; + Buffer.contents b diff --git a/lib/render/emit_ics.mli b/lib/render/emit_ics.mli new file mode 100644 index 0000000..270bd76 --- /dev/null +++ b/lib/render/emit_ics.mli @@ -0,0 +1,15 @@ +(* emit_ics.mli *) +(** RFC 5545 iCalendar. NOT a template job: folding, escaping, exclusive DTEND + and stable UIDs are rules a logic-less template cannot enforce, and getting + any of them wrong produces a feed that fails silently in a subscriber's + client (spec section 6). *) + +(** [year ?dtstamp ?calname view]. + + [dtstamp] defaults to [YYYY0101T000000Z] for the view's own year. It is a + PARAMETER, never a clock read: RFC 5545 requires DTSTAMP, the obvious + implementation reads the wall clock, and that would both violate the + kernel's determinism rule and make two feeds from identical data differ + byte-for-byte -- defeating reproducible builds and a reviewable git diff on + a published tree. Same data in, same bytes out. *) +val year : ?dtstamp:string -> ?calname:string -> Template.value -> string diff --git a/lib/render/emit_json.ml b/lib/render/emit_json.ml new file mode 100644 index 0000000..272e4ca --- /dev/null +++ b/lib/render/emit_json.ml @@ -0,0 +1,47 @@ +(* emit_json.ml *) +module T = Template + +let escape_string s = + let b = Buffer.create (String.length s + 8) in + Buffer.add_char b '"'; + String.iter + (fun c -> + match c with + | '"' -> Buffer.add_string b "\\\"" + | '\\' -> Buffer.add_string b "\\\\" + | '\n' -> Buffer.add_string b "\\n" + | '\r' -> Buffer.add_string b "\\r" + | '\t' -> Buffer.add_string b "\\t" + | c when Char.code c < 0x20 -> Buffer.add_string b (Printf.sprintf "\\u%04x" (Char.code c)) + | c -> Buffer.add_char b c) + s; + Buffer.add_char b '"'; + Buffer.contents b + +(* Emit the view tree directly. Bools stay bools; everything else is a string, + an array or an object -- there are no numbers in the view, deliberately, so a + consumer never has to guess whether "week" is 2 or "2". *) +let rec write b (v : T.value) = + match v with + | T.Str s -> Buffer.add_string b (escape_string s) + | T.Bool x -> Buffer.add_string b (if x then "true" else "false") + | T.List l -> + Buffer.add_char b '['; + List.iteri (fun i x -> if i > 0 then Buffer.add_char b ','; write b x) l; + Buffer.add_char b ']' + | T.Obj kvs -> + Buffer.add_char b '{'; + List.iteri + (fun i (k, x) -> + if i > 0 then Buffer.add_char b ','; + Buffer.add_string b (escape_string k); + Buffer.add_char b ':'; + write b x) + kvs; + Buffer.add_char b '}' + +let year v = + let b = Buffer.create (64 * 1024) in + write b v; + Buffer.add_char b '\n'; + Buffer.contents b diff --git a/lib/render/emit_json.mli b/lib/render/emit_json.mli new file mode 100644 index 0000000..91c8f48 --- /dev/null +++ b/lib/render/emit_json.mli @@ -0,0 +1,10 @@ +(* emit_json.mli *) +(** JSON writer. Hand-rolled: the dependency list is frozen (spec section 1), + and writing JSON is a page of code where escaping is the only subtlety. + Output shape is pinned by schema/day-v1.json, the published contract. *) + +(** RFC 8259 section 7, including \u-escaping of control characters below 0x20. + Returns the value WITH its surrounding quotes. Exposed for testing. *) +val escape_string : string -> string + +val year : Template.value -> string diff --git a/lib/render/emit_xml.ml b/lib/render/emit_xml.ml new file mode 100644 index 0000000..4e4b51a --- /dev/null +++ b/lib/render/emit_xml.ml @@ -0,0 +1,48 @@ +(* emit_xml.ml *) +module T = Template + +let escape s = Escape.apply Escape.Xml 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 el b name value = + Buffer.add_string b (" <" ^ name ^ ">" ^ escape value ^ "</" ^ name ^ ">\n") + +let day b d = + Buffer.add_string b (" <day date=\"" ^ escape (s d "iso") ^ "\">\n"); + el b "season" (s d "season"); + el b "week" (s d "week"); + el b "slug" (s d "slug"); + el b "rank" (s d "rank"); + el b "colour" (s d "colour"); + 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 + | _ -> ()); + let cite name value = + if value <> "" then + Buffer.add_string b (" <citation part=\"" ^ name ^ "\">" ^ escape value ^ "</citation>\n") + in + cite "first" (s d "first"); + cite "gospel" (s d "gospel"); + Buffer.add_string b " </day>\n" + +let year v = + let b = Buffer.create (128 * 1024) in + Buffer.add_string b "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"; + Buffer.add_string b + ("<calendar rite=\"" ^ escape (s v "rite") ^ "\" year=\"" ^ escape (s v "year") ^ "\">\n"); + Buffer.add_string b " <days>\n"; + (match get v "days" with Some (T.List l) -> List.iter (day b) l | _ -> ()); + Buffer.add_string b " </days>\n</calendar>\n"; + Buffer.contents b diff --git a/lib/render/emit_xml.mli b/lib/render/emit_xml.mli new file mode 100644 index 0000000..4d539d6 --- /dev/null +++ b/lib/render/emit_xml.mli @@ -0,0 +1,8 @@ +(* emit_xml.mli *) +(** Element-per-field XML. Attributes carry identity only (rite, year, date); + everything else is an element, and there is no mixed content -- so a + consumer's XPath never has to distinguish the two. Shape pinned by + schema/colitur-v1.xsd. *) + +val escape : string -> string +val year : Template.value -> string diff --git a/lib/render/escape.ml b/lib/render/escape.ml new file mode 100644 index 0000000..f506c44 --- /dev/null +++ b/lib/render/escape.ml @@ -0,0 +1,95 @@ +type flavour = Latex | Groff | Html | Xml | Ics | None_ + +let all = [ Latex; Groff; Html; Xml; Ics; None_ ] + +let to_string = function + | Latex -> "latex" | Groff -> "groff" | Html -> "html" + | Xml -> "xml" | Ics -> "ics" | None_ -> "none" + +let of_string = function + | "latex" -> Some Latex | "groff" -> Some Groff | "html" -> Some Html + | "xml" -> Some Xml | "ics" -> Some Ics | "none" -> Some None_ + | _ -> None + +let of_extension = function + | ".tex" -> Some Latex + | ".ms" | ".mom" | ".me" -> Some Groff + | ".html" | ".htm" -> Some Html + | ".xml" -> Some Xml + | ".ics" -> Some Ics + | ".md" | ".adoc" | ".txt" -> Some None_ + | _ -> None + +(* Replace each character with its expansion, in ONE pass. A sequence of + String.concat replacements would double-escape: "&" -> "\\&" then the + backslash rule would rewrite the backslash it just introduced. *) +let expand f s = + let b = Buffer.create (String.length s + 16) in + String.iter (fun c -> Buffer.add_string b (f c)) s; + Buffer.contents b + +let latex = function + | '\\' -> "\\textbackslash{}" + | '{' -> "\\{" | '}' -> "\\}" + | '$' -> "\\$" | '&' -> "\\&" | '#' -> "\\#" + | '_' -> "\\_" | '%' -> "\\%" + | '^' -> "\\textasciicircum{}" + | '~' -> "\\textasciitilde{}" + | c -> String.make 1 c + +let html = function + | '&' -> "&" | '<' -> "<" | '>' -> ">" + | '"' -> """ | '\'' -> "'" + | c -> String.make 1 c + +let ics = function + | '\\' -> "\\\\" | ';' -> "\\;" | ',' -> "\\," + | '\n' -> "\\n" | '\r' -> "" + | c -> String.make 1 c + +(* groff: a backslash starts an escape, and a '.' or '\'' in COLUMN ONE starts a + request. \& is the zero-width non-printing character that defuses it. *) +let groff s = + let escaped = expand (function '\\' -> "\\e" | c -> String.make 1 c) s in + if String.length escaped > 0 && (escaped.[0] = '.' || escaped.[0] = '\'') then "\\&" ^ escaped + else escaped + +let apply flavour s = + match flavour with + | Latex -> expand latex s + | Groff -> groff s + | Html | Xml -> expand html s + | Ics -> expand ics s + | None_ -> s + +(* RFC 5545 section 3.1. A continuation byte is 0x80-0xBF; backing off to a + non-continuation byte keeps every fold on a character boundary. *) +let fold_ics line = + let n = String.length line in + let b = Buffer.create (n + (n / 70) + 8) in + let is_cont c = Char.code c land 0xC0 = 0x80 in + let rec go pos first = + let limit = if first then 75 else 74 (* the leading space costs one octet *) in + if n - pos <= limit then ( + if not first then Buffer.add_char b ' '; + Buffer.add_string b (String.sub line pos (n - pos)); + Buffer.add_string b "\r\n") + else begin + let cut = ref (pos + limit) in + while !cut > pos && is_cont line.[!cut] do decr cut done; + (* Valid UTF-8's longest continuation run is 3, so backoff finds a + boundary within a few bytes. But this function must stay TOTAL on + ARBITRARY octet strings, not only valid UTF-8: 74+ consecutive + continuation bytes back `cut` all the way down to `pos`, which would + yield a zero-length chunk and recurse on the identical position + forever. When backoff finds no boundary inside the window, cut hard + at the limit instead -- forward progress is then unconditional. *) + if !cut = pos then cut := pos + limit; + if not first then Buffer.add_char b ' '; + Buffer.add_string b (String.sub line pos (!cut - pos)); + Buffer.add_string b "\r\n"; + go !cut false + end + in + go 0 true; + Buffer.contents b diff --git a/lib/render/escape.mli b/lib/render/escape.mli new file mode 100644 index 0000000..97cf050 --- /dev/null +++ b/lib/render/escape.mli @@ -0,0 +1,28 @@ +(** Per-flavour escaping for the template engine, plus RFC 5545 line folding. + + Knows nothing about calendars. Pure and total: every function is defined on + every string, and none reads the clock, the environment or the filesystem. *) + +(** The six escaping modes. Markdown, AsciiDoc and plain text all use [None_]: + their metacharacter sets are context-dependent, and escaping them + aggressively produces worse output than not escaping at all (spec section 5). + This is a documented limitation of those flavours. *) +type flavour = Latex | Groff | Html | Xml | Ics | None_ + +val all : flavour list +val to_string : flavour -> string +val of_string : string -> flavour option + +(** [of_extension ".tex"] is [Some Latex]. Returns [None] for an unrecognised + extension: the caller must treat that as an error, never as a fallback to + [None_] (spec section 5 -- guessing wrong here produces malformed output + that looks fine until it does not). *) +val of_extension : string -> flavour option + +(** Escape one interpolated value for [flavour]. *) +val apply : flavour -> string -> string + +(** Fold one unfolded content line per RFC 5545 section 3.1: at most 75 octets + per line, continuation lines prefixed with one space, CRLF terminators, + never splitting a UTF-8 sequence. The returned string ends with CRLF. *) +val fold_ics : string -> string diff --git a/lib/render/template.ml b/lib/render/template.ml new file mode 100644 index 0000000..7efa92b --- /dev/null +++ b/lib/render/template.ml @@ -0,0 +1,151 @@ +type value = + | Str of string + | Bool of bool + | List of value list + | Obj of (string * value) list + +type node = + | Text of string + | Var of string list + | Section of string list * node list + | Inverted of string list * node list + +type tag = TVar of string list | TOpen of string list | TInv of string list | TClose of string list | TComment + +let trim = String.trim + +let path s = + String.split_on_char '.' (trim s) |> List.map trim |> List.filter (fun x -> x <> "") + +(* Lex into a flat list of text and tags. Returns Error on an unterminated tag + rather than treating the remainder as text: silently swallowing "{{name" is + how a typo becomes invisible missing output. *) +let lex src = + let n = String.length src in + let out = ref [] in + let buf = Buffer.create 256 in + let flush () = + if Buffer.length buf > 0 then (out := `Text (Buffer.contents buf) :: !out; Buffer.clear buf) + in + let rec go i = + if i >= n then (flush (); Ok (List.rev !out)) + else if i + 1 < n && src.[i] = '{' && src.[i + 1] = '{' then + match String.index_from_opt src (i + 2) '}' with + | Some j when j + 1 < n && src.[j + 1] = '}' -> + let body = String.sub src (i + 2) (j - i - 2) in + flush (); + let tag = + let b = trim body in + if b = "" then Error "empty tag {{}}" + else + (* Every sigil form (var, #, ^, /) resolves a dotted path; an + empty path ("{{.}}", "{{#}}", "{{^}}", "{{/}}") names + nothing -- there is no "current context" concept for a bare + dot to mean, so it is a parse error rather than an invented + behaviour Task 3 would have to define. *) + let with_path ctor rest = + match path rest with + | [] -> Error "empty tag path: {{.}} and {{#}} name nothing" + | p -> Ok (ctor p) + in + match b.[0] with + | '#' -> with_path (fun p -> TOpen p) (String.sub b 1 (String.length b - 1)) + | '^' -> with_path (fun p -> TInv p) (String.sub b 1 (String.length b - 1)) + | '/' -> with_path (fun p -> TClose p) (String.sub b 1 (String.length b - 1)) + | '!' -> Ok TComment + | '>' -> Error "partials are not supported: a template may not include another file" + | _ -> with_path (fun p -> TVar p) b + in + (match tag with + | Error e -> Error e + | Ok t -> out := `Tag t :: !out; go (j + 2)) + | _ -> Error "unterminated tag: '{{' with no matching '}}'" + else (Buffer.add_char buf src.[i]; go (i + 1)) + in + go 0 + +(* Fold the flat list into a tree, checking that every close matches its open. *) +let build items = + let rec go acc stack items = + match items with + | [] -> ( + match stack with + | [] -> Ok (List.rev acc) + | (p, _, _) :: _ -> Error ("unclosed section {{#" ^ String.concat "." p ^ "}}")) + | `Text "" :: rest -> go acc stack rest + | `Text t :: rest -> go (Text t :: acc) stack rest + | `Tag TComment :: rest -> go acc stack rest + | `Tag (TVar p) :: rest -> go (Var p :: acc) stack rest + | `Tag (TOpen p) :: rest -> go [] ((p, `Sec, acc) :: stack) rest + | `Tag (TInv p) :: rest -> go [] ((p, `Inv, acc) :: stack) rest + | `Tag (TClose p) :: rest -> ( + match stack with + | [] -> Error ("close tag {{/" ^ String.concat "." p ^ "}} with no open section") + | (op, kind, outer) :: tl -> + if op <> p then + Error + ("mismatched close: {{#" ^ String.concat "." op ^ "}} closed by {{/" + ^ String.concat "." p ^ "}}") + else + let inner = List.rev acc in + let node = match kind with `Sec -> Section (op, inner) | `Inv -> Inverted (op, inner) in + go (node :: outer) tl rest) + in + go [] [] items + +let parse src = match lex src with Error e -> Error e | Ok items -> build items + +(* Scope is a STACK, innermost first: a section pushes its own object, and a + lookup falls back outward. Without the fallback a grid template could not + reach the year number from inside a week. *) +let rec lookup stack path = + match stack with + | [] -> None + | top :: rest -> ( + match descend top path with Some v -> Some v | None -> lookup rest path) + +and descend v path = + match (v, path) with + | _, [] -> Some v + | Obj kvs, k :: tl -> ( + match List.assoc_opt k kvs with Some v' -> descend v' tl | None -> None) + | _ -> None + +let truthy = function + | Bool b -> b + | Str "" -> false + | Str _ -> true + | List [] -> false + | List _ -> true + | Obj _ -> true + +let render ~flavour nodes value = + let b = Buffer.create 4096 in + let rec go stack nodes = + List.iter + (fun node -> + match node with + | Text t -> Buffer.add_string b t + | Var p -> ( + match lookup stack p with + | Some (Str s) -> Buffer.add_string b (Escape.apply flavour s) + | Some (Bool true) -> Buffer.add_string b "true" + | Some (Bool false) -> () + | Some (List _) | Some (Obj _) | None -> ()) + | Section (p, body) -> ( + match lookup stack p with + | None -> () + | Some (List items) -> List.iter (fun it -> go (it :: stack) body) items + | Some v when truthy v -> go (v :: stack) body + | Some _ -> ()) + | Inverted (p, body) -> ( + match lookup stack p with + | None -> go stack body + | Some v -> if not (truthy v) then go stack body)) + nodes + in + go [ value ] nodes; + Buffer.contents b + +let render_string ~flavour src value = + match parse src with Error e -> Error e | Ok nodes -> Ok (render ~flavour nodes value) diff --git a/lib/render/template.mli b/lib/render/template.mli new file mode 100644 index 0000000..4118496 --- /dev/null +++ b/lib/render/template.mli @@ -0,0 +1,37 @@ +(** A deliberately logic-less template engine (spec section 5). + + A template is DATA, never a program: placeholders, sections, inverted + sections and comments, and nothing else. There are no partials, no lambdas, + no expression evaluation, no arithmetic, and no filesystem or process + access. There is deliberately no "raw" or triple-brace form -- a template + cannot opt out of its flavour's escaping. + + Anything a calendar needs that this cannot express (a month grid's leading + blanks, week bucketing) is computed in {!View} and handed in as data. That + division is the design, not a workaround. *) + +(** What a template can be rendered against. *) +type value = + | Str of string + | Bool of bool + | List of value list + | Obj of (string * value) list + +type node = + | Text of string + | Var of string list (** dotted path, e.g. ["name"; "la"] *) + | Section of string list * node list + | Inverted of string list * node list + +(** Never raises; a malformed template is an [Error] with a human-readable + reason, because the template is user input. *) +val parse : string -> (node list, string) result + +(** Render against a value. Escaping is applied to every interpolated value and + NEVER to the template's own literal text, which is the author's markup. + A missing key renders as the empty string -- the one deliberate silence, so + that a template survives a rite that does not set every optional field. *) +val render : flavour:Escape.flavour -> node list -> value -> string + +(** [parse] then [render]. *) +val render_string : flavour:Escape.flavour -> string -> value -> (string, string) result diff --git a/lib/render/view.ml b/lib/render/view.ml new file mode 100644 index 0000000..733aa45 --- /dev/null +++ b/lib/render/view.ml @@ -0,0 +1,149 @@ +module K = Colitur_kernel +module T = Template + +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 + +let citation_ref cits part = + match + List.find_opt (fun (c : K.Citation.t) -> c.K.Citation.part = part) cits + with + | Some c -> c.K.Citation.reference + | None -> "" + +let comm_value (c, priv) = + T.Obj + [ ("slug", str (K.Slug.to_string c.K.Celebration.slug)); + ("name", names_value c.K.Celebration.names); + ("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. *) +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 ""); + ("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 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 + 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)); + ("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)); + ("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)); + ( "transferred_in", + T.List + (match d.K.Liturgical_day.transferred_in with + | None -> [] + | Some c -> [ T.Obj [ ("slug", str (K.Slug.to_string c.K.Celebration.slug)) ] ]) ); + ( "transferred_out", + T.List + (List.map + (fun (c, dest) -> + T.Obj + [ ("slug", str (K.Slug.to_string c.K.Celebration.slug)); + ("to", str (K.Date.to_iso8601 dest)) ]) + d.K.Liturgical_day.transferred_out) ); + ("first", str (citation_ref d.K.Liturgical_day.citations K.Citation.First)); + ("gospel", str (citation_ref d.K.Liturgical_day.citations K.Citation.Gospel)); + (* overwritten per grid row by [set_last]; false in the flat [days] list *) + ("last", bool false) ] + +(* Bucket a month's day values into Sunday-started weeks of exactly seven cells, + padding both ends. This is the computation the template cannot do. *) +(* [last] is true on the seventh cell of a row. A table row needs its separator + BETWEEN cells and the engine has no "unless last" construct, so the flag is + data -- the same rule as [in_month]. Without it the LaTeX grid emits eight + columns for seven cells and pdflatex rejects the file. *) +let set_last cells = + List.mapi + (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 = + let lead = List.init first_dow (fun i -> padding_cell i) in + let cells = lead @ day_values in + let rec chunk acc = function + | [] -> List.rev acc + | rest -> + let take = min 7 (List.length rest) in + let week = List.filteri (fun i _ -> i < take) rest in + let tl = List.filteri (fun i _ -> i >= take) rest in + let week = + if take = 7 then week + else week @ List.init (7 - take) (fun i -> padding_cell (take + i)) + in + 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)) ]) + (chunk [] cells) + +let of_days ~vocab ~rite ~year days = + let dvs = List.map (fun d -> (d, day_value ~vocab d)) days in + let months = + List.init 12 (fun i -> + let m = i + 1 in + let own = + List.filter (fun (d, _) -> K.Date.month d.K.Liturgical_day.date = m) dvs + in + let day_values = List.map snd own in + let first_dow = + match own with + | (d, _) :: _ -> dow_int (K.Date.weekday d.K.Liturgical_day.date) + | [] -> 0 + in + let la, en = month_names.(i) in + T.Obj + [ ("num", str (string_of_int m)); + ("name", T.Obj [ ("la", str la); ("en", str en) ]); + ("days", T.List day_values); + ("weeks", T.List (weeks_of_month ~first_dow day_values)) ]) + in + T.Obj + [ ("rite", str rite); + ("year", str (string_of_int year)); + ("months", T.List months); + ("days", T.List (List.map snd dvs)) ] diff --git a/lib/render/view.mli b/lib/render/view.mli new file mode 100644 index 0000000..fb48590 --- /dev/null +++ b/lib/render/view.mli @@ -0,0 +1,24 @@ +(** Shapes a civil year of resolved days into the value a template renders + against (spec section 4). + + This layer exists because a month grid needs leading blank cells, week + bucketing and an "is this cell in the current month" test, and a logic-less + template can compute none of it. Shaping the data here keeps the engine + logic-less AND makes the grid template trivial. The view model IS the design. + + Both [weeks] and [days] are offered at every level: the booklet walks + [days], the grid walks [weeks]. One model, two artefacts, no second code + path that could drift. + + Colours are exposed as six booleans rather than a hex string: hex would bake + a presentation policy into the engine, and LaTeX, groff and HTML each want a + different colour expression. Exactly one of the six is true on every day. *) + +val of_days : + 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. *) |
