@port60/template-kit 1.2.0 → 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 (52) hide show
  1. package/README.md +46 -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/islands.json +3 -2
  7. package/src/vendor/contract/v2/manifest.schema.json +73 -2
  8. package/src/vendor/contract/v2/presentation.json +107 -0
  9. package/src/vendor/contract/v2/sections.json +120 -4
  10. package/src/vendor/contract/v2.lock.json +562 -9
  11. package/src/vendor/engine/budgets.mjs +0 -6
  12. package/src/vendor/engine/colour-roles.mjs +68 -0
  13. package/src/vendor/engine/content-footprint.mjs +0 -11
  14. package/src/vendor/engine/design-settings.mjs +34 -0
  15. package/src/vendor/engine/dialect.mjs +3 -10
  16. package/src/vendor/engine/locale.mjs +0 -5
  17. package/src/vendor/engine/majors.mjs +0 -5
  18. package/src/vendor/engine/presentation-capabilities.mjs +0 -2
  19. package/src/vendor/engine/section-fields.mjs +64 -0
  20. package/src/vendor/engine/section-heading-alignment.mjs +0 -2
  21. package/src/vendor/engine/section-presentation.mjs +127 -0
  22. package/src/vendor/validator/behaviors-runtime.js +1 -1
  23. package/src/vendor/validator/collection-link-visibility.mjs +0 -1
  24. package/src/vendor/validator/colour-treatment.mjs +154 -0
  25. package/src/vendor/validator/fixture-art.mjs +0 -15
  26. package/src/vendor/validator/focus.mjs +0 -5
  27. package/src/vendor/validator/fonts.mjs +0 -6
  28. package/src/vendor/validator/heading-alignment.mjs +0 -3
  29. package/src/vendor/validator/icons.mjs +0 -4
  30. package/src/vendor/validator/image-overlay.mjs +141 -0
  31. package/src/vendor/validator/intro-photo-framing.mjs +101 -0
  32. package/src/vendor/validator/model-reference-v2.mjs +0 -4
  33. package/src/vendor/validator/model-reference.mjs +0 -4
  34. package/src/vendor/validator/navigation-highlights.mjs +0 -3
  35. package/src/vendor/validator/platform-base.css +23 -0
  36. package/src/vendor/validator/presentation-proof.mjs +0 -2
  37. package/src/vendor/validator/preview-v1.mjs +3 -103
  38. package/src/vendor/validator/preview-v2.mjs +12 -105
  39. package/src/vendor/validator/section-fields.mjs +41 -0
  40. package/src/vendor/validator/section-layout.mjs +156 -0
  41. package/src/vendor/validator/section-presentation.mjs +225 -0
  42. package/src/vendor/validator/site-context-v2.mjs +17 -2
  43. package/src/vendor/validator/site-context.mjs +0 -14
  44. package/src/vendor/validator/validate-v1.mjs +4 -98
  45. package/src/vendor/validator/validate-v2.mjs +44 -103
  46. package/src/vendor/validator/validate.mjs +0 -2
  47. package/starter/assets/theme.css +55 -0
  48. package/starter/manifest.json +20 -0
  49. package/starter/sections/cta.liquid +2 -2
  50. package/starter/sections/hero.liquid +5 -3
  51. package/starter/sections/homeHero.liquid +6 -6
  52. 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('')
@@ -115,6 +94,7 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
115
94
  return `<section class="hero-carousel" data-p60-preview-island="hero_carousel">
116
95
  ${previewNote(name)}
117
96
  <div class="hero-slides">${slides}</div>
97
+ <button class="hero-playback" type="button" aria-label="Pause photographs" disabled><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M7 5h4v14H7zm6 0h4v14h-4z" /></svg></button>
118
98
  <div class="hero-dots" aria-hidden="true">${Array.from({ length: dots }, (_, i) => `<span class="hero-dot${i === 0 ? ' hero-dot--active' : ''}"></span>`).join('')}</div>
119
99
  </section>`;
120
100
  }
@@ -125,8 +105,6 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
125
105
  <label class="newsletter-consent"><input type="checkbox" disabled><span>Email me about our work and appeals.</span></label>
126
106
  </form>`;
127
107
  case 'primary_action_widget':
128
- // What this island becomes follows the preview's focus (fixtures.focus, from ?focus= on the
129
- // dev server): the volunteer sign-up, nothing, or the default, the donation widget.
130
108
  if (fixtures.focus === 'volunteer') {
131
109
  return islandSkeleton('volunteer_signup', ctx, fx).replace('data-p60-preview-island="volunteer_signup"', 'data-p60-preview-island="primary_action_widget"');
132
110
  }
@@ -144,13 +122,14 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
144
122
  </div>
145
123
  </section>`;
