@port60/template-kit 1.2.1 → 1.3.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 (50) hide show
  1. package/README.md +38 -1
  2. package/package.json +1 -1
  3. package/src/commands/create.mjs +11 -0
  4. package/src/lib/agentsMd.mjs +176 -0
  5. package/src/vendor/contract/v2/dialect.json +2 -1
  6. package/src/vendor/contract/v2/manifest.schema.json +73 -2
  7. package/src/vendor/contract/v2/presentation.json +107 -0
  8. package/src/vendor/contract/v2/sections.json +120 -4
  9. package/src/vendor/contract/v2.lock.json +559 -7
  10. package/src/vendor/engine/budgets.mjs +0 -6
  11. package/src/vendor/engine/colour-roles.mjs +68 -0
  12. package/src/vendor/engine/content-footprint.mjs +0 -11
  13. package/src/vendor/engine/design-settings.mjs +34 -0
  14. package/src/vendor/engine/dialect.mjs +3 -10
  15. package/src/vendor/engine/locale.mjs +0 -5
  16. package/src/vendor/engine/majors.mjs +0 -5
  17. package/src/vendor/engine/presentation-capabilities.mjs +0 -2
  18. package/src/vendor/engine/section-fields.mjs +64 -0
  19. package/src/vendor/engine/section-heading-alignment.mjs +0 -2
  20. package/src/vendor/engine/section-presentation.mjs +127 -0
  21. package/src/vendor/validator/behaviors-runtime.js +1 -1
  22. package/src/vendor/validator/collection-link-visibility.mjs +0 -1
  23. package/src/vendor/validator/colour-treatment.mjs +154 -0
  24. package/src/vendor/validator/fixture-art.mjs +0 -15
  25. package/src/vendor/validator/focus.mjs +0 -5
  26. package/src/vendor/validator/fonts.mjs +0 -6
  27. package/src/vendor/validator/heading-alignment.mjs +0 -3
  28. package/src/vendor/validator/icons.mjs +0 -4
  29. package/src/vendor/validator/image-overlay.mjs +141 -0
  30. package/src/vendor/validator/intro-photo-framing.mjs +101 -0
  31. package/src/vendor/validator/model-reference-v2.mjs +0 -4
  32. package/src/vendor/validator/model-reference.mjs +0 -4
  33. package/src/vendor/validator/navigation-highlights.mjs +0 -3
  34. package/src/vendor/validator/presentation-proof.mjs +0 -2
  35. package/src/vendor/validator/preview-v1.mjs +3 -103
  36. package/src/vendor/validator/preview-v2.mjs +11 -105
  37. package/src/vendor/validator/section-fields.mjs +41 -0
  38. package/src/vendor/validator/section-layout.mjs +156 -0
  39. package/src/vendor/validator/section-presentation.mjs +225 -0
  40. package/src/vendor/validator/site-context-v2.mjs +17 -2
  41. package/src/vendor/validator/site-context.mjs +0 -14
  42. package/src/vendor/validator/validate-v1.mjs +4 -98
  43. package/src/vendor/validator/validate-v2.mjs +44 -103
  44. package/src/vendor/validator/validate.mjs +0 -2
  45. package/starter/assets/theme.css +55 -0
  46. package/starter/manifest.json +20 -0
  47. package/starter/sections/cta.liquid +2 -2
  48. package/starter/sections/hero.liquid +5 -3
  49. package/starter/sections/homeHero.liquid +6 -6
  50. package/starter/sections/values.liquid +2 -2
@@ -1,16 +1,9 @@
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
3
- // locally in T3. Deliberately NOT the production TemplateHost path: a studio version must never
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
6
- // cannot fetch, beacon or import anything, and the consumer embeds it in a sandboxed iframe.
7
- // Islands render as realistic, non-interactive fixture skeletons through their public styling
8
- // classes. Preview HTML carries no runtime and never attempts a platform transaction.
9
1
  import { readFileSync } from 'node:fs';
10
2
  import { join } from 'node:path';
11
3
  import { Liquid } from 'liquidjs';
12
4
  import { CONTENT_SLOT, configureDialect, splitIslandParts } from '../engine/dialect.mjs';
13
5
  import { LIQUID_BUDGETS } from '../engine/budgets.mjs';
6
+ import { resolveDesignSettings } from '../engine/design-settings.mjs';
14
7
  import dialect from '../contract/v1/dialect.json' with { type: 'json' };
15
8
  import sectionCatalogue from '../contract/v1/sections.json' with { type: 'json' };
16
9
  import islandRegistry from '../contract/v1/islands.json' with { type: 'json' };
@@ -21,23 +14,15 @@ import { withResolvedIcons } from './icons.mjs';
21
14
  import { FONT_PROVIDER_ORIGIN, fontCssHref, fontStackFor, knownFamily, toProvider } from './fonts.mjs';
22
15
  import { normaliseFocus, previewActions, withResolvedActions, applyFocus } from './focus.mjs';
23
16
 
24
- // Fixture imagery resolved for the SEALED studio render (p60fixture: refs become inline-SVG data
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.
27
17
  const STUDIO_FX = resolveFixtureArt(contextContract.fixtures);
28
18
 
29
19
  const escapeHtml = (s) =>
30
20
  String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
31
21
 
32
- // The platform base stylesheet, production loads it on EVERY template page before the theme, so
33
- // the preview does too: islands and platform components arrive with their real baseline look,
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.)
36
22
  let PLATFORM_BASE = '';
37
23
  try {
38
24
  PLATFORM_BASE = readFileSync(join(import.meta.dirname, 'platform-base.css'), 'utf8');
39
25
  } catch {
40
- // An older vendored copy without the file, the preview degrades to theme-only styling.
41
26
  }
