aboutsummaryrefslogtreecommitdiff
path: root/man
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-19 11:48:30 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-19 11:48:30 +0200
commit6762ce46af3cb12bc6ae37cda762c5d95add7903 (patch)
treebc1d8c86050d0149ff961a9a4ff838f9c474ac2a /man
parent897c274fd28402159ca6d45eedc1257b1ce98696 (diff)
parent390bc6ac5196a946c473d0dbe7760fa41837c428 (diff)
downloadcolitur-6762ce46af3cb12bc6ae37cda762c5d95add7903.tar.gz
colitur-6762ce46af3cb12bc6ae37cda762c5d95add7903.zip
feat: output, rendering and publishing
Gives colitur a publishable exit. Until now its only output was terminal rows; it can now print an ordo booklet and a wall calendar, publish an iCalendar feed people subscribe to, and serve a static JSON/XML API. lib/render escaping (six flavours + RFC 5545 folding), a deliberately logic-less template engine, the view model, and five emitters (CSV, JSON, XML, iCalendar, S-expression) CLI emit, table, render, publish -- all accepting --overlay templates ordo booklet in six flavours, wall grid in three schema day-v1.json and colitur-v1.xsd, the published contract man colitur-templates.5, plus colitur.1 updates The view model is why the engine can stay logic-less: a month grid needs leading blank cells, week bucketing and an in-month test, and a logic-less template can compute none of it. Shaping the data in OCaml keeps the engine safe for untrusted templates and makes the grid trivial. Formats split by whether correctness is mechanical. Presentation goes through templates; iCalendar and XML get dedicated emitters, because folding, exclusive DTEND, stable UIDs and schema fidelity are rules a template cannot enforce and each fails silently in a subscriber's client rather than loudly at generation. publish is deterministic and non-destructive: two runs produce a byte-identical tree, and --prune removes only files a previous run created, refusing any manifest entry that escapes the output directory. No new dependencies. The kernel and rite modules are untouched, and colitur day and colitur readings remain byte-identical.
Diffstat (limited to 'man')
-rw-r--r--man/colitur-templates.5788
-rw-r--r--man/colitur.1501
2 files changed, 1281 insertions, 8 deletions
diff --git a/man/colitur-templates.5 b/man/colitur-templates.5
new file mode 100644
index 0000000..2f42d68
--- /dev/null
+++ b/man/colitur-templates.5
@@ -0,0 +1,788 @@
+.TH COLITUR\-TEMPLATES 5 "2026" "colitur" "File Formats"
+.SH NAME
+colitur\-templates \- template format for colitur(1)'s table, render and publish
+.SH SYNOPSIS
+.I booklet.tex
+.br
+.I calendar.html
+.br
+.I feed.ics
+.SH DESCRIPTION
+A
+.B colitur
+template is the file named by
+.BR "colitur table" 's
+and
+.BR "colitur render" 's
+.B \-\-template
+flag (and used internally by
+.BR "colitur publish" ).
+It is a deliberately logic\-less, Mustache\-family format: the file is
+.I data,
+never a program. There are exactly four constructs \(em variable
+interpolation, a section, an inverted section, and a comment \(em and nothing
+else.
+.PP
+.B There are no partials, no lambdas, no arithmetic, no expression
+.B evaluation, and no "raw" or triple\-brace form that could opt out of
+.B escaping.
+A template cannot include another file, cannot compute anything, and cannot
+choose to skip the escaping its own flavour applies. Everything a rendered
+document needs \(em conditionals on emptiness, iteration over days or weeks,
+formatting \(em is expressed with the four constructs below over the fields
+.B VIEW MODEL
+describes; nothing else is available, and nothing else will be added by
+supplying cleverer template syntax \(em that is what the escaping and
+scope rules exist to prevent.
+.SH SYNTAX
+.TP
+.BI "{{" name "}}"
+Interpolates the value at
+.I name,
+a dot\-separated path resolved against the current scope (see
+.B SCOPE AND LOOKUP
+below). A string value is escaped per the active flavour and inserted; a
+boolean
+.B true
+renders as the literal text
+.RB \(lq true \(rq,
+.B false
+renders as nothing; a list or an object value used as a plain variable also
+renders as nothing \(em only a section can iterate one. A path that resolves
+to nothing renders as nothing, silently: this is the one deliberate silence
+in the engine, so a template survives a day that does not carry every
+optional field (an empty
+.I week
+on a day the rite does not number, an empty
+.I first
+or
+.I gospel
+citation, and so on).
+.TP
+.BI "{{#" name "}}...{{/" name "}}"
+A section. If
+.I name
+resolves to a
+.B list,
+the body is rendered once per item, with each item pushed onto the scope
+stack (see below). If it resolves to a truthy non\-list value (a non\-empty
+string, or an object), the body is rendered once, with that value pushed onto
+the stack. If it resolves to nothing, or to a falsy value (an empty string,
+.BR false ,
+or an empty list), the body is skipped entirely.
+.TP
+.BI "{{^" name "}}...{{/" name "}}"
+An inverted section: the mirror image of
+.BR # .
+The body renders \(em exactly once, without pushing anything new onto the
+scope \(em only when
+.I name
+resolves to nothing, or to a falsy value. This is how a template supplies a
+fallback for an optional or absent field.
+.TP
+.BI "{{!" " text " "}}"
+A comment. Everything between
+.B {{!
+and the closing
+.B }}
+is discarded; nothing is written to the rendered output. See
+.B HOST\-LANGUAGE COMMENTS
+below before relying on this for documentation inside a template that also
+has its own comment syntax.
+.PP
+A section and its inverted counterpart, and a section and its close tag, must
+name the identical path \(em
+.BR {{#days}} " ... " {{/months}}
+is a parse error, not a silently mismatched close. An empty path
+(
+.BR {{.}} ", " {{#}} ", " {{^}} ", " {{/}}
+) is also a parse error: there is no "current context" concept for a bare dot
+to mean, so nothing is guessed on a template's behalf.
+.PP
+A malformed template \(em an unterminated
+.BR {{ ,
+a section left unclosed, a close tag with no matching open \(em is reported
+with the parser's own reason and exits 2. A template is user input, exactly
+like an
+.BR colitur\-overlay (5)
+file, and is never allowed to crash the program that reads it.
+.SH SCOPE AND LOOKUP
+.B This section documents a real hazard, not a theoretical one \(em it has
+.B produced wrong output during this program's own development.
+.PP
+Scope is a stack. Rendering starts with the whole view (the year) as the one
+entry on the stack; each
+.B {{#section}}
+pushes the value it iterates or opens onto the stack for the duration of its
+body, and pops it again at
+.BR {{/section}} .
+A lookup for
+.I name
+is tried against the
+.I innermost
+(most recently pushed) entry first. If
+.I name
+is not found there, the lookup falls back to the
+.I next
+entry outward, and so on to the outermost (the year itself). This fallback is
+deliberate and necessary \(em without it, a cell deep inside
+.B {{#months}}{{#weeks}}{{#days}}
+could never reach
+.BR {{year}} ,
+which lives only on the outermost object.
+.PP
+.B The hazard: a DOTTED PATH that resolves only PART WAY inward falls back
+.B WHOLESALE to an outer scope of the SAME NAME.
+A bare key inside the immediately enclosing section is not at risk this way
+\(em it either resolves right there or fails outright (see
+.B num
+below); it is specifically a dotted path, one step of which is missing from
+the inner object, that abandons the whole path and restarts the lookup one
+level out. Falling back outward means a name that exists at
+.I both
+levels never fails and never warns \(em it just silently resolves to the
+.I outer
+one, because the inner object's own absence of that key is indistinguishable
+from "look further out" and "this key does not apply here". Two collisions
+are known to exist in the shipped view model:
+.TP
+.B name
+Both a
+.B month
+and a
+.B day
+carry a
+.I name
+field (each an object keyed by language, e.g.
+.BR la " and " en ).
+Written naively, when
+.B {{#days}}
+is nested inside
+.BR {{#months}} ,
+a bare
+.B {{name.la}}
+does
+.I not
+resolve to the day's own Latin name. It resolves to the
+.I enclosing month's
+Latin name, because the day's own
+.I name
+object either has no
+.B la
+key (an unnamed day) or the dotted path fails partway and the WHOLE path
+falls back to the outer scope, which does have one. This is not a corner
+case: on an ordinary month, most days carry no Latin name at all (only named
+sanctoral days do), so the naive form renders the
+.I month's
+name on nearly every day \(em in a per\-month booklet, dozens of wrong
+lines; in a month grid (whose
+.B {{#weeks}}
+is itself only ever reachable through
+.BR {{#months}} ,
+since
+.I weeks
+is a field of
+.IR month ,
+never a top\-level list) EVERY cell reads the month's own name.
+.PP
+.B The collision needs a month actually on the scope stack to fire.
+Iterating the TOP\-LEVEL, flat
+.B days
+list directly (
+.B {{#days}}...{{/days}}
+at the outermost level, never passing through
+.B {{#months}}
+first) puts no month object anywhere on the stack, so the naive form does
+.I not
+silently substitute the wrong answer there \(em it silently resolves to
+nothing, exactly as any other absent key would, because there is no
+outer scope left to climb to. This is precisely what makes the hazard easy
+to miss: an author who tries the naive form against the flat list first
+sees the unnamed days come out empty, reasonably concludes the form is
+safe, and then hits the real collision the moment the identical fields are
+read from inside
+.BR {{#months}} ,
+which is what every shipped template that produces a grid or a per\-month
+booklet actually does.
+.TP
+.B num
+Both a
+.B month
+and a
+.B week
+carry a
+.I num
+field. Inside
+.BR {{#weeks}} ,
+a bare
+.B {{num}}
+is the week's own number, correctly \(em but only because nothing between
+the week and the day currently redefines it. A template that reaches
+.I num
+from any scope where the immediately enclosing section does not itself
+carry it will silently climb to whichever ancestor does, and that may not be
+the one the author meant.
+.PP
+.B The safe idiom.
+Push the object you actually want onto the scope stack yourself, with a
+.B {{#name}}
+section, before reading its fields \(em then a bare field inside that
+section can only resolve against the object you just pushed, or fail
+outright and fall through to an inverted fallback you write explicitly:
+.RS
+.nf
+
+{{#name}}{{la}}{{^la}}{{slug}}{{/la}}{{/name}}
+.fi
+.RE
+This opens the day's own
+.I name
+object (never the month's \(em a
+.B {{#section}}
+always resolves its OWN path from the point it appears, which for
+.B {{#name}}
+written inside
+.B {{#days}}
+is the day's
+.IR name ,
+shadowing the month's identically\-named field exactly as intended), reads
+.B la
+from it, and falls back to the day's own
+.B slug
+only when
+.B la
+is genuinely absent from
+.I that
+object \(em never when it is merely absent from an ancestor. Every shipped
+template that prints a day's name uses exactly this idiom; none uses the
+bare dotted form.
+.SH HOST\-LANGUAGE COMMENTS
+.B The engine has no awareness of the target language's own comment syntax.
+A
+.B {{...}}
+tag inside a LaTeX
+.BR % ,
+a groff
+.BR .\e" ,
+or an HTML
+.B <!\-\- \-\->
+comment is still lexed and rendered exactly as if it were live template
+markup \(em the engine sees only its own
+.B {{
+/
+.B }}
+delimiters, never the host format's comment sigils, because a template is
+rendered as one flat character stream, not parsed as LaTeX, groff or HTML
+first. This broke a shipped template during development: an explanatory
+.B {{example}}
+written inside a LaTeX
+.B %
+comment, meant purely as documentation for a future reader, was parsed as a
+real variable reference.
+.PP
+Write in\-template documentation without any
+.B {{
+or
+.B }}
+characters in it, in whatever host\-comment syntax the target format uses.
+Use
+.B {{!comment}}
+only where the surrounding host format has no comment syntax of its own that
+would otherwise be preferable (its own body is safe \(em text between
+.B {{!
+and
+.B }}
+is discarded unparsed, so a stray
+.B {{
+inside a
+.B {{!...}}
+comment is not itself a hazard \(em but the comment's own delimiters are
+still ordinary
+.B {{
+/
+.B }}
+tokens, so they compete with the host format's own comment syntax for the
+same file exactly as any other tag would).
+.SH FLAVOURS
+.B \-\-flavour
+selects how an interpolated
+.I value
+is escaped before being written. It never touches the template's own literal
+markup (the LaTeX, groff, HTML, XML or ICS surrounding a
+.BR {{tag}} ),
+which is the template author's and is trusted exactly as written. One of six:
+.TP
+.B latex
+.BR \e " \(-> " \etextbackslash{} ,
+.BR { " \(-> " \e{ ,
+.BR } " \(-> " \e} ,
+.BR $ " \(-> " \e$ ,
+.BR & " \(-> " \e& ,
+.BR # " \(-> " \e# ,
+.BR _ " \(-> " \e_ ,
+.BR % " \(-> " \e% ,
+.BR ^ " \(-> " \etextasciicircum{} ,
+.BR ~ " \(-> " \etextasciitilde{} .
+Every LaTeX special character is covered; nothing else is touched.
+.TP
+.B groff
+A backslash is escaped to
+.BR \ee ,
+because a bare backslash starts a groff escape. If the ESCAPED string then
+begins with
+.B .
+or
+.BR ' ,
+the zero\-width non\-printing character
+.B \e&
+is prefixed \(em a
+.B .
+or
+.B '
+in column one would otherwise start a request rather than print literally.
+.TP
+.B html
+(also used for the
+.B xml
+flavour, identically)
+.BR & " \(-> " &amp; ,
+.BR < " \(-> " &lt; ,
+.BR > " \(-> " &gt; ,
+.BR \(dq " \(-> " &quot; ,
+.BR ' " \(-> " &#39; .
+.TP
+.B xml
+Identical to
+.B html
+above.
+.TP
+.B ics
+Per RFC 5545: a backslash doubles, a semicolon and a comma are each
+backslash\-escaped, a newline becomes the two\-character sequence
+.BR \en ,
+and a carriage return is dropped outright (never doubled or passed through).
+Line folding at 75 octets, on a UTF\-8 character boundary, is applied
+separately to the whole rendered line \(em it is not part of value escaping
+and is not something a template can see or control.
+.TP
+.B none
+Escapes nothing at all: the value is inserted byte\-for\-byte. See
+.B LIMITATIONS
+below \(em this is not an oversight, and it is not safe to treat as one.
+.PP
+.B \-\-flavour
+is inferred from
+.BR \-\-template 's
+own file extension when the flag is omitted:
+.RS
+.nf
+
+.I .tex \(-> latex
+.I .ms .mom .me \(-> groff
+.I .html .htm \(-> html
+.I .xml \(-> xml
+.I .ics \(-> ics
+.I .md .adoc .txt \(-> none
+.fi
+.RE
+.PP
+An extension
+.B colitur
+does not recognise is a hard
+.B ERROR
+naming the six flavours above; it is
+.I never
+a silent fallback to
+.BR none .
+Guessing the flavour wrong would produce output that looks fine right up
+until the metacharacters it silently failed to escape appear in a rendered
+document.
+.SH LIMITATIONS
+.B AsciiDoc and Markdown are NOT escaped.
+The
+.B none
+flavour (selected for
+.IR .md " and " .adoc ,
+as well as
+.IR .txt )
+passes every interpolated value through unchanged. This is a deliberate
+choice, not a gap: unlike LaTeX, groff, HTML, XML or ICS, AsciiDoc and
+Markdown have no fixed, small metacharacter set that could be escaped
+mechanically \(em their own metacharacters are context\-dependent (a
+.B *
+means something different at the start of a line than in the middle of a
+word), and escaping them here, generically, would produce
+.I worse
+output than leaving values alone in the ordinary case.
+.PP
+The consequence is real and is stated here plainly rather than left for a
+reader to discover: a feast name, or any other interpolated field, containing
+.B *
+or
+.B _
+renders as Markdown/AsciiDoc emphasis in the rendered document, not as a
+literal asterisk or underscore. No shipped sanctoral name currently contains
+either character, but a
+.BR colitur\-overlay (5)
+file supplying a local celebration's own name is not validated against this
+constraint, and its author is responsible for avoiding both characters, or
+accepting the emphasis, in any name rendered through a
+.I .md
+or
+.I .adoc
+template.
+.PP
+Only the Extraordinary Form (1962) view model is documented below; see
+.BR colitur (1)
+for the rite's own scope and limitations (readings cover only the Epistle
+and Gospel; the votive Office of the Blessed Virgin Mary on Saturday does not
+yet select among its five seasonal Masses).
+.SH VIEW MODEL
+The value a template renders against is built once per
+.B colitur table
+/
+.B render
+/
+.B publish
+invocation, from the same resolved calendar
+.B colitur emit
+uses, and is shaped for two artefacts from one model: a flat booklet (the
+.B days
+list, one entry per day of the requested year) and a month grid (the
+.B months
+list, each carrying its own
+.B weeks
+list of Sunday\-started, seven\-cell rows, padded at both ends with blank
+cells so every row has exactly seven). This is the same shape published at
+.IR schema/day\-v1.json ,
+described here in prose; the JSON Schema is the machine\-checked contract and
+this page is its worked explanation.
+.SS Top level
+.TP
+.B rite
+The rite identifier, currently always the string
+.BR ef .
+.TP
+.B year
+The civil year requested, as a four\-digit string.
+.TP
+.B months
+A list of twelve
+.B month
+objects, January through December.
+.TP
+.B days
+A flat list of every day's own
+.B day
+object, in date order, for the whole requested year \(em what a booklet
+template iterates over directly, without going through
+.BR months .
+.SS month
+.TP
+.B num
+The month number, 1 through 12, as a string.
+.TP
+.B name
+An object keyed by language (currently
+.B la
+and
+.BR en ),
+each value the month's own name in that language (e.g.
+.RB \(lq Ianuarius \(rq
+/
+.RB \(lq January \(rq ).
+.TP
+.B days
+This month's own
+.B day
+objects, in date order, only the days that actually fall in this month.
+.TP
+.B weeks
+This month's
+.B day
+objects grouped into Sunday\-started rows of exactly seven, the first and
+last rows padded with blank cells (see
+.B day \(-> in_month
+below) so every row has seven entries regardless of which weekday the month
+starts or ends on.
+.SS week
+.TP
+.B num
+The week's ordinal within its month (1, 2, 3, ...), as a string. This is
+.I not
+a liturgical week number \(em see
+.B day \(-> week
+below for that.
+.TP
+.B days
+Exactly seven
+.B day
+objects, Sunday first.
+.SS day
+Every key below is always present on every day object, including a padding
+cell (see
+.BR in_month ),
+so a template never hits a missing key on a real day OR a blank grid cell \(em
+the one deliberate exception is that a padding cell's own string fields are
+all set to the empty string and its boolean and list fields to
+.B false
+/empty, which read as absent under
+.BR # / ^ / {{var}}
+exactly as a genuinely unset field would.
+.TP
+.B iso
+The date, ISO\-8601 (\c
+.IR YYYY\-MM\-DD ).
+Empty on a grid padding cell.
+.TP
+.B dom
+The day of the month, as a string (no leading zero). Empty on a padding
+cell.
+.TP
+.B dow
+The day of the week as a string digit,
+.B 0
+for Sunday through
+.B 6
+for Saturday. Present, and meaningful, even on a padding cell \(em it is how
+a grid template knows which column a blank cell belongs in.
+.TP
+.B in_month
+Boolean.
+.B false
+on a padding cell (a blank cell added so a month's first or last week has
+seven entries); a template checks this, not
+.BR iso 's
+emptiness, to decide whether to render a cell's contents.
+.TP
+.B season
+The liturgical season's own string name (e.g.
+.BR paschaltide ", " lent ).
+.TP
+.B week
+The liturgical week number within the season, as a string, or the empty
+string on a day the rite does not number (this is
+.I not
+the same field as a
+.B week
+object's own
+.BR num ,
+described above \(em see
+.B SCOPE AND LOOKUP
+for why the two identically\-named fields do not collide here: a plain
+.B day
+object has no
+.B num
+key of its own at all, only
+.BR week ,
+so there is nothing for it to shadow).
+.TP
+.B slug
+The observed celebration's stable identifier (e.g.
+.BR ef\-easter\-sunday ).
+.TP
+.B name
+An object keyed by language, the observed celebration's own name in each
+language colitur's data supplies one for. Frequently has no
+.B la
+or
+.B en
+key at all (most temporal days, most sanctoral entries in the shipped data)
+\(em see
+.B SCOPE AND LOOKUP
+above for the resulting month\-name collision and its safe idiom.
+.TP
+.B rank
+The observed celebration's class, as the kernel's own string (e.g.
+.BR class\-1 ).
+There is deliberately no separate, localized rank label: the kernel carries
+no per\-language rank names to draw one from.
+.TP
+.B colour
+The observed celebration's liturgical colour, lowercase (one of
+.BR white ", " red ", " green ", " violet ", " rose ", " black ,
+or the empty string).
+.TP
+.BR is_white ", " is_red ", " is_green ", " is_violet ", " is_rose ", " is_black
+Six booleans, exactly one true (matching
+.BR colour )
+on a real day, all false on a padding cell. Provided so a template can
+select styling (a cell background colour, a class name) with a plain
+.B {{#is_white}}
+section instead of a string comparison the engine does not offer \(em there
+is no expression evaluation, so this is the only way a template branches on
+colour at all.
+.TP
+.B subject
+Whose feast this is, lowercase (one of
+.BR lord ", " bvm ", " saint ", " temporal ).
+.TP
+.B comms
+A list of commemoration objects admitted on this day, each carrying
+.BR slug ,
+.B name
+(an object keyed by language, same shape as the day's own
+.BR name ),
+and
+.B privileged
+(boolean: true for a privileged commemoration under RG 109, which survives
+even where an ordinary one would be capped out). Empty list on a day with no
+commemorations, and always an empty list \(em never absent \(em on a padding
+cell.
+.TP
+.B transferred_in
+A list of at most one object, present when a feast impeded elsewhere was
+transferred onto THIS day (RG 96\(en98); carries the transferred
+celebration's own
+.BR slug .
+Empty list when nothing transferred in.
+.TP
+.B transferred_out
+A list of objects, one per celebration that would have fallen on this day
+but was displaced and moved to a later date; each carries
+.B slug
+and
+.B to
+(the ISO\-8601 date it was moved to). Empty on the ordinary day.
+.TP
+.B first
+The Epistle/Lesson reading citation (e.g.
+.RB \(lq "Heb 1:1\-12" \(rq ),
+never scripture text \(em a reference only. Empty string when none resolved.
+.TP
+.B gospel
+The Gospel reading citation, same shape as
+.BR first .
+.TP
+.B last
+Boolean, true on the seventh (final) cell of a grid row, false everywhere
+else including every entry of the flat
+.B days
+list. Exists because the engine offers no "unless this is the last item"
+construct, so a template that must print a separator BETWEEN cells but not
+after the last one (a table row's column rule, for instance) reads this flag
+rather than computing it: without it, a seven\-column LaTeX grid would emit
+an eighth, empty column and
+.B pdflatex
+would reject the file outright.
+.SH A WORKED MINIMAL TEMPLATE
+A plain\-text booklet, days nested inside months \(em the shape every
+shipped template actually uses, and the shape the
+.B SCOPE AND LOOKUP
+hazard needs to fire \(em using the safe name idiom from that section:
+.RS
+.nf
+
+{{rite}} {{year}}
+{{#months}}{{#days}}
+{{iso}} {{#name}}{{la}}{{^la}}{{slug}}{{/la}}{{/name}} {{colour}}{{#comms}} +{{slug}}{{/comms}}
+{{/days}}{{/months}}
+.fi
+.RE
+.PP
+Rendered (extension
+.IR .txt ,
+so flavour
+.BR none ,
+no escaping applied; the blank lines are the template's own \(em its section
+body starts and ends with a literal newline, and this engine does not trim
+one):
+.RS
+.nf
+
+.B colitur table \-\-year 2026 \-\-template minimal.txt | head \-7
+ef 2026
+
+2026\-01\-01 ef\-circumcision white
+
+2026\-01\-02 ef\-christmas\-1\-friday white
+
+2026\-01\-03 Officium sanctae Mariae in sabbato white
+.fi
+.RE
+.PP
+The second data line shows the fallback firing: 2 January carries no Latin
+.B name
+in the shipped data, so
+.B {{^la}}
+supplies
+.B {{slug}}
+instead. The third shows the non\-fallback case: 3 January
+.I does
+carry a Latin name (the votive Office of the Blessed Virgin Mary on
+Saturday), and the idiom prints it correctly.
+.PP
+Now swap only the interpolation \(em the safe idiom above becomes the
+naive, unsafe
+.B {{name.la}} ,
+nesting left exactly as it was:
+.RS
+.nf
+
+{{rite}} {{year}}
+{{#months}}{{#days}}
+{{iso}} {{name.la}} {{colour}}
+{{/days}}{{/months}}
+.fi
+.RE
+.PP
+Rendered against the identical year, same extension and flavour:
+.RS
+.nf
+
+.B colitur table \-\-year 2026 \-\-template minimal\-unsafe.txt | head \-7
+ef 2026
+
+2026\-01\-01 Ianuarius white
+
+2026\-01\-02 Ianuarius white
+
+2026\-01\-03 Officium sanctae Mariae in sabbato white
+.fi
+.RE
+.PP
+The collision fires for real on the first two lines: with a month on the
+scope stack, the day's own missing
+.B la
+key falls back all the way out to the ENCLOSING MONTH's own
+.RB \(lq Ianuarius \(rq
+rather than failing. The third line is untouched, because 3 January's own
+.I name
+object genuinely has a
+.B la
+key, so the dotted path resolves without ever needing to fall back.
+.PP
+.B This is specific to nesting, and that is the point.
+The identical naive
+.B {{name.la}}
+written against the TOP\-LEVEL, flat
+.B days
+list (no
+.B {{#months}}
+wrapping it) does
+.I not
+print
+.RB \(lq Ianuarius \(rq
+anywhere \(em there is no month on the stack to fall back to, so it prints
+nothing on an unnamed day instead, silently but not wrongly. See
+.B SCOPE AND LOOKUP
+above for why testing the naive form only against a flat list is exactly
+how this hazard goes unnoticed until a template is later nested under
+.BR {{#months}} .
+.SH SEE ALSO
+.BR colitur (1)
+for
+.BR table ", " render " and " publish ,
+and for the five
+.B emit
+formats that share this same view model.
+.PP
+.BR colitur\-overlay (5)
+for the local\-calendar file format that supplies the celebrations a
+template's
+.B name
+and
+.B slug
+fields can carry.
+.SH LICENSE
+AGPL\-3.0\-or\-later.
diff --git a/man/colitur.1 b/man/colitur.1
index 58e46ff..005cc0a 100644
--- a/man/colitur.1
+++ b/man/colitur.1
@@ -13,6 +13,30 @@ colitur \- deterministic liturgical calendar and lectionary engine (Roman rite,
.RI [ ... ]
.br
.B colitur
+.B emit
+.BI \-\-format " FMT"
+.BI \-\-from " YEAR"
+.BI \-\-to " YEAR"
+.RB [ \-\-overlay " FILE" " ...]"
+.RB [ \-\-dtstamp " STAMP" ]
+.br
+.B colitur
+.BR table | render
+.BI \-\-year " YEAR"
+.BI \-\-template " FILE"
+.RB [ \-\-flavour " FLAVOUR" ]
+.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
@@ -65,6 +89,32 @@ occurrence, commemoration and transfer.
.BI readings " YEAR"
The Mass reading citations, one line per day.
.TP
+.B emit
+Render a civil\-year range through one of five emitters \(em
+.BR csv ", " json ", " sexp ", " xml " or " ics .
+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
+.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
@@ -97,13 +147,81 @@ below.
.TP
.BI \-\-overlay " FILE"
Apply a user calendar on top of the shipped one. Repeatable and ordered;
-.B day
-and
-.B readings
+.BR day ", " readings ", " emit ", " table ", " render " and " publish
only. See
.B OVERLAYS
below.
.TP
+.BI \-\-format " FMT"
+.RB ( "colitur emit" " only)"
+One of
+.BR csv ", " json ", " sexp ", " xml " or " ics .
+Required. See
+.B EMIT
+below.
+.TP
+.BI \-\-from " YEAR" ", " \-\-to " YEAR"
+.RB ( "colitur emit" " and " "colitur publish" " only)"
+The inclusive civil\-year range to render, each
+.B 1583..9999
+as elsewhere.
+.I FROM
+must not be after
+.IR TO .
+Both required.
+.TP
+.BI \-\-dtstamp " STAMP"
+.RB ( "colitur emit \-\-format ics" " and " "colitur publish" " only)"
+Fix the feed's own DTSTAMP instead of the default
+.IR YYYY0101T000000Z ,
+where
+.I YYYY
+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,
+.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
@@ -162,6 +280,349 @@ Both reports are one line per day and ordered by date, so they compose with
and
.BR join (1)
in the ordinary way.
+.SH EMIT
+.BI "colitur emit " \-\-format " FMT " \-\-from " YEAR " \-\-to " YEAR"
+renders the same resolved day \(em season, week, slug, rank, colour,
+subject, Latin and English names, citations, commemorations \(em through one
+of five emitters, for every day in the inclusive civil\-year range
+.IR FROM .. TO .
+Every emitter consumes one shared view of the data, so all five describe
+exactly the same fields.
+.TP
+.B csv
+RFC 4180. One header row for the whole run, not one per year, so a
+multi\-year range still has exactly one header and
+.BR wc (1)
+or
+.B "awk 'NR>1'"
+behave as expected.
+.RS
+.nf
+
+.B colitur emit \-\-format csv \-\-from 2026 \-\-to 2026 | head \-2
+date,rite,season,week,slug,rank,colour,subject,name_la,name_en,first,gospel,comms
+2026\-01\-01,ef,christmastide,,ef\-circumcision,class\-1,white,temporal,,,Titus 2:11\-15,Luke 2:21,
+.fi
+.RE
+.TP
+.B json
+One JSON object per requested year, concatenated. Shape pinned by
+.IR schema/day\-v1.json .
+.RS
+.nf
+
+.B colitur emit \-\-format json \-\-from 2026 \-\-to 2026 | head \-c 40
+{"rite":"ef","year":"2026","months":[{...
+.fi
+.RE
+.TP
+.B sexp
+One S\-expression per day, one per line \(em the same
+.I Liturgical_day.t
+shape used internally, printed with
+.IR sexplib "'s " to_string_hum .
+.TP
+.B xml
+Element\-per\-field, one
+.I <calendar>
+document per requested year, concatenated. Attributes carry identity only
+(rite, year, date); everything else is an element. Shape pinned by
+.IR schema/colitur\-v1.xsd ,
+checked by
+.B make check\-schema
+when
+.BR xmllint (1)
+is installed.
+.TP
+.B ics
+RFC 5545. One
+.I VCALENDAR
+per requested year, concatenated, one all\-day
+.I VEVENT
+per day. Lines are folded at 75 octets and end
+.RI ( CRLF ),
+matching the protocol exactly \(em
+.RB \(lq " cat \-A " \(rq
+on the output shows
+.B ^M$
+at each line end.
+.RS
+.nf
+
+.B colitur emit \-\-format ics \-\-from 2026 \-\-to 2026 | head \-1
+BEGIN:VCALENDAR
+.fi
+.RE
+.PP
+.B \-\-dtstamp
+fixes the feed's own
+.I DTSTAMP
+field, which RFC 5545 requires on every event. Without it the value defaults
+to
+.I YYYY0101T000000Z
+for the emitted year \(em a fixed value, not a clock read \(em so two
+.B emit \-\-format ics
+runs over identical data are byte\-identical, which matters for a
+reproducible build or a diffable published calendar file. Nothing in the
+.B emit
+path reads the wall clock, for any format.
+.PP
+.BR \-\-overlay
+is accepted exactly as on
+.B day
+and
+.BR readings :
+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.
+.PP
+See
+.BR colitur\-templates (5)
+for the full syntax, the escaping table per flavour, the complete
+view\-model field reference, and \(em before writing a template of any
+complexity \(em its
+.B SCOPE AND LOOKUP
+section: an inner key silently loses to an outer key of the same name (a bare
+.B {{name.la}}
+inside a day resolves to the enclosing MONTH's name, not the day's own),
+which has produced wrong output in this project's own templates.
+.SS Flavours
+.BI \-\-flavour
+controls how interpolated
+.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 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"
@@ -242,11 +703,9 @@ shapes:
.RE
.PP
Accepted on
-.B day
-and
-.B readings
-only. The other commands read no sanctoral data at all, so the flag would have
-no effect there and is
+.BR day ", " readings ", " emit ", " table ", " render " and " publish .
+.BR easter " and " temporal
+read no sanctoral data at all, so the flag would have no effect there and is
.I refused
rather than silently ignored.
.PP
@@ -347,6 +806,25 @@ 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
+.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
@@ -376,6 +854,13 @@ reading citations fall back to the day's ordinary ones.
for the overlay file format \(em every directive, every field, the three date
shapes and worked examples.
.PP
+.BR colitur\-templates (5)
+for the template format used by
+.BR table ", " render " and " publish
+\(em the four syntax forms, the six flavours and their escaping, the full
+view\-model field reference, and the scope\-shadowing hazard a template author
+will hit.
+.PP
.BR lectio (1)
.SH LICENSE
AGPL\-3.0\-or\-later.