146
124
  case 'language_switch':
147
- return ''; // Compatibility slot, not a promise of translated tenant content.
125
+ return '';
148
126
  case 'search':
149
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>`;
150
128
  case 'map': {
151
- // The impact-map skeleton: the fixture's points projected onto a token-themed canvas,
152
- // the same fallback rendering production uses until the platform tile layer is configured.
153
- 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 '';
154
133
  const pts = im.points ?? [];
155
134
  const lats = pts.map((pt) => pt.latitude);
156
135
  const lngs = pts.map((pt) => pt.longitude);
@@ -178,13 +157,6 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
178
157
  }
179
158
  }
180
159
 
181
- // ── Platform surface skeletons ──────────────────────────────────────────────
182
- // The ROUTED dev preview's answer to "what does X look like in my theme": each platform-owned
183
- // page as a fixture skeleton through the PRODUCTION class names, so the platform base + the
184
- // theme's tokens style it exactly as live, wrapped by the template's own layout. Never
185
- // interactive, the same posture as island skeletons. Where the theme ships its own page
186
- // template for a surface (events, course, articles, article), that template renders instead.
187
-
188
160
  function surfaceDivider(label) {
189
161
  return `<div class="p60-preview-divider" role="note">platform page: ${escapeHtml(label)}, styled by your tokens and chrome</div>`;
190
162
  }
@@ -338,7 +310,6 @@ function aboutSkeleton(fx, composition = []) {
338
310
  return `${surfaceDivider('about')}<section class="section"><div class="container"><h1>About us</h1>${rows}</div></section>`;
339
311
  }
340
312
 
341
- // surface → { pageTemplate to prefer when the theme declares it, builtin skeleton }
342
313
  const SURFACES = {
343
314
  events: { template: 'events', builtin: eventsListingSkeleton },
344
315
  event: { template: null, builtin: eventDetailSkeleton },
@@ -367,13 +338,9 @@ function surfaceBar(active, focus = 'donate', looks = [], activeLook = null) {
367
338
  link('campaigns', '/campaigns'), link('campaign', '/campaigns/fixture'), link('course', '/courses'),
368
339
  `<a href="${withFocus('/model')}" style="margin-left:auto;font-weight:700">site.content model →</a>`,
369
340
  ];
370
- // What leads: the charity's switch, here as three links, so a developer sees the hero widget as
371
- // the donation widget, as the volunteer sign-up, or absent, with the buttons following each time.
372
341
  const focusLink = (value, label) =>
373
342
  `<a href="${value === 'donate' ? '/' : `/?focus=${value}`}"${value === focus ? ' style="font-weight:700;text-decoration:underline"' : ''}>${label}</a>`;
374
343
  const focusLinks = [focusLink('donate', 'giving'), focusLink('volunteer', 'volunteering'), focusLink('none', 'buttons only')];
375
- // Looks: the author's one-click bundles, switchable here the way a charity switches them in
376
- // Appearance; ?look= on any URL, and ?p60s-<key>=<value> for a single knob.
377
344
  const lookLink = (name, href) =>
378
345
  `<a href="${href}"${name === activeLook ? ' style="font-weight:700;text-decoration:underline"' : ''}>${escapeHtml(name)}</a>`;
379
346
  const lookLinks = looks.length
@@ -408,29 +375,8 @@ function partsToHtml(html, contentHtml, ctx = {}, fx = STUDIO_FX) {
408
375
  * sealed and renders the fallback stacks).
409
376
  */
410
377
  function knobValues(manifest, overrides = {}) {
411
- const attrs = [];
412
- const vars = [];
413
- const fontSlots = [];
414
- for (const knob of manifest?.settings?.schema ?? []) {
415
- const raw = overrides[knob.key] ?? knob.default;
416
- if (raw == null || raw === '') continue;
417
- const value = String(raw);
418
- if (knob.kind === 'color') {
419
- if (/^#[0-9a-fA-F]{3,8}$/.test(value)) vars.push(`--p60s-${knob.key}: ${value};`);
420
- } else if (knob.kind === 'font') {
421
- const family = knownFamily(value) ?? knownFamily(knob.default);
422
- if (family) {
423
- fontSlots.push({ family, weights: knob.weights ?? [] });
424
- vars.push(`--p60s-${knob.key}: ${fontStackFor(family)};`);
425
- }
426
- } else {
427
- const options = Array.isArray(knob.options) ? knob.options : null;
428
- const chosen = options && !options.includes(value) ? knob.default : raw;
429
- if (chosen == null || chosen === '') continue;
430
- attrs.push(`data-p60s-${knob.key}="${escapeHtml(String(chosen))}"`);
431
- }
432
- }
433
- 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 };
434
380
  }
435
381
 
436
382
  /**
@@ -446,33 +392,23 @@ export async function renderStudioPreview(files, options = {}) {
446
392
  configureDialect(liquid, dialect, allIslands);
447
393
 
448
394
  const catalogueByType = new Map(sectionCatalogue.sections.map((s) => [s.type, s]));
449
- // Studio-sealed by default; the kit's dev server may pass fixtureImageBase to resolve fixture
450
- // imagery to the platform CDN instead of inline-SVG art (the dev-richer half of the split).
451
395
  const artOptions = options.fixtureImageBase ? { imageBase: options.fixtureImageBase } : null;
452
396
  let fx = artOptions ? resolveFixtureArt(contextContract.fixtures, artOptions) : STUDIO_FX;
453
- // What leads (docs/volunteering.md 'Site focus'): the dev server passes ?focus=; the studio
454
- // renders the platform default. The island skeletons read it from the fixtures they are handed.
455
397
  const focus = normaliseFocus(options.focus);
456
398
  const actions = previewActions(focus);
457
399
  fx = { ...fx, focus };
458
- // The one content tree (content model v1): about composed from this manifest's declared
459
- // sections, dev preview-content overlaid when the kit passes it (validated there), imagery
460
- // resolved exactly like the rest of the fixtures.
461
400
  const surface = options.surface ?? 'home';
462
- // The section-based pages: home and about compose from supports.pages and the catalogue's page
463
- // assignment (or the preview-content `pages` block, the admin-authored composition previewed).
464
- // The tree's `about` is the composition of the page being rendered, as it is live.
465
401
  const pageOf = (page) => pageComposition(manifest, page, options.previewContent);
466
402
  const treePage = surface;
467
403
  const baseSite = buildSiteFixture(manifest, { page: treePage });
468
404
  const site = resolveFixtureArt(applyPreviewContent(baseSite, options.previewContent ?? null), artOptions ?? {});
469
- // Author fixtures may still carry the old selector options. Match the public host without
470
- // changing their chosen locale/direction or mutating their source data.
471
405
  site.locale.languages = [];
472
406
  const headerMode = resolvedNavigationMode(manifest, site.nav.headerMode);
473
407
  if (headerMode === undefined) delete site.nav.headerMode;
474
408
  else site.nav.headerMode = headerMode;
475
- 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
+ })) : [];
476
412
  const listingFixture = contextContract.fixtures.pages?.[surface]?.collection;
477
413
  if (listingFixture && site.content[surface] && !Object.hasOwn(options.previewContent ?? {}, surface)) {
478
414
  site.content[surface] = resolveFixtureArt(structuredClone(listingFixture), artOptions ?? {});
@@ -480,7 +416,6 @@ export async function renderStudioPreview(files, options = {}) {
480
416
  if (!options.previewContent?.actions) site.actions = { header: actions.header, hero: actions.hero, widget: actions.widget ?? 'none' };
481
417
  const brand = site.brand;
482
418
  const c = site.content;
483
- // Private skeleton views keep their existing CSS. They never enter the Liquid public context.
484
419
  fx = {
485
420
  ...fx, site, brand, focus: site.actions.widget,
486
421
  pages: {
@@ -502,8 +437,6 @@ export async function renderStudioPreview(files, options = {}) {
502
437
 
503
438
  let contentHtml;
504
439
  if (surface !== 'home' && SURFACES[surface]) {
505
- // A routed platform surface: the theme's own page template when it ships one, else the
506
- // platform-page fixture skeleton, either way inside the theme's layout below.
507
440
  const def = SURFACES[surface];
508
441
  const templateSource = def.template ? files[`pages/${def.template}.liquid`] : null;
509
442
  const fixture = def.template ? fx.pages?.[def.template] : null;
@@ -517,13 +450,8 @@ export async function renderStudioPreview(files, options = {}) {
517
450
  contentHtml = def.builtin(fx);
518
451
  }
519
452
  } else if (surface === 'about' && !(manifest?.supports?.pages ?? []).includes('about')) {
520
- // The template declares no about page: live, the platform's own About body renders inside the
521
- // template's layout, so the preview shows that as a skeleton rather than the home composition.
522
453
  contentHtml = aboutSkeleton(fx, pageOf('about'));
523
454
  } else {
524
- // Render one page composition: each section's Liquid over its content (the sample, or the
525
- // preview-content override), icons resolved on items like the engine does, an unsupported
526
- // type omitted, never an error (the contract).
527
455
  const renderComposition = async (composition) => {
528
456
  const out = [];
529
457
  for (const entryInPage of composition) {
@@ -539,8 +467,6 @@ export async function renderStudioPreview(files, options = {}) {
539
467
  }
540
468
  const context = { section: withResolvedIcons(section), site, ...(type === 'impactMap' ? fx.sections?.impactMap : {}) };
541
469
  const rendered = await liquid.parseAndRender(source, context);
542
- // Island skeletons see the SAME context the section rendered with, that is what lets the
543
- // hero carousel skeleton hydrate from the section's own photo fixtures.
544
470
  out.push(partsToHtml(rendered, '', context, fx));
545
471
  }
546
472
  return out;
@@ -550,17 +476,12 @@ export async function renderStudioPreview(files, options = {}) {
550
476
  sectionsHtml.push(...(await renderComposition(pageOf('about'))));
551
477
  } else {
552
478
  sectionsHtml.push(...(await renderComposition(pageOf('home'))));
553
- // The studio's single document (no surface requested) keeps showing everything the template
554
- // ships: the about composition follows the home one under a divider.
555
479
  if (!options.surface && (manifest?.supports?.pages ?? []).includes('about')) {
556
480
  const about = await renderComposition(pageOf('about'));
557
481
  if (about.length) sectionsHtml.push(`<div class="p60-preview-divider" role="note">page: about</div>`, ...about);
558
482
  }
559
483
  }
560
484
 
561
- // Declared page templates render too (over their page fixtures), the loop an author lives in
562
- // covers every surface they ship, not just home sections. The routed dev preview ALSO serves
563
- // each at its own path; this keeps the studio's single document complete.
564
485
  for (const page of options.surface ? [] : (manifest?.supports?.pageTemplates ?? [])) {
565
486
  const source = files[`pages/${page}.liquid`];
566
487
  const fixture = fx.pages?.[page];
@@ -583,9 +504,6 @@ export async function renderStudioPreview(files, options = {}) {
583
504
 
584
505
  const themeCss = files['assets/theme.css'] ?? '';
585
506
  const { attrs, vars, fontSlots } = knobValues(manifest, options.knobs ?? {});
586
- // Webfonts: the DEV preview loads the same stylesheets production would (the template's own
587
- // manifest.fonts through the provider mirror, plus one for the chosen font knobs) so type is
588
- // judged for real locally; the studio render stays network-dead and shows the fallback stacks.
589
507
  const webfonts = options.webfonts
590
508
  ? [...(manifest?.fonts ?? []).map(toProvider), ...(fontCssHref(fontSlots) ? [fontCssHref(fontSlots)] : [])]
591
509
  : [];
@@ -593,28 +511,17 @@ export async function renderStudioPreview(files, options = {}) {
593
511
  ? `<link rel="preconnect" href="${FONT_PROVIDER_ORIGIN}" crossorigin>\n${webfonts.map((href) => `<link rel="stylesheet" href="${escapeHtml(href)}">`).join('\n')}`
594
512
  : '';
595
513
 
596
- // The kit's dev server passes the platform's own behaviour runtime (a self-contained bundle) so
597
- // authors see their carousels, reveals and tabs living locally. The document stays network-dead
598
- //, the ONLY script it can run is the inline platform bundle; studio and demo previews pass
599
- // nothing and keep the fully script-free CSP. Without the runtime, a CSS-only crossfade
600
- // approximates behaviour carousels so a static preview still reads as alive.
601
514
  const runtime = options.behaviorsRuntime ?? null;
602
- // With a fixture image base, the dev document may load imagery from that ONE origin; the studio
603
- // render never widens beyond data: URIs.
604
515
  const imgOrigins = new Set();
605
516
  if (options.fixtureImageBase) {
606
517
  try {
607
518
  imgOrigins.add(new URL(options.fixtureImageBase).origin);
608
519
  } catch {
609
- // A relative or malformed base stays sealed rather than guessing an origin.
610
520
  }
611
521
  }
612
- // The author's own imagery (a --content override) renders from exactly the hosts it names.
613
522
  for (const origin of options.contentImageOrigins ?? []) {
614
523
  imgOrigins.add(origin);
615
524
  }
616
- // The author's own photographs beside the template (a preview/ folder the dev server serves)
617
- // are same-origin; the studio never admits them, there is no such folder to serve there.
618
525
  if (options.localImages) imgOrigins.add("'self'");
619
526
  const imgSrc = ['data:', ...imgOrigins].join(' ');
620
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
+ }