aboutsummaryrefslogtreecommitdiff
path: root/lib/render/escape.ml
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-19 11:48:30 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-19 11:48:30 +0200
commit6762ce46af3cb12bc6ae37cda762c5d95add7903 (patch)
treebc1d8c86050d0149ff961a9a4ff838f9c474ac2a /lib/render/escape.ml
parent897c274fd28402159ca6d45eedc1257b1ce98696 (diff)
parent390bc6ac5196a946c473d0dbe7760fa41837c428 (diff)
downloadcolitur-6762ce46af3cb12bc6ae37cda762c5d95add7903.tar.gz
colitur-6762ce46af3cb12bc6ae37cda762c5d95add7903.zip
feat: output, rendering and publishing
Gives colitur a publishable exit. Until now its only output was terminal rows; it can now print an ordo booklet and a wall calendar, publish an iCalendar feed people subscribe to, and serve a static JSON/XML API. lib/render escaping (six flavours + RFC 5545 folding), a deliberately logic-less template engine, the view model, and five emitters (CSV, JSON, XML, iCalendar, S-expression) CLI emit, table, render, publish -- all accepting --overlay templates ordo booklet in six flavours, wall grid in three schema day-v1.json and colitur-v1.xsd, the published contract man colitur-templates.5, plus colitur.1 updates The view model is why the engine can stay logic-less: a month grid needs leading blank cells, week bucketing and an in-month test, and a logic-less template can compute none of it. Shaping the data in OCaml keeps the engine safe for untrusted templates and makes the grid trivial. Formats split by whether correctness is mechanical. Presentation goes through templates; iCalendar and XML get dedicated emitters, because folding, exclusive DTEND, stable UIDs and schema fidelity are rules a template cannot enforce and each fails silently in a subscriber's client rather than loudly at generation. publish is deterministic and non-destructive: two runs produce a byte-identical tree, and --prune removes only files a previous run created, refusing any manifest entry that escapes the output directory. No new dependencies. The kernel and rite modules are untouched, and colitur day and colitur readings remain byte-identical.
Diffstat (limited to 'lib/render/escape.ml')
-rw-r--r--lib/render/escape.ml95
1 files changed, 95 insertions, 0 deletions
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
+ | '&' -> "&amp;" | '<' -> "&lt;" | '>' -> "&gt;"
+ | '"' -> "&quot;" | '\'' -> "&#39;"
+ | 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