@port60/template-kit 0.9.0 → 0.11.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 +52 -24
- 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 +165 -0
- package/src/vendor/contract/v1/context.json +1497 -289
- 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 +242 -235
- package/src/vendor/contract/v1/layout.json +4 -4
- package/src/vendor/contract/v1/manifest.schema.json +10 -12
- package/src/vendor/contract/v1/sections.json +189 -107
- 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 +107 -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 +144 -59
- package/src/vendor/validator/site-context.mjs +97 -0
- package/src/vendor/validator/validate.mjs +70 -17
- 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,12 +146,30 @@ 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="#">
|
|
141
|
-
case '
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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>`;
|
|
150
|
+
case 'map': {
|
|
151
|
+
// The impact-map skeleton: the fixture's points projected onto a token-themed canvas —
|
|
152
|
+
// the same fallback rendering production uses until the platform tile layer is configured.
|
|
153
|
+
const im = fx.sections?.impactMap?.impactMap ?? { title: 'Impact map', points: [] };
|
|
154
|
+
const pts = im.points ?? [];
|
|
155
|
+
const lats = pts.map((pt) => pt.latitude);
|
|
156
|
+
const lngs = pts.map((pt) => pt.longitude);
|
|
157
|
+
const pad = 0.15;
|
|
158
|
+
const latMin = Math.min(...lats), latMax = Math.max(...lats);
|
|
159
|
+
const lngMin = Math.min(...lngs), lngMax = Math.max(...lngs);
|
|
160
|
+
const pos = (pt) => {
|
|
161
|
+
const x = lngMax === lngMin ? 50 : (pad + (1 - 2 * pad) * ((pt.longitude - lngMin) / (lngMax - lngMin))) * 100;
|
|
162
|
+
const y = latMax === latMin ? 50 : (pad + (1 - 2 * pad) * (1 - (pt.latitude - latMin) / (latMax - latMin))) * 100;
|
|
163
|
+
return `left:${x.toFixed(1)}%;top:${y.toFixed(1)}%`;
|
|
164
|
+
};
|
|
165
|
+
const first = pts[0] ?? null;
|
|
166
|
+
return `<section class="impact-map" data-p60-preview-island="map">
|
|
167
|
+
${previewNote(name)}
|
|
168
|
+
<div class="impact-map-canvas" style="position:relative;min-height:320px;border-radius:12px;background:linear-gradient(160deg,#22303c,#101820)">
|
|
169
|
+
${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('')}
|
|
170
|
+
</div>
|
|
171
|
+
${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>'}
|
|
172
|
+
</section>`;
|
|
146
173
|
}
|
|
147
174
|
case 'form':
|
|
148
175
|
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>`;
|
|
@@ -162,8 +189,8 @@ function surfaceDivider(label) {
|
|
|
162
189
|
return `<div class="p60-preview-divider" role="note">platform page: ${escapeHtml(label)} — styled by your tokens and chrome</div>`;
|
|
163
190
|
}
|
|
164
191
|
|
|
165
|
-
function eventsListingSkeleton() {
|
|
166
|
-
const events =
|
|
192
|
+
function eventsListingSkeleton(fx = STUDIO_FX) {
|
|
193
|
+
const events = fx.pages?.events?.events ?? [];
|
|
167
194
|
return `${surfaceDivider('events')}<section class="section"><div class="container">
|
|
168
195
|
<h1>What's on</h1>
|
|
169
196
|
<div class="event-grid">${events.map((e) => `
|
|
@@ -178,8 +205,8 @@ function eventsListingSkeleton() {
|
|
|
178
205
|
</div></section>`;
|
|
179
206
|
}
|
|
180
207
|
|
|
181
|
-
function eventDetailSkeleton() {
|
|
182
|
-
const event = (
|
|
208
|
+
function eventDetailSkeleton(fx = STUDIO_FX) {
|
|
209
|
+
const event = (fx.pages?.events?.events ?? [])
|
|
183
210
|
.find((e) => e.registrationMode === 'TICKETED') ?? { name: 'Fixture Gala', venueName: 'Town Hall' };
|
|
184
211
|
const tier = (name, sub, price, note) => `
|
|
185
212
|
<div class="ticket-type"><div class="tt-main">
|
|
@@ -204,9 +231,9 @@ function eventDetailSkeleton() {
|
|
|
204
231
|
</div></section>`;
|
|
205
232
|
}
|
|
206
233
|
|
|
207
|
-
function donateSkeleton() {
|
|
208
|
-
const causes =
|
|
209
|
-
??
|
|
234
|
+
function donateSkeleton(fx = STUDIO_FX) {
|
|
235
|
+
const causes = fx.sections?.appealGrid?.causes
|
|
236
|
+
?? fx.sections?.emergency?.causes ?? [];
|
|
210
237
|
return `${surfaceDivider('donate')}<section class="section"><div class="container">
|
|
211
238
|
<h1>Donate</h1>
|
|
212
239
|
<div class="cause-options">${causes.slice(0, 2).map((c) => `
|
|
@@ -215,12 +242,12 @@ function donateSkeleton() {
|
|
|
215
242
|
<p class="cause-option-desc">${escapeHtml(c.text ?? c.description ?? '')}</p>
|
|
216
243
|
<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
244
|
</article>`).join('')}</div>
|
|
218
|
-
${islandSkeleton('donation_widget')}
|
|
245
|
+
${islandSkeleton('donation_widget', {}, fx)}
|
|
219
246
|
</div></section>`;
|
|
220
247
|
}
|
|
221
248
|
|
|
222
|
-
function articlesListingSkeleton() {
|
|
223
|
-
const articles =
|
|
249
|
+
function articlesListingSkeleton(fx = STUDIO_FX) {
|
|
250
|
+
const articles = fx.pages?.articles?.articles ?? [];
|
|
224
251
|
return `${surfaceDivider('articles')}<section class="section"><div class="container">
|
|
225
252
|
<h1>Latest</h1>
|
|
226
253
|
<div class="article-grid">${articles.map((a) => `
|
|
@@ -232,23 +259,23 @@ function articlesListingSkeleton() {
|
|
|
232
259
|
</div></section>`;
|
|
233
260
|
}
|
|
234
261
|
|
|
235
|
-
function articleDetailSkeleton() {
|
|
236
|
-
const article =
|
|
262
|
+
function articleDetailSkeleton(fx = STUDIO_FX) {
|
|
263
|
+
const article = fx.pages?.article?.article ?? { title: 'Fixture article', bodyHtml: '<p>Body</p>' };
|
|
237
264
|
return `${surfaceDivider('article')}<section class="section"><div class="container">
|
|
238
265
|
<article class="article-body">
|
|
239
266
|
<h1>${escapeHtml(article.title)}</h1>
|
|
240
267
|
<p class="article-card-meta">${escapeHtml(article.authorName ?? '')} · ${article.readingMinutes ?? 3} min read</p>
|
|
241
268
|
${article.bodyHtml ?? ''}
|
|
242
|
-
${islandSkeleton('article_engagement')}
|
|
243
|
-
${islandSkeleton('article_comments')}
|
|
269
|
+
${islandSkeleton('article_engagement', {}, fx)}
|
|
270
|
+
${islandSkeleton('article_comments', {}, fx)}
|
|
244
271
|
</article>
|
|
245
272
|
</div></section>`;
|
|
246
273
|
}
|
|
247
274
|
|
|
248
|
-
function campaignsListingSkeleton() {
|
|
249
|
-
const campaigns =
|
|
275
|
+
function campaignsListingSkeleton(fx = STUDIO_FX) {
|
|
276
|
+
const campaigns = fx.sections?.campaigns?.campaigns ?? [];
|
|
250
277
|
return `${surfaceDivider('campaigns')}<section class="section"><div class="container">
|
|
251
|
-
<h1>${escapeHtml(
|
|
278
|
+
<h1>${escapeHtml(fx.sections?.campaigns?.campaignsLabel ?? 'Campaigns')}</h1>
|
|
252
279
|
<div class="article-grid">${campaigns.map((c) => `
|
|
253
280
|
<article class="article-card"><div class="article-card-body">
|
|
254
281
|
<h3><a href="/campaigns/${escapeHtml(c.href?.split('/').pop() ?? '')}">${escapeHtml(c.title)}</a></h3>
|
|
@@ -257,18 +284,18 @@ function campaignsListingSkeleton() {
|
|
|
257
284
|
</div></section>`;
|
|
258
285
|
}
|
|
259
286
|
|
|
260
|
-
function campaignDetailSkeleton() {
|
|
261
|
-
const campaign = (
|
|
287
|
+
function campaignDetailSkeleton(fx = STUDIO_FX) {
|
|
288
|
+
const campaign = (fx.sections?.campaigns?.campaigns ?? [])[0]
|
|
262
289
|
?? { title: 'Fixture Winter Campaign', summary: '' };
|
|
263
290
|
return `${surfaceDivider('campaign')}<section class="section"><div class="container">
|
|
264
291
|
<h1>${escapeHtml(campaign.title)}</h1>
|
|
265
292
|
${campaign.summary ? `<p>${escapeHtml(campaign.summary)}</p>` : ''}
|
|
266
|
-
${islandSkeleton('form')}
|
|
293
|
+
${islandSkeleton('form', {}, fx)}
|
|
267
294
|
</div></section>`;
|
|
268
295
|
}
|
|
269
296
|
|
|
270
|
-
function servicesListingSkeleton() {
|
|
271
|
-
const services =
|
|
297
|
+
function servicesListingSkeleton(fx = STUDIO_FX) {
|
|
298
|
+
const services = fx.sections?.programmes?.services ?? [];
|
|
272
299
|
return `${surfaceDivider('services')}<section class="section"><div class="container">
|
|
273
300
|
<h1>What we do</h1>
|
|
274
301
|
<div class="article-grid">${services.map((s) => `
|
|
@@ -279,8 +306,8 @@ function servicesListingSkeleton() {
|
|
|
279
306
|
</div></section>`;
|
|
280
307
|
}
|
|
281
308
|
|
|
282
|
-
function serviceDetailSkeleton() {
|
|
283
|
-
const service = (
|
|
309
|
+
function serviceDetailSkeleton(fx = STUDIO_FX) {
|
|
310
|
+
const service = (fx.sections?.programmes?.services ?? [])[0]
|
|
284
311
|
?? { title: 'Fixture service', summary: '' };
|
|
285
312
|
return `${surfaceDivider('service page')}<section class="section"><div class="container">
|
|
286
313
|
<article class="article-body">
|
|
@@ -292,8 +319,8 @@ function serviceDetailSkeleton() {
|
|
|
292
319
|
</div></section>`;
|
|
293
320
|
}
|
|
294
321
|
|
|
295
|
-
function courseListingSkeleton() {
|
|
296
|
-
const course =
|
|
322
|
+
function courseListingSkeleton(fx = STUDIO_FX) {
|
|
323
|
+
const course = fx.pages?.course?.course ?? { title: 'Fixture course' };
|
|
297
324
|
return `${surfaceDivider('courses')}<section class="section"><div class="container">
|
|
298
325
|
<h1>Courses</h1>
|
|
299
326
|
<article class="event-card"><div class="event-card-body">
|
|
@@ -329,17 +356,18 @@ function surfaceBar(active) {
|
|
|
329
356
|
link('services', '/services'), link('service', '/services?service=fixture'),
|
|
330
357
|
link('donate', '/donate'), link('articles', '/articles'), link('article', '/articles/fixture'),
|
|
331
358
|
link('campaigns', '/campaigns'), link('campaign', '/campaigns/fixture'), link('course', '/courses'),
|
|
359
|
+
`<a href="/model" style="margin-left:auto;font-weight:700">site.content model →</a>`,
|
|
332
360
|
];
|
|
333
361
|
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
362
|
<strong style="letter-spacing:.06em;text-transform:uppercase;font-size:10px">Surfaces</strong>${links.join('')}
|
|
335
363
|
</nav>`;
|
|
336
364
|
}
|
|
337
365
|
|
|
338
|
-
function partsToHtml(html, contentHtml, ctx = {}) {
|
|
366
|
+
function partsToHtml(html, contentHtml, ctx = {}, fx = STUDIO_FX) {
|
|
339
367
|
let out = '';
|
|
340
368
|
for (const part of splitIslandParts(html)) {
|
|
341
369
|
if (part.island === CONTENT_SLOT) out += contentHtml ?? '';
|
|
342
|
-
else if (part.island) out += islandSkeleton(part.island, ctx);
|
|
370
|
+
else if (part.island) out += islandSkeleton(part.island, ctx, fx);
|
|
343
371
|
else out += part.html;
|
|
344
372
|
}
|
|
345
373
|
return out;
|
|
@@ -377,7 +405,47 @@ export async function renderStudioPreview(files, options = {}) {
|
|
|
377
405
|
configureDialect(liquid, dialect, allIslands);
|
|
378
406
|
|
|
379
407
|
const catalogueByType = new Map(sectionCatalogue.sections.map((s) => [s.type, s]));
|
|
380
|
-
|
|
408
|
+
// Studio-sealed by default; the kit's dev server may pass fixtureImageBase to resolve fixture
|
|
409
|
+
// imagery to the platform CDN instead of inline-SVG art (the dev-richer half of the split).
|
|
410
|
+
const artOptions = options.fixtureImageBase ? { imageBase: options.fixtureImageBase } : null;
|
|
411
|
+
let fx = artOptions ? resolveFixtureArt(contextContract.fixtures, artOptions) : STUDIO_FX;
|
|
412
|
+
// The one content tree (content model v1): about composed from this manifest's declared
|
|
413
|
+
// sections, dev preview-content overlaid when the kit passes it (validated there), imagery
|
|
414
|
+
// resolved exactly like the rest of the fixtures.
|
|
415
|
+
const site = resolveFixtureArt(
|
|
416
|
+
applyPreviewContent(buildSiteFixture(manifest), options.previewContent ?? null),
|
|
417
|
+
artOptions ?? {});
|
|
418
|
+
const brand = site.brand ?? fx.brand;
|
|
419
|
+
// With a content override, the TREE is the source of truth for every fixture view: the routed
|
|
420
|
+
// platform skeletons and island skeletons re-derive their slices from the overridden site, so
|
|
421
|
+
// the developer's own data shows on /events, /donate and friends, not just where site.* is read.
|
|
422
|
+
if (options.previewContent) {
|
|
423
|
+
const c = site.content;
|
|
424
|
+
fx = {
|
|
425
|
+
...fx,
|
|
426
|
+
brand: site.brand,
|
|
427
|
+
pages: {
|
|
428
|
+
...fx.pages,
|
|
429
|
+
events: { events: c.events },
|
|
430
|
+
articles: { ...fx.pages?.articles, articles: c.articles },
|
|
431
|
+
article: c.articles?.length
|
|
432
|
+
? { ...fx.pages?.article, article: { ...fx.pages?.article?.article, ...c.articles[0] } }
|
|
433
|
+
: fx.pages?.article
|
|
434
|
+
},
|
|
435
|
+
sections: {
|
|
436
|
+
...fx.sections,
|
|
437
|
+
events: { events: (c.events ?? []).filter((e) => e.registrationMode === 'TICKETED') },
|
|
438
|
+
whatsOn: { infoEvents: (c.events ?? []).filter((e) => e.registrationMode === 'INFO') },
|
|
439
|
+
articles: { latestArticles: c.articles ?? [] },
|
|
440
|
+
campaigns: { ...fx.sections?.campaigns, campaigns: c.campaigns ?? [] },
|
|
441
|
+
appealGrid: { causes: c.causes ?? [] },
|
|
442
|
+
emergency: { causes: c.causes ?? [] },
|
|
443
|
+
programmes: { services: c.services ?? [] },
|
|
444
|
+
resources: { resources: c.resources ?? [] },
|
|
445
|
+
locations: { locations: c.locations ?? [] }
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
}
|
|
381
449
|
|
|
382
450
|
const surface = options.surface ?? 'home';
|
|
383
451
|
let contentHtml;
|
|
@@ -386,15 +454,15 @@ export async function renderStudioPreview(files, options = {}) {
|
|
|
386
454
|
// platform-page fixture skeleton — either way inside the theme's layout below.
|
|
387
455
|
const def = SURFACES[surface];
|
|
388
456
|
const templateSource = def.template ? files[`pages/${def.template}.liquid`] : null;
|
|
389
|
-
const fixture = def.template ?
|
|
457
|
+
const fixture = def.template ? fx.pages?.[def.template] : null;
|
|
390
458
|
if (templateSource != null && fixture != null
|
|
391
459
|
&& (manifest?.supports?.pageTemplates ?? []).includes(def.template)) {
|
|
392
|
-
const context = { brand, ...fixture };
|
|
460
|
+
const context = { brand, site, ...fixture };
|
|
393
461
|
const rendered = await liquid.parseAndRender(templateSource, context);
|
|
394
462
|
contentHtml = `<div class="p60-preview-divider" role="note">page template: ${escapeHtml(def.template)}</div>`
|
|
395
|
-
+ partsToHtml(rendered, '', context);
|
|
463
|
+
+ partsToHtml(rendered, '', context, fx);
|
|
396
464
|
} else {
|
|
397
|
-
contentHtml = def.builtin();
|
|
465
|
+
contentHtml = def.builtin(fx);
|
|
398
466
|
}
|
|
399
467
|
} else {
|
|
400
468
|
const sectionsHtml = [];
|
|
@@ -403,14 +471,15 @@ export async function renderStudioPreview(files, options = {}) {
|
|
|
403
471
|
const source = files[`sections/${type}.liquid`];
|
|
404
472
|
if (!entry || source == null) continue;
|
|
405
473
|
const context = {
|
|
406
|
-
section: entry.sample ?? {},
|
|
474
|
+
section: artOptions ? resolveFixtureArt(entry.sample ?? {}, artOptions) : resolveFixtureArt(entry.sample ?? {}),
|
|
407
475
|
brand,
|
|
408
|
-
|
|
476
|
+
site,
|
|
477
|
+
...(fx.sections?.[type] ?? {})
|
|
409
478
|
};
|
|
410
479
|
const rendered = await liquid.parseAndRender(source, context);
|
|
411
480
|
// Island skeletons see the SAME context the section rendered with — that is what lets the
|
|
412
481
|
// hero carousel skeleton hydrate from the section's own photo fixtures.
|
|
413
|
-
sectionsHtml.push(partsToHtml(rendered, '', context));
|
|
482
|
+
sectionsHtml.push(partsToHtml(rendered, '', context, fx));
|
|
414
483
|
}
|
|
415
484
|
|
|
416
485
|
// Declared page templates render too (over their page fixtures) — the loop an author lives in
|
|
@@ -418,12 +487,12 @@ export async function renderStudioPreview(files, options = {}) {
|
|
|
418
487
|
// each at its own path; this keeps the studio's single document complete.
|
|
419
488
|
for (const page of manifest?.supports?.pageTemplates ?? []) {
|
|
420
489
|
const source = files[`pages/${page}.liquid`];
|
|
421
|
-
const fixture =
|
|
490
|
+
const fixture = fx.pages?.[page];
|
|
422
491
|
if (source == null || fixture == null) continue;
|
|
423
|
-
const context = { brand, ...fixture };
|
|
492
|
+
const context = { brand, site, ...fixture };
|
|
424
493
|
const rendered = await liquid.parseAndRender(source, context);
|
|
425
494
|
sectionsHtml.push(`<div class="p60-preview-divider" role="note">page template: ${escapeHtml(page)}</div>`
|
|
426
|
-
+ partsToHtml(rendered, '', context));
|
|
495
|
+
+ partsToHtml(rendered, '', context, fx));
|
|
427
496
|
}
|
|
428
497
|
contentHtml = sectionsHtml.join('\n');
|
|
429
498
|
}
|
|
@@ -432,12 +501,13 @@ export async function renderStudioPreview(files, options = {}) {
|
|
|
432
501
|
if (manifest?.supports?.layout && files['layout.liquid'] != null) {
|
|
433
502
|
const rendered = await liquid.parseAndRender(files['layout.liquid'], {
|
|
434
503
|
brand,
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
504
|
+
site,
|
|
505
|
+
nav: fx.layout.nav,
|
|
506
|
+
socials: fx.layout.socials ?? [],
|
|
507
|
+
worship: manifest?.supports?.worship ? (fx.layout.worship ?? null) : null,
|
|
508
|
+
locale: fx.layout.locale
|
|
439
509
|
});
|
|
440
|
-
bodyHtml = partsToHtml(rendered, contentHtml);
|
|
510
|
+
bodyHtml = partsToHtml(rendered, contentHtml, {}, fx);
|
|
441
511
|
} else {
|
|
442
512
|
bodyHtml = contentHtml;
|
|
443
513
|
}
|
|
@@ -451,9 +521,24 @@ export async function renderStudioPreview(files, options = {}) {
|
|
|
451
521
|
// nothing and keep the fully script-free CSP. Without the runtime, a CSS-only crossfade
|
|
452
522
|
// approximates behaviour carousels so a static preview still reads as alive.
|
|
453
523
|
const runtime = options.behaviorsRuntime ?? null;
|
|
524
|
+
// With a fixture image base, the dev document may load imagery from that ONE origin; the studio
|
|
525
|
+
// render never widens beyond data: URIs.
|
|
526
|
+
const imgOrigins = new Set();
|
|
527
|
+
if (options.fixtureImageBase) {
|
|
528
|
+
try {
|
|
529
|
+
imgOrigins.add(new URL(options.fixtureImageBase).origin);
|
|
530
|
+
} catch {
|
|
531
|
+
// A relative or malformed base stays sealed rather than guessing an origin.
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
// The author's own imagery (a --content override) renders from exactly the hosts it names.
|
|
535
|
+
for (const origin of options.contentImageOrigins ?? []) {
|
|
536
|
+
imgOrigins.add(origin);
|
|
537
|
+
}
|
|
538
|
+
const imgSrc = ['data:', ...imgOrigins].join(' ');
|
|
454
539
|
const csp = runtime
|
|
455
|
-
?
|
|
456
|
-
:
|
|
540
|
+
? `default-src 'none'; style-src 'unsafe-inline'; img-src ${imgSrc}; script-src 'unsafe-inline';`
|
|
541
|
+
: `default-src 'none'; style-src 'unsafe-inline'; img-src ${imgSrc};`;
|
|
457
542
|
const motionApproximation = runtime ? '' : `
|
|
458
543
|
@media (prefers-reduced-motion: no-preference) {
|
|
459
544
|
[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
|
+
}
|