@port60/template-kit 0.7.0 → 0.9.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/package.json +1 -1
- package/src/commands/create.mjs +4 -1
- package/src/commands/dev.mjs +43 -5
- package/src/lib/agentsMd.mjs +32 -3
- package/src/vendor/contract/v1/behaviours.json +129 -0
- package/src/vendor/contract/v1/context.json +459 -7
- package/src/vendor/contract/v1/islands.json +125 -0
- package/src/vendor/contract/v1/layout.json +25 -0
- package/src/vendor/contract/v1/manifest.schema.json +51 -5
- package/src/vendor/contract/v1/sections.json +114 -1
- package/src/vendor/validator/behaviors-runtime.js +2 -0
- package/src/vendor/validator/platform-base.css +4229 -0
- package/src/vendor/validator/preview.mjs +399 -18
- package/src/vendor/validator/validate.mjs +149 -6
- package/starter/assets/theme.css +46 -0
- package/starter/layout.liquid +16 -6
- package/starter/manifest.json +10 -5
- package/starter/sections/campaigns.liquid +23 -0
- package/starter/sections/homeHero.liquid +16 -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';
|
|
@@ -16,15 +19,327 @@ import contextContract from '../contract/v1/context.json' with { type: 'json' };
|
|
|
16
19
|
const escapeHtml = (s) =>
|
|
17
20
|
String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
// The platform base stylesheet — production loads it on EVERY template page before the theme, so
|
|
23
|
+
// the preview does too: islands and platform components arrive with their real baseline look,
|
|
24
|
+
// wearing the template's tokens, and the theme restyles over it exactly as in production.
|
|
25
|
+
// (Generated copy of src/styles/global.css — scripts/build-preview-base.mjs.)
|
|
26
|
+
let PLATFORM_BASE = '';
|
|
27
|
+
try {
|
|
28
|
+
PLATFORM_BASE = readFileSync(join(import.meta.dirname, 'platform-base.css'), 'utf8');
|
|
29
|
+
} catch {
|
|
30
|
+
// An older vendored copy without the file — the preview degrades to theme-only styling.
|
|
21
31
|
}
|
|
22
32
|
|
|
23
|
-
function
|
|
33
|
+
function previewNote(name) {
|
|
34
|
+
return `<span class="p60-preview-badge">${escapeHtml(name.replaceAll('_', ' '))} preview</span>`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function eventCards(events) {
|
|
38
|
+
return events.slice(0, 2).map((event) => `
|
|
39
|
+
<article class="carousel-slide">
|
|
40
|
+
<div class="carousel-caption">
|
|
41
|
+
<p>${escapeHtml(event.venueName ?? (event.online ? 'Online' : 'Event'))}</p>
|
|
42
|
+
<h3>${escapeHtml(event.name)}</h3>
|
|
43
|
+
<a href="${escapeHtml(event.detailHref)}">View event</a>
|
|
44
|
+
</div>
|
|
45
|
+
</article>`).join('');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function islandSkeleton(name, ctx = {}) {
|
|
49
|
+
const fixtures = contextContract.fixtures;
|
|
50
|
+
const events = fixtures.sections?.events?.events ?? fixtures.pages?.events?.events ?? [];
|
|
51
|
+
const infoEvents = fixtures.sections?.whatsOn?.infoEvents ?? [];
|
|
52
|
+
const articles = fixtures.sections?.articles?.latestArticles ?? [];
|
|
53
|
+
|
|
54
|
+
switch (name) {
|
|
55
|
+
case 'donation_widget':
|
|
56
|
+
return `<section class="donate-card" data-p60-preview-island="donation_widget">
|
|
57
|
+
${previewNote(name)}
|
|
58
|
+
<header class="donate-head"><h3>Make a donation</h3><p class="donate-sub">Your support makes this work possible.</p></header>
|
|
59
|
+
<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>
|
|
60
|
+
<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>
|
|
61
|
+
<div class="giftaid-box"><strong>Add Gift Aid</strong><p>Eligible UK taxpayers can increase their donation by 25%.</p></div>
|
|
62
|
+
<button class="donate-submit" type="button" disabled>Continue</button>
|
|
63
|
+
<p class="donate-note">Secure payment provided by Port60</p>
|
|
64
|
+
</section>`;
|
|
65
|
+
case 'member_menu':
|
|
66
|
+
// Lives INSIDE the nav row, so the wrapper stays inline and the badge trails the button —
|
|
67
|
+
// block layout here read as a stray element between the nav's last link and Sign in.
|
|
68
|
+
return `<div data-p60-preview-island="member_menu" style="display:inline-flex;align-items:center;gap:8px">
|
|
69
|
+
<button class="nav-p60-signin" type="button" disabled><span class="p60-mark" aria-hidden="true">P</span> Sign in</button>
|
|
70
|
+
${previewNote(name)}
|
|
71
|
+
</div>`;
|
|
72
|
+
case 'events_carousel':
|
|
73
|
+
return `<section class="events-carousel" data-p60-preview-island="events_carousel">
|
|
74
|
+
${previewNote(name)}
|
|
75
|
+
${eventCards(events)}
|
|
76
|
+
<div class="carousel-dots" aria-hidden="true"><span class="carousel-dot"></span><span class="carousel-dot"></span></div>
|
|
77
|
+
</section>`;
|
|
78
|
+
case 'whats_on_strip':
|
|
79
|
+
return `<section class="info-events-bar" data-p60-preview-island="whats_on_strip">
|
|
80
|
+
${previewNote(name)}
|
|
81
|
+
<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>
|
|
82
|
+
</section>`;
|
|
83
|
+
case 'latest_articles':
|
|
84
|
+
return `<section data-p60-preview-island="latest_articles">
|
|
85
|
+
${previewNote(name)}
|
|
86
|
+
<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>
|
|
87
|
+
<a class="article-strip-cta" href="/articles/all">Read all articles</a>
|
|
88
|
+
</section>`;
|
|
89
|
+
case 'course_enrol':
|
|
90
|
+
return `<section class="course-enrol" data-p60-preview-island="course_enrol">
|
|
91
|
+
${previewNote(name)}
|
|
92
|
+
<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>
|
|
93
|
+
<div class="course-subject-list"><strong>Participant</strong><p>One child place</p></div>
|
|
94
|
+
<div class="course-summary"><span>Weekly course</span><strong>£15</strong></div>
|
|
95
|
+
<div class="course-pay-panel"><button type="button" disabled>Continue to enrolment</button></div>
|
|
96
|
+
</section>`;
|
|
97
|
+
case 'article_engagement':
|
|
98
|
+
return `<aside class="article-engage" data-p60-preview-island="article_engagement">
|
|
99
|
+
${previewNote(name)}
|
|
100
|
+
<span class="article-views">82 views</span>
|
|
101
|
+
<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
|
+
</aside>`;
|
|
103
|
+
case 'next_prayer': {
|
|
104
|
+
const time = (contextContract.fixtures.layout?.worship?.times ?? [])
|
|
105
|
+
.find((t) => t.name === 'Asr') ?? { name: 'Asr', congregation: '19:00' };
|
|
106
|
+
return `<aside class="next-prayer" data-p60-preview-island="next_prayer">
|
|
107
|
+
${previewNote(name)}
|
|
108
|
+
<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>
|
|
109
|
+
</aside>`;
|
|
110
|
+
}
|
|
111
|
+
case 'article_comments':
|
|
112
|
+
return `<section class="article-comments" data-p60-preview-island="article_comments">
|
|
113
|
+
${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>
|
|
115
|
+
<div class="article-comments-gate"><p class="article-comments-note">Sign in to join the conversation.</p></div>
|
|
116
|
+
</section>`;
|
|
117
|
+
case 'hero_carousel': {
|
|
118
|
+
// Hydrated from the surrounding section's images (the homeHero sample fixture) — real
|
|
119
|
+
// slides through the real styling API, CSS-crossfaded by the preview so it reads as alive.
|
|
120
|
+
const images = Array.isArray(ctx.section?.images) ? ctx.section.images.filter((i) => i?.imageUrl) : [];
|
|
121
|
+
const slides = images.length > 0
|
|
122
|
+
? 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('')
|
|
123
|
+
: '<div class="hero-slide hero-slide--active"><div class="hero-slide-img p60-preview-hero-image"></div><div class="hero-slide-scrim"></div></div>';
|
|
124
|
+
const dots = Math.max(images.length, 2);
|
|
125
|
+
return `<section class="hero-carousel" data-p60-preview-island="hero_carousel">
|
|
126
|
+
${previewNote(name)}
|
|
127
|
+
<div class="hero-slides">${slides}</div>
|
|
128
|
+
<div class="hero-dots" aria-hidden="true">${Array.from({ length: dots }, (_, i) => `<span class="hero-dot${i === 0 ? ' hero-dot--active' : ''}"></span>`).join('')}</div>
|
|
129
|
+
</section>`;
|
|
130
|
+
}
|
|
131
|
+
case 'newsletter_signup':
|
|
132
|
+
return `<form class="newsletter-signup" data-p60-preview-island="newsletter_signup">
|
|
133
|
+
${previewNote(name)}
|
|
134
|
+
<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>
|
|
135
|
+
<label class="newsletter-consent"><input type="checkbox" disabled><span>Email me about our work and appeals.</span></label>
|
|
136
|
+
</form>`;
|
|
137
|
+
case 'language_switch':
|
|
138
|
+
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
|
+
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>`;
|
|
146
|
+
}
|
|
147
|
+
case 'form':
|
|
148
|
+
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
|
+
default:
|
|
150
|
+
return `<div class="p60-preview-island" data-p60-preview-island="${escapeHtml(name)}" role="note">${previewNote(name)}</div>`;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Platform surface skeletons ──────────────────────────────────────────────
|
|
155
|
+
// The ROUTED dev preview's answer to "what does X look like in my theme": each platform-owned
|
|
156
|
+
// page as a fixture skeleton through the PRODUCTION class names, so the platform base + the
|
|
157
|
+
// theme's tokens style it exactly as live, wrapped by the template's own layout. Never
|
|
158
|
+
// interactive — the same posture as island skeletons. Where the theme ships its own page
|
|
159
|
+
// template for a surface (events, course, articles, article), that template renders instead.
|
|
160
|
+
|
|
161
|
+
function surfaceDivider(label) {
|
|
162
|
+
return `<div class="p60-preview-divider" role="note">platform page: ${escapeHtml(label)} — styled by your tokens and chrome</div>`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function eventsListingSkeleton() {
|
|
166
|
+
const events = contextContract.fixtures.pages?.events?.events ?? [];
|
|
167
|
+
return `${surfaceDivider('events')}<section class="section"><div class="container">
|
|
168
|
+
<h1>What's on</h1>
|
|
169
|
+
<div class="event-grid">${events.map((e) => `
|
|
170
|
+
<article class="event-card">
|
|
171
|
+
<div class="event-card-body">
|
|
172
|
+
<span class="event-card-mode">${escapeHtml(e.registrationMode ?? 'INFO')}</span>
|
|
173
|
+
<h3>${escapeHtml(e.name)}</h3>
|
|
174
|
+
<p class="event-card-when">${escapeHtml(e.venueName ?? (e.online ? 'Online' : ''))}</p>
|
|
175
|
+
<span class="event-action">${e.registrationMode === 'TICKETED' ? 'Get tickets' : 'View event'}</span>
|
|
176
|
+
</div>
|
|
177
|
+
</article>`).join('')}</div>
|
|
178
|
+
</div></section>`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function eventDetailSkeleton() {
|
|
182
|
+
const event = (contextContract.fixtures.pages?.events?.events ?? [])
|
|
183
|
+
.find((e) => e.registrationMode === 'TICKETED') ?? { name: 'Fixture Gala', venueName: 'Town Hall' };
|
|
184
|
+
const tier = (name, sub, price, note) => `
|
|
185
|
+
<div class="ticket-type"><div class="tt-main">
|
|
186
|
+
<div class="tt-info"><span class="tt-name">${name}</span><span class="tt-sub">${sub}</span>${note ? `<span class="tt-desc">${note}</span>` : ''}</div>
|
|
187
|
+
<span class="tt-price">${price}</span>
|
|
188
|
+
<div class="tt-qty"><button class="tt-stepper" type="button" disabled>−</button><span>0</span><button class="tt-stepper" type="button" disabled>+</button></div>
|
|
189
|
+
</div></div>`;
|
|
190
|
+
return `${surfaceDivider('event tickets')}<section class="section"><div class="container">
|
|
191
|
+
<article class="event-detail" data-p60-preview-island="event tickets">
|
|
192
|
+
${previewNote('ticket purchase')}
|
|
193
|
+
<h1>${escapeHtml(event.name)}</h1>
|
|
194
|
+
<p class="event-detail-meta">${escapeHtml(event.venueName ?? 'Online')}</p>
|
|
195
|
+
<p class="event-detail-desc">${escapeHtml(event.description ?? 'An evening of music and giving.')}</p>
|
|
196
|
+
<div class="ticket-types">
|
|
197
|
+
<span class="tt-tier">General admission</span>
|
|
198
|
+
${tier('Adult', 'General', '£15', '')}
|
|
199
|
+
${tier('Family', 'General', '£40', 'Admits 2 adults and 2 children')}
|
|
200
|
+
</div>
|
|
201
|
+
<div class="ticket-order-summary"><span>Nothing selected yet</span><strong>£0</strong></div>
|
|
202
|
+
<button class="donate-submit" type="button" disabled>Buy tickets</button>
|
|
203
|
+
</article>
|
|
204
|
+
</div></section>`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function donateSkeleton() {
|
|
208
|
+
const causes = contextContract.fixtures.sections?.appealGrid?.causes
|
|
209
|
+
?? contextContract.fixtures.sections?.emergency?.causes ?? [];
|
|
210
|
+
return `${surfaceDivider('donate')}<section class="section"><div class="container">
|
|
211
|
+
<h1>Donate</h1>
|
|
212
|
+
<div class="cause-options">${causes.slice(0, 2).map((c) => `
|
|
213
|
+
<article class="cause-option">
|
|
214
|
+
<div class="cause-option-head"><span class="cause-tag">Appeal</span><h3>${escapeHtml(c.title ?? c.name ?? 'Appeal')}</h3></div>
|
|
215
|
+
<p class="cause-option-desc">${escapeHtml(c.text ?? c.description ?? '')}</p>
|
|
216
|
+
<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
|
+
</article>`).join('')}</div>
|
|
218
|
+
${islandSkeleton('donation_widget')}
|
|
219
|
+
</div></section>`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function articlesListingSkeleton() {
|
|
223
|
+
const articles = contextContract.fixtures.pages?.articles?.articles ?? [];
|
|
224
|
+
return `${surfaceDivider('articles')}<section class="section"><div class="container">
|
|
225
|
+
<h1>Latest</h1>
|
|
226
|
+
<div class="article-grid">${articles.map((a) => `
|
|
227
|
+
<article class="article-card"><div class="article-card-body">
|
|
228
|
+
<p>${escapeHtml(a.categories?.[0] ?? 'Article')}</p>
|
|
229
|
+
<h3><a href="/articles/${escapeHtml(a.slug ?? '')}">${escapeHtml(a.title)}</a></h3>
|
|
230
|
+
<p>${escapeHtml(a.excerpt ?? '')}</p>
|
|
231
|
+
</div></article>`).join('')}</div>
|
|
232
|
+
</div></section>`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function articleDetailSkeleton() {
|
|
236
|
+
const article = contextContract.fixtures.pages?.article?.article ?? { title: 'Fixture article', bodyHtml: '<p>Body</p>' };
|
|
237
|
+
return `${surfaceDivider('article')}<section class="section"><div class="container">
|
|
238
|
+
<article class="article-body">
|
|
239
|
+
<h1>${escapeHtml(article.title)}</h1>
|
|
240
|
+
<p class="article-card-meta">${escapeHtml(article.authorName ?? '')} · ${article.readingMinutes ?? 3} min read</p>
|
|
241
|
+
${article.bodyHtml ?? ''}
|
|
242
|
+
${islandSkeleton('article_engagement')}
|
|
243
|
+
${islandSkeleton('article_comments')}
|
|
244
|
+
</article>
|
|
245
|
+
</div></section>`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function campaignsListingSkeleton() {
|
|
249
|
+
const campaigns = contextContract.fixtures.sections?.campaigns?.campaigns ?? [];
|
|
250
|
+
return `${surfaceDivider('campaigns')}<section class="section"><div class="container">
|
|
251
|
+
<h1>${escapeHtml(contextContract.fixtures.sections?.campaigns?.campaignsLabel ?? 'Campaigns')}</h1>
|
|
252
|
+
<div class="article-grid">${campaigns.map((c) => `
|
|
253
|
+
<article class="article-card"><div class="article-card-body">
|
|
254
|
+
<h3><a href="/campaigns/${escapeHtml(c.href?.split('/').pop() ?? '')}">${escapeHtml(c.title)}</a></h3>
|
|
255
|
+
${c.summary ? `<p>${escapeHtml(c.summary)}</p>` : ''}
|
|
256
|
+
</div></article>`).join('')}</div>
|
|
257
|
+
</div></section>`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function campaignDetailSkeleton() {
|
|
261
|
+
const campaign = (contextContract.fixtures.sections?.campaigns?.campaigns ?? [])[0]
|
|
262
|
+
?? { title: 'Fixture Winter Campaign', summary: '' };
|
|
263
|
+
return `${surfaceDivider('campaign')}<section class="section"><div class="container">
|
|
264
|
+
<h1>${escapeHtml(campaign.title)}</h1>
|
|
265
|
+
${campaign.summary ? `<p>${escapeHtml(campaign.summary)}</p>` : ''}
|
|
266
|
+
${islandSkeleton('form')}
|
|
267
|
+
</div></section>`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function servicesListingSkeleton() {
|
|
271
|
+
const services = contextContract.fixtures.sections?.programmes?.services ?? [];
|
|
272
|
+
return `${surfaceDivider('services')}<section class="section"><div class="container">
|
|
273
|
+
<h1>What we do</h1>
|
|
274
|
+
<div class="article-grid">${services.map((s) => `
|
|
275
|
+
<article class="article-card"><div class="article-card-body">
|
|
276
|
+
<h3><a href="${escapeHtml(s.href ?? '#')}">${escapeHtml(s.title)}</a></h3>
|
|
277
|
+
${s.summary ? `<p>${escapeHtml(s.summary)}</p>` : ''}
|
|
278
|
+
</div></article>`).join('')}</div>
|
|
279
|
+
</div></section>`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function serviceDetailSkeleton() {
|
|
283
|
+
const service = (contextContract.fixtures.sections?.programmes?.services ?? [])[0]
|
|
284
|
+
?? { title: 'Fixture service', summary: '' };
|
|
285
|
+
return `${surfaceDivider('service page')}<section class="section"><div class="container">
|
|
286
|
+
<article class="article-body">
|
|
287
|
+
<h1>${escapeHtml(service.title)}</h1>
|
|
288
|
+
${service.summary ? `<p>${escapeHtml(service.summary)}</p>` : ''}
|
|
289
|
+
<p>Content pages are written in the workspace's WYSIWYG editor and arrive as sanitised
|
|
290
|
+
HTML — headings, lists, images and embeds render here styled by your theme's typography.</p>
|
|
291
|
+
</article>
|
|
292
|
+
</div></section>`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function courseListingSkeleton() {
|
|
296
|
+
const course = contextContract.fixtures.pages?.course?.course ?? { title: 'Fixture course' };
|
|
297
|
+
return `${surfaceDivider('courses')}<section class="section"><div class="container">
|
|
298
|
+
<h1>Courses</h1>
|
|
299
|
+
<article class="event-card"><div class="event-card-body">
|
|
300
|
+
<h3>${escapeHtml(course.title)}</h3>
|
|
301
|
+
<p class="event-card-when">${escapeHtml(course.venue ?? '')}</p>
|
|
302
|
+
<span class="event-action">View course</span>
|
|
303
|
+
</div></article>
|
|
304
|
+
</div></section>`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// surface → { pageTemplate to prefer when the theme declares it, builtin skeleton }
|
|
308
|
+
const SURFACES = {
|
|
309
|
+
events: { template: 'events', builtin: eventsListingSkeleton },
|
|
310
|
+
event: { template: null, builtin: eventDetailSkeleton },
|
|
311
|
+
services: { template: null, builtin: servicesListingSkeleton },
|
|
312
|
+
service: { template: null, builtin: serviceDetailSkeleton },
|
|
313
|
+
donate: { template: null, builtin: donateSkeleton },
|
|
314
|
+
articles: { template: 'articles', builtin: articlesListingSkeleton },
|
|
315
|
+
article: { template: 'article', builtin: articleDetailSkeleton },
|
|
316
|
+
campaigns: { template: null, builtin: campaignsListingSkeleton },
|
|
317
|
+
campaign: { template: null, builtin: campaignDetailSkeleton },
|
|
318
|
+
course: { template: 'course', builtin: courseListingSkeleton },
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
/** The routed dev preview's surface names (plus 'home'). */
|
|
322
|
+
export const PREVIEW_SURFACES = ['home', ...Object.keys(SURFACES)];
|
|
323
|
+
|
|
324
|
+
function surfaceBar(active) {
|
|
325
|
+
const link = (name, href) =>
|
|
326
|
+
`<a href="${href}"${name === active ? ' style="font-weight:700;text-decoration:underline"' : ''}>${name}</a>`;
|
|
327
|
+
const links = [
|
|
328
|
+
link('home', '/'), link('events', '/events'), link('event', '/events?event=fixture'),
|
|
329
|
+
link('services', '/services'), link('service', '/services?service=fixture'),
|
|
330
|
+
link('donate', '/donate'), link('articles', '/articles'), link('article', '/articles/fixture'),
|
|
331
|
+
link('campaigns', '/campaigns'), link('campaign', '/campaigns/fixture'), link('course', '/courses'),
|
|
332
|
+
];
|
|
333
|
+
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
|
+
<strong style="letter-spacing:.06em;text-transform:uppercase;font-size:10px">Surfaces</strong>${links.join('')}
|
|
335
|
+
</nav>`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function partsToHtml(html, contentHtml, ctx = {}) {
|
|
24
339
|
let out = '';
|
|
25
340
|
for (const part of splitIslandParts(html)) {
|
|
26
341
|
if (part.island === CONTENT_SLOT) out += contentHtml ?? '';
|
|
27
|
-
else if (part.island) out +=
|
|
342
|
+
else if (part.island) out += islandSkeleton(part.island, ctx);
|
|
28
343
|
else out += part.html;
|
|
29
344
|
}
|
|
30
345
|
return out;
|
|
@@ -55,7 +370,7 @@ function knobDefaults(manifest) {
|
|
|
55
370
|
* preview only versions the validator has already passed, so a throw here is a bug report, not
|
|
56
371
|
* a user flow.
|
|
57
372
|
*/
|
|
58
|
-
export async function renderStudioPreview(files) {
|
|
373
|
+
export async function renderStudioPreview(files, options = {}) {
|
|
59
374
|
const manifest = JSON.parse(files['manifest.json']);
|
|
60
375
|
const allIslands = new Set(islandRegistry.islands.map((i) => i.name));
|
|
61
376
|
const liquid = new Liquid({ outputEscape: 'escape', strictFilters: true, ...LIQUID_BUDGETS });
|
|
@@ -64,15 +379,54 @@ export async function renderStudioPreview(files) {
|
|
|
64
379
|
const catalogueByType = new Map(sectionCatalogue.sections.map((s) => [s.type, s]));
|
|
65
380
|
const brand = contextContract.fixtures.brand;
|
|
66
381
|
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
382
|
+
const surface = options.surface ?? 'home';
|
|
383
|
+
let contentHtml;
|
|
384
|
+
if (surface !== 'home' && SURFACES[surface]) {
|
|
385
|
+
// A routed platform surface: the theme's own page template when it ships one, else the
|
|
386
|
+
// platform-page fixture skeleton — either way inside the theme's layout below.
|
|
387
|
+
const def = SURFACES[surface];
|
|
388
|
+
const templateSource = def.template ? files[`pages/${def.template}.liquid`] : null;
|
|
389
|
+
const fixture = def.template ? contextContract.fixtures.pages?.[def.template] : null;
|
|
390
|
+
if (templateSource != null && fixture != null
|
|
391
|
+
&& (manifest?.supports?.pageTemplates ?? []).includes(def.template)) {
|
|
392
|
+
const context = { brand, ...fixture };
|
|
393
|
+
const rendered = await liquid.parseAndRender(templateSource, context);
|
|
394
|
+
contentHtml = `<div class="p60-preview-divider" role="note">page template: ${escapeHtml(def.template)}</div>`
|
|
395
|
+
+ partsToHtml(rendered, '', context);
|
|
396
|
+
} else {
|
|
397
|
+
contentHtml = def.builtin();
|
|
398
|
+
}
|
|
399
|
+
} else {
|
|
400
|
+
const sectionsHtml = [];
|
|
401
|
+
for (const type of manifest?.supports?.sections ?? []) {
|
|
402
|
+
const entry = catalogueByType.get(type);
|
|
403
|
+
const source = files[`sections/${type}.liquid`];
|
|
404
|
+
if (!entry || source == null) continue;
|
|
405
|
+
const context = {
|
|
406
|
+
section: entry.sample ?? {},
|
|
407
|
+
brand,
|
|
408
|
+
...(contextContract.fixtures.sections?.[type] ?? {})
|
|
409
|
+
};
|
|
410
|
+
const rendered = await liquid.parseAndRender(source, context);
|
|
411
|
+
// Island skeletons see the SAME context the section rendered with — that is what lets the
|
|
412
|
+
// hero carousel skeleton hydrate from the section's own photo fixtures.
|
|
413
|
+
sectionsHtml.push(partsToHtml(rendered, '', context));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Declared page templates render too (over their page fixtures) — the loop an author lives in
|
|
417
|
+
// covers every surface they ship, not just home sections. The routed dev preview ALSO serves
|
|
418
|
+
// each at its own path; this keeps the studio's single document complete.
|
|
419
|
+
for (const page of manifest?.supports?.pageTemplates ?? []) {
|
|
420
|
+
const source = files[`pages/${page}.liquid`];
|
|
421
|
+
const fixture = contextContract.fixtures.pages?.[page];
|
|
422
|
+
if (source == null || fixture == null) continue;
|
|
423
|
+
const context = { brand, ...fixture };
|
|
424
|
+
const rendered = await liquid.parseAndRender(source, context);
|
|
425
|
+
sectionsHtml.push(`<div class="p60-preview-divider" role="note">page template: ${escapeHtml(page)}</div>`
|
|
426
|
+
+ partsToHtml(rendered, '', context));
|
|
427
|
+
}
|
|
428
|
+
contentHtml = sectionsHtml.join('\n');
|
|
74
429
|
}
|
|
75
|
-
const contentHtml = sectionsHtml.join('\n');
|
|
76
430
|
|
|
77
431
|
let bodyHtml;
|
|
78
432
|
if (manifest?.supports?.layout && files['layout.liquid'] != null) {
|
|
@@ -80,7 +434,8 @@ export async function renderStudioPreview(files) {
|
|
|
80
434
|
brand,
|
|
81
435
|
nav: contextContract.fixtures.layout.nav,
|
|
82
436
|
socials: contextContract.fixtures.layout.socials ?? [],
|
|
83
|
-
worship: manifest?.supports?.worship ? (contextContract.fixtures.layout.worship ?? null) : null
|
|
437
|
+
worship: manifest?.supports?.worship ? (contextContract.fixtures.layout.worship ?? null) : null,
|
|
438
|
+
locale: contextContract.fixtures.layout.locale
|
|
84
439
|
});
|
|
85
440
|
bodyHtml = partsToHtml(rendered, contentHtml);
|
|
86
441
|
} else {
|
|
@@ -90,22 +445,48 @@ export async function renderStudioPreview(files) {
|
|
|
90
445
|
const themeCss = files['assets/theme.css'] ?? '';
|
|
91
446
|
const { attrs, vars } = knobDefaults(manifest);
|
|
92
447
|
|
|
448
|
+
// The kit's dev server passes the platform's own behaviour runtime (a self-contained bundle) so
|
|
449
|
+
// authors see their carousels, reveals and tabs living locally. The document stays network-dead
|
|
450
|
+
// — the ONLY script it can run is the inline platform bundle; studio and demo previews pass
|
|
451
|
+
// nothing and keep the fully script-free CSP. Without the runtime, a CSS-only crossfade
|
|
452
|
+
// approximates behaviour carousels so a static preview still reads as alive.
|
|
453
|
+
const runtime = options.behaviorsRuntime ?? null;
|
|
454
|
+
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:;";
|
|
457
|
+
const motionApproximation = runtime ? '' : `
|
|
458
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
459
|
+
[data-p60-carousel] > [data-p60-slide], .p60-preview-slide { animation: p60-preview-crossfade 8s infinite; }
|
|
460
|
+
[data-p60-carousel] > [data-p60-slide]:nth-child(2), .p60-preview-slide:nth-child(2) { animation-delay: -4s; }
|
|
461
|
+
@keyframes p60-preview-crossfade { 0%, 45% { opacity: 1; } 55%, 95% { opacity: 0.25; } 100% { opacity: 1; } }
|
|
462
|
+
}`;
|
|
463
|
+
|
|
93
464
|
return `<!doctype html>
|
|
94
465
|
<html lang="en">
|
|
95
466
|
<head>
|
|
96
467
|
<meta charset="utf-8">
|
|
97
468
|
<!-- Network-dead by design: the template's CSS can style, never fetch. -->
|
|
98
|
-
<meta http-equiv="Content-Security-Policy" content="
|
|
469
|
+
<meta http-equiv="Content-Security-Policy" content="${csp}">
|
|
99
470
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
100
471
|
<title>${escapeHtml(manifest?.label ?? manifest?.name ?? 'Template preview')} — studio preview</title>
|
|
101
472
|
<style>${vars ? `:root { ${vars} }` : ''}
|
|
102
|
-
.p60-preview-
|
|
103
|
-
|
|
473
|
+
.p60-preview-badge { display: inline-flex; align-items: center; width: fit-content; margin: 0 0 8px;
|
|
474
|
+
border: 1px solid currentColor; border-radius: 999px; padding: 3px 8px; font: 600 10px/1.2 system-ui, sans-serif;
|
|
475
|
+
letter-spacing: .06em; opacity: .62; text-transform: uppercase; }
|
|
476
|
+
.p60-preview-island { border: 1px solid currentColor; border-radius: 8px; padding: 18px; margin: 8px 0;
|
|
477
|
+
font: 13px/1.4 system-ui, sans-serif; opacity: .8; text-align: center; }
|
|
478
|
+
.p60-preview-hero-image { min-height: 360px; background: linear-gradient(135deg, #596f65, #bdab7c); }
|
|
479
|
+
.p60-preview-divider { margin: 32px 0 8px; padding: 6px 10px; border: 1px dashed currentColor; border-radius: 6px;
|
|
480
|
+
font: 600 11px/1.2 system-ui, sans-serif; letter-spacing: .06em; opacity: .62; text-transform: uppercase; }
|
|
481
|
+
[data-p60-preview-island] button:disabled, [data-p60-preview-island] input:disabled { opacity: 1; }${motionApproximation}
|
|
104
482
|
</style>
|
|
483
|
+
<style>${PLATFORM_BASE}</style>
|
|
105
484
|
<style>${themeCss}</style>
|
|
106
485
|
</head>
|
|
107
486
|
<body ${attrs}>
|
|
487
|
+
${options.surface ? surfaceBar(surface) : ''}
|
|
108
488
|
${bodyHtml}
|
|
489
|
+
${runtime ? `<script>${runtime}\np60Behaviors.initBehaviors();</script>` : ''}
|
|
109
490
|
</body>
|
|
110
491
|
</html>`;
|
|
111
492
|
}
|