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 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bin/dune') 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)) -- 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/dune') 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