@port60/template-kit 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +10 -3
  2. package/bin/cli.mjs +26 -4
  3. package/package.json +1 -1
  4. package/src/commands/content.mjs +42 -0
  5. package/src/commands/create.mjs +6 -6
  6. package/src/commands/dev.mjs +97 -9
  7. package/src/commands/model.mjs +34 -0
  8. package/src/commands/packageCmd.mjs +3 -3
  9. package/src/commands/refresh.mjs +48 -0
  10. package/src/commands/upgrade.mjs +44 -0
  11. package/src/commands/validate.mjs +2 -3
  12. package/src/lib/agentsMd.mjs +52 -24
  13. package/src/vendor/contract/v1/behaviours.json +4 -4
  14. package/src/vendor/contract/v1/content-bounds.json +3 -3
  15. package/src/vendor/contract/v1/content-model.json +165 -0
  16. package/src/vendor/contract/v1/context.json +1497 -289
  17. package/src/vendor/contract/v1/dialect.json +3 -3
  18. package/src/vendor/contract/v1/fonts.json +1 -1
  19. package/src/vendor/contract/v1/imagery.json +4 -4
  20. package/src/vendor/contract/v1/islands.json +242 -235
  21. package/src/vendor/contract/v1/layout.json +4 -4
  22. package/src/vendor/contract/v1/manifest.schema.json +10 -12
  23. package/src/vendor/contract/v1/sections.json +189 -107
  24. package/src/vendor/contract/v1/tokens.json +1 -1
  25. package/src/vendor/contract/v1.lock.json +1 -1
  26. package/src/vendor/engine/content-footprint.mjs +79 -0
  27. package/src/vendor/validator/fixture-art.mjs +107 -0
  28. package/src/vendor/validator/model-reference.mjs +92 -0
  29. package/src/vendor/validator/platform-base.css +21 -0
  30. package/src/vendor/validator/preview.mjs +144 -59
  31. package/src/vendor/validator/site-context.mjs +97 -0
  32. package/src/vendor/validator/validate.mjs +70 -17
  33. package/starter/assets/theme.css +10 -10
  34. package/starter/layout.liquid +15 -16
  35. package/starter/manifest.json +2 -2
  36. package/starter/sections/campaigns.liquid +5 -5
  37. package/starter/sections/homeHero.liquid +11 -11
  38. package/starter/sections/people.liquid +1 -1
  39. package/starter/sections/values.liquid +1 -1
@@ -1,13 +1,12 @@
1
1
  // The scaffold's AI-agent briefing (T3, user requirement: the kit must let people put an AI
2
- // engine on template work and be productive immediately). Written for ANY coding agent —
3
- // AGENTS.md is the cross-tool convention and CLAUDE.md carries the identical content for tools
2
+ // engine on template work and be productive immediately). Written for ANY coding agent, // AGENTS.md is the cross-tool convention and CLAUDE.md carries the identical content for tools
4
3
  // that read that name. The briefing is the contract's rules + the iteration loop, so an agent's
5
4
  // first move is always the same: read this, edit, validate --json, repeat until clean.
6
5
 
