summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-19 09:35:52 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-19 09:35:52 +0200
commit99172c86c518fdb2104c098b7b7a79e6c13ba8ea (patch)
tree62d7e82300c13136aa7ffb668c5ddb774ca049b6
parentd71504ad9fc39b735689da32fa0be8a63c2cc7f8 (diff)
downloadcolitur-99172c86c518fdb2104c098b7b7a79e6c13ba8ea.tar.gz
colitur-99172c86c518fdb2104c098b7b7a79e6c13ba8ea.zip
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.
-rw-r--r--bin/main.ml179
-rw-r--r--man/colitur.1183
-rw-r--r--test/cli.t69
3 files changed, 428 insertions, 3 deletions
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 [<year>] 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]