@port60/template-kit 0.20.4 → 1.0.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 (58) hide show
  1. package/README.md +42 -3
  2. package/bin/cli.mjs +10 -0
  3. package/package.json +4 -2
  4. package/src/commands/content.mjs +4 -3
  5. package/src/commands/create.mjs +11 -3
  6. package/src/commands/dev.mjs +9 -3
  7. package/src/commands/model.mjs +3 -3
  8. package/src/commands/packageCmd.mjs +1 -1
  9. package/src/commands/publish.mjs +1 -1
  10. package/src/commands/refresh.mjs +8 -3
  11. package/src/commands/release.mjs +22 -0
  12. package/src/commands/setupPreviews.mjs +19 -0
  13. package/src/commands/validate.mjs +1 -1
  14. package/src/lib/agentsMd.mjs +108 -155
  15. package/src/lib/designer-bridge.js +26 -0
  16. package/src/lib/designerPalette.mjs +62 -0
  17. package/src/lib/galleryPosters.mjs +105 -0
  18. package/src/lib/previewOptions.mjs +1 -1
  19. package/src/lib/releaseBundle.mjs +141 -0
  20. package/src/vendor/contract/v1/context.json +1 -1
  21. package/src/vendor/contract/v1/manifest.schema.json +6 -0
  22. package/src/vendor/contract/v2/behaviours.json +346 -0
  23. package/src/vendor/contract/v2/content-bounds.json +117 -0
  24. package/src/vendor/contract/v2/content-model.json +766 -0
  25. package/src/vendor/contract/v2/context.json +1569 -0
  26. package/src/vendor/contract/v2/dialect.json +105 -0
  27. package/src/vendor/contract/v2/fonts.json +910 -0
  28. package/src/vendor/contract/v2/imagery.json +32 -0
  29. package/src/vendor/contract/v2/islands.json +263 -0
  30. package/src/vendor/contract/v2/layout.json +28 -0
  31. package/src/vendor/contract/v2/manifest.schema.json +388 -0
  32. package/src/vendor/contract/v2/sections.json +947 -0
  33. package/src/vendor/contract/v2/site.schema.json +1353 -0
  34. package/src/vendor/contract/v2/tokens.json +101 -0
  35. package/src/vendor/contract/v2.lock.json +6931 -0
  36. package/src/vendor/engine/content-footprint.mjs +76 -1
  37. package/src/vendor/engine/majors.mjs +42 -0
  38. package/src/vendor/validator/gift-aid-logo.svg +6 -0
  39. package/src/vendor/validator/model-reference-v2.mjs +95 -0
  40. package/src/vendor/validator/platform-base.css +359 -2
  41. package/src/vendor/validator/preview-v1.mjs +691 -0
  42. package/src/vendor/validator/preview-v2.mjs +654 -0
  43. package/src/vendor/validator/preview.mjs +8 -688
  44. package/src/vendor/validator/site-context-v2.mjs +113 -0
  45. package/src/vendor/validator/validate-v1.mjs +666 -0
  46. package/src/vendor/validator/validate-v2.mjs +642 -0
  47. package/src/vendor/validator/validate.mjs +21 -621
  48. package/starter/assets/theme.css +3 -0
  49. package/starter/layout.liquid +15 -18
  50. package/starter/manifest.json +6 -4
  51. package/starter/preview/config.json +4 -0
  52. package/starter/preview/media/README.md +9 -0
  53. package/starter/sections/campaigns.liquid +5 -4
  54. package/starter/sections/cta.liquid +2 -2
  55. package/starter/sections/hero.liquid +9 -2
  56. package/starter/sections/homeHero.liquid +28 -24
  57. package/starter/sections/people.liquid +7 -6
  58. package/starter/sections/values.liquid +3 -3
@@ -3,8 +3,11 @@
3
3
  // the production loader computes the same footprint the validator stamps, the render path
4
4
  // fetches precisely what a template reads, and the two can never disagree.
5
5
  import contentModel from '../contract/v1/content-model.json' with { type: 'json' };
6
+ import contentModelV2 from '../contract/v2/content-model.json' with { type: 'json' };
7
+ import siteSchemaV2 from '../contract/v2/site.schema.json' with { type: 'json' };
6
8
 
7
9
  export { contentModel };
10
+ export { contentModelV2 };
8
11
 
9
12
  // ── Extraction ─────────────────────────────────────────────────────────
10
13
  // The dialect is closed, so the site paths a template reads are decidable from its sources: every
