@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.
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 +52 -24
  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 +165 -0
  16. package/src/vendor/contract/v1/context.json +1497 -289
  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 +242 -235
  21. package/src/vendor/contract/v1/layout.json +4 -4
  22. package/src/vendor/contract/v1/manifest.schema.json +10 -12
  23. package/src/vendor/contract/v1/sections.json +189 -107
  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 +107 -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 +144 -59
  31. package/src/vendor/validator/site-context.mjs +97 -0
  32. package/src/vendor/validator/validate.mjs +70 -17
  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,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="#">Fixture search result</a><p class="site-search-summary">A realistic non-interactive preview result.</p></li></ul></div>`;
141
- case 'volunteer_signup': {
142
- const opportunities = contextContract.fixtures.sections?.volunteering?.opportunities ?? [];
143
- const options = (opportunities.length ? opportunities : [{ title: 'Fixture Garden Volunteer' }])
144
- .map((o) => `<option>${escapeHtml(o.title)}</option>`).join('');
145
- 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>`;
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 = contextContract.fixtures.pages?.events?.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 = (contextContract.fixtures.pages?.events?.events ?? [])
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 = contextContract.fixtures.sections?.appealGrid?.causes
209
- ?? contextContract.fixtures.sections?.emergency?.causes ?? [];
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 = contextContract.fixtures.pages?.articles?.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 = contextContract.fixtures.pages?.article?.article ?? { title: 'Fixture article', bodyHtml: '<p>Body</p>' };
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 = contextContract.fixtures.sections?.campaigns?.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(contextContract.fixtures.sections?.campaigns?.campaignsLabel ?? 'Campaigns')}</h1>
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 = (contextContract.fixtures.sections?.campaigns?.campaigns ?? [])[0]
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 = contextContract.fixtures.sections?.programmes?.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 = (contextContract.fixtures.sections?.programmes?.services ?? [])[0]
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 = contextContract.fixtures.pages?.course?.course ?? { title: 'Fixture 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
- const brand = contextContract.fixtures.brand;
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 ? contextContract.fixtures.pages?.[def.template] : null;
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
- ...(contextContract.fixtures.sections?.[type] ?? {})
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 = contextContract.fixtures.pages?.[page];
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
- 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
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
- ? "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:;";
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
+ }