42
27
 
43
28
  function previewNote(name) {
@@ -73,8 +58,6 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
73
58
  <p class="donate-note">Secure payment provided by Port60</p>
74
59
  </section>`;
75
60
  case 'member_menu':
76
- // Lives INSIDE the nav row, so the wrapper stays inline and the badge trails the button,
77
- // block layout here read as a stray element between the nav's last link and Sign in.
78
61
  return `<div data-p60-preview-island="member_menu" style="display:inline-flex;align-items:center;gap:8px">
79
62
  <button class="nav-p60-signin" type="button" disabled><span class="p60-mark" aria-hidden="true">P</span> Sign in</button>
80
63
  ${previewNote(name)}
@@ -127,8 +110,6 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
127
110
  <div class="article-comments-gate"><p class="article-comments-note">Sign in to join the conversation.</p></div>
128
111
  </section>`;
129
112
  case 'hero_carousel': {
130
- // Hydrated from the surrounding section's images (the homeHero sample fixture), real
131
- // slides through the real styling API, CSS-crossfaded by the preview so it reads as alive.
132
113
  const images = Array.isArray(ctx.section?.images) ? ctx.section.images.filter((i) => i?.imageUrl) : [];
133
114
  const slides = images.length > 0
134
115
  ? images.map((image, i) => `<div class="hero-slide${i === 0 ? ' hero-slide--active' : ''} p60-preview-slide"><img class="hero-slide-img" src="${escapeHtml(image.imageUrl)}" alt="${escapeHtml(image.alt ?? '')}"><div class="hero-slide-scrim"></div></div>`).join('')
@@ -147,8 +128,6 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
147
128
  <label class="newsletter-consent"><input type="checkbox" disabled><span>Email me about our work and appeals.</span></label>
148
129
  </form>`;
149
130
  case 'primary_action_widget':
150
- // What this island becomes follows the preview's focus (fixtures.focus, from ?focus= on the
151
- // dev server): the volunteer sign-up, nothing, or the default, the donation widget.
152
131
  if (fixtures.focus === 'volunteer') {
153
132
  return islandSkeleton('volunteer_signup', ctx, fx).replace('data-p60-preview-island="volunteer_signup"', 'data-p60-preview-island="primary_action_widget"');
154
133
  }
@@ -170,8 +149,6 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
170
149
  case 'search':
171
150
  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
151
  case 'map': {
173
- // The impact-map skeleton: the fixture's points projected onto a token-themed canvas,
174
- // the same fallback rendering production uses until the platform tile layer is configured.
175
152
  const im = fx.sections?.impactMap?.impactMap ?? { title: 'Impact map', points: [] };
176
153
  const pts = im.points ?? [];
177
154
  const lats = pts.map((pt) => pt.latitude);
@@ -200,13 +177,6 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
200
177
  }
201
178
  }
202
179
 
203
- // ── Platform surface skeletons ──────────────────────────────────────────────
204
- // The ROUTED dev preview's answer to "what does X look like in my theme": each platform-owned
205
- // page as a fixture skeleton through the PRODUCTION class names, so the platform base + the
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
208
- // template for a surface (events, course, articles, article), that template renders instead.
209
-
210
180
  function surfaceDivider(label) {
211
181
  return `<div class="p60-preview-divider" role="note">platform page: ${escapeHtml(label)}, styled by your tokens and chrome</div>`;
212
182
  }
@@ -360,7 +330,6 @@ function aboutSkeleton(fx, composition = []) {
360
330
  return `${surfaceDivider('about')}<section class="section"><div class="container"><h1>About us</h1>${rows}</div></section>`;
361
331
  }
362
332
 
