@port60/template-kit 0.21.0 → 1.1.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 +58 -3
- package/bin/cli.mjs +10 -0
- package/package.json +4 -2
- package/src/commands/content.mjs +4 -3
- package/src/commands/create.mjs +11 -3
- package/src/commands/dev.mjs +9 -3
- package/src/commands/model.mjs +3 -3
- package/src/commands/packageCmd.mjs +1 -1
- package/src/commands/publish.mjs +1 -1
- package/src/commands/refresh.mjs +8 -3
- package/src/commands/release.mjs +22 -0
- package/src/commands/setupPreviews.mjs +19 -0
- package/src/commands/validate.mjs +1 -1
- package/src/lib/agentsMd.mjs +116 -161
- package/src/lib/designer-bridge.js +26 -0
- package/src/lib/designerPalette.mjs +62 -0
- package/src/lib/galleryPosters.mjs +105 -0
- package/src/lib/previewOptions.mjs +1 -1
- package/src/lib/releaseBundle.mjs +141 -0
- package/src/vendor/contract/v2/behaviours.json +346 -0
- package/src/vendor/contract/v2/content-bounds.json +117 -0
- package/src/vendor/contract/v2/content-model.json +766 -0
- package/src/vendor/contract/v2/context.json +1569 -0
- package/src/vendor/contract/v2/dialect.json +107 -0
- package/src/vendor/contract/v2/fonts.json +910 -0
- package/src/vendor/contract/v2/imagery.json +32 -0
- package/src/vendor/contract/v2/islands.json +263 -0
- package/src/vendor/contract/v2/layout.json +28 -0
- package/src/vendor/contract/v2/manifest.schema.json +388 -0
- package/src/vendor/contract/v2/sections.json +947 -0
- package/src/vendor/contract/v2/site.schema.json +1353 -0
- package/src/vendor/contract/v2/tokens.json +101 -0
- package/src/vendor/contract/v2.lock.json +6933 -0
- package/src/vendor/engine/content-footprint.mjs +76 -1
- package/src/vendor/engine/dialect.mjs +4 -0
- package/src/vendor/engine/locale.mjs +74 -0
- package/src/vendor/engine/majors.mjs +42 -0
- package/src/vendor/validator/gift-aid-logo.svg +6 -0
- package/src/vendor/validator/model-reference-v2.mjs +95 -0
- package/src/vendor/validator/platform-base.css +320 -11
- package/src/vendor/validator/preview-v1.mjs +691 -0
- package/src/vendor/validator/preview-v2.mjs +654 -0
- package/src/vendor/validator/preview.mjs +8 -688
- package/src/vendor/validator/site-context-v2.mjs +113 -0
- package/src/vendor/validator/validate-v1.mjs +666 -0
- package/src/vendor/validator/validate-v2.mjs +642 -0
- package/src/vendor/validator/validate.mjs +21 -663
- package/starter/assets/theme.css +3 -0
- package/starter/layout.liquid +15 -18
- package/starter/manifest.json +5 -4
- package/starter/preview/config.json +4 -0
- package/starter/preview/media/README.md +9 -0
- package/starter/sections/campaigns.liquid +5 -4
- package/starter/sections/homeHero.liquid +25 -21
- package/starter/sections/values.liquid +1 -1
|
@@ -1,666 +1,24 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
import { Liquid } from 'liquidjs';
|
|
13
|
-
import Ajv2020 from 'ajv/dist/2020.js';
|
|
14
|
-
import { CONTENT_SLOT, configureDialect, splitIslandParts } from '../engine/dialect.mjs';
|
|
15
|
-
import { LIQUID_BUDGETS } from '../engine/budgets.mjs';
|
|
16
|
-
|
|
17
|
-
import dialect from '../contract/v1/dialect.json' with { type: 'json' };
|
|
18
|
-
import sectionCatalogue from '../contract/v1/sections.json' with { type: 'json' };
|
|
19
|
-
import islandRegistry from '../contract/v1/islands.json' with { type: 'json' };
|
|
20
|
-
import manifestSchema from '../contract/v1/manifest.schema.json' with { type: 'json' };
|
|
21
|
-
import contextContract from '../contract/v1/context.json' with { type: 'json' };
|
|
22
|
-
import fontCatalogue from '../contract/v1/fonts.json' with { type: 'json' };
|
|
23
|
-
import layoutContract from '../contract/v1/layout.json' with { type: 'json' };
|
|
24
|
-
import behaviourCatalogue from '../contract/v1/behaviours.json' with { type: 'json' };
|
|
25
|
-
import { buildSiteFixture, extractContentFootprint, contentModel } from './site-context.mjs';
|
|
26
|
-
import { proveNavigationHighlights } from './navigation-highlights.mjs';
|
|
27
|
-
|
|
28
|
-
const Ajv = Ajv2020.default ?? Ajv2020;
|
|
29
|
-
|
|
30
|
-
export { dialect as contractDialect, sectionCatalogue, islandRegistry, contextContract, behaviourCatalogue };
|
|
31
|
-
|
|
32
|
-
// The behaviour to opt-in-attribute map is DERIVED from the catalogue (FR-5): `primaryAttribute` on
|
|
33
|
-
// each entry feeds the checks below, the generated reference and the kit, so adding a behaviour is
|
|
34
|
-
// one catalogue entry plus its runtime initialiser. The trailing word boundary keeps the original
|
|
35
|
-
// semantics: a companion attribute that starts with the primary one (data-p60-reveal-group,
|
|
36
|
-
// data-p60-nav-item) counts as the behaviour in use.
|
|
37
|
-
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
38
|
-
export const BEHAVIOUR_PRIMARY_ATTR = Object.fromEntries(
|
|
39
|
-
behaviourCatalogue.behaviours.map((b) => [b.name, b.primaryAttribute])
|
|
40
|
-
);
|
|
41
|
-
const PRIMARY_ATTR = Object.fromEntries(
|
|
42
|
-
behaviourCatalogue.behaviours.map((b) => [b.name, [new RegExp(`${escapeRegExp(b.primaryAttribute)}\\b`), b.primaryAttribute]])
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
// Templates are markup and attributes, NEVER code (docs/template-behaviours.md). These are hard
|
|
46
|
-
// errors over the RAW liquid source, even inside comments, because there is no legitimate reason
|
|
47
|
-
// for the tokens to appear at all. The handler pattern names real DOM event families rather than
|
|
48
|
-
// matching any on* word, so attributes like `once` or `online` never false-positive.
|
|
49
|
-
const FORBIDDEN_MARKUP = [
|
|
50
|
-
[/<script\b/i, 'a <script> tag'],
|
|
51
|
-
[/<(iframe|object|embed)\b/i, 'an embedded frame or plugin element'],
|
|
52
|
-
[/\son(?:click|dbl|aux|load|error|abort|unload|mouse|pointer|touch|drag|drop|wheel|scroll|key|focus|blur|input|change|submit|reset|invalid|select|toggle|copy|paste|cut|context|play|pause|ended|seek|stall|suspend|time|volume|waiting|canplay|animation|transition|message|resize|hashchange|popstate|storage|got|lost)[a-z]*\s*=/i,
|
|
53
|
-
'an inline event handler'],
|
|
54
|
-
[/javascript\s*:/i, 'a javascript: URL'],
|
|
55
|
-
];
|
|
56
|
-
|
|
57
|
-
export async function validateArtifact(files) {
|
|
58
|
-
const errors = [];
|
|
59
|
-
const warnings = [];
|
|
60
|
-
const has = (path) => Object.hasOwn(files, path);
|
|
61
|
-
const read = (path) => files[path];
|
|
62
|
-
|
|
63
|
-
// 1. Manifest against the schema.
|
|
64
|
-
let manifest = null;
|
|
65
|
-
if (!has('manifest.json')) {
|
|
66
|
-
return { errors: ['manifest.json is missing, every artifact starts with its manifest'], warnings, manifest };
|
|
67
|
-
}
|
|
68
|
-
try {
|
|
69
|
-
manifest = JSON.parse(read('manifest.json'));
|
|
70
|
-
} catch (e) {
|
|
71
|
-
return { errors: [`manifest.json unreadable: ${e.message}`], warnings, manifest: null };
|
|
72
|
-
}
|
|
73
|
-
// preview-content.json is a DEV-ONLY data override: package excludes it and the intake
|
|
74
|
-
// refuses it, an artifact must never carry data, only shape.
|
|
75
|
-
if (has('preview-content.json')) {
|
|
76
|
-
errors.push('preview-content.json: development preview data never ships in an artifact, remove it (package excludes it automatically)');
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Content model v1: the footprint is decidable from the sources (closed dialect); unknown
|
|
80
|
-
// paths, dynamic indexing and tree aliasing are errors from site-context.mjs.
|
|
81
|
-
const contentAnalysis = extractContentFootprint(files);
|
|
82
|
-
errors.push(...contentAnalysis.errors);
|
|
83
|
-
const siteFx = buildSiteFixture(manifest);
|
|
84
|
-
|
|
85
|
-
const ajv = new Ajv({ allErrors: true });
|
|
86
|
-
const validateManifest = ajv.compile(manifestSchema);
|
|
87
|
-
if (!validateManifest(manifest)) {
|
|
88
|
-
for (const err of validateManifest.errors) {
|
|
89
|
-
errors.push(`manifest${err.instancePath || ''}: ${err.message}`);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Capability declarations are catalogue MATCHING metadata, not an entitlement shortcut. Keep
|
|
94
|
-
// them honest by requiring one corresponding template-facing surface. The mapping is deliberately
|
|
95
|
-
// structural: it proves the design can present a capability without inspecting tenant data or
|
|
96
|
-
// crossing the platform-owned transaction, identity and consent boundaries.
|
|
97
|
-
{
|
|
98
|
-
const sections = new Set(manifest?.supports?.sections ?? []);
|
|
99
|
-
const islands = new Set(manifest?.supports?.islands ?? []);
|
|
100
|
-
const pages = new Set(manifest?.supports?.pageTemplates ?? []);
|
|
101
|
-
const hasAny = (values, expected) => expected.some((value) => values.has(value));
|
|
102
|
-
const capabilitySurface = {
|
|
103
|
-
giving: () => islands.has('donation_widget'),
|
|
104
|
-
appeals: () => hasAny(sections, ['appealGrid', 'emergency']) || islands.has('donation_widget'),
|
|
105
|
-
worship: () => manifest?.supports?.worship === true || islands.has('next_prayer'),
|
|
106
|
-
courses: () => pages.has('course') || islands.has('course_enrol'),
|
|
107
|
-
membership: () => islands.has('member_menu'),
|
|
108
|
-
events: () => sections.has('events') || pages.has('events') || islands.has('events_carousel'),
|
|
109
|
-
articles: () => sections.has('articles') || hasAny(pages, ['articles', 'article']) || islands.has('latest_articles'),
|
|
110
|
-
services: () => sections.has('programmes'),
|
|
111
|
-
forms: () => islands.has('form'),
|
|
112
|
-
resources: () => sections.has('resources'),
|
|
113
|
-
locations: () => sections.has('locations'),
|
|
114
|
-
newsletter: () => islands.has('newsletter_signup'),
|
|
115
|
-
i18n: () => islands.has('language_switch'),
|
|
116
|
-
search: () => islands.has('search'),
|
|
117
|
-
volunteering: () => islands.has('volunteer_signup') || islands.has('primary_action_widget')
|
|
118
|
-
};
|
|
119
|
-
for (const capability of manifest?.requiresCapabilities ?? []) {
|
|
120
|
-
if (!capabilitySurface[capability]?.()) {
|
|
121
|
-
errors.push(`manifest: requiresCapabilities '${capability}' has no corresponding declared section, page template or island`);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Site focus honesty (docs/volunteering.md): a template that claims it can lead with
|
|
127
|
-
// volunteering must give the volunteer sign-up a way into the hero, either the
|
|
128
|
-
// primary_action_widget island (which becomes the sign-up when volunteering leads) or the
|
|
129
|
-
// volunteer_signup island placed directly.
|
|
130
|
-
{
|
|
131
|
-
const focusKinds = new Set(manifest?.supports?.focus ?? []);
|
|
132
|
-
if (focusKinds.has('volunteer')) {
|
|
133
|
-
const liquidSource = Object.entries(files).filter(([path]) => path.endsWith('.liquid')).map(([, s]) => s).join('\n');
|
|
134
|
-
if (!/island\s+['"]primary_action_widget['"]/.test(liquidSource) && !/island\s+['"]volunteer_signup['"]/.test(liquidSource)) {
|
|
135
|
-
errors.push("manifest: supports.focus lists 'volunteer' but no section places {% island 'primary_action_widget' %} (or 'volunteer_signup'), so the site could never lead with volunteering");
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Markup and attributes, NEVER code, the machine-enforced JavaScript ban over every liquid
|
|
141
|
-
// source (docs/template-behaviours.md). Behaviour is engine-owned; a template wanting motion
|
|
142
|
-
// declares supports.behaviors and uses the data-p60-* grammar.
|
|
143
|
-
for (const [path, source] of Object.entries(files)) {
|
|
144
|
-
if (!path.endsWith('.liquid')) continue;
|
|
145
|
-
for (const [pattern, what] of FORBIDDEN_MARKUP) {
|
|
146
|
-
if (pattern.test(source)) {
|
|
147
|
-
errors.push(`${path}: contains ${what}, templates are markup and attributes, never code (behaviour is engine-owned; see the behaviour catalogue)`);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Behaviour declaration and usage must agree in BOTH directions. Source-level, deliberately:
|
|
153
|
-
// usage often sits inside content-dependent branches the fixtures never take, so the grammar's
|
|
154
|
-
// presence in the source is the honest minimal proof.
|
|
155
|
-
{
|
|
156
|
-
const declaredBehaviours = new Set(manifest?.supports?.behaviors ?? []);
|
|
157
|
-
const liquidSource = Object.entries(files)
|
|
158
|
-
.filter(([path]) => path.endsWith('.liquid'))
|
|
159
|
-
.map(([, source]) => source)
|
|
160
|
-
.join('\n');
|
|
161
|
-
for (const name of declaredBehaviours) {
|
|
162
|
-
const primary = PRIMARY_ATTR[name];
|
|
163
|
-
if (primary && !primary[0].test(liquidSource)) {
|
|
164
|
-
errors.push(`manifest: supports.behaviors declares '${name}' but no ${primary[1]} attribute appears in any liquid source`);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
for (const [name, [pattern, attr]] of Object.entries(PRIMARY_ATTR)) {
|
|
168
|
-
if (pattern.test(liquidSource) && !declaredBehaviours.has(name)) {
|
|
169
|
-
errors.push(`behaviour '${name}': ${attr} appears in the markup but supports.behaviors does not declare it`);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
if (/data-p60-carousel(?!-)\b/.test(liquidSource) && !/data-p60-slide\b/.test(liquidSource)) {
|
|
173
|
-
errors.push("behaviour 'carousel': a data-p60-carousel container needs data-p60-slide children");
|
|
174
|
-
}
|
|
175
|
-
if (/data-p60-lightbox(?!-)\b/.test(liquidSource) && !/data-p60-lightbox-item\b/.test(liquidSource)) {
|
|
176
|
-
errors.push("behaviour 'lightbox': a data-p60-lightbox group needs data-p60-lightbox-item anchors");
|
|
177
|
-
}
|
|
178
|
-
if (/data-p60-tabs\b/.test(liquidSource)
|
|
179
|
-
&& (!/data-p60-tab(?!s)\b/.test(liquidSource) || !/data-p60-panel\b/.test(liquidSource))) {
|
|
180
|
-
errors.push("behaviour 'tabs': a data-p60-tabs container needs data-p60-tab controls and data-p60-panel panels");
|
|
181
|
-
}
|
|
182
|
-
if (/data-p60-nav-item\b/.test(liquidSource)
|
|
183
|
-
&& (!/data-p60-nav-toggle\b/.test(liquidSource) || !/data-p60-nav-menu\b/.test(liquidSource))) {
|
|
184
|
-
errors.push("behaviour 'nav': a data-p60-nav-item group needs a data-p60-nav-toggle control and a data-p60-nav-menu panel");
|
|
185
|
-
}
|
|
186
|
-
if (/data-p60-nav-(item|burger)\b/.test(liquidSource) && !/data-p60-nav(?!-)\b/.test(liquidSource)) {
|
|
187
|
-
errors.push("behaviour 'nav': data-p60-nav-item and data-p60-nav-burger need a data-p60-nav root around the menu");
|
|
188
|
-
}
|
|
189
|
-
if (/data-p60-show-more(?!-)\b/.test(liquidSource)
|
|
190
|
-
&& (!/data-p60-show-more-item\b/.test(liquidSource) || !/data-p60-show-more-toggle\b/.test(liquidSource))) {
|
|
191
|
-
errors.push("behaviour 'showMore': a data-p60-show-more list needs data-p60-show-more-item entries and a data-p60-show-more-toggle button");
|
|
192
|
-
}
|
|
193
|
-
if (/data-p60-show-more-toggle\b/.test(liquidSource) && !/<button\b[^>]*data-p60-show-more-toggle\b/.test(liquidSource)) {
|
|
194
|
-
errors.push("behaviour 'showMore': data-p60-show-more-toggle belongs on a <button> (a link would navigate; a div would not be operable)");
|
|
195
|
-
}
|
|
196
|
-
if (/data-p60-scrollspy\b/.test(liquidSource) && !/href=["']#[^"'\s]/.test(liquidSource)) {
|
|
197
|
-
errors.push("behaviour 'scrollspy': a data-p60-scrollspy menu needs links to in-page anchors (href=\"#section-id\")");
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Font knobs: the default family must be a real catalogue entry (the tenant-unset render uses
|
|
202
|
-
// it), and the slot must declare the weights the template's typographic system needs.
|
|
203
|
-
{
|
|
204
|
-
const familyNames = new Set(fontCatalogue.families.map((f) => f.name));
|
|
205
|
-
for (const knob of manifest?.settings?.schema ?? []) {
|
|
206
|
-
if (knob.kind !== 'font') continue;
|
|
207
|
-
if (typeof knob.default !== 'string' || !familyNames.has(knob.default)) {
|
|
208
|
-
errors.push(`settings knob '${knob.key}': font default '${knob.default}' is not in the font catalogue`);
|
|
209
|
-
} else if (!Array.isArray(knob.weights) || knob.weights.length === 0) {
|
|
210
|
-
errors.push(`settings knob '${knob.key}': font knobs must declare the weights the template uses`);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Looks: every value must target a declared knob and be valid for it, a look that half-applies
|
|
216
|
-
// would leave the tenant in a state no author designed.
|
|
217
|
-
{
|
|
218
|
-
const knobByKey = new Map((manifest?.settings?.schema ?? []).map((k) => [k.key, k]));
|
|
219
|
-
const familyNames = new Set(fontCatalogue.families.map((f) => f.name));
|
|
220
|
-
const seenNames = new Set();
|
|
221
|
-
for (const look of manifest?.looks ?? []) {
|
|
222
|
-
if (seenNames.has(look.name)) errors.push(`look '${look.name}': duplicate name`);
|
|
223
|
-
seenNames.add(look.name);
|
|
224
|
-
for (const [key, value] of Object.entries(look.values ?? {})) {
|
|
225
|
-
const knob = knobByKey.get(key);
|
|
226
|
-
if (!knob) {
|
|
227
|
-
errors.push(`look '${look.name}': '${key}' is not a declared settings knob`);
|
|
228
|
-
} else if (knob.kind === 'select' && !(knob.options ?? []).includes(value)) {
|
|
229
|
-
errors.push(`look '${look.name}': '${value}' is not an option of select knob '${key}'`);
|
|
230
|
-
} else if (knob.kind === 'font' && !familyNames.has(value)) {
|
|
231
|
-
errors.push(`look '${look.name}': font '${value}' is not in the font catalogue`);
|
|
232
|
-
} else if (knob.kind === 'color' && value !== '' && !/^#[0-9a-fA-F]{3,8}$/.test(value)) {
|
|
233
|
-
errors.push(`look '${look.name}': '${value}' is not a colour value for knob '${key}'`);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Engine identical to production: escape-by-default, strict filters, restricted dialect,
|
|
240
|
-
// and the SAME DoS budgets the live renderer runs with.
|
|
241
|
-
const availableIslands = new Set(
|
|
242
|
-
islandRegistry.islands.filter((i) => i.status === 'available').map((i) => i.name)
|
|
243
|
-
);
|
|
244
|
-
const allIslands = new Set(islandRegistry.islands.map((i) => i.name));
|
|
245
|
-
const liquid = new Liquid({ outputEscape: 'escape', strictFilters: true, ...LIQUID_BUDGETS });
|
|
246
|
-
configureDialect(liquid, dialect, allIslands);
|
|
247
|
-
|
|
248
|
-
const catalogueByType = new Map(sectionCatalogue.sections.map((s) => [s.type, s]));
|
|
249
|
-
const declaredIslands = new Set(manifest?.supports?.islands ?? []);
|
|
250
|
-
const placedIslands = new Set();
|
|
251
|
-
// heroImagery proof state (filled by the homeHero renders below).
|
|
252
|
-
let homeHeroMultiShows = null; // sample (several photos): probe in html OR hero_carousel placed
|
|
253
|
-
let homeHeroSingleShows = null; // single-photo variant: probe rendered directly
|
|
254
|
-
|
|
255
|
-
// Author-renderable widget sections (the flip): each carries a curated data context; a template
|
|
256
|
-
// either places the section's DEFAULT ISLAND (which owns rendering + empty states) or renders
|
|
257
|
-
// the data itself, in which case both directions are proven behaviourally (the worship
|
|
258
|
-
// pattern): the populated fixture's sentinel must appear, and the EMPTY context must render it
|
|
259
|
-
// away (derive or omit, nothing invented, nothing dangling).
|
|
260
|
-
// Sentinels are DERIVED from the canonical fixtures (the first item's display field), never
|
|
261
|
-
// hardcoded, the fixture data is free to become richer without touching a proof, and a proof
|
|
262
|
-
// can never drift from the data it renders.
|
|
263
|
-
const sentinelOf = (type, dataKey, field) =>
|
|
264
|
-
(contextContract.fixtures.sections?.[type]?.[dataKey] ?? [])[0]?.[field] ?? null;
|
|
265
|
-
const WIDGET_SECTIONS = {
|
|
266
|
-
events: { island: 'events_carousel', dataKey: 'events', sentinel: sentinelOf('events', 'events', 'name') },
|
|
267
|
-
whatsOn: { island: 'whats_on_strip', dataKey: 'infoEvents', sentinel: sentinelOf('whatsOn', 'infoEvents', 'name') },
|
|
268
|
-
articles: { island: 'latest_articles', dataKey: 'latestArticles', sentinel: sentinelOf('articles', 'latestArticles', 'title') },
|
|
269
|
-
campaigns: { island: null, dataKey: 'campaigns', sentinel: sentinelOf('campaigns', 'campaigns', 'title') },
|
|
270
|
-
resources: { island: null, dataKey: 'resources', sentinel: sentinelOf('resources', 'resources', 'title') },
|
|
271
|
-
locations: { island: null, dataKey: 'locations', sentinel: sentinelOf('locations', 'locations', 'name') }
|
|
272
|
-
};
|
|
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.
|
|
299
|
-
for (const type of manifest?.supports?.sections ?? []) {
|
|
300
|
-
const entry = catalogueByType.get(type);
|
|
301
|
-
if (!entry) {
|
|
302
|
-
errors.push(`section '${type}': not in the platform section catalogue`);
|
|
303
|
-
continue;
|
|
304
|
-
}
|
|
305
|
-
const file = `sections/${type}.liquid`;
|
|
306
|
-
if (!has(file)) {
|
|
307
|
-
errors.push(`section '${type}': declared in the manifest but sections/${type}.liquid is missing`);
|
|
308
|
-
continue;
|
|
309
|
-
}
|
|
310
|
-
let parsed;
|
|
311
|
-
try {
|
|
312
|
-
parsed = liquid.parse(read(file));
|
|
313
|
-
} catch (e) {
|
|
314
|
-
errors.push(`section '${type}': does not parse under the dialect, ${e.message}`);
|
|
315
|
-
continue;
|
|
316
|
-
}
|
|
317
|
-
// Widget-section proof (independent of the minimal/sample loop): island placed → the island
|
|
318
|
-
// owns everything; hand-rendered → sentinel appears with data, vanishes without.
|
|
319
|
-
const widget = WIDGET_SECTIONS[type];
|
|
320
|
-
if (widget) {
|
|
321
|
-
const dataFixture = contextContract.fixtures.sections?.[type] ?? {};
|
|
322
|
-
try {
|
|
323
|
-
const populated = await liquid.render(parsed, {
|
|
324
|
-
section: {},
|
|
325
|
-
brand: contextContract.fixtures.brand,
|
|
326
|
-
site: siteFx,
|
|
327
|
-
...dataFixture
|
|
328
|
-
});
|
|
329
|
-
const placesIsland = widget.island !== null
|
|
330
|
-
&& splitIslandParts(populated).some((p) => p.island === widget.island);
|
|
331
|
-
if (!placesIsland) {
|
|
332
|
-
if (widget.sentinel && !populated.includes(widget.sentinel)) {
|
|
333
|
-
errors.push(
|
|
334
|
-
widget.island
|
|
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`
|
|
337
|
-
);
|
|
338
|
-
}
|
|
339
|
-
const empty = await liquid.render(parsed, {
|
|
340
|
-
section: {},
|
|
341
|
-
brand: contextContract.fixtures.brand,
|
|
342
|
-
site: { ...siteFx, content: Object.fromEntries(Object.keys(siteFx.content).map((k) => [k, []])) },
|
|
343
|
-
[widget.dataKey]: []
|
|
344
|
-
});
|
|
345
|
-
if (widget.sentinel && empty.includes(widget.sentinel)) {
|
|
346
|
-
errors.push(`section '${type}': still shows fixture content with an empty ${widget.dataKey}, content must come from the context`);
|
|
347
|
-
}
|
|
348
|
-
if (/\bundefined\b|\bnull\b/.test(empty.replace(/data-[a-z-]+="[^"]*"/g, ''))) {
|
|
349
|
-
errors.push(`section '${type}': renders 'undefined'/'null' literals when ${widget.dataKey} is empty, guard the empty case (derive or omit)`);
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
} catch (e) {
|
|
353
|
-
errors.push(`section '${type}': failed rendering the ${widget.dataKey} context fixtures, ${e.message}`);
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
for (const fixtureName of ['minimal', 'sample']) {
|
|
357
|
-
const fixture = entry[fixtureName] ?? {};
|
|
358
|
-
try {
|
|
359
|
-
const html = await liquid.render(parsed, {
|
|
360
|
-
section: fixture,
|
|
361
|
-
brand: contextContract.fixtures.brand,
|
|
362
|
-
site: siteFx,
|
|
363
|
-
// Widget data rides the ordinary fixture renders too, so a hand-rendering section
|
|
364
|
-
// doesn't fail the generic pass for want of its context.
|
|
365
|
-
...(widget ? (contextContract.fixtures.sections?.[type] ?? {}) : {})
|
|
366
|
-
});
|
|
367
|
-
if (type === 'homeHero' && fixtureName === 'sample') {
|
|
368
|
-
// The sample's own first image reference is the probe: `p60fixture:` refs are quote-free
|
|
369
|
-
// and survive HTML escaping, so "does the hero display the photos?" stays a substring check.
|
|
370
|
-
const probe = (fixture.images ?? [])[0]?.imageUrl ?? 'p60fixture:';
|
|
371
|
-
homeHeroMultiShows = html.includes(probe)
|
|
372
|
-
|| splitIslandParts(html).some((p) => p.island === 'hero_carousel');
|
|
373
|
-
// The single-photo path proven separately: same fixture, first photo only.
|
|
374
|
-
try {
|
|
375
|
-
const single = await liquid.render(parsed, {
|
|
376
|
-
section: { ...fixture, images: (fixture.images ?? []).slice(0, 1) },
|
|
377
|
-
brand: contextContract.fixtures.brand,
|
|
378
|
-
site: siteFx
|
|
379
|
-
});
|
|
380
|
-
homeHeroSingleShows = single.includes(probe);
|
|
381
|
-
} catch {
|
|
382
|
-
homeHeroSingleShows = false;
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
for (const part of splitIslandParts(html)) {
|
|
386
|
-
if (part.island === CONTENT_SLOT) {
|
|
387
|
-
errors.push(`section '${type}': uses {% content %}, that tag is layout-only`);
|
|
388
|
-
} else if (part.island) {
|
|
389
|
-
placedIslands.add(part.island);
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
} catch (e) {
|
|
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/`);
|
|
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');
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// Hero-imagery honesty, checked BEHAVIOURALLY (the worship pattern). The homeHero sample
|
|
441
|
-
// fixture carries photographs whose data URIs embed a quote-free marker that survives HTML
|
|
442
|
-
// escaping, so "does the rendered hero display the tenant's photos?" is a substring check,
|
|
443
|
-
// and with several photos, placing the hero_carousel island IS displaying them (the island
|
|
444
|
-
// renders the slides at runtime). The single-photo path is proven separately: images[0] must
|
|
445
|
-
// appear directly. Declaration and behaviour must agree; the choosers badge photo-led tenants
|
|
446
|
-
// by supports.heroImagery. Legacy imageUrl-only renderers never match (the fixture's photos
|
|
447
|
-
// ride `images`), so they pass undeclared, they just don't earn the badge.
|
|
448
|
-
{
|
|
449
|
-
const declaresHero = manifest?.supports?.heroImagery === true;
|
|
450
|
-
if (declaresHero && !(manifest?.supports?.sections ?? []).includes('homeHero')) {
|
|
451
|
-
errors.push('manifest: supports.heroImagery requires the homeHero section, the photographs live on it');
|
|
452
|
-
} else if (homeHeroMultiShows !== null) {
|
|
453
|
-
if (declaresHero && !homeHeroMultiShows) {
|
|
454
|
-
errors.push('homeHero: manifest declares supports.heroImagery but the rendered section neither displays the images fixture nor places the hero_carousel island');
|
|
455
|
-
}
|
|
456
|
-
if (declaresHero && !homeHeroSingleShows) {
|
|
457
|
-
errors.push('homeHero: supports.heroImagery must render a SINGLE photograph directly (images[0], treated, never raw), the carousel island only covers 2+');
|
|
458
|
-
}
|
|
459
|
-
if (!declaresHero && (homeHeroMultiShows || homeHeroSingleShows)) {
|
|
460
|
-
errors.push('homeHero: renders the hero photographs but the manifest does not declare supports.heroImagery, declare it so the choosers can badge it');
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
// 7. Layout (when declared): parse + render the layout fixture + exactly one content slot.
|
|
466
|
-
if (manifest?.supports?.worship && !manifest?.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');
|
|
471
|
-
}
|
|
472
|
-
if (manifest?.supports?.layout) {
|
|
473
|
-
if (!has('layout.liquid')) {
|
|
474
|
-
errors.push('layout: manifest declares supports.layout but layout.liquid is missing');
|
|
475
|
-
} else {
|
|
476
|
-
let parsedLayout;
|
|
477
|
-
try {
|
|
478
|
-
parsedLayout = liquid.parse(read('layout.liquid'));
|
|
479
|
-
} catch (e) {
|
|
480
|
-
errors.push(`layout: does not parse under the dialect, ${e.message}`);
|
|
481
|
-
}
|
|
482
|
-
if (parsedLayout) {
|
|
483
|
-
try {
|
|
484
|
-
const html = await liquid.render(parsedLayout, {
|
|
485
|
-
site: siteFx,
|
|
486
|
-
brand: contextContract.fixtures.brand,
|
|
487
|
-
nav: contextContract.fixtures.layout.nav,
|
|
488
|
-
socials: contextContract.fixtures.layout.socials ?? [],
|
|
489
|
-
worship: contextContract.fixtures.layout.worship ?? null,
|
|
490
|
-
locale: contextContract.fixtures.layout.locale
|
|
491
|
-
});
|
|
492
|
-
let contentSlots = 0;
|
|
493
|
-
const layoutIslands = [];
|
|
494
|
-
for (const part of splitIslandParts(html)) {
|
|
495
|
-
if (part.island === CONTENT_SLOT) contentSlots++;
|
|
496
|
-
else if (part.island) { placedIslands.add(part.island); layoutIslands.push(part.island); }
|
|
497
|
-
}
|
|
498
|
-
if (contentSlots !== 1) {
|
|
499
|
-
errors.push(`layout: must contain exactly one {% content %} slot (found ${contentSlots})`);
|
|
500
|
-
}
|
|
501
|
-
if (!layoutIslands.includes('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");
|
|
503
|
-
}
|
|
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
|
-
|
|
516
|
-
// Two-sided worship honesty, checked BEHAVIOURALLY: does the rendered layout actually
|
|
517
|
-
// display the worship fixture's times? Declaration and behaviour must agree, the
|
|
518
|
-
// choosers steer worship-enabled tenants by supports.worship, so a false declaration
|
|
519
|
-
// either hides their times (undeclared but rendered is fine to fix by declaring) or
|
|
520
|
-
// promises a rail that never appears.
|
|
521
|
-
const worshipProbe = contextContract.fixtures.layout.worship?.times?.[0]?.name;
|
|
522
|
-
if (worshipProbe) {
|
|
523
|
-
const rendersWorship = html.includes(worshipProbe);
|
|
524
|
-
if (manifest?.supports?.worship && !rendersWorship) {
|
|
525
|
-
errors.push('layout: manifest declares supports.worship but the rendered layout does not display the worship fixture times, the rail never appears');
|
|
526
|
-
}
|
|
527
|
-
if (!manifest?.supports?.worship && rendersWorship) {
|
|
528
|
-
errors.push('layout: renders the worship rail but the manifest does not declare supports.worship, declare it so the choosers can badge it');
|
|
529
|
-
}
|
|
530
|
-
if (manifest?.supports?.worship) {
|
|
531
|
-
const nullHtml = await liquid.render(parsedLayout, {
|
|
532
|
-
site: siteFx,
|
|
533
|
-
brand: contextContract.fixtures.brand,
|
|
534
|
-
nav: contextContract.fixtures.layout.nav,
|
|
535
|
-
socials: contextContract.fixtures.layout.socials ?? [],
|
|
536
|
-
worship: null,
|
|
537
|
-
site: { ...siteFx, content: { ...siteFx.content, schedules: [] } },
|
|
538
|
-
locale: contextContract.fixtures.layout.locale
|
|
539
|
-
});
|
|
540
|
-
if (nullHtml.includes(worshipProbe)) {
|
|
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)');
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
} catch (e) {
|
|
546
|
-
errors.push(`layout: failed rendering the layout fixture, ${e.message}`);
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
} else if (has('layout.liquid')) {
|
|
551
|
-
warnings.push('layout.liquid present but manifest.supports.layout is not true, it will be ignored');
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
// 8. Page templates (when declared): file exists, parses, renders the page's data fixture.
|
|
555
|
-
for (const pageName of manifest?.supports?.pageTemplates ?? []) {
|
|
556
|
-
const fixture = contextContract.fixtures.pages?.[pageName];
|
|
557
|
-
if (!fixture) {
|
|
558
|
-
errors.push(`page template '${pageName}': no such page in the contract (no fixtures.pages.${pageName})`);
|
|
559
|
-
continue;
|
|
560
|
-
}
|
|
561
|
-
const pageFile = `pages/${pageName}.liquid`;
|
|
562
|
-
if (!has(pageFile)) {
|
|
563
|
-
errors.push(`page template '${pageName}': declared in supports.pageTemplates but pages/${pageName}.liquid is missing`);
|
|
564
|
-
continue;
|
|
565
|
-
}
|
|
566
|
-
let parsedPage;
|
|
567
|
-
try {
|
|
568
|
-
parsedPage = liquid.parse(read(pageFile));
|
|
569
|
-
} catch (e) {
|
|
570
|
-
errors.push(`page template '${pageName}': does not parse under the dialect, ${e.message}`);
|
|
571
|
-
continue;
|
|
572
|
-
}
|
|
573
|
-
try {
|
|
574
|
-
const html = await liquid.render(parsedPage, { ...fixture, site: siteFx, brand: contextContract.fixtures.brand });
|
|
575
|
-
for (const part of splitIslandParts(html)) {
|
|
576
|
-
if (part.island === CONTENT_SLOT) {
|
|
577
|
-
errors.push(`page template '${pageName}': uses {% content %}, that tag is layout-only`);
|
|
578
|
-
} else if (part.island) {
|
|
579
|
-
placedIslands.add(part.island);
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
} catch (e) {
|
|
583
|
-
errors.push(`page template '${pageName}': failed rendering the page fixture, ${e.message}`);
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
// 5. Island discipline: placed ⊆ declared ⊆ registry (and available).
|
|
588
|
-
for (const name of placedIslands) {
|
|
589
|
-
if (!declaredIslands.has(name)) {
|
|
590
|
-
errors.push(`island '${name}': placed in a section but not declared in manifest.supports.islands`);
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
for (const name of declaredIslands) {
|
|
594
|
-
if (!allIslands.has(name)) {
|
|
595
|
-
errors.push(`island '${name}': not in the platform island registry`);
|
|
596
|
-
} else if (!availableIslands.has(name)) {
|
|
597
|
-
warnings.push(`island '${name}': registry status is 'planned', it will render nothing until available`);
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
// 6. Theme.
|
|
602
|
-
if (!has('assets/theme.css') || read('assets/theme.css').trim() === '') {
|
|
603
|
-
errors.push('assets/theme.css missing or empty, a template must ship its look');
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
// 6b. Layout contract (contract/v1/layout.json). Platform pages render through the content SEAM
|
|
607
|
-
// (.container / .full); a template STYLES those to place content, never a parallel content container.
|
|
608
|
-
// The rule is DATA, the seam token, the allowed selectors and the message all come from the contract
|
|
609
|
-
// file; this only implements the check KIND (a non-seam selector sizing its width off the token).
|
|
610
|
-
{
|
|
611
|
-
const css = has('assets/theme.css') ? read('assets/theme.css') : '';
|
|
612
|
-
const stripped = css.replace(/\/\*[\s\S]*?\*\//g, ' '); // drop comments so an example can't trip it
|
|
613
|
-
const RULE = /([^{}]+)\{([^{}]*)\}/g;
|
|
614
|
-
for (const rule of layoutContract.rules ?? []) {
|
|
615
|
-
if (rule.kind !== 'css-width-off-token') continue;
|
|
616
|
-
const token = rule.token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
617
|
-
const sizesOffToken = new RegExp(`\\b(?:min-|max-)?(?:width|inline-size)\\s*:[^;]*var\\(\\s*${token}\\b`);
|
|
618
|
-
const isSeam = new RegExp(`\\.(?:${rule.allow.join('|')})(?![\\w-])`);
|
|
619
|
-
const offenders = new Set();
|
|
620
|
-
let m;
|
|
621
|
-
RULE.lastIndex = 0;
|
|
622
|
-
while ((m = RULE.exec(stripped)) !== null) {
|
|
623
|
-
if (!sizesOffToken.test(m[2])) continue; // only rules that size a box off the token
|
|
624
|
-
for (const sel of m[1].split(',')) {
|
|
625
|
-
const s = sel.trim();
|
|
626
|
-
if (s && !isSeam.test(s)) offenders.add(s);
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
for (const sel of [...offenders].sort()) {
|
|
630
|
-
errors.push(`theme: selector '${sel}' ${rule.message}`);
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
}
|
|
1
|
+
// Existing artifacts are validated against their own contract. New uploads have an explicit v2 gate.
|
|
2
|
+
import { validateArtifact as validateV1Artifact } from './validate-v1.mjs';
|
|
3
|
+
import { validateArtifact as validateV2Artifact } from './validate-v2.mjs';
|
|
4
|
+
export { validateV1Artifact, validateV2Artifact };
|
|
5
|
+
// Historical consumers use these exact v1 exports; v2 consumers select the versioned module.
|
|
6
|
+
export { contractDialect, sectionCatalogue, islandRegistry, contextContract, behaviourCatalogue, BEHAVIOUR_PRIMARY_ATTR } from './validate-v1.mjs';
|
|
7
|
+
|
|
8
|
+
function manifestOf(files) {
|
|
9
|
+
try { return JSON.parse(files['manifest.json'] ?? 'null'); }
|
|
10
|
+
catch { return null; }
|
|
11
|
+
}
|
|
634
12
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
if (
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
id: 'ffffffff-ffff-4fff-8fff-ffffffffffff',
|
|
642
|
-
name: 'Open Enum Probe Event',
|
|
643
|
-
registrationMode: 'X_FUTURE_MODE',
|
|
644
|
-
detailHref: '/events?event=ffffffff-ffff-4fff-8fff-ffffffffffff'
|
|
645
|
-
};
|
|
646
|
-
const doctored = { ...siteFx, content: { ...siteFx.content, events: [...siteFx.content.events, futureEvent] } };
|
|
647
|
-
for (const type of manifest?.supports?.sections ?? []) {
|
|
648
|
-
const file = `sections/${type}.liquid`;
|
|
649
|
-
if (!has(file) || !/\bsite\s*[.[]/.test(read(file))) continue;
|
|
650
|
-
try {
|
|
651
|
-
const html = await liquid.render(liquid.parse(read(file)), {
|
|
652
|
-
section: catalogueByType.get(type)?.sample ?? {},
|
|
653
|
-
brand: contextContract.fixtures.brand,
|
|
654
|
-
site: doctored
|
|
655
|
-
});
|
|
656
|
-
if (/\bundefined\b|\bnull\b/.test(html.replace(/data-[a-z-]+="[^"]*"/g, ''))) {
|
|
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`);
|
|
658
|
-
}
|
|
659
|
-
} catch (e) {
|
|
660
|
-
errors.push(`section '${type}': failed rendering an unknown event registrationMode, the enum is OPEN and new modes will arrive (${e.message})`);
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
}
|
|
13
|
+
export function validateArtifact(files) {
|
|
14
|
+
const manifest = manifestOf(files);
|
|
15
|
+
if (manifest?.format === 'port60-liquid@2') return validateV2Artifact(files);
|
|
16
|
+
if (manifest?.format === 'port60-liquid@1' || !manifest?.format) return validateV1Artifact(files);
|
|
17
|
+
return Promise.resolve({ errors: [`Unsupported template format '${manifest.format}'. This host accepts port60-liquid@1 and port60-liquid@2 for existing artifacts.`], warnings: [], manifest });
|
|
18
|
+
}
|
|
664
19
|
|
|
665
|
-
|
|
20
|
+
export function validateNewArtifact(files) {
|
|
21
|
+
const manifest = manifestOf(files);
|
|
22
|
+
if (manifest?.format !== 'port60-liquid@2') return Promise.resolve({ errors: ['New uploads require port60-liquid@2. Migrate the template with kit 1.0.0; existing v1 pins are not changed.'], warnings: [], manifest });
|
|
23
|
+
return validateV2Artifact(files);
|
|
666
24
|
}
|