@@ -29,7 +32,7 @@ function segmentsOf(chain) {
29
32
  * Returns { footprint: string[], minModelVersion: string|null, errors: string[] }, footprint
30
33
  * entries are 'brand' | 'nav' | 'socials' | 'locale' | 'content.<collection>'.
31
34
  */
32
- export function extractContentFootprint(files) {
35
+ function extractV1ContentFootprint(files) {
33
36
  const errors = [];
34
37
  const touched = new Set();
35
38
  const siblings = new Set(contentModel.siblings.keys);
@@ -77,3 +80,75 @@ export function extractContentFootprint(files) {
77
80
  return { footprint: [...touched].sort(), minModelVersion, errors };
78
81
  }
79
82
 
83
+ /** Metadata-only collection reads do not require fetching the collection's records. */
84
+ export function needsCollectionData(footprint, name) {
85
+ return footprint.includes(`content.${name}`);
86
+ }
87
+
88
+ function artifactFormat(files) {
89
+ try { return JSON.parse(files['manifest.json'] ?? '{}').format ?? 'port60-liquid@1'; }
90
+ catch { return 'port60-liquid@1'; }
91
+ }
92
+
93
+ /** The same explicit major decision is used by validation and the public host. */
94
+ export function extractContentFootprint(files, options = {}) {
95
+ const format = options.format ?? artifactFormat(files);
96
+ if (format === 'port60-liquid@1') return extractV1ContentFootprint(files);
97
+ if (format !== 'port60-liquid@2') return { footprint: [], minModelVersion: null, errors: [`Unsupported content-model format '${format}'.`] };
98
+ const errors = [];
99
+ const touched = new Set();
100
+ const siblings = new Set(contentModelV2.siblings.keys);
101
+ for (const [path, source] of Object.entries(files)) {
102
+ if (!path.endsWith('.liquid')) continue;
103
+ const code = source.replace(/\{%[-]?\s*comment\s*[-]?%\}[\s\S]*?\{%[-]?\s*endcomment\s*[-]?%\}/g, '');
104
+ const bindings = new Set([...code.matchAll(/\b(?:assign|for)\s+([A-Za-z_][A-Za-z0-9_]*)\s+(?:=|in)\s+/g)].map(match => match[1]));
105
+ for (const token of code.matchAll(/\{(?:\{[\s\S]*?\}\}|%[\s\S]*?%\})/g)) {
106
+ for (const match of token[0].matchAll(/(?<![.\w])\b(brand|nav|socials|locale|worship|focus|events|infoEvents|latestArticles|campaigns|campaignsLabel|causes|services|courses|resources|locations|relatedArticles|backHref)(?=\.|\[|\s*(?:%\}|\}\}|\|))/g)) {
107
+ if (!bindings.has(match[1])) errors.push(`${path}: flat context '${match[1]}' is not available in v2; read the canonical site tree.`);
108
+ }
109
+ }
110
+ if (DYNAMIC_INDEX.test(source)) errors.push(`${path}: dynamic indexing into site.* is refused; use a named path.`);
111
+ if (BARE_SITE.test(source)) errors.push(`${path}: aliasing the bare site tree is refused; use a named path.`);
112
+ for (const match of source.matchAll(new RegExp(CHAIN.source, 'g'))) {
113
+ const [head, member, ...rest] = segmentsOf(match[1]);
114
+ if (head === 'content') {
115
+ const collection = contentModelV2.collections[member];
116
+ if (!collection) {
117
+ errors.push(`${path}: site.content.${member ?? ''} is not in content model 2.0. Use the canonical collection or site.page.`);
118
+ continue;
119
+ }
120
+ if (collection.shape === 'array') {
121
+ touched.add(`content.${member}`);
122
+ continue;
123
+ }
124
+ const field = rest[0];
125
+ if (field && !['label', 'href', 'items', 'pagination'].includes(field)) {
126
+ errors.push(`${path}: site.content.${member} is an envelope with label, href, items and pagination; '${field}' is not a member.`);
127
+ continue;
128
+ }
129
+ if (field === 'items') {
130
+ const itemField = ['first', 'last'].includes(rest[1]) ? rest[2] : rest[1];
131
+ if (itemField && !['size', 'first', 'last'].includes(itemField) && !collection.item[itemField]) errors.push(`${path}: '${itemField}' is not a ${member} item field.`);
132
+ touched.add(`content.${member}`);
133
+ } else {
134
+ if (field === 'pagination' && rest[1] && !['page', 'size', 'totalElements', 'totalPages', 'nextHref', 'previousHref'].includes(rest[1])) errors.push(`${path}: '${rest[1]}' is not a pagination field.`);
135
+ touched.add(field ? `content.${member}.${field}` : `content.${member}`);
136
+ }
137
+ } else if (siblings.has(head)) {
138
+ const properties = siteSchemaV2.properties[head]?.properties;
139
+ if (member && properties && !properties[member]) errors.push(`${path}: site.${head}.${member} is not part of the v2 model.`);
140
+ touched.add(head);
141
+ } else errors.push(`${path}: site.${head} is not part of the v2 model.`);
142
+ }
143
+ // Local aliases of nav/collection objects are valid. Retired members are not.
144
+ const retired = [
145
+ [/\b(?:site\.)?nav\.(?:items|derived)\b/, 'nav.header and nav.footer replace nav.items/nav.derived'],
146
+ [/\b(?:site\.)?actions\.(?:primary|secondary)\b/, 'actions.header, actions.hero and actions.widget replace action aliases'],
147
+ [/\bsection\.(?:primary|secondary)\b/, 'use site.actions rather than section action aliases'],
148
+ [/\bmegaMenu\.columns\b|\.megaMenu\.columns\b/, 'the template owns responsive menu columns'],
149
+ [/\b(?:item|link|child|node|navItem)\.cta\b/, 'navigation CTA flags are retired; use site.actions.header']
150
+ ];
151
+ for (const [pattern, advice] of retired) if (pattern.test(source)) errors.push(`${path}: ${advice}.`);
152
+ }
153
+ return { footprint: [...touched].sort(), minModelVersion: touched.size ? '2.0' : null, errors };
154
+ }
@@ -0,0 +1,42 @@
1
+ // The CONTRACT MAJORS registry (roadmap 8.6, multi-major engine support): the engine resolves a
2
+ // template's declared `format` to the contract set that renders it, so `port60-liquid@1` keeps
3
+ // rendering after `@2` ships. Today one major exists; when `@2` arrives it gets its own entry
4
+ // (own dialect/catalogues, own configured Liquid instance) and @1 artifacts keep resolving here
5
+ // untouched, with the published deprecation window governing how long.
6
+ import dialect from '../contract/v1/dialect.json' with { type: 'json' };
7
+ import sections from '../contract/v1/sections.json' with { type: 'json' };
8
+ import islands from '../contract/v1/islands.json' with { type: 'json' };
9
+ import context from '../contract/v1/context.json' with { type: 'json' };
10
+ import dialectV2 from '../contract/v2/dialect.json' with { type: 'json' };
11
+ import sectionsV2 from '../contract/v2/sections.json' with { type: 'json' };
12
+ import islandsV2 from '../contract/v2/islands.json' with { type: 'json' };
13
+ import contextV2 from '../contract/v2/context.json' with { type: 'json' };
14
+
15
+ const MAJORS = {
16
+ 1: { format: dialect.format, dialect, sections, islands, context },
17
+ 2: { format: dialectV2.format, dialect: dialectV2, sections: sectionsV2, islands: islandsV2, context: contextV2 }
18
+ };
19
+
20
+ /** Parse "port60-liquid@N" → N, or null when the string isn't even format-shaped. */
21
+ export function parseFormatMajor(format) {
22
+ const m = /^port60-liquid@(\d+)$/.exec(String(format ?? ''));
23
+ return m ? Number(m[1]) : null;
24
+ }
25
+
26
+ /**
27
+ * Resolve the contract set for a declared format. Throws a NAMED error for unknown majors,
28
+ * callers (the artifact loader) let that ride their normal artifact-failure fallback, so a
29
+ * template declaring a future major degrades the site to the built-in chrome, never downs it.
30
+ */
31
+ export function resolveMajor(format) {
32
+ const major = parseFormatMajor(format);
33
+ const entry = major === null ? undefined : MAJORS[major];
34
+ if (!entry) {
35
+ throw new Error(
36
+ `unsupported template format '${format}', this engine speaks major(s) [${Object.keys(MAJORS).join(', ')}]`
37
+ );
38
+ }
39
+ return entry;
40
+ }
41
+
42
+ export const SUPPORTED_MAJORS = Object.freeze(Object.keys(MAJORS).map(Number));
@@ -0,0 +1,6 @@
1
+ <!-- Static preview copy of components/GiftAidLogo.tsx. Provenance: docs/living-templates/gift-aid-logo-source.md. -->
2
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 671.722 236.156"
3
+ class="donation-giftaid-logo" aria-hidden="true" focusable="false">
4
+ <path fill="currentColor" d="M425.721,293c18.637-29.194,9.291-33.682,8.339-33.142-18.131,10.215-31.939,40.964-30.78,51.206,1.6,14.1,12.543-2.569,22.441-18.064m70.606-26.791a53.62,53.62,0,0,0-6.419,19.5c-.477,3.424-1.033,18.3-.91,20.322a70.845,70.845,0,0,0,1.253,9.775c.443,1.611,3.206,7.4-5.661,8.623-5.218.718-5.116-12.339-5.218-14.476-.2-4.554.555-20.554,1.038-22.929.51-2.429-.507.145-1.467,1.735-2.211,3.7-4.784,8.706-7.865,13.635-4.047,6.481-13.073,21.919-25.383,21-4.605-.348-9.289-.941-11.965-13.775-1.026-4.919-.628-11.386-.523-15.268-.128-.8-.019-1.717-.688-1.343C428.159,303.18,414,333.158,398,326.419c-7.41-3.112-11.061-12.678-2.881-31.358,7.969-18.206,28.071-40.784,36.078-42.3,7.137-1.355,7.879,2.24,7.879,2.24.651-1.327,5.1-1.171,6.073.732,1.3,2.545,2.168,2.986,1.3,9.7-.491,3.872-4.088,28.092-3.467,36.414.286,3.9.955,13.509,4.919,13.972,6.924.829,15.315-13.9,16.268-15.439,1.865-3.009,16.805-28.056,18.644-31.713,1.979-3.943,3.23-6.822,4.507-9.332s5.126-2.789,8.481-.5S497.476,264.27,496.327,266.211ZM180.189,278.478c-8.784.946-24.8,22.18-28.1,29.721-3.374,7.7-4.592,15.123-.588,18.22,8.285,6.433,16.856-4.472,19.375-7.007,1.559-1.56,12.459-13.45,15.2-25.526C187.888,285.912,188.727,277.564,180.189,278.478Zm4.578,94.246q-.909,1.625-1.869,3.221c-6.472,10.744-14.962,20.331-25.489,27.241-14.258,9.356-27.649,12.95-42.611,12.77-8.7-.1-17.09-1.509-24.244-6.059-5.864-3.73-6.7-9.562-4.924-13.412,2.469-5.363,10.176-4.841,13.455-1.763,8.187,7.677,13.074,10.654,23.6,10.144a56.707,56.707,0,0,0,24.69-7.26c43.542-25.427,43.144-71.137,44.912-98.68.187-1.675-.723-.457-1.031.455a108.567,108.567,0,0,1-7.775,16.943,58.19,58.19,0,0,1-18.424,20.1c-5.514,3.642-14.469,5.405-20.775,1.294-2.286-1.5-15.654-9.325-4.324-32.638,7.125-14.668,22.118-24.843,25.5-27,8.27-5.282,14.18-7.794,20.377-7.318,3.156.23,3.611,1.472,5.957,1.41,1.336-1.568,2.073-2.72,4.27-3.327,2.95-.471,6.453,2,7.8,6.683s-2.161,12.206-3.047,15.824C200.822,291.35,203.938,338.277,184.767,372.724Zm49.607-105.731c-5.337.1-4.9,3.952-6.019,7.322-1.083,3.36-10.28,33.575-11.448,38.992s-1.969,10.282-.28,17.716c1.718,7.6,10.429,5.379,11.353,4.045,4.256-5.96.486-7.716.349-11.879-.109-4.351,1.082-8.915,1.981-14.806.32-2.1.962-11.649,1.277-12.945.355-1.4,3.36-12.033,7.689-18.5C241.129,274.166,242.76,266.848,234.374,266.993ZM248.9,231.656c-.73-1.5-4.7-6.225-9.882-2.557-7.708,5.458,1.18,13,2.607,16.7s9.132,6.8,8.007-.5C249.468,244.249,251.02,236,248.9,231.656Zm85.108,35.59c-2.509.559-7.516,1.569-10.666,2.1-4.77.8-7.369,3.8-7.189,6.832.18,3.054,1.225,4.493,5.481,3.685,3.848-.728,7.132-4,10.5-4.014,0,0-6.954,29.319-7.409,33.248s-2.512,14.678,1.713,20.777c9.4,13.576,28.2-1.985,29.957-3.68,1.291-1.253,13.168-11.668,17.194-20.021,2.04-4.239-1.433-5.175-3.784-6.04s-7.318.63-8.012,3.73c-1.512,6.734-17.96,24.9-26.1,21.258-9.594-4.3,7.713-52.479,7.713-52.479,2.064-.926,12.517-3.62,22.413-6.165,11.417-2.925,46.312-9.815,38.436-20.24-3.854-5.1-13.816,2.666-17.192,4.292-12.095,5.8-41.7,14.315-41.7,14.315s3.844-17.32,5.088-20.732c1.429-3.905,6.7-6.46,7.467-13.412.905-8.086-8.835-11.768-13.484-2.915-.962,1.817-2.287,6.832-2.417,6.957-1.249,1.2-12,5.493-22.571,10.533-3.332,1.588-22.256,8.469-22.291,8.424.026-.144,5.746-18.281,12.374-33.412,4.41-10.061,10.623-24.066,21.63-26.909,10.308-2.657,5.448,8.08,10.086,10.871,5.883,3.538,9.492-.924,10.393-5.191.706-3.342.519-20.879-17.31-19.126-16.965,1.673-30.484,27.571-47.2,75.964-.18.194-13.457,8.77-24.464,10.974-5.443,1.088-7.851,1.559-8.571,3.5a8.571,8.571,0,0,0,2,9.168c3.137,3.744,27.646-12.479,27.62-12.367-.5.661-20.609,77.281-20.77,78.175-1.159,6.381-7.642,23.206-7.291,30.843.194,3.929,1.734,12.107,7.391,11.742,11.846.777,9.561-15.865,8.765-18.917-1.659-6.313,6.851-48.045,14.488-78.8,1.557-6.273,4.756-16.566,5.313-19.014a11.455,11.455,0,0,1,6.22-8.233s42.583-20.073,42.979-19.957C340.811,243.054,334.008,267.246,334.008,267.246Zm291.747,38.3C634,313.343,642.2,308.4,646.421,305c12.945-10.41,39.635-57.853,39.635-57.853.152,1.588-11.121,56,3.95,62.033,9.664,3.86,32.156-15.33,34.732-17.107,14.851-10.251,5.751-14,2.886-14-7.658-.024-6.279,5.521-8.374,8.011-3.455,4.114-17.341,18.4-24.14,14.607-3.661-2.052-2.758-14.14-2.393-16.926,2.14-28.8,2.711-34,4.389-40.624.94-3.682,5.426-3.585,8.173-4.089,37.08-6.851,43.732-4.088,48.355-6.342,3.67-1.805,4.327-9.575-.879-11.246-2.382-.749-5.4,1.064-10.858,3.1-5.431,2.007-32.791,8.057-36.815,8.654-3.706.536-4.5.014-5.4-1.147-.886-1.149.82-14.663,6.135-25.865,2.313-4.848,2.374-12.267-5.178-12.841-3.106-.223-5.744.661-6.924,3.308-1.175,2.63-3.438,26.63-3.967,28.228-.535,1.578-3.4,2.81-4.033,5.549s-.192,4.448-2.035,5.064c-1.863.633-6.965,1.123-9.064,1.628-2.209.538-3.519,4.763-3.6,7.206s3.86.991,5.761.714c2.17-.316,7.476-2.967,3.7,1.578-1.431,1.737-27.17,41.973-37.222,50.258-11.566,9.521-10.739-9.2-10.45-12.389,1.748-19.092,10.1-31.9,11.3-34.94,1.18-3.055,1.677-12.107-8.183-10.107a6.979,6.979,0,0,0-4.938,4.637.992.992,0,0,0-.029.2C629.964,253.222,612.841,293.331,625.755,305.549Zm15.029-93.416c3.038,5.924,8.229,3.829,9.113,2.3.887-1.517,1.595-1.832,4.112-2.846,4.621-1.846-3.358-10.14-10.948-7.8a2.9,2.9,0,0,0-1.737,2.466C641.53,207.986,639.75,210.149,640.784,212.133ZM547.7,258.685s4.981-4.292,7.09-4.181c.979.048-.728,2.759,2.694,4.434,1.818.9-1.988,13.007-9.512,24.628-17.279,26.682-25.471,25.478-26.182,17.374C520.329,284.033,542.689,262.128,547.7,258.685Zm8.772,21.876s-.064,5.749.384,10.664c.4,4.412,1.908,13.644,3.462,16.63,2.408,4.6,7.514,4.6,11.446,2.346,5.147-2.957,2.713-8.488.753-10.329-1.711-1.621-5.869-3.7-7.128-10.645-.741-4.111-.135-22.637,1.083-29.118a86.628,86.628,0,0,1,5.621-17.3c2.05-4.045,14.4-27.206,14.581-34.981.075-3.393-3.42-6.206-9.131-5.618s-7.132,5.161-7.142,8.159c-.014,2.981-6.061,23.973-6.374,24.928-.5,1.434-1.491,10.029-4.441,10.048a28.671,28.671,0,0,0-18.159,6.078c-2.846,2.156-12.509,9.673-18.087,17.516-2.531,3.55-14.038,18.342-12.737,36,1.185,16.056,12.419,13.215,15.642,12.7,14.661-2.355,24.969-26.469,26.462-29.426C555.348,282.964,554.455,284.376,556.471,280.561ZM498.716,221.9c-1.074,1.32-.806,5.509,1.533,7.675,1.761,1.633,2.007,1.934,2.064,3.254.1,2.434,1.654,2.569,4.047,2.535,2.927-.042,5.32-2.073,5.616-6.206s-3.708-7.668-6.931-8.464S500.109,220.173,498.716,221.9Z"
5
+ transform="translate(-84.802 -179.806)" />
6
+ </svg>
@@ -0,0 +1,95 @@
1
+ // THE MODEL, VISIBLE (content model v2): a self-contained reference page the kit's dev server
2
+ // serves at /model, the registry's shape beside the LIVE example data the preview is rendering
3
+ // right now (preview-content.json overlay included), so "what can I read?" is answered where the
4
+ // developer already lives. Network-dead like every preview document.
5
+ import { contentModelV2 as contentModel } from '../engine/content-footprint.mjs';
6
+
7
+ const escapeHtml = (s) =>
8
+ String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
9
+
10
+ function fieldRows(item) {
11
+ return Object.entries(item)
12
+ .map(([name, spec]) => {
13
+ const notes = [];
14
+ if (spec.nullable) notes.push('nullable, always branch');
15
+ if (spec.enumOpen) {
16
+ notes.push(spec.enumOpen.length
17
+ ? `open enum: ${spec.enumOpen.join(', ')} today, more will arrive`
18
+ : 'open enum');
19
+ }
20
+ if (spec.max) notes.push(`max ${spec.max} chars`);
21
+ return `<tr><td><code>${escapeHtml(name)}</code></td><td>${escapeHtml(spec.type)}</td>` +
22
+ `<td>${escapeHtml(notes.join(' · '))}</td><td>${escapeHtml(spec.since)}</td></tr>`;
23
+ })
24
+ .join('');
25
+ }
26
+
27
+ function exampleOf(site, name) {
28
+ const value = site?.content?.[name];
29
+ const items = Array.isArray(value) ? value : value?.items;
30
+ if (!Array.isArray(items) || items.length === 0) return null;
31
+ return JSON.stringify(items[0], null, 2);
32
+ }
33
+
34
+ /** The /model page: registry + the live example values of THIS preview's site tree. */
35
+ export function renderModelReferenceHtml(site) {
36
+ const sectionsHtml = Object.entries(contentModel.collections)
37
+ .map(([name, model]) => {
38
+ const example = exampleOf(site, name);
39
+ const envelope = model.shape === "envelope" ? `<p>Envelope: <code>label</code>, nullable <code>href</code>, <code>items</code>, nullable <code>pagination</code>. Iterate <code>site.content.${name}.items</code>. Metadata reads do not fetch rows.</p>` : "<p>A bounded array.</p>";
40
+ return `<section id="${escapeHtml(name)}">
41
+ <h2><code>site.content.${escapeHtml(name)}</code></h2>
42
+ <p class="meta">bounded at <strong>${model.cap}</strong>${model.moreHref ? ` · more at <code>${escapeHtml(model.moreHref)}</code>` : ''} · since ${escapeHtml(model.since)}</p>
43
+ <p>${escapeHtml(model.description)}</p>
44
+ ${envelope}
45
+ <table><thead><tr><th>Field</th><th>Type</th><th>Notes</th><th>Since</th></tr></thead>
46
+ <tbody>${fieldRows(model.item)}</tbody></table>
47
+ ${example ? `<details><summary>Live example (item 0 of what this preview is rendering)</summary><pre>${escapeHtml(example)}</pre></details>` : '<p class="meta">No items in the current preview data.</p>'}
48
+ </section>`;
49
+ })
50
+ .join('\n');
51
+
52
+ const toc = Object.keys(contentModel.collections)
53
+ .map((name) => `<a href="#${escapeHtml(name)}">${escapeHtml(name)}</a>`)
54
+ .join(' · ');
55
+
56
+ return `<!doctype html>
57
+ <html lang="en">
58
+ <head>
59
+ <meta charset="utf-8">
60
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline';">
61
+ <meta name="viewport" content="width=device-width, initial-scale=1">
62
+ <title>site.content, the content model</title>
63
+ <style>
64
+ body { font: 15px/1.6 system-ui, sans-serif; margin: 0 auto; max-width: 56rem; padding: 24px 20px 64px; color: #172029; background: #fbfbf9; }
65
+ h1 { margin: 0 0 4px; } h2 { margin: 40px 0 2px; font-size: 20px; }
66
+ .meta { color: #5a6672; font-size: 13px; margin: 2px 0 8px; }
67
+ .lede { color: #3c4854; max-width: 46rem; }
68
+ nav.toc { font-size: 13px; margin: 14px 0 6px; color: #5a6672; }
69
+ table { border-collapse: collapse; width: 100%; font-size: 13.5px; }
70
+ th, td { text-align: left; border-bottom: 1px solid #e3e3dc; padding: 5px 10px 5px 0; vertical-align: top; }
71
+ th { font-size: 11px; letter-spacing: .05em; text-transform: uppercase; color: #5a6672; }
72
+ code { background: #eeeee6; border-radius: 4px; padding: 1px 5px; font-size: 12.5px; }
73
+ pre { background: #10161c; color: #dbe4ec; border-radius: 8px; padding: 12px 14px; overflow-x: auto; font-size: 12.5px; }
74
+ details { margin: 8px 0 0; } summary { cursor: pointer; font-size: 13px; color: #5a6672; }
75
+ .rules { background: #f0f0e8; border-radius: 10px; padding: 14px 18px; font-size: 13.5px; }
76
+ .back { font-size: 13px; }
77
+ </style>
78
+ </head>
79
+ <body>
80
+ <p class="back"><a href="/">← back to the preview</a></p>
81
+ <h1>The content model</h1>
82
+ <p class="lede">Everything your template reads lives on one tree: <code>site.brand</code>, <code>site.nav</code>, <code>site.socials</code>, <code>site.locale</code>, <code>site.actions</code>, <code>site.page</code> and the typed collections below. Model version <strong>${escapeHtml(contentModel.version)}</strong>. The validator computes your content footprint from the paths you read and stamps the minimum model version at publish, you never declare versions.</p>
83
+ <div class="rules">
84
+ <strong>Four rules the model never breaks (so neither should you):</strong>
85
+ every collection is bounded (cap + supplied <code>href</code> and <code>pagination</code>), link onward, never assume you have everything;
86
+ enum fields are open, branch on the values you style, fall back for the rest;
87
+ optional fields are explicitly nullable, always branch;
88
+ the model only grows, nothing you read today is ever removed within the major.
89
+ Replace the preview data with your own via <code>preview-content.json</code> (shape fixed, hot-reloaded, never packaged).
90
+ </div>
91
+ <nav class="toc">${toc}</nav>
92
+ ${sectionsHtml}
93
+ </body>
94
+ </html>`;
95
+ }
@@ -623,6 +623,32 @@ a.eyebrow:hover {
623
623
  max-width: 640px;
624
624
  }