363
- // surface → { pageTemplate to prefer when the theme declares it, builtin skeleton }
364
333
  const SURFACES = {
365
334
  events: { template: 'events', builtin: eventsListingSkeleton },
366
335
  event: { template: null, builtin: eventDetailSkeleton },
@@ -388,13 +357,9 @@ function surfaceBar(active, focus = 'donate', looks = [], activeLook = null) {
388
357
  link('campaigns', '/campaigns'), link('campaign', '/campaigns/fixture'), link('course', '/courses'),
389
358
  `<a href="${withFocus('/model')}" style="margin-left:auto;font-weight:700">site.content model →</a>`,
390
359
  ];
391
- // What leads: the charity's switch, here as three links, so a developer sees the hero widget as
392
- // the donation widget, as the volunteer sign-up, or absent, with the buttons following each time.
393
360
  const focusLink = (value, label) =>
394
361
  `<a href="${value === 'donate' ? '/' : `/?focus=${value}`}"${value === focus ? ' style="font-weight:700;text-decoration:underline"' : ''}>${label}</a>`;
395
362
  const focusLinks = [focusLink('donate', 'giving'), focusLink('volunteer', 'volunteering'), focusLink('none', 'buttons only')];
396
- // Looks: the author's one-click bundles, switchable here the way a charity switches them in
397
- // Appearance; ?look= on any URL, and ?p60s-<key>=<value> for a single knob.
398
363
  const lookLink = (name, href) =>
399
364
  `<a href="${href}"${name === activeLook ? ' style="font-weight:700;text-decoration:underline"' : ''}>${escapeHtml(name)}</a>`;
400
365
  const lookLinks = looks.length
@@ -429,29 +394,8 @@ function partsToHtml(html, contentHtml, ctx = {}, fx = STUDIO_FX) {
429
394
  * sealed and renders the fallback stacks).
430
395
  */
431
396
  function knobValues(manifest, overrides = {}) {
432
- const attrs = [];
433
- const vars = [];
434
- const fontSlots = [];
435
- for (const knob of manifest?.settings?.schema ?? []) {
436
- const raw = overrides[knob.key] ?? knob.default;
437
- if (raw == null || raw === '') continue;
438
- const value = String(raw);
439
- if (knob.kind === 'color') {
440
- if (/^#[0-9a-fA-F]{3,8}$/.test(value)) vars.push(`--p60s-${knob.key}: ${value};`);
441
- } else if (knob.kind === 'font') {
442
- const family = knownFamily(value) ?? knownFamily(knob.default);
443
- if (family) {
444
- fontSlots.push({ family, weights: knob.weights ?? [] });
445
- vars.push(`--p60s-${knob.key}: ${fontStackFor(family)};`);
446
- }
447
- } else {
448
- const options = Array.isArray(knob.options) ? knob.options : null;
449
- const chosen = options && !options.includes(value) ? knob.default : raw;
450
- if (chosen == null || chosen === '') continue;
451
- attrs.push(`data-p60s-${knob.key}="${escapeHtml(String(chosen))}"`);
452
- }
453
- }
454
- return { attrs: attrs.join(' '), vars: vars.join(' '), fontSlots };
397
+ const { bodyAttrs, colorVars, fontSlots } = resolveDesignSettings(manifest?.settings?.schema, overrides, { knownFamily, fontStackFor });
398
+ return { attrs: Object.entries(bodyAttrs).map(([key, value]) => `${key}="${escapeHtml(value)}"`).join(' '), vars: colorVars.join(' '), fontSlots };
455
399
  }
456
400
 
457
401
  /**
@@ -467,22 +411,12 @@ export async function renderStudioPreview(files, options = {}) {
467
411
  configureDialect(liquid, dialect, allIslands);
468
412
 
469
413
  const catalogueByType = new Map(sectionCatalogue.sections.map((s) => [s.type, s]));
470
- // Studio-sealed by default; the kit's dev server may pass fixtureImageBase to resolve fixture
471
- // imagery to the platform CDN instead of inline-SVG art (the dev-richer half of the split).
472
414
  const artOptions = options.fixtureImageBase ? { imageBase: options.fixtureImageBase } : null;
473
415
  let fx = artOptions ? resolveFixtureArt(contextContract.fixtures, artOptions) : STUDIO_FX;
474
- // What leads (docs/volunteering.md 'Site focus'): the dev server passes ?focus=; the studio
475
- // renders the platform default. The island skeletons read it from the fixtures they are handed.
476
416
  const focus = normaliseFocus(options.focus);
477
417
  const actions = previewActions(focus);
478
418
  fx = { ...fx, focus };
479
- // The one content tree (content model v1): about composed from this manifest's declared
480
- // sections, dev preview-content overlaid when the kit passes it (validated there), imagery
481
- // resolved exactly like the rest of the fixtures.
482
419
  const surface = options.surface ?? 'home';
483
- // The section-based pages: home and about compose from supports.pages and the catalogue's page
484
- // assignment (or the preview-content `pages` block, the admin-authored composition previewed).
485
- // The tree's `about` is the composition of the page being rendered, as it is live.
486
420
  const pageOf = (page) => pageComposition(manifest, page, options.previewContent);
487
421
  const treePage = PAGE_KEYS.includes(surface) ? surface : 'home';
488
422
  const baseSite = buildSiteFixture(manifest, { page: treePage });
@@ -490,9 +424,6 @@ export async function renderStudioPreview(files, options = {}) {
490
424
  applyPreviewContent({ ...baseSite, content: { ...baseSite.content, about: pageOf(treePage) } }, options.previewContent ?? null),
491
425
  artOptions ?? {}), actions);
492
426
  const brand = site.brand ?? fx.brand;
493
- // With a content override, the TREE is the source of truth for every fixture view: the routed
494
- // platform skeletons and island skeletons re-derive their slices from the overridden site, so
495
- // the developer's own data shows on /events, /donate and friends, not just where site.* is read.
496
427
  if (options.previewContent) {
497
428
  const c = site.content;
498
429
  fx = {
@@ -523,8 +454,6 @@ export async function renderStudioPreview(files, options = {}) {
523
454
 
524
455
  let contentHtml;
525
456
  if (surface !== 'home' && SURFACES[surface]) {
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.
528
457
  const def = SURFACES[surface];
529
458
  const templateSource = def.template ? files[`pages/${def.template}.liquid`] : null;
530
459
  const fixture = def.template ? fx.pages?.[def.template] : null;
@@ -538,13 +467,8 @@ export async function renderStudioPreview(files, options = {}) {
538
467
  contentHtml = def.builtin(fx);
539
468
  }
540
469
  } else if (surface === 'about' && !(manifest?.supports?.pages ?? []).includes('about')) {
541
- // The template declares no about page: live, the platform's own About body renders inside the
542
- // template's layout, so the preview shows that as a skeleton rather than the home composition.
543
470
  contentHtml = aboutSkeleton(fx, pageOf('about'));
544
471
  } else {
545
- // Render one page composition: each section's Liquid over its content (the sample, or the
546
- // preview-content override), icons resolved on items like the engine does, an unsupported
547
- // type omitted, never an error (the contract).
548
472
  const renderComposition = async (composition) => {
549
473
  const out = [];
550
474
  for (const { type, content } of composition) {
@@ -553,17 +477,12 @@ export async function renderStudioPreview(files, options = {}) {
553
477
  if (!entry || source == null) continue;
554
478
  const base = artOptions ? resolveFixtureArt(content ?? {}, artOptions) : resolveFixtureArt(content ?? {});
555
479
  const context = {
556
- // The home hero carries the resolved actions exactly as the platform hands them over, so a
557
- // developer sees the widget AND the button that pairs with it. The catalogue sample's
558
- // givingStyle is dropped here: in the preview the focus decides the style.
559
480
  section: withResolvedIcons(type === 'homeHero' ? withResolvedActions({ ...base, givingStyle: undefined, actionStyle: undefined }, actions) : base),
560
481
  brand,
561
482
  site,
562
483
  ...(fx.sections?.[type] ?? {})
563
484
  };
564
485
  const rendered = await liquid.parseAndRender(source, context);
565
- // Island skeletons see the SAME context the section rendered with, that is what lets the
566
- // hero carousel skeleton hydrate from the section's own photo fixtures.
567
486
  out.push(partsToHtml(rendered, '', context, fx));
568
487
  }
569
488
  return out;
@@ -573,17 +492,12 @@ export async function renderStudioPreview(files, options = {}) {
573
492
  sectionsHtml.push(...(await renderComposition(pageOf('about'))));
574
493
  } else {
575
494
  sectionsHtml.push(...(await renderComposition(pageOf('home'))));
576
- // The studio's single document (no surface requested) keeps showing everything the template
577
- // ships: the about composition follows the home one under a divider.
578
495
  if (!options.surface && (manifest?.supports?.pages ?? []).includes('about')) {
579
496
  const about = await renderComposition(pageOf('about'));
580
497
  if (about.length) sectionsHtml.push(`<div class="p60-preview-divider" role="note">page: about</div>`, ...about);
581
498
  }
582
499
  }
583
500
 
584
- // Declared page templates render too (over their page fixtures), the loop an author lives in
585
- // covers every surface they ship, not just home sections. The routed dev preview ALSO serves
586
- // each at its own path; this keeps the studio's single document complete.
587
501
  for (const page of surface === 'about' ? [] : (manifest?.supports?.pageTemplates ?? [])) {
588
502
  const source = files[`pages/${page}.liquid`];
589
503
  const fixture = fx.pages?.[page];
@@ -613,9 +527,6 @@ export async function renderStudioPreview(files, options = {}) {
613
527
 
614
528
  const themeCss = files['assets/theme.css'] ?? '';
615
529
  const { attrs, vars, fontSlots } = knobValues(manifest, options.knobs ?? {});
616
- // Webfonts: the DEV preview loads the same stylesheets production would (the template's own
617
- // manifest.fonts through the provider mirror, plus one for the chosen font knobs) so type is
618
- // judged for real locally; the studio render stays network-dead and shows the fallback stacks.
619
530
  const webfonts = options.webfonts
620
531
  ? [...(manifest?.fonts ?? []).map(toProvider), ...(fontCssHref(fontSlots) ? [fontCssHref(fontSlots)] : [])]
621
532
  : [];
@@ -623,28 +534,17 @@ export async function renderStudioPreview(files, options = {}) {
623
534
  ? `<link rel="preconnect" href="${FONT_PROVIDER_ORIGIN}" crossorigin>\n${webfonts.map((href) => `<link rel="stylesheet" href="${escapeHtml(href)}">`).join('\n')}`
624
535
  : '';
625
536
 
626
- // The kit's dev server passes the platform's own behaviour runtime (a self-contained bundle) so
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
629
- // nothing and keep the fully script-free CSP. Without the runtime, a CSS-only crossfade
630
- // approximates behaviour carousels so a static preview still reads as alive.
631
537
  const runtime = options.behaviorsRuntime ?? null;
632
- // With a fixture image base, the dev document may load imagery from that ONE origin; the studio
633
- // render never widens beyond data: URIs.
634
538
  const imgOrigins = new Set();
635
539
  if (options.fixtureImageBase) {
636
540
  try {
637
541
  imgOrigins.add(new URL(options.fixtureImageBase).origin);
638
542
  } catch {
639
- // A relative or malformed base stays sealed rather than guessing an origin.
640
543
  }
641
544
  }
642
- // The author's own imagery (a --content override) renders from exactly the hosts it names.
643
545
  for (const origin of options.contentImageOrigins ?? []) {
644
546
  imgOrigins.add(origin);
645
547
  }
646
- // The author's own photographs beside the template (a preview/ folder the dev server serves)
647
- // are same-origin; the studio never admits them, there is no such folder to serve there.
648
548
  if (options.localImages) imgOrigins.add("'self'");
649
549
  const imgSrc = ['data:', ...imgOrigins].join(' ');
650
550
  const styleSrc = webfonts.length ? `'unsafe-inline' ${FONT_PROVIDER_ORIGIN}` : "'unsafe-inline'";
@@ -1,16 +1,9 @@
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
3
- // locally in T3. Deliberately NOT the production TemplateHost path: a studio version must never
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
6
- // cannot fetch, beacon or import anything, and the consumer embeds it in a sandboxed iframe.
7
- // Islands render as realistic, non-interactive fixture skeletons through their public styling
8
- // classes. Preview HTML carries no runtime and never attempts a platform transaction.
9
1
  import { readFileSync } from 'node:fs';
10
2
  import { join } from 'node:path';
11
3
  import { Liquid } from 'liquidjs';
12
4
  import { CONTENT_SLOT, configureDialect, splitIslandParts } from '../engine/dialect.mjs';
13
5
  import { LIQUID_BUDGETS } from '../engine/budgets.mjs';
6
+ import { resolveDesignSettings } from '../engine/design-settings.mjs';
14
7
  import dialect from '../contract/v2/dialect.json' with { type: 'json' };
15
8
  import sectionCatalogue from '../contract/v2/sections.json' with { type: 'json' };
16
9
  import islandRegistry from '../contract/v2/islands.json' with { type: 'json' };
@@ -22,27 +15,17 @@ import { withResolvedIcons } from './icons.mjs';
22
15
  import { FONT_PROVIDER_ORIGIN, fontCssHref, fontStackFor, knownFamily, toProvider } from './fonts.mjs';
23
16
  import { normaliseFocus, previewActions } from './focus.mjs';
24
17
 
25
- // Fixture imagery resolved for the SEALED studio render (p60fixture: refs become inline-SVG data
26
- // URIs the network-dead CSP can show). The dev preview may instead resolve them to the platform
27
- // CDN via options.fixtureImageBase, the dev-richer / studio-sealed split.
28
18
  const STUDIO_FX = resolveFixtureArt(contextContract.fixtures);
29
19
 
30
- // Static artwork only: preserve the live island's complete wordmark without importing
31
- // its React runtime, payment configuration or Gift Aid workflow into the sealed preview.
32
20
  const GIFT_AID_LOGO = readFileSync(join(import.meta.dirname, 'gift-aid-logo.svg'), 'utf8');
33
21
 
34
22
  const escapeHtml = (s) =>
35
23
  String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
36
24
 
37
- // The platform base stylesheet, production loads it on EVERY template page before the theme, so
38
- // the preview does too: islands and platform components arrive with their real baseline look,
39
- // wearing the template's tokens, and the theme restyles over it exactly as in production.
40
- // (Generated copy of src/styles/global.css, scripts/build-preview-base.mjs.)
41
25
  let PLATFORM_BASE = '';
42
26
  try {
43
27
  PLATFORM_BASE = readFileSync(join(import.meta.dirname, 'platform-base.css'), 'utf8');
44
28
  } catch {
45
- // An older vendored copy without the file, the preview degrades to theme-only styling.
46
29
  }
47
30
 
48
31
  function previewNote(name) {
@@ -71,8 +54,6 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
71
54
  <button class="button button-primary donate-submit" type="button" disabled>Continue to payment</button>
72
55
  </section>`;
73
56
  case 'member_menu':
74
- // Match the production control's compact footprint and responsive label hook. An extra
75
- // explanatory badge would squeeze the organisation name on narrow preview headers.
76
57
  return `<div data-p60-preview-island="member_menu" style="display:inline-flex;align-items:center;gap:8px">
77
58
  <button class="nav-p60-signin" type="button" aria-label="Sign in with Port60 ID" disabled><svg class="nav-account-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="8" r="3.6"/><path d="M5 20c1.2-3.4 3.9-5 7-5s5.8 1.6 7 5"/></svg><span class="nav-p60-signin-label">Sign in</span></button>
78
59
  </div>`;
@@ -105,8 +86,6 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
105
86
  <div class="article-comments-gate"><p class="article-comments-note">Sign in to join the conversation.</p></div>
106
87
  </section>`;
107
88
  case 'hero_carousel': {
108
- // Hydrated from the surrounding section's images (the homeHero sample fixture), real
109
- // slides through the real styling API, CSS-crossfaded by the preview so it reads as alive.
110
89
  const images = Array.isArray(ctx.section?.images) ? ctx.section.images.filter((i) => i?.imageUrl) : [];
111
90
  const slides = images.length > 0
112
91
  ? images.map((image, i) => `<div class="hero-slide${i === 0 ? ' hero-slide--active' : ''} p60-preview-slide"><img class="hero-slide-img" src="${escapeHtml(image.imageUrl)}" alt="${escapeHtml(image.alt ?? '')}"><div class="hero-slide-scrim"></div></div>`).join('')
@@ -126,8 +105,6 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
126
105
  <label class="newsletter-consent"><input type="checkbox" disabled><span>Email me about our work and appeals.</span></label>
127
106
  </form>`;
128
107
  case 'primary_action_widget':
129
- // What this island becomes follows the preview's focus (fixtures.focus, from ?focus= on the
130
- // dev server): the volunteer sign-up, nothing, or the default, the donation widget.
131
108
  if (fixtures.focus === 'volunteer') {
132
109
  return islandSkeleton('volunteer_signup', ctx, fx).replace('data-p60-preview-island="volunteer_signup"', 'data-p60-preview-island="primary_action_widget"');
133
110
  }
@@ -145,13 +122,14 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
145
122
  </div>
146
123
  </section>`;
147
124
  case 'language_switch':
148
- return ''; // Compatibility slot, not a promise of translated tenant content.
125
+ return '';
149
126
  case 'search':
150
127
  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>`;
151
128
  case 'map': {
152
- // The impact-map skeleton: the fixture's points projected onto a token-themed canvas,
153
- // the same fallback rendering production uses until the platform tile layer is configured.
154
- const im = fx.sections?.impactMap?.impactMap ?? { title: 'Impact map', points: [] };
129
+ const im = fx.sections?.impactMap?.impactMap;
130
+ // A fixture is an explicitly selected source, never a default for an empty placement.
131
+ if (!im || typeof ctx.section?.mapSlug !== 'string' || !ctx.section.mapSlug.trim()
132
+ || ctx.section.mapSlug !== im.slug) return '';
155
133
  const pts = im.points ?? [];
156
134
  const lats = pts.map((pt) => pt.latitude);
157
135
  const lngs = pts.map((pt) => pt.longitude);
@@ -179,13 +157,6 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
179
157
  }
180
158
  }
181
159
 
182
- // ── Platform surface skeletons ──────────────────────────────────────────────
183
- // The ROUTED dev preview's answer to "what does X look like in my theme": each platform-owned
184
- // page as a fixture skeleton through the PRODUCTION class names, so the platform base + the
185
- // theme's tokens style it exactly as live, wrapped by the template's own layout. Never
186
- // interactive, the same posture as island skeletons. Where the theme ships its own page
187
- // template for a surface (events, course, articles, article), that template renders instead.
188
-
189
160
  function surfaceDivider(label) {
190
161
  return `<div class="p60-preview-divider" role="note">platform page: ${escapeHtml(label)}, styled by your tokens and chrome</div>`;
191
162
  }
@@ -339,7 +310,6 @@ function aboutSkeleton(fx, composition = []) {
339
310
  return `${surfaceDivider('about')}<section class="section"><div class="container"><h1>About us</h1>${rows}</div></section>`;
340
311
  }
341
312
 
342
- // surface → { pageTemplate to prefer when the theme declares it, builtin skeleton }
343
313
  const SURFACES = {
344
314
  events: { template: 'events', builtin: eventsListingSkeleton },
345
315
  event: { template: null, builtin: eventDetailSkeleton },
@@ -368,13 +338,9 @@ function surfaceBar(active, focus = 'donate', looks = [], activeLook = null) {
368
338
  link('campaigns', '/campaigns'), link('campaign', '/campaigns/fixture'), link('course', '/courses'),
369
339
  `<a href="${withFocus('/model')}" style="margin-left:auto;font-weight:700">site.content model →</a>`,
370
340
  ];
371
- // What leads: the charity's switch, here as three links, so a developer sees the hero widget as
372
- // the donation widget, as the volunteer sign-up, or absent, with the buttons following each time.
373
341
  const focusLink = (value, label) =>
374
342
  `<a href="${value === 'donate' ? '/' : `/?focus=${value}`}"${value === focus ? ' style="font-weight:700;text-decoration:underline"' : ''}>${label}</a>`;
375
343
  const focusLinks = [focusLink('donate', 'giving'), focusLink('volunteer', 'volunteering'), focusLink('none', 'buttons only')];
376
- // Looks: the author's one-click bundles, switchable here the way a charity switches them in
377
- // Appearance; ?look= on any URL, and ?p60s-<key>=<value> for a single knob.
378
344
  const lookLink = (name, href) =>
379
345
  `<a href="${href}"${name === activeLook ? ' style="font-weight:700;text-decoration:underline"' : ''}>${escapeHtml(name)}</a>`;
380
346
  const lookLinks = looks.length
@@ -409,29 +375,8 @@ function partsToHtml(html, contentHtml, ctx = {}, fx = STUDIO_FX) {
409
375
  * sealed and renders the fallback stacks).
410
376
  */
411
377
  function knobValues(manifest, overrides = {}) {
412
- const attrs = [];
413
- const vars = [];
414
- const fontSlots = [];
415
- for (const knob of manifest?.settings?.schema ?? []) {
416
- const raw = overrides[knob.key] ?? knob.default;
417
- if (raw == null || raw === '') continue;
418
- const value = String(raw);
419
- if (knob.kind === 'color') {
420
- if (/^#[0-9a-fA-F]{3,8}$/.test(value)) vars.push(`--p60s-${knob.key}: ${value};`);
421
- } else if (knob.kind === 'font') {
422
- const family = knownFamily(value) ?? knownFamily(knob.default);
423
- if (family) {
424
- fontSlots.push({ family, weights: knob.weights ?? [] });
425
- vars.push(`--p60s-${knob.key}: ${fontStackFor(family)};`);
426
- }
427
- } else {
428
- const options = Array.isArray(knob.options) ? knob.options : null;
429
- const chosen = options && !options.includes(value) ? knob.default : raw;
430
- if (chosen == null || chosen === '') continue;
431
- attrs.push(`data-p60s-${knob.key}="${escapeHtml(String(chosen))}"`);
432
- }
433
- }
434
- return { attrs: attrs.join(' '), vars: vars.join(' '), fontSlots };
378
+ const { bodyAttrs, colorVars, fontSlots } = resolveDesignSettings(manifest?.settings?.schema, overrides, { knownFamily, fontStackFor });
379
+ return { attrs: Object.entries(bodyAttrs).map(([key, value]) => `${key}="${escapeHtml(value)}"`).join(' '), vars: colorVars.join(' '), fontSlots };
435
380
  }
436
381
 
437
382
  /**
@@ -447,33 +392,23 @@ export async function renderStudioPreview(files, options = {}) {
447
392
  configureDialect(liquid, dialect, allIslands);
448
393
 
449
394
  const catalogueByType = new Map(sectionCatalogue.sections.map((s) => [s.type, s]));
450
- // Studio-sealed by default; the kit's dev server may pass fixtureImageBase to resolve fixture
451
- // imagery to the platform CDN instead of inline-SVG art (the dev-richer half of the split).
452
395
  const artOptions = options.fixtureImageBase ? { imageBase: options.fixtureImageBase } : null;
453
396
  let fx = artOptions ? resolveFixtureArt(contextContract.fixtures, artOptions) : STUDIO_FX;
454
- // What leads (docs/volunteering.md 'Site focus'): the dev server passes ?focus=; the studio
455
- // renders the platform default. The island skeletons read it from the fixtures they are handed.
456
397
  const focus = normaliseFocus(options.focus);
457
398
  const actions = previewActions(focus);
458
399
  fx = { ...fx, focus };
459
- // The one content tree (content model v1): about composed from this manifest's declared
460
- // sections, dev preview-content overlaid when the kit passes it (validated there), imagery
461
- // resolved exactly like the rest of the fixtures.
462
400
  const surface = options.surface ?? 'home';
463
- // The section-based pages: home and about compose from supports.pages and the catalogue's page
464
- // assignment (or the preview-content `pages` block, the admin-authored composition previewed).
465
- // The tree's `about` is the composition of the page being rendered, as it is live.
466
401
  const pageOf = (page) => pageComposition(manifest, page, options.previewContent);
467
402
  const treePage = surface;
468
403
  const baseSite = buildSiteFixture(manifest, { page: treePage });
469
404
  const site = resolveFixtureArt(applyPreviewContent(baseSite, options.previewContent ?? null), artOptions ?? {});
470
- // Author fixtures may still carry the old selector options. Match the public host without
471
- // changing their chosen locale/direction or mutating their source data.
472
405
  site.locale.languages = [];
473
406
  const headerMode = resolvedNavigationMode(manifest, site.nav.headerMode);
474
407
  if (headerMode === undefined) delete site.nav.headerMode;
475
408
  else site.nav.headerMode = headerMode;
476
- site.page.sections = PAGE_KEYS.includes(surface) ? pageOf(surface) : [];
409
+ site.page.sections = PAGE_KEYS.includes(surface) ? pageOf(surface).map(section => ({
410
+ ...section, content: resolveSectionFixture(section, site, manifest)
411
+ })) : [];
477
412
  const listingFixture = contextContract.fixtures.pages?.[surface]?.collection;
478
413
  if (listingFixture && site.content[surface] && !Object.hasOwn(options.previewContent ?? {}, surface)) {
479
414
  site.content[surface] = resolveFixtureArt(structuredClone(listingFixture), artOptions ?? {});
@@ -481,7 +416,6 @@ export async function renderStudioPreview(files, options = {}) {
481
416
  if (!options.previewContent?.actions) site.actions = { header: actions.header, hero: actions.hero, widget: actions.widget ?? 'none' };
482
417
  const brand = site.brand;
483
418
  const c = site.content;
484
- // Private skeleton views keep their existing CSS. They never enter the Liquid public context.
485
419
  fx = {
486
420
  ...fx, site, brand, focus: site.actions.widget,
487
421
  pages: {
@@ -503,8 +437,6 @@ export async function renderStudioPreview(files, options = {}) {
503
437
 
504
438
  let contentHtml;
505
439
  if (surface !== 'home' && SURFACES[surface]) {
506
- // A routed platform surface: the theme's own page template when it ships one, else the
507
- // platform-page fixture skeleton, either way inside the theme's layout below.
508
440
  const def = SURFACES[surface];
509
441
  const templateSource = def.template ? files[`pages/${def.template}.liquid`] : null;
510
442
  const fixture = def.template ? fx.pages?.[def.template] : null;
@@ -518,13 +450,8 @@ export async function renderStudioPreview(files, options = {}) {
518
450
  contentHtml = def.builtin(fx);
519
451
  }
520
452
  } else if (surface === 'about' && !(manifest?.supports?.pages ?? []).includes('about')) {
521
- // The template declares no about page: live, the platform's own About body renders inside the
522
- // template's layout, so the preview shows that as a skeleton rather than the home composition.
523
453
  contentHtml = aboutSkeleton(fx, pageOf('about'));
524
454
  } else {
525
- // Render one page composition: each section's Liquid over its content (the sample, or the
526
- // preview-content override), icons resolved on items like the engine does, an unsupported
527
- // type omitted, never an error (the contract).
528
455
  const renderComposition = async (composition) => {
529
456
  const out = [];
530
457
  for (const entryInPage of composition) {
@@ -540,8 +467,6 @@ export async function renderStudioPreview(files, options = {}) {
540
467
  }
541
468
  const context = { section: withResolvedIcons(section), site, ...(type === 'impactMap' ? fx.sections?.impactMap : {}) };
542
469
  const rendered = await liquid.parseAndRender(source, context);
543
- // Island skeletons see the SAME context the section rendered with, that is what lets the
544
- // hero carousel skeleton hydrate from the section's own photo fixtures.
545
470
  out.push(partsToHtml(rendered, '', context, fx));
546
471
  }
547
472
  return out;
@@ -551,17 +476,12 @@ export async function renderStudioPreview(files, options = {}) {
551
476
  sectionsHtml.push(...(await renderComposition(pageOf('about'))));
552
477
  } else {
553
478
  sectionsHtml.push(...(await renderComposition(pageOf('home'))));
554
- // The studio's single document (no surface requested) keeps showing everything the template
555
- // ships: the about composition follows the home one under a divider.
556
479
  if (!options.surface && (manifest?.supports?.pages ?? []).includes('about')) {
557
480
  const about = await renderComposition(pageOf('about'));
558
481
  if (about.length) sectionsHtml.push(`<div class="p60-preview-divider" role="note">page: about</div>`, ...about);
559
482
  }
560
483
  }
561
484
 
562
- // Declared page templates render too (over their page fixtures), the loop an author lives in
563
- // covers every surface they ship, not just home sections. The routed dev preview ALSO serves
564
- // each at its own path; this keeps the studio's single document complete.
565
485
  for (const page of options.surface ? [] : (manifest?.supports?.pageTemplates ?? [])) {
566
486
  const source = files[`pages/${page}.liquid`];
567
487
  const fixture = fx.pages?.[page];
@@ -584,9 +504,6 @@ export async function renderStudioPreview(files, options = {}) {
584
504
 
585
505
  const themeCss = files['assets/theme.css'] ?? '';
586
506
  const { attrs, vars, fontSlots } = knobValues(manifest, options.knobs ?? {});
587
- // Webfonts: the DEV preview loads the same stylesheets production would (the template's own
588
- // manifest.fonts through the provider mirror, plus one for the chosen font knobs) so type is
589
- // judged for real locally; the studio render stays network-dead and shows the fallback stacks.
590
507
  const webfonts = options.webfonts
591
508
  ? [...(manifest?.fonts ?? []).map(toProvider), ...(fontCssHref(fontSlots) ? [fontCssHref(fontSlots)] : [])]
592
509
  : [];
@@ -594,28 +511,17 @@ export async function renderStudioPreview(files, options = {}) {
594
511
  ? `<link rel="preconnect" href="${FONT_PROVIDER_ORIGIN}" crossorigin>\n${webfonts.map((href) => `<link rel="stylesheet" href="${escapeHtml(href)}">`).join('\n')}`
595
512
  : '';
596
513
 
597
- // The kit's dev server passes the platform's own behaviour runtime (a self-contained bundle) so
598
- // authors see their carousels, reveals and tabs living locally. The document stays network-dead
599
- //, the ONLY script it can run is the inline platform bundle; studio and demo previews pass
600
- // nothing and keep the fully script-free CSP. Without the runtime, a CSS-only crossfade
601
- // approximates behaviour carousels so a static preview still reads as alive.
602
514
  const runtime = options.behaviorsRuntime ?? null;
603
- // With a fixture image base, the dev document may load imagery from that ONE origin; the studio
604
- // render never widens beyond data: URIs.
605
515
  const imgOrigins = new Set();
606
516
  if (options.fixtureImageBase) {
607
517
  try {
608
518
  imgOrigins.add(new URL(options.fixtureImageBase).origin);
609
519
  } catch {
610
- // A relative or malformed base stays sealed rather than guessing an origin.
611
520
  }
612
521
  }
613
- // The author's own imagery (a --content override) renders from exactly the hosts it names.
614
522
  for (const origin of options.contentImageOrigins ?? []) {
615
523
  imgOrigins.add(origin);
616
524
  }
617
- // The author's own photographs beside the template (a preview/ folder the dev server serves)
618
- // are same-origin; the studio never admits them, there is no such folder to serve there.
619
525
  if (options.localImages) imgOrigins.add("'self'");
620
526
  const imgSrc = ['data:', ...imgOrigins].join(' ');
621
527
  const styleSrc = webfonts.length ? `'unsafe-inline' ${FONT_PROVIDER_ORIGIN}` : "'unsafe-inline'";
@@ -0,0 +1,41 @@
1
+ import { parsePresentationMarkup, presentationVisible, presentationText } from './presentation-proof.mjs';
2
+ import { proveIntroPhotoFraming } from './intro-photo-framing.mjs';
3
+
4
+ /** A photo is an optional section field, including when no layout variants are offered. */
5
+ export async function proveSectionFields(render, entry, fields, { fieldMarkers = false, css = '' } = {}) {
6
+ if (!fields?.includes('imageUrl')) return [];
7
+ const errors = [];
8
+ const base = { ...structuredClone(entry.sample), imageUrl: '', imageAlt: '' };
9
+ let baselineCopy;
10
+ for (const [imageUrl, imageAlt] of [
11
+ ['', ''], ['https://example.invalid/first-intro-photo.jpg', 'Our volunteers preparing parcels'],
12
+ ['https://example.invalid/replaced-intro-photo.jpg', '<Community> & "Neighbours"'],
13
+ ['https://example.invalid/replaced-intro-photo.jpg', ''], ['', ''], [undefined, undefined]
14
+ ]) {
15
+ try {
16
+ const content = { ...base, imageUrl, imageAlt };
17
+ if (imageUrl === undefined) { delete content.imageUrl; delete content.imageAlt; }
18
+ const html = await render(content);
19
+ const parsed = parsePresentationMarkup(html);
20
+ const copy = presentationText(parsed.root);
21
+ baselineCopy ??= copy;
22
+ if (copy !== baselineCopy) errors.push('optional photograph edits must not change section copy or render alternative text as visible copy');
23
+ const marked = parsed.nodes.filter(node => node.attrs['data-p60-field'] === 'imageUrl');
24
+ const images = parsed.nodes.filter(node => node.tag === 'img' && node.attrs.src === imageUrl && presentationVisible(node));
25
+ if (imageUrl) {
26
+ if (images.length !== 1 || images[0].attrs.alt !== imageAlt) errors.push('imageUrl/imageAlt must render exactly once on the actual visible photograph with escaped authored alt text');
27
+ if (fieldMarkers && (marked.length !== 1 || marked[0] !== images[0])) errors.push('data-p60-field="imageUrl" must mark only the actual photograph, never a wrapper or text node');
28
+ if (marked.some(node => node.tag !== 'img')) errors.push('imageUrl field markers must target img elements');
29
+ if (parsed.nodes.some(node => node.attrs['data-p60-field'] === 'imageAlt')) errors.push('imageAlt belongs in the photograph alt attribute, never an inline text field marker');
30
+ } else if (marked.length || parsed.nodes.some(node => node.tag === 'img'
31
+ || Object.hasOwn(node.attrs, 'data-p60-layout-has-media') || node.attrs['data-p60-layout-role'] === 'media')) {
32
+ errors.push('missing or removed photograph must omit its media, marker and media-present flag, never a placeholder');
33
+ }
34
+ } catch (error) {
35
+ errors.push(`failed rendering photograph fixture: ${error.message}`);
36
+ return errors.map(error => `section '${entry.type}' optional fields: ${error}`);
37
+ }
38
+ }
39
+ if (fields.includes('photoFraming')) errors.push(...await proveIntroPhotoFraming(render, entry, css));
40
+ return [...new Set(errors)].map(error => `section '${entry.type}' optional fields: ${error}`);
41
+ }