@port60/template-kit 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -3
- package/bin/cli.mjs +26 -4
- package/package.json +1 -1
- package/src/commands/content.mjs +42 -0
- package/src/commands/create.mjs +10 -7
- package/src/commands/dev.mjs +134 -8
- 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 +78 -21
- package/src/vendor/contract/v1/behaviours.json +129 -0
- package/src/vendor/contract/v1/content-bounds.json +3 -3
- package/src/vendor/contract/v1/content-model.json +193 -0
- package/src/vendor/contract/v1/context.json +1985 -141
- package/src/vendor/contract/v1/dialect.json +3 -3
- package/src/vendor/contract/v1/fonts.json +1 -1
- package/src/vendor/contract/v1/imagery.json +4 -4
- package/src/vendor/contract/v1/islands.json +256 -110
- package/src/vendor/contract/v1/layout.json +25 -0
- package/src/vendor/contract/v1/manifest.schema.json +60 -14
- package/src/vendor/contract/v1/sections.json +330 -61
- 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/behaviors-runtime.js +2 -0
- package/src/vendor/validator/fixture-art.mjs +109 -0
- package/src/vendor/validator/model-reference.mjs +92 -0
- package/src/vendor/validator/platform-base.css +4250 -0
- package/src/vendor/validator/preview.mjs +497 -23
- package/src/vendor/validator/site-context.mjs +97 -0
- package/src/vendor/validator/validate.mjs +214 -14
- package/starter/assets/theme.css +54 -8
- package/starter/layout.liquid +15 -16
- package/starter/manifest.json +10 -5
- package/starter/sections/campaigns.liquid +23 -0
- package/starter/sections/homeHero.liquid +26 -11
- package/starter/sections/people.liquid +1 -1
- package/starter/sections/values.liquid +6 -1
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
// touch a live site, so this renders from an in-memory file map and the page it produces is
|
|
5
5
|
// self-contained and network-dead — a CSP meta of default-src 'none' means the template's CSS
|
|
6
6
|
// cannot fetch, beacon or import anything, and the consumer embeds it in a sandboxed iframe.
|
|
7
|
-
// Islands render as
|
|
7
|
+
// Islands render as realistic, non-interactive fixture skeletons through their public styling
|
|
8
|
+
// classes. Preview HTML carries no runtime and never attempts a platform transaction.
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
8
11
|
import { Liquid } from 'liquidjs';
|
|
9
12
|
import { CONTENT_SLOT, configureDialect, splitIslandParts } from '../engine/dialect.mjs';
|
|
10
13
|
import { LIQUID_BUDGETS } from '../engine/budgets.mjs';
|
|
@@ -12,19 +15,365 @@ import dialect from '../contract/v1/dialect.json' with { type: 'json' };
|
|
|
12
15
|
import sectionCatalogue from '../contract/v1/sections.json' with { type: 'json' };
|
|
13
16
|
import islandRegistry from '../contract/v1/islands.json' with { type: 'json' };
|
|
14
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);
|
|
15
25
|
|
|
16
26
|
const escapeHtml = (s) =>
|
|
17
27
|
String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
18
28
|
|
|
19
|
-
|
|
20
|
-
|
|
29
|
+
// The platform base stylesheet — production loads it on EVERY template page before the theme, so
|
|
30
|
+
// the preview does too: islands and platform components arrive with their real baseline look,
|
|
31
|
+
// wearing the template's tokens, and the theme restyles over it exactly as in production.
|
|
32
|
+
// (Generated copy of src/styles/global.css — scripts/build-preview-base.mjs.)
|
|
33
|
+
let PLATFORM_BASE = '';
|
|
34
|
+
try {
|
|
35
|
+
PLATFORM_BASE = readFileSync(join(import.meta.dirname, 'platform-base.css'), 'utf8');
|
|
36
|
+
} catch {
|
|
37
|
+
// An older vendored copy without the file — the preview degrades to theme-only styling.
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function previewNote(name) {
|
|
41
|
+
return `<span class="p60-preview-badge">${escapeHtml(name.replaceAll('_', ' '))} preview</span>`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function eventCards(events) {
|
|
45
|
+
return events.slice(0, 3).map((event) => `
|
|
46
|
+
<article class="carousel-slide">
|
|
47
|
+
<div class="carousel-caption">
|
|
48
|
+
<p>${escapeHtml(event.venueName ?? (event.online ? 'Online' : 'Event'))}</p>
|
|
49
|
+
<h3>${escapeHtml(event.name)}</h3>
|
|
50
|
+
<a href="${escapeHtml(event.detailHref)}">View event</a>
|
|
51
|
+
</div>
|
|
52
|
+
</article>`).join('');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
|
|
56
|
+
const fixtures = fx;
|
|
57
|
+
const events = fixtures.sections?.events?.events ?? fixtures.pages?.events?.events ?? [];
|
|
58
|
+
const infoEvents = fixtures.sections?.whatsOn?.infoEvents ?? [];
|
|
59
|
+
const articles = fixtures.sections?.articles?.latestArticles ?? [];
|
|
60
|
+
|
|
61
|
+
switch (name) {
|
|
62
|
+
case 'donation_widget':
|
|
63
|
+
return `<section class="donate-card" data-p60-preview-island="donation_widget">
|
|
64
|
+
${previewNote(name)}
|
|
65
|
+
<header class="donate-head"><h3>Make a donation</h3><p class="donate-sub">Your support makes this work possible.</p></header>
|
|
66
|
+
<div class="freq-tabs" aria-label="Donation frequency"><button class="freq-tab" type="button" disabled>One off</button><button class="freq-tab" type="button" disabled>Monthly</button></div>
|
|
67
|
+
<div class="amount-grid"><button class="amount-option" type="button" disabled>£10</button><button class="amount-option" type="button" disabled>£25</button><button class="amount-option" type="button" disabled>£50</button></div>
|
|
68
|
+
<div class="giftaid-box"><strong>Add Gift Aid</strong><p>Eligible UK taxpayers can increase their donation by 25%.</p></div>
|
|
69
|
+
<button class="donate-submit" type="button" disabled>Continue</button>
|
|
70
|
+
<p class="donate-note">Secure payment provided by Port60</p>
|
|
71
|
+
</section>`;
|
|
72
|
+
case 'member_menu':
|
|
73
|
+
// Lives INSIDE the nav row, so the wrapper stays inline and the badge trails the button —
|
|
74
|
+
// block layout here read as a stray element between the nav's last link and Sign in.
|
|
75
|
+
return `<div data-p60-preview-island="member_menu" style="display:inline-flex;align-items:center;gap:8px">
|
|
76
|
+
<button class="nav-p60-signin" type="button" disabled><span class="p60-mark" aria-hidden="true">P</span> Sign in</button>
|
|
77
|
+
${previewNote(name)}
|
|
78
|
+
</div>`;
|
|
79
|
+
case 'events_carousel': {
|
|
80
|
+
const dots = Math.max(Math.min(events.length, 3), 2);
|
|
81
|
+
return `<section class="events-carousel" data-p60-preview-island="events_carousel">
|
|
82
|
+
${previewNote(name)}
|
|
83
|
+
${eventCards(events)}
|
|
84
|
+
<div class="carousel-dots" aria-hidden="true">${Array.from({ length: dots }, () => '<span class="carousel-dot"></span>').join('')}</div>
|
|
85
|
+
</section>`;
|
|
86
|
+
}
|
|
87
|
+
case 'whats_on_strip':
|
|
88
|
+
return `<section class="info-events-bar" data-p60-preview-island="whats_on_strip">
|
|
89
|
+
${previewNote(name)}
|
|
90
|
+
<div class="info-events-inner">${infoEvents.map((event) => `<a class="info-event-chip" href="${escapeHtml(event.detailHref)}"><strong>${escapeHtml(event.name)}</strong><span>${escapeHtml(event.venueName ?? 'Online')}</span></a>`).join('')}</div>
|
|
91
|
+
</section>`;
|
|
92
|
+
case 'latest_articles':
|
|
93
|
+
return `<section data-p60-preview-island="latest_articles">
|
|
94
|
+
${previewNote(name)}
|
|
95
|
+
<div class="article-grid">${articles.map((article) => `<article class="article-card"><div class="article-card-body"><p>${escapeHtml(article.categories?.[0] ?? 'Article')}</p><h3><a href="${escapeHtml(article.href)}">${escapeHtml(article.title)}</a></h3><p>${escapeHtml(article.excerpt ?? '')}</p></div></article>`).join('')}</div>
|
|
96
|
+
<a class="article-strip-cta" href="/articles/all">Read all articles</a>
|
|
97
|
+
</section>`;
|
|
98
|
+
case 'course_enrol':
|
|
99
|
+
return `<section class="course-enrol" data-p60-preview-island="course_enrol">
|
|
100
|
+
${previewNote(name)}
|
|
101
|
+
<div class="course-variant-list"><strong>Choose a session</strong><label><input type="radio" disabled> Saturday mornings</label><label><input type="radio" disabled> Tuesday evenings</label></div>
|
|
102
|
+
<div class="course-subject-list"><strong>Participant</strong><p>One child place</p></div>
|
|
103
|
+
<div class="course-summary"><span>Weekly course</span><strong>£15</strong></div>
|
|
104
|
+
<div class="course-pay-panel"><button type="button" disabled>Continue to enrolment</button></div>
|
|
105
|
+
</section>`;
|
|
106
|
+
case 'article_engagement':
|
|
107
|
+
return `<aside class="article-engage" data-p60-preview-island="article_engagement">
|
|
108
|
+
${previewNote(name)}
|
|
109
|
+
<span class="article-views">82 views</span>
|
|
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>
|
|
111
|
+
</aside>`;
|
|
112
|
+
case 'next_prayer': {
|
|
113
|
+
const time = (fx.layout?.worship?.times ?? [])
|
|
114
|
+
.find((t) => t.name === 'Asr') ?? { name: 'Asr', congregation: '19:00' };
|
|
115
|
+
return `<aside class="next-prayer" data-p60-preview-island="next_prayer">
|
|
116
|
+
${previewNote(name)}
|
|
117
|
+
<span class="next-prayer-label">Next</span><strong class="next-prayer-name">${escapeHtml(time.name)}</strong><span class="next-prayer-time">${escapeHtml(time.congregation ?? time.begins ?? '')}</span><span class="next-prayer-countdown">1h 24m</span>
|
|
118
|
+
</aside>`;
|
|
119
|
+
}
|
|
120
|
+
case 'article_comments':
|
|
121
|
+
return `<section class="article-comments" data-p60-preview-island="article_comments">
|
|
122
|
+
${previewNote(name)}
|
|
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>
|
|
124
|
+
<div class="article-comments-gate"><p class="article-comments-note">Sign in to join the conversation.</p></div>
|
|
125
|
+
</section>`;
|
|
126
|
+
case 'hero_carousel': {
|
|
127
|
+
// Hydrated from the surrounding section's images (the homeHero sample fixture) — real
|
|
128
|
+
// slides through the real styling API, CSS-crossfaded by the preview so it reads as alive.
|
|
129
|
+
const images = Array.isArray(ctx.section?.images) ? ctx.section.images.filter((i) => i?.imageUrl) : [];
|
|
130
|
+
const slides = images.length > 0
|
|
131
|
+
? images.map((image, i) => `<div class="hero-slide${i === 0 ? ' hero-slide--active' : ''} p60-preview-slide"><img class="hero-slide-img" src="${escapeHtml(image.imageUrl)}" alt="${escapeHtml(image.alt ?? '')}"><div class="hero-slide-scrim"></div></div>`).join('')
|
|
132
|
+
: '<div class="hero-slide hero-slide--active"><div class="hero-slide-img p60-preview-hero-image"></div><div class="hero-slide-scrim"></div></div>';
|
|
133
|
+
const dots = Math.max(images.length, 2);
|
|
134
|
+
return `<section class="hero-carousel" data-p60-preview-island="hero_carousel">
|
|
135
|
+
${previewNote(name)}
|
|
136
|
+
<div class="hero-slides">${slides}</div>
|
|
137
|
+
<div class="hero-dots" aria-hidden="true">${Array.from({ length: dots }, (_, i) => `<span class="hero-dot${i === 0 ? ' hero-dot--active' : ''}"></span>`).join('')}</div>
|
|
138
|
+
</section>`;
|
|
139
|
+
}
|
|
140
|
+
case 'newsletter_signup':
|
|
141
|
+
return `<form class="newsletter-signup" data-p60-preview-island="newsletter_signup">
|
|
142
|
+
${previewNote(name)}
|
|
143
|
+
<label class="newsletter-label">Email address</label><div class="newsletter-fields"><input class="newsletter-email" type="email" disabled><button class="newsletter-submit" type="button" disabled>Join our newsletter</button></div>
|
|
144
|
+
<label class="newsletter-consent"><input type="checkbox" disabled><span>Email me about our work and appeals.</span></label>
|
|
145
|
+
</form>`;
|
|
146
|
+
case 'language_switch':
|
|
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>`;
|
|
148
|
+
case 'search':
|
|
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 'volunteer_signup': {
|
|
151
|
+
const opportunities = fx.sections?.volunteering?.opportunities ?? [];
|
|
152
|
+
const options = (opportunities.length ? opportunities : [{ title: 'Garden Volunteer' }])
|
|
153
|
+
.map((o) => `<option>${escapeHtml(o.title)}</option>`).join('');
|
|
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>`;
|
|
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
|
+
}
|
|
180
|
+
case 'form':
|
|
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>`;
|
|
182
|
+
default:
|
|
183
|
+
return `<div class="p60-preview-island" data-p60-preview-island="${escapeHtml(name)}" role="note">${previewNote(name)}</div>`;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Platform surface skeletons ──────────────────────────────────────────────
|
|
188
|
+
// The ROUTED dev preview's answer to "what does X look like in my theme": each platform-owned
|
|
189
|
+
// page as a fixture skeleton through the PRODUCTION class names, so the platform base + the
|
|
190
|
+
// theme's tokens style it exactly as live, wrapped by the template's own layout. Never
|
|
191
|
+
// interactive — the same posture as island skeletons. Where the theme ships its own page
|
|
192
|
+
// template for a surface (events, course, articles, article), that template renders instead.
|
|
193
|
+
|
|
194
|
+
function surfaceDivider(label) {
|
|
195
|
+
return `<div class="p60-preview-divider" role="note">platform page: ${escapeHtml(label)} — styled by your tokens and chrome</div>`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function eventsListingSkeleton(fx = STUDIO_FX) {
|
|
199
|
+
const events = fx.pages?.events?.events ?? [];
|
|
200
|
+
return `${surfaceDivider('events')}<section class="section"><div class="container">
|
|
201
|
+
<h1>What's on</h1>
|
|
202
|
+
<div class="event-grid">${events.map((e) => `
|
|
203
|
+
<article class="event-card">
|
|
204
|
+
<div class="event-card-body">
|
|
205
|
+
<span class="event-card-mode">${escapeHtml(e.registrationMode ?? 'INFO')}</span>
|
|
206
|
+
<h3>${escapeHtml(e.name)}</h3>
|
|
207
|
+
<p class="event-card-when">${escapeHtml(e.venueName ?? (e.online ? 'Online' : ''))}</p>
|
|
208
|
+
<span class="event-action">${e.registrationMode === 'TICKETED' ? 'Get tickets' : 'View event'}</span>
|
|
209
|
+
</div>
|
|
210
|
+
</article>`).join('')}</div>
|
|
211
|
+
</div></section>`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function eventDetailSkeleton(fx = STUDIO_FX) {
|
|
215
|
+
const event = (fx.pages?.events?.events ?? [])
|
|
216
|
+
.find((e) => e.registrationMode === 'TICKETED') ?? { name: 'Fixture Gala', venueName: 'Town Hall' };
|
|
217
|
+
const tier = (name, sub, price, note) => `
|
|
218
|
+
<div class="ticket-type"><div class="tt-main">
|
|
219
|
+
<div class="tt-info"><span class="tt-name">${name}</span><span class="tt-sub">${sub}</span>${note ? `<span class="tt-desc">${note}</span>` : ''}</div>
|
|
220
|
+
<span class="tt-price">${price}</span>
|
|
221
|
+
<div class="tt-qty"><button class="tt-stepper" type="button" disabled>−</button><span>0</span><button class="tt-stepper" type="button" disabled>+</button></div>
|
|
222
|
+
</div></div>`;
|
|
223
|
+
return `${surfaceDivider('event tickets')}<section class="section"><div class="container">
|
|
224
|
+
<article class="event-detail" data-p60-preview-island="event tickets">
|
|
225
|
+
${previewNote('ticket purchase')}
|
|
226
|
+
<h1>${escapeHtml(event.name)}</h1>
|
|
227
|
+
<p class="event-detail-meta">${escapeHtml(event.venueName ?? 'Online')}</p>
|
|
228
|
+
<p class="event-detail-desc">${escapeHtml(event.description ?? 'An evening of music and giving.')}</p>
|
|
229
|
+
<div class="ticket-types">
|
|
230
|
+
<span class="tt-tier">General admission</span>
|
|
231
|
+
${tier('Adult', 'General', '£15', '')}
|
|
232
|
+
${tier('Family', 'General', '£40', 'Admits 2 adults and 2 children')}
|
|
233
|
+
</div>
|
|
234
|
+
<div class="ticket-order-summary"><span>Nothing selected yet</span><strong>£0</strong></div>
|
|
235
|
+
<button class="donate-submit" type="button" disabled>Buy tickets</button>
|
|
236
|
+
</article>
|
|
237
|
+
</div></section>`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function donateSkeleton(fx = STUDIO_FX) {
|
|
241
|
+
const causes = fx.sections?.appealGrid?.causes
|
|
242
|
+
?? fx.sections?.emergency?.causes ?? [];
|
|
243
|
+
return `${surfaceDivider('donate')}<section class="section"><div class="container">
|
|
244
|
+
<h1>Donate</h1>
|
|
245
|
+
<div class="cause-options">${causes.slice(0, 2).map((c) => `
|
|
246
|
+
<article class="cause-option">
|
|
247
|
+
<div class="cause-option-head"><span class="cause-tag">Appeal</span><h3>${escapeHtml(c.title ?? c.name ?? 'Appeal')}</h3></div>
|
|
248
|
+
<p class="cause-option-desc">${escapeHtml(c.text ?? c.description ?? '')}</p>
|
|
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>
|
|
250
|
+
</article>`).join('')}</div>
|
|
251
|
+
${islandSkeleton('donation_widget', {}, fx)}
|
|
252
|
+
</div></section>`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function articlesListingSkeleton(fx = STUDIO_FX) {
|
|
256
|
+
const articles = fx.pages?.articles?.articles ?? [];
|
|
257
|
+
return `${surfaceDivider('articles')}<section class="section"><div class="container">
|
|
258
|
+
<h1>Latest</h1>
|
|
259
|
+
<div class="article-grid">${articles.map((a) => `
|
|
260
|
+
<article class="article-card"><div class="article-card-body">
|
|
261
|
+
<p>${escapeHtml(a.categories?.[0] ?? 'Article')}</p>
|
|
262
|
+
<h3><a href="/articles/${escapeHtml(a.slug ?? '')}">${escapeHtml(a.title)}</a></h3>
|
|
263
|
+
<p>${escapeHtml(a.excerpt ?? '')}</p>
|
|
264
|
+
</div></article>`).join('')}</div>
|
|
265
|
+
</div></section>`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function articleDetailSkeleton(fx = STUDIO_FX) {
|
|
269
|
+
const article = fx.pages?.article?.article ?? { title: 'Fixture article', bodyHtml: '<p>Body</p>' };
|
|
270
|
+
return `${surfaceDivider('article')}<section class="section"><div class="container">
|
|
271
|
+
<article class="article-body">
|
|
272
|
+
<h1>${escapeHtml(article.title)}</h1>
|
|
273
|
+
<p class="article-card-meta">${escapeHtml(article.authorName ?? '')} · ${article.readingMinutes ?? 3} min read</p>
|
|
274
|
+
${article.bodyHtml ?? ''}
|
|
275
|
+
${islandSkeleton('article_engagement', {}, fx)}
|
|
276
|
+
${islandSkeleton('article_comments', {}, fx)}
|
|
277
|
+
</article>
|
|
278
|
+
</div></section>`;
|
|
21
279
|
}
|
|
22
280
|
|
|
23
|
-
function
|
|
281
|
+
function campaignsListingSkeleton(fx = STUDIO_FX) {
|
|
282
|
+
const campaigns = fx.sections?.campaigns?.campaigns ?? [];
|
|
283
|
+
return `${surfaceDivider('campaigns')}<section class="section"><div class="container">
|
|
284
|
+
<h1>${escapeHtml(fx.sections?.campaigns?.campaignsLabel ?? 'Campaigns')}</h1>
|
|
285
|
+
<div class="article-grid">${campaigns.map((c) => `
|
|
286
|
+
<article class="article-card"><div class="article-card-body">
|
|
287
|
+
<h3><a href="/campaigns/${escapeHtml(c.href?.split('/').pop() ?? '')}">${escapeHtml(c.title)}</a></h3>
|
|
288
|
+
${c.summary ? `<p>${escapeHtml(c.summary)}</p>` : ''}
|
|
289
|
+
</div></article>`).join('')}</div>
|
|
290
|
+
</div></section>`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function campaignDetailSkeleton(fx = STUDIO_FX) {
|
|
294
|
+
const campaign = (fx.sections?.campaigns?.campaigns ?? [])[0]
|
|
295
|
+
?? { title: 'Fixture Winter Campaign', summary: '' };
|
|
296
|
+
return `${surfaceDivider('campaign')}<section class="section"><div class="container">
|
|
297
|
+
<h1>${escapeHtml(campaign.title)}</h1>
|
|
298
|
+
${campaign.summary ? `<p>${escapeHtml(campaign.summary)}</p>` : ''}
|
|
299
|
+
${islandSkeleton('form', {}, fx)}
|
|
300
|
+
</div></section>`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function servicesListingSkeleton(fx = STUDIO_FX) {
|
|
304
|
+
const services = fx.sections?.programmes?.services ?? [];
|
|
305
|
+
return `${surfaceDivider('services')}<section class="section"><div class="container">
|
|
306
|
+
<h1>What we do</h1>
|
|
307
|
+
<div class="article-grid">${services.map((s) => `
|
|
308
|
+
<article class="article-card"><div class="article-card-body">
|
|
309
|
+
<h3><a href="${escapeHtml(s.href ?? '#')}">${escapeHtml(s.title)}</a></h3>
|
|
310
|
+
${s.summary ? `<p>${escapeHtml(s.summary)}</p>` : ''}
|
|
311
|
+
</div></article>`).join('')}</div>
|
|
312
|
+
</div></section>`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function serviceDetailSkeleton(fx = STUDIO_FX) {
|
|
316
|
+
const service = (fx.sections?.programmes?.services ?? [])[0]
|
|
317
|
+
?? { title: 'Fixture service', summary: '' };
|
|
318
|
+
return `${surfaceDivider('service page')}<section class="section"><div class="container">
|
|
319
|
+
<article class="article-body">
|
|
320
|
+
<h1>${escapeHtml(service.title)}</h1>
|
|
321
|
+
${service.summary ? `<p>${escapeHtml(service.summary)}</p>` : ''}
|
|
322
|
+
<p>Content pages are written in the workspace's WYSIWYG editor and arrive as sanitised
|
|
323
|
+
HTML — headings, lists, images and embeds render here styled by your theme's typography.</p>
|
|
324
|
+
</article>
|
|
325
|
+
</div></section>`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function courseListingSkeleton(fx = STUDIO_FX) {
|
|
329
|
+
const course = fx.pages?.course?.course ?? { title: 'Fixture course' };
|
|
330
|
+
return `${surfaceDivider('courses')}<section class="section"><div class="container">
|
|
331
|
+
<h1>Courses</h1>
|
|
332
|
+
<article class="event-card"><div class="event-card-body">
|
|
333
|
+
<h3>${escapeHtml(course.title)}</h3>
|
|
334
|
+
<p class="event-card-when">${escapeHtml(course.venue ?? '')}</p>
|
|
335
|
+
<span class="event-action">View course</span>
|
|
336
|
+
</div></article>
|
|
337
|
+
</div></section>`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// surface → { pageTemplate to prefer when the theme declares it, builtin skeleton }
|
|
341
|
+
const SURFACES = {
|
|
342
|
+
events: { template: 'events', builtin: eventsListingSkeleton },
|
|
343
|
+
event: { template: null, builtin: eventDetailSkeleton },
|
|
344
|
+
services: { template: null, builtin: servicesListingSkeleton },
|
|
345
|
+
service: { template: null, builtin: serviceDetailSkeleton },
|
|
346
|
+
donate: { template: null, builtin: donateSkeleton },
|
|
347
|
+
articles: { template: 'articles', builtin: articlesListingSkeleton },
|
|
348
|
+
article: { template: 'article', builtin: articleDetailSkeleton },
|
|
349
|
+
campaigns: { template: null, builtin: campaignsListingSkeleton },
|
|
350
|
+
campaign: { template: null, builtin: campaignDetailSkeleton },
|
|
351
|
+
course: { template: 'course', builtin: courseListingSkeleton },
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
/** The routed dev preview's surface names (plus 'home'). */
|
|
355
|
+
export const PREVIEW_SURFACES = ['home', ...Object.keys(SURFACES)];
|
|
356
|
+
|
|
357
|
+
function surfaceBar(active) {
|
|
358
|
+
const link = (name, href) =>
|
|
359
|
+
`<a href="${href}"${name === active ? ' style="font-weight:700;text-decoration:underline"' : ''}>${name}</a>`;
|
|
360
|
+
const links = [
|
|
361
|
+
link('home', '/'), link('events', '/events'), link('event', '/events?event=fixture'),
|
|
362
|
+
link('services', '/services'), link('service', '/services?service=fixture'),
|
|
363
|
+
link('donate', '/donate'), link('articles', '/articles'), link('article', '/articles/fixture'),
|
|
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>`,
|
|
366
|
+
];
|
|
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">
|
|
368
|
+
<strong style="letter-spacing:.06em;text-transform:uppercase;font-size:10px">Surfaces</strong>${links.join('')}
|
|
369
|
+
</nav>`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function partsToHtml(html, contentHtml, ctx = {}, fx = STUDIO_FX) {
|
|
24
373
|
let out = '';
|
|
25
374
|
for (const part of splitIslandParts(html)) {
|
|
26
375
|
if (part.island === CONTENT_SLOT) out += contentHtml ?? '';
|
|
27
|
-
else if (part.island) out +=
|
|
376
|
+
else if (part.island) out += islandSkeleton(part.island, ctx, fx);
|
|
28
377
|
else out += part.html;
|
|
29
378
|
}
|
|
30
379
|
return out;
|
|
@@ -55,34 +404,118 @@ function knobDefaults(manifest) {
|
|
|
55
404
|
* preview only versions the validator has already passed, so a throw here is a bug report, not
|
|
56
405
|
* a user flow.
|
|
57
406
|
*/
|
|
58
|
-
export async function renderStudioPreview(files) {
|
|
407
|
+
export async function renderStudioPreview(files, options = {}) {
|
|
59
408
|
const manifest = JSON.parse(files['manifest.json']);
|
|
60
409
|
const allIslands = new Set(islandRegistry.islands.map((i) => i.name));
|
|
61
410
|
const liquid = new Liquid({ outputEscape: 'escape', strictFilters: true, ...LIQUID_BUDGETS });
|
|
62
411
|
configureDialect(liquid, dialect, allIslands);
|
|
63
412
|
|
|
64
413
|
const catalogueByType = new Map(sectionCatalogue.sections.map((s) => [s.type, s]));
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
+
}
|
|
457
|
+
|
|
458
|
+
const surface = options.surface ?? 'home';
|
|
459
|
+
let contentHtml;
|
|
460
|
+
if (surface !== 'home' && SURFACES[surface]) {
|
|
461
|
+
// A routed platform surface: the theme's own page template when it ships one, else the
|
|
462
|
+
// platform-page fixture skeleton — either way inside the theme's layout below.
|
|
463
|
+
const def = SURFACES[surface];
|
|
464
|
+
const templateSource = def.template ? files[`pages/${def.template}.liquid`] : null;
|
|
465
|
+
const fixture = def.template ? fx.pages?.[def.template] : null;
|
|
466
|
+
if (templateSource != null && fixture != null
|
|
467
|
+
&& (manifest?.supports?.pageTemplates ?? []).includes(def.template)) {
|
|
468
|
+
const context = { brand, site, ...fixture };
|
|
469
|
+
const rendered = await liquid.parseAndRender(templateSource, context);
|
|
470
|
+
contentHtml = `<div class="p60-preview-divider" role="note">page template: ${escapeHtml(def.template)}</div>`
|
|
471
|
+
+ partsToHtml(rendered, '', context, fx);
|
|
472
|
+
} else {
|
|
473
|
+
contentHtml = def.builtin(fx);
|
|
474
|
+
}
|
|
475
|
+
} else {
|
|
476
|
+
const sectionsHtml = [];
|
|
477
|
+
for (const type of manifest?.supports?.sections ?? []) {
|
|
478
|
+
const entry = catalogueByType.get(type);
|
|
479
|
+
const source = files[`sections/${type}.liquid`];
|
|
480
|
+
if (!entry || source == null) continue;
|
|
481
|
+
const context = {
|
|
482
|
+
section: artOptions ? resolveFixtureArt(entry.sample ?? {}, artOptions) : resolveFixtureArt(entry.sample ?? {}),
|
|
483
|
+
brand,
|
|
484
|
+
site,
|
|
485
|
+
...(fx.sections?.[type] ?? {})
|
|
486
|
+
};
|
|
487
|
+
const rendered = await liquid.parseAndRender(source, context);
|
|
488
|
+
// Island skeletons see the SAME context the section rendered with — that is what lets the
|
|
489
|
+
// hero carousel skeleton hydrate from the section's own photo fixtures.
|
|
490
|
+
sectionsHtml.push(partsToHtml(rendered, '', context, fx));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Declared page templates render too (over their page fixtures) — the loop an author lives in
|
|
494
|
+
// covers every surface they ship, not just home sections. The routed dev preview ALSO serves
|
|
495
|
+
// each at its own path; this keeps the studio's single document complete.
|
|
496
|
+
for (const page of manifest?.supports?.pageTemplates ?? []) {
|
|
497
|
+
const source = files[`pages/${page}.liquid`];
|
|
498
|
+
const fixture = fx.pages?.[page];
|
|
499
|
+
if (source == null || fixture == null) continue;
|
|
500
|
+
const context = { brand, site, ...fixture };
|
|
501
|
+
const rendered = await liquid.parseAndRender(source, context);
|
|
502
|
+
sectionsHtml.push(`<div class="p60-preview-divider" role="note">page template: ${escapeHtml(page)}</div>`
|
|
503
|
+
+ partsToHtml(rendered, '', context, fx));
|
|
504
|
+
}
|
|
505
|
+
contentHtml = sectionsHtml.join('\n');
|
|
74
506
|
}
|
|
75
|
-
const contentHtml = sectionsHtml.join('\n');
|
|
76
507
|
|
|
77
508
|
let bodyHtml;
|
|
78
509
|
if (manifest?.supports?.layout && files['layout.liquid'] != null) {
|
|
79
510
|
const rendered = await liquid.parseAndRender(files['layout.liquid'], {
|
|
80
511
|
brand,
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
84
517
|
});
|
|
85
|
-
bodyHtml = partsToHtml(rendered, contentHtml);
|
|
518
|
+
bodyHtml = partsToHtml(rendered, contentHtml, {}, fx);
|
|
86
519
|
} else {
|
|
87
520
|
bodyHtml = contentHtml;
|
|
88
521
|
}
|
|
@@ -90,22 +523,63 @@ export async function renderStudioPreview(files) {
|
|
|
90
523
|
const themeCss = files['assets/theme.css'] ?? '';
|
|
91
524
|
const { attrs, vars } = knobDefaults(manifest);
|
|
92
525
|
|
|
526
|
+
// The kit's dev server passes the platform's own behaviour runtime (a self-contained bundle) so
|
|
527
|
+
// authors see their carousels, reveals and tabs living locally. The document stays network-dead
|
|
528
|
+
// — the ONLY script it can run is the inline platform bundle; studio and demo previews pass
|
|
529
|
+
// nothing and keep the fully script-free CSP. Without the runtime, a CSS-only crossfade
|
|
530
|
+
// approximates behaviour carousels so a static preview still reads as alive.
|
|
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(' ');
|
|
547
|
+
const csp = runtime
|
|
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};`;
|
|
550
|
+
const motionApproximation = runtime ? '' : `
|
|
551
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
552
|
+
[data-p60-carousel] > [data-p60-slide], .p60-preview-slide { animation: p60-preview-crossfade 8s infinite; }
|
|
553
|
+
[data-p60-carousel] > [data-p60-slide]:nth-child(2), .p60-preview-slide:nth-child(2) { animation-delay: -4s; }
|
|
554
|
+
@keyframes p60-preview-crossfade { 0%, 45% { opacity: 1; } 55%, 95% { opacity: 0.25; } 100% { opacity: 1; } }
|
|
555
|
+
}`;
|
|
556
|
+
|
|
93
557
|
return `<!doctype html>
|
|
94
558
|
<html lang="en">
|
|
95
559
|
<head>
|
|
96
560
|
<meta charset="utf-8">
|
|
97
561
|
<!-- Network-dead by design: the template's CSS can style, never fetch. -->
|
|
98
|
-
<meta http-equiv="Content-Security-Policy" content="
|
|
562
|
+
<meta http-equiv="Content-Security-Policy" content="${csp}">
|
|
99
563
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
100
564
|
<title>${escapeHtml(manifest?.label ?? manifest?.name ?? 'Template preview')} — studio preview</title>
|
|
101
565
|
<style>${vars ? `:root { ${vars} }` : ''}
|
|
102
|
-
.p60-preview-
|
|
103
|
-
|
|
566
|
+
.p60-preview-badge { display: inline-flex; align-items: center; width: fit-content; margin: 0 0 8px;
|
|
567
|
+
border: 1px solid currentColor; border-radius: 999px; padding: 3px 8px; font: 600 10px/1.2 system-ui, sans-serif;
|
|
568
|
+
letter-spacing: .06em; opacity: .62; text-transform: uppercase; }
|
|
569
|
+
.p60-preview-island { border: 1px solid currentColor; border-radius: 8px; padding: 18px; margin: 8px 0;
|
|
570
|
+
font: 13px/1.4 system-ui, sans-serif; opacity: .8; text-align: center; }
|
|
571
|
+
.p60-preview-hero-image { min-height: 360px; background: linear-gradient(135deg, #596f65, #bdab7c); }
|
|
572
|
+
.p60-preview-divider { margin: 32px 0 8px; padding: 6px 10px; border: 1px dashed currentColor; border-radius: 6px;
|
|
573
|
+
font: 600 11px/1.2 system-ui, sans-serif; letter-spacing: .06em; opacity: .62; text-transform: uppercase; }
|
|
574
|
+
[data-p60-preview-island] button:disabled, [data-p60-preview-island] input:disabled { opacity: 1; }${motionApproximation}
|
|
104
575
|
</style>
|
|
576
|
+
<style>${PLATFORM_BASE}</style>
|
|
105
577
|
<style>${themeCss}</style>
|
|
106
578
|
</head>
|
|
107
579
|
<body ${attrs}>
|
|
580
|
+
${options.surface ? surfaceBar(surface) : ''}
|
|
108
581
|
${bodyHtml}
|
|
582
|
+
${runtime ? `<script>${runtime}\np60Behaviors.initBehaviors();</script>` : ''}
|
|
109
583
|
</body>
|
|
110
584
|
</html>`;
|
|
111
585
|
}
|