7
6
  export function agentsMd(name) {
8
7
  return `# Working on the "${name}" Port60 template
9
8
 
10
- You are working on a **Port60 site template** — a small, versioned artifact of Liquid renderers
9
+ You are working on a **Port60 site template**, a small, versioned artifact of Liquid renderers
11
10
  and CSS that a charity's site is rendered through. It contains **no application code**: no
12
11
  JavaScript, no API calls, no payment logic. Templates decide how a site *looks*; the platform
13
12
  owns what it *does*.
@@ -22,25 +21,25 @@ npm run package # validate + produce the uploadable <name>-<version>.z
22
21
  \`\`\`
23
22
 
24
23
  **After every meaningful edit, run \`npm run validate:json\` and fix every error before moving
25
- on.** The validator is the exact code the platform runs at upload — if it passes here, the
24
+ on.** The validator is the exact code the platform runs at upload, if it passes here, the
26
25
  platform accepts it; if it fails here, the upload will fail identically.
27
26
 
28
27
  ## The file layout (nothing else is accepted)
29
28
 
30
- - \`manifest.json\` — identity + what you support. \`name\` and \`version\` are immutable
29
+ - \`manifest.json\`, identity + what you support. \`name\` and \`version\` are immutable
31
30
  identity; bump \`version\` (semver) for every published change.
32
- - \`layout.liquid\` — the page chrome (header/nav/footer). Must contain **exactly one**
31
+ - \`layout.liquid\`, the page chrome (header/nav/footer). Must contain **exactly one**
33
32
  \`{% content %}\` slot. Only needed when \`supports.layout\` is true.
34
- - \`sections/<type>.liquid\` — one renderer per section type you declare in
33
+ - \`sections/<type>.liquid\`, one renderer per section type you declare in
35
34
  \`supports.sections\`. Charities compose pages from a **closed catalogue** of section types
36
- (see \`npm run validate\` output or the docs) — you cannot invent new types.
37
- - \`pages/<page>.liquid\` — optional full-page templates for \`supports.pageTemplates\`.
38
- - \`assets/theme.css\` — required, your entire look. More \`.css\` files under \`assets/\` are
39
- allowed. **No images, no JS** — they are refused at upload.
35
+ (see \`npm run validate\` output or the docs), you cannot invent new types.
36
+ - \`pages/<page>.liquid\`, optional full-page templates for \`supports.pageTemplates\`.
37
+ - \`assets/theme.css\`, required, your entire look. More \`.css\` files under \`assets/\` are
38
+ allowed. **No images, no JS**, they are refused at upload.
40
39
 
41
40
  ## The rules the validator enforces (do not fight them)
42
41
 
43
- 1. **Escape-by-default.** Every output is HTML-escaped unless you use \`| raw\` — and the only
42
+ 1. **Escape-by-default.** Every output is HTML-escaped unless you use \`| raw\`, and the only
44
43
  values you may pass through raw are the contract's sanitised richtext fields.
45
44
  2. **The dialect is a whitelist.** \`{% include %}\`, \`{% render %}\`, \`{% layout %}\` and
46
45
  several other tags are excluded and fail at parse. Unknown filters throw.
@@ -55,7 +54,7 @@ platform accepts it; if it fails here, the upload will fail identically.
55
54
  5. **Render budgets are real.** Runaway loops are killed (~1s per render). Keep renderers simple.
56
55
  6. **Context is a whitelist.** Sections see \`{section, brand}\` plus only the collection named by
57
56
  that section in the contract; layouts see \`{brand, nav, socials, worship, locale}\`; page
58
- templates see their documented fixture + \`brand\`. Nothing else exists — do not invent
57
+ templates see their documented fixture + \`brand\`. Nothing else exists, do not invent
59
58
  variables.
60
59
  7. **Capabilities are matching metadata, never entitlements.** Every value in
61
60
  \`requiresCapabilities\` must have a declared section, page template or island that presents it.
@@ -77,15 +76,44 @@ platform accepts it; if it fails here, the upload will fail identically.
77
76
  - Other platform routes keep their platform body and render inside your layout. Capability flags
78
77
  expose documented optional context; they do not transfer transaction or route ownership.
79
78
 
79
+ ## The content model
80
+
81
+ Everything you read comes from ONE tree: \`site\`, \`site.brand\`, \`site.nav\`,
82
+ \`site.socials\`, \`site.locale\` and the typed collections under \`site.content.*\`
83
+ (services, events, articles, campaigns, causes, courses, resources, locations,
84
+ schedules, about). Four rules it never breaks, so neither should you:
85
+
86
+ - Every collection is BOUNDED (a documented cap plus a \`moreHref\`), link onward, never
87
+ assume you have everything.
88
+ - Enum fields are OPEN, branch on the values you style and fall back for the rest; the
89
+ validator proves your template survives values it has never seen.
90
+ - Optional fields are explicitly nullable, always branch.
91
+ - The model only grows. The validator computes your content footprint from the paths you read
92
+ and stamps the minimum model version at publish; you never declare versions, and dynamic
93
+ indexing into \`site.content\` is refused so that stays decidable.
94
+
95
+ SEE the model: \`npm run dev\` serves the full reference with live example data at \`/model\`;
96
+ \`npx p60-template-kit model --json\` prints the machine-readable registry; the same reference
97
+ lives at https://developers.port60.com/reference/content-model/.
98
+
99
+ Bring your own content and imagery: \`npx p60-template-kit content .\` ejects every collection,
100
+ fully populated, as an editable JSON file; replace the copy and the imageUrl values, then
101
+ \`npx p60-template-kit dev . --content my-org.json\` renders YOUR data (the preview admits
102
+ exactly the image hosts your file names, nothing else).
103
+
104
+ You may replace the preview DATA with your own via \`preview-content.json\` beside the
105
+ manifest ({ collection: [items] }, schema-checked, hot-reloaded). The shape is fixed, packaging
106
+ excludes it, and conformance proofs always run on the canonical fixtures.
107
+
80
108
  ## What to build with
81
109
 
82
110
  - Theme via CSS custom properties and the settings knobs you declare in
83
- \`manifest.settings.schema\` — they surface in the charity's Appearance editor as
111
+ \`manifest.settings.schema\`, they surface in the charity's Appearance editor as
84
112
  \`--p60s-<key>\` variables and \`data-p60s-<key>\` body attributes.
85
113
  - Hero photographs (\`homeHero.images\`, when you declare \`supports.heroImagery\`): render one
86
114
  photo directly as a TREATED backdrop (a scrim/tint built from your own palette variables via
87
- color-mix — never raw), place the \`hero_carousel\` island for two or more (style its
88
- \`.hero-slide-scrim\`), and design the no-photo state as a gradient/colour — never a
115
+ color-mix, never raw), place the \`hero_carousel\` island for two or more (style its
116
+ \`.hero-slide-scrim\`), and design the no-photo state as a gradient/colour, never a
89
117
  placeholder. The starter's \`.lq-homehero\` is the reference; the validator checks all of this
90
118
  behaviourally.
91
119
  - "Looks" = named one-click bundles of knob values in \`manifest.looks\`.
@@ -93,19 +121,19 @@ platform accepts it; if it fails here, the upload will fail identically.
93
121
  - Navigation can contain two levels below a top item. Render every supplied child and branch on
94
122
  optional \`group\`, \`description\`, \`imageUrl\` and \`megaMenu\` promo metadata. Never hardcode
95
123
  menu groups that are not in \`nav\`.
96
- - Dynamic sections include appeals (\`causes\`), programmes (\`services\`), resources, locations,
97
- volunteering opportunities and structured media. Derive or omit when a collection is empty and
98
- use the supplied URLs rather than constructing routes.
99
- - Submission and behaviour surfaces such as \`newsletter_signup\`, \`volunteer_signup\`, \`form\`,
100
- \`search\`, \`language_switch\` and \`next_prayer\` are platform islands. Place and style them;
124
+ - Dynamic sections include appeals (\`causes\`), programmes (\`services\`), resources and
125
+ locations. Derive or omit when a collection is empty and use the supplied URLs rather than
126
+ constructing routes.
127
+ - Submission and behaviour surfaces such as \`newsletter_signup\`, \`form\`, \`search\`,
128
+ \`language_switch\` and \`next_prayer\` are platform islands. Place and style them;
101
129
  never reproduce their API calls or consent behaviour.
102
130
  - \`manifest.imagery.hero\` (optional but recommended): declare the photo shape YOUR hero
103
- composes best with (\`idealAspect\`, \`minWidth\`, a one-line \`note\`) — the charity's editor
131
+ composes best with (\`idealAspect\`, \`minWidth\`, a one-line \`note\`), the charity's editor
104
132
  measures their actual upload against it and advises. Advice, never enforcement.
105
133
 
106
134
  ## Which contract this is
107
135
 
108
- The section catalogue, islands and fixtures here are the **Charity Platform contract v1** — the
136
+ The section catalogue, islands and fixtures here are the **Charity Platform contract v1**, the
109
137
  platform's first product surface. The dialect, the rules above and this toolchain are
110
138
  platform-wide; other Port60 products will ship their own contract packs. Do not assume the
111
139
  current section list is universal.
@@ -113,7 +141,7 @@ current section list is universal.
113
141
  ## Reference
114
142
 
115
143
  The full generated reference (sections, islands, context variables, tokens, dialect) lives at
116
- https://developers.port60.com — also available in one file for agents at
144
+ https://developers.port60.com, also available in one file for agents at
117
145
  https://developers.port60.com/llms-full.txt
118
146
  `;
119
147
  }
