@port60/template-kit 0.20.3 → 0.20.4

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.
@@ -1,8 +1,8 @@
1
1
  // The STUDIO PREVIEW renderer (developer program T1.3): a validated artifact rendered over the
2
- // contract's KIND FIXTURES — no tenant, no tenant data, exactly what `template-kit dev` will show
2
+ // contract's KIND FIXTURES, no tenant, no tenant data, exactly what `template-kit dev` will show
3
3
  // locally in T3. Deliberately NOT the production TemplateHost path: a studio version must never
4
4
  // touch a live site, so this renders from an in-memory file map and the page it produces is
5
- // self-contained and network-dead — a CSP meta of default-src 'none' means the template's CSS
5
+ // self-contained and network-dead, a CSP meta of default-src 'none' means the template's CSS
6
6
  // cannot fetch, beacon or import anything, and the consumer embeds it in a sandboxed iframe.
7
7
  // Islands render as realistic, non-interactive fixture skeletons through their public styling
8
8
  // classes. Preview HTML carries no runtime and never attempts a platform transaction.
@@ -23,21 +23,21 @@ import { normaliseFocus, previewActions, withResolvedActions, applyFocus } from
23
23
 
24
24
  // Fixture imagery resolved for the SEALED studio render (p60fixture: refs become inline-SVG data
25
25
  // URIs the network-dead CSP can show). The dev preview may instead resolve them to the platform
26
- // CDN via options.fixtureImageBase — the dev-richer / studio-sealed split.
26
+ // CDN via options.fixtureImageBase, the dev-richer / studio-sealed split.
27
27
  const STUDIO_FX = resolveFixtureArt(contextContract.fixtures);
28
28
 
29
29
  const escapeHtml = (s) =>
30
30
  String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
31
31
 
32
- // The platform base stylesheet — production loads it on EVERY template page before the theme, so
32
+ // The platform base stylesheet, production loads it on EVERY template page before the theme, so
33
33
  // the preview does too: islands and platform components arrive with their real baseline look,
34
34
  // wearing the template's tokens, and the theme restyles over it exactly as in production.
35
- // (Generated copy of src/styles/global.css — scripts/build-preview-base.mjs.)
35
+ // (Generated copy of src/styles/global.css, scripts/build-preview-base.mjs.)
36
36
  let PLATFORM_BASE = '';
37
37
  try {
38
38
  PLATFORM_BASE = readFileSync(join(import.meta.dirname, 'platform-base.css'), 'utf8');
39
39
  } catch {
40
- // An older vendored copy without the file — the preview degrades to theme-only styling.
40
+ // An older vendored copy without the file, the preview degrades to theme-only styling.
41
41
  }
42
42
 
43
43
  function previewNote(name) {
@@ -73,7 +73,7 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
73
73
  <p class="donate-note">Secure payment provided by Port60</p>
74
74
  </section>`;
75
75
  case 'member_menu':
76
- // Lives INSIDE the nav row, so the wrapper stays inline and the badge trails the button —
76
+ // Lives INSIDE the nav row, so the wrapper stays inline and the badge trails the button,
77
77
  // block layout here read as a stray element between the nav's last link and Sign in.
78
78
  return `<div data-p60-preview-island="member_menu" style="display:inline-flex;align-items:center;gap:8px">
79
79
  <button class="nav-p60-signin" type="button" disabled><span class="p60-mark" aria-hidden="true">P</span> Sign in</button>
@@ -127,7 +127,7 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
127
127
  <div class="article-comments-gate"><p class="article-comments-note">Sign in to join the conversation.</p></div>
128
128
  </section>`;
129
129
  case 'hero_carousel': {
130
- // Hydrated from the surrounding section's images (the homeHero sample fixture) — real
130
+ // Hydrated from the surrounding section's images (the homeHero sample fixture), real
131
131
  // slides through the real styling API, CSS-crossfaded by the preview so it reads as alive.
132
132
  const images = Array.isArray(ctx.section?.images) ? ctx.section.images.filter((i) => i?.imageUrl) : [];
133
133
  const slides = images.length > 0
@@ -170,7 +170,7 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
170
170
  case 'search':
171
171
  return `<div class="site-search" data-p60-preview-island="search">${previewNote(name)}<form class="site-search-form"><label class="site-search-label">Search this site</label><div class="site-search-fields"><input class="site-search-input" type="search" disabled><button class="site-search-submit" type="button" disabled>Search</button></div></form><ul class="site-search-results"><li class="site-search-result"><span class="site-search-kind">Article</span><a class="site-search-link" href="#">The Community Garden Opens Its Gates</a><p class="site-search-summary">Two years of digging and Saturday mornings in the rain: the Foundry Lane garden is open.</p></li></ul></div>`;
172
172
  case 'map': {
173
- // The impact-map skeleton: the fixture's points projected onto a token-themed canvas —
173
+ // The impact-map skeleton: the fixture's points projected onto a token-themed canvas,
174
174
  // the same fallback rendering production uses until the platform tile layer is configured.
175
175
  const im = fx.sections?.impactMap?.impactMap ?? { title: 'Impact map', points: [] };
176
176
  const pts = im.points ?? [];
@@ -204,11 +204,11 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
204
204
  // The ROUTED dev preview's answer to "what does X look like in my theme": each platform-owned
205
205
  // page as a fixture skeleton through the PRODUCTION class names, so the platform base + the
206
206
  // theme's tokens style it exactly as live, wrapped by the template's own layout. Never
207
- // interactive — the same posture as island skeletons. Where the theme ships its own page
207
+ // interactive, the same posture as island skeletons. Where the theme ships its own page
208
208
  // template for a surface (events, course, articles, article), that template renders instead.
209
209
 
210
210
  function surfaceDivider(label) {
211
- return `<div class="p60-preview-divider" role="note">platform page: ${escapeHtml(label)} — styled by your tokens and chrome</div>`;
211
+ return `<div class="p60-preview-divider" role="note">platform page: ${escapeHtml(label)}, styled by your tokens and chrome</div>`;
212
212
  }
213
213
 
214
214
  function eventsListingSkeleton(fx = STUDIO_FX) {
@@ -336,7 +336,7 @@ function serviceDetailSkeleton(fx = STUDIO_FX) {
336
336
  <h1>${escapeHtml(service.title)}</h1>
337
337
  ${service.summary ? `<p>${escapeHtml(service.summary)}</p>` : ''}
338
338
  <p>Content pages are written in the workspace's WYSIWYG editor and arrive as sanitised
339
- HTML — headings, lists, images and embeds render here styled by your theme's typography.</p>
339
+ HTML, headings, lists, images and embeds render here styled by your theme's typography.</p>
340
340
  </article>
341
341
  </div></section>`;
342
342
  }
