summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-19 10:18:24 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-19 10:18:24 +0200
commit2760d43d695ba08fc33f65357590675707b6570d (patch)
tree8c139100c5babd6fce6c7af6d55b7f4938a86efd
parent24de019a3e1d5b9a6d4601bdd65489813baa7e58 (diff)
downloadcolitur-2760d43d695ba08fc33f65357590675707b6570d.tar.gz
colitur-2760d43d695ba08fc33f65357590675707b6570d.zip
feat(cli): colitur publish -- the static tree
Writes ef/<year>.{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/<year>/ 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.
-rw-r--r--bin/dune6
-rw-r--r--bin/main.ml269
-rw-r--r--dune14
-rw-r--r--man/colitur.1157
-rw-r--r--test/cli.t442
-rw-r--r--test/dune8
6 files changed, 888 insertions, 8 deletions
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/<year>/<mm>/, then ef/<year>/)
+ empty behind them -- and an empty directory still makes `test -d
+ out/ef/<year>` 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
+ <prefix>/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
+ "<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\">\n\
+ <title>colitur</title>\n\
+ <style>body{font-family:sans-serif;max-width:40em;margin:2em auto;line-height:1.5}\n\
+ code{background:#f4f4f4;padding:.1em .3em}</style></head><body>\n\
+ <h1>colitur</h1>\n\
+ <p>Liturgical calendar of the 1962 Missale Romanum. Citations only \xe2\x80\x94 never scripture text.</p>\n";
+ Buffer.add_string b "<h2>Subscribe</h2>\n<ul>\n";
+ for y = from_y to to_y do
+ Buffer.add_string b (Printf.sprintf "<li><a href=\"ef/%d.ics\">ef/%d.ics</a></li>\n" y y)
+ done;
+ Buffer.add_string b "</ul>\n<h2>Data</h2>\n<ul>\n";
+ for y = from_y to to_y do
+ Buffer.add_string b
+ (Printf.sprintf
+ "<li>%d: <a href=\"ef/%d.json\">json</a> <a href=\"ef/%d.csv\">csv</a> \
+ <a href=\"ef/%d.xml\">xml</a> \xe2\x80\x94 per-day at <code>ef/%d/MM/DD.json</code></li>\n"
+ y y y y y)
+ done;
+ Buffer.add_string b
+ "</ul>\n<p>Contract: <a href=\"schema/day-v1.json\">schema/day-v1.json</a></p>\n\
+ </body></html>\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/<year>.{json,csv,xml,ics} one civil year, all days
+ ef/<year>/<mm>/<dd>.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/<year>.json one civil year, all days, whole\-year emitters
+ef/<year>.csv
+ef/<year>.xml
+ef/<year>.ics
+ef/<year>/<mm>/<dd>.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))