@port60/template-kit 0.21.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +42 -3
  2. package/bin/cli.mjs +10 -0
  3. package/package.json +4 -2
  4. package/src/commands/content.mjs +4 -3
  5. package/src/commands/create.mjs +11 -3
  6. package/src/commands/dev.mjs +9 -3
  7. package/src/commands/model.mjs +3 -3
  8. package/src/commands/packageCmd.mjs +1 -1
  9. package/src/commands/publish.mjs +1 -1
  10. package/src/commands/refresh.mjs +8 -3
  11. package/src/commands/release.mjs +22 -0
  12. package/src/commands/setupPreviews.mjs +19 -0
  13. package/src/commands/validate.mjs +1 -1
  14. package/src/lib/agentsMd.mjs +108 -162
  15. package/src/lib/designer-bridge.js +26 -0
  16. package/src/lib/designerPalette.mjs +62 -0
  17. package/src/lib/galleryPosters.mjs +105 -0
  18. package/src/lib/previewOptions.mjs +1 -1
  19. package/src/lib/releaseBundle.mjs +141 -0
  20. package/src/vendor/contract/v2/behaviours.json +346 -0
  21. package/src/vendor/contract/v2/content-bounds.json +117 -0
  22. package/src/vendor/contract/v2/content-model.json +766 -0
  23. package/src/vendor/contract/v2/context.json +1569 -0
  24. package/src/vendor/contract/v2/dialect.json +105 -0
  25. package/src/vendor/contract/v2/fonts.json +910 -0
  26. package/src/vendor/contract/v2/imagery.json +32 -0
  27. package/src/vendor/contract/v2/islands.json +263 -0
  28. package/src/vendor/contract/v2/layout.json +28 -0
  29. package/src/vendor/contract/v2/manifest.schema.json +388 -0
  30. package/src/vendor/contract/v2/sections.json +947 -0
  31. package/src/vendor/contract/v2/site.schema.json +1353 -0
  32. package/src/vendor/contract/v2/tokens.json +101 -0
  33. package/src/vendor/contract/v2.lock.json +6931 -0
  34. package/src/vendor/engine/content-footprint.mjs +76 -1
  35. package/src/vendor/engine/majors.mjs +42 -0
  36. package/src/vendor/validator/gift-aid-logo.svg +6 -0
  37. package/src/vendor/validator/model-reference-v2.mjs +95 -0
  38. package/src/vendor/validator/platform-base.css +310 -0
  39. package/src/vendor/validator/preview-v1.mjs +691 -0
  40. package/src/vendor/validator/preview-v2.mjs +654 -0
  41. package/src/vendor/validator/preview.mjs +8 -688
  42. package/src/vendor/validator/site-context-v2.mjs +113 -0
  43. package/src/vendor/validator/validate-v1.mjs +666 -0
  44. package/src/vendor/validator/validate-v2.mjs +642 -0
  45. package/src/vendor/validator/validate.mjs +21 -663
  46. package/starter/assets/theme.css +3 -0
  47. package/starter/layout.liquid +15 -18
  48. package/starter/manifest.json +5 -4
  49. package/starter/preview/config.json +4 -0
  50. package/starter/preview/media/README.md +9 -0
  51. package/starter/sections/campaigns.liquid +5 -4
  52. package/starter/sections/homeHero.liquid +25 -21
  53. package/starter/sections/values.liquid +1 -1