@@ -456,7 +456,7 @@ function knobValues(manifest, overrides = {}) {
456
456
 
457
457
  /**
458
458
  * Renders the artifact's declared sections (sample fixtures) inside its layout (when declared)
459
- * and returns a complete, self-contained HTML document. Throws on parse/render failure — callers
459
+ * and returns a complete, self-contained HTML document. Throws on parse/render failure, callers
460
460
  * preview only versions the validator has already passed, so a throw here is a bug report, not
461
461
  * a user flow.
462
462
  */
@@ -524,7 +524,7 @@ export async function renderStudioPreview(files, options = {}) {
524
524
  let contentHtml;
525
525
  if (surface !== 'home' && SURFACES[surface]) {
526
526
  // A routed platform surface: the theme's own page template when it ships one, else the
527
- // platform-page fixture skeleton — either way inside the theme's layout below.
527
+ // platform-page fixture skeleton, either way inside the theme's layout below.
528
528
  const def = SURFACES[surface];
529
529
  const templateSource = def.template ? files[`pages/${def.template}.liquid`] : null;
530
530
  const fixture = def.template ? fx.pages?.[def.template] : null;
@@ -562,7 +562,7 @@ export async function renderStudioPreview(files, options = {}) {
562
562
  ...(fx.sections?.[type] ?? {})
563
563
  };
564
564
  const rendered = await liquid.parseAndRender(source, context);
565
- // Island skeletons see the SAME context the section rendered with — that is what lets the
565
+ // Island skeletons see the SAME context the section rendered with, that is what lets the
566
566
  // hero carousel skeleton hydrate from the section's own photo fixtures.
567
567
  out.push(partsToHtml(rendered, '', context, fx));
568
568
  }
@@ -581,7 +581,7 @@ export async function renderStudioPreview(files, options = {}) {
581
581
  }
582
582
  }
583
583
 
584
- // Declared page templates render too (over their page fixtures) — the loop an author lives in
584
+ // Declared page templates render too (over their page fixtures), the loop an author lives in
585
585
  // covers every surface they ship, not just home sections. The routed dev preview ALSO serves
586
586
  // each at its own path; this keeps the studio's single document complete.
587
587
  for (const page of surface === 'about' ? [] : (manifest?.supports?.pageTemplates ?? [])) {
@@ -625,7 +625,7 @@ export async function renderStudioPreview(files, options = {}) {
625
625
 
626
626
  // The kit's dev server passes the platform's own behaviour runtime (a self-contained bundle) so
627
627
  // authors see their carousels, reveals and tabs living locally. The document stays network-dead
628
- // — the ONLY script it can run is the inline platform bundle; studio and demo previews pass
628
+ //, the ONLY script it can run is the inline platform bundle; studio and demo previews pass
629
629
  // nothing and keep the fully script-free CSP. Without the runtime, a CSS-only crossfade
630
630
  // approximates behaviour carousels so a static preview still reads as alive.
631
631
  const runtime = options.behaviorsRuntime ?? null;
@@ -667,7 +667,7 @@ export async function renderStudioPreview(files, options = {}) {
667
667
  <meta http-equiv="Content-Security-Policy" content="${csp}">
668
668
  <meta name="viewport" content="width=device-width, initial-scale=1">
669
669
  ${fontLinks}
670
- <title>${escapeHtml(manifest?.label ?? manifest?.name ?? 'Template preview')} — studio preview</title>
670
+ <title>${escapeHtml(manifest?.label ?? manifest?.name ?? 'Template preview')}, studio preview</title>
671
671
  <style>${vars ? `:root { ${vars} }` : ''}
672
672
  .p60-preview-badge { display: inline-flex; align-items: center; width: fit-content; margin: 0 0 8px;
673
673
  border: 1px solid currentColor; border-radius: 999px; padding: 3px 8px; font: 600 10px/1.2 system-ui, sans-serif;
@@ -1,7 +1,7 @@
1
- // The `site` tree for validator and preview renders (content model v1 — contract/v1/
1
+ // The `site` tree for validator and preview renders (content model v1, contract/v1/
2
2
  // content-model.json): ONE realistic organisation assembled from the canonical fixtures, with
3
3
  // `site.content.about` composed per-template from the section catalogue's samples for the
4
- // manifest's declared sections — the admin-authored page composition, previewed honestly.
4
+ // manifest's declared sections, the admin-authored page composition, previewed honestly.
5
5
  import contextContract from '../contract/v1/context.json' with { type: 'json' };
6
6
  import sectionCatalogue from '../contract/v1/sections.json' with { type: 'json' };
7
7
 
@@ -20,6 +20,18 @@ export const PAGE_KEYS = ['home', 'about'];
20
20
  * site's composition is admin-authored, and the preview-content `pages` block plays that role.
21
21
  */
22
22
  export function composePage(manifest, page) {
23
+ // The template's own composition when it declares one (stage 4): its order, its choice of
24
+ // sections, the `optional` ones left out so the preview shows the designed default.
25
+ const declared = manifest?.compositions?.[page];
26
+ if (Array.isArray(declared) && declared.length > 0) {
27
+ return declared
28
+ .filter((entry) => entry && entry.role !== 'optional')
29
+ .map((entry) => {
30
+ const catalogueEntry = catalogueByType.get(entry.type);
31
+ return catalogueEntry ? { type: entry.type, content: catalogueEntry.sample ?? {} } : null;
32
+ })
33
+ .filter(Boolean);
34
+ }
23
35
  return (manifest?.supports?.sections ?? [])
24
36
  .map((type) => {
25
37
  const entry = catalogueByType.get(type);
@@ -132,7 +144,7 @@ export function validatePreviewContent(json) {
132
144
  continue;
133
145
  }
134
146
  for (const field of Object.keys(items)) {
135
- if (field !== 'items') errors.push(`preview-content.json: nav.${field} does not exist — nav carries items`);
147
+ if (field !== 'items') errors.push(`preview-content.json: nav.${field} does not exist, nav carries items`);
136
148
  }
137
149
  validateNavItems(items.items, 'nav.items', errors);
138
150
  continue;
@@ -159,7 +171,7 @@ export function validatePreviewContent(json) {
159
171
  }
160
172
  for (const field of Object.keys(items)) {
161
173
  if (!BRAND_FIELDS.has(field)) {
162
- errors.push(`preview-content.json: brand.${field} does not exist — brand carries ${[...BRAND_FIELDS].join(', ')}`);
174
+ errors.push(`preview-content.json: brand.${field} does not exist, brand carries ${[...BRAND_FIELDS].join(', ')}`);
163
175
  }
164
176
  }
165
177
  continue;
@@ -174,7 +186,7 @@ export function validatePreviewContent(json) {
174
186
  continue;
175
187
  }
176
188
  if (items.length > model.cap) {
177
- errors.push(`preview-content.json: '${name}' holds ${items.length} items — the collection is bounded at ${model.cap}`);
189
+ errors.push(`preview-content.json: '${name}' holds ${items.length} items, the collection is bounded at ${model.cap}`);
178
190
  }
179
191
  items.forEach((item, i) => {
180
192
  if (item === null || typeof item !== 'object' || Array.isArray(item)) {
@@ -183,7 +195,7 @@ export function validatePreviewContent(json) {
183
195
  }
184
196
  for (const field of Object.keys(item)) {
185
197
  if (!model.item[field]) {
186
- errors.push(`preview-content.json: ${name}[${i}].${field} does not exist in the model — a field that does not exist in production cannot exist in a preview`);
198
+ errors.push(`preview-content.json: ${name}[${i}].${field} does not exist in the model, a field that does not exist in production cannot exist in a preview`);
187
199
  }
188
200
  }
189
201
  for (const [field, spec] of Object.entries(model.item)) {
@@ -1,6 +1,6 @@
1
1
  // The Port60 template CONFORMANCE VALIDATOR as a PURE MODULE (developer program T1.3): the same
2
- // checks the publish-time CLI has always run, callable with an in-memory file map — no fs, no
3
- // argv, no process.exit — so the studio upload lane (an HTTP endpoint) and the CLI share ONE
2
+ // checks the publish-time CLI has always run, callable with an in-memory file map, no fs, no
3
+ // argv, no process.exit, so the studio upload lane (an HTTP endpoint) and the CLI share ONE
4
4
  // implementation, and "validated ⇒ renders in production" keeps holding: the Liquid instance is
5
5
  // configured identically to the engine's, dialect enforcement is the same shared module, and the
6
6
  // render budgets here match production's.
@@ -23,6 +23,7 @@ import fontCatalogue from '../contract/v1/fonts.json' with { type: 'json' };
23
23
  import layoutContract from '../contract/v1/layout.json' with { type: 'json' };
24
24
  import behaviourCatalogue from '../contract/v1/behaviours.json' with { type: 'json' };
25
25
  import { buildSiteFixture, extractContentFootprint, contentModel } from './site-context.mjs';
26
+ import { proveNavigationHighlights } from './navigation-highlights.mjs';
26
27
 
27
28
  const Ajv = Ajv2020.default ?? Ajv2020;
28
29
 
@@ -42,7 +43,7 @@ const PRIMARY_ATTR = Object.fromEntries(
42
43
  );
43
44
 
44
45
  // Templates are markup and attributes, NEVER code (docs/template-behaviours.md). These are hard
45
- // errors over the RAW liquid source — even inside comments, because there is no legitimate reason
46
+ // errors over the RAW liquid source, even inside comments, because there is no legitimate reason
46
47
  // for the tokens to appear at all. The handler pattern names real DOM event families rather than
47
48
  // matching any on* word, so attributes like `once` or `online` never false-positive.
48
49
  const FORBIDDEN_MARKUP = [
@@ -62,7 +63,7 @@ export async function validateArtifact(files) {
62
63
  // 1. Manifest against the schema.
63
64
  let manifest = null;
64
65
  if (!has('manifest.json')) {
65
- return { errors: ['manifest.json is missing — every artifact starts with its manifest'], warnings, manifest };
66
+ return { errors: ['manifest.json is missing, every artifact starts with its manifest'], warnings, manifest };
66
67
  }
67
68
  try {
68
69
  manifest = JSON.parse(read('manifest.json'));
@@ -70,9 +71,9 @@ export async function validateArtifact(files) {
70
71
  return { errors: [`manifest.json unreadable: ${e.message}`], warnings, manifest: null };
71
72
  }
72
73
  // preview-content.json is a DEV-ONLY data override: package excludes it and the intake
73
- // refuses it — an artifact must never carry data, only shape.
74
+ // refuses it, an artifact must never carry data, only shape.
74
75
  if (has('preview-content.json')) {
75
- errors.push('preview-content.json: development preview data never ships in an artifact — remove it (package excludes it automatically)');
76
+ errors.push('preview-content.json: development preview data never ships in an artifact, remove it (package excludes it automatically)');
76
77
  }
77
78
 
78
79
  // Content model v1: the footprint is decidable from the sources (closed dialect); unknown
@@ -136,14 +137,14 @@ export async function validateArtifact(files) {
136
137
  }
137
138
  }
138
139
 
139
- // Markup and attributes, NEVER code — the machine-enforced JavaScript ban over every liquid
140
+ // Markup and attributes, NEVER code, the machine-enforced JavaScript ban over every liquid
140
141
  // source (docs/template-behaviours.md). Behaviour is engine-owned; a template wanting motion
141
142
  // declares supports.behaviors and uses the data-p60-* grammar.
142
143
  for (const [path, source] of Object.entries(files)) {
143
144
  if (!path.endsWith('.liquid')) continue;
144
145
  for (const [pattern, what] of FORBIDDEN_MARKUP) {
145
146
  if (pattern.test(source)) {
146
- errors.push(`${path}: contains ${what} — templates are markup and attributes, never code (behaviour is engine-owned; see the behaviour catalogue)`);
147
+ errors.push(`${path}: contains ${what}, templates are markup and attributes, never code (behaviour is engine-owned; see the behaviour catalogue)`);
147
148
  }
148
149
  }
149
150
  }
@@ -211,7 +212,7 @@ export async function validateArtifact(files) {
211
212
  }
212
213
  }
213
214
 
214
- // Looks: every value must target a declared knob and be valid for it — a look that half-applies
215
+ // Looks: every value must target a declared knob and be valid for it, a look that half-applies
215
216
  // would leave the tenant in a state no author designed.
216
217
  {
217
218
  const knobByKey = new Map((manifest?.settings?.schema ?? []).map((k) => [k.key, k]));
@@ -253,11 +254,11 @@ export async function validateArtifact(files) {
253
254
 
254
255
  // Author-renderable widget sections (the flip): each carries a curated data context; a template
255
256
  // either places the section's DEFAULT ISLAND (which owns rendering + empty states) or renders
256
- // the data itself — in which case both directions are proven behaviourally (the worship
257
+ // the data itself, in which case both directions are proven behaviourally (the worship
257
258
  // pattern): the populated fixture's sentinel must appear, and the EMPTY context must render it
258
- // away (derive or omit — nothing invented, nothing dangling).
259
+ // away (derive or omit, nothing invented, nothing dangling).
259
260
  // Sentinels are DERIVED from the canonical fixtures (the first item's display field), never
260
- // hardcoded — the fixture data is free to become richer without touching a proof, and a proof
261
+ // hardcoded, the fixture data is free to become richer without touching a proof, and a proof
261
262
  // can never drift from the data it renders.
262
263
  const sentinelOf = (type, dataKey, field) =>
263
264
  (contextContract.fixtures.sections?.[type]?.[dataKey] ?? [])[0]?.[field] ?? null;
@@ -270,7 +271,31 @@ export async function validateArtifact(files) {
270
271
  locations: { island: null, dataKey: 'locations', sentinel: sentinelOf('locations', 'locations', 'name') }
271
272
  };
272
273
 
273
- // 2–4. Sections: catalogue membership, parse, fixture renders.
274
+ // 2b. Compositions (site editor stage 4): each page must be a supported page, each type a
275
+ // supported section the catalogue assigns to that page, listed once, with a role.
276
+ const compositions = manifest?.compositions ?? {};
277
+ for (const [page, entries] of Object.entries(compositions)) {
278
+ if (!(manifest?.supports?.pages ?? []).includes(page)) {
279
+ errors.push(`compositions.${page}: not in supports.pages`);
280
+ continue;
281
+ }
282
+ const seen = new Set();
283
+ for (const entry of entries ?? []) {
284
+ const type = entry?.type;
285
+ if (!(manifest?.supports?.sections ?? []).includes(type)) {
286
+ errors.push(`compositions.${page}: '${type}' is not in supports.sections`);
287
+ continue;
288
+ }
289
+ const catalogueEntry = catalogueByType.get(type);
290
+ if (catalogueEntry && !(catalogueEntry.pages ?? []).includes(page)) {
291
+ errors.push(`compositions.${page}: '${type}' is not a ${page} page section in the catalogue`);
292
+ }
293
+ if (seen.has(type)) errors.push(`compositions.${page}: '${type}' is listed twice`);
294
+ seen.add(type);
295
+ }
296
+ }
297
+
298
+ // 2-4. Sections: catalogue membership, parse, fixture renders.
274
299
  for (const type of manifest?.supports?.sections ?? []) {
275
300
  const entry = catalogueByType.get(type);
276
301
  if (!entry) {
@@ -286,7 +311,7 @@ export async function validateArtifact(files) {
286
311
  try {
287
312
  parsed = liquid.parse(read(file));
288
313
  } catch (e) {
289
- errors.push(`section '${type}': does not parse under the dialect — ${e.message}`);
314
+ errors.push(`section '${type}': does not parse under the dialect, ${e.message}`);
290
315
  continue;
291
316
  }
292
317
  // Widget-section proof (independent of the minimal/sample loop): island placed → the island
@@ -307,8 +332,8 @@ export async function validateArtifact(files) {
307
332
  if (widget.sentinel && !populated.includes(widget.sentinel)) {
308
333
  errors.push(
309
334
  widget.island
310
- ? `section '${type}': neither places the ${widget.island} island nor renders the ${widget.dataKey} context — render the data (the fixture's "${widget.sentinel}" must appear) or place the island`
311
- : `section '${type}': does not render the ${widget.dataKey} context — the fixture's "${widget.sentinel}" must appear`
335
+ ? `section '${type}': neither places the ${widget.island} island nor renders the ${widget.dataKey} context, render the data (the fixture's "${widget.sentinel}" must appear) or place the island`
336
+ : `section '${type}': does not render the ${widget.dataKey} context, the fixture's "${widget.sentinel}" must appear`
312
337
  );
313
338
  }
314
339
  const empty = await liquid.render(parsed, {
@@ -318,14 +343,14 @@ export async function validateArtifact(files) {
318
343
  [widget.dataKey]: []
319
344
  });
320
345
  if (widget.sentinel && empty.includes(widget.sentinel)) {
321
- errors.push(`section '${type}': still shows fixture content with an empty ${widget.dataKey} — content must come from the context`);
346
+ errors.push(`section '${type}': still shows fixture content with an empty ${widget.dataKey}, content must come from the context`);
322
347
  }
323
348
  if (/\bundefined\b|\bnull\b/.test(empty.replace(/data-[a-z-]+="[^"]*"/g, ''))) {
324
- errors.push(`section '${type}': renders 'undefined'/'null' literals when ${widget.dataKey} is empty — guard the empty case (derive or omit)`);
349
+ errors.push(`section '${type}': renders 'undefined'/'null' literals when ${widget.dataKey} is empty, guard the empty case (derive or omit)`);
325
350
  }
326
351
  }
327
352
  } catch (e) {
328
- errors.push(`section '${type}': failed rendering the ${widget.dataKey} context fixtures — ${e.message}`);
353
+ errors.push(`section '${type}': failed rendering the ${widget.dataKey} context fixtures, ${e.message}`);
329
354
  }
330
355
  }
331
356
  for (const fixtureName of ['minimal', 'sample']) {
@@ -359,45 +384,48 @@ export async function validateArtifact(files) {
359
384
  }
360
385
  for (const part of splitIslandParts(html)) {
361
386
  if (part.island === CONTENT_SLOT) {
362
- errors.push(`section '${type}': uses {% content %} — that tag is layout-only`);
387
+ errors.push(`section '${type}': uses {% content %}, that tag is layout-only`);
363
388
  } else if (part.island) {
364
389
  placedIslands.add(part.island);
365
390
  }
366
391
  }
367
392
  } catch (e) {
368
- errors.push(`section '${type}': failed rendering the ${fixtureName} fixture — ${e.message}`);
393
+ errors.push(`section '${type}': failed rendering the ${fixtureName} fixture, ${e.message}`);
369
394
  }
370
395
  }
371
396
  }
372
397
 
373
398
  // Hero-imagery honesty, checked BEHAVIOURALLY (the worship pattern). The homeHero sample
374
399
  // fixture carries photographs whose data URIs embed a quote-free marker that survives HTML
375
- // escaping, so "does the rendered hero display the tenant's photos?" is a substring check —
400
+ // escaping, so "does the rendered hero display the tenant's photos?" is a substring check,
376
401
  // and with several photos, placing the hero_carousel island IS displaying them (the island
377
402
  // renders the slides at runtime). The single-photo path is proven separately: images[0] must
378
403
  // appear directly. Declaration and behaviour must agree; the choosers badge photo-led tenants
379
404
  // by supports.heroImagery. Legacy imageUrl-only renderers never match (the fixture's photos
380
- // ride `images`), so they pass undeclared — they just don't earn the badge.
405
+ // ride `images`), so they pass undeclared, they just don't earn the badge.
381
406
  {
382
407
  const declaresHero = manifest?.supports?.heroImagery === true;
383
408
  if (declaresHero && !(manifest?.supports?.sections ?? []).includes('homeHero')) {
384
- errors.push('manifest: supports.heroImagery requires the homeHero section — the photographs live on it');
409
+ errors.push('manifest: supports.heroImagery requires the homeHero section, the photographs live on it');
385
410
  } else if (homeHeroMultiShows !== null) {
386
411
  if (declaresHero && !homeHeroMultiShows) {
387
412
  errors.push('homeHero: manifest declares supports.heroImagery but the rendered section neither displays the images fixture nor places the hero_carousel island');
388
413
  }
389
414
  if (declaresHero && !homeHeroSingleShows) {
390
- errors.push('homeHero: supports.heroImagery must render a SINGLE photograph directly (images[0], treated, never raw) — the carousel island only covers 2+');
415
+ errors.push('homeHero: supports.heroImagery must render a SINGLE photograph directly (images[0], treated, never raw), the carousel island only covers 2+');
391
416
  }
392
417
  if (!declaresHero && (homeHeroMultiShows || homeHeroSingleShows)) {
393
- errors.push('homeHero: renders the hero photographs but the manifest does not declare supports.heroImagery — declare it so the choosers can badge it');
418
+ errors.push('homeHero: renders the hero photographs but the manifest does not declare supports.heroImagery, declare it so the choosers can badge it');
394
419
  }
395
420
  }
396
421
  }
397
422
 
398
423
  // 7. Layout (when declared): parse + render the layout fixture + exactly one content slot.
399
424
  if (manifest?.supports?.worship && !manifest?.supports?.layout) {
400
- errors.push('manifest: supports.worship requires supports.layout — the worship rail is layout chrome');
425
+ errors.push('manifest: supports.worship requires supports.layout, the worship rail is layout chrome');
426
+ }
427
+ if (manifest?.supports?.navigationHighlights && !manifest?.supports?.layout) {
428
+ errors.push('manifest: supports.navigationHighlights requires supports.layout, highlights belong to navigation chrome');
401
429
  }
402
430
  if (manifest?.supports?.layout) {
403
431
  if (!has('layout.liquid')) {
@@ -407,7 +435,7 @@ export async function validateArtifact(files) {
407
435
  try {
408
436
  parsedLayout = liquid.parse(read('layout.liquid'));
409
437
  } catch (e) {
410
- errors.push(`layout: does not parse under the dialect — ${e.message}`);
438
+ errors.push(`layout: does not parse under the dialect, ${e.message}`);
411
439
  }
412
440
  if (parsedLayout) {
413
441
  try {
@@ -429,11 +457,22 @@ export async function validateArtifact(files) {
429
457
  errors.push(`layout: must contain exactly one {% content %} slot (found ${contentSlots})`);
430
458
  }
431
459
  if (!layoutIslands.includes('member_menu')) {
432
- warnings.push("layout: no {% island 'member_menu' %} — member sign-in will be unreachable on tenants that allow sign-ups; place it in your header");
460
+ warnings.push("layout: no {% island 'member_menu' %}, member sign-in will be unreachable on tenants that allow sign-ups; place it in your header");
433
461
  }
434
462
 
463
+ const highlights = await proveNavigationHighlights((nav) => liquid.render(parsedLayout, {
464
+ site: { ...siteFx, nav },
465
+ brand: contextContract.fixtures.brand,
466
+ nav,
467
+ socials: contextContract.fixtures.layout.socials ?? [],
468
+ worship: contextContract.fixtures.layout.worship ?? null,
469
+ locale: contextContract.fixtures.layout.locale
470
+ }), manifest?.supports?.navigationHighlights);
471
+ errors.push(...highlights.errors);
472
+ warnings.push(...highlights.warnings);
473
+
435
474
  // Two-sided worship honesty, checked BEHAVIOURALLY: does the rendered layout actually
436
- // display the worship fixture's times? Declaration and behaviour must agree — the
475
+ // display the worship fixture's times? Declaration and behaviour must agree, the
437
476
  // choosers steer worship-enabled tenants by supports.worship, so a false declaration
438
477
  // either hides their times (undeclared but rendered is fine to fix by declaring) or
439
478
  // promises a rail that never appears.
@@ -441,10 +480,10 @@ export async function validateArtifact(files) {
441
480
  if (worshipProbe) {
442
481
  const rendersWorship = html.includes(worshipProbe);
443
482
  if (manifest?.supports?.worship && !rendersWorship) {
444
- errors.push('layout: manifest declares supports.worship but the rendered layout does not display the worship fixture times — the rail never appears');
483
+ errors.push('layout: manifest declares supports.worship but the rendered layout does not display the worship fixture times, the rail never appears');
445
484
  }
446
485
  if (!manifest?.supports?.worship && rendersWorship) {
447
- errors.push('layout: renders the worship rail but the manifest does not declare supports.worship — declare it so the choosers can badge it');
486
+ errors.push('layout: renders the worship rail but the manifest does not declare supports.worship, declare it so the choosers can badge it');
448
487
  }
449
488
  if (manifest?.supports?.worship) {
450
489
  const nullHtml = await liquid.render(parsedLayout, {
@@ -457,17 +496,17 @@ export async function validateArtifact(files) {
457
496
  locale: contextContract.fixtures.layout.locale
458
497
  });
459
498
  if (nullHtml.includes(worshipProbe)) {
460
- errors.push('layout: worship rail content appears even when `worship` is null — always branch on it (tenants without a schedule must not see a rail)');
499
+ errors.push('layout: worship rail content appears even when `worship` is null, always branch on it (tenants without a schedule must not see a rail)');
461
500
  }
462
501
  }
463
502
  }
464
503
  } catch (e) {
465
- errors.push(`layout: failed rendering the layout fixture — ${e.message}`);
504
+ errors.push(`layout: failed rendering the layout fixture, ${e.message}`);
466
505
  }
467
506
  }
468
507
  }
469
508
  } else if (has('layout.liquid')) {
470
- warnings.push('layout.liquid present but manifest.supports.layout is not true — it will be ignored');
509
+ warnings.push('layout.liquid present but manifest.supports.layout is not true, it will be ignored');
471
510
  }
472
511
 
473
512
  // 8. Page templates (when declared): file exists, parses, renders the page's data fixture.
@@ -486,20 +525,20 @@ export async function validateArtifact(files) {
486
525
  try {
487
526
  parsedPage = liquid.parse(read(pageFile));
488
527
  } catch (e) {
489
- errors.push(`page template '${pageName}': does not parse under the dialect — ${e.message}`);
528
+ errors.push(`page template '${pageName}': does not parse under the dialect, ${e.message}`);
490
529
  continue;
491
530
  }
492
531
  try {
493
532
  const html = await liquid.render(parsedPage, { ...fixture, site: siteFx, brand: contextContract.fixtures.brand });
494
533
  for (const part of splitIslandParts(html)) {
495
534
  if (part.island === CONTENT_SLOT) {
496
- errors.push(`page template '${pageName}': uses {% content %} — that tag is layout-only`);
535
+ errors.push(`page template '${pageName}': uses {% content %}, that tag is layout-only`);
497
536
  } else if (part.island) {
498
537
  placedIslands.add(part.island);
499
538
  }
500
539
  }
501
540
  } catch (e) {
502
- errors.push(`page template '${pageName}': failed rendering the page fixture — ${e.message}`);
541
+ errors.push(`page template '${pageName}': failed rendering the page fixture, ${e.message}`);
503
542
  }
504
543
  }
505
544
 
@@ -513,18 +552,18 @@ export async function validateArtifact(files) {
513
552
  if (!allIslands.has(name)) {
514
553
  errors.push(`island '${name}': not in the platform island registry`);
515
554
  } else if (!availableIslands.has(name)) {
516
- warnings.push(`island '${name}': registry status is 'planned' — it will render nothing until available`);
555
+ warnings.push(`island '${name}': registry status is 'planned', it will render nothing until available`);
517
556
  }
518
557
  }
519
558
 
520
559
  // 6. Theme.
521
560
  if (!has('assets/theme.css') || read('assets/theme.css').trim() === '') {
522
- errors.push('assets/theme.css missing or empty — a template must ship its look');
561
+ errors.push('assets/theme.css missing or empty, a template must ship its look');
523
562
  }
524
563
 
525
564
  // 6b. Layout contract (contract/v1/layout.json). Platform pages render through the content SEAM
526
565
  // (.container / .full); a template STYLES those to place content, never a parallel content container.
527
- // The rule is DATA — the seam token, the allowed selectors and the message all come from the contract
566
+ // The rule is DATA, the seam token, the allowed selectors and the message all come from the contract
528
567
  // file; this only implements the check KIND (a non-seam selector sizing its width off the token).
529
568
  {
530
569
  const css = has('assets/theme.css') ? read('assets/theme.css') : '';
@@ -552,7 +591,7 @@ export async function validateArtifact(files) {
552
591
  }
553
592
 
554
593
  // Open enums, proven survivable (content model v1 discipline 2): a template whose footprint
555
- // reads site.content.events must survive a registration mode it has never heard of — new modes
594
+ // reads site.content.events must survive a registration mode it has never heard of, new modes
556
595
  // WILL arrive within the major. No throw, and no undefined/null literal leaking into markup.
557
596
  if (contentAnalysis.footprint.includes('content.events')) {
558
597
  const futureEvent = {
@@ -573,10 +612,10 @@ export async function validateArtifact(files) {
573
612
  site: doctored
574
613
  });
575
614
  if (/\bundefined\b|\bnull\b/.test(html.replace(/data-[a-z-]+="[^"]*"/g, ''))) {
576
- errors.push(`section '${type}': renders 'undefined'/'null' literals for an unknown event registrationMode — the enum is OPEN, branch on the modes you style and fall back for the rest`);
615
+ errors.push(`section '${type}': renders 'undefined'/'null' literals for an unknown event registrationMode, the enum is OPEN, branch on the modes you style and fall back for the rest`);
577
616
  }
578
617
  } catch (e) {
579
- errors.push(`section '${type}': failed rendering an unknown event registrationMode — the enum is OPEN and new modes will arrive (${e.message})`);
618
+ errors.push(`section '${type}': failed rendering an unknown event registrationMode, the enum is OPEN and new modes will arrive (${e.message})`);
580
619
  }
581
620
  }
582
621
  }
@@ -5,6 +5,7 @@
5
5
  "label": "Starter",
6
6
  "description": "The reference template for the Port60 dialect, the developer docs' worked example and the base to copy when building your own. 1.1.0 adds the layout slot: the template owns the header, navigation and footer chrome.",
7
7
  "supports": {
8
+ "navigationHighlights": false,
8
9
  "pages": [
9
10
  "home",
10
11
  "about"
@@ -37,6 +38,44 @@
37
38
  "volunteer"
38
39
  ]
39
40
  },
41
+ "compositions": {
42
+ "home": [
43
+ {
44
+ "type": "homeHero",
45
+ "role": "core"
46
+ },
47
+ {
48
+ "type": "campaigns",
49
+ "role": "recommended"
50
+ },
51
+ {
52
+ "type": "impactMap",
53
+ "role": "optional"
54
+ },
55
+ {
56
+ "type": "cta",
57
+ "role": "recommended"
58
+ }
59
+ ],
60
+ "about": [
61
+ {
62
+ "type": "hero",
63
+ "role": "core"
64
+ },
65
+ {
66
+ "type": "values",
67
+ "role": "recommended"
68
+ },
69
+ {
70
+ "type": "people",
71
+ "role": "optional"
72
+ },
73
+ {
74
+ "type": "cta",
75
+ "role": "recommended"
76
+ }
77
+ ]
78
+ },
40
79
  "settings": {
41
80
  "schema": [
42
81
  {