625
625
 
626
+ .campaign-form {
627
+ margin-block: 2rem;
628
+ }
629
+
630
+ /* An embedded form belongs to the campaign, not a second page inside it. */
631
+ .p60-form--embedded {
632
+ padding: clamp(1rem, 3vw, 2rem);
633
+ border: 1px solid var(--line);
634
+ border-radius: var(--radius-sm, 12px);
635
+ background: var(--surface);
636
+ color: var(--ink);
637
+ }
638
+
639
+ .p60-form--embedded .p60-form-control {
640
+ background: var(--bg);
641
+ border-color: color-mix(in srgb, var(--ink) 35%, var(--surface));
642
+ }
643
+
644
+ .p60-form--embedded .p60-form-control:focus {
645
+ border-color: var(--primary);
646
+ }
647
+
648
+ .p60-form--embedded > :last-child {
649
+ margin-block-end: 0;
650
+ }
651
+
626
652
  .p60-form-intro {
627
653
  color: var(--muted);
628
654
  margin: 0 0 1.5rem;
@@ -636,6 +662,18 @@ a.eyebrow:hover {
636
662
  margin-bottom: 1.15rem;
637
663
  }
638
664
 
665
+ .p60-form-respondent {
666
+ min-inline-size: 0;
667
+ border: 0;
668
+ padding: 0 0 0.5rem;
669
+ margin: 0 0 1.5rem;
670
+ border-block-end: 1px solid var(--line);
671
+ }
672
+
673
+ .p60-form-respondent > legend {
674
+ margin-block-end: 1rem;
675
+ }
676
+
639
677
  .p60-form-label {
640
678
  display: block;
641
679
  font-weight: 600;
@@ -717,11 +755,18 @@ textarea.p60-form-control {
717
755
  width: 1.05rem;
718
756
  height: 1.05rem;
719
757
  flex: 0 0 auto;
720
- margin: 0.15rem 0 0;
721
- accent-color: var(--primary);
758
+ margin: 0.25rem 0 0;
759
+ padding: 0;
760
+ accent-color: var(--accent, var(--primary));
722
761
  cursor: pointer;
723
762
  }
724
763
 
764
+ .p60-form-check input:is([type='checkbox'], [type='radio']):focus-visible {
765
+ outline: 2px solid var(--ink);
766
+ outline-offset: 3px;
767
+ box-shadow: none;
768
+ }
769
+
725
770
  .p60-form-check.has-error span {
726
771
  color: var(--danger);
727
772
  }
@@ -1358,6 +1403,232 @@ textarea.p60-form-control {
1358
1403
  color: var(--primary-strong);
1359
1404
  }
1360
1405
 
1406
+ /* The donation island can sit in a narrow desktop column or a wide checkout.
1407
+ Size its fields from their available space, without changing other islands. */
1408
+ .donate-card.donation-widget {
1409
+ padding: 1.25rem;
1410
+ gap: 0.75rem;
1411
+ }
1412
+
1413
+ .donation-widget .donor-fields {
1414
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr));
1415
+ }
1416
+
1417
+ .donation-widget .giftaid-grid {
1418
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
1419
+ }
1420
+
1421
+ .donation-widget .donor-fields > .marketing-optin,
1422
+ .donation-widget .giftaid-name-grid > .giftaid-title {
1423
+ grid-column: 1 / -1;
1424
+ }
1425
+
1426
+ .donation-widget .giftaid-title {
1427
+ inline-size: min(100%, 10rem);
1428
+ }
1429
+
1430
+ .donation-widget .field,
1431
+ .donation-widget .field input,
1432
+ .donation-widget .field select,
1433
+ .donation-widget .freq-tab {
1434
+ min-inline-size: 0;
1435
+ }
1436
+
1437
+ .donation-widget .field select {
1438
+ inline-size: 100%;
1439
+ border: 1px solid var(--line);
1440
+ border-radius: 12px;
1441
+ padding: 0.85rem 0.95rem;
1442
+ background: var(--surface);
1443
+ color: var(--ink);
1444
+ }
1445
+
1446
+ .donation-widget .field select:focus-visible {
1447
+ outline: 2px solid var(--primary);
1448
+ outline-offset: 2px;
1449
+ }
1450
+
1451
+ .donation-widget .freq-tabs {
1452
+ grid-template-columns: none;
1453
+ grid-auto-flow: column;
1454
+ grid-auto-columns: minmax(0, 1fr);
1455
+ }
1456
+
1457
+ .donation-widget .freq-tab {
1458
+ overflow-wrap: anywhere;
1459
+ }
1460
+
1461
+ .donation-widget .donate-change {
1462
+ white-space: normal;
1463
+ }
1464
+
1465
+ .donation-widget .donation-giftaid-controls {
1466
+ display: grid;
1467
+ gap: 0.5rem;
1468
+ }
1469
+
1470
+ .donation-widget .donation-giftaid-summary {
1471
+ margin: 0;
1472
+ color: var(--ink);
1473
+ font-size: 0.9rem;
1474
+ line-height: 1.5;
1475
+ font-weight: 600;
1476
+ }
1477
+
1478
+ .donation-widget .donation-giftaid-summary-actions {
1479
+ display: flex;
1480
+ flex-wrap: wrap;
1481
+ align-items: center;
1482
+ gap: 0.75rem;
1483
+ }
1484
+
1485
+ /* One button surrounds the action and intact wordmark, preserving its clear space. */
1486
+ .donation-widget .donation-giftaid-trigger {
1487
+ display: inline-flex;
1488
+ align-items: center;
1489
+ justify-content: center;
1490
+ gap: 15px;
1491
+ min-block-size: 44px;
1492
+ padding: 15px;
1493
+ border: 1px solid color-mix(in srgb, var(--ink) 45%, transparent);
1494
+ border-radius: var(--radius);
1495
+ background: transparent;
1496
+ color: var(--ink);
1497
+ font: inherit;
1498
+ font-weight: 600;
1499
+ cursor: pointer;
1500
+ }
1501
+
1502
+ .donation-widget .donation-giftaid-logo {
1503
+ display: block;
1504
+ flex: 0 0 auto;
1505
+ inline-size: 72px;
1506
+ block-size: auto;
1507
+ }
1508
+
1509
+ .donation-widget .donation-giftaid-action {
1510
+ text-decoration: none;
1511
+ }
1512
+
1513
+ .donation-widget .donation-giftaid-trigger:hover:not(:disabled) {
1514
+ border-color: var(--ink);
1515
+ background: color-mix(in srgb, var(--ink) 5%, transparent);
1516
+ }
1517
+
1518
+ .donation-widget .donation-giftaid-trigger:focus-visible {
1519
+ outline: 2px solid var(--ink);
1520
+ outline-offset: 2px;
1521
+ }
1522
+
1523
+ .donation-widget .donation-giftaid-trigger:disabled {
1524
+ opacity: 0.6;
1525
+ cursor: not-allowed;
1526
+ }
1527
+
1528
+ /* Native top layer keeps the optional details clear of template hero clipping and motion. */
1529
+ .donation-widget .donation-giftaid-dialog {
1530
+ position: fixed;
1531
+ inset: 0;
1532
+ margin: auto;
1533
+ inline-size: min(42rem, calc(100% - 2rem));
1534
+ max-inline-size: none;
1535
+ max-block-size: calc(100dvh - 2rem);
1536
+ padding: 0;
1537
+ border: 1px solid var(--line);
1538
+ border-radius: var(--radius);
1539
+ background: var(--surface);
1540
+ color: var(--ink);
1541
+ font: inherit;
1542
+ text-align: start;
1543
+ box-shadow: var(--shadow);
1544
+ overflow: hidden;
1545
+ }
1546
+
1547
+ .donation-widget .donation-giftaid-dialog:not([open]) { display: none; }
1548
+ .donation-widget .donation-giftaid-dialog::backdrop { background: rgb(0 0 0 / 0.5); }
1549
+ html:has(.donation-giftaid-dialog[open]) { overflow: hidden; }
1550
+
1551
+ .donation-widget .donation-giftaid-dialog > form {
1552
+ display: grid;
1553
+ grid-template-rows: auto minmax(0, 1fr) auto;
1554
+ max-block-size: calc(100dvh - 2rem);
1555
+ margin: 0;
1556
+ }
1557
+
1558
+ .donation-widget .donation-giftaid-heading {
1559
+ display: flex;
1560
+ align-items: flex-start;
1561
+ justify-content: space-between;
1562
+ gap: 1rem;
1563
+ padding: 1.5rem;
1564
+ border-block-end: 1px solid var(--line);
1565
+ }
1566
+
1567
+ .donation-widget .donation-giftaid-heading h2 {
1568
+ font-size: clamp(1.25rem, 3vw, 1.6rem);
1569
+ line-height: 1.2;
1570
+ margin: 0;
1571
+ }
1572
+
1573
+ .donation-widget .donation-giftaid-heading p {
1574
+ color: var(--muted);
1575
+ margin: 0.5rem 0 0;
1576
+ font-size: 0.9rem;
1577
+ line-height: 1.5;
1578
+ }
1579
+
1580
+ .donation-widget .donation-giftaid-close {
1581
+ display: grid;
1582
+ place-items: center;
1583
+ flex: none;
1584
+ inline-size: 2.75rem;
1585
+ block-size: 2.75rem;
1586
+ padding: 0;
1587
+ border: 1px solid var(--line-strong);
1588
+ border-radius: 50%;
1589
+ background: var(--surface);
1590
+ color: var(--ink);
1591
+ font: inherit;
1592
+ font-size: 1.5rem;
1593
+ cursor: pointer;
1594
+ }
1595
+
1596
+ .donation-widget .donation-giftaid-body {
1597
+ display: grid;
1598
+ gap: 1.25rem;
1599
+ padding: 1.5rem;
1600
+ min-block-size: 0;
1601
+ overflow-y: auto;
1602
+ overscroll-behavior: contain;
1603
+ }
1604
+
1605
+ .donation-widget .donation-giftaid-dialog .giftaid-declaration { font-size: 0.875rem; line-height: 1.6; }
1606
+
1607
+ .donation-widget .donation-giftaid-actions {
1608
+ display: flex;
1609
+ flex-wrap: wrap;
1610
+ justify-content: flex-end;
1611
+ gap: 0.75rem;
1612
+ padding: 1rem 1.5rem;
1613
+ border-block-start: 1px solid var(--line);
1614
+ background: var(--surface);
1615
+ }
1616
+
1617
+ .donation-widget .donation-giftaid-actions .button { min-block-size: 2.75rem; }
1618
+ .donation-widget .donation-giftaid-dialog :focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
1619
+
1620
+ @media (max-width: 480px) {
1621
+ .donation-widget .donation-giftaid-dialog {
1622
+ inline-size: calc(100% - 1rem);
1623
+ max-block-size: calc(100dvh - 1rem);
1624
+ }
1625
+ .donation-widget .donation-giftaid-dialog > form { max-block-size: calc(100dvh - 1rem); }
1626
+ .donation-widget .donation-giftaid-heading,
1627
+ .donation-widget .donation-giftaid-body,
1628
+ .donation-widget .donation-giftaid-actions { padding: 1rem; }
1629
+ .donation-widget .donation-giftaid-actions .button { flex: 1; }
1630
+ }
1631
+
1361
1632
  .card-host {
1362
1633
  min-height: 2.5rem;
1363
1634
  margin: 0.25rem 0 0.5rem;
@@ -1578,6 +1849,49 @@ textarea.p60-form-control {
1578
1849
  color: var(--ink);
1579
1850
  margin: 0 0 1rem;
1580
1851
  }
1852
+ /* Side by side content from the rich-text editor (articles, events, courses). Structure only, and
1853
+ deliberately unscoped so a template page that renders a body with the raw filter gets the same
1854
+ floor: the theme owns the typography inside a pane and may restyle the gap, the breakpoint or the
1855
+ boxed look. Under 720px of page width the panes stack in reading order. Nothing about width or
1856
+ colour is stored with the content; the classes are exact tokens the sanitiser allow-lists. */
1857
+ .p60-cols {
1858
+ display: grid;
1859
+ gap: 1.5rem;
1860
+ margin: 1.25rem 0;
1861
+ align-items: start;
1862
+ }
1863
+ .p60-cols-2 {
1864
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1865
+ }
1866
+ .p60-cols-3 {
1867
+ grid-template-columns: repeat(3, minmax(0, 1fr));
1868
+ }
1869
+ .p60-col > :first-child {
1870
+ margin-top: 0;
1871
+ }
1872
+ .p60-col > :last-child {
1873
+ margin-bottom: 0;
1874
+ }
1875
+ /* A floated image never escapes its pane. */
1876
+ .p60-col img,
1877
+ .p60-col figure {
1878
+ float: none;
1879
+ max-width: 100%;
1880
+ }
1881
+ /* The one named look: a box drawn from the theme's own tokens. */
1882
+ .p60-col-boxed {
1883
+ border: 1px solid var(--line);
1884
+ border-radius: var(--radius);
1885
+ background: var(--bg-tint);
1886
+ padding: 1rem 1.15rem;
1887
+ }
1888
+ @media (max-width: 720px) {
1889
+ .p60-cols {
1890
+ grid-template-columns: 1fr;
1891
+ gap: 1rem;
1892
+ }
1893
+ }
1894
+
1581
1895
  .service-body {
1582
1896
  color: var(--ink);
1583
1897
  line-height: 1.7;
@@ -4119,6 +4433,47 @@ textarea.p60-form-control {
4119
4433
  }
4120
4434
  .member-course-feedback-toggle:hover { opacity: 1; text-decoration: underline; }
4121
4435
  .member-course-feedback-body { margin-top: 0.6rem; display: grid; gap: 0.75rem; }
4436
+ /* The course's structure as a family reads it: parts in order, where the class is, whether their
4437
+ learner was there, and the marks the course lets through. Indentation is the only thing that
4438
+ says which level a part sits at, because the composer named the levels. */
4439
+ .member-course-structure { margin-top: 0.6rem; display: grid; gap: 0.4rem; }
4440
+ .member-course-structure-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.2rem; }
4441
+ .member-course-structure-node { display: flex; justify-content: space-between; gap: 0.75rem; padding: 0.15rem 0; }
4442
+ .member-course-structure-node.is-heading .member-course-structure-name { font-weight: 600; }
4443
+ .member-course-structure-node.is-leaf:not(.is-covered) { opacity: 0.6; }
4444
+ .member-course-structure-node.is-current .member-course-structure-name { font-weight: 600; }
4445
+ .member-course-structure-now { font-style: normal; font-size: 0.8em; margin-inline-start: 0.4rem; padding: 0 0.4rem; border-radius: 999px; background: color-mix(in srgb, currentColor 12%, transparent); }
4446
+ .member-course-structure-state { display: inline-flex; gap: 0.5rem; align-items: baseline; white-space: nowrap; font-size: 0.9em; }
4447
+ .member-course-structure-mark { font-style: normal; font-weight: 600; }
4448
+ .member-course-structure-completion { margin: 0; font-weight: 600; }
4449
+ /* The progress meter: one segment per module, one cell per unit. Fills are the theme's primary so
4450
+ every template recolours it; every state also carries a word, a tick or a hatch, never colour
4451
+ alone. A 2px surface gap separates fills, and the current unit is lit rather than recoloured. */
4452
+ .member-course-meter { display: grid; gap: 0.75rem; padding: 0.9rem 1rem; border: 1px solid var(--line); border-radius: 0.75rem; background: color-mix(in srgb, var(--primary) 6%, transparent); }
4453
+ .member-course-meter-head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-end; gap: 0.5rem 1.25rem; }
4454
+ .member-course-meter-hero { display: flex; align-items: baseline; gap: 0.6rem; }
4455
+ .member-course-meter-number { font-size: 2.1rem; font-weight: 800; line-height: 1; letter-spacing: -0.02em; color: var(--primary); }
4456
+ .member-course-meter-caption { font-size: 0.9rem; color: var(--muted); }
4457
+ .member-course-meter-now { display: grid; gap: 0.15rem; font-size: 0.9rem; font-weight: 600; text-align: end; }
4458
+ .member-course-meter-presence { font-weight: 400; color: var(--muted); }
4459
+ .member-course-meter-bar { display: flex; gap: 0.5rem; align-items: flex-start; }
4460
+ .member-course-meter-seg { display: grid; gap: 0.4rem; min-inline-size: 0; }
4461
+ .member-course-meter-cells { display: flex; gap: 2px; }
4462
+ .member-course-meter-cell { flex: 1 1 0; display: inline-block; min-inline-size: 0.6rem; block-size: 0.85rem; border-radius: 4px; background: color-mix(in srgb, currentColor 14%, transparent); }
4463
+ .member-course-meter-cell.is-covered { background: var(--primary); }
4464
+ .member-course-meter-cell.is-covered.is-away { background: repeating-linear-gradient(135deg, var(--primary) 0 3px, color-mix(in srgb, var(--primary) 35%, transparent) 3px 6px); }
4465
+ .member-course-meter-cell.is-current { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--primary); animation: member-course-meter-pulse 2.4s ease-in-out infinite; }
4466
+ @keyframes member-course-meter-pulse { 0%, 100% { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--primary); } 50% { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px color-mix(in srgb, var(--primary) 35%, transparent); } }
4467
+ @media (prefers-reduced-motion: reduce) { .member-course-meter-cell.is-current { animation: none; } }
4468
+ .member-course-meter-seg-label { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.5rem; font-size: 0.8rem; min-inline-size: 0; }
4469
+ .member-course-meter-seg-name { font-weight: 600; overflow-wrap: anywhere; }
4470
+ .member-course-meter-legend { display: flex; flex-wrap: wrap; gap: 0.35rem 1rem; font-size: 0.75rem; color: var(--muted); }
4471
+ .member-course-meter-legend i { display: inline-block; inline-size: 0.9rem; block-size: 0.6rem; vertical-align: middle; margin-inline-end: 0.15rem; }
4472
+ .member-course-meter-legend i.is-current { animation: none; }
4473
+ .member-course-structure-details summary { cursor: pointer; }
4474
+ .member-course-mini { display: block; inline-size: min(14rem, 100%); block-size: 4px; margin-block-start: 0.35rem; border-radius: 999px; background: color-mix(in srgb, currentColor 14%, transparent); overflow: hidden; }
4475
+ .member-course-mini-fill { display: block; block-size: 100%; background: var(--primary); border-radius: inherit; }
4476
+ @media (max-width: 560px) { .member-course-meter-bar { flex-direction: column; gap: 0.6rem; } .member-course-meter-now { text-align: start; } }
4122
4477
  .member-course-feedback-note {
4123
4478
  border-left: 3px solid currentColor;
4124
4479
  padding: 0.35rem 0 0.35rem 0.75rem;
@@ -4162,6 +4517,8 @@ textarea.p60-form-control {
4162
4517
  }
4163
4518
  .member-course-attendance li:last-child { border-bottom: none; }
4164
4519
  .member-course-attendance li.is-absent { opacity: 0.7; }
4520
+ /* Which class this mark was, for a learner who attends more than one. */
4521
+ .member-course-attendance-class { display: block; font-size: 0.8em; font-style: normal; opacity: 0.75; }
4165
4522
 
4166
4523
  /* C1.9 breaks (informational) + C1.8 course materials (presigned downloads). */
4167
4524
  .member-course-breaks {