aboutsummaryrefslogtreecommitdiff
path: root/man
diff options
context:
space:
mode:
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.