@@ -64,7 +64,7 @@
64
64
  "name": "stickyHeader",
65
65
  "status": "available",
66
66
  "label": "Sticky header condense",
67
- "description": "State classes for scroll-aware headers: past a small threshold the engine adds is-condensed; with the \"auto-hide\" value, scrolling down past 160px adds is-hidden and scrolling up removes it. The engine only ever toggles classes (rAF-throttled) — what condensing or hiding looks like, and whether it animates, is entirely the template's CSS (gate transitions behind prefers-reduced-motion yourself; the classes always apply because a condensed header is layout, not decoration).",
67
+ "description": "State classes for scroll-aware headers: past a small threshold the engine adds is-condensed; with the \"auto-hide\" value, scrolling down past 160px adds is-hidden and scrolling up removes it. The engine only ever toggles classes (rAF-throttled), what condensing or hiding looks like, and whether it animates, is entirely the template's CSS (gate transitions behind prefers-reduced-motion yourself; the classes always apply because a condensed header is layout, not decoration).",
68
68
  "attributes": [
69
69
  { "attr": "data-p60-sticky-header", "value": "none for condense-only; \"auto-hide\" adds the hide-on-scroll-down pair" }
70
70
  ],
@@ -75,7 +75,7 @@
75
75
  "name": "stickyCta",
