From decb13fb17697f6d6d952c407f2173a714164804 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 19 Aug 2026 09:19:14 +0200 Subject: feat(cli): colitur emit -- csv, json, sexp, xml, ics Reuses resolved_year_report's existing two-liturgical-year indexing rather than copying it: that walk owns the civil-vs-liturgical span reasoning, and a second copy would drift. It is refactored to return the days, with the printer layered on top, so day and readings behave identically -- which cli.t proves byte-for-byte. CSV emits one header for a whole multi-year run, not one per year. A reversed range is a usage error rather than silently empty output. Asserted in cli.t: two ics runs are byte-identical, because nothing in the path reads a clock. --- bin/dune | 2 +- bin/main.ml | 166 +++++++++++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 150 insertions(+), 18 deletions(-) (limited to 'bin') diff --git a/bin/dune b/bin/dune index c79bbf1..376fe70 100644 --- a/bin/dune +++ b/bin/dune @@ -2,4 +2,4 @@ (name main) (public_name colitur) (package colitur) - (libraries colitur_kernel rite_ef)) + (libraries colitur_kernel rite_ef colitur_render)) diff --git a/bin/main.ml b/bin/main.ml index 231d003..ec7fd71 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -314,12 +314,15 @@ let load_ef_data ?(user_overlays = []) () = | Error msg -> Error msg | Ok commons -> Ok (layer, lectionary, commons))) -(* The resolved-year walk, shared by [day_report] and [readings_report]: they - differ only in how each day is printed, and the two-liturgical-year - indexing below (with its own reasoning about civil-vs-liturgical spans) is - exactly the part that must not be duplicated and drift. [line] is the only - difference between the two commands. *) -let resolved_year_report ~line ~overlays y = +(* The resolved-year walk, shared by [day_report], [readings_report] and + [emit_report] (Task 8): they differ only in what happens to each day, and + the two-liturgical-year indexing below (with its own reasoning about + civil-vs-liturgical spans) is exactly the part that must not be duplicated + and drift. [resolved_year_days] owns that walk and returns the resolved + days, in date order, for one civil year; every caller layers its own + handling (a line-printer, an accumulator for a whole-year [Template.value]) + on top rather than repeating the indexing. *) +let resolved_year_days ~overlays y = match load_ef_data ~user_overlays:overlays () with | Error msg -> Printf.eprintf "colitur: %s\n" msg; @@ -341,9 +344,10 @@ let resolved_year_report ~line ~overlays y = let jan1 = match D.make ~year:y ~month:1 ~day:1 with Ok t -> t | Error e -> failwith e in let dec31 = match D.make ~year:y ~month:12 ~day:31 with Ok t -> t | Error e -> failwith e in let d = ref jan1 in + let acc = ref [] in while D.compare !d dec31 <= 0 do (match Hashtbl.find_opt by_rata (D.to_rata !d) with - | Some day -> line day + | Some day -> acc := day :: !acc | None -> (* Unreachable for any [y] in 1583..9999: the two indexed liturgical years jointly cover [year_start (y-1), year_start @@ -357,11 +361,71 @@ let resolved_year_report ~line ~overlays y = loudly on stderr rather than as a quietly short year. *) Printf.eprintf "colitur: internal error: no resolved day for %s\n" (D.to_iso8601 !d)); d := D.add_days !d 1 - done + done; + List.rev !acc + +let resolved_year_report ~line ~overlays y = + List.iter line (resolved_year_days ~overlays y) let day_report ~overlays y = resolved_year_report ~line:day_line ~overlays y let readings_report ~overlays y = resolved_year_report ~line:readings_line ~overlays y +(* Task 8: `colitur emit` -- the five template-family emitters built in + Tasks 5-7, wired to a year RANGE rather than a single year, because a + published feed (ics) or a data export (csv/json/xml) is usually wanted + for more than one civil year at a time. Reuses [resolved_year_days] + rather than re-walking the two-liturgical-year index: see that + function's own comment. + + CSV is the one format that spans years in a single stream deliberately + printed as ONE header followed by every year's rows: emitting a fresh + header per year would make `wc -l` and `awk 'NR>1'` both wrong on a + multi-year run, and nothing about RFC 4180 requires a header per file + rather than per stream. json/xml/sexp/ics are printed once per year + instead -- concatenating whole JSON objects or VCALENDARs into one + stream is what each of those formats itself expects a multi-document + feed to look like (SEXP: printed one form per line, matching the + sexp-per-day shape [Liturgical_day.t] already uses elsewhere in this + file; XML: one document per year, the schema's own root is a single + year; ICS: one VCALENDAR per year, valid to concatenate for a + subscriber that reads multiple files). *) +let emit_report ~format ~overlays ~dtstamp ~from_y ~to_y = + if from_y > to_y then begin + Printf.eprintf "colitur: --from %d is after --to %d\n" from_y to_y; + exit 2 + end; + for y = from_y to to_y do + let days = resolved_year_days ~overlays y in + let v = + Colitur_render.View.of_days ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days + in + match format with + | "csv" -> + (* One header for the whole run, not one per year. *) + let body = Colitur_render.Emit_csv.year v in + if y = from_y then print_string body + else + print_string + (match String.index_opt body '\n' with + | Some i -> String.sub body (i + 1) (String.length body - i - 1) + | None -> body) + | "json" -> print_string (Colitur_render.Emit_json.year v) + | "xml" -> print_string (Colitur_render.Emit_xml.year v) + | "ics" -> print_string (Colitur_render.Emit_ics.year ?dtstamp v) + | "sexp" -> + List.iter + (fun d -> + print_string + (Sexplib.Sexp.to_string_hum + (Colitur_kernel.Liturgical_day.sexp_of_t + Rite_ef.Vocab_ef.sexp_of_season Rite_ef.Vocab_ef.sexp_of_rank d)); + print_newline ()) + days + | other -> + Printf.eprintf "colitur: unknown format %S (want csv, json, sexp, xml or ics)\n" other; + exit 2 + done + (* Help and usage are deliberately DIFFERENT things, and the difference is the Unix convention rather than a preference: asking for help is a request that SUCCEEDED, so [--help] prints to stdout and exits 0 (it can be piped into a @@ -388,6 +452,9 @@ usage: colitur day the resolved day identity, one line per day colitur readings the Mass reading citations, one line per day colitur day|readings --overlay FILE [--overlay FILE ...] + colitur emit --format csv|json|sexp|xml|ics --from Y --to Y + [--overlay FILE ...] [--dtstamp S] + render a resolved year range through one of five emitters colitur new-overlay print a starter overlay file to stdout colitur convert FILE.ini flat INI overlay -> S-expression, on stdout colitur check FILE ... load an overlay, say what it does, exit 2 if not @@ -407,6 +474,13 @@ output formats: day stays space-separated; that is why they are separate commands rather than extra columns. + emit one schema (season, week, slug, rank, colour, subject, names, + citations, commemorations), rendered five ways: csv (RFC 4180, + one header for the whole run), json, sexp, xml (schema/colitur- + v1.xsd) and ics (RFC 5545). --from/--to give a civil-year range, + inclusive. --dtstamp fixes the ics DTSTAMP so two runs over the + same data are byte-identical -- the engine reads no clock. + overlays: --overlay FILE (repeatable, ordered; -o) applies a user calendar ON TOP of the shipped universal one, never instead of it, so local feasts @@ -485,12 +559,33 @@ let with_year ys f = [--overlay] accumulates in the order given, and that order is load-bearing ({!Overlay.merge} is last-writer-wins), so the list is reversed exactly - once at the end rather than callers guessing. *) + once at the end rather than callers guessing. + + [--format]/[--from]/[--to]/[--dtstamp] (Task 8, `emit`) are each single- + valued, unlike [--overlay], so they are plain [string option] fields + rather than accumulating lists. *) +type parsed_args = { + overlays : string list; + format : string option; + from_y : string option; + to_y : string option; + dtstamp : string option; + positional : string list; +} + let parse_args argv = - let rec go overlays positional = function - | [] -> Ok (List.rev overlays, List.rev positional) - | ("--overlay" | "-o") :: path :: rest -> go (path :: overlays) positional rest + let rec go acc = function + | [] -> Ok { acc with overlays = List.rev acc.overlays; positional = List.rev acc.positional } + | ("--overlay" | "-o") :: path :: rest -> go { acc with overlays = path :: acc.overlays } rest | [ ("--overlay" | "-o") ] -> Error "--overlay needs a file path" + | "--format" :: v :: rest -> go { acc with format = Some v } rest + | [ "--format" ] -> Error "--format needs a value" + | "--from" :: v :: rest -> go { acc with from_y = Some v } rest + | [ "--from" ] -> Error "--from needs a value" + | "--to" :: v :: rest -> go { acc with to_y = Some v } rest + | [ "--to" ] -> Error "--to needs a value" + | "--dtstamp" :: v :: rest -> go { acc with dtstamp = Some v } rest + | [ "--dtstamp" ] -> Error "--dtstamp needs a value" (* The recognised bare flags pass through as positional words for the dispatch below to match; anything else beginning with '-' is rejected rather than silently treated as a command or a year. *) @@ -499,9 +594,21 @@ let parse_args argv = && arg.[0] = '-' && not (List.mem arg [ "-h"; "--help"; "-V"; "--version" ]) -> Error (Printf.sprintf "unknown option %s" arg) - | arg :: rest -> go overlays (arg :: positional) rest + | arg :: rest -> go { acc with positional = arg :: acc.positional } rest in - go [] [] argv + go { overlays = []; format = None; from_y = None; to_y = None; dtstamp = None; positional = [] } argv + +(* Sibling to [reject_overlays_for]: `emit`'s own four flags have no meaning + on any other command (they take a single [] positional, not a + [--from]/[--to] range), so accepting and silently dropping them would be + the same failure mode `--overlay` already refuses on `easter`/`temporal`. *) +let reject_emit_flags_for cmd ~format ~from_y ~to_y ~dtstamp = + if format <> None || from_y <> None || to_y <> None || dtstamp <> None then begin + Printf.eprintf + "colitur: --format/--from/--to/--dtstamp have no effect on `%s`; refusing rather than ignoring them\n" + cmd; + exit 2 + end (* [easter] reads no calendar data at all, and [temporal] deliberately runs the temporal cycle BEFORE any sanctoral layer exists, so an overlay could not @@ -677,31 +784,56 @@ let () = | Error msg -> Printf.eprintf "colitur: %s\n" msg; usage () - | Ok (overlays, positional) -> ( + | Ok { overlays; format; from_y; to_y; dtstamp; positional } -> ( + let reject_emit = reject_emit_flags_for ~format ~from_y ~to_y ~dtstamp in match positional with | [ ("-h" | "--help" | "help") ] -> reject_overlays_for "--help" overlays; + reject_emit "--help"; print_help () | [ ("-V" | "--version" | "version") ] -> reject_overlays_for "--version" overlays; + reject_emit "--version"; print_endline version; exit 0 | [ "easter"; ys ] -> reject_overlays_for "easter" overlays; + reject_emit "easter"; with_year ys easter_report | [ "temporal"; ys ] -> reject_overlays_for "temporal" overlays; + reject_emit "temporal"; with_year ys temporal_report | "check" :: (_ :: _ as files) -> reject_overlays_for "check" overlays; + reject_emit "check"; check_report files | [ "convert"; path ] -> reject_overlays_for "convert" overlays; + reject_emit "convert"; convert_report path | [ "new-overlay" ] -> reject_overlays_for "new-overlay" overlays; + reject_emit "new-overlay"; print_string new_overlay_template; exit 0 - | [ "day"; ys ] -> with_year ys (day_report ~overlays) - | [ "readings"; ys ] -> with_year ys (readings_report ~overlays) + | [ "day"; ys ] -> + reject_emit "day"; + with_year ys (day_report ~overlays) + | [ "readings"; ys ] -> + reject_emit "readings"; + with_year ys (readings_report ~overlays) + | [ "emit" ] -> ( + match format with + | None -> + Printf.eprintf "colitur: emit requires --format csv|json|sexp|xml|ics\n"; + exit 2 + | Some format -> ( + match (from_y, to_y) with + | None, _ | _, None -> + Printf.eprintf "colitur: emit requires --from YEAR and --to YEAR\n"; + exit 2 + | Some from_ys, Some to_ys -> + with_year from_ys (fun from_y -> + with_year to_ys (fun to_y -> emit_report ~format ~overlays ~dtstamp ~from_y ~to_y)))) | _ -> usage ()) -- cgit v1.3 From 99172c86c518fdb2104c098b7b7a79e6c13ba8ea Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 19 Aug 2026 09:35:52 +0200 Subject: feat(cli): colitur table and render Computes and renders in one process. There is deliberately no stdin-fed render: honouring the pipe would need a JSON parser we would have to write, purely to serialise and immediately re-parse our own view -- a second hand-rolled component and a second place for the contract to drift, for no benefit. colitur emit --format json | jq still composes. An unknown extension with no --flavour is an error naming the six valid flavours, never a silent fallback to none: guessing wrong produces malformed output that looks fine until it does not. A malformed template reports the parser's own reason and exits 2. A template is user input; it must never crash the program. --- bin/main.ml | 179 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- man/colitur.1 | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++- test/cli.t | 69 ++++++++++++++++++++++ 3 files changed, 428 insertions(+), 3 deletions(-) (limited to 'bin') diff --git a/bin/main.ml b/bin/main.ml index ec7fd71..5e3d07d 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -426,6 +426,75 @@ let emit_report ~format ~overlays ~dtstamp ~from_y ~to_y = exit 2 done +(* Task 9: `colitur table` and `colitur render` -- compute a year and render it + through a user-supplied template, in ONE process. + + The design's own sketch was `compute | render` as a Unix pipe, with `render` + reading a serialised view back from stdin. That is deliberately NOT built: + honouring the pipe would need a JSON *parser*, purely to re-read the view + this same process just serialised -- a second hand-rolled component, and a + second place for the published contract to drift, for no benefit over + calling [View.of_days] directly. So `table --year Y --template F` computes + and renders in one process (the command that actually gets used), and + `render --template F --year Y` is the identical operation under the name + the design used, kept so that documented vocabulary still works. There is + no stdin-fed `render`; `colitur emit --format json | jq` still composes for + real pipe use, because JSON there is the OUTPUT, never something colitur + itself has to parse back in. *) + +let read_file path = + match open_in_bin path with + | exception Sys_error _ -> Error ("cannot read template " ^ path) + | ic -> + let n = in_channel_length ic in + let s = really_input_string ic n in + close_in ic; + Ok s + +let extension path = + match String.rindex_opt path '.' with + | Some i -> String.sub path i (String.length path - i) + | None -> "" + +(* An unknown extension with no [--flavour] is an ERROR, never a silent + fallback to [Escape.None_]: guessing the flavour wrong produces malformed + output (unescaped LaTeX/HTML metacharacters) that looks fine until it does + not -- the same "never silently substitute" discipline [data_dir]'s own + [COLITUR_DATA_DIR] handling documents above. *) +let table_report ~template ~flavour_opt ~overlays y = + let flavour = + match flavour_opt with + | Some name -> ( + match Colitur_render.Escape.of_string name with + | Some f -> f + | None -> + Printf.eprintf "colitur: unknown flavour %S (want latex, groff, html, xml, ics or none)\n" + name; + exit 2) + | None -> ( + match Colitur_render.Escape.of_extension (extension template) with + | Some f -> f + | None -> + Printf.eprintf + "colitur: cannot infer a flavour from %S; pass --flavour latex|groff|html|xml|ics|none\n" + (extension template); + exit 2) + in + match read_file template with + | Error msg -> + Printf.eprintf "colitur: %s\n" msg; + exit 2 + | Ok src -> ( + let days = resolved_year_days ~overlays y in + let v = Colitur_render.View.of_days ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days in + match Colitur_render.Template.render_string ~flavour src v with + | Error e -> + (* The template is user input; a parse failure is reported with the + parser's OWN reason and exits 2, never an uncaught exception. *) + Printf.eprintf "colitur: template %s: %s\n" template e; + exit 2 + | Ok out -> print_string out) + (* Help and usage are deliberately DIFFERENT things, and the difference is the Unix convention rather than a preference: asking for help is a request that SUCCEEDED, so [--help] prints to stdout and exits 0 (it can be piped into a @@ -455,6 +524,11 @@ usage: colitur emit --format csv|json|sexp|xml|ics --from Y --to Y [--overlay FILE ...] [--dtstamp S] render a resolved year range through one of five emitters + colitur table --year Y --template FILE [--flavour X] [--overlay FILE ...] + colitur render --template FILE --year Y [--flavour X] [--overlay FILE ...] + compute year Y and render it through FILE, a logic-less + Mustache-family template; table and render are the same + operation, two names (see "rendering" below) colitur new-overlay print a starter overlay file to stdout colitur convert FILE.ini flat INI overlay -> S-expression, on stdout colitur check FILE ... load an overlay, say what it does, exit 2 if not @@ -517,6 +591,55 @@ overlays: (Nth_weekday (month M) (nth N) (weekday W)) with N negative to count from the end of the month. +rendering: + --template FILE (required on `table`/`render`) is a logic-less Mustache- + family template: {{placeholder}}, {{#section}}...{{/section}}, + {{^inverted}}...{{/inverted}}, {{!comment}} -- nothing else. It is + DATA, never a program: no partials, no lambdas, no expression + evaluation, no filesystem or process access, and no "raw" or + triple-brace form that could opt out of escaping. The value it + renders against is the same schema `emit` uses (season, week, + slug, rank, colour, subject, names, citations, commemorations), + reshaped into a booklet (`days`) and a month grid (`weeks`, with + padding cells for the leading/trailing blanks); see colitur(1) + for the full field list. + + --flavour X selects how interpolated VALUES are escaped (never the + template's own literal markup, which is the author's). One of: + + latex groff html xml ics none + + Inferred from --template's extension when --flavour is omitted: + + .tex -> latex + .ms .mom .me -> groff + .html .htm -> html + .xml -> xml + .ics -> ics + .md .adoc .txt -> none (no metacharacters are escaped; + Markdown/AsciiDoc/plain text have no fixed + metacharacter set, so escaping them here + would produce worse output than leaving + them alone) + + An extension colitur does not recognise is a hard ERROR naming + the six flavours above, never a silent fallback to `none`: + guessing wrong produces output that looks fine until the + metacharacters it silently failed to escape show up. + + `table` and `render` are the SAME operation under two names. The design + this project followed originally sketched `compute | render` as a + Unix pipe, with `render` reading a serialised view back from + stdin. That is deliberately not built: honouring the pipe would + need a JSON *parser*, purely so this program could re-read a view + it had just serialised itself -- a second hand-rolled component, + and a second place for the published contract to drift, for no + benefit over calling the view builder directly in the same + process. There is therefore no stdin-fed `render`; `colitur emit + --format json | jq` still composes for real pipe use, because + that JSON is the OUTPUT, never something colitur itself parses + back in. + environment: COLITUR_DATA_DIR Read the calendar data from this directory instead of the @@ -564,12 +687,19 @@ let with_year ys f = [--format]/[--from]/[--to]/[--dtstamp] (Task 8, `emit`) are each single- valued, unlike [--overlay], so they are plain [string option] fields rather than accumulating lists. *) +(* [year]/[template]/[flavour] (Task 9, `table`/`render`) are each single- + valued, the same shape as [format]/[from_y]/[to_y]/[dtstamp] above -- + `table`/`render` take one year and one template file, never a range or a + repeatable list. *) type parsed_args = { overlays : string list; format : string option; from_y : string option; to_y : string option; dtstamp : string option; + year : string option; + template : string option; + flavour : string option; positional : string list; } @@ -586,6 +716,12 @@ let parse_args argv = | [ "--to" ] -> Error "--to needs a value" | "--dtstamp" :: v :: rest -> go { acc with dtstamp = Some v } rest | [ "--dtstamp" ] -> Error "--dtstamp needs a value" + | "--year" :: v :: rest -> go { acc with year = Some v } rest + | [ "--year" ] -> Error "--year needs a value" + | "--template" :: v :: rest -> go { acc with template = Some v } rest + | [ "--template" ] -> Error "--template needs a value" + | "--flavour" :: v :: rest -> go { acc with flavour = Some v } rest + | [ "--flavour" ] -> Error "--flavour needs a value" (* The recognised bare flags pass through as positional words for the dispatch below to match; anything else beginning with '-' is rejected rather than silently treated as a command or a year. *) @@ -596,7 +732,10 @@ let parse_args argv = Error (Printf.sprintf "unknown option %s" arg) | arg :: rest -> go { acc with positional = arg :: acc.positional } rest in - go { overlays = []; format = None; from_y = None; to_y = None; dtstamp = None; positional = [] } argv + go + { overlays = []; format = None; from_y = None; to_y = None; dtstamp = None; year = None; + template = None; flavour = None; positional = [] } + argv (* Sibling to [reject_overlays_for]: `emit`'s own four flags have no meaning on any other command (they take a single [] positional, not a @@ -620,6 +759,18 @@ let reject_overlays_for cmd overlays = exit 2 end +(* Sibling to [reject_emit_flags_for]/[reject_overlays_for]: `table`/`render`'s + own three flags (Task 9) have no meaning on any other command, so accepting + and silently dropping them would be the same failure mode this project + already refuses everywhere else. *) +let reject_table_flags_for cmd ~year ~template ~flavour = + if year <> None || template <> None || flavour <> None then begin + Printf.eprintf + "colitur: --year/--template/--flavour have no effect on `%s`; refusing rather than ignoring them\n" + cmd; + exit 2 + end + (* `colitur check FILE...` -- load a user overlay, apply it to the real shipped calendar, and say what it did, without printing a year of output. @@ -784,46 +935,57 @@ let () = | Error msg -> Printf.eprintf "colitur: %s\n" msg; usage () - | Ok { overlays; format; from_y; to_y; dtstamp; positional } -> ( + | Ok { overlays; format; from_y; to_y; dtstamp; year; template; flavour; positional } -> ( let reject_emit = reject_emit_flags_for ~format ~from_y ~to_y ~dtstamp in + let reject_table = reject_table_flags_for ~year ~template ~flavour in match positional with | [ ("-h" | "--help" | "help") ] -> reject_overlays_for "--help" overlays; reject_emit "--help"; + reject_table "--help"; print_help () | [ ("-V" | "--version" | "version") ] -> reject_overlays_for "--version" overlays; reject_emit "--version"; + reject_table "--version"; print_endline version; exit 0 | [ "easter"; ys ] -> reject_overlays_for "easter" overlays; reject_emit "easter"; + reject_table "easter"; with_year ys easter_report | [ "temporal"; ys ] -> reject_overlays_for "temporal" overlays; reject_emit "temporal"; + reject_table "temporal"; with_year ys temporal_report | "check" :: (_ :: _ as files) -> reject_overlays_for "check" overlays; reject_emit "check"; + reject_table "check"; check_report files | [ "convert"; path ] -> reject_overlays_for "convert" overlays; reject_emit "convert"; + reject_table "convert"; convert_report path | [ "new-overlay" ] -> reject_overlays_for "new-overlay" overlays; reject_emit "new-overlay"; + reject_table "new-overlay"; print_string new_overlay_template; exit 0 | [ "day"; ys ] -> reject_emit "day"; + reject_table "day"; with_year ys (day_report ~overlays) | [ "readings"; ys ] -> reject_emit "readings"; + reject_table "readings"; with_year ys (readings_report ~overlays) | [ "emit" ] -> ( + reject_table "emit"; match format with | None -> Printf.eprintf "colitur: emit requires --format csv|json|sexp|xml|ics\n"; @@ -836,4 +998,17 @@ let () = | Some from_ys, Some to_ys -> with_year from_ys (fun from_y -> with_year to_ys (fun to_y -> emit_report ~format ~overlays ~dtstamp ~from_y ~to_y)))) + | [ ("table" | "render") as cmd ] -> ( + reject_emit cmd; + match template with + | None -> + Printf.eprintf "colitur: %s requires --year YEAR and --template FILE\n" cmd; + exit 2 + | Some template -> ( + match year with + | None -> + Printf.eprintf "colitur: %s requires --year YEAR and --template FILE\n" cmd; + exit 2 + | Some ys -> with_year ys (fun y -> table_report ~template ~flavour_opt:flavour ~overlays y) + )) | _ -> usage ()) diff --git a/man/colitur.1 b/man/colitur.1 index 1db7f9a..da08a36 100644 --- a/man/colitur.1 +++ b/man/colitur.1 @@ -21,6 +21,13 @@ colitur \- deterministic liturgical calendar and lectionary engine (Roman rite, .RB [ \-\-dtstamp " STAMP" ] .br .B colitur +.BR table | render +.BI \-\-year " YEAR" +.BI \-\-template " FILE" +.RB [ \-\-flavour " FLAVOUR" ] +.RB [ \-\-overlay " FILE" " ...]" +.br +.B colitur .BR \-h | \-\-help .SH DESCRIPTION .B colitur @@ -80,6 +87,17 @@ See .B EMIT below. .TP +.BR table | render +Compute one civil year and render it through a user\-supplied template, in one +process. +.B table +and +.B render +are the same operation under two names \(em see +.B RENDERING +below for why there is no separate, stdin\-fed +.B render . +.TP .BI convert " FILE" .ini Convert a flat INI overlay to the S\-expression form, on standard output. The conversion verifies its own output before emitting it: the generated text is @@ -112,7 +130,7 @@ below. .TP .BI \-\-overlay " FILE" Apply a user calendar on top of the shipped one. Repeatable and ordered; -.BR day ", " readings " and " emit +.BR day ", " readings ", " emit ", " table " and " render only. See .B OVERLAYS below. @@ -145,6 +163,28 @@ is the emitted year. Never a clock read either way \(em see .B EMIT below. .TP +.BI \-\-year " YEAR" +.RB ( "colitur table" " and " "colitur render" " only)" +The civil year to compute, +.B 1583..9999 +as elsewhere. Required. +.TP +.BI \-\-template " FILE" +.RB ( "colitur table" " and " "colitur render" " only)" +The template file to render the year through. Required. See +.B RENDERING +below. +.TP +.BI \-\-flavour " FLAVOUR" +.RB ( "colitur table" " and " "colitur render" " only)" +One of +.BR latex ", " groff ", " html ", " xml ", " ics " or " none . +Overrides the flavour that would otherwise be inferred from +.BR \-\-template 's +own extension. See +.B RENDERING +below. +.TP .BR \-h ", " \-\-help Print a usage summary to standard output and exit 0. .TP @@ -299,6 +339,139 @@ applied on top of the shipped calendar, in order, before the range is rendered. See .B OVERLAYS below. +.SH RENDERING +.BI "colitur table " \-\-year " YEAR " \-\-template " FILE" +and +.BI "colitur render " \-\-template " FILE " \-\-year " YEAR" +are the +.I same +operation under two names: compute the resolved year, shape it into the +same view +.B emit +uses, and render it through +.I FILE +in one process. Both accept +.BR \-\-flavour " and " \-\-overlay +identically. +.SS Why there is no stdin\-fed render +The design this project followed originally sketched a Unix pipe, +.BR "compute | render" , +with +.B render +reading a serialised view back from standard input. That is deliberately +.I not +built. +Honouring the pipe would require a JSON +.I parser +inside +.B colitur +\(em a second hand\-rolled component, purely so this program could read back a +view it had just serialised itself, and a second place for the published +output schema to drift out of step with what the parser actually accepts. +That is a real cost for no benefit over calling the same view builder +directly in the same process, which is what +.B table +and +.B render +both do. +.PP +Unix composition is not abandoned, only narrowed to where it is cheap and +honest: +.B "colitur emit \-\-format json | jq" +still composes fine, because that JSON is the +.I output +of the pipeline, never something +.B colitur +itself has to parse back in. +.SS Templates +.I FILE +is a deliberately logic\-less, Mustache\-family template: it is +.I data, +never a program. The only constructs are +.BR {{placeholder}} , +.BR {{#section}}...{{/section}} , +.BR {{^inverted}}...{{/inverted}} +and +.BR {{!comment}} . +There are no partials, no lambdas, no expression evaluation, no arithmetic, +and no filesystem or process access from inside a template. There is +deliberately no "raw" or triple\-brace form either \(em a template cannot opt +out of its flavour's escaping. +.PP +The template renders against the same schema +.B emit +uses (season, week, slug, rank, colour, subject, names, citations, +commemorations), reshaped for two artefacts from one model: a flat booklet +(the +.B days +list, one entry per day of the year) and a month grid (the +.B weeks +list, with padding cells flagged for the leading and trailing blanks a grid +needs and a booklet does not). A key absent on a given day (an optional field +a rite does not always set) renders as the empty string rather than an error +\(em the one deliberate silence, so a template survives a day that does not +carry every optional field. +.SS Flavours +.BI \-\-flavour +controls how interpolated +.I values +are escaped for the target format. It never touches the template's own +literal markup, which is the author's and is trusted as\-is. One of: +.RS +.nf + +latex groff html xml ics none +.fi +.RE +.PP +When +.B \-\-flavour +is omitted it is inferred from +.BR \-\-template 's +own file extension: +.RS +.nf + +.I .tex -> latex +.I .ms .mom .me -> groff +.I .html .htm -> html +.I .xml -> xml +.I .ics -> ics +.I .md .adoc .txt -> none +.fi +.RE +.PP +.B none +escapes nothing: Markdown, AsciiDoc and plain text have no fixed +metacharacter set, so escaping them here would produce worse output than +leaving them alone. +.PP +An extension +.B colitur +does not recognise is a hard error naming the six flavours above; it is +.I never +a silent fallback to +.BR none . +Guessing the flavour wrong produces output that looks fine right up until +the metacharacters it silently failed to escape show up in a rendered +document. +.RS +.nf + +.B colitur table \-\-year 2027 \-\-template invite.wat +colitur: cannot infer a flavour from ".wat"; pass \-\-flavour latex|groff|html|xml|ics|none +.fi +.RE +.PP +A malformed template reports the parser's own reason and exits 2, never a +crash \(em a template is user input, exactly like an overlay file. +.RS +.nf + +.B colitur table \-\-year 2027 \-\-template bad.txt +colitur: template bad.txt: unclosed section {{#days}} +.fi +.RE .SH OVERLAYS .TP .BI \-\-overlay " FILE" @@ -484,6 +657,14 @@ Run against a checkout's data rather than the installed copy: .B COLITUR_DATA_DIR=~/git/projects/colitur/data/ef colitur day 2026 .fi .RE +.PP +Render a year through a template, flavour inferred from the extension: +.RS +.nf + +.B colitur table \-\-year 2026 \-\-template booklet.tex > booklet.tex.out +.fi +.RE .SH SOURCES The calendar is computed against the 1962 .I Missale Romanum diff --git a/test/cli.t b/test/cli.t index 9b3373a..73eefc1 100644 --- a/test/cli.t +++ b/test/cli.t @@ -484,3 +484,72 @@ day and readings are untouched: $ colitur readings 2027 | head -1 2027-01-01 ef-circumcision | Titus 2:11-15 | Luke 2:21 + +A minimal inline template renders -- table computes and renders in one +process (2 January 2027 is a Saturday, not a Sunday, so Holy Name Sunday +falls on the 3rd, not the 2nd, that year): + + $ printf '{{#days}}{{iso}} {{slug}}\n{{/days}}' > /tmp/t.txt + $ colitur table --year 2027 --template /tmp/t.txt | head -2 + 2027-01-01 ef-circumcision + 2027-01-02 ef-christmas-1-saturday + +render is the same operation under the name the design used: + + $ colitur render --template /tmp/t.txt --year 2027 | head -2 + 2027-01-01 ef-circumcision + 2027-01-02 ef-christmas-1-saturday + +Flavour is inferred from the extension and escapes data -- Sts. Peter & +Paul (29 June) and its vigil are the only two 2035 entries whose English +name needs LaTeX escaping: + + $ printf '{{#days}}{{name.en}}\n{{/days}}' > /tmp/t.tex + $ colitur table --year 2035 --template /tmp/t.tex | grep -c 'Peter \\& Paul' + 2 + +An unknown extension with no --flavour is an error, not a silent fallback: + + $ printf 'x' > /tmp/t.wat + $ colitur table --year 2027 --template /tmp/t.wat + colitur: cannot infer a flavour from ".wat"; pass --flavour latex|groff|html|xml|ics|none + [2] + + $ colitur table --year 2027 --template /tmp/t.wat --flavour none + x + +An unrecognised --flavour value is also an error naming the six valid ones: + + $ colitur table --year 2027 --template /tmp/t.txt --flavour bogus + colitur: unknown flavour "bogus" (want latex, groff, html, xml, ics or none) + [2] + +A malformed template is a clear error, not a crash: + + $ printf '{{#days}}oops' > /tmp/bad.txt + $ colitur table --year 2027 --template /tmp/bad.txt + colitur: template /tmp/bad.txt: unclosed section {{#days}} + [2] + +A missing template file is an error: + + $ colitur table --year 2027 --template /tmp/nope.txt + colitur: cannot read template /tmp/nope.txt + [2] + +table and render both require --year and --template: + + $ colitur table --year 2027 + colitur: table requires --year YEAR and --template FILE + [2] + + $ colitur render --template /tmp/t.txt + colitur: render requires --year YEAR and --template FILE + [2] + +table/render's own flags have no effect on the other commands, refused +rather than silently ignored: + + $ colitur emit --format csv --from 2027 --to 2027 --year 2028 + colitur: --year/--template/--flavour have no effect on `emit`; refusing rather than ignoring them + [2] -- cgit v1.3 From 6bd741bd512904dfec7c1aa2b7b2bd3dd4269681 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 19 Aug 2026 09:53:37 +0200 Subject: fix(cli): guard the whole template read, not only the open read_file guarded open_in_bin but left in_channel_length and really_input_string unguarded, so a path that opens but cannot be read as bytes -- a directory -- escaped as an uncaught Sys_error and crashed the program, leaking the open channel on every failure path. A template is user input; it must never crash the program. Wrap the whole read in Fun.protect so the channel closes on every path (success, exception, early return), matching the close-on-every-path pattern already used in the test suite. The missing-file message stays exactly as before; a read failure after a successful open now carries the exception text, the same path: exception shape Layer.load and Overlay.load already use. New cram case points --template at a directory (the sandbox's own cwd, not /tmp) and asserts one stderr line and exit 2, not a crash. --- bin/main.ml | 31 ++++++++++++++++++++++++++----- test/cli.t | 9 +++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) (limited to 'bin') diff --git a/bin/main.ml b/bin/main.ml index 5e3d07d..e916cbe 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -442,14 +442,35 @@ let emit_report ~format ~overlays ~dtstamp ~from_y ~to_y = real pipe use, because JSON there is the OUTPUT, never something colitur itself has to parse back in. *) +(* The open is guarded separately from the read: a missing file fails at + [open_in_bin] with a plain, path-only message (matching the wording this + project already uses for every other "no such file" case), while a file + that opens but cannot be READ -- a directory, a device node, anything + whose length or content changes between [open] and [read] -- fails inside + the [Fun.protect]'d body instead, carrying the raised exception's own text + (mirrors {!Colitur_kernel.Layer.load}/{!Colitur_kernel.Overlay.load}'s own + catch-all shape, lib/kernel/layer.ml and lib/kernel/overlay.ml). Either + way the channel is closed on EVERY path -- success, exception, or an + early return -- because [close_in_noerr] runs in [~finally], which + [Fun.protect] guarantees runs even when the protected function raises; a + bare [close_in] after [really_input_string] only ever ran on the success + path, leaking the descriptor on every failure. The whole read is inside + the [try], not only [open_in_bin], because [in_channel_length] and + [really_input_string] can themselves raise [Sys_error] (a directory opens + fine but is not readable as bytes) -- a template is user input, and this + project's own rule is that user input must never crash the program. *) let read_file path = match open_in_bin path with | exception Sys_error _ -> Error ("cannot read template " ^ path) - | ic -> - let n = in_channel_length ic in - let s = really_input_string ic n in - close_in ic; - Ok s + | ic -> ( + try + Fun.protect + ~finally:(fun () -> close_in_noerr ic) + (fun () -> + let n = in_channel_length ic in + let s = really_input_string ic n in + Ok s) + with exn -> Error (Printf.sprintf "cannot read template %s: %s" path (Printexc.to_string exn))) let extension path = match String.rindex_opt path '.' with diff --git a/test/cli.t b/test/cli.t index 73eefc1..c5aeb98 100644 --- a/test/cli.t +++ b/test/cli.t @@ -537,6 +537,15 @@ A missing template file is an error: colitur: cannot read template /tmp/nope.txt [2] +Pointing --template at a directory is an error, not a crash: the read +itself is guarded, not only the open (F1, fix round 1). "." is used rather +than a fixed /tmp path so this does not depend on anything outside the +cram sandbox itself: + + $ colitur table --year 2027 --template . --flavour none + colitur: cannot read template .: Sys_error("Value too large for defined data type") + [2] + table and render both require --year and --template: $ colitur table --year 2027 -- cgit v1.3 From 2760d43d695ba08fc33f65357590675707b6570d Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 19 Aug 2026 10:18:24 +0200 Subject: feat(cli): colitur publish -- the static tree Writes ef/.{json,csv,xml,ics}, one JSON per day, the schema and a generated index. That tree is the API: any web server or git repo serves it, and nothing runs at request time. Deterministic: publishing twice is byte-identical, asserted in cli.t. That is what makes publishing into a git repo safe -- the diff shows only real change, and you review it before pushing. Non-destructive: a manifest records exactly the files this tool wrote, so --prune can only remove files a previous run created. A file you put in the output directory yourself is never touched, with or without --prune. Asserted in both directions. Pruning a stale file also removes any directory it leaves empty behind it (e.g. an old year's own ef// tree), stopping at --out itself -- without this, a pruned year's own directory would survive empty and test -d would still see it. schema/day-v1.json is resolved the same prefix-relative way data/ef's own sexp files are (installed vs build-tree, probed rather than assumed), never from cwd, and a missing schema fails with one line on stderr before anything is written rather than emitting an empty file. Needed schema/day-v1.json wired into the root dune file's default alias and into test/dune's cram deps -- unlike data/ and templates/, nothing made dune mirror schema/ into the build tree before this. unix is added to bin/dune's libraries for mkdir_p; it ships with the compiler, so colitur.opam and dune-project are unchanged. --- bin/dune | 6 +- bin/main.ml | 269 ++++++++++++++++++++++++++++++++++- dune | 14 +- man/colitur.1 | 157 ++++++++++++++++++++- test/cli.t | 442 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ test/dune | 8 +- 6 files changed, 888 insertions(+), 8 deletions(-) (limited to 'bin') diff --git a/bin/dune b/bin/dune index 376fe70..856dafe 100644 --- a/bin/dune +++ b/bin/dune @@ -2,4 +2,8 @@ (name main) (public_name colitur) (package colitur) - (libraries colitur_kernel rite_ef colitur_render)) + ; [unix] ships with the OCaml compiler -- it is not a new entry in + ; colitur.opam's frozen depends, only a new library this executable links + ; against. Used by Task 12's [mkdir_p] (colitur publish, recursive + ; directory creation) and nowhere else. + (libraries colitur_kernel rite_ef colitur_render unix)) diff --git a/bin/main.ml b/bin/main.ml index e916cbe..1669a47 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -516,6 +516,183 @@ let table_report ~template ~flavour_opt ~overlays y = exit 2 | Ok out -> print_string out) +(* Task 12: `colitur publish` -- writes the static tree that IS this + project's API: a set of files any web server or git repo can serve as-is, + computed once, with nothing running at request time. + + Two properties matter more than anything else here: + + DETERMINISTIC -- publishing the same year range twice must produce a + byte-identical tree. That is what makes publishing into a git repo safe: + `git status` shows only genuine change, and a human reviews a real diff + before pushing. Nothing below reads a wall clock; [dtstamp] is threaded + through as a plain parameter all the way to + {!Colitur_render.Emit_ics.year}, exactly as `emit --format ics` already + requires (see that command's own comment above). + + NON-DESTRUCTIVE -- publish writes only files it owns, names every one of + them in a manifest ([.colitur-manifest], one relative path per line, + itself never subject to pruning), and [--prune] removes only entries + THAT MANIFEST lists which this run did not rewrite. A file the caller put + in the output directory themselves is never in the manifest, so it is + never touched, with or without [--prune] -- asserted in both directions + in test/cli.t. *) + +let rec mkdir_p path = + if path <> "" && path <> "/" && not (Sys.file_exists path) then begin + mkdir_p (Filename.dirname path); + try Unix.mkdir path 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> () + end + +let write_file path contents = + mkdir_p (Filename.dirname path); + let oc = open_out_bin path in + output_string oc contents; + close_out oc + +let manifest_name = ".colitur-manifest" + +(* [read_file] rather than a second hand-rolled reader -- see its own + comment above for why the whole read, not only the open, is guarded. A + missing manifest (the very first publish into a fresh directory) is not + an error here: it just means there is nothing yet to prune against. *) +let read_manifest out = + match read_file (Filename.concat out manifest_name) with + | Error _ -> [] + | Ok contents -> String.split_on_char '\n' contents |> List.filter (fun l -> l <> "") + +(* [--prune] deletes the FILES a stale manifest entry names, but that alone + can leave their parent directories (ef///, then ef//) + empty behind them -- and an empty directory still makes `test -d + out/ef/` true, which is exactly the check a caller uses to confirm + an old year is gone. Walk upward from each deleted file's own directory, + removing it while it is empty, stopping at (never including) [out] + itself: [out] is the caller's own directory, never ours to remove, even + when it is empty. *) +let rec prune_empty_dirs ~out dir = + if dir <> out && String.length dir > String.length out && Sys.file_exists dir then + match Sys.readdir dir with + | [||] -> + (try Unix.rmdir dir with Unix.Unix_error _ -> ()); + prune_empty_dirs ~out (Filename.dirname dir) + | _ -> () + | exception Sys_error _ -> () + +(* schema/day-v1.json is resolved the same prefix-relative way [data_dir] + above resolves data/ef/*.sexp -- NOT from the process's own cwd, which + would break an installed binary invoked from an arbitrary directory. Two + candidates, installed then build-tree, the same shape as [data_dir]; a + candidate counts only if the file is actually there. No COLITUR_DATA_DIR + override here: that variable's whole contract is about the directory + holding sanctoral.sexp, and schema/ is not nested inside it. + + The installed candidate assumes schema/ lands at + /share/colitur/schema/day-v1.json, mirroring data/dune's own ef/ + layout. Adding that install rule is explicitly Task 13's job, not this + one -- this function only has to be ready to find the file once the rule + exists, which is why it is PROBED rather than assumed, exactly like + [data_dir]'s own installed candidate. *) +let schema_path () = + let prefix = Filename.dirname (Filename.dirname Sys.executable_name) in + let installed = + List.fold_left Filename.concat prefix [ "share"; "colitur"; "schema"; "day-v1.json" ] + in + let build_tree = List.fold_left Filename.concat prefix [ "schema"; "day-v1.json" ] in + if Sys.file_exists installed then Some installed else if Sys.file_exists build_tree then Some build_tree else None + +(* An ordinary OCaml string, NOT a template: it describes the TREE, not the + calendar, so it has no business in the template vocabulary. *) +let index_html ~from_y ~to_y = + let b = Buffer.create 4096 in + Buffer.add_string b + "\n\n\ + colitur\n\ + \n\ +

colitur

\n\ +

Liturgical calendar of the 1962 Missale Romanum. Citations only \xe2\x80\x94 never scripture text.

\n"; + Buffer.add_string b "

Subscribe

\n
    \n"; + for y = from_y to to_y do + Buffer.add_string b (Printf.sprintf "
  • ef/%d.ics
  • \n" y y) + done; + Buffer.add_string b "
\n

Data

\n
    \n"; + for y = from_y to to_y do + Buffer.add_string b + (Printf.sprintf + "
  • %d: json csv \ + xml \xe2\x80\x94 per-day at ef/%d/MM/DD.json
  • \n" + y y y y y) + done; + Buffer.add_string b + "
\n

Contract: schema/day-v1.json

\n\ + \n"; + Buffer.contents b + +let publish_report ~from_y ~to_y ~out ~overlays ~dtstamp ~prune = + if from_y > to_y then begin + Printf.eprintf "colitur: --from %d is after --to %d\n" from_y to_y; + exit 2 + end; + (* Resolved and read BEFORE any file is written, so a missing/unreadable + schema fails fast, before the output directory has anything half- + written in it. [read_file]'s own error text says "cannot read + template ..." (it was built for Task 9's template reads) -- accurate + about the mechanism, wrong about the noun, so the message here is + rebuilt rather than printed verbatim. *) + let schema = + match schema_path () with + | None -> + Printf.eprintf + "colitur: cannot find schema/day-v1.json (looked in the installed and build-tree locations)\n"; + exit 2 + | Some p -> ( + match read_file p with + | Error _ -> + Printf.eprintf "colitur: cannot read schema %s\n" p; + exit 2 + | Ok s -> s) + in + let written = ref [] in + let emit rel contents = + write_file (Filename.concat out rel) contents; + written := rel :: !written + in + for y = from_y to to_y do + let days = resolved_year_days ~overlays y in + let v = Colitur_render.View.of_days ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days in + let ys = string_of_int y in + emit ("ef/" ^ ys ^ ".json") (Colitur_render.Emit_json.year v); + emit ("ef/" ^ ys ^ ".csv") (Colitur_render.Emit_csv.year v); + emit ("ef/" ^ ys ^ ".xml") (Colitur_render.Emit_xml.year v); + emit ("ef/" ^ ys ^ ".ics") (Colitur_render.Emit_ics.year ?dtstamp v); + (* One file per day: the static equivalent of a per-day endpoint. + [View.of_days] with a one-day list yields 12 months, 11 empty, one + populated -- exactly the shape a single day's own page needs. *) + List.iter + (fun d -> + let iso = D.to_iso8601 d.Colitur_kernel.Liturgical_day.date in + let mm = String.sub iso 5 2 and dd = String.sub iso 8 2 in + let one = Colitur_render.View.of_days ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y [ d ] in + emit (Printf.sprintf "ef/%s/%s/%s.json" ys mm dd) (Colitur_render.Emit_json.year one)) + days + done; + emit "schema/day-v1.json" schema; + emit "index.html" (index_html ~from_y ~to_y); + let now = List.sort compare !written in + if prune then + List.iter + (fun old -> + if not (List.mem old now) then begin + let p = Filename.concat out old in + if Sys.file_exists p then begin + Sys.remove p; + prune_empty_dirs ~out (Filename.dirname p) + end + end) + (read_manifest out); + write_file (Filename.concat out manifest_name) (String.concat "\n" now ^ "\n"); + Printf.printf "colitur: wrote %d files to %s\n" (List.length now) out + (* Help and usage are deliberately DIFFERENT things, and the difference is the Unix convention rather than a preference: asking for help is a request that SUCCEEDED, so [--help] prints to stdout and exits 0 (it can be piped into a @@ -550,6 +727,11 @@ usage: compute year Y and render it through FILE, a logic-less Mustache-family template; table and render are the same operation, two names (see "rendering" below) + colitur publish --from Y --to Y --out DIR [--overlay FILE ...] [--prune] + [--dtstamp S] + write the static tree: per-year csv/json/xml/ics, one JSON + file per day, the schema and a generated index (see + "publish" below) colitur new-overlay print a starter overlay file to stdout colitur convert FILE.ini flat INI overlay -> S-expression, on stdout colitur check FILE ... load an overlay, say what it does, exit 2 if not @@ -661,6 +843,32 @@ rendering: that JSON is the OUTPUT, never something colitur itself parses back in. +publish: + --out DIR (required) writes the static tree that IS this program's API: + any web server or git repo can serve it as-is, and nothing runs + at request time. + + ef/.{json,csv,xml,ics} one civil year, all days + ef///
.json one file per day + schema/day-v1.json the published JSON contract + index.html a generated index page + .colitur-manifest every path this run wrote + + Deterministic: publishing the same --from/--to range twice + produces a byte-identical tree (--dtstamp behaves exactly as on + `emit`). That is what makes publishing into a git repo safe -- + `git status` shows only real change, and you review an actual + diff before pushing. + + Non-destructive: publish writes only files it owns, and records + every one in .colitur-manifest. A file you put in the output + directory yourself is never in that manifest, so it is never + touched, whether or not --prune is given. --prune additionally + removes manifest entries from a PREVIOUS run that this run did + not rewrite (e.g. an earlier year's per-day files, when you + publish a different range into the same directory) -- never + anything the manifest does not name. + environment: COLITUR_DATA_DIR Read the calendar data from this directory instead of the @@ -712,6 +920,10 @@ let with_year ys f = valued, the same shape as [format]/[from_y]/[to_y]/[dtstamp] above -- `table`/`render` take one year and one template file, never a range or a repeatable list. *) +(* [out] (Task 12, `publish`) is single-valued like [format]/[year]/etc. + [prune] is the one plain boolean flag in this whole record -- every other + field here takes a value, but [--prune] does not, so it cannot reuse the + `"--flag" :: v :: rest` shape the value-taking flags share below. *) type parsed_args = { overlays : string list; format : string option; @@ -721,6 +933,8 @@ type parsed_args = { year : string option; template : string option; flavour : string option; + out : string option; + prune : bool; positional : string list; } @@ -743,6 +957,9 @@ let parse_args argv = | [ "--template" ] -> Error "--template needs a value" | "--flavour" :: v :: rest -> go { acc with flavour = Some v } rest | [ "--flavour" ] -> Error "--flavour needs a value" + | "--out" :: v :: rest -> go { acc with out = Some v } rest + | [ "--out" ] -> Error "--out needs a directory path" + | "--prune" :: rest -> go { acc with prune = true } rest (* The recognised bare flags pass through as positional words for the dispatch below to match; anything else beginning with '-' is rejected rather than silently treated as a command or a year. *) @@ -755,7 +972,7 @@ let parse_args argv = in go { overlays = []; format = None; from_y = None; to_y = None; dtstamp = None; year = None; - template = None; flavour = None; positional = [] } + template = None; flavour = None; out = None; prune = false; positional = [] } argv (* Sibling to [reject_overlays_for]: `emit`'s own four flags have no meaning @@ -792,6 +1009,27 @@ let reject_table_flags_for cmd ~year ~template ~flavour = exit 2 end +(* Sibling to [reject_emit_flags_for]/[reject_table_flags_for]: `--format` + has no meaning on `publish` (it always writes all four whole-year + formats plus the per-day JSON tree, never a single chosen one), so + accepting and silently dropping it would be the same failure mode this + project already refuses everywhere else. Narrower than + [reject_emit_flags_for] on purpose -- `publish` legitimately takes + --from/--to/--dtstamp, so that blanket check cannot be reused here. *) +let reject_format_for cmd format = + if format <> None then begin + Printf.eprintf "colitur: --format has no effect on `%s`; refusing rather than ignoring it\n" cmd; + exit 2 + end + +(* Sibling to the three rejectors above: `--out`/`--prune` (Task 12) have no + meaning on any command except `publish`. *) +let reject_publish_flags_for cmd ~out ~prune = + if out <> None || prune then begin + Printf.eprintf "colitur: --out/--prune have no effect on `%s`; refusing rather than ignoring them\n" cmd; + exit 2 + end + (* `colitur check FILE...` -- load a user overlay, apply it to the real shipped calendar, and say what it did, without printing a year of output. @@ -956,57 +1194,68 @@ let () = | Error msg -> Printf.eprintf "colitur: %s\n" msg; usage () - | Ok { overlays; format; from_y; to_y; dtstamp; year; template; flavour; positional } -> ( + | Ok { overlays; format; from_y; to_y; dtstamp; year; template; flavour; out; prune; positional } -> ( let reject_emit = reject_emit_flags_for ~format ~from_y ~to_y ~dtstamp in let reject_table = reject_table_flags_for ~year ~template ~flavour in + let reject_publish = reject_publish_flags_for ~out ~prune in match positional with | [ ("-h" | "--help" | "help") ] -> reject_overlays_for "--help" overlays; reject_emit "--help"; reject_table "--help"; + reject_publish "--help"; print_help () | [ ("-V" | "--version" | "version") ] -> reject_overlays_for "--version" overlays; reject_emit "--version"; reject_table "--version"; + reject_publish "--version"; print_endline version; exit 0 | [ "easter"; ys ] -> reject_overlays_for "easter" overlays; reject_emit "easter"; reject_table "easter"; + reject_publish "easter"; with_year ys easter_report | [ "temporal"; ys ] -> reject_overlays_for "temporal" overlays; reject_emit "temporal"; reject_table "temporal"; + reject_publish "temporal"; with_year ys temporal_report | "check" :: (_ :: _ as files) -> reject_overlays_for "check" overlays; reject_emit "check"; reject_table "check"; + reject_publish "check"; check_report files | [ "convert"; path ] -> reject_overlays_for "convert" overlays; reject_emit "convert"; reject_table "convert"; + reject_publish "convert"; convert_report path | [ "new-overlay" ] -> reject_overlays_for "new-overlay" overlays; reject_emit "new-overlay"; reject_table "new-overlay"; + reject_publish "new-overlay"; print_string new_overlay_template; exit 0 | [ "day"; ys ] -> reject_emit "day"; reject_table "day"; + reject_publish "day"; with_year ys (day_report ~overlays) | [ "readings"; ys ] -> reject_emit "readings"; reject_table "readings"; + reject_publish "readings"; with_year ys (readings_report ~overlays) | [ "emit" ] -> ( reject_table "emit"; + reject_publish "emit"; match format with | None -> Printf.eprintf "colitur: emit requires --format csv|json|sexp|xml|ics\n"; @@ -1021,6 +1270,7 @@ let () = with_year to_ys (fun to_y -> emit_report ~format ~overlays ~dtstamp ~from_y ~to_y)))) | [ ("table" | "render") as cmd ] -> ( reject_emit cmd; + reject_publish cmd; match template with | None -> Printf.eprintf "colitur: %s requires --year YEAR and --template FILE\n" cmd; @@ -1032,4 +1282,19 @@ let () = exit 2 | Some ys -> with_year ys (fun y -> table_report ~template ~flavour_opt:flavour ~overlays y) )) + | [ "publish" ] -> ( + reject_table "publish"; + reject_format_for "publish" format; + match out with + | None -> + Printf.eprintf "colitur: publish requires --out DIR\n"; + exit 2 + | Some out -> ( + match (from_y, to_y) with + | None, _ | _, None -> + Printf.eprintf "colitur: publish requires --from YEAR and --to YEAR\n"; + exit 2 + | Some from_ys, Some to_ys -> + with_year from_ys (fun from_y -> + with_year to_ys (fun to_y -> publish_report ~from_y ~to_y ~out ~overlays ~dtstamp ~prune)))) | _ -> usage ()) diff --git a/dune b/dune index b7cd19b..e2353df 100644 --- a/dune +++ b/dune @@ -23,10 +23,22 @@ ; this file was originally written to close applied to it too -- a fresh ; `dune build` left it absent from _build/default/data/ef/, only ever ; materialised there as a side effect of test/dune's own deps. +; +; schema/day-v1.json added here for the identical reason again (Task 12, +; `colitur publish`): unlike data/ef/, the top-level schema/ directory has +; no dune file of its own, so nothing makes dune copy it into +; _build/default/ by default -- confirmed empirically, a clean `dune build` +; left _build/default/schema/ missing entirely. bin/main.ml's own +; [schema_path] resolves it the same prefix-relative way [data_dir] resolves +; the sanctoral data, and that resolution needs the file actually present in +; the build tree, not only in the source tree. This is a BUILD-TIME +; convenience only -- it says nothing about `dune install`, which is +; Task 13's own job (see schema_path's comment in bin/main.ml). (alias (name default) (deps (alias_rec install) data/ef/sanctoral.sexp data/ef/adjustments.sexp - data/ef/lectionary.sexp)) + data/ef/lectionary.sexp + schema/day-v1.json)) diff --git a/man/colitur.1 b/man/colitur.1 index da08a36..d015a49 100644 --- a/man/colitur.1 +++ b/man/colitur.1 @@ -28,6 +28,15 @@ colitur \- deterministic liturgical calendar and lectionary engine (Roman rite, .RB [ \-\-overlay " FILE" " ...]" .br .B colitur +.B publish +.BI \-\-from " YEAR" +.BI \-\-to " YEAR" +.BI \-\-out " DIR" +.RB [ \-\-overlay " FILE" " ...]" +.RB [ \-\-prune ] +.RB [ \-\-dtstamp " STAMP" ] +.br +.B colitur .BR \-h | \-\-help .SH DESCRIPTION .B colitur @@ -98,6 +107,14 @@ are the same operation under two names \(em see below for why there is no separate, stdin\-fed .B render . .TP +.B publish +Write the static tree that +.I is +this program's API: a civil\-year range rendered once, as files, so any web +server or git repository can serve it and nothing runs at request time. See +.B PUBLISH +below. +.TP .BI convert " FILE" .ini Convert a flat INI overlay to the S\-expression form, on standard output. The conversion verifies its own output before emitting it: the generated text is @@ -130,7 +147,7 @@ below. .TP .BI \-\-overlay " FILE" Apply a user calendar on top of the shipped one. Repeatable and ordered; -.BR day ", " readings ", " emit ", " table " and " render +.BR day ", " readings ", " emit ", " table ", " render " and " publish only. See .B OVERLAYS below. @@ -144,7 +161,7 @@ Required. See below. .TP .BI \-\-from " YEAR" ", " \-\-to " YEAR" -.RB ( "colitur emit" " only)" +.RB ( "colitur emit" " and " "colitur publish" " only)" The inclusive civil\-year range to render, each .B 1583..9999 as elsewhere. @@ -154,7 +171,7 @@ must not be after Both required. .TP .BI \-\-dtstamp " STAMP" -.RB ( "colitur emit \-\-format ics" " only)" +.RB ( "colitur emit \-\-format ics" " and " "colitur publish" " only)" Fix the feed's own DTSTAMP instead of the default .IR YYYY0101T000000Z , where @@ -163,6 +180,26 @@ is the emitted year. Never a clock read either way \(em see .B EMIT below. .TP +.BI \-\-out " DIR" +.RB ( "colitur publish" " only)" +The directory to write the static tree into. Created if it does not exist. +Required. See +.B PUBLISH +below. +.TP +.B \-\-prune +.RB ( "colitur publish" " only)" +Remove files a previous +.B publish +run into the same +.B \-\-out +wrote that this run did not rewrite. Never removes a file that is not +recorded in +.IR out /.colitur\-manifest , +regardless of this flag. See +.B PUBLISH +below. +.TP .BI \-\-year " YEAR" .RB ( "colitur table" " and " "colitur render" " only)" The civil year to compute, @@ -472,6 +509,109 @@ crash \(em a template is user input, exactly like an overlay file. colitur: template bad.txt: unclosed section {{#days}} .fi .RE +.SH PUBLISH +.BI "colitur publish " \-\-from " YEAR " \-\-to " YEAR " \-\-out " DIR" +writes the static tree that +.I is +this program's API: every file a civil\-year range can be asked for, +computed once and written out, so any web server or git repository can +serve the result as\-is and nothing runs at request time. +.RS +.nf + +ef/.json one civil year, all days, whole\-year emitters +ef/.csv +ef/.xml +ef/.ics +ef///
.json one file per day +schema/day\-v1.json the published JSON contract +index.html a generated index page, not a template +\&.colitur\-manifest every path this run wrote, one per line +.fi +.RE +.PP +Every emitted file goes through the same emitters +.B emit +uses; a published +.I .ics +file for a given year is byte\-for\-byte what +.B "colitur emit \-\-format ics" +would print for that year, and +.B \-\-dtstamp +means exactly what it means there. The per\-day JSON files carry the same +shape as the whole\-year one, scoped to a single day \(em +.B "colitur table" +and template authors needing one day's data can read either. +.PP +.B Deterministic. +Publishing the same +.B \-\-from / \-\-to +range into an empty directory twice produces a byte\-identical tree. Nothing +in the publish path reads the wall clock; the +.I .ics +files' own DTSTAMP defaults to a fixed value derived from the emitted year, +exactly as it does under +.B emit +(see +.B EMIT +above), and +.B \-\-dtstamp +overrides it the same way. This is what makes publishing into a git +repository safe: +.B git status +shows only genuine change, and you review an actual diff before pushing, +never a rewrite of files that did not change. +.PP +.B Non\-destructive. +.B publish +writes only files it owns, and records the relative path of every one of +them in +.IR out /.colitur\-manifest +(itself never subject to pruning). A file you put in the output directory +yourself \(em by hand, or from some other tool \(em is never named in that +manifest, so it is never touched, +.I whether or not +.B \-\-prune +is given. +.RS +.nf + +.B "touch out/MY\-NOTES.txt" +.B "colitur publish \-\-from 2027 \-\-to 2027 \-\-out out \-\-prune" +.B "test \-f out/MY\-NOTES.txt && echo kept" +kept +.fi +.RE +.PP +.B \-\-prune +removes exactly the entries a +.I previous +publish into the same +.B \-\-out +wrote that this run did not rewrite \(em typically an earlier year's own +per\-day files, when a later +.B publish +targets a different +.B \-\-from / \-\-to +range into the same directory. A directory a stale entry's removal leaves +empty is removed too (so, for example, +.I out/ef/2027/ +itself goes away once every file under it is gone), but nothing above +.B \-\-out +is ever touched, and +.B \-\-out +itself is never removed even when nothing is left in it. Without +.BR \-\-prune , +old entries are left in place, and only the manifest is rewritten to +describe the current run. +.PP +.BR \-\-overlay +is accepted exactly as on +.BR day ", " readings " and " emit : +applied on top of the shipped calendar, in order, before each year in the +range is rendered. See +.B OVERLAYS +below. .SH OVERLAYS .TP .BI \-\-overlay " FILE" @@ -665,6 +805,17 @@ Render a year through a template, flavour inferred from the extension: .B colitur table \-\-year 2026 \-\-template booklet.tex > booklet.tex.out .fi .RE +.PP +Publish a year range as a static tree, then keep it in step with +.B \-\-prune +as the range moves: +.RS +.nf + +.B colitur publish \-\-from 2026 \-\-to 2027 \-\-out ~/public/colitur +.B colitur publish \-\-from 2027 \-\-to 2028 \-\-out ~/public/colitur \-\-prune +.fi +.RE .SH SOURCES The calendar is computed against the 1962 .I Missale Romanum diff --git a/test/cli.t b/test/cli.t index c5aeb98..c74331b 100644 --- a/test/cli.t +++ b/test/cli.t @@ -562,3 +562,445 @@ rather than silently ignored: $ colitur emit --format csv --from 2027 --to 2027 --year 2028 colitur: --year/--template/--flavour have no effect on `emit`; refusing rather than ignoring them [2] + +publish writes the documented tree (Task 12): the manifest itself +(.colitur-manifest) is a real file `find` sees too, since it lives in the +same directory as everything else it tracks. Every /tmp/pub* path below is +cleared first, so this section is self-contained across repeat runs: + + $ rm -rf /tmp/pub /tmp/pub1 /tmp/pub2 /tmp/pub3 + + $ colitur publish --from 2027 --to 2027 --out /tmp/pub >/dev/null + $ find /tmp/pub -type f | sed 's|/tmp/pub/||' | sort + .colitur-manifest + ef/2027.csv + ef/2027.ics + ef/2027.json + ef/2027.xml + ef/2027/01/01.json + ef/2027/01/02.json + ef/2027/01/03.json + ef/2027/01/04.json + ef/2027/01/05.json + ef/2027/01/06.json + ef/2027/01/07.json + ef/2027/01/08.json + ef/2027/01/09.json + ef/2027/01/10.json + ef/2027/01/11.json + ef/2027/01/12.json + ef/2027/01/13.json + ef/2027/01/14.json + ef/2027/01/15.json + ef/2027/01/16.json + ef/2027/01/17.json + ef/2027/01/18.json + ef/2027/01/19.json + ef/2027/01/20.json + ef/2027/01/21.json + ef/2027/01/22.json + ef/2027/01/23.json + ef/2027/01/24.json + ef/2027/01/25.json + ef/2027/01/26.json + ef/2027/01/27.json + ef/2027/01/28.json + ef/2027/01/29.json + ef/2027/01/30.json + ef/2027/01/31.json + ef/2027/02/01.json + ef/2027/02/02.json + ef/2027/02/03.json + ef/2027/02/04.json + ef/2027/02/05.json + ef/2027/02/06.json + ef/2027/02/07.json + ef/2027/02/08.json + ef/2027/02/09.json + ef/2027/02/10.json + ef/2027/02/11.json + ef/2027/02/12.json + ef/2027/02/13.json + ef/2027/02/14.json + ef/2027/02/15.json + ef/2027/02/16.json + ef/2027/02/17.json + ef/2027/02/18.json + ef/2027/02/19.json + ef/2027/02/20.json + ef/2027/02/21.json + ef/2027/02/22.json + ef/2027/02/23.json + ef/2027/02/24.json + ef/2027/02/25.json + ef/2027/02/26.json + ef/2027/02/27.json + ef/2027/02/28.json + ef/2027/03/01.json + ef/2027/03/02.json + ef/2027/03/03.json + ef/2027/03/04.json + ef/2027/03/05.json + ef/2027/03/06.json + ef/2027/03/07.json + ef/2027/03/08.json + ef/2027/03/09.json + ef/2027/03/10.json + ef/2027/03/11.json + ef/2027/03/12.json + ef/2027/03/13.json + ef/2027/03/14.json + ef/2027/03/15.json + ef/2027/03/16.json + ef/2027/03/17.json + ef/2027/03/18.json + ef/2027/03/19.json + ef/2027/03/20.json + ef/2027/03/21.json + ef/2027/03/22.json + ef/2027/03/23.json + ef/2027/03/24.json + ef/2027/03/25.json + ef/2027/03/26.json + ef/2027/03/27.json + ef/2027/03/28.json + ef/2027/03/29.json + ef/2027/03/30.json + ef/2027/03/31.json + ef/2027/04/01.json + ef/2027/04/02.json + ef/2027/04/03.json + ef/2027/04/04.json + ef/2027/04/05.json + ef/2027/04/06.json + ef/2027/04/07.json + ef/2027/04/08.json + ef/2027/04/09.json + ef/2027/04/10.json + ef/2027/04/11.json + ef/2027/04/12.json + ef/2027/04/13.json + ef/2027/04/14.json + ef/2027/04/15.json + ef/2027/04/16.json + ef/2027/04/17.json + ef/2027/04/18.json + ef/2027/04/19.json + ef/2027/04/20.json + ef/2027/04/21.json + ef/2027/04/22.json + ef/2027/04/23.json + ef/2027/04/24.json + ef/2027/04/25.json + ef/2027/04/26.json + ef/2027/04/27.json + ef/2027/04/28.json + ef/2027/04/29.json + ef/2027/04/30.json + ef/2027/05/01.json + ef/2027/05/02.json + ef/2027/05/03.json + ef/2027/05/04.json + ef/2027/05/05.json + ef/2027/05/06.json + ef/2027/05/07.json + ef/2027/05/08.json + ef/2027/05/09.json + ef/2027/05/10.json + ef/2027/05/11.json + ef/2027/05/12.json + ef/2027/05/13.json + ef/2027/05/14.json + ef/2027/05/15.json + ef/2027/05/16.json + ef/2027/05/17.json + ef/2027/05/18.json + ef/2027/05/19.json + ef/2027/05/20.json + ef/2027/05/21.json + ef/2027/05/22.json + ef/2027/05/23.json + ef/2027/05/24.json + ef/2027/05/25.json + ef/2027/05/26.json + ef/2027/05/27.json + ef/2027/05/28.json + ef/2027/05/29.json + ef/2027/05/30.json + ef/2027/05/31.json + ef/2027/06/01.json + ef/2027/06/02.json + ef/2027/06/03.json + ef/2027/06/04.json + ef/2027/06/05.json + ef/2027/06/06.json + ef/2027/06/07.json + ef/2027/06/08.json + ef/2027/06/09.json + ef/2027/06/10.json + ef/2027/06/11.json + ef/2027/06/12.json + ef/2027/06/13.json + ef/2027/06/14.json + ef/2027/06/15.json + ef/2027/06/16.json + ef/2027/06/17.json + ef/2027/06/18.json + ef/2027/06/19.json + ef/2027/06/20.json + ef/2027/06/21.json + ef/2027/06/22.json + ef/2027/06/23.json + ef/2027/06/24.json + ef/2027/06/25.json + ef/2027/06/26.json + ef/2027/06/27.json + ef/2027/06/28.json + ef/2027/06/29.json + ef/2027/06/30.json + ef/2027/07/01.json + ef/2027/07/02.json + ef/2027/07/03.json + ef/2027/07/04.json + ef/2027/07/05.json + ef/2027/07/06.json + ef/2027/07/07.json + ef/2027/07/08.json + ef/2027/07/09.json + ef/2027/07/10.json + ef/2027/07/11.json + ef/2027/07/12.json + ef/2027/07/13.json + ef/2027/07/14.json + ef/2027/07/15.json + ef/2027/07/16.json + ef/2027/07/17.json + ef/2027/07/18.json + ef/2027/07/19.json + ef/2027/07/20.json + ef/2027/07/21.json + ef/2027/07/22.json + ef/2027/07/23.json + ef/2027/07/24.json + ef/2027/07/25.json + ef/2027/07/26.json + ef/2027/07/27.json + ef/2027/07/28.json + ef/2027/07/29.json + ef/2027/07/30.json + ef/2027/07/31.json + ef/2027/08/01.json + ef/2027/08/02.json + ef/2027/08/03.json + ef/2027/08/04.json + ef/2027/08/05.json + ef/2027/08/06.json + ef/2027/08/07.json + ef/2027/08/08.json + ef/2027/08/09.json + ef/2027/08/10.json + ef/2027/08/11.json + ef/2027/08/12.json + ef/2027/08/13.json + ef/2027/08/14.json + ef/2027/08/15.json + ef/2027/08/16.json + ef/2027/08/17.json + ef/2027/08/18.json + ef/2027/08/19.json + ef/2027/08/20.json + ef/2027/08/21.json + ef/2027/08/22.json + ef/2027/08/23.json + ef/2027/08/24.json + ef/2027/08/25.json + ef/2027/08/26.json + ef/2027/08/27.json + ef/2027/08/28.json + ef/2027/08/29.json + ef/2027/08/30.json + ef/2027/08/31.json + ef/2027/09/01.json + ef/2027/09/02.json + ef/2027/09/03.json + ef/2027/09/04.json + ef/2027/09/05.json + ef/2027/09/06.json + ef/2027/09/07.json + ef/2027/09/08.json + ef/2027/09/09.json + ef/2027/09/10.json + ef/2027/09/11.json + ef/2027/09/12.json + ef/2027/09/13.json + ef/2027/09/14.json + ef/2027/09/15.json + ef/2027/09/16.json + ef/2027/09/17.json + ef/2027/09/18.json + ef/2027/09/19.json + ef/2027/09/20.json + ef/2027/09/21.json + ef/2027/09/22.json + ef/2027/09/23.json + ef/2027/09/24.json + ef/2027/09/25.json + ef/2027/09/26.json + ef/2027/09/27.json + ef/2027/09/28.json + ef/2027/09/29.json + ef/2027/09/30.json + ef/2027/10/01.json + ef/2027/10/02.json + ef/2027/10/03.json + ef/2027/10/04.json + ef/2027/10/05.json + ef/2027/10/06.json + ef/2027/10/07.json + ef/2027/10/08.json + ef/2027/10/09.json + ef/2027/10/10.json + ef/2027/10/11.json + ef/2027/10/12.json + ef/2027/10/13.json + ef/2027/10/14.json + ef/2027/10/15.json + ef/2027/10/16.json + ef/2027/10/17.json + ef/2027/10/18.json + ef/2027/10/19.json + ef/2027/10/20.json + ef/2027/10/21.json + ef/2027/10/22.json + ef/2027/10/23.json + ef/2027/10/24.json + ef/2027/10/25.json + ef/2027/10/26.json + ef/2027/10/27.json + ef/2027/10/28.json + ef/2027/10/29.json + ef/2027/10/30.json + ef/2027/10/31.json + ef/2027/11/01.json + ef/2027/11/02.json + ef/2027/11/03.json + ef/2027/11/04.json + ef/2027/11/05.json + ef/2027/11/06.json + ef/2027/11/07.json + ef/2027/11/08.json + ef/2027/11/09.json + ef/2027/11/10.json + ef/2027/11/11.json + ef/2027/11/12.json + ef/2027/11/13.json + ef/2027/11/14.json + ef/2027/11/15.json + ef/2027/11/16.json + ef/2027/11/17.json + ef/2027/11/18.json + ef/2027/11/19.json + ef/2027/11/20.json + ef/2027/11/21.json + ef/2027/11/22.json + ef/2027/11/23.json + ef/2027/11/24.json + ef/2027/11/25.json + ef/2027/11/26.json + ef/2027/11/27.json + ef/2027/11/28.json + ef/2027/11/29.json + ef/2027/11/30.json + ef/2027/12/01.json + ef/2027/12/02.json + ef/2027/12/03.json + ef/2027/12/04.json + ef/2027/12/05.json + ef/2027/12/06.json + ef/2027/12/07.json + ef/2027/12/08.json + ef/2027/12/09.json + ef/2027/12/10.json + ef/2027/12/11.json + ef/2027/12/12.json + ef/2027/12/13.json + ef/2027/12/14.json + ef/2027/12/15.json + ef/2027/12/16.json + ef/2027/12/17.json + ef/2027/12/18.json + ef/2027/12/19.json + ef/2027/12/20.json + ef/2027/12/21.json + ef/2027/12/22.json + ef/2027/12/23.json + ef/2027/12/24.json + ef/2027/12/25.json + ef/2027/12/26.json + ef/2027/12/27.json + ef/2027/12/28.json + ef/2027/12/29.json + ef/2027/12/30.json + ef/2027/12/31.json + index.html + schema/day-v1.json + + $ ls /tmp/pub/ef/2027/01/*.json | wc -l + 31 + + $ test -f /tmp/pub/schema/day-v1.json && echo schema-present + schema-present + + $ test -f /tmp/pub/index.html && echo index-present + index-present + +Publishing twice is byte-identical -- safe to publish into a git repo: + + $ colitur publish --from 2027 --to 2027 --out /tmp/pub1 >/dev/null + $ colitur publish --from 2027 --to 2027 --out /tmp/pub2 >/dev/null + $ diff -r /tmp/pub1 /tmp/pub2 && echo identical + identical + +The published .ics is byte-identical to `emit --format ics` for the same +year -- both walk through the identical Emit_ics.year: + + $ colitur emit --format ics --from 2027 --to 2027 > /tmp/emit-2027.ics + $ diff /tmp/pub1/ef/2027.ics /tmp/emit-2027.ics && echo ics-identical + ics-identical + +publish never deletes a file it does not own: + + $ touch /tmp/pub1/MY-NOTES.txt + $ colitur publish --from 2027 --to 2027 --out /tmp/pub1 >/dev/null + $ test -f /tmp/pub1/MY-NOTES.txt && echo kept + kept + +--prune removes only files a previous run created: + + $ colitur publish --from 2027 --to 2027 --out /tmp/pub1 --prune >/dev/null + $ test -f /tmp/pub1/MY-NOTES.txt && echo still-kept + still-kept + + $ colitur publish --from 2028 --to 2028 --out /tmp/pub1 --prune >/dev/null + $ test -d /tmp/pub1/ef/2027 || echo pruned-2027 + pruned-2027 + $ test -f /tmp/pub1/MY-NOTES.txt && echo notes-survived-prune + notes-survived-prune + +--out is required: + + $ colitur publish --from 2027 --to 2027 + colitur: publish requires --out DIR + [2] + +publish's own flags have no effect on the other commands, and other +commands' flags have no effect on publish -- refused rather than silently +ignored, the same discipline as everywhere else: + + $ colitur publish --from 2027 --to 2027 --out /tmp/pub3 --format json + colitur: --format has no effect on `publish`; refusing rather than ignoring it + [2] + + $ colitur day 2027 --out /tmp/pub3 --prune + colitur: --out/--prune have no effect on `day`; refusing rather than ignoring them + [2] diff --git a/test/dune b/test/dune index aa4bd66..8d0c551 100644 --- a/test/dune +++ b/test/dune @@ -43,4 +43,10 @@ ../data/ef/sanctoral.sexp ../data/ef/adjustments.sexp ../data/ef/lectionary.sexp - ../data/ef/commons.sexp)) + ../data/ef/commons.sexp + ; Task 12, `colitur publish`: the cram sandbox only ever gets what this + ; stanza names explicitly (unlike a plain `dune build`, it does not fall + ; back to the workspace root's own default alias), so schema/day-v1.json + ; needs its own entry here too, exactly like every other runtime file + ; above. + ../schema/day-v1.json)) -- cgit v1.3 From b90678e560808dd788fa7d7eb319d93a83005db4 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 19 Aug 2026 10:32:33 +0200 Subject: docs(render): template reference, install rules, typesetting check colitur-templates.5 documents the four syntax forms, the six flavours and their escaping, and the full view-model field reference. It states plainly that there are no partials, no raw form and no expression evaluation -- a template is data, never a program. It documents two real hazards found during this build, not theoretical ones: the outward scope fallback silently shadowing an inner name/num key with an outer one of the same name (with the safe {{#name}}...{{^la}} idiom), and the engine's lack of host-comment awareness (a {{...}} inside a LaTeX %, groff .\" or HTML comment is still parsed as a tag). It also states the limitation rather than hiding it: AsciiDoc and Markdown are not escaped, so a feast name containing * or _ renders as emphasis. templates/ and schema/ now install into /share/colitur/, matching data/ef/, via new install stanzas; colitur-templates.5 installs to man5 beside colitur-overlay.5. Verified against a scratch prefix: the installed binary resolves both from the prefix, not the source tree, when run from an unrelated working directory. make check-templates typesets every shipped template through pdflatex and groff when they are installed, and prints SKIPPED loudly when they are not. Golden tests prove templates render; only this proves they typeset. A silent skip would read as a pass. Fixed a real doc/help drift while here: bin/main.ml's --help still said --overlay was accepted on day and readings only, three commands out of date (emit, table/render and publish all accept it too), disagreeing with the man page's own OVERLAYS section, which carried the identical stale line. Both are corrected; --overlay's own behaviour is unchanged. --- Makefile | 43 ++- README.md | 12 + bin/main.ml | 18 +- man/colitur-templates.5 | 699 ++++++++++++++++++++++++++++++++++++++++++++++++ man/colitur.1 | 26 +- schema/dune | 16 ++ templates/dune | 22 ++ 7 files changed, 821 insertions(+), 15 deletions(-) create mode 100644 man/colitur-templates.5 create mode 100644 schema/dune create mode 100644 templates/dune (limited to 'bin') diff --git a/Makefile b/Makefile index 0d4d1d5..aa4bce6 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,11 @@ MAN5DIR := $(PREFIX)/share/man/man5 # (/share/colitur/ef), so `dune install` -- not a hand-rolled copy -- # is what places the four .sexp files where a running colitur will look for # them. See bin/main.ml's own [data_dir] for the full resolution order. +# Task 13 (schema/dune, templates/dune) extends the same `dune install` +# mechanism to /share/colitur/schema/ (bin/main.ml's own +# [schema_path], read by `colitur publish`) and /share/colitur/ +# templates/ (a convenience install -- `--template` takes a plain file path, +# nothing in colitur probes this location the way [data_dir]/[schema_path] do). # # dune needs the project-local opam switch on PATH. Every recipe that invokes # dune goes through this, so `make` works from a plain shell with no @@ -17,7 +22,7 @@ MAN5DIR := $(PREFIX)/share/man/man5 # over documenting the dune commands. DUNE := opam exec -- -.PHONY: help build test check check-schema install uninstall reinstall clean fmt man doc release +.PHONY: help build test check check-schema check-templates install uninstall reinstall clean fmt man doc release help: ## show this help @grep -hE '^[a-z-]+:.*##' $(MAKEFILE_LIST) | sed -E 's/:.*## /\t/' | sort @@ -39,19 +44,47 @@ check-schema: build ## validate emitted XML against schema/colitur-v1.xsd (needs echo "SKIPPED: xmllint not installed -- XML is emitted but NOT schema-validated"; \ fi -install: build ## install binary, calendar data and man page into PREFIX (default ~/.local) +# Typesets every shipped template through the real tool that would typeset it +# for a reader, not merely through this program's own renderer. The golden +# tests already prove `colitur table` PRODUCES the expected LaTeX/groff/HTML +# text; only this proves that text actually TYPESETS -- a template can render +# byte-for-byte as pinned and still be malformed LaTeX or groff. Each tool is +# genuinely optional (neither is in colitur's own frozen deps), so absence is +# printed LOUDLY as SKIPPED rather than silently treated as a pass -- a +# silent skip reads as a pass, which is the whole failure mode this guards +# against. +check-templates: build ## typeset every shipped template (needs pdflatex/groff; skipped if absent) + @ok=1; \ + if command -v pdflatex >/dev/null 2>&1; then \ + for t in ordo grid; do \ + opam exec -- dune exec colitur -- table --year 2027 --template templates/ef/$$t.tex > /tmp/$$t.tex && \ + (cd /tmp && pdflatex -halt-on-error -interaction=nonstopmode $$t.tex >/dev/null) && \ + echo "pdflatex: $$t.tex OK" || { echo "pdflatex: $$t.tex FAILED"; ok=0; }; \ + done; \ + else echo "SKIPPED: pdflatex not installed -- LaTeX templates render but are NOT typeset"; fi; \ + if command -v groff >/dev/null 2>&1; then \ + for t in ordo grid; do \ + opam exec -- dune exec colitur -- table --year 2027 --template templates/ef/$$t.ms > /tmp/$$t.ms && \ + groff -ms -t -Tpdf /tmp/$$t.ms > /tmp/$$t-ms.pdf && \ + echo "groff: $$t.ms OK" || { echo "groff: $$t.ms FAILED"; ok=0; }; \ + done; \ + else echo "SKIPPED: groff not installed -- groff templates render but are NOT typeset"; fi; \ + test $$ok -eq 1 + +install: build ## install binary, calendar data, templates, schema and man pages into PREFIX (default ~/.local) $(DUNE) dune install --prefix $(PREFIX) @mkdir -p $(MANDIR) install -m 644 man/colitur.1 $(MANDIR)/colitur.1 @mkdir -p $(MAN5DIR) install -m 644 man/colitur-overlay.5 $(MAN5DIR)/colitur-overlay.5 - @echo "installed $(BINDIR)/$(COLITUR), data in $(PREFIX)/share/colitur/ef, man pages in $(MANDIR) and $(MAN5DIR)" + install -m 644 man/colitur-templates.5 $(MAN5DIR)/colitur-templates.5 + @echo "installed $(BINDIR)/$(COLITUR), data in $(PREFIX)/share/colitur/{ef,templates,schema}, man pages in $(MANDIR) and $(MAN5DIR)" @command -v $(COLITUR) >/dev/null 2>&1 || \ echo "note: $(BINDIR) is not on PATH -- add it, or run $(BINDIR)/$(COLITUR) directly" uninstall: ## remove everything install put into PREFIX -$(DUNE) dune uninstall --prefix $(PREFIX) - rm -f $(MANDIR)/colitur.1 $(MAN5DIR)/colitur-overlay.5 + rm -f $(MANDIR)/colitur.1 $(MAN5DIR)/colitur-overlay.5 $(MAN5DIR)/colitur-templates.5 @echo "removed $(COLITUR) from $(PREFIX)" reinstall: uninstall install ## uninstall then install (the installed copy is a snapshot, not a link) @@ -59,10 +92,12 @@ reinstall: uninstall install ## uninstall then install (the installed copy is a man: ## preview the man pages man -l man/colitur.1 man -l man/colitur-overlay.5 + man -l man/colitur-templates.5 doc: ## lint the man pages (groff warnings; silence means clean) groff -man -Tutf8 -ww -z man/colitur.1 groff -man -Tutf8 -ww -z man/colitur-overlay.5 + groff -man -Tutf8 -ww -z man/colitur-templates.5 fmt: ## format the OCaml sources $(DUNE) dune build @fmt --auto-promote diff --git a/README.md b/README.md index 6f71f6c..aafde4e 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,18 @@ dune exec colitur -- temporal 2026 # the EF temporal cycle only, one line per d dune exec colitur -- day 2026 # the full resolved EF calendar (temporal + sanctoral) ``` +## Rendering + +```sh +dune exec colitur -- table --year 2027 --template templates/ef/ordo.tex > ordo.tex && pdflatex ordo.tex +dune exec colitur -- table --year 2027 --template templates/ef/grid.tex > grid.tex && pdflatex grid.tex +dune exec colitur -- publish --from 2027 --to 2027 --out ./public +``` + +See `colitur-templates(5)` for the template format (syntax, escaping, the +full field reference) and `colitur(1)` for `emit`, `table`/`render` and +`publish` in full. + ## License AGPL-3.0-or-later. See `LICENSE`. diff --git a/bin/main.ml b/bin/main.ml index 1669a47..4f1fbf2 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -763,9 +763,9 @@ overlays: the shipped universal one, never instead of it, so local feasts add to it rather than replacing it. Later files win over earlier ones, and over the universal calendar, when they name the same - slug. Accepted on `day` and `readings` only -- the other commands - read no sanctoral data, so the flag is refused there rather than - silently ignored. + slug. Accepted on `day`, `readings`, `emit`, `table`, `render` and + `publish` -- `easter` and `temporal` read no sanctoral data, so + the flag is refused there rather than silently ignored. An overlay is applied, NOT validated: colitur's test layers assert things about the shipped calendar and cannot vouch for a file you @@ -804,8 +804,11 @@ rendering: renders against is the same schema `emit` uses (season, week, slug, rank, colour, subject, names, citations, commemorations), reshaped into a booklet (`days`) and a month grid (`weeks`, with - padding cells for the leading/trailing blanks); see colitur(1) - for the full field list. + padding cells for the leading/trailing blanks); see + colitur-templates(5) for the full field list, the syntax and the + scope-shadowing hazard (an inner key silently loses to an outer + key of the same name -- a bare {{name.la}} inside a day resolves + to the enclosing MONTH's name, not the day's own). --flavour X selects how interpolated VALUES are escaped (never the template's own literal markup, which is the author's). One of: @@ -882,7 +885,10 @@ exit status: Reading references only (e.g. "Jn 3:16"); never scripture text. See colitur(1) for the full description and the sources it computes against, -and colitur-overlay(5) for the overlay file format in full.|} +colitur-overlay(5) for the overlay file format in full, and +colitur-templates(5) for the template format in full -- the syntax, the +scope-shadowing hazard, the six flavours' escaping, and the full view-model +field reference.|} let print_help () = print_endline help_text; diff --git a/man/colitur-templates.5 b/man/colitur-templates.5 new file mode 100644 index 0000000..ec883cb --- /dev/null +++ b/man/colitur-templates.5 @@ -0,0 +1,699 @@ +.TH COLITUR\-TEMPLATES 5 "2026" "colitur" "File Formats" +.SH NAME +colitur\-templates \- template format for colitur(1)'s table, render and publish +.SH SYNOPSIS +.I booklet.tex +.br +.I calendar.html +.br +.I feed.ics +.SH DESCRIPTION +A +.B colitur +template is the file named by +.BR "colitur table" 's +and +.BR "colitur render" 's +.B \-\-template +flag (and used internally by +.BR "colitur publish" ). +It is a deliberately logic\-less, Mustache\-family format: the file is +.I data, +never a program. There are exactly four constructs \(em variable +interpolation, a section, an inverted section, and a comment \(em and nothing +else. +.PP +.B There are no partials, no lambdas, no arithmetic, no expression +.B evaluation, and no "raw" or triple\-brace form that could opt out of +.B escaping. +A template cannot include another file, cannot compute anything, and cannot +choose to skip the escaping its own flavour applies. Everything a rendered +document needs \(em conditionals on emptiness, iteration over days or weeks, +formatting \(em is expressed with the four constructs below over the fields +.B VIEW MODEL +describes; nothing else is available, and nothing else will be added by +supplying cleverer template syntax \(em that is what the escaping and +scope rules exist to prevent. +.SH SYNTAX +.TP +.BI "{{" name "}}" +Interpolates the value at +.I name, +a dot\-separated path resolved against the current scope (see +.B SCOPE AND LOOKUP +below). A string value is escaped per the active flavour and inserted; a +boolean +.B true +renders as the literal text +.RB \(lq true \(rq, +.B false +renders as nothing; a list or an object value used as a plain variable also +renders as nothing \(em only a section can iterate one. A path that resolves +to nothing renders as nothing, silently: this is the one deliberate silence +in the engine, so a template survives a day that does not carry every +optional field (an empty +.I week +on a day the rite does not number, an empty +.I first +or +.I gospel +citation, and so on). +.TP +.BI "{{#" name "}}...{{/" name "}}" +A section. If +.I name +resolves to a +.B list, +the body is rendered once per item, with each item pushed onto the scope +stack (see below). If it resolves to a truthy non\-list value (a non\-empty +string, or an object), the body is rendered once, with that value pushed onto +the stack. If it resolves to nothing, or to a falsy value (an empty string, +.BR false , +or an empty list), the body is skipped entirely. +.TP +.BI "{{^" name "}}...{{/" name "}}" +An inverted section: the mirror image of +.BR # . +The body renders \(em exactly once, without pushing anything new onto the +scope \(em only when +.I name +resolves to nothing, or to a falsy value. This is how a template supplies a +fallback for an optional or absent field. +.TP +.BI "{{!" " text " "}}" +A comment. Everything between +.B {{! +and the closing +.B }} +is discarded; nothing is written to the rendered output. See +.B HOST\-LANGUAGE COMMENTS +below before relying on this for documentation inside a template that also +has its own comment syntax. +.PP +A section and its inverted counterpart, and a section and its close tag, must +name the identical path \(em +.BR {{#days}} " ... " {{/months}} +is a parse error, not a silently mismatched close. An empty path +( +.BR {{.}} ", " {{#}} ", " {{^}} ", " {{/}} +) is also a parse error: there is no "current context" concept for a bare dot +to mean, so nothing is guessed on a template's behalf. +.PP +A malformed template \(em an unterminated +.BR {{ , +a section left unclosed, a close tag with no matching open \(em is reported +with the parser's own reason and exits 2. A template is user input, exactly +like an +.BR colitur\-overlay (5) +file, and is never allowed to crash the program that reads it. +.SH SCOPE AND LOOKUP +.B This section documents a real hazard, not a theoretical one \(em it has +.B produced wrong output during this program's own development. +.PP +Scope is a stack. Rendering starts with the whole view (the year) as the one +entry on the stack; each +.B {{#section}} +pushes the value it iterates or opens onto the stack for the duration of its +body, and pops it again at +.BR {{/section}} . +A lookup for +.I name +is tried against the +.I innermost +(most recently pushed) entry first. If +.I name +is not found there, the lookup falls back to the +.I next +entry outward, and so on to the outermost (the year itself). This fallback is +deliberate and necessary \(em without it, a cell deep inside +.B {{#months}}{{#weeks}}{{#days}} +could never reach +.BR {{year}} , +which lives only on the outermost object. +.PP +.B The hazard: an inner key silently loses to an outer key of the SAME NAME. +Falling back outward means a name that exists at +.I both +levels never fails and never warns \(em it just silently resolves to the +.I outer +one, because the inner object's own absence of that key is indistinguishable +from "look further out" and "this key does not apply here". Two collisions +are known to exist in the shipped view model: +.TP +.B name +Both a +.B month +and a +.B day +carry a +.I name +field (each an object keyed by language, e.g. +.BR la " and " en ). +Written naively, inside +.BR {{#days}} , +a bare +.B {{name.la}} +does +.I not +resolve to the day's own Latin name. It resolves to the +.I enclosing month's +Latin name, because the day's own +.I name +object either has no +.B la +key (an unnamed day) or the dotted path fails partway and the WHOLE path +falls back to the outer scope, which does have one. This is not a corner +case: on an ordinary month, most days carry no Latin name at all (only named +sanctoral days do), so the naive form renders the +.I month's +name on nearly every day \(em in a flat booklet, dozens of wrong lines; in a +month grid, EVERY cell reads the month's own name. +.TP +.B num +Both a +.B month +and a +.B week +carry a +.I num +field. Inside +.BR {{#weeks}} , +a bare +.B {{num}} +is the week's own number, correctly \(em but only because nothing between +the week and the day currently redefines it. A template that reaches +.I num +from any scope where the immediately enclosing section does not itself +carry it will silently climb to whichever ancestor does, and that may not be +the one the author meant. +.PP +.B The safe idiom. +Push the object you actually want onto the scope stack yourself, with a +.B {{#name}} +section, before reading its fields \(em then a bare field inside that +section can only resolve against the object you just pushed, or fail +outright and fall through to an inverted fallback you write explicitly: +.RS +.nf + +{{#name}}{{la}}{{^la}}{{slug}}{{/la}}{{/name}} +.fi +.RE +This opens the day's own +.I name +object (never the month's \(em a +.B {{#section}} +always resolves its OWN path from the point it appears, which for +.B {{#name}} +written inside +.B {{#days}} +is the day's +.IR name , +shadowing the month's identically\-named field exactly as intended), reads +.B la +from it, and falls back to the day's own +.B slug +only when +.B la +is genuinely absent from +.I that +object \(em never when it is merely absent from an ancestor. Every shipped +template that prints a day's name uses exactly this idiom; none uses the +bare dotted form. +.SH HOST\-LANGUAGE COMMENTS +.B The engine has no awareness of the target language's own comment syntax. +A +.B {{...}} +tag inside a LaTeX +.BR % , +a groff +.BR .\e" , +or an HTML +.B +comment is still lexed and rendered exactly as if it were live template +markup \(em the engine sees only its own +.B {{ +/ +.B }} +delimiters, never the host format's comment sigils, because a template is +rendered as one flat character stream, not parsed as LaTeX, groff or HTML +first. This broke a shipped template during development: an explanatory +.B {{example}} +written inside a LaTeX +.B % +comment, meant purely as documentation for a future reader, was parsed as a +real variable reference. +.PP +Write in\-template documentation without any +.B {{ +or +.B }} +characters in it, in whatever host\-comment syntax the target format uses. +Use +.B {{!comment}} +only where the surrounding host format has no comment syntax of its own that +would otherwise be preferable (its own body is safe \(em text between +.B {{! +and +.B }} +is discarded unparsed, so a stray +.B {{ +inside a +.B {{!...}} +comment is not itself a hazard \(em but the comment's own delimiters are +still ordinary +.B {{ +/ +.B }} +tokens, so they compete with the host format's own comment syntax for the +same file exactly as any other tag would). +.SH FLAVOURS +.B \-\-flavour +selects how an interpolated +.I value +is escaped before being written. It never touches the template's own literal +markup (the LaTeX, groff, HTML, XML or ICS surrounding a +.BR {{tag}} ), +which is the template author's and is trusted exactly as written. One of six: +.TP +.B latex +.BR \e " \(-> " \etextbackslash{} , +.BR { " \(-> " \e{ , +.BR } " \(-> " \e} , +.BR $ " \(-> " \e$ , +.BR & " \(-> " \e& , +.BR # " \(-> " \e# , +.BR _ " \(-> " \e_ , +.BR % " \(-> " \e% , +.BR ^ " \(-> " \etextasciicircum{} , +.BR ~ " \(-> " \etextasciitilde{} . +Every LaTeX special character is covered; nothing else is touched. +.TP +.B groff +A backslash is escaped to +.BR \ee , +because a bare backslash starts a groff escape. If the ESCAPED string then +begins with +.B . +or +.BR ' , +the zero\-width non\-printing character +.B \e& +is prefixed \(em a +.B . +or +.B ' +in column one would otherwise start a request rather than print literally. +.TP +.B html +(also used for the +.B xml +flavour, identically) +.BR & " \(-> " & , +.BR < " \(-> " < , +.BR > " \(-> " > , +.BR \(dq " \(-> " " , +.BR ' " \(-> " ' . +.TP +.B xml +Identical to +.B html +above. +.TP +.B ics +Per RFC 5545: a backslash doubles, a semicolon and a comma are each +backslash\-escaped, a newline becomes the two\-character sequence +.BR \en , +and a carriage return is dropped outright (never doubled or passed through). +Line folding at 75 octets, on a UTF\-8 character boundary, is applied +separately to the whole rendered line \(em it is not part of value escaping +and is not something a template can see or control. +.TP +.B none +Escapes nothing at all: the value is inserted byte\-for\-byte. See +.B LIMITATIONS +below \(em this is not an oversight, and it is not safe to treat as one. +.PP +.B \-\-flavour +is inferred from +.BR \-\-template 's +own file extension when the flag is omitted: +.RS +.nf + +.I .tex \(-> latex +.I .ms .mom .me \(-> groff +.I .html .htm \(-> html +.I .xml \(-> xml +.I .ics \(-> ics +.I .md .adoc .txt \(-> none +.fi +.RE +.PP +An extension +.B colitur +does not recognise is a hard +.B ERROR +naming the six flavours above; it is +.I never +a silent fallback to +.BR none . +Guessing the flavour wrong would produce output that looks fine right up +until the metacharacters it silently failed to escape appear in a rendered +document. +.SH LIMITATIONS +.B AsciiDoc and Markdown are NOT escaped. +The +.B none +flavour (selected for +.IR .md " and " .adoc , +as well as +.IR .txt ) +passes every interpolated value through unchanged. This is a deliberate +choice, not a gap: unlike LaTeX, groff, HTML, XML or ICS, AsciiDoc and +Markdown have no fixed, small metacharacter set that could be escaped +mechanically \(em their own metacharacters are context\-dependent (a +.B * +means something different at the start of a line than in the middle of a +word), and escaping them here, generically, would produce +.I worse +output than leaving values alone in the ordinary case. +.PP +The consequence is real and is stated here plainly rather than left for a +reader to discover: a feast name, or any other interpolated field, containing +.B * +or +.B _ +renders as Markdown/AsciiDoc emphasis in the rendered document, not as a +literal asterisk or underscore. No shipped sanctoral name currently contains +either character, but a +.BR colitur\-overlay (5) +file supplying a local celebration's own name is not validated against this +constraint, and its author is responsible for avoiding both characters, or +accepting the emphasis, in any name rendered through a +.I .md +or +.I .adoc +template. +.PP +Only the Extraordinary Form (1962) view model is documented below; see +.BR colitur (1) +for the rite's own scope and limitations (readings cover only the Epistle +and Gospel; the votive Office of the Blessed Virgin Mary on Saturday does not +yet select among its five seasonal Masses). +.SH VIEW MODEL +The value a template renders against is built once per +.B colitur table +/ +.B render +/ +.B publish +invocation, from the same resolved calendar +.B colitur emit +uses, and is shaped for two artefacts from one model: a flat booklet (the +.B days +list, one entry per day of the requested year) and a month grid (the +.B months +list, each carrying its own +.B weeks +list of Sunday\-started, seven\-cell rows, padded at both ends with blank +cells so every row has exactly seven). This is the same shape published at +.IR schema/day\-v1.json , +described here in prose; the JSON Schema is the machine\-checked contract and +this page is its worked explanation. +.SS Top level +.TP +.B rite +The rite identifier, currently always the string +.BR ef . +.TP +.B year +The civil year requested, as a four\-digit string. +.TP +.B months +A list of twelve +.B month +objects, January through December. +.TP +.B days +A flat list of every day's own +.B day +object, in date order, for the whole requested year \(em what a booklet +template iterates over directly, without going through +.BR months . +.SS month +.TP +.B num +The month number, 1 through 12, as a string. +.TP +.B name +An object keyed by language (currently +.B la +and +.BR en ), +each value the month's own name in that language (e.g. +.RB \(lq Ianuarius \(rq +/ +.RB \(lq January \(rq ). +.TP +.B days +This month's own +.B day +objects, in date order, only the days that actually fall in this month. +.TP +.B weeks +This month's +.B day +objects grouped into Sunday\-started rows of exactly seven, the first and +last rows padded with blank cells (see +.B day \(-> in_month +below) so every row has seven entries regardless of which weekday the month +starts or ends on. +.SS week +.TP +.B num +The week's ordinal within its month (1, 2, 3, ...), as a string. This is +.I not +a liturgical week number \(em see +.B day \(-> week +below for that. +.TP +.B days +Exactly seven +.B day +objects, Sunday first. +.SS day +Every key below is always present on every day object, including a padding +cell (see +.BR in_month ), +so a template never hits a missing key on a real day OR a blank grid cell \(em +the one deliberate exception is that a padding cell's own string fields are +all set to the empty string and its boolean and list fields to +.B false +/empty, which read as absent under +.BR # / ^ / {{var}} +exactly as a genuinely unset field would. +.TP +.B iso +The date, ISO\-8601 (\c +.IR YYYY\-MM\-DD ). +Empty on a grid padding cell. +.TP +.B dom +The day of the month, as a string (no leading zero). Empty on a padding +cell. +.TP +.B dow +The day of the week as a string digit, +.B 0 +for Sunday through +.B 6 +for Saturday. Present, and meaningful, even on a padding cell \(em it is how +a grid template knows which column a blank cell belongs in. +.TP +.B in_month +Boolean. +.B false +on a padding cell (a blank cell added so a month's first or last week has +seven entries); a template checks this, not +.BR iso 's +emptiness, to decide whether to render a cell's contents. +.TP +.B season +The liturgical season's own string name (e.g. +.BR paschaltide ", " lent ). +.TP +.B week +The liturgical week number within the season, as a string, or the empty +string on a day the rite does not number (this is +.I not +the same field as a +.B week +object's own +.BR num , +described above \(em see +.B SCOPE AND LOOKUP +for why the two identically\-named fields do not collide here: a plain +.B day +object has no +.B num +key of its own at all, only +.BR week , +so there is nothing for it to shadow). +.TP +.B slug +The observed celebration's stable identifier (e.g. +.BR ef\-easter\-sunday ). +.TP +.B name +An object keyed by language, the observed celebration's own name in each +language colitur's data supplies one for. Frequently has no +.B la +or +.B en +key at all (most temporal days, most sanctoral entries in the shipped data) +\(em see +.B SCOPE AND LOOKUP +above for the resulting month\-name collision and its safe idiom. +.TP +.B rank +The observed celebration's class, as the kernel's own string (e.g. +.BR class\-1 ). +There is deliberately no separate, localized rank label: the kernel carries +no per\-language rank names to draw one from. +.TP +.B colour +The observed celebration's liturgical colour, lowercase (one of +.BR white ", " red ", " green ", " violet ", " rose ", " black , +or the empty string). +.TP +.BR is_white ", " is_red ", " is_green ", " is_violet ", " is_rose ", " is_black +Six booleans, exactly one true (matching +.BR colour ) +on a real day, all false on a padding cell. Provided so a template can +select styling (a cell background colour, a class name) with a plain +.B {{#is_white}} +section instead of a string comparison the engine does not offer \(em there +is no expression evaluation, so this is the only way a template branches on +colour at all. +.TP +.B subject +Whose feast this is, lowercase (one of +.BR lord ", " bvm ", " saint ", " temporal ). +.TP +.B comms +A list of commemoration objects admitted on this day, each carrying +.BR slug , +.B name +(an object keyed by language, same shape as the day's own +.BR name ), +and +.B privileged +(boolean: true for a privileged commemoration under RG 109, which survives +even where an ordinary one would be capped out). Empty list on a day with no +commemorations, and always an empty list \(em never absent \(em on a padding +cell. +.TP +.B transferred_in +A list of at most one object, present when a feast impeded elsewhere was +transferred onto THIS day (RG 96\(en98); carries the transferred +celebration's own +.BR slug . +Empty list when nothing transferred in. +.TP +.B transferred_out +A list of objects, one per celebration that would have fallen on this day +but was displaced and moved to a later date; each carries +.B slug +and +.B to +(the ISO\-8601 date it was moved to). Empty on the ordinary day. +.TP +.B first +The Epistle/Lesson reading citation (e.g. +.RB \(lq "Heb 1:1\-12" \(rq ), +never scripture text \(em a reference only. Empty string when none resolved. +.TP +.B gospel +The Gospel reading citation, same shape as +.BR first . +.TP +.B last +Boolean, true on the seventh (final) cell of a grid row, false everywhere +else including every entry of the flat +.B days +list. Exists because the engine offers no "unless this is the last item" +construct, so a template that must print a separator BETWEEN cells but not +after the last one (a table row's column rule, for instance) reads this flag +rather than computing it: without it, a seven\-column LaTeX grid would emit +an eighth, empty column and +.B pdflatex +would reject the file outright. +.SH A WORKED MINIMAL TEMPLATE +A plain\-text booklet, one line per day, using the safe name idiom from +.BR "SCOPE AND LOOKUP" : +.RS +.nf + +{{rite}} {{year}} +{{#days}} +{{iso}} {{#name}}{{la}}{{^la}}{{slug}}{{/la}}{{/name}} {{colour}}{{#comms}} +{{slug}}{{/comms}} +{{/days}} +.fi +.RE +.PP +Rendered (extension +.IR .txt , +so flavour +.BR none , +no escaping applied; the blank lines are the template's own \(em its section +body starts and ends with a literal newline, and this engine does not trim +one): +.RS +.nf + +.B colitur table \-\-year 2026 \-\-template minimal.txt | head \-7 +ef 2026 + +2026\-01\-01 ef\-circumcision white + +2026\-01\-02 ef\-christmas\-1\-friday white + +2026\-01\-03 Officium sanctae Mariae in sabbato white +.fi +.RE +.PP +The second data line shows the fallback firing: 2 January carries no Latin +.B name +in the shipped data, so +.B {{^la}} +supplies +.B {{slug}} +instead. The third shows the non\-fallback case: 3 January +.I does +carry a Latin name (the votive Office of the Blessed Virgin Mary on +Saturday), and the idiom prints it correctly. A template using the unsafe, +bare +.B {{name.la}} +form would instead have printed +.RB \(lq Ianuarius \(rq +on BOTH of those lines \(em the enclosing month's own name \(em see +.B SCOPE AND LOOKUP +above. +.SH SEE ALSO +.BR colitur (1) +for +.BR table ", " render " and " publish , +and for the five +.B emit +formats that share this same view model. +.PP +.BR colitur\-overlay (5) +for the local\-calendar file format that supplies the celebrations a +template's +.B name +and +.B slug +fields can carry. +.SH LICENSE +AGPL\-3.0\-or\-later. diff --git a/man/colitur.1 b/man/colitur.1 index d015a49..005cc0a 100644 --- a/man/colitur.1 +++ b/man/colitur.1 @@ -448,6 +448,17 @@ needs and a booklet does not). A key absent on a given day (an optional field a rite does not always set) renders as the empty string rather than an error \(em the one deliberate silence, so a template survives a day that does not carry every optional field. +.PP +See +.BR colitur\-templates (5) +for the full syntax, the escaping table per flavour, the complete +view\-model field reference, and \(em before writing a template of any +complexity \(em its +.B SCOPE AND LOOKUP +section: an inner key silently loses to an outer key of the same name (a bare +.B {{name.la}} +inside a day resolves to the enclosing MONTH's name, not the day's own), +which has produced wrong output in this project's own templates. .SS Flavours .BI \-\-flavour controls how interpolated @@ -692,11 +703,9 @@ shapes: .RE .PP Accepted on -.B day -and -.B readings -only. The other commands read no sanctoral data at all, so the flag would have -no effect there and is +.BR day ", " readings ", " emit ", " table ", " render " and " publish . +.BR easter " and " temporal +read no sanctoral data at all, so the flag would have no effect there and is .I refused rather than silently ignored. .PP @@ -845,6 +854,13 @@ reading citations fall back to the day's ordinary ones. for the overlay file format \(em every directive, every field, the three date shapes and worked examples. .PP +.BR colitur\-templates (5) +for the template format used by +.BR table ", " render " and " publish +\(em the four syntax forms, the six flavours and their escaping, the full +view\-model field reference, and the scope\-shadowing hazard a template author +will hit. +.PP .BR lectio (1) .SH LICENSE AGPL\-3.0\-or\-later. diff --git a/schema/dune b/schema/dune new file mode 100644 index 0000000..b3c7dfe --- /dev/null +++ b/schema/dune @@ -0,0 +1,16 @@ +; The published contracts, installed into /share/colitur/schema/ so +; an installed `colitur` can find schema/day-v1.json from an arbitrary +; working directory, not only a build-tree checkout -- bin/main.ml's own +; [schema_path] (`colitur publish`) probes exactly this location as its +; INSTALLED candidate, mirroring data/dune's own ef/ layout. Task 13. +; +; colitur-v1.xsd ships alongside it as the XML sibling contract (`colitur +; emit --format xml`); nothing in colitur itself reads it at runtime -- only +; `make check-schema`, via xmllint, and a template author who wants to +; validate their own rendered XML by hand. +(install + (section share) + (package colitur) + (files + (day-v1.json as schema/day-v1.json) + (colitur-v1.xsd as schema/colitur-v1.xsd))) diff --git a/templates/dune b/templates/dune new file mode 100644 index 0000000..e5918a0 --- /dev/null +++ b/templates/dune @@ -0,0 +1,22 @@ +; The shipped templates, installed into /share/colitur/templates/ +; so a user can render a booklet or a wall calendar straight from an +; installed `colitur`, without a source checkout on hand -- the same +; "runnable documentation" reasoning data/dune already gives +; examples/diocesan-example.sexp. Nothing in colitur's own code path looks +; these up (--template takes a plain, user-given file path, read exactly +; like an overlay file), so this is a convenience install, not something a +; probe function depends on the way [data_dir]/[schema_path] depend on +; data/ef/ and schema/. Task 13. +(install + (section share) + (package colitur) + (files + (ef/ordo.tex as templates/ef/ordo.tex) + (ef/ordo.ms as templates/ef/ordo.ms) + (ef/ordo.html as templates/ef/ordo.html) + (ef/ordo.adoc as templates/ef/ordo.adoc) + (ef/ordo.md as templates/ef/ordo.md) + (ef/ordo.txt as templates/ef/ordo.txt) + (ef/grid.tex as templates/ef/grid.tex) + (ef/grid.ms as templates/ef/grid.ms) + (ef/grid.html as templates/ef/grid.html))) -- cgit v1.3 From bca7dabd2b436b8fe8de21736c59437b5ea990af Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 19 Aug 2026 10:42:22 +0200 Subject: fix(cli): publish --prune refuses a manifest entry that escapes --out CRITICAL: .colitur-manifest lives INSIDE the tree publish writes into -- the very tree this feature exists to have committed into a git repo. A manifest entry with a ".." path component, or an absolute path, let --prune Sys.remove/Unix.rmdir a file OUTSIDE --out. No attacker is required: an ordinary bad merge, a conflict resolved the wrong way, or a hand-edit of that file is enough to plant such an entry, and publish's own stated contract -- it never deletes a file it does not own -- broke outright the moment one was present. Two independent checks, both required, applied before every deletion: - structural (manifest_entry_is_safe): reject an entry that is absolute or has a ".." path COMPONENT, by splitting on '/' and comparing components, not by substring-matching ".." (which would wrongly reject a legitimate name like foo..bar). - containment (resolves_under): resolve both --out and the candidate with Unix.realpath (closing a symlink-inside-out gap the structural check alone would miss) and verify the candidate is a genuine path descendant of --out, not merely a string with the same prefix. Applied at both the file-deletion loop and prune_empty_dirs' own directory removals. A rejected entry is skipped with a one-line stderr warning; publish completes rather than aborting -- a corrupted manifest must not make the tool itself unusable. test/cli.t reproduces the exact canary scenario (a ".." entry surviving deletion of a file outside --out), an absolute-path entry, and a legitimate dotted filename (no .. component) still pruning normally, alongside the existing --prune coverage. --- bin/main.ml | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++------ test/cli.t | 48 ++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 9 deletions(-) (limited to 'bin') diff --git a/bin/main.ml b/bin/main.ml index 4f1fbf2..6f3cbd5 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -561,6 +561,63 @@ let read_manifest out = | Error _ -> [] | Ok contents -> String.split_on_char '\n' contents |> List.filter (fun l -> l <> "") +(* Fix round 1 (coordinator review), CRITICAL: a manifest entry is + UNTRUSTED input the moment [--prune] reads it back. The manifest is a + plain-text file that lives INSIDE the very tree this feature exists to + have committed into a git repo -- an ordinary bad merge or a hand-edit is + enough to put an arbitrary path in it, no attacker required. Without a + check, an entry like "../outside/CANARY.txt" resolves, via + [Filename.concat out entry], to a path OUTSIDE [out], and the prune loop + below would [Sys.remove] it -- deleting a file [publish] never wrote, + breaking the "never deletes a file it does not own" contract outright. + + Two independent checks, deliberately, because either alone is easy to + regress later without anyone noticing in review: + + 1. STRUCTURAL ([manifest_entry_is_safe]) -- reject an entry that is + absolute, or that has a ".." path component anywhere. Split on '/' + and compare COMPONENTS, never a bare substring test: substring- + matching ".." would wrongly reject a legitimate name like + "foo..bar", which contains the two characters but has no ".." + component of its own. + 2. CONTAINMENT ([resolves_under]) -- even an entry that passes check 1 + is not trusted until the path it actually resolves to, symlinks + included, is verified to sit under [out]. [Unix.realpath] resolves + symlinks as well as "..", so this also catches an entry that a + symlink planted inside [out] could use to defeat check 1 alone. A + plain string-prefix compare is not enough by itself either: + "/tmp/pub1" is a byte-prefix of "/tmp/pub1-evil", a directory that is + not nested inside it at all, so [is_under] insists the character + right after the prefix is the path separator (or that the paths are + identical). *) +let manifest_entry_is_safe entry = + entry <> "" + && entry.[0] <> '/' + && not (List.mem ".." (String.split_on_char '/' entry)) + +let is_under ~root path = + let root = + if String.length root > 1 && root.[String.length root - 1] = '/' then + String.sub root 0 (String.length root - 1) + else root + in + String.equal path root + || (String.length path > String.length root + && String.sub path 0 (String.length root) = root + && path.[String.length root] = '/') + +(* [Unix.realpath] requires the path to exist, which is fine here: every + caller below checks [Sys.file_exists]/[Sys.readdir] first. Any failure + (missing path, dangling symlink, permission error) is treated as "not + contained" -- refuse to act rather than guess. *) +let resolves_under out p = + match Unix.realpath out with + | exception (Unix.Unix_error _ | Sys_error _) -> false + | out_real -> ( + match Unix.realpath p with + | exception (Unix.Unix_error _ | Sys_error _) -> false + | p_real -> is_under ~root:out_real p_real) + (* [--prune] deletes the FILES a stale manifest entry names, but that alone can leave their parent directories (ef///, then ef//) empty behind them -- and an empty directory still makes `test -d @@ -568,9 +625,18 @@ let read_manifest out = an old year is gone. Walk upward from each deleted file's own directory, removing it while it is empty, stopping at (never including) [out] itself: [out] is the caller's own directory, never ours to remove, even - when it is empty. *) + when it is empty. The same containment discipline as the file deletions + above applies here too ([resolves_under]), not only structurally (this + function is only ever reached via a [p] the file-deletion path already + validated, but re-checking each directory step is the belt to that + entry's braces -- see the two-layer reasoning above). *) let rec prune_empty_dirs ~out dir = - if dir <> out && String.length dir > String.length out && Sys.file_exists dir then + if + dir <> out + && String.length dir > String.length out + && Sys.file_exists dir + && resolves_under out dir + then match Sys.readdir dir with | [||] -> (try Unix.rmdir dir with Unix.Unix_error _ -> ()); @@ -679,16 +745,31 @@ let publish_report ~from_y ~to_y ~out ~overlays ~dtstamp ~prune = emit "schema/day-v1.json" schema; emit "index.html" (index_html ~from_y ~to_y); let now = List.sort compare !written in + (* Every stale entry is validated TWICE before anything is removed -- + see [manifest_entry_is_safe]/[resolves_under]'s own comment above for + why both layers exist. A rejected entry is skipped and warned about on + stderr, never fatal: a corrupt or hand-mangled manifest must not make + `publish` itself unusable -- it completes, having refused to act on + the bad line. *) if prune then List.iter (fun old -> - if not (List.mem old now) then begin - let p = Filename.concat out old in - if Sys.file_exists p then begin - Sys.remove p; - prune_empty_dirs ~out (Filename.dirname p) - end - end) + if not (List.mem old now) then + if not (manifest_entry_is_safe old) then + Printf.eprintf + "colitur: refusing to prune manifest entry %S (absolute path or .. component) +" old + else begin + let p = Filename.concat out old in + if Sys.file_exists p then + if resolves_under out p then begin + Sys.remove p; + prune_empty_dirs ~out (Filename.dirname p) + end + else + Printf.eprintf "colitur: refusing to prune %s (resolves outside %s) +" p out + end) (read_manifest out); write_file (Filename.concat out manifest_name) (String.concat "\n" now ^ "\n"); Printf.printf "colitur: wrote %d files to %s\n" (List.length now) out diff --git a/test/cli.t b/test/cli.t index c74331b..a4fa696 100644 --- a/test/cli.t +++ b/test/cli.t @@ -1004,3 +1004,51 @@ ignored, the same discipline as everywhere else: $ colitur day 2027 --out /tmp/pub3 --prune colitur: --out/--prune have no effect on `day`; refusing rather than ignoring them [2] + +--prune's manifest-driven deletion is hardened against a manifest entry it +did not itself write (fix round 1, F1, CRITICAL): the manifest lives INSIDE +the tree publish writes into, so a bad merge or a hand-edit can put an +arbitrary path in it -- no attacker required. This is the exact CANARY +reproduction the finding was raised with: a ".." entry appended to the +manifest must never let --prune delete outside --out. + + $ rm -rf /tmp/pub-sec /tmp/pub-sec-outside + $ mkdir -p /tmp/pub-sec-outside + $ touch /tmp/pub-sec-outside/CANARY.txt + $ colitur publish --from 2027 --to 2027 --out /tmp/pub-sec >/dev/null + $ echo '../pub-sec-outside/CANARY.txt' >> /tmp/pub-sec/.colitur-manifest + $ colitur publish --from 2028 --to 2028 --out /tmp/pub-sec --prune >/dev/null + colitur: refusing to prune manifest entry "../pub-sec-outside/CANARY.txt" (absolute path or .. component) + $ test -f /tmp/pub-sec-outside/CANARY.txt && echo canary-survives + canary-survives + +The same run's own legitimate stale entries (2027's files, superseded by +2028) still prune normally -- the hardening does not disable pruning, only +unsafe entries: + + $ test -d /tmp/pub-sec/ef/2027 || echo 2027-pruned-normally + 2027-pruned-normally + +An absolute-path entry is refused the same way, not only a ".." one: + + $ echo '/tmp/pub-sec-outside/CANARY.txt' >> /tmp/pub-sec/.colitur-manifest + $ colitur publish --from 2028 --to 2028 --out /tmp/pub-sec --prune >/dev/null + colitur: refusing to prune manifest entry "/tmp/pub-sec-outside/CANARY.txt" (absolute path or .. component) + $ test -f /tmp/pub-sec-outside/CANARY.txt && echo canary-still-survives + canary-still-survives + +A legitimate filename that merely CONTAINS two dots -- but has no ".." path +COMPONENT -- is not caught by the same check, proving it is not +over-broad: it still prunes normally when stale. + + $ touch /tmp/pub-sec/ef/2027..old.json + $ echo 'ef/2027..old.json' >> /tmp/pub-sec/.colitur-manifest + $ colitur publish --from 2029 --to 2029 --out /tmp/pub-sec --prune >/dev/null + $ test -f /tmp/pub-sec/ef/2027..old.json || echo dotted-name-pruned + dotted-name-pruned + +...and that same run is an ordinary --prune cycle in every other respect -- +2028's own files, now stale relative to 2029, are gone too: + + $ test -d /tmp/pub-sec/ef/2028 || echo pruned-2028 + pruned-2028 -- cgit v1.3 From b084bba86fe9d394d6a5ea287161864e7695a609 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 19 Aug 2026 11:35:21 +0200 Subject: fix(cli): guard publish's IO, validate --dtstamp, and list all commands publish's own mkdir_p/write_file (unlike every other IO path on this branch) were unguarded: an unwritable --out parent raised a bare Unix.Unix_error(EACCES,...) and an --out naming an existing file raised ENOTDIR, both as uncaught exceptions with a stack trace rather than the project's one-line "colitur: ..." form. The same defect class commit 6bd741b already fixed once for template reads -- --out is user input too. Fixed by wrapping the whole publish_report call (not each write_file site) in one handler for Unix.Unix_error and Sys_error, mirroring why that earlier fix guarded the whole read and not only the open. Added a cram case using a read-only directory inside the test's own cram sandbox, not /tmp, so a failed cleanup cannot leave an unwritable directory behind in a shared location. --dtstamp was the only user string reaching output unescaped and unvalidated: "--dtstamp hello" silently emitted an invalid "DTSTAMP:hello", and a value carrying its own CRLF injected extra lines into every VEVENT. Fixed by rejecting anything not matching RFC 5545's UTC form (8 digits, 'T', 6 digits, 'Z') before either emit or publish does anything else, one line to stderr, exit 2. usage() was byte-unchanged from before the branch and listed only the six pre-existing commands, omitting all four commands this branch added (emit, table, render, publish). Added them; the three cram pins of the exact usage string are updated to match. --- bin/main.ml | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- test/cli.t | 43 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 98 insertions(+), 5 deletions(-) (limited to 'bin') diff --git a/bin/main.ml b/bin/main.ml index 6f3cbd5..6f64582 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -389,7 +389,38 @@ let readings_report ~overlays y = resolved_year_report ~line:readings_line ~over file; XML: one document per year, the schema's own root is a single year; ICS: one VCALENDAR per year, valid to concatenate for a subscriber that reads multiple files). *) +(* [--dtstamp] is the only user string that reaches [emit]/[publish] output + unescaped and unvalidated (it becomes an ICS DTSTAMP: property value + directly, Colitur_render.Emit_ics.year's own [dtstamp] parameter) -- + every OTHER interpolated value in this project's output is either + escaped (Escape.apply) or engine-computed, never raw user input placed + straight into a line-oriented format. RFC 5545 section 3.3.5 defines + DATE-TIME's UTC form as exactly 8 digits, "T", 6 digits, "Z" + (e.g. "20270101T000000Z"); rejecting anything else is what stops + "--dtstamp hello" from silently emitting a malformed "DTSTAMP:hello" AND + what stops a value carrying its own CRLF (e.g. "X\r\nBEGIN:VEVENT\r\n...") + from being injected verbatim into every VEVENT -- a value shaped exactly + like the real form cannot contain either character. *) +let dtstamp_well_formed s = + let is_digit c = c >= '0' && c <= '9' in + String.length s = 16 + && String.for_all is_digit (String.sub s 0 8) + && s.[8] = 'T' + && String.for_all is_digit (String.sub s 9 6) + && s.[15] = 'Z' + +let check_dtstamp = function + | None -> () + | Some s when dtstamp_well_formed s -> () + | Some s -> + Printf.eprintf + "colitur: --dtstamp %S is not RFC 5545 UTC form (want 8 digits, 'T', 6 digits, 'Z', e.g. \ + 20270101T000000Z)\n" + s; + exit 2 + let emit_report ~format ~overlays ~dtstamp ~from_y ~to_y = + check_dtstamp dtstamp; if from_y > to_y then begin Printf.eprintf "colitur: --from %d is after --to %d\n" from_y to_y; exit 2 @@ -695,6 +726,7 @@ let index_html ~from_y ~to_y = Buffer.contents b let publish_report ~from_y ~to_y ~out ~overlays ~dtstamp ~prune = + check_dtstamp dtstamp; if from_y > to_y then begin Printf.eprintf "colitur: --from %d is after --to %d\n" from_y to_y; exit 2 @@ -978,7 +1010,9 @@ let print_help () = let usage () = prerr_endline "colitur: usage: colitur easter | colitur temporal | colitur day | colitur \ - readings | colitur check FILE | colitur new-overlay (try: colitur --help)"; + readings | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template \ + FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | \ + colitur check FILE | colitur new-overlay (try: colitur --help)"; exit 2 let with_year ys f = @@ -1383,5 +1417,27 @@ let () = exit 2 | Some from_ys, Some to_ys -> with_year from_ys (fun from_y -> - with_year to_ys (fun to_y -> publish_report ~from_y ~to_y ~out ~overlays ~dtstamp ~prune)))) + with_year to_ys (fun to_y -> + (* [publish_report] writes many files across a whole + year range ([mkdir_p]/[write_file], both above) -- + an unwritable [--out] parent (EACCES) or an [--out] + that names an existing plain file (ENOTDIR) raises + from deep inside that loop, same defect class as + the template read this project already guards + (commit 6bd741b): "--out" is user input too, and + the WHOLE call is guarded here rather than each + [write_file] site individually, for the same + reason that fix guarded the whole read and not + only the open. [Unix.mkdir] raises + [Unix.Unix_error] directly; [open_out_bin] + (stdlib, not the Unix module) wraps the same + underlying errno in [Sys_error] instead -- both + are real on this path, so both are caught. *) + try publish_report ~from_y ~to_y ~out ~overlays ~dtstamp ~prune with + | Unix.Unix_error (e, fn, arg) -> + Printf.eprintf "colitur: %s: %s: %s\n" fn arg (Unix.error_message e); + exit 2 + | Sys_error e -> + Printf.eprintf "colitur: %s\n" e; + exit 2)))) | _ -> usage ()) diff --git a/test/cli.t b/test/cli.t index a4fa696..80c8078 100644 --- a/test/cli.t +++ b/test/cli.t @@ -17,7 +17,7 @@ A year outside the supported domain is rejected (exit 2): No/garbage arguments give a usage error (exit 2): $ colitur - colitur: usage: colitur easter | colitur temporal | colitur day | colitur readings | colitur check FILE | colitur new-overlay (try: colitur --help) + colitur: usage: colitur easter | colitur temporal | colitur day | colitur readings | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | colitur check FILE | colitur new-overlay (try: colitur --help) [2] The EF temporal cycle for a year, one line per day: @@ -327,14 +327,14 @@ A flag needing a value, given none: $ colitur day 2026 --overlay colitur: --overlay needs a file path - colitur: usage: colitur easter | colitur temporal | colitur day | colitur readings | colitur check FILE | colitur new-overlay (try: colitur --help) + colitur: usage: colitur easter | colitur temporal | colitur day | colitur readings | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | colitur check FILE | colitur new-overlay (try: colitur --help) [2] An unknown option is rejected rather than treated as a positional word: $ colitur day 2026 --diocese colitur: unknown option --diocese - colitur: usage: colitur easter | colitur temporal | colitur day | colitur readings | colitur check FILE | colitur new-overlay (try: colitur --help) + colitur: usage: colitur easter | colitur temporal | colitur day | colitur readings | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | colitur check FILE | colitur new-overlay (try: colitur --help) [2] The shipped example overlay is runnable documentation, and it must actually @@ -464,6 +464,28 @@ An unknown format is a usage error on stderr, exit 2: colitur: unknown format "yaml" (want csv, json, sexp, xml or ics) [2] +--dtstamp is the only user string that reaches ICS output unescaped and +unvalidated -- it must be exactly RFC 5545's UTC DATE-TIME form (8 digits, +"T", 6 digits, "Z") or refused outright, rather than either silently +emitting a malformed DTSTAMP or, worse, letting an embedded CRLF inject +extra lines into every VEVENT: + + $ colitur emit --format ics --from 2027 --to 2027 --dtstamp hello + colitur: --dtstamp "hello" is not RFC 5545 UTC form (want 8 digits, 'T', 6 digits, 'Z', e.g. 20270101T000000Z) + [2] + + $ colitur emit --format ics --from 2027 --to 2027 --dtstamp "$(printf 'X\r\nBEGIN:VEVENT\r\nUID:evil')" + colitur: --dtstamp "X\r\nBEGIN:VEVENT\r\nUID:evil" is not RFC 5545 UTC form (want 8 digits, 'T', 6 digits, 'Z', e.g. 20270101T000000Z) + [2] + +A well-formed value is threaded through unchanged. (The events themselves +end in CRLF per RFC 5545 -- match the substring, not a `$`-anchored full +line, or a shell that does not mangle the trailing "\r" is doing the +grep-anchor's job for it by accident.) + + $ colitur emit --format ics --from 2027 --to 2027 --dtstamp 20270101T000000Z | grep -c 'DTSTAMP:20270101T000000Z' + 365 + emit refuses a reversed range rather than emitting nothing: $ colitur emit --format csv --from 2028 --to 2027 @@ -993,6 +1015,21 @@ publish never deletes a file it does not own: colitur: publish requires --out DIR [2] +publish writes many files across a whole year range (mkdir_p/write_file), +same as the template read guarded in commit 6bd741b -- --out is user input +too, and an unwritable parent used to surface as an uncaught +Unix.Unix_error instead of the project's one-line form. The read-only +directory below lives in this test's own cram sandbox, not /tmp: a failed +`rm -rf` of an unwritable directory would otherwise leave it behind in a +shared location, so it is restored to writable before the test ends either +way: + + $ mkdir ro-parent && chmod 555 ro-parent + $ colitur publish --from 2027 --to 2027 --out ro-parent/sub + colitur: mkdir: ro-parent/sub: Permission denied + [2] + $ chmod 755 ro-parent + publish's own flags have no effect on the other commands, and other commands' flags have no effect on publish -- refused rather than silently ignored, the same discipline as everywhere else: -- cgit v1.3