@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.
- package/README.md +46 -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/islands.json +3 -2
- 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 +562 -9
- 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/platform-base.css +23 -0
- 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 +12 -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('')
|
|
@@ -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 '';
|
|
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
|
-
|
|
152
|
-
//
|
|
153
|
-
|
|
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
|
|
412
|
-
|
|
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
|
+
}
|