@@ -1,691 +1,11 @@
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
- import { readFileSync } from 'node:fs';
10
- import { join } from 'node:path';
11
- import { Liquid } from 'liquidjs';
12
- import { CONTENT_SLOT, configureDialect, splitIslandParts } from '../engine/dialect.mjs';
13
- import { LIQUID_BUDGETS } from '../engine/budgets.mjs';
14
- import dialect from '../contract/v1/dialect.json' with { type: 'json' };
15
- import sectionCatalogue from '../contract/v1/sections.json' with { type: 'json' };
16
- import islandRegistry from '../contract/v1/islands.json' with { type: 'json' };
17
- import contextContract from '../contract/v1/context.json' with { type: 'json' };
18
- import { resolveFixtureArt } from './fixture-art.mjs';
19
- import { buildSiteFixture, applyPreviewContent, pageComposition, PAGE_KEYS } from './site-context.mjs';
20
- import { withResolvedIcons } from './icons.mjs';
21
- import { FONT_PROVIDER_ORIGIN, fontCssHref, fontStackFor, knownFamily, toProvider } from './fonts.mjs';
22
- import { normaliseFocus, previewActions, withResolvedActions, applyFocus } from './focus.mjs';
1
+ import { renderStudioPreview as renderV1 } from './preview-v1.mjs';
2
+ import { renderStudioPreview as renderV2 } from './preview-v2.mjs';
3
+ export { PREVIEW_SURFACES } from './preview-v2.mjs';
23
4
 
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
- const STUDIO_FX = resolveFixtureArt(contextContract.fixtures);
28
-
29
- const escapeHtml = (s) =>
30
- String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
31
-
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
- let PLATFORM_BASE = '';
37
- try {
38
- PLATFORM_BASE = readFileSync(join(import.meta.dirname, 'platform-base.css'), 'utf8');
39
- } catch {
40
- // An older vendored copy without the file, the preview degrades to theme-only styling.
41
- }
42
-
43
- function previewNote(name) {
44
- return `<span class="p60-preview-badge">${escapeHtml(name.replaceAll('_', ' '))} preview</span>`;
45
- }
46
-
47
- function eventCards(events) {
48
- return events.slice(0, 3).map((event) => `
49
- <article class="carousel-slide">
50
- <div class="carousel-caption">
51
- <p>${escapeHtml(event.venueName ?? (event.online ? 'Online' : 'Event'))}</p>
52
- <h3>${escapeHtml(event.name)}</h3>
53
- <a href="${escapeHtml(event.detailHref)}">View event</a>
54
- </div>
55
- </article>`).join('');
56
- }
57
-
58
- function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
59
- const fixtures = fx;
60
- const events = fixtures.sections?.events?.events ?? fixtures.pages?.events?.events ?? [];
61
- const infoEvents = fixtures.sections?.whatsOn?.infoEvents ?? [];
62
- const articles = fixtures.sections?.articles?.latestArticles ?? [];
63
-
64
- switch (name) {
65
- case 'donation_widget':
66
- return `<section class="donate-card" data-p60-preview-island="donation_widget">
67
- ${previewNote(name)}
68
- <header class="donate-head"><h3>Make a donation</h3><p class="donate-sub">Your support makes this work possible.</p></header>
69
- <div class="freq-tabs" aria-label="Donation frequency"><button class="freq-tab" type="button" disabled>One off</button><button class="freq-tab" type="button" disabled>Monthly</button></div>
70
- <div class="amount-grid"><button class="amount-option" type="button" disabled>£10</button><button class="amount-option" type="button" disabled>£25</button><button class="amount-option" type="button" disabled>£50</button></div>
71
- <div class="giftaid-box"><strong>Add Gift Aid</strong><p>Eligible UK taxpayers can increase their donation by 25%.</p></div>
72
- <button class="donate-submit" type="button" disabled>Continue</button>
73
- <p class="donate-note">Secure payment provided by Port60</p>
74
- </section>`;
75
- 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
- return `<div data-p60-preview-island="member_menu" style="display:inline-flex;align-items:center;gap:8px">
79
- <button class="nav-p60-signin" type="button" disabled><span class="p60-mark" aria-hidden="true">P</span> Sign in</button>
80
- ${previewNote(name)}
81
- </div>`;
82
- case 'events_carousel': {
83
- const dots = Math.max(Math.min(events.length, 3), 2);
84
- return `<section class="events-carousel" data-p60-preview-island="events_carousel">
85
- ${previewNote(name)}
86
- ${eventCards(events)}
87
- <div class="carousel-dots" aria-hidden="true">${Array.from({ length: dots }, () => '<span class="carousel-dot"></span>').join('')}</div>
88
- </section>`;
89
- }
90
- case 'whats_on_strip':
91
- return `<section class="info-events-bar" data-p60-preview-island="whats_on_strip">
92
- ${previewNote(name)}
93
- <div class="info-events-inner">${infoEvents.map((event) => `<a class="info-event-chip" href="${escapeHtml(event.detailHref)}"><strong>${escapeHtml(event.name)}</strong><span>${escapeHtml(event.venueName ?? 'Online')}</span></a>`).join('')}</div>
94
- </section>`;
95
- case 'latest_articles':
96
- return `<section data-p60-preview-island="latest_articles">
97
- ${previewNote(name)}
98
- <div class="article-grid">${articles.map((article) => `<article class="article-card"><div class="article-card-body"><p>${escapeHtml(article.categories?.[0] ?? 'Article')}</p><h3><a href="${escapeHtml(article.href)}">${escapeHtml(article.title)}</a></h3><p>${escapeHtml(article.excerpt ?? '')}</p></div></article>`).join('')}</div>
99
- <a class="article-strip-cta" href="/articles/all">Read all articles</a>
100
- </section>`;
101
- case 'course_enrol':
102
- return `<section class="course-enrol" data-p60-preview-island="course_enrol">
103
- ${previewNote(name)}
104
- <div class="course-variant-list"><strong>Choose a session</strong><label><input type="radio" disabled> Saturday mornings</label><label><input type="radio" disabled> Tuesday evenings</label></div>
105
- <div class="course-subject-list"><strong>Participant</strong><p>One child place</p></div>
106
- <div class="course-summary"><span>Weekly course</span><strong>£15</strong></div>
107
- <div class="course-pay-panel"><button type="button" disabled>Continue to enrolment</button></div>
108
- </section>`;
109
- case 'article_engagement':
110
- return `<aside class="article-engage" data-p60-preview-island="article_engagement">
111
- ${previewNote(name)}
112
- <span class="article-views">82 views</span>
113
- <div class="article-share-group"><span class="article-share-word">Share</span><button class="article-share-btn" type="button" disabled>Email</button><button class="article-share-btn" type="button" disabled>Copy link</button></div>
114
- </aside>`;
115
- case 'next_prayer': {
116
- const time = (fx.layout?.worship?.times ?? [])
117
- .find((t) => t.name === 'Asr') ?? { name: 'Asr', congregation: '19:00' };
118
- return `<aside class="next-prayer" data-p60-preview-island="next_prayer">
119
- ${previewNote(name)}
120
- <span class="next-prayer-label">Next</span><strong class="next-prayer-name">${escapeHtml(time.name)}</strong><span class="next-prayer-time">${escapeHtml(time.congregation ?? time.begins ?? '')}</span><span class="next-prayer-countdown">1h 24m</span>
121
- </aside>`;
122
- }
123
- case 'article_comments':
124
- return `<section class="article-comments" data-p60-preview-island="article_comments">
125
- ${previewNote(name)}
126
- <div class="article-comment-list"><article class="article-comment"><header class="article-comment-head"><strong>Margaret H.</strong></header><p>Wonderful to see the garden open at last. See you Saturday.</p></article></div>
127
- <div class="article-comments-gate"><p class="article-comments-note">Sign in to join the conversation.</p></div>
128
- </section>`;
129
- 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
- const images = Array.isArray(ctx.section?.images) ? ctx.section.images.filter((i) => i?.imageUrl) : [];
133
- const slides = images.length > 0
134
- ? 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('')
135
- : '<div class="hero-slide hero-slide--active"><div class="hero-slide-img p60-preview-hero-image"></div><div class="hero-slide-scrim"></div></div>';
136
- const dots = Math.max(images.length, 2);
137
- return `<section class="hero-carousel" data-p60-preview-island="hero_carousel">
138
- ${previewNote(name)}
139
- <div class="hero-slides">${slides}</div>
140
- <div class="hero-dots" aria-hidden="true">${Array.from({ length: dots }, (_, i) => `<span class="hero-dot${i === 0 ? ' hero-dot--active' : ''}"></span>`).join('')}</div>
141
- </section>`;
142
- }
143
- case 'newsletter_signup':
144
- return `<form class="newsletter-signup" data-p60-preview-island="newsletter_signup">
145
- ${previewNote(name)}
146
- <label class="newsletter-label">Email address</label><div class="newsletter-fields"><input class="newsletter-email" type="email" disabled><button class="newsletter-submit" type="button" disabled>Join our newsletter</button></div>
147
- <label class="newsletter-consent"><input type="checkbox" disabled><span>Email me about our work and appeals.</span></label>
148
- </form>`;
149
- 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
- if (fixtures.focus === 'volunteer') {
153
- return islandSkeleton('volunteer_signup', ctx, fx).replace('data-p60-preview-island="volunteer_signup"', 'data-p60-preview-island="primary_action_widget"');
154
- }
155
- if (fixtures.focus === 'none') {
156
- return `<div class="p60-preview-empty" data-p60-preview-island="primary_action_widget">${previewNote(name)}<p>No widget here: this charity leads with buttons only (site.focus is 'none').</p></div>`;
157
- }
158
- return islandSkeleton('donation_widget', ctx, fx).replace('data-p60-preview-island="donation_widget"', 'data-p60-preview-island="primary_action_widget"');
159
- case 'volunteer_signup':
160
- return `<section class="donate-card vol-card" data-p60-preview-island="volunteer_signup">
161
- ${previewNote(name)}
162
- <header class="donate-head"><h2>Volunteer with us</h2><p class="donate-sub">Where would you like to help?</p></header>
163
- <div class="vol-list">
164
- <div class="vol-group"><p class="vol-group-label">General</p><button class="vol-option" type="button" disabled><span class="vol-option-main"><strong>Help out where needed</strong><span class="vol-option-summary">A few hours a month, wherever the team needs a hand.</span></span><span class="vol-option-meta"><span class="vol-needed">Everyone welcome</span></span></button></div>
165
- <div class="vol-group"><p class="vol-group-label">Events</p><button class="vol-option" type="button" disabled><span class="vol-option-main"><strong>Spring fair stewards</strong><span class="vol-option-summary">Welcome visitors and keep the day running.</span></span><span class="vol-option-meta"><span class="vol-needed">6 volunteers needed</span><span class="vol-shift-count">3 shifts</span></span></button></div>
166
- </div>
167
- </section>`;
168
- case 'language_switch':
169
- return `<label class="language-switch" data-p60-preview-island="language_switch">${previewNote(name)}<span class="language-switch-label">Language</span><select class="language-switch-select" disabled><option>English</option><option>Cymraeg</option><option>العربية</option></select></label>`;
170
- case 'search':
171
- return `<div class="site-search" data-p60-preview-island="search">${previewNote(name)}<form class="site-search-form"><label class="site-search-label">Search this site</label><div class="site-search-fields"><input class="site-search-input" type="search" disabled><button class="site-search-submit" type="button" disabled>Search</button></div></form><ul class="site-search-results"><li class="site-search-result"><span class="site-search-kind">Article</span><a class="site-search-link" href="#">The Community Garden Opens Its Gates</a><p class="site-search-summary">Two years of digging and Saturday mornings in the rain: the Foundry Lane garden is open.</p></li></ul></div>`;
172
- 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
- const im = fx.sections?.impactMap?.impactMap ?? { title: 'Impact map', points: [] };
176
- const pts = im.points ?? [];
177
- const lats = pts.map((pt) => pt.latitude);
178
- const lngs = pts.map((pt) => pt.longitude);
179
- const pad = 0.15;
180
- const latMin = Math.min(...lats), latMax = Math.max(...lats);
181
- const lngMin = Math.min(...lngs), lngMax = Math.max(...lngs);
182
- const pos = (pt) => {
183
- const x = lngMax === lngMin ? 50 : (pad + (1 - 2 * pad) * ((pt.longitude - lngMin) / (lngMax - lngMin))) * 100;
184
- const y = latMax === latMin ? 50 : (pad + (1 - 2 * pad) * (1 - (pt.latitude - latMin) / (latMax - latMin))) * 100;
185
- return `left:${x.toFixed(1)}%;top:${y.toFixed(1)}%`;
186
- };
187
- const first = pts[0] ?? null;
188
- return `<section class="impact-map" data-p60-preview-island="map">
189
- ${previewNote(name)}
190
- <div class="impact-map-canvas" style="position:relative;min-height:320px;border-radius:12px;background:linear-gradient(160deg,#22303c,#101820)">
191
- ${pts.map((pt, i) => `<button class="impact-map-pin${i === 0 ? ' impact-map-pin--active' : ''}" type="button" disabled style="position:absolute;${pos(pt)};width:14px;height:14px;border-radius:50%;border:2px solid #fff;background:currentColor" aria-label="${escapeHtml(pt.title)}"></button>`).join('')}
192
- </div>
193
- ${first ? `<aside class="impact-map-card"><span class="impact-map-card-kind">${escapeHtml(first.kind)}</span><h3 class="impact-map-card-title">${escapeHtml(first.title)}</h3><p class="impact-map-card-summary">${escapeHtml(first.summary ?? '')}</p>${first.href ? `<a class="impact-map-card-cta" href="${escapeHtml(first.href)}">See more</a>` : ''}</aside>` : '<p class="impact-map-empty">No published map yet.</p>'}
194
- </section>`;
195
- }
196
- case 'form':
197
- return `<form class="public-form" data-p60-preview-island="form">${previewNote(name)}<header class="public-form-head"><h2>Ask the team</h2><p>Send a question and the team will respond.</p></header><label class="public-form-field"><span>Name</span><input disabled></label><label class="public-form-field"><span>Email address</span><input disabled></label><label class="public-form-field"><span>Your question</span><textarea disabled></textarea></label><label class="public-form-privacy"><input type="checkbox" disabled><span>I agree that the organisation may respond.</span></label><button class="public-form-submit" type="button" disabled>Send</button></form>`;
198
- default:
199
- return `<div class="p60-preview-island" data-p60-preview-island="${escapeHtml(name)}" role="note">${previewNote(name)}</div>`;
200
- }
201
- }
202
-
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
- function surfaceDivider(label) {
211
- return `<div class="p60-preview-divider" role="note">platform page: ${escapeHtml(label)}, styled by your tokens and chrome</div>`;
212
- }
213
-
214
- function eventsListingSkeleton(fx = STUDIO_FX) {
215
- const events = fx.pages?.events?.events ?? [];
216
- return `${surfaceDivider('events')}<section class="section"><div class="container">
217
- <h1>What's on</h1>
218
- <div class="event-grid">${events.map((e) => `
219
- <article class="event-card">
220
- <div class="event-card-body">
221
- <span class="event-card-mode">${escapeHtml(e.registrationMode ?? 'INFO')}</span>
222
- <h3>${escapeHtml(e.name)}</h3>
223
- <p class="event-card-when">${escapeHtml(e.venueName ?? (e.online ? 'Online' : ''))}</p>
224
- <span class="event-action">${e.registrationMode === 'TICKETED' ? 'Get tickets' : 'View event'}</span>
225
- </div>
226
- </article>`).join('')}</div>
227
- </div></section>`;
228
- }
229
-
230
- function eventDetailSkeleton(fx = STUDIO_FX) {
231
- const event = (fx.pages?.events?.events ?? [])
232
- .find((e) => e.registrationMode === 'TICKETED') ?? { name: 'Fixture Gala', venueName: 'Town Hall' };
233
- const tier = (name, sub, price, note) => `
234
- <div class="ticket-type"><div class="tt-main">
235
- <div class="tt-info"><span class="tt-name">${name}</span><span class="tt-sub">${sub}</span>${note ? `<span class="tt-desc">${note}</span>` : ''}</div>
236
- <span class="tt-price">${price}</span>
237
- <div class="tt-qty"><button class="tt-stepper" type="button" disabled>−</button><span>0</span><button class="tt-stepper" type="button" disabled>+</button></div>
238
- </div></div>`;
239
- return `${surfaceDivider('event tickets')}<section class="section"><div class="container">
240
- <article class="event-detail" data-p60-preview-island="event tickets">
241
- ${previewNote('ticket purchase')}
242
- <h1>${escapeHtml(event.name)}</h1>
243
- <p class="event-detail-meta">${escapeHtml(event.venueName ?? 'Online')}</p>
244
- <p class="event-detail-desc">${escapeHtml(event.description ?? 'An evening of music and giving.')}</p>
245
- <div class="ticket-types">
246
- <span class="tt-tier">General admission</span>
247
- ${tier('Adult', 'General', '£15', '')}
248
- ${tier('Family', 'General', '£40', 'Admits 2 adults and 2 children')}
249
- </div>
250
- <div class="ticket-order-summary"><span>Nothing selected yet</span><strong>£0</strong></div>
251
- <button class="donate-submit" type="button" disabled>Buy tickets</button>
252
- </article>
253
- </div></section>`;
254
- }
255
-
256
- function donateSkeleton(fx = STUDIO_FX) {
257
- const causes = fx.sections?.appealGrid?.causes
258
- ?? fx.sections?.emergency?.causes ?? [];
259
- return `${surfaceDivider('donate')}<section class="section"><div class="container">
260
- <h1>Donate</h1>
261
- <div class="cause-options">${causes.slice(0, 2).map((c) => `
262
- <article class="cause-option">
263
- <div class="cause-option-head"><span class="cause-tag">Appeal</span><h3>${escapeHtml(c.title ?? c.name ?? 'Appeal')}</h3></div>
264
- <p class="cause-option-desc">${escapeHtml(c.text ?? c.description ?? '')}</p>
265
- <div class="cause-progress"><div class="cause-progress-track"><div class="cause-progress-fill" style="width: 62%"></div></div><span class="cause-progress-text">62% of target</span></div>
266
- </article>`).join('')}</div>
267
- ${islandSkeleton('donation_widget', {}, fx)}
268
- </div></section>`;
269
- }
270
-
271
- function articlesListingSkeleton(fx = STUDIO_FX) {
272
- const articles = fx.pages?.articles?.articles ?? [];
273
- return `${surfaceDivider('articles')}<section class="section"><div class="container">
274
- <h1>Latest</h1>
275
- <div class="article-grid">${articles.map((a) => `
276
- <article class="article-card"><div class="article-card-body">
277
- <p>${escapeHtml(a.categories?.[0] ?? 'Article')}</p>
278
- <h3><a href="/articles/${escapeHtml(a.slug ?? '')}">${escapeHtml(a.title)}</a></h3>
279
- <p>${escapeHtml(a.excerpt ?? '')}</p>
280
- </div></article>`).join('')}</div>
281
- </div></section>`;
282
- }
283
-
284
- function articleDetailSkeleton(fx = STUDIO_FX) {
285
- const article = fx.pages?.article?.article ?? { title: 'Fixture article', bodyHtml: '<p>Body</p>' };
286
- return `${surfaceDivider('article')}<section class="section"><div class="container">
287
- <article class="article-body">
288
- <h1>${escapeHtml(article.title)}</h1>
289
- <p class="article-card-meta">${escapeHtml(article.authorName ?? '')} · ${article.readingMinutes ?? 3} min read</p>
290
- ${article.bodyHtml ?? ''}
291
- ${islandSkeleton('article_engagement', {}, fx)}
292
- ${islandSkeleton('article_comments', {}, fx)}
293
- </article>
294
- </div></section>`;
295
- }
296
-
297
- function campaignsListingSkeleton(fx = STUDIO_FX) {
298
- const campaigns = fx.sections?.campaigns?.campaigns ?? [];
299
- return `${surfaceDivider('campaigns')}<section class="section"><div class="container">
300
- <h1>${escapeHtml(fx.sections?.campaigns?.campaignsLabel ?? 'Campaigns')}</h1>
301
- <div class="article-grid">${campaigns.map((c) => `
302
- <article class="article-card"><div class="article-card-body">
303
- <h3><a href="/campaigns/${escapeHtml(c.href?.split('/').pop() ?? '')}">${escapeHtml(c.title)}</a></h3>
304
- ${c.summary ? `<p>${escapeHtml(c.summary)}</p>` : ''}
305
- </div></article>`).join('')}</div>
306
- </div></section>`;
307
- }
308
-
309
- function campaignDetailSkeleton(fx = STUDIO_FX) {
310
- const campaign = (fx.sections?.campaigns?.campaigns ?? [])[0]
311
- ?? { title: 'Fixture Winter Campaign', summary: '' };
312
- return `${surfaceDivider('campaign')}<section class="section"><div class="container">
313
- <h1>${escapeHtml(campaign.title)}</h1>
314
- ${campaign.summary ? `<p>${escapeHtml(campaign.summary)}</p>` : ''}
315
- ${islandSkeleton('form', {}, fx)}
316
- </div></section>`;
317
- }
318
-
319
- function servicesListingSkeleton(fx = STUDIO_FX) {
320
- const services = fx.sections?.programmes?.services ?? [];
321
- return `${surfaceDivider('services')}<section class="section"><div class="container">
322
- <h1>What we do</h1>
323
- <div class="article-grid">${services.map((s) => `
324
- <article class="article-card"><div class="article-card-body">
325
- <h3><a href="${escapeHtml(s.href ?? '#')}">${escapeHtml(s.title)}</a></h3>
326
- ${s.summary ? `<p>${escapeHtml(s.summary)}</p>` : ''}
327
- </div></article>`).join('')}</div>
328
- </div></section>`;
329
- }
330
-
331
- function serviceDetailSkeleton(fx = STUDIO_FX) {
332
- const service = (fx.sections?.programmes?.services ?? [])[0]
333
- ?? { title: 'Fixture service', summary: '' };
334
- return `${surfaceDivider('service page')}<section class="section"><div class="container">
335
- <article class="article-body">
336
- <h1>${escapeHtml(service.title)}</h1>
337
- ${service.summary ? `<p>${escapeHtml(service.summary)}</p>` : ''}
338
- <p>Content pages are written in the workspace's WYSIWYG editor and arrive as sanitised
339
- HTML, headings, lists, images and embeds render here styled by your theme's typography.</p>
340
- </article>
341
- </div></section>`;
342
- }
343
-
344
- function courseListingSkeleton(fx = STUDIO_FX) {
345
- const course = fx.pages?.course?.course ?? { title: 'Fixture course' };
346
- return `${surfaceDivider('courses')}<section class="section"><div class="container">
347
- <h1>Courses</h1>
348
- <article class="event-card"><div class="event-card-body">
349
- <h3>${escapeHtml(course.title)}</h3>
350
- <p class="event-card-when">${escapeHtml(course.venue ?? '')}</p>
351
- <span class="event-action">View course</span>
352
- </div></article>
353
- </div></section>`;
354
- }
355
-
356
- /** The platform's own About body, for a template that does not declare the about page: the
357
- * composition's sections listed as skeletons, the way the platform renders them itself live. */
358
- function aboutSkeleton(fx, composition = []) {
359
- const rows = composition.map((s) => `<article class="event-card"><div class="event-card-body"><h3>${escapeHtml(s.type)}</h3><p class="event-card-when">rendered by the platform</p></div></article>`).join('');
360
- return `${surfaceDivider('about')}<section class="section"><div class="container"><h1>About us</h1>${rows}</div></section>`;
361
- }
362
-
363
- // surface → { pageTemplate to prefer when the theme declares it, builtin skeleton }
364
- const SURFACES = {
365
- events: { template: 'events', builtin: eventsListingSkeleton },
366
- event: { template: null, builtin: eventDetailSkeleton },
367
- services: { template: null, builtin: servicesListingSkeleton },
368
- service: { template: null, builtin: serviceDetailSkeleton },
369
- donate: { template: null, builtin: donateSkeleton },
370
- articles: { template: 'articles', builtin: articlesListingSkeleton },
371
- article: { template: 'article', builtin: articleDetailSkeleton },
372
- campaigns: { template: null, builtin: campaignsListingSkeleton },
373
- campaign: { template: null, builtin: campaignDetailSkeleton },
374
- course: { template: 'course', builtin: courseListingSkeleton },
375
- };
376
-
377
- /** The routed dev preview's surface names (plus 'home'). */
378
- export const PREVIEW_SURFACES = ['home', 'about', ...Object.keys(SURFACES)];
379
-
380
- function surfaceBar(active, focus = 'donate', looks = [], activeLook = null) {
381
- const withFocus = (href) => (focus === 'donate' ? href : `${href}${href.includes('?') ? '&' : '?'}focus=${focus}`);
382
- const link = (name, href) =>
383
- `<a href="${withFocus(href)}"${name === active ? ' style="font-weight:700;text-decoration:underline"' : ''}>${name}</a>`;
384
- const links = [
385
- link('home', '/'), link('about', '/about'), link('events', '/events'), link('event', '/events?event=fixture'),
386
- link('services', '/services'), link('service', '/services?service=fixture'),
387
- link('donate', '/donate'), link('articles', '/articles'), link('article', '/articles/fixture'),
388
- link('campaigns', '/campaigns'), link('campaign', '/campaigns/fixture'), link('course', '/courses'),
389
- `<a href="${withFocus('/model')}" style="margin-left:auto;font-weight:700">site.content model →</a>`,
390
- ];
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
- const focusLink = (value, label) =>
394
- `<a href="${value === 'donate' ? '/' : `/?focus=${value}`}"${value === focus ? ' style="font-weight:700;text-decoration:underline"' : ''}>${label}</a>`;
395
- 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
- const lookLink = (name, href) =>
399
- `<a href="${href}"${name === activeLook ? ' style="font-weight:700;text-decoration:underline"' : ''}>${escapeHtml(name)}</a>`;
400
- const lookLinks = looks.length
401
- ? [lookLink(null, '/').replace('>null<', '>defaults<'), ...looks.map((l) => lookLink(l.name, `/?look=${encodeURIComponent(l.name)}`))]
402
- : [];
403
- return `<nav class="p60-preview-surfaces" aria-label="Preview surfaces" style="position:sticky;top:0;z-index:99;display:flex;gap:12px;flex-wrap:wrap;padding:8px 14px;font:12px/1.4 system-ui,sans-serif;background:#0b1220;color:#e6e9f2;opacity:.94">
404
- <strong style="letter-spacing:.06em;text-transform:uppercase;font-size:10px">Surfaces</strong>${links.join('')}
405
- <span style="flex-basis:100%;height:0"></span>
406
- <strong style="letter-spacing:.06em;text-transform:uppercase;font-size:10px">Leads with</strong>${focusLinks.join('')}
407
- <span style="opacity:.7">the hero widget becomes the donation widget, the volunteer sign-up, or nothing; the buttons follow</span>${lookLinks.length ? `
408
- <span style="flex-basis:100%;height:0"></span>
409
- <strong style="letter-spacing:.06em;text-transform:uppercase;font-size:10px">Looks</strong>${lookLinks.join('')}
410
- <span style="opacity:.7">?look=name, or ?p60s-key=value for one knob</span>` : ''}
411
- </nav>`;
412
- }
413
-
414
- function partsToHtml(html, contentHtml, ctx = {}, fx = STUDIO_FX) {
415
- let out = '';
416
- for (const part of splitIslandParts(html)) {
417
- if (part.island === CONTENT_SLOT) out += contentHtml ?? '';
418
- else if (part.island) out += islandSkeleton(part.island, ctx, fx);
419
- else out += part.html;
420
- }
421
- return out;
422
- }
423
-
424
- /**
425
- * Knob values → the same body attributes / CSS vars TemplateHost stamps, with the same guards: an
426
- * override (a Look, or ?p60s-<key>=) wins over the default; a select value that is not one of the
427
- * knob's options falls back to the default; a font must be a catalogue family. Font slots come
428
- * back too, so the dev preview can load the one stylesheet production would (the studio stays
429
- * sealed and renders the fallback stacks).
430
- */
431
- function knobValues(manifest, overrides = {}) {
432
- const attrs = [];
433
- const vars = [];
434
- const fontSlots = [];
435
- for (const knob of manifest?.settings?.schema ?? []) {
436
- const raw = overrides[knob.key] ?? knob.default;
437
- if (raw == null || raw === '') continue;
438
- const value = String(raw);
439
- if (knob.kind === 'color') {
440
- if (/^#[0-9a-fA-F]{3,8}$/.test(value)) vars.push(`--p60s-${knob.key}: ${value};`);
441
- } else if (knob.kind === 'font') {
442
- const family = knownFamily(value) ?? knownFamily(knob.default);
443
- if (family) {
444
- fontSlots.push({ family, weights: knob.weights ?? [] });
445
- vars.push(`--p60s-${knob.key}: ${fontStackFor(family)};`);
446
- }
447
- } else {
448
- const options = Array.isArray(knob.options) ? knob.options : null;
449
- const chosen = options && !options.includes(value) ? knob.default : raw;
450
- if (chosen == null || chosen === '') continue;
451
- attrs.push(`data-p60s-${knob.key}="${escapeHtml(String(chosen))}"`);
452
- }
453
- }
454
- return { attrs: attrs.join(' '), vars: vars.join(' '), fontSlots };
455
- }
456
-
457
- /**
458
- * Renders the artifact's declared sections (sample fixtures) inside its layout (when declared)
459
- * and returns a complete, self-contained HTML document. Throws on parse/render failure, callers
460
- * preview only versions the validator has already passed, so a throw here is a bug report, not
461
- * a user flow.
462
- */
463
- export async function renderStudioPreview(files, options = {}) {
5
+ /** Explicit old artifacts keep their own fixture projection. New authoring uses v2. */
6
+ export function renderStudioPreview(files, options = {}) {
464
7
  const manifest = JSON.parse(files['manifest.json']);
465
- const allIslands = new Set(islandRegistry.islands.map((i) => i.name));
466
- const liquid = new Liquid({ outputEscape: 'escape', strictFilters: true, ...LIQUID_BUDGETS });
467
- configureDialect(liquid, dialect, allIslands);
468
-
469
- 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
- const artOptions = options.fixtureImageBase ? { imageBase: options.fixtureImageBase } : null;
473
- 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
- const focus = normaliseFocus(options.focus);
477
- const actions = previewActions(focus);
478
- 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
- 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
- const pageOf = (page) => pageComposition(manifest, page, options.previewContent);
487
- const treePage = PAGE_KEYS.includes(surface) ? surface : 'home';
488
- const baseSite = buildSiteFixture(manifest, { page: treePage });
489
- const site = applyFocus(resolveFixtureArt(
490
- applyPreviewContent({ ...baseSite, content: { ...baseSite.content, about: pageOf(treePage) } }, options.previewContent ?? null),
491
- artOptions ?? {}), actions);
492
- 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
- if (options.previewContent) {
497
- const c = site.content;
498
- fx = {
499
- ...fx,
500
- brand: site.brand,
501
- pages: {
502
- ...fx.pages,
503
- events: { events: c.events },
504
- articles: { ...fx.pages?.articles, articles: c.articles },
505
- article: c.articles?.length
506
- ? { ...fx.pages?.article, article: { ...fx.pages?.article?.article, ...c.articles[0] } }
507
- : fx.pages?.article
508
- },
509
- sections: {
510
- ...fx.sections,
511
- events: { events: (c.events ?? []).filter((e) => e.registrationMode === 'TICKETED') },
512
- whatsOn: { infoEvents: (c.events ?? []).filter((e) => e.registrationMode === 'INFO') },
513
- articles: { latestArticles: c.articles ?? [] },
514
- campaigns: { ...fx.sections?.campaigns, campaigns: c.campaigns ?? [] },
515
- appealGrid: { causes: c.causes ?? [] },
516
- emergency: { causes: c.causes ?? [] },
517
- programmes: { services: c.services ?? [] },
518
- resources: { resources: c.resources ?? [] },
519
- locations: { locations: c.locations ?? [] }
520
- }
521
- };
522
- }
523
-
524
- let contentHtml;
525
- 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
- const def = SURFACES[surface];
529
- const templateSource = def.template ? files[`pages/${def.template}.liquid`] : null;
530
- const fixture = def.template ? fx.pages?.[def.template] : null;
531
- if (templateSource != null && fixture != null
532
- && (manifest?.supports?.pageTemplates ?? []).includes(def.template)) {
533
- const context = { brand, site, ...fixture };
534
- const rendered = await liquid.parseAndRender(templateSource, context);
535
- contentHtml = `<div class="p60-preview-divider" role="note">page template: ${escapeHtml(def.template)}</div>`
536
- + partsToHtml(rendered, '', context, fx);
537
- } else {
538
- contentHtml = def.builtin(fx);
539
- }
540
- } 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
- contentHtml = aboutSkeleton(fx, pageOf('about'));
544
- } 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
- const renderComposition = async (composition) => {
549
- const out = [];
550
- for (const { type, content } of composition) {
551
- const entry = catalogueByType.get(type);
552
- const source = files[`sections/${type}.liquid`];
553
- if (!entry || source == null) continue;
554
- const base = artOptions ? resolveFixtureArt(content ?? {}, artOptions) : resolveFixtureArt(content ?? {});
555
- 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
- section: withResolvedIcons(type === 'homeHero' ? withResolvedActions({ ...base, givingStyle: undefined, actionStyle: undefined }, actions) : base),
560
- brand,
561
- site,
562
- ...(fx.sections?.[type] ?? {})
563
- };
564
- 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
- out.push(partsToHtml(rendered, '', context, fx));
568
- }
569
- return out;
570
- };
571
- const sectionsHtml = [];
572
- if (surface === 'about') {
573
- sectionsHtml.push(...(await renderComposition(pageOf('about'))));
574
- } else {
575
- 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
- if (!options.surface && (manifest?.supports?.pages ?? []).includes('about')) {
579
- const about = await renderComposition(pageOf('about'));
580
- if (about.length) sectionsHtml.push(`<div class="p60-preview-divider" role="note">page: about</div>`, ...about);
581
- }
582
- }
583
-
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
- for (const page of surface === 'about' ? [] : (manifest?.supports?.pageTemplates ?? [])) {
588
- const source = files[`pages/${page}.liquid`];
589
- const fixture = fx.pages?.[page];
590
- if (source == null || fixture == null) continue;
591
- const context = { brand, site, ...fixture };
592
- const rendered = await liquid.parseAndRender(source, context);
593
- sectionsHtml.push(`<div class="p60-preview-divider" role="note">page template: ${escapeHtml(page)}</div>`
594
- + partsToHtml(rendered, '', context, fx));
595
- }
596
- contentHtml = sectionsHtml.join('\n');
597
- }
598
-
599
- let bodyHtml;
600
- if (manifest?.supports?.layout && files['layout.liquid'] != null) {
601
- const rendered = await liquid.parseAndRender(files['layout.liquid'], {
602
- brand,
603
- site,
604
- nav: options.previewContent?.nav ? site.nav : fx.layout.nav,
605
- socials: fx.layout.socials ?? [],
606
- worship: manifest?.supports?.worship ? (fx.layout.worship ?? null) : null,
607
- locale: fx.layout.locale
608
- });
609
- bodyHtml = partsToHtml(rendered, contentHtml, {}, fx);
610
- } else {
611
- bodyHtml = contentHtml;
612
- }
613
-
614
- const themeCss = files['assets/theme.css'] ?? '';
615
- 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
- const webfonts = options.webfonts
620
- ? [...(manifest?.fonts ?? []).map(toProvider), ...(fontCssHref(fontSlots) ? [fontCssHref(fontSlots)] : [])]
621
- : [];
622
- const fontLinks = webfonts.length
623
- ? `<link rel="preconnect" href="${FONT_PROVIDER_ORIGIN}" crossorigin>\n${webfonts.map((href) => `<link rel="stylesheet" href="${escapeHtml(href)}">`).join('\n')}`
624
- : '';
625
-
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
- 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
- const imgOrigins = new Set();
635
- if (options.fixtureImageBase) {
636
- try {
637
- imgOrigins.add(new URL(options.fixtureImageBase).origin);
638
- } catch {
639
- // A relative or malformed base stays sealed rather than guessing an origin.
640
- }
641
- }
642
- // The author's own imagery (a --content override) renders from exactly the hosts it names.
643
- for (const origin of options.contentImageOrigins ?? []) {
644
- imgOrigins.add(origin);
645
- }
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
- if (options.localImages) imgOrigins.add("'self'");
649
- const imgSrc = ['data:', ...imgOrigins].join(' ');
650
- const styleSrc = webfonts.length ? `'unsafe-inline' ${FONT_PROVIDER_ORIGIN}` : "'unsafe-inline'";
651
- const fontSrc = webfonts.length ? ` font-src ${FONT_PROVIDER_ORIGIN};` : '';
652
- const csp = runtime
653
- ? `default-src 'none'; style-src ${styleSrc}; img-src ${imgSrc};${fontSrc} script-src 'unsafe-inline';`
654
- : `default-src 'none'; style-src ${styleSrc}; img-src ${imgSrc};${fontSrc}`;
655
- const motionApproximation = runtime ? '' : `
656
- @media (prefers-reduced-motion: no-preference) {
657
- [data-p60-carousel] > [data-p60-slide], .p60-preview-slide { animation: p60-preview-crossfade 8s infinite; }
658
- [data-p60-carousel] > [data-p60-slide]:nth-child(2), .p60-preview-slide:nth-child(2) { animation-delay: -4s; }
659
- @keyframes p60-preview-crossfade { 0%, 45% { opacity: 1; } 55%, 95% { opacity: 0.25; } 100% { opacity: 1; } }
660
- }`;
661
-
662
- return `<!doctype html>
663
- <html lang="en">
664
- <head>
665
- <meta charset="utf-8">
666
- <!-- Network-dead by design: the template's CSS can style, never fetch. -->
667
- <meta http-equiv="Content-Security-Policy" content="${csp}">
668
- <meta name="viewport" content="width=device-width, initial-scale=1">
669
- ${fontLinks}
670
- <title>${escapeHtml(manifest?.label ?? manifest?.name ?? 'Template preview')}, studio preview</title>
671
- <style>${vars ? `:root { ${vars} }` : ''}
672
- .p60-preview-badge { display: inline-flex; align-items: center; width: fit-content; margin: 0 0 8px;
673
- border: 1px solid currentColor; border-radius: 999px; padding: 3px 8px; font: 600 10px/1.2 system-ui, sans-serif;
674
- letter-spacing: .06em; opacity: .62; text-transform: uppercase; }
675
- .p60-preview-island { border: 1px solid currentColor; border-radius: 8px; padding: 18px; margin: 8px 0;
676
- font: 13px/1.4 system-ui, sans-serif; opacity: .8; text-align: center; }
677
- .p60-preview-hero-image { min-height: 360px; background: linear-gradient(135deg, #596f65, #bdab7c); }
678
- .p60-preview-divider { margin: 32px 0 8px; padding: 6px 10px; border: 1px dashed currentColor; border-radius: 6px;
679
- font: 600 11px/1.2 system-ui, sans-serif; letter-spacing: .06em; opacity: .62; text-transform: uppercase; }
680
- [data-p60-preview-island] button:disabled, [data-p60-preview-island] input:disabled { opacity: 1; }${motionApproximation}
681
- </style>
682
- <style>${PLATFORM_BASE}</style>
683
- <style>${themeCss}</style>
684
- </head>
685
- <body ${attrs}>
686
- ${options.surface ? surfaceBar(surface, focus, manifest?.looks ?? [], options.look ?? null) : ''}
687
- ${bodyHtml}
688
- ${runtime ? `<script>${runtime}\np60Behaviors.initBehaviors();</script>` : ''}
689
- </body>
690
- </html>`;
8
+ if (manifest.format === 'port60-liquid@2') return renderV2(files, options);
9
+ if (!manifest.format || manifest.format === 'port60-liquid@1') return renderV1(files, options);
10
+ throw new Error(`Unsupported template format '${manifest.format}' in preview.`);
691
11
  }