76
76
  "status": "available",
77
77
  "label": "Sticky call-to-action bar",
78
- "description": "Adds is-stuck once the visitor scrolls past the threshold (the attribute value in px, default 480) and removes it above. The bar should DUPLICATE an action that already exists in the page (a donate button, a ticket link) — because of that, this is the one behaviour whose element may be hidden unconditionally in CSS rather than .p60-js-scoped: a no-JS visitor loses nothing, the original action is still on the page.",
78
+ "description": "Adds is-stuck once the visitor scrolls past the threshold (the attribute value in px, default 480) and removes it above. The bar should DUPLICATE an action that already exists in the page (a donate button, a ticket link); because of that, this is the one behaviour whose element may be hidden unconditionally in CSS rather than .p60-js-scoped: a no-JS visitor loses nothing; the original action is still on the page.",
79
79
  "attributes": [
80
80
  { "attr": "data-p60-sticky-cta", "value": "optional scroll threshold in px (default 480)" }
81
81
  ],
@@ -86,7 +86,7 @@
86
86
  "name": "lightbox",
87
87
  "status": "available",
88
88
  "label": "Lightbox",
89
- "description": "On a group container: each data-p60-lightbox-item is an anchor whose href is the full image (the no-JS render simply navigates to it — already functional). The engine intercepts the click and opens an injected overlay (.p60-lightbox) with the image, a caption from data-p60-caption or the thumbnail's alt, a close button, backdrop and Escape close, arrow-key prev/next within the group, and a focus trap. Reduced motion: no transition on the overlay (the engine sets none; style the steady state).",
89
+ "description": "On a group container: each data-p60-lightbox-item is an anchor whose href is the full image (the no-JS render simply navigates to it, already functional). The engine intercepts the click and opens an injected overlay (.p60-lightbox) with the image, a caption from data-p60-caption or the thumbnail's alt, a close button, backdrop and Escape close, arrow-key prev/next within the group, and a focus trap. Reduced motion: no transition on the overlay (the engine sets none; style the steady state).",
90
90
  "attributes": [
91
91
  { "attr": "data-p60-lightbox", "value": "none; the group container" },
92
92
  { "attr": "data-p60-lightbox-item", "value": "none; on each anchor to a full image" },
@@ -100,7 +100,7 @@
100
100
  "name": "tabs",
101
101
  "status": "available",
102
102
  "label": "Tabs",
103
- "description": "data-p60-tab=\"key\" on each tab control and data-p60-panel=\"key\" on each panel, inside a data-p60-tabs container. The engine wires the tablist/tab/tabpanel roles, aria-selected, roving tabindex, arrow/Home/End keys, toggles is-active on the active pair and hides inactive panels with the hidden attribute — so the no-JS render shows every panel stacked and complete, with no CSS scoping needed. The first tab (or the one authored with is-active) starts active.",
103
+ "description": "data-p60-tab=\"key\" on each tab control and data-p60-panel=\"key\" on each panel, inside a data-p60-tabs container. The engine wires the tablist/tab/tabpanel roles, aria-selected, roving tabindex, arrow/Home/End keys, toggles is-active on the active pair and hides inactive panels with the hidden attribute, so the no-JS render shows every panel stacked and complete, with no CSS scoping needed. The first tab (or the one authored with is-active) starts active.",
104
104
  "attributes": [
105
105
  { "attr": "data-p60-tabs", "value": "none; the container" },
106
106
  { "attr": "data-p60-tab", "value": "the key of the panel this control activates" },
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "The maximum lengths of every admin-entered display field a template renders. The admin screens stop input at these caps and the API rejects anything longer, so a template can design cards, headings and captions against known worst cases. Additive-only within a contract major: a bound may loosen, never tighten. Long-form bodies (article bodies, course About pages, service pages) are deliberately outside this table — they are sanitised HTML rendered in dedicated full-width regions, never inside cards.",
2
+ "description": "The maximum lengths of every admin-entered display field a template renders. The admin screens stop input at these caps and the API rejects anything longer, so a template can design cards, headings and captions against known worst cases. Additive-only within a contract major: a bound may loosen, never tighten. Long-form bodies (article bodies, course About pages, service pages) are deliberately outside this table; they are sanitised HTML rendered in dedicated full-width regions, never inside cards.",
3
3
  "groups": [
4
4
  {
5
5
  "name": "Brand (layout chrome)",
@@ -68,7 +68,7 @@
68
68
  {
69
69
  "field": "Tag",
70
70
  "max": 40,
71
- "note": "Each — rendered as chips."
71
+ "note": "Each, rendered as chips."
72
72
  }
73
73
  ]
74
74
  },
@@ -109,7 +109,7 @@
109
109
  {
110
110
  "field": "Page name",
111
111
  "max": 200,
112
- "note": "The body is sanitised HTML rendered full-width — not card content."
112
+ "note": "The body is sanitised HTML rendered full-width, not card content."
113
113
  }
114
114
  ]
115
115
  }
