@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.
Files changed (39) hide show
  1. package/README.md +10 -3
  2. package/bin/cli.mjs +26 -4
  3. package/package.json +1 -1
  4. package/src/commands/content.mjs +42 -0
  5. package/src/commands/create.mjs +6 -6
  6. package/src/commands/dev.mjs +97 -9
  7. package/src/commands/model.mjs +34 -0
  8. package/src/commands/packageCmd.mjs +3 -3
  9. package/src/commands/refresh.mjs +48 -0
  10. package/src/commands/upgrade.mjs +44 -0
  11. package/src/commands/validate.mjs +2 -3
  12. package/src/lib/agentsMd.mjs +47 -19
  13. package/src/vendor/contract/v1/behaviours.json +4 -4
  14. package/src/vendor/contract/v1/content-bounds.json +3 -3
  15. package/src/vendor/contract/v1/content-model.json +193 -0
  16. package/src/vendor/contract/v1/context.json +1667 -275
  17. package/src/vendor/contract/v1/dialect.json +3 -3
  18. package/src/vendor/contract/v1/fonts.json +1 -1
  19. package/src/vendor/contract/v1/imagery.json +4 -4
  20. package/src/vendor/contract/v1/islands.json +256 -235
  21. package/src/vendor/contract/v1/layout.json +4 -4
  22. package/src/vendor/contract/v1/manifest.schema.json +9 -9
  23. package/src/vendor/contract/v1/sections.json +248 -92
  24. package/src/vendor/contract/v1/tokens.json +1 -1
  25. package/src/vendor/contract/v1.lock.json +1 -1
  26. package/src/vendor/engine/content-footprint.mjs +79 -0
  27. package/src/vendor/validator/fixture-art.mjs +109 -0
  28. package/src/vendor/validator/model-reference.mjs +92 -0
  29. package/src/vendor/validator/platform-base.css +21 -0
  30. package/src/vendor/validator/preview.mjs +149 -56
  31. package/src/vendor/validator/site-context.mjs +97 -0
  32. package/src/vendor/validator/validate.mjs +71 -14
  33. package/starter/assets/theme.css +10 -10
  34. package/starter/layout.liquid +15 -16
  35. package/starter/manifest.json +2 -2
  36. package/starter/sections/campaigns.liquid +5 -5
  37. package/starter/sections/homeHero.liquid +11 -11
  38. package/starter/sections/people.liquid +1 -1
  39. 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
@@ -35,7 +42,7 @@ function previewNote(name) {
35
42
  }
36
43
 
37
44
  function eventCards(events) {
38
- return events.slice(0, 2).map((event) => `
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 = contextContract.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"><span class="carousel-dot"></span><span class="carousel-dot"></span></div>
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 = (contextContract.fixtures.layout?.worship?.times ?? [])
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>Fixture supporter</strong></header><p>Thank you for sharing this update.</p></article></div>
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="#">Fixture search result</a><p class="site-search-summary">A realistic non-interactive preview result.</p></li></ul></div>`;
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 = contextContract.fixtures.sections?.volunteering?.opportunities ?? [];
143
- const options = (opportunities.length ? opportunities : [{ title: 'Fixture Garden Volunteer' }])
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 = contextContract.fixtures.pages?.events?.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 = (contextContract.fixtures.pages?.events?.events ?? [])
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 = contextContract.fixtures.sections?.appealGrid?.causes
209
- ?? contextContract.fixtures.sections?.emergency?.causes ?? [];
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 = contextContract.fixtures.pages?.articles?.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 = contextContract.fixtures.pages?.article?.article ?? { title: 'Fixture article', bodyHtml: '<p>Body</p>' };
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 = contextContract.fixtures.sections?.campaigns?.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(contextContract.fixtures.sections?.campaigns?.campaignsLabel ?? 'Campaigns')}</h1>
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 = (contextContract.fixtures.sections?.campaigns?.campaigns ?? [])[0]
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 = contextContract.fixtures.sections?.programmes?.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 = (contextContract.fixtures.sections?.programmes?.services ?? [])[0]
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 = contextContract.fixtures.pages?.course?.course ?? { title: 'Fixture 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
- const brand = contextContract.fixtures.brand;
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 ? contextContract.fixtures.pages?.[def.template] : null;
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
- ...(contextContract.fixtures.sections?.[type] ?? {})
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 = contextContract.fixtures.pages?.[page];
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
- nav: contextContract.fixtures.layout.nav,
436
- socials: contextContract.fixtures.layout.socials ?? [],
437
- worship: manifest?.supports?.worship ? (contextContract.fixtures.layout.worship ?? null) : null,
438
- locale: contextContract.fixtures.layout.locale
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
- ? "default-src 'none'; style-src 'unsafe-inline'; img-src data:; script-src 'unsafe-inline';"
456
- : "default-src 'none'; style-src 'unsafe-inline'; img-src data:;";
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
+ }