@port60/template-kit 0.9.0 → 0.10.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 +10 -3
- package/bin/cli.mjs +26 -4
- package/package.json +1 -1
- package/src/commands/content.mjs +42 -0
- package/src/commands/create.mjs +6 -6
- package/src/commands/dev.mjs +97 -9
- package/src/commands/model.mjs +34 -0
- package/src/commands/packageCmd.mjs +3 -3
- package/src/commands/refresh.mjs +48 -0
- package/src/commands/upgrade.mjs +44 -0
- package/src/commands/validate.mjs +2 -3
- package/src/lib/agentsMd.mjs +47 -19
- package/src/vendor/contract/v1/behaviours.json +4 -4
- package/src/vendor/contract/v1/content-bounds.json +3 -3
- package/src/vendor/contract/v1/content-model.json +193 -0
- package/src/vendor/contract/v1/context.json +1667 -275
- package/src/vendor/contract/v1/dialect.json +3 -3
- package/src/vendor/contract/v1/fonts.json +1 -1
- package/src/vendor/contract/v1/imagery.json +4 -4
- package/src/vendor/contract/v1/islands.json +256 -235
- package/src/vendor/contract/v1/layout.json +4 -4
- package/src/vendor/contract/v1/manifest.schema.json +9 -9
- package/src/vendor/contract/v1/sections.json +248 -92
- package/src/vendor/contract/v1/tokens.json +1 -1
- package/src/vendor/contract/v1.lock.json +1 -1
- package/src/vendor/engine/content-footprint.mjs +79 -0
- package/src/vendor/validator/fixture-art.mjs +109 -0
- package/src/vendor/validator/model-reference.mjs +92 -0
- package/src/vendor/validator/platform-base.css +21 -0
- package/src/vendor/validator/preview.mjs +149 -56
- package/src/vendor/validator/site-context.mjs +97 -0
- package/src/vendor/validator/validate.mjs +71 -14
- package/starter/assets/theme.css +10 -10
- package/starter/layout.liquid +15 -16
- package/starter/manifest.json +2 -2
- package/starter/sections/campaigns.liquid +5 -5
- package/starter/sections/homeHero.liquid +11 -11
- package/starter/sections/people.liquid +1 -1
- package/starter/sections/values.liquid +1 -1
|
@@ -15,6 +15,13 @@ import dialect from '../contract/v1/dialect.json' with { type: 'json' };
|
|
|
15
15
|
import sectionCatalogue from '../contract/v1/sections.json' with { type: 'json' };
|
|
16
16
|
import islandRegistry from '../contract/v1/islands.json' with { type: 'json' };
|
|
17
17
|
import contextContract from '../contract/v1/context.json' with { type: 'json' };
|
|
18
|
+
import { resolveFixtureArt } from './fixture-art.mjs';
|
|
19
|
+
import { buildSiteFixture, applyPreviewContent } from './site-context.mjs';
|
|
20
|
+
|
|
21
|
+
// Fixture imagery resolved for the SEALED studio render (p60fixture: refs become inline-SVG data
|
|
22
|
+
// URIs the network-dead CSP can show). The dev preview may instead resolve them to the platform
|
|
23
|
+
// CDN via options.fixtureImageBase — the dev-richer / studio-sealed split.
|
|
24
|
+
const STUDIO_FX = resolveFixtureArt(contextContract.fixtures);
|
|
18
25
|
|
|
19
26
|
const escapeHtml = (s) =>
|
|
20
27
|
String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
@@ -35,7 +42,7 @@ function previewNote(name) {
|
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
function eventCards(events) {
|
|
38
|
-
return events.slice(0,
|
|
45
|
+
return events.slice(0, 3).map((event) => `
|
|
39
46
|
<article class="carousel-slide">
|
|
40
47
|
<div class="carousel-caption">
|
|
41
48
|
<p>${escapeHtml(event.venueName ?? (event.online ? 'Online' : 'Event'))}</p>
|
|
@@ -45,8 +52,8 @@ function eventCards(events) {
|
|
|
45
52
|
</article>`).join('');
|
|
46
53
|
}
|
|
47
54
|
|
|
48
|
-
function islandSkeleton(name, ctx = {}) {
|
|
49
|
-
const fixtures =
|
|
55
|
+
function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
|
|
56
|
+
const fixtures = fx;
|
|
50
57
|
const events = fixtures.sections?.events?.events ?? fixtures.pages?.events?.events ?? [];
|
|
51
58
|
const infoEvents = fixtures.sections?.whatsOn?.infoEvents ?? [];
|
|
52
59
|
const articles = fixtures.sections?.articles?.latestArticles ?? [];
|
|
@@ -69,12 +76,14 @@ function islandSkeleton(name, ctx = {}) {
|
|
|
69
76
|
<button class="nav-p60-signin" type="button" disabled><span class="p60-mark" aria-hidden="true">P</span> Sign in</button>
|
|
70
77
|
${previewNote(name)}
|
|
71
78
|
</div>`;
|
|
72
|
-
case 'events_carousel':
|
|
79
|
+
case 'events_carousel': {
|
|
80
|
+
const dots = Math.max(Math.min(events.length, 3), 2);
|
|
73
81
|
return `<section class="events-carousel" data-p60-preview-island="events_carousel">
|
|
74
82
|
${previewNote(name)}
|
|
75
83
|
${eventCards(events)}
|
|
76
|
-
<div class="carousel-dots" aria-hidden="true"
|
|
84
|
+
<div class="carousel-dots" aria-hidden="true">${Array.from({ length: dots }, () => '<span class="carousel-dot"></span>').join('')}</div>
|
|
77
85
|
</section>`;
|
|
86
|
+
}
|
|
78
87
|
case 'whats_on_strip':
|
|
79
88
|
return `<section class="info-events-bar" data-p60-preview-island="whats_on_strip">
|
|
80
89
|
${previewNote(name)}
|
|
@@ -101,7 +110,7 @@ function islandSkeleton(name, ctx = {}) {
|
|
|
101
110
|
<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>
|
|
102
111
|
</aside>`;
|
|
103
112
|
case 'next_prayer': {
|
|
104
|
-
const time = (
|
|
113
|
+
const time = (fx.layout?.worship?.times ?? [])
|
|
105
114
|
.find((t) => t.name === 'Asr') ?? { name: 'Asr', congregation: '19:00' };
|
|
106
115
|
return `<aside class="next-prayer" data-p60-preview-island="next_prayer">
|
|
107
116
|
${previewNote(name)}
|
|
@@ -111,7 +120,7 @@ function islandSkeleton(name, ctx = {}) {
|
|
|
111
120
|
case 'article_comments':
|
|
112
121
|
return `<section class="article-comments" data-p60-preview-island="article_comments">
|
|
113
122
|
${previewNote(name)}
|
|
114
|
-
<div class="article-comment-list"><article class="article-comment"><header class="article-comment-head"><strong>
|
|
123
|
+
<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>
|
|
115
124
|
<div class="article-comments-gate"><p class="article-comments-note">Sign in to join the conversation.</p></div>
|
|
116
125
|
</section>`;
|
|
117
126
|
case 'hero_carousel': {
|
|
@@ -137,13 +146,37 @@ function islandSkeleton(name, ctx = {}) {
|
|
|
137
146
|
case 'language_switch':
|
|
138
147
|
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>`;
|
|
139
148
|
case 'search':
|
|
140
|
-
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="#">
|
|
149
|
+
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>`;
|
|
141
150
|
case 'volunteer_signup': {
|
|
142
|
-
const opportunities =
|
|
143
|
-
const options = (opportunities.length ? opportunities : [{ title: '
|
|
151
|
+
const opportunities = fx.sections?.volunteering?.opportunities ?? [];
|
|
152
|
+
const options = (opportunities.length ? opportunities : [{ title: 'Garden Volunteer' }])
|
|
144
153
|
.map((o) => `<option>${escapeHtml(o.title)}</option>`).join('');
|
|
145
154
|
return `<form class="volunteer-signup" data-p60-preview-island="volunteer_signup">${previewNote(name)}<label class="volunteer-field"><span>Opportunity</span><select disabled>${options}</select></label><label class="volunteer-field"><span>Name</span><input disabled></label><label class="volunteer-field"><span>Email address</span><input disabled></label><label class="volunteer-privacy"><input type="checkbox" disabled><span>I agree that the organisation may respond.</span></label><button class="volunteer-submit" type="button" disabled>Register my interest</button></form>`;
|
|
146
155
|
}
|
|
156
|
+
case 'map': {
|
|
157
|
+
// The impact-map skeleton: the fixture's points projected onto a token-themed canvas —
|
|
158
|
+
// the same fallback rendering production uses until the platform tile layer is configured.
|
|
159
|
+
const im = fx.sections?.impactMap?.impactMap ?? { title: 'Impact map', points: [] };
|
|
160
|
+
const pts = im.points ?? [];
|
|
161
|
+
const lats = pts.map((pt) => pt.latitude);
|
|
162
|
+
const lngs = pts.map((pt) => pt.longitude);
|
|
163
|
+
const pad = 0.15;
|
|
164
|
+
const latMin = Math.min(...lats), latMax = Math.max(...lats);
|
|
165
|
+
const lngMin = Math.min(...lngs), lngMax = Math.max(...lngs);
|
|
166
|
+
const pos = (pt) => {
|
|
167
|
+
const x = lngMax === lngMin ? 50 : (pad + (1 - 2 * pad) * ((pt.longitude - lngMin) / (lngMax - lngMin))) * 100;
|
|
168
|
+
const y = latMax === latMin ? 50 : (pad + (1 - 2 * pad) * (1 - (pt.latitude - latMin) / (latMax - latMin))) * 100;
|
|
169
|
+
return `left:${x.toFixed(1)}%;top:${y.toFixed(1)}%`;
|
|
170
|
+
};
|
|
171
|
+
const first = pts[0] ?? null;
|
|
172
|
+
return `<section class="impact-map" data-p60-preview-island="map">
|
|
173
|
+
${previewNote(name)}
|
|
174
|
+
<div class="impact-map-canvas" style="position:relative;min-height:320px;border-radius:12px;background:linear-gradient(160deg,#22303c,#101820)">
|
|
175
|
+
${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('')}
|
|
176
|
+
</div>
|
|
177
|
+
${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>'}
|
|
178
|
+
</section>`;
|
|
179
|
+
}
|
|
147
180
|
case 'form':
|
|
148
181
|
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>`;
|
|
149
182
|
default:
|
|
@@ -162,8 +195,8 @@ function surfaceDivider(label) {
|
|
|
162
195
|
return `<div class="p60-preview-divider" role="note">platform page: ${escapeHtml(label)} — styled by your tokens and chrome</div>`;
|
|
163
196
|
}
|
|
164
197
|
|
|
165
|
-
function eventsListingSkeleton() {
|
|
166
|
-
const events =
|
|
198
|
+
function eventsListingSkeleton(fx = STUDIO_FX) {
|
|
199
|
+
const events = fx.pages?.events?.events ?? [];
|
|
167
200
|
return `${surfaceDivider('events')}<section class="section"><div class="container">
|
|
168
201
|
<h1>What's on</h1>
|
|
169
202
|
<div class="event-grid">${events.map((e) => `
|
|
@@ -178,8 +211,8 @@ function eventsListingSkeleton() {
|
|
|
178
211
|
</div></section>`;
|
|
179
212
|
}
|
|
180
213
|
|
|
181
|
-
function eventDetailSkeleton() {
|
|
182
|
-
const event = (
|
|
214
|
+
function eventDetailSkeleton(fx = STUDIO_FX) {
|
|
215
|
+
const event = (fx.pages?.events?.events ?? [])
|
|
183
216
|
.find((e) => e.registrationMode === 'TICKETED') ?? { name: 'Fixture Gala', venueName: 'Town Hall' };
|
|
184
217
|
const tier = (name, sub, price, note) => `
|
|
185
218
|
<div class="ticket-type"><div class="tt-main">
|
|
@@ -204,9 +237,9 @@ function eventDetailSkeleton() {
|
|
|
204
237
|
</div></section>`;
|
|
205
238
|
}
|
|
206
239
|
|
|
207
|
-
function donateSkeleton() {
|
|
208
|
-
const causes =
|
|
209
|
-
??
|
|
240
|
+
function donateSkeleton(fx = STUDIO_FX) {
|
|
241
|
+
const causes = fx.sections?.appealGrid?.causes
|
|
242
|
+
?? fx.sections?.emergency?.causes ?? [];
|
|
210
243
|
return `${surfaceDivider('donate')}<section class="section"><div class="container">
|
|
211
244
|
<h1>Donate</h1>
|
|
212
245
|
<div class="cause-options">${causes.slice(0, 2).map((c) => `
|
|
@@ -215,12 +248,12 @@ function donateSkeleton() {
|
|
|
215
248
|
<p class="cause-option-desc">${escapeHtml(c.text ?? c.description ?? '')}</p>
|
|
216
249
|
<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>
|
|
217
250
|
</article>`).join('')}</div>
|
|
218
|
-
${islandSkeleton('donation_widget')}
|
|
251
|
+
${islandSkeleton('donation_widget', {}, fx)}
|
|
219
252
|
</div></section>`;
|
|
220
253
|
}
|
|
221
254
|
|
|
222
|
-
function articlesListingSkeleton() {
|
|
223
|
-
const articles =
|
|
255
|
+
function articlesListingSkeleton(fx = STUDIO_FX) {
|
|
256
|
+
const articles = fx.pages?.articles?.articles ?? [];
|
|
224
257
|
return `${surfaceDivider('articles')}<section class="section"><div class="container">
|
|
225
258
|
<h1>Latest</h1>
|
|
226
259
|
<div class="article-grid">${articles.map((a) => `
|
|
@@ -232,23 +265,23 @@ function articlesListingSkeleton() {
|
|
|
232
265
|
</div></section>`;
|
|
233
266
|
}
|
|
234
267
|
|
|
235
|
-
function articleDetailSkeleton() {
|
|
236
|
-
const article =
|
|
268
|
+
function articleDetailSkeleton(fx = STUDIO_FX) {
|
|
269
|
+
const article = fx.pages?.article?.article ?? { title: 'Fixture article', bodyHtml: '<p>Body</p>' };
|
|
237
270
|
return `${surfaceDivider('article')}<section class="section"><div class="container">
|
|
238
271
|
<article class="article-body">
|
|
239
272
|
<h1>${escapeHtml(article.title)}</h1>
|
|
240
273
|
<p class="article-card-meta">${escapeHtml(article.authorName ?? '')} · ${article.readingMinutes ?? 3} min read</p>
|
|
241
274
|
${article.bodyHtml ?? ''}
|
|
242
|
-
${islandSkeleton('article_engagement')}
|
|
243
|
-
${islandSkeleton('article_comments')}
|
|
275
|
+
${islandSkeleton('article_engagement', {}, fx)}
|
|
276
|
+
${islandSkeleton('article_comments', {}, fx)}
|
|
244
277
|
</article>
|
|
245
278
|
</div></section>`;
|
|
246
279
|
}
|
|
247
280
|
|
|
248
|
-
function campaignsListingSkeleton() {
|
|
249
|
-
const campaigns =
|
|
281
|
+
function campaignsListingSkeleton(fx = STUDIO_FX) {
|
|
282
|
+
const campaigns = fx.sections?.campaigns?.campaigns ?? [];
|
|
250
283
|
return `${surfaceDivider('campaigns')}<section class="section"><div class="container">
|
|
251
|
-
<h1>${escapeHtml(
|
|
284
|
+
<h1>${escapeHtml(fx.sections?.campaigns?.campaignsLabel ?? 'Campaigns')}</h1>
|
|
252
285
|
<div class="article-grid">${campaigns.map((c) => `
|
|
253
286
|
<article class="article-card"><div class="article-card-body">
|
|
254
287
|
<h3><a href="/campaigns/${escapeHtml(c.href?.split('/').pop() ?? '')}">${escapeHtml(c.title)}</a></h3>
|
|
@@ -257,18 +290,18 @@ function campaignsListingSkeleton() {
|
|
|
257
290
|
</div></section>`;
|
|
258
291
|
}
|
|
259
292
|
|
|
260
|
-
function campaignDetailSkeleton() {
|
|
261
|
-
const campaign = (
|
|
293
|
+
function campaignDetailSkeleton(fx = STUDIO_FX) {
|
|
294
|
+
const campaign = (fx.sections?.campaigns?.campaigns ?? [])[0]
|
|
262
295
|
?? { title: 'Fixture Winter Campaign', summary: '' };
|
|
263
296
|
return `${surfaceDivider('campaign')}<section class="section"><div class="container">
|
|
264
297
|
<h1>${escapeHtml(campaign.title)}</h1>
|
|
265
298
|
${campaign.summary ? `<p>${escapeHtml(campaign.summary)}</p>` : ''}
|
|
266
|
-
${islandSkeleton('form')}
|
|
299
|
+
${islandSkeleton('form', {}, fx)}
|
|
267
300
|
</div></section>`;
|
|
268
301
|
}
|
|
269
302
|
|
|
270
|
-
function servicesListingSkeleton() {
|
|
271
|
-
const services =
|
|
303
|
+
function servicesListingSkeleton(fx = STUDIO_FX) {
|
|
304
|
+
const services = fx.sections?.programmes?.services ?? [];
|
|
272
305
|
return `${surfaceDivider('services')}<section class="section"><div class="container">
|
|
273
306
|
<h1>What we do</h1>
|
|
274
307
|
<div class="article-grid">${services.map((s) => `
|
|
@@ -279,8 +312,8 @@ function servicesListingSkeleton() {
|
|
|
279
312
|
</div></section>`;
|
|
280
313
|
}
|
|
281
314
|
|
|
282
|
-
function serviceDetailSkeleton() {
|
|
283
|
-
const service = (
|
|
315
|
+
function serviceDetailSkeleton(fx = STUDIO_FX) {
|
|
316
|
+
const service = (fx.sections?.programmes?.services ?? [])[0]
|
|
284
317
|
?? { title: 'Fixture service', summary: '' };
|
|
285
318
|
return `${surfaceDivider('service page')}<section class="section"><div class="container">
|
|
286
319
|
<article class="article-body">
|
|
@@ -292,8 +325,8 @@ function serviceDetailSkeleton() {
|
|
|
292
325
|
</div></section>`;
|
|
293
326
|
}
|
|
294
327
|
|
|
295
|
-
function courseListingSkeleton() {
|
|
296
|
-
const course =
|
|
328
|
+
function courseListingSkeleton(fx = STUDIO_FX) {
|
|
329
|
+
const course = fx.pages?.course?.course ?? { title: 'Fixture course' };
|
|
297
330
|
return `${surfaceDivider('courses')}<section class="section"><div class="container">
|
|
298
331
|
<h1>Courses</h1>
|
|
299
332
|
<article class="event-card"><div class="event-card-body">
|
|
@@ -329,17 +362,18 @@ function surfaceBar(active) {
|
|
|
329
362
|
link('services', '/services'), link('service', '/services?service=fixture'),
|
|
330
363
|
link('donate', '/donate'), link('articles', '/articles'), link('article', '/articles/fixture'),
|
|
331
364
|
link('campaigns', '/campaigns'), link('campaign', '/campaigns/fixture'), link('course', '/courses'),
|
|
365
|
+
`<a href="/model" style="margin-left:auto;font-weight:700">site.content model →</a>`,
|
|
332
366
|
];
|
|
333
367
|
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">
|
|
334
368
|
<strong style="letter-spacing:.06em;text-transform:uppercase;font-size:10px">Surfaces</strong>${links.join('')}
|
|
335
369
|
</nav>`;
|
|
336
370
|
}
|
|
337
371
|
|
|
338
|
-
function partsToHtml(html, contentHtml, ctx = {}) {
|
|
372
|
+
function partsToHtml(html, contentHtml, ctx = {}, fx = STUDIO_FX) {
|
|
339
373
|
let out = '';
|
|
340
374
|
for (const part of splitIslandParts(html)) {
|
|
341
375
|
if (part.island === CONTENT_SLOT) out += contentHtml ?? '';
|
|
342
|
-
else if (part.island) out += islandSkeleton(part.island, ctx);
|
|
376
|
+
else if (part.island) out += islandSkeleton(part.island, ctx, fx);
|
|
343
377
|
else out += part.html;
|
|
344
378
|
}
|
|
345
379
|
return out;
|
|
@@ -377,7 +411,49 @@ export async function renderStudioPreview(files, options = {}) {
|
|
|
377
411
|
configureDialect(liquid, dialect, allIslands);
|
|
378
412
|
|
|
379
413
|
const catalogueByType = new Map(sectionCatalogue.sections.map((s) => [s.type, s]));
|
|
380
|
-
|
|
414
|
+
// Studio-sealed by default; the kit's dev server may pass fixtureImageBase to resolve fixture
|
|
415
|
+
// imagery to the platform CDN instead of inline-SVG art (the dev-richer half of the split).
|
|
416
|
+
const artOptions = options.fixtureImageBase ? { imageBase: options.fixtureImageBase } : null;
|
|
417
|
+
let fx = artOptions ? resolveFixtureArt(contextContract.fixtures, artOptions) : STUDIO_FX;
|
|
418
|
+
// The one content tree (content model v1): about composed from this manifest's declared
|
|
419
|
+
// sections, dev preview-content overlaid when the kit passes it (validated there), imagery
|
|
420
|
+
// resolved exactly like the rest of the fixtures.
|
|
421
|
+
const site = resolveFixtureArt(
|
|
422
|
+
applyPreviewContent(buildSiteFixture(manifest), options.previewContent ?? null),
|
|
423
|
+
artOptions ?? {});
|
|
424
|
+
const brand = site.brand ?? fx.brand;
|
|
425
|
+
// With a content override, the TREE is the source of truth for every fixture view: the routed
|
|
426
|
+
// platform skeletons and island skeletons re-derive their slices from the overridden site, so
|
|
427
|
+
// the developer's own data shows on /events, /donate and friends, not just where site.* is read.
|
|
428
|
+
if (options.previewContent) {
|
|
429
|
+
const c = site.content;
|
|
430
|
+
fx = {
|
|
431
|
+
...fx,
|
|
432
|
+
brand: site.brand,
|
|
433
|
+
pages: {
|
|
434
|
+
...fx.pages,
|
|
435
|
+
events: { events: c.events },
|
|
436
|
+
articles: { ...fx.pages?.articles, articles: c.articles },
|
|
437
|
+
article: c.articles?.length
|
|
438
|
+
? { ...fx.pages?.article, article: { ...fx.pages?.article?.article, ...c.articles[0] } }
|
|
439
|
+
: fx.pages?.article
|
|
440
|
+
},
|
|
441
|
+
sections: {
|
|
442
|
+
...fx.sections,
|
|
443
|
+
events: { events: (c.events ?? []).filter((e) => e.registrationMode === 'TICKETED') },
|
|
444
|
+
whatsOn: { infoEvents: (c.events ?? []).filter((e) => e.registrationMode === 'INFO') },
|
|
445
|
+
articles: { latestArticles: c.articles ?? [] },
|
|
446
|
+
campaigns: { ...fx.sections?.campaigns, campaigns: c.campaigns ?? [] },
|
|
447
|
+
appealGrid: { causes: c.causes ?? [] },
|
|
448
|
+
emergency: { causes: c.causes ?? [] },
|
|
449
|
+
programmes: { services: c.services ?? [] },
|
|
450
|
+
resources: { resources: c.resources ?? [] },
|
|
451
|
+
locations: { locations: c.locations ?? [] },
|
|
452
|
+
volunteering: { opportunities: c.volunteering ?? [] },
|
|
453
|
+
media: { media: c.media ?? [] }
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
}
|
|
381
457
|
|
|
382
458
|
const surface = options.surface ?? 'home';
|
|
383
459
|
let contentHtml;
|
|
@@ -386,15 +462,15 @@ export async function renderStudioPreview(files, options = {}) {
|
|
|
386
462
|
// platform-page fixture skeleton — either way inside the theme's layout below.
|
|
387
463
|
const def = SURFACES[surface];
|
|
388
464
|
const templateSource = def.template ? files[`pages/${def.template}.liquid`] : null;
|
|
389
|
-
const fixture = def.template ?
|
|
465
|
+
const fixture = def.template ? fx.pages?.[def.template] : null;
|
|
390
466
|
if (templateSource != null && fixture != null
|
|
391
467
|
&& (manifest?.supports?.pageTemplates ?? []).includes(def.template)) {
|
|
392
|
-
const context = { brand, ...fixture };
|
|
468
|
+
const context = { brand, site, ...fixture };
|
|
393
469
|
const rendered = await liquid.parseAndRender(templateSource, context);
|
|
394
470
|
contentHtml = `<div class="p60-preview-divider" role="note">page template: ${escapeHtml(def.template)}</div>`
|
|
395
|
-
+ partsToHtml(rendered, '', context);
|
|
471
|
+
+ partsToHtml(rendered, '', context, fx);
|
|
396
472
|
} else {
|
|
397
|
-
contentHtml = def.builtin();
|
|
473
|
+
contentHtml = def.builtin(fx);
|
|
398
474
|
}
|
|
399
475
|
} else {
|
|
400
476
|
const sectionsHtml = [];
|
|
@@ -403,14 +479,15 @@ export async function renderStudioPreview(files, options = {}) {
|
|
|
403
479
|
const source = files[`sections/${type}.liquid`];
|
|
404
480
|
if (!entry || source == null) continue;
|
|
405
481
|
const context = {
|
|
406
|
-
section: entry.sample ?? {},
|
|
482
|
+
section: artOptions ? resolveFixtureArt(entry.sample ?? {}, artOptions) : resolveFixtureArt(entry.sample ?? {}),
|
|
407
483
|
brand,
|
|
408
|
-
|
|
484
|
+
site,
|
|
485
|
+
...(fx.sections?.[type] ?? {})
|
|
409
486
|
};
|
|
410
487
|
const rendered = await liquid.parseAndRender(source, context);
|
|
411
488
|
// Island skeletons see the SAME context the section rendered with — that is what lets the
|
|
412
489
|
// hero carousel skeleton hydrate from the section's own photo fixtures.
|
|
413
|
-
sectionsHtml.push(partsToHtml(rendered, '', context));
|
|
490
|
+
sectionsHtml.push(partsToHtml(rendered, '', context, fx));
|
|
414
491
|
}
|
|
415
492
|
|
|
416
493
|
// Declared page templates render too (over their page fixtures) — the loop an author lives in
|
|
@@ -418,12 +495,12 @@ export async function renderStudioPreview(files, options = {}) {
|
|
|
418
495
|
// each at its own path; this keeps the studio's single document complete.
|
|
419
496
|
for (const page of manifest?.supports?.pageTemplates ?? []) {
|
|
420
497
|
const source = files[`pages/${page}.liquid`];
|
|
421
|
-
const fixture =
|
|
498
|
+
const fixture = fx.pages?.[page];
|
|
422
499
|
if (source == null || fixture == null) continue;
|
|
423
|
-
const context = { brand, ...fixture };
|
|
500
|
+
const context = { brand, site, ...fixture };
|
|
424
501
|
const rendered = await liquid.parseAndRender(source, context);
|
|
425
502
|
sectionsHtml.push(`<div class="p60-preview-divider" role="note">page template: ${escapeHtml(page)}</div>`
|
|
426
|
-
+ partsToHtml(rendered, '', context));
|
|
503
|
+
+ partsToHtml(rendered, '', context, fx));
|
|
427
504
|
}
|
|
428
505
|
contentHtml = sectionsHtml.join('\n');
|
|
429
506
|
}
|
|
@@ -432,12 +509,13 @@ export async function renderStudioPreview(files, options = {}) {
|
|
|
432
509
|
if (manifest?.supports?.layout && files['layout.liquid'] != null) {
|
|
433
510
|
const rendered = await liquid.parseAndRender(files['layout.liquid'], {
|
|
434
511
|
brand,
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
512
|
+
site,
|
|
513
|
+
nav: fx.layout.nav,
|
|
514
|
+
socials: fx.layout.socials ?? [],
|
|
515
|
+
worship: manifest?.supports?.worship ? (fx.layout.worship ?? null) : null,
|
|
516
|
+
locale: fx.layout.locale
|
|
439
517
|
});
|
|
440
|
-
bodyHtml = partsToHtml(rendered, contentHtml);
|
|
518
|
+
bodyHtml = partsToHtml(rendered, contentHtml, {}, fx);
|
|
441
519
|
} else {
|
|
442
520
|
bodyHtml = contentHtml;
|
|
443
521
|
}
|
|
@@ -451,9 +529,24 @@ export async function renderStudioPreview(files, options = {}) {
|
|
|
451
529
|
// nothing and keep the fully script-free CSP. Without the runtime, a CSS-only crossfade
|
|
452
530
|
// approximates behaviour carousels so a static preview still reads as alive.
|
|
453
531
|
const runtime = options.behaviorsRuntime ?? null;
|
|
532
|
+
// With a fixture image base, the dev document may load imagery from that ONE origin; the studio
|
|
533
|
+
// render never widens beyond data: URIs.
|
|
534
|
+
const imgOrigins = new Set();
|
|
535
|
+
if (options.fixtureImageBase) {
|
|
536
|
+
try {
|
|
537
|
+
imgOrigins.add(new URL(options.fixtureImageBase).origin);
|
|
538
|
+
} catch {
|
|
539
|
+
// A relative or malformed base stays sealed rather than guessing an origin.
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
// The author's own imagery (a --content override) renders from exactly the hosts it names.
|
|
543
|
+
for (const origin of options.contentImageOrigins ?? []) {
|
|
544
|
+
imgOrigins.add(origin);
|
|
545
|
+
}
|
|
546
|
+
const imgSrc = ['data:', ...imgOrigins].join(' ');
|
|
454
547
|
const csp = runtime
|
|
455
|
-
?
|
|
456
|
-
:
|
|
548
|
+
? `default-src 'none'; style-src 'unsafe-inline'; img-src ${imgSrc}; script-src 'unsafe-inline';`
|
|
549
|
+
: `default-src 'none'; style-src 'unsafe-inline'; img-src ${imgSrc};`;
|
|
457
550
|
const motionApproximation = runtime ? '' : `
|
|
458
551
|
@media (prefers-reduced-motion: no-preference) {
|
|
459
552
|
[data-p60-carousel] > [data-p60-slide], .p60-preview-slide { animation: p60-preview-crossfade 8s infinite; }
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// The `site` tree for validator and preview renders (content model v1 — contract/v1/
|
|
2
|
+
// content-model.json): ONE realistic organisation assembled from the canonical fixtures, with
|
|
3
|
+
// `site.content.about` composed per-template from the section catalogue's samples for the
|
|
4
|
+
// manifest's declared sections — the admin-authored page composition, previewed honestly.
|
|
5
|
+
import contextContract from '../contract/v1/context.json' with { type: 'json' };
|
|
6
|
+
import sectionCatalogue from '../contract/v1/sections.json' with { type: 'json' };
|
|
7
|
+
|
|
8
|
+
export { contentModel, extractContentFootprint } from '../engine/content-footprint.mjs';
|
|
9
|
+
import { contentModel } from '../engine/content-footprint.mjs';
|
|
10
|
+
|
|
11
|
+
/** The canonical site fixture with `about` composed for this manifest's declared sections. */
|
|
12
|
+
export function buildSiteFixture(manifest) {
|
|
13
|
+
const base = contextContract.fixtures.site;
|
|
14
|
+
const catalogueByType = new Map(sectionCatalogue.sections.map((s) => [s.type, s]));
|
|
15
|
+
const about = (manifest?.supports?.sections ?? [])
|
|
16
|
+
.map((type) => {
|
|
17
|
+
const entry = catalogueByType.get(type);
|
|
18
|
+
return entry ? { type, content: entry.sample ?? {} } : null;
|
|
19
|
+
})
|
|
20
|
+
.filter(Boolean);
|
|
21
|
+
return { ...base, content: { ...base.content, about } };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── preview-content.json (author-editable data) ────────────────────────────────
|
|
25
|
+
// Data is free, SHAPE is fixed: a collection override must be an array of items whose fields all
|
|
26
|
+
// exist in the model, within the collection's cap. Proofs always run on the canonical fixtures,
|
|
27
|
+
// so custom data can never dodge a gate; this file only feeds the dev preview.
|
|
28
|
+
|
|
29
|
+
const BRAND_FIELDS = new Set(['name', 'tagline', 'logoUrl', 'logoType', 'footerLogoUrl']);
|
|
30
|
+
|
|
31
|
+
export function validatePreviewContent(json) {
|
|
32
|
+
const errors = [];
|
|
33
|
+
if (json === null || typeof json !== 'object' || Array.isArray(json)) {
|
|
34
|
+
return ['preview-content.json: must be an object of { collection: [items] } overrides'];
|
|
35
|
+
}
|
|
36
|
+
for (const [name, items] of Object.entries(json)) {
|
|
37
|
+
// `brand` is the one non-collection override: the organisation's own name and marks.
|
|
38
|
+
if (name === 'brand') {
|
|
39
|
+
if (items === null || typeof items !== 'object' || Array.isArray(items)) {
|
|
40
|
+
errors.push('preview-content.json: brand must be an object');
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
for (const field of Object.keys(items)) {
|
|
44
|
+
if (!BRAND_FIELDS.has(field)) {
|
|
45
|
+
errors.push(`preview-content.json: brand.${field} does not exist — brand carries ${[...BRAND_FIELDS].join(', ')}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const model = contentModel.collections[name];
|
|
51
|
+
if (!model) {
|
|
52
|
+
errors.push(`preview-content.json: '${name}' is not a content collection (see contract/v1/content-model.json)`);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (!Array.isArray(items)) {
|
|
56
|
+
errors.push(`preview-content.json: '${name}' must be an array`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (items.length > model.cap) {
|
|
60
|
+
errors.push(`preview-content.json: '${name}' holds ${items.length} items — the collection is bounded at ${model.cap}`);
|
|
61
|
+
}
|
|
62
|
+
items.forEach((item, i) => {
|
|
63
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item)) {
|
|
64
|
+
errors.push(`preview-content.json: ${name}[${i}] must be an object`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
for (const field of Object.keys(item)) {
|
|
68
|
+
if (!model.item[field]) {
|
|
69
|
+
errors.push(`preview-content.json: ${name}[${i}].${field} does not exist in the model — a field that does not exist in production cannot exist in a preview`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const [field, spec] of Object.entries(model.item)) {
|
|
73
|
+
const value = item[field];
|
|
74
|
+
if (value == null) continue;
|
|
75
|
+
const kind = spec.type === 'string[]' ? 'array' : spec.type;
|
|
76
|
+
const actual = Array.isArray(value) ? 'array' : typeof value;
|
|
77
|
+
if (kind !== 'object' && actual !== kind) {
|
|
78
|
+
errors.push(`preview-content.json: ${name}[${i}].${field} should be ${spec.type}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return errors;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Overlay validated preview content onto a site fixture (collections replaced wholesale). */
|
|
87
|
+
export function applyPreviewContent(site, json) {
|
|
88
|
+
if (!json || typeof json !== 'object') return site;
|
|
89
|
+
const content = { ...site.content };
|
|
90
|
+
for (const [name, items] of Object.entries(json)) {
|
|
91
|
+
if (contentModel.collections[name] && Array.isArray(items)) content[name] = items;
|
|
92
|
+
}
|
|
93
|
+
const brand = json.brand && typeof json.brand === 'object' && !Array.isArray(json.brand)
|
|
94
|
+
? { ...site.brand, ...json.brand }
|
|
95
|
+
: site.brand;
|
|
96
|
+
return { ...site, brand, content };
|
|
97
|
+
}
|