@port60/template-kit 0.20.3 → 0.21.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/lib/agentsMd.mjs +17 -0
- package/src/vendor/contract/v1/context.json +2 -2
- package/src/vendor/contract/v1/fonts.json +4 -2
- package/src/vendor/contract/v1/manifest.schema.json +33 -0
- package/src/vendor/contract/v1.lock.json +252 -0
- package/src/vendor/engine/budgets.mjs +1 -1
- package/src/vendor/engine/content-footprint.mjs +9 -9
- package/src/vendor/engine/dialect.mjs +4 -4
- package/src/vendor/validator/behaviors-runtime.js +1 -1
- package/src/vendor/validator/fixture-art.mjs +5 -5
- package/src/vendor/validator/fonts.mjs +13 -3
- package/src/vendor/validator/navigation-highlights.mjs +149 -0
- package/src/vendor/validator/platform-base.css +168 -88
- package/src/vendor/validator/preview.mjs +18 -18
- package/src/vendor/validator/site-context.mjs +18 -6
- package/src/vendor/validator/validate.mjs +125 -44
- package/starter/manifest.json +42 -2
- package/starter/sections/campaigns.liquid +2 -2
- package/starter/sections/cta.liquid +2 -2
- package/starter/sections/hero.liquid +9 -2
- package/starter/sections/homeHero.liquid +9 -9
- package/starter/sections/people.liquid +7 -6
- package/starter/sections/values.liquid +3 -3
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// The STUDIO PREVIEW renderer (developer program T1.3): a validated artifact rendered over the
|
|
2
|
-
// contract's KIND FIXTURES
|
|
2
|
+
// contract's KIND FIXTURES, no tenant, no tenant data, exactly what `template-kit dev` will show
|
|
3
3
|
// locally in T3. Deliberately NOT the production TemplateHost path: a studio version must never
|
|
4
4
|
// touch a live site, so this renders from an in-memory file map and the page it produces is
|
|
5
|
-
// self-contained and network-dead
|
|
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
7
|
// Islands render as realistic, non-interactive fixture skeletons through their public styling
|
|
8
8
|
// classes. Preview HTML carries no runtime and never attempts a platform transaction.
|
|
@@ -23,21 +23,21 @@ import { normaliseFocus, previewActions, withResolvedActions, applyFocus } from
|
|
|
23
23
|
|
|
24
24
|
// Fixture imagery resolved for the SEALED studio render (p60fixture: refs become inline-SVG data
|
|
25
25
|
// URIs the network-dead CSP can show). The dev preview may instead resolve them to the platform
|
|
26
|
-
// CDN via options.fixtureImageBase
|
|
26
|
+
// CDN via options.fixtureImageBase, the dev-richer / studio-sealed split.
|
|
27
27
|
const STUDIO_FX = resolveFixtureArt(contextContract.fixtures);
|
|
28
28
|
|
|
29
29
|
const escapeHtml = (s) =>
|
|
30
30
|
String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
31
31
|
|
|
32
|
-
// The platform base stylesheet
|
|
32
|
+
// The platform base stylesheet, production loads it on EVERY template page before the theme, so
|
|
33
33
|
// the preview does too: islands and platform components arrive with their real baseline look,
|
|
34
34
|
// wearing the template's tokens, and the theme restyles over it exactly as in production.
|
|
35
|
-
// (Generated copy of src/styles/global.css
|
|
35
|
+
// (Generated copy of src/styles/global.css, scripts/build-preview-base.mjs.)
|
|
36
36
|
let PLATFORM_BASE = '';
|
|
37
37
|
try {
|
|
38
38
|
PLATFORM_BASE = readFileSync(join(import.meta.dirname, 'platform-base.css'), 'utf8');
|
|
39
39
|
} catch {
|
|
40
|
-
// An older vendored copy without the file
|
|
40
|
+
// An older vendored copy without the file, the preview degrades to theme-only styling.
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
function previewNote(name) {
|
|
@@ -73,7 +73,7 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
|
|
|
73
73
|
<p class="donate-note">Secure payment provided by Port60</p>
|
|
74
74
|
</section>`;
|
|
75
75
|
case 'member_menu':
|
|
76
|
-
// Lives INSIDE the nav row, so the wrapper stays inline and the badge trails the button
|
|
76
|
+
// Lives INSIDE the nav row, so the wrapper stays inline and the badge trails the button,
|
|
77
77
|
// block layout here read as a stray element between the nav's last link and Sign in.
|
|
78
78
|
return `<div data-p60-preview-island="member_menu" style="display:inline-flex;align-items:center;gap:8px">
|
|
79
79
|
<button class="nav-p60-signin" type="button" disabled><span class="p60-mark" aria-hidden="true">P</span> Sign in</button>
|
|
@@ -127,7 +127,7 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
|
|
|
127
127
|
<div class="article-comments-gate"><p class="article-comments-note">Sign in to join the conversation.</p></div>
|
|
128
128
|
</section>`;
|
|
129
129
|
case 'hero_carousel': {
|
|
130
|
-
// Hydrated from the surrounding section's images (the homeHero sample fixture)
|
|
130
|
+
// Hydrated from the surrounding section's images (the homeHero sample fixture), real
|
|
131
131
|
// slides through the real styling API, CSS-crossfaded by the preview so it reads as alive.
|
|
132
132
|
const images = Array.isArray(ctx.section?.images) ? ctx.section.images.filter((i) => i?.imageUrl) : [];
|
|
133
133
|
const slides = images.length > 0
|
|
@@ -170,7 +170,7 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
|
|
|
170
170
|
case 'search':
|
|
171
171
|
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>`;
|
|
172
172
|
case 'map': {
|
|
173
|
-
// The impact-map skeleton: the fixture's points projected onto a token-themed canvas
|
|
173
|
+
// The impact-map skeleton: the fixture's points projected onto a token-themed canvas,
|
|
174
174
|
// the same fallback rendering production uses until the platform tile layer is configured.
|
|
175
175
|
const im = fx.sections?.impactMap?.impactMap ?? { title: 'Impact map', points: [] };
|
|
176
176
|
const pts = im.points ?? [];
|
|
@@ -204,11 +204,11 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
|
|
|
204
204
|
// The ROUTED dev preview's answer to "what does X look like in my theme": each platform-owned
|
|
205
205
|
// page as a fixture skeleton through the PRODUCTION class names, so the platform base + the
|
|
206
206
|
// theme's tokens style it exactly as live, wrapped by the template's own layout. Never
|
|
207
|
-
// interactive
|
|
207
|
+
// interactive, the same posture as island skeletons. Where the theme ships its own page
|
|
208
208
|
// template for a surface (events, course, articles, article), that template renders instead.
|
|
209
209
|
|
|
210
210
|
function surfaceDivider(label) {
|
|
211
|
-
return `<div class="p60-preview-divider" role="note">platform page: ${escapeHtml(label)}
|
|
211
|
+
return `<div class="p60-preview-divider" role="note">platform page: ${escapeHtml(label)}, styled by your tokens and chrome</div>`;
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
function eventsListingSkeleton(fx = STUDIO_FX) {
|
|
@@ -336,7 +336,7 @@ function serviceDetailSkeleton(fx = STUDIO_FX) {
|
|
|
336
336
|
<h1>${escapeHtml(service.title)}</h1>
|
|
337
337
|
${service.summary ? `<p>${escapeHtml(service.summary)}</p>` : ''}
|
|
338
338
|
<p>Content pages are written in the workspace's WYSIWYG editor and arrive as sanitised
|
|
339
|
-
HTML
|
|
339
|
+
HTML, headings, lists, images and embeds render here styled by your theme's typography.</p>
|
|
340
340
|
</article>
|
|
341
341
|
</div></section>`;
|
|
342
342
|
}
|
|
@@ -456,7 +456,7 @@ function knobValues(manifest, overrides = {}) {
|
|
|
456
456
|
|
|
457
457
|
/**
|
|
458
458
|
* Renders the artifact's declared sections (sample fixtures) inside its layout (when declared)
|
|
459
|
-
* and returns a complete, self-contained HTML document. Throws on parse/render failure
|
|
459
|
+
* and returns a complete, self-contained HTML document. Throws on parse/render failure, callers
|
|
460
460
|
* preview only versions the validator has already passed, so a throw here is a bug report, not
|
|
461
461
|
* a user flow.
|
|
462
462
|
*/
|
|
@@ -524,7 +524,7 @@ export async function renderStudioPreview(files, options = {}) {
|
|
|
524
524
|
let contentHtml;
|
|
525
525
|
if (surface !== 'home' && SURFACES[surface]) {
|
|
526
526
|
// A routed platform surface: the theme's own page template when it ships one, else the
|
|
527
|
-
// platform-page fixture skeleton
|
|
527
|
+
// platform-page fixture skeleton, either way inside the theme's layout below.
|
|
528
528
|
const def = SURFACES[surface];
|
|
529
529
|
const templateSource = def.template ? files[`pages/${def.template}.liquid`] : null;
|
|
530
530
|
const fixture = def.template ? fx.pages?.[def.template] : null;
|
|
@@ -562,7 +562,7 @@ export async function renderStudioPreview(files, options = {}) {
|
|
|
562
562
|
...(fx.sections?.[type] ?? {})
|
|
563
563
|
};
|
|
564
564
|
const rendered = await liquid.parseAndRender(source, context);
|
|
565
|
-
// Island skeletons see the SAME context the section rendered with
|
|
565
|
+
// Island skeletons see the SAME context the section rendered with, that is what lets the
|
|
566
566
|
// hero carousel skeleton hydrate from the section's own photo fixtures.
|
|
567
567
|
out.push(partsToHtml(rendered, '', context, fx));
|
|
568
568
|
}
|
|
@@ -581,7 +581,7 @@ export async function renderStudioPreview(files, options = {}) {
|
|
|
581
581
|
}
|
|
582
582
|
}
|
|
583
583
|
|
|
584
|
-
// Declared page templates render too (over their page fixtures)
|
|
584
|
+
// Declared page templates render too (over their page fixtures), the loop an author lives in
|
|
585
585
|
// covers every surface they ship, not just home sections. The routed dev preview ALSO serves
|
|
586
586
|
// each at its own path; this keeps the studio's single document complete.
|
|
587
587
|
for (const page of surface === 'about' ? [] : (manifest?.supports?.pageTemplates ?? [])) {
|
|
@@ -625,7 +625,7 @@ export async function renderStudioPreview(files, options = {}) {
|
|
|
625
625
|
|
|
626
626
|
// The kit's dev server passes the platform's own behaviour runtime (a self-contained bundle) so
|
|
627
627
|
// authors see their carousels, reveals and tabs living locally. The document stays network-dead
|
|
628
|
-
|
|
628
|
+
//, the ONLY script it can run is the inline platform bundle; studio and demo previews pass
|
|
629
629
|
// nothing and keep the fully script-free CSP. Without the runtime, a CSS-only crossfade
|
|
630
630
|
// approximates behaviour carousels so a static preview still reads as alive.
|
|
631
631
|
const runtime = options.behaviorsRuntime ?? null;
|
|
@@ -667,7 +667,7 @@ export async function renderStudioPreview(files, options = {}) {
|
|
|
667
667
|
<meta http-equiv="Content-Security-Policy" content="${csp}">
|
|
668
668
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
669
669
|
${fontLinks}
|
|
670
|
-
<title>${escapeHtml(manifest?.label ?? manifest?.name ?? 'Template preview')}
|
|
670
|
+
<title>${escapeHtml(manifest?.label ?? manifest?.name ?? 'Template preview')}, studio preview</title>
|
|
671
671
|
<style>${vars ? `:root { ${vars} }` : ''}
|
|
672
672
|
.p60-preview-badge { display: inline-flex; align-items: center; width: fit-content; margin: 0 0 8px;
|
|
673
673
|
border: 1px solid currentColor; border-radius: 999px; padding: 3px 8px; font: 600 10px/1.2 system-ui, sans-serif;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// The `site` tree for validator and preview renders (content model v1
|
|
1
|
+
// The `site` tree for validator and preview renders (content model v1, contract/v1/
|
|
2
2
|
// content-model.json): ONE realistic organisation assembled from the canonical fixtures, with
|
|
3
3
|
// `site.content.about` composed per-template from the section catalogue's samples for the
|
|
4
|
-
// manifest's declared sections
|
|
4
|
+
// manifest's declared sections, the admin-authored page composition, previewed honestly.
|
|
5
5
|
import contextContract from '../contract/v1/context.json' with { type: 'json' };
|
|
6
6
|
import sectionCatalogue from '../contract/v1/sections.json' with { type: 'json' };
|
|
7
7
|
|
|
@@ -20,6 +20,18 @@ export const PAGE_KEYS = ['home', 'about'];
|
|
|
20
20
|
* site's composition is admin-authored, and the preview-content `pages` block plays that role.
|
|
21
21
|
*/
|
|
22
22
|
export function composePage(manifest, page) {
|
|
23
|
+
// The template's own composition when it declares one (stage 4): its order, its choice of
|
|
24
|
+
// sections, the `optional` ones left out so the preview shows the designed default.
|
|
25
|
+
const declared = manifest?.compositions?.[page];
|
|
26
|
+
if (Array.isArray(declared) && declared.length > 0) {
|
|
27
|
+
return declared
|
|
28
|
+
.filter((entry) => entry && entry.role !== 'optional')
|
|
29
|
+
.map((entry) => {
|
|
30
|
+
const catalogueEntry = catalogueByType.get(entry.type);
|
|
31
|
+
return catalogueEntry ? { type: entry.type, content: catalogueEntry.sample ?? {} } : null;
|
|
32
|
+
})
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
}
|
|
23
35
|
return (manifest?.supports?.sections ?? [])
|
|
24
36
|
.map((type) => {
|
|
25
37
|
const entry = catalogueByType.get(type);
|
|
@@ -132,7 +144,7 @@ export function validatePreviewContent(json) {
|
|
|
132
144
|
continue;
|
|
133
145
|
}
|
|
134
146
|
for (const field of Object.keys(items)) {
|
|
135
|
-
if (field !== 'items') errors.push(`preview-content.json: nav.${field} does not exist
|
|
147
|
+
if (field !== 'items') errors.push(`preview-content.json: nav.${field} does not exist, nav carries items`);
|
|
136
148
|
}
|
|
137
149
|
validateNavItems(items.items, 'nav.items', errors);
|
|
138
150
|
continue;
|
|
@@ -159,7 +171,7 @@ export function validatePreviewContent(json) {
|
|
|
159
171
|
}
|
|
160
172
|
for (const field of Object.keys(items)) {
|
|
161
173
|
if (!BRAND_FIELDS.has(field)) {
|
|
162
|
-
errors.push(`preview-content.json: brand.${field} does not exist
|
|
174
|
+
errors.push(`preview-content.json: brand.${field} does not exist, brand carries ${[...BRAND_FIELDS].join(', ')}`);
|
|
163
175
|
}
|
|
164
176
|
}
|
|
165
177
|
continue;
|
|
@@ -174,7 +186,7 @@ export function validatePreviewContent(json) {
|
|
|
174
186
|
continue;
|
|
175
187
|
}
|
|
176
188
|
if (items.length > model.cap) {
|
|
177
|
-
errors.push(`preview-content.json: '${name}' holds ${items.length} items
|
|
189
|
+
errors.push(`preview-content.json: '${name}' holds ${items.length} items, the collection is bounded at ${model.cap}`);
|
|
178
190
|
}
|
|
179
191
|
items.forEach((item, i) => {
|
|
180
192
|
if (item === null || typeof item !== 'object' || Array.isArray(item)) {
|
|
@@ -183,7 +195,7 @@ export function validatePreviewContent(json) {
|
|
|
183
195
|
}
|
|
184
196
|
for (const field of Object.keys(item)) {
|
|
185
197
|
if (!model.item[field]) {
|
|
186
|
-
errors.push(`preview-content.json: ${name}[${i}].${field} does not exist in the model
|
|
198
|
+
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`);
|
|
187
199
|
}
|
|
188
200
|
}
|
|
189
201
|
for (const [field, spec] of Object.entries(model.item)) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The Port60 template CONFORMANCE VALIDATOR as a PURE MODULE (developer program T1.3): the same
|
|
2
|
-
// checks the publish-time CLI has always run, callable with an in-memory file map
|
|
3
|
-
// argv, no process.exit
|
|
2
|
+
// checks the publish-time CLI has always run, callable with an in-memory file map, no fs, no
|
|
3
|
+
// argv, no process.exit, so the studio upload lane (an HTTP endpoint) and the CLI share ONE
|
|
4
4
|
// implementation, and "validated ⇒ renders in production" keeps holding: the Liquid instance is
|
|
5
5
|
// configured identically to the engine's, dialect enforcement is the same shared module, and the
|
|
6
6
|
// render budgets here match production's.
|
|
@@ -23,6 +23,7 @@ import fontCatalogue from '../contract/v1/fonts.json' with { type: 'json' };
|
|
|
23
23
|
import layoutContract from '../contract/v1/layout.json' with { type: 'json' };
|
|
24
24
|
import behaviourCatalogue from '../contract/v1/behaviours.json' with { type: 'json' };
|
|
25
25
|
import { buildSiteFixture, extractContentFootprint, contentModel } from './site-context.mjs';
|
|
26
|
+
import { proveNavigationHighlights } from './navigation-highlights.mjs';
|
|
26
27
|
|
|
27
28
|
const Ajv = Ajv2020.default ?? Ajv2020;
|
|
28
29
|
|
|
@@ -42,7 +43,7 @@ const PRIMARY_ATTR = Object.fromEntries(
|
|
|
42
43
|
);
|
|
43
44
|
|
|
44
45
|
// Templates are markup and attributes, NEVER code (docs/template-behaviours.md). These are hard
|
|
45
|
-
// errors over the RAW liquid source
|
|
46
|
+
// errors over the RAW liquid source, even inside comments, because there is no legitimate reason
|
|
46
47
|
// for the tokens to appear at all. The handler pattern names real DOM event families rather than
|
|
47
48
|
// matching any on* word, so attributes like `once` or `online` never false-positive.
|
|
48
49
|
const FORBIDDEN_MARKUP = [
|
|
@@ -62,7 +63,7 @@ export async function validateArtifact(files) {
|
|
|
62
63
|
// 1. Manifest against the schema.
|
|
63
64
|
let manifest = null;
|
|
64
65
|
if (!has('manifest.json')) {
|
|
65
|
-
return { errors: ['manifest.json is missing
|
|
66
|
+
return { errors: ['manifest.json is missing, every artifact starts with its manifest'], warnings, manifest };
|
|
66
67
|
}
|
|
67
68
|
try {
|
|
68
69
|
manifest = JSON.parse(read('manifest.json'));
|
|
@@ -70,9 +71,9 @@ export async function validateArtifact(files) {
|
|
|
70
71
|
return { errors: [`manifest.json unreadable: ${e.message}`], warnings, manifest: null };
|
|
71
72
|
}
|
|
72
73
|
// preview-content.json is a DEV-ONLY data override: package excludes it and the intake
|
|
73
|
-
// refuses it
|
|
74
|
+
// refuses it, an artifact must never carry data, only shape.
|
|
74
75
|
if (has('preview-content.json')) {
|
|
75
|
-
errors.push('preview-content.json: development preview data never ships in an artifact
|
|
76
|
+
errors.push('preview-content.json: development preview data never ships in an artifact, remove it (package excludes it automatically)');
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
// Content model v1: the footprint is decidable from the sources (closed dialect); unknown
|
|
@@ -136,14 +137,14 @@ export async function validateArtifact(files) {
|
|
|
136
137
|
}
|
|
137
138
|
}
|
|
138
139
|
|
|
139
|
-
// Markup and attributes, NEVER code
|
|
140
|
+
// Markup and attributes, NEVER code, the machine-enforced JavaScript ban over every liquid
|
|
140
141
|
// source (docs/template-behaviours.md). Behaviour is engine-owned; a template wanting motion
|
|
141
142
|
// declares supports.behaviors and uses the data-p60-* grammar.
|
|
142
143
|
for (const [path, source] of Object.entries(files)) {
|
|
143
144
|
if (!path.endsWith('.liquid')) continue;
|
|
144
145
|
for (const [pattern, what] of FORBIDDEN_MARKUP) {
|
|
145
146
|
if (pattern.test(source)) {
|
|
146
|
-
errors.push(`${path}: contains ${what}
|
|
147
|
+
errors.push(`${path}: contains ${what}, templates are markup and attributes, never code (behaviour is engine-owned; see the behaviour catalogue)`);
|
|
147
148
|
}
|
|
148
149
|
}
|
|
149
150
|
}
|
|
@@ -211,7 +212,7 @@ export async function validateArtifact(files) {
|
|
|
211
212
|
}
|
|
212
213
|
}
|
|
213
214
|
|
|
214
|
-
// Looks: every value must target a declared knob and be valid for it
|
|
215
|
+
// Looks: every value must target a declared knob and be valid for it, a look that half-applies
|
|
215
216
|
// would leave the tenant in a state no author designed.
|
|
216
217
|
{
|
|
217
218
|
const knobByKey = new Map((manifest?.settings?.schema ?? []).map((k) => [k.key, k]));
|
|
@@ -253,11 +254,11 @@ export async function validateArtifact(files) {
|
|
|
253
254
|
|
|
254
255
|
// Author-renderable widget sections (the flip): each carries a curated data context; a template
|
|
255
256
|
// either places the section's DEFAULT ISLAND (which owns rendering + empty states) or renders
|
|
256
|
-
// the data itself
|
|
257
|
+
// the data itself, in which case both directions are proven behaviourally (the worship
|
|
257
258
|
// pattern): the populated fixture's sentinel must appear, and the EMPTY context must render it
|
|
258
|
-
// away (derive or omit
|
|
259
|
+
// away (derive or omit, nothing invented, nothing dangling).
|
|
259
260
|
// Sentinels are DERIVED from the canonical fixtures (the first item's display field), never
|
|
260
|
-
// hardcoded
|
|
261
|
+
// hardcoded, the fixture data is free to become richer without touching a proof, and a proof
|
|
261
262
|
// can never drift from the data it renders.
|
|
262
263
|
const sentinelOf = (type, dataKey, field) =>
|
|
263
264
|
(contextContract.fixtures.sections?.[type]?.[dataKey] ?? [])[0]?.[field] ?? null;
|
|
@@ -270,7 +271,31 @@ export async function validateArtifact(files) {
|
|
|
270
271
|
locations: { island: null, dataKey: 'locations', sentinel: sentinelOf('locations', 'locations', 'name') }
|
|
271
272
|
};
|
|
272
273
|
|
|
273
|
-
//
|
|
274
|
+
// 2b. Compositions (site editor stage 4): each page must be a supported page, each type a
|
|
275
|
+
// supported section the catalogue assigns to that page, listed once, with a role.
|
|
276
|
+
const compositions = manifest?.compositions ?? {};
|
|
277
|
+
for (const [page, entries] of Object.entries(compositions)) {
|
|
278
|
+
if (!(manifest?.supports?.pages ?? []).includes(page)) {
|
|
279
|
+
errors.push(`compositions.${page}: not in supports.pages`);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const seen = new Set();
|
|
283
|
+
for (const entry of entries ?? []) {
|
|
284
|
+
const type = entry?.type;
|
|
285
|
+
if (!(manifest?.supports?.sections ?? []).includes(type)) {
|
|
286
|
+
errors.push(`compositions.${page}: '${type}' is not in supports.sections`);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const catalogueEntry = catalogueByType.get(type);
|
|
290
|
+
if (catalogueEntry && !(catalogueEntry.pages ?? []).includes(page)) {
|
|
291
|
+
errors.push(`compositions.${page}: '${type}' is not a ${page} page section in the catalogue`);
|
|
292
|
+
}
|
|
293
|
+
if (seen.has(type)) errors.push(`compositions.${page}: '${type}' is listed twice`);
|
|
294
|
+
seen.add(type);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 2-4. Sections: catalogue membership, parse, fixture renders.
|
|
274
299
|
for (const type of manifest?.supports?.sections ?? []) {
|
|
275
300
|
const entry = catalogueByType.get(type);
|
|
276
301
|
if (!entry) {
|
|
@@ -286,7 +311,7 @@ export async function validateArtifact(files) {
|
|
|
286
311
|
try {
|
|
287
312
|
parsed = liquid.parse(read(file));
|
|
288
313
|
} catch (e) {
|
|
289
|
-
errors.push(`section '${type}': does not parse under the dialect
|
|
314
|
+
errors.push(`section '${type}': does not parse under the dialect, ${e.message}`);
|
|
290
315
|
continue;
|
|
291
316
|
}
|
|
292
317
|
// Widget-section proof (independent of the minimal/sample loop): island placed → the island
|
|
@@ -307,8 +332,8 @@ export async function validateArtifact(files) {
|
|
|
307
332
|
if (widget.sentinel && !populated.includes(widget.sentinel)) {
|
|
308
333
|
errors.push(
|
|
309
334
|
widget.island
|
|
310
|
-
? `section '${type}': neither places the ${widget.island} island nor renders the ${widget.dataKey} context
|
|
311
|
-
: `section '${type}': does not render the ${widget.dataKey} context
|
|
335
|
+
? `section '${type}': neither places the ${widget.island} island nor renders the ${widget.dataKey} context, render the data (the fixture's "${widget.sentinel}" must appear) or place the island`
|
|
336
|
+
: `section '${type}': does not render the ${widget.dataKey} context, the fixture's "${widget.sentinel}" must appear`
|
|
312
337
|
);
|
|
313
338
|
}
|
|
314
339
|
const empty = await liquid.render(parsed, {
|
|
@@ -318,14 +343,14 @@ export async function validateArtifact(files) {
|
|
|
318
343
|
[widget.dataKey]: []
|
|
319
344
|
});
|
|
320
345
|
if (widget.sentinel && empty.includes(widget.sentinel)) {
|
|
321
|
-
errors.push(`section '${type}': still shows fixture content with an empty ${widget.dataKey}
|
|
346
|
+
errors.push(`section '${type}': still shows fixture content with an empty ${widget.dataKey}, content must come from the context`);
|
|
322
347
|
}
|
|
323
348
|
if (/\bundefined\b|\bnull\b/.test(empty.replace(/data-[a-z-]+="[^"]*"/g, ''))) {
|
|
324
|
-
errors.push(`section '${type}': renders 'undefined'/'null' literals when ${widget.dataKey} is empty
|
|
349
|
+
errors.push(`section '${type}': renders 'undefined'/'null' literals when ${widget.dataKey} is empty, guard the empty case (derive or omit)`);
|
|
325
350
|
}
|
|
326
351
|
}
|
|
327
352
|
} catch (e) {
|
|
328
|
-
errors.push(`section '${type}': failed rendering the ${widget.dataKey} context fixtures
|
|
353
|
+
errors.push(`section '${type}': failed rendering the ${widget.dataKey} context fixtures, ${e.message}`);
|
|
329
354
|
}
|
|
330
355
|
}
|
|
331
356
|
for (const fixtureName of ['minimal', 'sample']) {
|
|
@@ -359,45 +384,90 @@ export async function validateArtifact(files) {
|
|
|
359
384
|
}
|
|
360
385
|
for (const part of splitIslandParts(html)) {
|
|
361
386
|
if (part.island === CONTENT_SLOT) {
|
|
362
|
-
errors.push(`section '${type}': uses {% content %}
|
|
387
|
+
errors.push(`section '${type}': uses {% content %}, that tag is layout-only`);
|
|
363
388
|
} else if (part.island) {
|
|
364
389
|
placedIslands.add(part.island);
|
|
365
390
|
}
|
|
366
391
|
}
|
|
367
392
|
} catch (e) {
|
|
368
|
-
errors.push(`section '${type}': failed rendering the ${fixtureName} fixture
|
|
393
|
+
errors.push(`section '${type}': failed rendering the ${fixtureName} fixture, ${e.message}`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// 5. FIELD MARKERS: which node shows which field, so the editor can put the caret on the page
|
|
399
|
+
// instead of in a side panel. Source-level, like behaviours: a marker often sits inside a
|
|
400
|
+
// content-dependent branch the fixtures never take, and its presence in the source is the honest
|
|
401
|
+
// proof. A marker is an address inside that section's own content: `title`, or `items.0.label`
|
|
402
|
+
// for a list, where the index is usually a Liquid expression and stands for any position.
|
|
403
|
+
{
|
|
404
|
+
const MARKER = /data-p60-field="([^"]*)"/g;
|
|
405
|
+
const fieldsOf = (type) => new Map((catalogueByType.get(type)?.fields ?? []).map((f) => [f.name, f]));
|
|
406
|
+
const marked = [];
|
|
407
|
+
for (const type of manifest?.supports?.sections ?? []) {
|
|
408
|
+
const source = has(`sections/${type}.liquid`) ? read(`sections/${type}.liquid`) : '';
|
|
409
|
+
const fields = fieldsOf(type);
|
|
410
|
+
for (const [, raw] of source.matchAll(MARKER)) {
|
|
411
|
+
marked.push(type);
|
|
412
|
+
// An index written as Liquid stands for whichever item this is.
|
|
413
|
+
const steps = raw.replace(/\{\{[^}]*\}\}/g, '#').split('.');
|
|
414
|
+
const field = fields.get(steps[0]);
|
|
415
|
+
const named = steps.length === 1
|
|
416
|
+
? Boolean(field) && field.kind !== 'items'
|
|
417
|
+
: steps.length === 3
|
|
418
|
+
&& Boolean(field) && field.kind === 'items'
|
|
419
|
+
&& (steps[1] === '#' || /^\d+$/.test(steps[1]))
|
|
420
|
+
&& (field.itemFields ?? []).some((f) => f.name === steps[2]);
|
|
421
|
+
if (!named) {
|
|
422
|
+
warnings.push(`section '${type}': data-p60-field="${raw}" does not name a field of this section, the editor will ignore it (a field of ${type}, or items.<index>.<field> for a list)`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
for (const [path, source] of Object.entries(files)) {
|
|
427
|
+
if (path.endsWith('.liquid') && !path.startsWith('sections/') && MARKER.test(source)) {
|
|
428
|
+
warnings.push(`${path}: data-p60-field marks a section's own content, and there is none here; the editor ignores markers outside sections/`);
|
|
369
429
|
}
|
|
430
|
+
MARKER.lastIndex = 0;
|
|
431
|
+
}
|
|
432
|
+
if (manifest?.supports?.fieldMarkers && marked.length === 0) {
|
|
433
|
+
errors.push('manifest: supports.fieldMarkers is declared but no section marks a field, the editor would offer typing on the page and find nothing to type into');
|
|
434
|
+
}
|
|
435
|
+
if (!manifest?.supports?.fieldMarkers && marked.length > 0) {
|
|
436
|
+
warnings.push('manifest: sections carry data-p60-field markers but supports.fieldMarkers is not declared, declare it so the editor offers typing on the page');
|
|
370
437
|
}
|
|
371
438
|
}
|
|
372
439
|
|
|
373
440
|
// Hero-imagery honesty, checked BEHAVIOURALLY (the worship pattern). The homeHero sample
|
|
374
441
|
// fixture carries photographs whose data URIs embed a quote-free marker that survives HTML
|
|
375
|
-
// escaping, so "does the rendered hero display the tenant's photos?" is a substring check
|
|
442
|
+
// escaping, so "does the rendered hero display the tenant's photos?" is a substring check,
|
|
376
443
|
// and with several photos, placing the hero_carousel island IS displaying them (the island
|
|
377
444
|
// renders the slides at runtime). The single-photo path is proven separately: images[0] must
|
|
378
445
|
// appear directly. Declaration and behaviour must agree; the choosers badge photo-led tenants
|
|
379
446
|
// by supports.heroImagery. Legacy imageUrl-only renderers never match (the fixture's photos
|
|
380
|
-
// ride `images`), so they pass undeclared
|
|
447
|
+
// ride `images`), so they pass undeclared, they just don't earn the badge.
|
|
381
448
|
{
|
|
382
449
|
const declaresHero = manifest?.supports?.heroImagery === true;
|
|
383
450
|
if (declaresHero && !(manifest?.supports?.sections ?? []).includes('homeHero')) {
|
|
384
|
-
errors.push('manifest: supports.heroImagery requires the homeHero section
|
|
451
|
+
errors.push('manifest: supports.heroImagery requires the homeHero section, the photographs live on it');
|
|
385
452
|
} else if (homeHeroMultiShows !== null) {
|
|
386
453
|
if (declaresHero && !homeHeroMultiShows) {
|
|
387
454
|
errors.push('homeHero: manifest declares supports.heroImagery but the rendered section neither displays the images fixture nor places the hero_carousel island');
|
|
388
455
|
}
|
|
389
456
|
if (declaresHero && !homeHeroSingleShows) {
|
|
390
|
-
errors.push('homeHero: supports.heroImagery must render a SINGLE photograph directly (images[0], treated, never raw)
|
|
457
|
+
errors.push('homeHero: supports.heroImagery must render a SINGLE photograph directly (images[0], treated, never raw), the carousel island only covers 2+');
|
|
391
458
|
}
|
|
392
459
|
if (!declaresHero && (homeHeroMultiShows || homeHeroSingleShows)) {
|
|
393
|
-
errors.push('homeHero: renders the hero photographs but the manifest does not declare supports.heroImagery
|
|
460
|
+
errors.push('homeHero: renders the hero photographs but the manifest does not declare supports.heroImagery, declare it so the choosers can badge it');
|
|
394
461
|
}
|
|
395
462
|
}
|
|
396
463
|
}
|
|
397
464
|
|
|
398
465
|
// 7. Layout (when declared): parse + render the layout fixture + exactly one content slot.
|
|
399
466
|
if (manifest?.supports?.worship && !manifest?.supports?.layout) {
|
|
400
|
-
errors.push('manifest: supports.worship requires supports.layout
|
|
467
|
+
errors.push('manifest: supports.worship requires supports.layout, the worship rail is layout chrome');
|
|
468
|
+
}
|
|
469
|
+
if (manifest?.supports?.navigationHighlights && !manifest?.supports?.layout) {
|
|
470
|
+
errors.push('manifest: supports.navigationHighlights requires supports.layout, highlights belong to navigation chrome');
|
|
401
471
|
}
|
|
402
472
|
if (manifest?.supports?.layout) {
|
|
403
473
|
if (!has('layout.liquid')) {
|
|
@@ -407,7 +477,7 @@ export async function validateArtifact(files) {
|
|
|
407
477
|
try {
|
|
408
478
|
parsedLayout = liquid.parse(read('layout.liquid'));
|
|
409
479
|
} catch (e) {
|
|
410
|
-
errors.push(`layout: does not parse under the dialect
|
|
480
|
+
errors.push(`layout: does not parse under the dialect, ${e.message}`);
|
|
411
481
|
}
|
|
412
482
|
if (parsedLayout) {
|
|
413
483
|
try {
|
|
@@ -429,11 +499,22 @@ export async function validateArtifact(files) {
|
|
|
429
499
|
errors.push(`layout: must contain exactly one {% content %} slot (found ${contentSlots})`);
|
|
430
500
|
}
|
|
431
501
|
if (!layoutIslands.includes('member_menu')) {
|
|
432
|
-
warnings.push("layout: no {% island 'member_menu' %}
|
|
502
|
+
warnings.push("layout: no {% island 'member_menu' %}, member sign-in will be unreachable on tenants that allow sign-ups; place it in your header");
|
|
433
503
|
}
|
|
434
504
|
|
|
505
|
+
const highlights = await proveNavigationHighlights((nav) => liquid.render(parsedLayout, {
|
|
506
|
+
site: { ...siteFx, nav },
|
|
507
|
+
brand: contextContract.fixtures.brand,
|
|
508
|
+
nav,
|
|
509
|
+
socials: contextContract.fixtures.layout.socials ?? [],
|
|
510
|
+
worship: contextContract.fixtures.layout.worship ?? null,
|
|
511
|
+
locale: contextContract.fixtures.layout.locale
|
|
512
|
+
}), manifest?.supports?.navigationHighlights);
|
|
513
|
+
errors.push(...highlights.errors);
|
|
514
|
+
warnings.push(...highlights.warnings);
|
|
515
|
+
|
|
435
516
|
// Two-sided worship honesty, checked BEHAVIOURALLY: does the rendered layout actually
|
|
436
|
-
// display the worship fixture's times? Declaration and behaviour must agree
|
|
517
|
+
// display the worship fixture's times? Declaration and behaviour must agree, the
|
|
437
518
|
// choosers steer worship-enabled tenants by supports.worship, so a false declaration
|
|
438
519
|
// either hides their times (undeclared but rendered is fine to fix by declaring) or
|
|
439
520
|
// promises a rail that never appears.
|
|
@@ -441,10 +522,10 @@ export async function validateArtifact(files) {
|
|
|
441
522
|
if (worshipProbe) {
|
|
442
523
|
const rendersWorship = html.includes(worshipProbe);
|
|
443
524
|
if (manifest?.supports?.worship && !rendersWorship) {
|
|
444
|
-
errors.push('layout: manifest declares supports.worship but the rendered layout does not display the worship fixture times
|
|
525
|
+
errors.push('layout: manifest declares supports.worship but the rendered layout does not display the worship fixture times, the rail never appears');
|
|
445
526
|
}
|
|
446
527
|
if (!manifest?.supports?.worship && rendersWorship) {
|
|
447
|
-
errors.push('layout: renders the worship rail but the manifest does not declare supports.worship
|
|
528
|
+
errors.push('layout: renders the worship rail but the manifest does not declare supports.worship, declare it so the choosers can badge it');
|
|
448
529
|
}
|
|
449
530
|
if (manifest?.supports?.worship) {
|
|
450
531
|
const nullHtml = await liquid.render(parsedLayout, {
|
|
@@ -457,17 +538,17 @@ export async function validateArtifact(files) {
|
|
|
457
538
|
locale: contextContract.fixtures.layout.locale
|
|
458
539
|
});
|
|
459
540
|
if (nullHtml.includes(worshipProbe)) {
|
|
460
|
-
errors.push('layout: worship rail content appears even when `worship` is null
|
|
541
|
+
errors.push('layout: worship rail content appears even when `worship` is null, always branch on it (tenants without a schedule must not see a rail)');
|
|
461
542
|
}
|
|
462
543
|
}
|
|
463
544
|
}
|
|
464
545
|
} catch (e) {
|
|
465
|
-
errors.push(`layout: failed rendering the layout fixture
|
|
546
|
+
errors.push(`layout: failed rendering the layout fixture, ${e.message}`);
|
|
466
547
|
}
|
|
467
548
|
}
|
|
468
549
|
}
|
|
469
550
|
} else if (has('layout.liquid')) {
|
|
470
|
-
warnings.push('layout.liquid present but manifest.supports.layout is not true
|
|
551
|
+
warnings.push('layout.liquid present but manifest.supports.layout is not true, it will be ignored');
|
|
471
552
|
}
|
|
472
553
|
|
|
473
554
|
// 8. Page templates (when declared): file exists, parses, renders the page's data fixture.
|
|
@@ -486,20 +567,20 @@ export async function validateArtifact(files) {
|
|
|
486
567
|
try {
|
|
487
568
|
parsedPage = liquid.parse(read(pageFile));
|
|
488
569
|
} catch (e) {
|
|
489
|
-
errors.push(`page template '${pageName}': does not parse under the dialect
|
|
570
|
+
errors.push(`page template '${pageName}': does not parse under the dialect, ${e.message}`);
|
|
490
571
|
continue;
|
|
491
572
|
}
|
|
492
573
|
try {
|
|
493
574
|
const html = await liquid.render(parsedPage, { ...fixture, site: siteFx, brand: contextContract.fixtures.brand });
|
|
494
575
|
for (const part of splitIslandParts(html)) {
|
|
495
576
|
if (part.island === CONTENT_SLOT) {
|
|
496
|
-
errors.push(`page template '${pageName}': uses {% content %}
|
|
577
|
+
errors.push(`page template '${pageName}': uses {% content %}, that tag is layout-only`);
|
|
497
578
|
} else if (part.island) {
|
|
498
579
|
placedIslands.add(part.island);
|
|
499
580
|
}
|
|
500
581
|
}
|
|
501
582
|
} catch (e) {
|
|
502
|
-
errors.push(`page template '${pageName}': failed rendering the page fixture
|
|
583
|
+
errors.push(`page template '${pageName}': failed rendering the page fixture, ${e.message}`);
|
|
503
584
|
}
|
|
504
585
|
}
|
|
505
586
|
|
|
@@ -513,18 +594,18 @@ export async function validateArtifact(files) {
|
|
|
513
594
|
if (!allIslands.has(name)) {
|
|
514
595
|
errors.push(`island '${name}': not in the platform island registry`);
|
|
515
596
|
} else if (!availableIslands.has(name)) {
|
|
516
|
-
warnings.push(`island '${name}': registry status is 'planned'
|
|
597
|
+
warnings.push(`island '${name}': registry status is 'planned', it will render nothing until available`);
|
|
517
598
|
}
|
|
518
599
|
}
|
|
519
600
|
|
|
520
601
|
// 6. Theme.
|
|
521
602
|
if (!has('assets/theme.css') || read('assets/theme.css').trim() === '') {
|
|
522
|
-
errors.push('assets/theme.css missing or empty
|
|
603
|
+
errors.push('assets/theme.css missing or empty, a template must ship its look');
|
|
523
604
|
}
|
|
524
605
|
|
|
525
606
|
// 6b. Layout contract (contract/v1/layout.json). Platform pages render through the content SEAM
|
|
526
607
|
// (.container / .full); a template STYLES those to place content, never a parallel content container.
|
|
527
|
-
// The rule is DATA
|
|
608
|
+
// The rule is DATA, the seam token, the allowed selectors and the message all come from the contract
|
|
528
609
|
// file; this only implements the check KIND (a non-seam selector sizing its width off the token).
|
|
529
610
|
{
|
|
530
611
|
const css = has('assets/theme.css') ? read('assets/theme.css') : '';
|
|
@@ -552,7 +633,7 @@ export async function validateArtifact(files) {
|
|
|
552
633
|
}
|
|
553
634
|
|
|
554
635
|
// Open enums, proven survivable (content model v1 discipline 2): a template whose footprint
|
|
555
|
-
// reads site.content.events must survive a registration mode it has never heard of
|
|
636
|
+
// reads site.content.events must survive a registration mode it has never heard of, new modes
|
|
556
637
|
// WILL arrive within the major. No throw, and no undefined/null literal leaking into markup.
|
|
557
638
|
if (contentAnalysis.footprint.includes('content.events')) {
|
|
558
639
|
const futureEvent = {
|
|
@@ -573,10 +654,10 @@ export async function validateArtifact(files) {
|
|
|
573
654
|
site: doctored
|
|
574
655
|
});
|
|
575
656
|
if (/\bundefined\b|\bnull\b/.test(html.replace(/data-[a-z-]+="[^"]*"/g, ''))) {
|
|
576
|
-
errors.push(`section '${type}': renders 'undefined'/'null' literals for an unknown event registrationMode
|
|
657
|
+
errors.push(`section '${type}': renders 'undefined'/'null' literals for an unknown event registrationMode, the enum is OPEN, branch on the modes you style and fall back for the rest`);
|
|
577
658
|
}
|
|
578
659
|
} catch (e) {
|
|
579
|
-
errors.push(`section '${type}': failed rendering an unknown event registrationMode
|
|
660
|
+
errors.push(`section '${type}': failed rendering an unknown event registrationMode, the enum is OPEN and new modes will arrive (${e.message})`);
|
|
580
661
|
}
|
|
581
662
|
}
|
|
582
663
|
}
|