@@ -0,0 +1,165 @@
1
+ {
2
+ "description": "The CONTENT MODEL REGISTRY (docs/template-content-model.md, v1): the typed `site.content.*` tree every template reads. One realistic organisation, one shape. Four disciplines are baked in from birth because they cannot be retrofitted: every collection is BOUNDED (a documented cap and an explicit moreHref, so a big tenant can never break a render budget); every enum field is OPEN (values grow; templates must survive unknown values, proven behaviourally); every optional field is EXPLICITLY nullable (branch-on-it is the norm); and the model is ADD-ONLY (fields and collections gain `deprecated: true` markers, they are never removed within a major, deprecated means it stops being documented, never that it stops being served). Versioning is COMPUTED, never author-declared: every collection and field carries `since`, the validator derives a template's required minimum from its content footprint at publish, and authors never think about versions. New list or field = minor. Fixture or doc clarification = patch.",
3
+ "version": "1.0",
4
+ "root": "site",
5
+ "siblings": {
6
+ "description": "site.brand, site.nav, site.socials and site.locale carry the SAME shapes documented in context.json for the flat era; the tree re-addresses them, it does not reshape them.",
7
+ "keys": ["brand", "nav", "socials", "locale"]
8
+ },
9
+ "collections": {
10
+ "services": {
11
+ "since": "1.0",
12
+ "cap": 24,
13
+ "moreHref": "/services",
14
+ "description": "The organisation's published content pages (the services CMS), newest curation order first.",
15
+ "item": {
16
+ "id": { "type": "string", "since": "1.0" },
17
+ "title": { "type": "string", "since": "1.0", "max": 200 },
18
+ "slug": { "type": "string", "since": "1.0" },
19
+ "summary": { "type": "string", "since": "1.0", "nullable": true, "max": 500 },
20
+ "imageUrl": { "type": "string", "since": "1.0", "nullable": true },
21
+ "href": { "type": "string", "since": "1.0" }
22
+ }
23
+ },
24
+ "events": {
25
+ "since": "1.0",
26
+ "cap": 12,
27
+ "moreHref": "/events",
28
+ "description": "Published events, soonest first. `registrationMode` is an OPEN enum: INFO, RSVP and TICKETED exist today and new modes will arrive; branch on the ones you style and fall back for the rest.",
29
+ "item": {
30
+ "id": { "type": "string", "since": "1.0" },
31
+ "name": { "type": "string", "since": "1.0", "max": 200 },
32
+ "description": { "type": "string", "since": "1.0", "nullable": true, "max": 200 },
33
+ "registrationMode": { "type": "string", "since": "1.0", "enumOpen": ["INFO", "RSVP", "TICKETED"] },
34
+ "startsAt": { "type": "string", "since": "1.0" },
35
+ "endsAt": { "type": "string", "since": "1.0", "nullable": true },
36
+ "venueName": { "type": "string", "since": "1.0", "nullable": true, "max": 200 },
37
+ "online": { "type": "boolean", "since": "1.0" },
38
+ "imageUrl": { "type": "string", "since": "1.0", "nullable": true },
39
+ "onSale": { "type": "boolean", "since": "1.0" },
40
+ "soldOut": { "type": "boolean", "since": "1.0" },
41
+ "priceFrom": { "type": "number", "since": "1.0", "nullable": true },
42
+ "currency": { "type": "string", "since": "1.0", "nullable": true },
43
+ "detailHref": { "type": "string", "since": "1.0" }
44
+ }
45
+ },
46
+ "articles": {
47
+ "since": "1.0",
48
+ "cap": 12,
49
+ "moreHref": "/articles/all",
50
+ "description": "Published articles, newest first. Detail bodies stay editor-owned sanitised richtext on the article page BY DESIGN; the card model carries everything a listing needs.",
51
+ "item": {
52
+ "id": { "type": "string", "since": "1.0" },
53
+ "title": { "type": "string", "since": "1.0", "max": 200 },
54
+ "slug": { "type": "string", "since": "1.0" },
55
+ "excerpt": { "type": "string", "since": "1.0", "nullable": true, "max": 500 },
56
+ "imageUrl": { "type": "string", "since": "1.0", "nullable": true },
57
+ "authorName": { "type": "string", "since": "1.0", "nullable": true, "max": 120 },
58
+ "authorRole": { "type": "string", "since": "1.0", "nullable": true, "max": 120 },
59
+ "categories": { "type": "string[]", "since": "1.0" },
60
+ "publishedAt": { "type": "string", "since": "1.0" },
61
+ "readingMinutes": { "type": "number", "since": "1.0", "nullable": true },
62
+ "href": { "type": "string", "since": "1.0" }
63
+ }
64
+ },
65
+ "campaigns": {
66
+ "since": "1.0",
67
+ "cap": 12,
68
+ "moreHref": "/campaigns",
69
+ "description": "Live campaigns, curation order.",
70
+ "item": {
71
+ "id": { "type": "string", "since": "1.0" },
72
+ "title": { "type": "string", "since": "1.0", "max": 200 },
73
+ "href": { "type": "string", "since": "1.0" },
74
+ "summary": { "type": "string", "since": "1.0", "nullable": true, "max": 500 },
75
+ "imageUrl": { "type": "string", "since": "1.0", "nullable": true }
76
+ }
77
+ },
78
+ "causes": {
79
+ "since": "1.0",
80
+ "cap": 12,
81
+ "moreHref": "/donate",
82
+ "description": "Causes currently accepting donations, quick-give first. `paymentOptions` mirrors the public cause contract.",
83
+ "item": {
84
+ "id": { "type": "string", "since": "1.0" },
85
+ "title": { "type": "string", "since": "1.0", "max": 200 },
86
+ "slug": { "type": "string", "since": "1.0" },
87
+ "description": { "type": "string", "since": "1.0", "nullable": true, "max": 500 },
88
+ "imageUrl": { "type": "string", "since": "1.0", "nullable": true },
89
+ "targetAmount": { "type": "number", "since": "1.0", "nullable": true },
90
+ "raisedAmount": { "type": "number", "since": "1.0", "nullable": true },
91
+ "progressPercentage": { "type": "number", "since": "1.0", "nullable": true },
92
+ "href": { "type": "string", "since": "1.0" },
93
+ "paymentOptions": { "type": "object", "since": "1.0", "nullable": true }
94
+ }
95
+ },
96
+ "courses": {
97
+ "since": "1.0",
98
+ "cap": 12,
99
+ "moreHref": "/courses",
100
+ "description": "Enrollable courses. The long-form About body is sanitised richtext on the course page, never in cards.",
101
+ "item": {
102
+ "id": { "type": "string", "since": "1.0" },
103
+ "title": { "type": "string", "since": "1.0", "max": 200 },
104
+ "description": { "type": "string", "since": "1.0", "nullable": true, "max": 500 },
105
+ "imageUrl": { "type": "string", "since": "1.0", "nullable": true },
106
+ "venue": { "type": "string", "since": "1.0", "nullable": true, "max": 200 },
107
+ "audience": { "type": "string", "since": "1.0", "enumOpen": ["ADULT", "CHILD", "FAMILY"] },
108
+ "priceFrom": { "type": "number", "since": "1.0", "nullable": true },
109
+ "currency": { "type": "string", "since": "1.0", "nullable": true },
110
+ "soldOut": { "type": "boolean", "since": "1.0" },
111
+ "href": { "type": "string", "since": "1.0" }
112
+ }
113
+ },
114
+ "resources": {
115
+ "since": "1.0",
116
+ "cap": 24,
117
+ "moreHref": "/resources",
118
+ "description": "Published documents, grouped by folder.",
119
+ "item": {
120
+ "id": { "type": "string", "since": "1.0" },
121
+ "title": { "type": "string", "since": "1.0", "max": 200 },
122
+ "folder": { "type": "string", "since": "1.0", "nullable": true },
123
+ "url": { "type": "string", "since": "1.0" },
124
+ "contentType": { "type": "string", "since": "1.0", "nullable": true },
125
+ "bytes": { "type": "number", "since": "1.0", "nullable": true },
126
+ "publishedAt": { "type": "string", "since": "1.0", "nullable": true }
127
+ }
128
+ },
129
+ "locations": {
130
+ "since": "1.0",
131
+ "cap": 12,
132
+ "moreHref": "/locations",
133
+ "description": "The organisation's physical places, primary first.",
134
+ "item": {
135
+ "id": { "type": "string", "since": "1.0" },
136
+ "name": { "type": "string", "since": "1.0", "max": 200 },
137
+ "address": { "type": "string", "since": "1.0", "nullable": true, "max": 500 },
138
+ "latitude": { "type": "number", "since": "1.0", "nullable": true },
139
+ "longitude": { "type": "number", "since": "1.0", "nullable": true },
140
+ "primary": { "type": "boolean", "since": "1.0" },
141
+ "directionsHref": { "type": "string", "since": "1.0", "nullable": true }
142
+ }
143
+ },
144
+ "schedules": {
145
+ "since": "1.0",
146
+ "cap": 4,
147
+ "moreHref": null,
148
+ "description": "TYPE-DERIVED recurring schedules. `type` is an OPEN enum; 'worship' is the first (its shape is the layout worship contract: times, labels, today, next). Other institutions arrive as new types; render the types you know, ignore the rest.",
149
+ "item": {
150
+ "type": { "type": "string", "since": "1.0", "enumOpen": ["worship"] },
151
+ "content": { "type": "object", "since": "1.0" }
152
+ }
153
+ },
154
+ "about": {
155
+ "since": "1.0",
156
+ "cap": 24,
157
+ "moreHref": null,
158
+ "description": "The admin-authored page composition: the ordered sections of the home/about surface. `type` is the section catalogue's OPEN enum; `content` is that section type's fields. This is what `section.*` re-addresses; a mega-page template can walk it directly.",
159
+ "item": {
160
+ "type": { "type": "string", "since": "1.0", "enumOpen": [] },
161
+ "content": { "type": "object", "since": "1.0" }
162
+ }
163
+ }
164
+ }
165
+ }