@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.
- package/README.md +38 -1
- package/package.json +1 -1
- package/src/commands/create.mjs +11 -0
- package/src/lib/agentsMd.mjs +176 -0
- package/src/vendor/contract/v2/dialect.json +2 -1
- package/src/vendor/contract/v2/manifest.schema.json +73 -2
- package/src/vendor/contract/v2/presentation.json +107 -0
- package/src/vendor/contract/v2/sections.json +120 -4
- package/src/vendor/contract/v2.lock.json +559 -7
- package/src/vendor/engine/budgets.mjs +0 -6
- package/src/vendor/engine/colour-roles.mjs +68 -0
- package/src/vendor/engine/content-footprint.mjs +0 -11
- package/src/vendor/engine/design-settings.mjs +34 -0
- package/src/vendor/engine/dialect.mjs +3 -10
- package/src/vendor/engine/locale.mjs +0 -5
- package/src/vendor/engine/majors.mjs +0 -5
- package/src/vendor/engine/presentation-capabilities.mjs +0 -2
- package/src/vendor/engine/section-fields.mjs +64 -0
- package/src/vendor/engine/section-heading-alignment.mjs +0 -2
- package/src/vendor/engine/section-presentation.mjs +127 -0
- package/src/vendor/validator/behaviors-runtime.js +1 -1
- package/src/vendor/validator/collection-link-visibility.mjs +0 -1
- package/src/vendor/validator/colour-treatment.mjs +154 -0
- package/src/vendor/validator/fixture-art.mjs +0 -15
- package/src/vendor/validator/focus.mjs +0 -5
- package/src/vendor/validator/fonts.mjs +0 -6
- package/src/vendor/validator/heading-alignment.mjs +0 -3
- package/src/vendor/validator/icons.mjs +0 -4
- package/src/vendor/validator/image-overlay.mjs +141 -0
- package/src/vendor/validator/intro-photo-framing.mjs +101 -0
- package/src/vendor/validator/model-reference-v2.mjs +0 -4
- package/src/vendor/validator/model-reference.mjs +0 -4
- package/src/vendor/validator/navigation-highlights.mjs +0 -3
- package/src/vendor/validator/presentation-proof.mjs +0 -2
- package/src/vendor/validator/preview-v1.mjs +3 -103
- package/src/vendor/validator/preview-v2.mjs +11 -105
- package/src/vendor/validator/section-fields.mjs +41 -0
- package/src/vendor/validator/section-layout.mjs +156 -0
- package/src/vendor/validator/section-presentation.mjs +225 -0
- package/src/vendor/validator/site-context-v2.mjs +17 -2
- package/src/vendor/validator/site-context.mjs +0 -14
- package/src/vendor/validator/validate-v1.mjs +4 -98
- package/src/vendor/validator/validate-v2.mjs +44 -103
- package/src/vendor/validator/validate.mjs +0 -2
- package/starter/assets/theme.css +55 -0
- package/starter/manifest.json +20 -0
- package/starter/sections/cta.liquid +2 -2
- package/starter/sections/hero.liquid +5 -3
- package/starter/sections/homeHero.liquid +6 -6
- 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
|
433
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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 '';
|
|
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
|
-
|
|
153
|
-
//
|
|
154
|
-
|
|
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
|
|
413
|
-
|
|
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
|
+
}
|