@port60/template-kit 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -3
- package/bin/cli.mjs +26 -4
- package/package.json +1 -1
- package/src/commands/content.mjs +42 -0
- package/src/commands/create.mjs +10 -7
- package/src/commands/dev.mjs +134 -8
- package/src/commands/model.mjs +34 -0
- package/src/commands/packageCmd.mjs +3 -3
- package/src/commands/refresh.mjs +48 -0
- package/src/commands/upgrade.mjs +44 -0
- package/src/commands/validate.mjs +2 -3
- package/src/lib/agentsMd.mjs +78 -21
- package/src/vendor/contract/v1/behaviours.json +129 -0
- package/src/vendor/contract/v1/content-bounds.json +3 -3
- package/src/vendor/contract/v1/content-model.json +193 -0
- package/src/vendor/contract/v1/context.json +1985 -141
- package/src/vendor/contract/v1/dialect.json +3 -3
- package/src/vendor/contract/v1/fonts.json +1 -1
- package/src/vendor/contract/v1/imagery.json +4 -4
- package/src/vendor/contract/v1/islands.json +256 -110
- package/src/vendor/contract/v1/layout.json +25 -0
- package/src/vendor/contract/v1/manifest.schema.json +60 -14
- package/src/vendor/contract/v1/sections.json +330 -61
- package/src/vendor/contract/v1/tokens.json +1 -1
- package/src/vendor/contract/v1.lock.json +1 -1
- package/src/vendor/engine/content-footprint.mjs +79 -0
- package/src/vendor/validator/behaviors-runtime.js +2 -0
- package/src/vendor/validator/fixture-art.mjs +109 -0
- package/src/vendor/validator/model-reference.mjs +92 -0
- package/src/vendor/validator/platform-base.css +4250 -0
- package/src/vendor/validator/preview.mjs +497 -23
- package/src/vendor/validator/site-context.mjs +97 -0
- package/src/vendor/validator/validate.mjs +214 -14
- package/starter/assets/theme.css +54 -8
- package/starter/layout.liquid +15 -16
- package/starter/manifest.json +10 -5
- package/starter/sections/campaigns.liquid +23 -0
- package/starter/sections/homeHero.liquid +26 -11
- package/starter/sections/people.liquid +1 -1
- package/starter/sections/values.liquid +6 -1
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// The `site` tree for validator and preview renders (content model v1 — contract/v1/
|
|
2
|
+
// content-model.json): ONE realistic organisation assembled from the canonical fixtures, with
|
|
3
|
+
// `site.content.about` composed per-template from the section catalogue's samples for the
|
|
4
|
+
// manifest's declared sections — the admin-authored page composition, previewed honestly.
|
|
5
|
+
import contextContract from '../contract/v1/context.json' with { type: 'json' };
|
|
6
|
+
import sectionCatalogue from '../contract/v1/sections.json' with { type: 'json' };
|
|
7
|
+
|
|
8
|
+
export { contentModel, extractContentFootprint } from '../engine/content-footprint.mjs';
|
|
9
|
+
import { contentModel } from '../engine/content-footprint.mjs';
|
|
10
|
+
|
|
11
|
+
/** The canonical site fixture with `about` composed for this manifest's declared sections. */
|
|
12
|
+
export function buildSiteFixture(manifest) {
|
|
13
|
+
const base = contextContract.fixtures.site;
|
|
14
|
+
const catalogueByType = new Map(sectionCatalogue.sections.map((s) => [s.type, s]));
|
|
15
|
+
const about = (manifest?.supports?.sections ?? [])
|
|
16
|
+
.map((type) => {
|
|
17
|
+
const entry = catalogueByType.get(type);
|
|
18
|
+
return entry ? { type, content: entry.sample ?? {} } : null;
|
|
19
|
+
})
|
|
20
|
+
.filter(Boolean);
|
|
21
|
+
return { ...base, content: { ...base.content, about } };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── preview-content.json (author-editable data) ────────────────────────────────
|
|
25
|
+
// Data is free, SHAPE is fixed: a collection override must be an array of items whose fields all
|
|
26
|
+
// exist in the model, within the collection's cap. Proofs always run on the canonical fixtures,
|
|
27
|
+
// so custom data can never dodge a gate; this file only feeds the dev preview.
|
|
28
|
+
|
|
29
|
+
const BRAND_FIELDS = new Set(['name', 'tagline', 'logoUrl', 'logoType', 'footerLogoUrl']);
|
|
30
|
+
|
|
31
|
+
export function validatePreviewContent(json) {
|
|
32
|
+
const errors = [];
|
|
33
|
+
if (json === null || typeof json !== 'object' || Array.isArray(json)) {
|
|
34
|
+
return ['preview-content.json: must be an object of { collection: [items] } overrides'];
|
|
35
|
+
}
|
|
36
|
+
for (const [name, items] of Object.entries(json)) {
|
|
37
|
+
// `brand` is the one non-collection override: the organisation's own name and marks.
|
|
38
|
+
if (name === 'brand') {
|
|
39
|
+
if (items === null || typeof items !== 'object' || Array.isArray(items)) {
|
|
40
|
+
errors.push('preview-content.json: brand must be an object');
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
for (const field of Object.keys(items)) {
|
|
44
|
+
if (!BRAND_FIELDS.has(field)) {
|
|
45
|
+
errors.push(`preview-content.json: brand.${field} does not exist — brand carries ${[...BRAND_FIELDS].join(', ')}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const model = contentModel.collections[name];
|
|
51
|
+
if (!model) {
|
|
52
|
+
errors.push(`preview-content.json: '${name}' is not a content collection (see contract/v1/content-model.json)`);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (!Array.isArray(items)) {
|
|
56
|
+
errors.push(`preview-content.json: '${name}' must be an array`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (items.length > model.cap) {
|
|
60
|
+
errors.push(`preview-content.json: '${name}' holds ${items.length} items — the collection is bounded at ${model.cap}`);
|
|
61
|
+
}
|
|
62
|
+
items.forEach((item, i) => {
|
|
63
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item)) {
|
|
64
|
+
errors.push(`preview-content.json: ${name}[${i}] must be an object`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
for (const field of Object.keys(item)) {
|
|
68
|
+
if (!model.item[field]) {
|
|
69
|
+
errors.push(`preview-content.json: ${name}[${i}].${field} does not exist in the model — a field that does not exist in production cannot exist in a preview`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const [field, spec] of Object.entries(model.item)) {
|
|
73
|
+
const value = item[field];
|
|
74
|
+
if (value == null) continue;
|
|
75
|
+
const kind = spec.type === 'string[]' ? 'array' : spec.type;
|
|
76
|
+
const actual = Array.isArray(value) ? 'array' : typeof value;
|
|
77
|
+
if (kind !== 'object' && actual !== kind) {
|
|
78
|
+
errors.push(`preview-content.json: ${name}[${i}].${field} should be ${spec.type}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return errors;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Overlay validated preview content onto a site fixture (collections replaced wholesale). */
|
|
87
|
+
export function applyPreviewContent(site, json) {
|
|
88
|
+
if (!json || typeof json !== 'object') return site;
|
|
89
|
+
const content = { ...site.content };
|
|
90
|
+
for (const [name, items] of Object.entries(json)) {
|
|
91
|
+
if (contentModel.collections[name] && Array.isArray(items)) content[name] = items;
|
|
92
|
+
}
|
|
93
|
+
const brand = json.brand && typeof json.brand === 'object' && !Array.isArray(json.brand)
|
|
94
|
+
? { ...site.brand, ...json.brand }
|
|
95
|
+
: site.brand;
|
|
96
|
+
return { ...site, brand, content };
|
|
97
|
+
}
|
|
@@ -20,10 +20,25 @@ import islandRegistry from '../contract/v1/islands.json' with { type: 'json' };
|
|
|
20
20
|
import manifestSchema from '../contract/v1/manifest.schema.json' with { type: 'json' };
|
|
21
21
|
import contextContract from '../contract/v1/context.json' with { type: 'json' };
|
|
22
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';
|
|
23
26
|
|
|
24
27
|
const Ajv = Ajv2020.default ?? Ajv2020;
|
|
25
28
|
|
|
26
|
-
export { dialect as contractDialect, sectionCatalogue, islandRegistry, contextContract };
|
|
29
|
+
export { dialect as contractDialect, sectionCatalogue, islandRegistry, contextContract, behaviourCatalogue };
|
|
30
|
+
|
|
31
|
+
// Templates are markup and attributes, NEVER code (docs/template-behaviours.md). These are hard
|
|
32
|
+
// errors over the RAW liquid source — even inside comments, because there is no legitimate reason
|
|
33
|
+
// for the tokens to appear at all. The handler pattern names real DOM event families rather than
|
|
34
|
+
// matching any on* word, so attributes like `once` or `online` never false-positive.
|
|
35
|
+
const FORBIDDEN_MARKUP = [
|
|
36
|
+
[/<script\b/i, 'a <script> tag'],
|
|
37
|
+
[/<(iframe|object|embed)\b/i, 'an embedded frame or plugin element'],
|
|
38
|
+
[/\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,
|
|
39
|
+
'an inline event handler'],
|
|
40
|
+
[/javascript\s*:/i, 'a javascript: URL'],
|
|
41
|
+
];
|
|
27
42
|
|
|
28
43
|
export async function validateArtifact(files) {
|
|
29
44
|
const errors = [];
|
|
@@ -41,6 +56,18 @@ export async function validateArtifact(files) {
|
|
|
41
56
|
} catch (e) {
|
|
42
57
|
return { errors: [`manifest.json unreadable: ${e.message}`], warnings, manifest: null };
|
|
43
58
|
}
|
|
59
|
+
// preview-content.json is a DEV-ONLY data override: package excludes it and the intake
|
|
60
|
+
// refuses it — an artifact must never carry data, only shape.
|
|
61
|
+
if (has('preview-content.json')) {
|
|
62
|
+
errors.push('preview-content.json: development preview data never ships in an artifact — remove it (package excludes it automatically)');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Content model v1: the footprint is decidable from the sources (closed dialect); unknown
|
|
66
|
+
// paths, dynamic indexing and tree aliasing are errors from site-context.mjs.
|
|
67
|
+
const contentAnalysis = extractContentFootprint(files);
|
|
68
|
+
errors.push(...contentAnalysis.errors);
|
|
69
|
+
const siteFx = buildSiteFixture(manifest);
|
|
70
|
+
|
|
44
71
|
const ajv = new Ajv({ allErrors: true });
|
|
45
72
|
const validateManifest = ajv.compile(manifestSchema);
|
|
46
73
|
if (!validateManifest(manifest)) {
|
|
@@ -49,6 +76,96 @@ export async function validateArtifact(files) {
|
|
|
49
76
|
}
|
|
50
77
|
}
|
|
51
78
|
|
|
79
|
+
// Capability declarations are catalogue MATCHING metadata, not an entitlement shortcut. Keep
|
|
80
|
+
// them honest by requiring one corresponding template-facing surface. The mapping is deliberately
|
|
81
|
+
// structural: it proves the design can present a capability without inspecting tenant data or
|
|
82
|
+
// crossing the platform-owned transaction, identity and consent boundaries.
|
|
83
|
+
{
|
|
84
|
+
const sections = new Set(manifest?.supports?.sections ?? []);
|
|
85
|
+
const islands = new Set(manifest?.supports?.islands ?? []);
|
|
86
|
+
const pages = new Set(manifest?.supports?.pageTemplates ?? []);
|
|
87
|
+
const hasAny = (values, expected) => expected.some((value) => values.has(value));
|
|
88
|
+
const capabilitySurface = {
|
|
89
|
+
giving: () => islands.has('donation_widget'),
|
|
90
|
+
appeals: () => hasAny(sections, ['appealGrid', 'emergency']) || islands.has('donation_widget'),
|
|
91
|
+
worship: () => manifest?.supports?.worship === true || islands.has('next_prayer'),
|
|
92
|
+
courses: () => pages.has('course') || islands.has('course_enrol'),
|
|
93
|
+
membership: () => islands.has('member_menu'),
|
|
94
|
+
events: () => sections.has('events') || pages.has('events') || islands.has('events_carousel'),
|
|
95
|
+
articles: () => sections.has('articles') || hasAny(pages, ['articles', 'article']) || islands.has('latest_articles'),
|
|
96
|
+
services: () => sections.has('programmes'),
|
|
97
|
+
volunteering: () => sections.has('volunteering') || islands.has('volunteer_signup'),
|
|
98
|
+
forms: () => islands.has('form'),
|
|
99
|
+
resources: () => sections.has('resources'),
|
|
100
|
+
locations: () => sections.has('locations'),
|
|
101
|
+
newsletter: () => islands.has('newsletter_signup'),
|
|
102
|
+
i18n: () => islands.has('language_switch'),
|
|
103
|
+
search: () => islands.has('search'),
|
|
104
|
+
media: () => sections.has('media')
|
|
105
|
+
};
|
|
106
|
+
for (const capability of manifest?.requiresCapabilities ?? []) {
|
|
107
|
+
if (!capabilitySurface[capability]?.()) {
|
|
108
|
+
errors.push(`manifest: requiresCapabilities '${capability}' has no corresponding declared section, page template or island`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Markup and attributes, NEVER code — the machine-enforced JavaScript ban over every liquid
|
|
114
|
+
// source (docs/template-behaviours.md). Behaviour is engine-owned; a template wanting motion
|
|
115
|
+
// declares supports.behaviors and uses the data-p60-* grammar.
|
|
116
|
+
for (const [path, source] of Object.entries(files)) {
|
|
117
|
+
if (!path.endsWith('.liquid')) continue;
|
|
118
|
+
for (const [pattern, what] of FORBIDDEN_MARKUP) {
|
|
119
|
+
if (pattern.test(source)) {
|
|
120
|
+
errors.push(`${path}: contains ${what} — templates are markup and attributes, never code (behaviour is engine-owned; see the behaviour catalogue)`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Behaviour declaration and usage must agree in BOTH directions. Source-level, deliberately:
|
|
126
|
+
// usage often sits inside content-dependent branches the fixtures never take, so the grammar's
|
|
127
|
+
// presence in the source is the honest minimal proof.
|
|
128
|
+
{
|
|
129
|
+
const declaredBehaviours = new Set(manifest?.supports?.behaviors ?? []);
|
|
130
|
+
const liquidSource = Object.entries(files)
|
|
131
|
+
.filter(([path]) => path.endsWith('.liquid'))
|
|
132
|
+
.map(([, source]) => source)
|
|
133
|
+
.join('\n');
|
|
134
|
+
const PRIMARY_ATTR = {
|
|
135
|
+
reveal: [/data-p60-reveal\b/, 'data-p60-reveal'],
|
|
136
|
+
counter: [/data-p60-count\b/, 'data-p60-count'],
|
|
137
|
+
progress: [/data-p60-progress\b/, 'data-p60-progress'],
|
|
138
|
+
countdown: [/data-p60-countdown\b/, 'data-p60-countdown'],
|
|
139
|
+
accordion: [/data-p60-accordion\b/, 'data-p60-accordion'],
|
|
140
|
+
carousel: [/data-p60-carousel\b/, 'data-p60-carousel'],
|
|
141
|
+
stickyHeader: [/data-p60-sticky-header\b/, 'data-p60-sticky-header'],
|
|
142
|
+
stickyCta: [/data-p60-sticky-cta\b/, 'data-p60-sticky-cta'],
|
|
143
|
+
lightbox: [/data-p60-lightbox\b/, 'data-p60-lightbox'],
|
|
144
|
+
tabs: [/data-p60-tabs\b/, 'data-p60-tabs'],
|
|
145
|
+
};
|
|
146
|
+
for (const name of declaredBehaviours) {
|
|
147
|
+
const primary = PRIMARY_ATTR[name];
|
|
148
|
+
if (primary && !primary[0].test(liquidSource)) {
|
|
149
|
+
errors.push(`manifest: supports.behaviors declares '${name}' but no ${primary[1]} attribute appears in any liquid source`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const [name, [pattern, attr]] of Object.entries(PRIMARY_ATTR)) {
|
|
153
|
+
if (pattern.test(liquidSource) && !declaredBehaviours.has(name)) {
|
|
154
|
+
errors.push(`behaviour '${name}': ${attr} appears in the markup but supports.behaviors does not declare it`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (/data-p60-carousel(?!-)\b/.test(liquidSource) && !/data-p60-slide\b/.test(liquidSource)) {
|
|
158
|
+
errors.push("behaviour 'carousel': a data-p60-carousel container needs data-p60-slide children");
|
|
159
|
+
}
|
|
160
|
+
if (/data-p60-lightbox(?!-)\b/.test(liquidSource) && !/data-p60-lightbox-item\b/.test(liquidSource)) {
|
|
161
|
+
errors.push("behaviour 'lightbox': a data-p60-lightbox group needs data-p60-lightbox-item anchors");
|
|
162
|
+
}
|
|
163
|
+
if (/data-p60-tabs\b/.test(liquidSource)
|
|
164
|
+
&& (!/data-p60-tab(?!s)\b/.test(liquidSource) || !/data-p60-panel\b/.test(liquidSource))) {
|
|
165
|
+
errors.push("behaviour 'tabs': a data-p60-tabs container needs data-p60-tab controls and data-p60-panel panels");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
52
169
|
// Font knobs: the default family must be a real catalogue entry (the tenant-unset render uses
|
|
53
170
|
// it), and the slot must declare the weights the template's typographic system needs.
|
|
54
171
|
{
|
|
@@ -108,10 +225,20 @@ export async function validateArtifact(files) {
|
|
|
108
225
|
// the data itself — in which case both directions are proven behaviourally (the worship
|
|
109
226
|
// pattern): the populated fixture's sentinel must appear, and the EMPTY context must render it
|
|
110
227
|
// away (derive or omit — nothing invented, nothing dangling).
|
|
228
|
+
// Sentinels are DERIVED from the canonical fixtures (the first item's display field), never
|
|
229
|
+
// hardcoded — the fixture data is free to become richer without touching a proof, and a proof
|
|
230
|
+
// can never drift from the data it renders.
|
|
231
|
+
const sentinelOf = (type, dataKey, field) =>
|
|
232
|
+
(contextContract.fixtures.sections?.[type]?.[dataKey] ?? [])[0]?.[field] ?? null;
|
|
111
233
|
const WIDGET_SECTIONS = {
|
|
112
|
-
events: { island: 'events_carousel', dataKey: 'events', sentinel: '
|
|
113
|
-
whatsOn: { island: 'whats_on_strip', dataKey: 'infoEvents', sentinel: '
|
|
114
|
-
articles: { island: 'latest_articles', dataKey: 'latestArticles', sentinel: '
|
|
234
|
+
events: { island: 'events_carousel', dataKey: 'events', sentinel: sentinelOf('events', 'events', 'name') },
|
|
235
|
+
whatsOn: { island: 'whats_on_strip', dataKey: 'infoEvents', sentinel: sentinelOf('whatsOn', 'infoEvents', 'name') },
|
|
236
|
+
articles: { island: 'latest_articles', dataKey: 'latestArticles', sentinel: sentinelOf('articles', 'latestArticles', 'title') },
|
|
237
|
+
campaigns: { island: null, dataKey: 'campaigns', sentinel: sentinelOf('campaigns', 'campaigns', 'title') },
|
|
238
|
+
resources: { island: null, dataKey: 'resources', sentinel: sentinelOf('resources', 'resources', 'title') },
|
|
239
|
+
locations: { island: null, dataKey: 'locations', sentinel: sentinelOf('locations', 'locations', 'name') },
|
|
240
|
+
volunteering: { island: 'volunteer_signup', dataKey: 'opportunities', sentinel: sentinelOf('volunteering', 'opportunities', 'title') },
|
|
241
|
+
media: { island: null, dataKey: 'media', sentinel: sentinelOf('media', 'media', 'title') }
|
|
115
242
|
};
|
|
116
243
|
|
|
117
244
|
// 2–4. Sections: catalogue membership, parse, fixture renders.
|
|
@@ -142,21 +269,26 @@ export async function validateArtifact(files) {
|
|
|
142
269
|
const populated = await liquid.render(parsed, {
|
|
143
270
|
section: {},
|
|
144
271
|
brand: contextContract.fixtures.brand,
|
|
272
|
+
site: siteFx,
|
|
145
273
|
...dataFixture
|
|
146
274
|
});
|
|
147
|
-
const placesIsland =
|
|
275
|
+
const placesIsland = widget.island !== null
|
|
276
|
+
&& splitIslandParts(populated).some((p) => p.island === widget.island);
|
|
148
277
|
if (!placesIsland) {
|
|
149
|
-
if (!populated.includes(widget.sentinel)) {
|
|
278
|
+
if (widget.sentinel && !populated.includes(widget.sentinel)) {
|
|
150
279
|
errors.push(
|
|
151
|
-
|
|
280
|
+
widget.island
|
|
281
|
+
? `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`
|
|
282
|
+
: `section '${type}': does not render the ${widget.dataKey} context — the fixture's "${widget.sentinel}" must appear`
|
|
152
283
|
);
|
|
153
284
|
}
|
|
154
285
|
const empty = await liquid.render(parsed, {
|
|
155
286
|
section: {},
|
|
156
287
|
brand: contextContract.fixtures.brand,
|
|
288
|
+
site: { ...siteFx, content: Object.fromEntries(Object.keys(siteFx.content).map((k) => [k, []])) },
|
|
157
289
|
[widget.dataKey]: []
|
|
158
290
|
});
|
|
159
|
-
if (empty.includes(widget.sentinel)) {
|
|
291
|
+
if (widget.sentinel && empty.includes(widget.sentinel)) {
|
|
160
292
|
errors.push(`section '${type}': still shows fixture content with an empty ${widget.dataKey} — content must come from the context`);
|
|
161
293
|
}
|
|
162
294
|
if (/\bundefined\b|\bnull\b/.test(empty.replace(/data-[a-z-]+="[^"]*"/g, ''))) {
|
|
@@ -173,19 +305,23 @@ export async function validateArtifact(files) {
|
|
|
173
305
|
const html = await liquid.render(parsed, {
|
|
174
306
|
section: fixture,
|
|
175
307
|
brand: contextContract.fixtures.brand,
|
|
308
|
+
site: siteFx,
|
|
176
309
|
// Widget data rides the ordinary fixture renders too, so a hand-rendering section
|
|
177
310
|
// doesn't fail the generic pass for want of its context.
|
|
178
311
|
...(widget ? (contextContract.fixtures.sections?.[type] ?? {}) : {})
|
|
179
312
|
});
|
|
180
313
|
if (type === 'homeHero' && fixtureName === 'sample') {
|
|
181
|
-
|
|
314
|
+
// The sample's own first image reference is the probe: `p60fixture:` refs are quote-free
|
|
315
|
+
// and survive HTML escaping, so "does the hero display the photos?" stays a substring check.
|
|
316
|
+
const probe = (fixture.images ?? [])[0]?.imageUrl ?? 'p60fixture:';
|
|
182
317
|
homeHeroMultiShows = html.includes(probe)
|
|
183
318
|
|| splitIslandParts(html).some((p) => p.island === 'hero_carousel');
|
|
184
319
|
// The single-photo path proven separately: same fixture, first photo only.
|
|
185
320
|
try {
|
|
186
321
|
const single = await liquid.render(parsed, {
|
|
187
322
|
section: { ...fixture, images: (fixture.images ?? []).slice(0, 1) },
|
|
188
|
-
brand: contextContract.fixtures.brand
|
|
323
|
+
brand: contextContract.fixtures.brand,
|
|
324
|
+
site: siteFx
|
|
189
325
|
});
|
|
190
326
|
homeHeroSingleShows = single.includes(probe);
|
|
191
327
|
} catch {
|
|
@@ -247,10 +383,12 @@ export async function validateArtifact(files) {
|
|
|
247
383
|
if (parsedLayout) {
|
|
248
384
|
try {
|
|
249
385
|
const html = await liquid.render(parsedLayout, {
|
|
386
|
+
site: siteFx,
|
|
250
387
|
brand: contextContract.fixtures.brand,
|
|
251
388
|
nav: contextContract.fixtures.layout.nav,
|
|
252
389
|
socials: contextContract.fixtures.layout.socials ?? [],
|
|
253
|
-
worship: contextContract.fixtures.layout.worship ?? null
|
|
390
|
+
worship: contextContract.fixtures.layout.worship ?? null,
|
|
391
|
+
locale: contextContract.fixtures.layout.locale
|
|
254
392
|
});
|
|
255
393
|
let contentSlots = 0;
|
|
256
394
|
const layoutIslands = [];
|
|
@@ -281,10 +419,13 @@ export async function validateArtifact(files) {
|
|
|
281
419
|
}
|
|
282
420
|
if (manifest?.supports?.worship) {
|
|
283
421
|
const nullHtml = await liquid.render(parsedLayout, {
|
|
422
|
+
site: siteFx,
|
|
284
423
|
brand: contextContract.fixtures.brand,
|
|
285
424
|
nav: contextContract.fixtures.layout.nav,
|
|
286
425
|
socials: contextContract.fixtures.layout.socials ?? [],
|
|
287
|
-
worship: null
|
|
426
|
+
worship: null,
|
|
427
|
+
site: { ...siteFx, content: { ...siteFx.content, schedules: [] } },
|
|
428
|
+
locale: contextContract.fixtures.layout.locale
|
|
288
429
|
});
|
|
289
430
|
if (nullHtml.includes(worshipProbe)) {
|
|
290
431
|
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)');
|
|
@@ -320,7 +461,7 @@ export async function validateArtifact(files) {
|
|
|
320
461
|
continue;
|
|
321
462
|
}
|
|
322
463
|
try {
|
|
323
|
-
const html = await liquid.render(parsedPage, { ...fixture, brand: contextContract.fixtures.brand });
|
|
464
|
+
const html = await liquid.render(parsedPage, { ...fixture, site: siteFx, brand: contextContract.fixtures.brand });
|
|
324
465
|
for (const part of splitIslandParts(html)) {
|
|
325
466
|
if (part.island === CONTENT_SLOT) {
|
|
326
467
|
errors.push(`page template '${pageName}': uses {% content %} — that tag is layout-only`);
|
|
@@ -352,5 +493,64 @@ export async function validateArtifact(files) {
|
|
|
352
493
|
errors.push('assets/theme.css missing or empty — a template must ship its look');
|
|
353
494
|
}
|
|
354
495
|
|
|
355
|
-
|
|
496
|
+
// 6b. Layout contract (contract/v1/layout.json). Platform pages render through the content SEAM
|
|
497
|
+
// (.container / .full); a template STYLES those to place content, never a parallel content container.
|
|
498
|
+
// The rule is DATA — the seam token, the allowed selectors and the message all come from the contract
|
|
499
|
+
// file; this only implements the check KIND (a non-seam selector sizing its width off the token).
|
|
500
|
+
{
|
|
501
|
+
const css = has('assets/theme.css') ? read('assets/theme.css') : '';
|
|
502
|
+
const stripped = css.replace(/\/\*[\s\S]*?\*\//g, ' '); // drop comments so an example can't trip it
|
|
503
|
+
const RULE = /([^{}]+)\{([^{}]*)\}/g;
|
|
504
|
+
for (const rule of layoutContract.rules ?? []) {
|
|
505
|
+
if (rule.kind !== 'css-width-off-token') continue;
|
|
506
|
+
const token = rule.token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
507
|
+
const sizesOffToken = new RegExp(`\\b(?:min-|max-)?(?:width|inline-size)\\s*:[^;]*var\\(\\s*${token}\\b`);
|
|
508
|
+
const isSeam = new RegExp(`\\.(?:${rule.allow.join('|')})(?![\\w-])`);
|
|
509
|
+
const offenders = new Set();
|
|
510
|
+
let m;
|
|
511
|
+
RULE.lastIndex = 0;
|
|
512
|
+
while ((m = RULE.exec(stripped)) !== null) {
|
|
513
|
+
if (!sizesOffToken.test(m[2])) continue; // only rules that size a box off the token
|
|
514
|
+
for (const sel of m[1].split(',')) {
|
|
515
|
+
const s = sel.trim();
|
|
516
|
+
if (s && !isSeam.test(s)) offenders.add(s);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
for (const sel of [...offenders].sort()) {
|
|
520
|
+
errors.push(`theme: selector '${sel}' ${rule.message}`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Open enums, proven survivable (content model v1 discipline 2): a template whose footprint
|
|
526
|
+
// reads site.content.events must survive a registration mode it has never heard of — new modes
|
|
527
|
+
// WILL arrive within the major. No throw, and no undefined/null literal leaking into markup.
|
|
528
|
+
if (contentAnalysis.footprint.includes('content.events')) {
|
|
529
|
+
const futureEvent = {
|
|
530
|
+
...(siteFx.content.events[0] ?? {}),
|
|
531
|
+
id: 'ffffffff-ffff-4fff-8fff-ffffffffffff',
|
|
532
|
+
name: 'Open Enum Probe Event',
|
|
533
|
+
registrationMode: 'X_FUTURE_MODE',
|
|
534
|
+
detailHref: '/events?event=ffffffff-ffff-4fff-8fff-ffffffffffff'
|
|
535
|
+
};
|
|
536
|
+
const doctored = { ...siteFx, content: { ...siteFx.content, events: [...siteFx.content.events, futureEvent] } };
|
|
537
|
+
for (const type of manifest?.supports?.sections ?? []) {
|
|
538
|
+
const file = `sections/${type}.liquid`;
|
|
539
|
+
if (!has(file) || !/\bsite\s*[.[]/.test(read(file))) continue;
|
|
540
|
+
try {
|
|
541
|
+
const html = await liquid.render(liquid.parse(read(file)), {
|
|
542
|
+
section: catalogueByType.get(type)?.sample ?? {},
|
|
543
|
+
brand: contextContract.fixtures.brand,
|
|
544
|
+
site: doctored
|
|
545
|
+
});
|
|
546
|
+
if (/\bundefined\b|\bnull\b/.test(html.replace(/data-[a-z-]+="[^"]*"/g, ''))) {
|
|
547
|
+
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`);
|
|
548
|
+
}
|
|
549
|
+
} catch (e) {
|
|
550
|
+
errors.push(`section '${type}': failed rendering an unknown event registrationMode — the enum is OPEN and new modes will arrive (${e.message})`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return { errors, warnings, manifest, contentFootprint: contentAnalysis.footprint, minContentVersion: contentAnalysis.minModelVersion };
|
|
356
556
|
}
|
package/starter/assets/theme.css
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* starter theme
|
|
1
|
+
/* starter theme, a deliberately distinct look (midnight/emerald) proving the artifact owns the skin */
|
|
2
2
|
:root:root {
|
|
3
3
|
--bg: #0e1614;
|
|
4
4
|
--bg-tint: #14201d;
|
|
@@ -39,13 +39,13 @@ strong { color: var(--ink); }
|
|
|
39
39
|
.lq-cta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 2.5rem; align-items: start; }
|
|
40
40
|
@media (max-width: 900px) { .lq-cta-grid { grid-template-columns: 1fr; } }
|
|
41
41
|
|
|
42
|
-
/* The one font slot
|
|
42
|
+
/* The one font slot, headings above already follow it; body text too. */
|
|
43
43
|
body { font-family: ' IBM Plex Sans Arabic', var(--p60s-siteFont, 'Inter'), ui-sans-serif, system-ui, sans-serif; }
|
|
44
44
|
|
|
45
|
-
/* ── Home hero (supports.heroImagery
|
|
45
|
+
/* ── Home hero (supports.heroImagery, the reference treatment) ─────────────────────────────
|
|
46
46
|
The no-photo state is a DESIGNED gradient (rule: derive or omit, never a placeholder image).
|
|
47
47
|
Photos get a scrim built from the palette itself via color-mix, so uploads tone into the
|
|
48
|
-
template
|
|
48
|
+
template, and any palette change re-tones them for free. Never place a hero photo raw. */
|
|
49
49
|
.lq-homehero {
|
|
50
50
|
position: relative;
|
|
51
51
|
padding: 4.5rem 0;
|
|
@@ -73,7 +73,7 @@ body { font-family: ' IBM Plex Sans Arabic', var(--p60s-siteFont, 'Inter'), ui-s
|
|
|
73
73
|
.lq-homehero-lead { color: var(--muted); max-width: 46ch; margin: 0.9rem 0 1.4rem; }
|
|
74
74
|
.lq-homehero-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; }
|
|
75
75
|
|
|
76
|
-
/* The platform hero_carousel island, styled through its class API
|
|
76
|
+
/* The platform hero_carousel island, styled through its class API, the island owns motion
|
|
77
77
|
(auto-advance, swipe, reduced-motion), this owns the look. Each slide's .hero-slide-scrim is
|
|
78
78
|
the same palette treatment as a single photo. */
|
|
79
79
|
.lq-homehero--carousel { overflow: hidden; }
|
|
@@ -114,7 +114,7 @@ body { font-family: ' IBM Plex Sans Arabic', var(--p60s-siteFont, 'Inter'), ui-s
|
|
|
114
114
|
}
|
|
115
115
|
.lq-homehero--carousel .hero-dot--active { background: var(--accent); }
|
|
116
116
|
|
|
117
|
-
/* ── Vista framing (photoFraming 'whole')
|
|
117
|
+
/* ── Vista framing (photoFraming 'whole'), the hero adapts to the PHOTO's shape ───────────── */
|
|
118
118
|
.lq-homehero--vista { padding-top: 0; }
|
|
119
119
|
.lq-vista { position: relative; overflow: hidden; }
|
|
120
120
|
.lq-vista-sizer { display: block; width: 100%; height: auto; max-height: 74vh; object-fit: cover; }
|
|
@@ -153,7 +153,7 @@ body { font-family: ' IBM Plex Sans Arabic', var(--p60s-siteFont, 'Inter'), ui-s
|
|
|
153
153
|
.lq-vista-sizer { min-height: 160px; }
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
/* Vista + form (1.5.2): the widget rides the seam
|
|
156
|
+
/* Vista + form (1.5.2): the widget rides the seam, grid pulled up over the band (under the
|
|
157
157
|
280px floor), gradient filling where the image ends; narrow screens stack back underneath. */
|
|
158
158
|
.lq-homehero--vista-form { padding-bottom: 2.6rem; }
|
|
159
159
|
.lq-vista-overlap { position: relative; z-index: 1; margin-top: -240px; align-items: start; }
|
|
@@ -161,7 +161,7 @@ body { font-family: ' IBM Plex Sans Arabic', var(--p60s-siteFont, 'Inter'), ui-s
|
|
|
161
161
|
.lq-vista-overlap { margin-top: 0; padding-top: 1.6rem; }
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
/* People / team cards
|
|
164
|
+
/* People / team cards, grid to single column on phones. */
|
|
165
165
|
.lq-people {
|
|
166
166
|
display: grid;
|
|
167
167
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
@@ -187,3 +187,49 @@ body { font-family: ' IBM Plex Sans Arabic', var(--p60s-siteFont, 'Inter'), ui-s
|
|
|
187
187
|
@media (max-width: 640px) {
|
|
188
188
|
.lq-people { grid-template-columns: 1fr; }
|
|
189
189
|
}
|
|
190
|
+
|
|
191
|
+
/* Campaigns, the same quiet rail as values: title, summary, one action. */
|
|
192
|
+
.lq-campaigns { padding: 3.5rem 0; }
|
|
193
|
+
.lq-campaign-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 1.6rem; max-width: 700px; }
|
|
194
|
+
.lq-campaign h3 { margin: 0 0 0.25rem; }
|
|
195
|
+
.lq-campaign p { margin: 0 0 0.6rem; }
|
|
196
|
+
|
|
197
|
+
/* Behaviours (engine-wired via data-p60-*, docs: Motion and behaviour guide). Everything that
|
|
198
|
+
hides pre-behaviour content is .p60-js-scoped: the no-JS render stays complete. */
|
|
199
|
+
|
|
200
|
+
/* Reveal: the values rail rises in with a stagger. */
|
|
201
|
+
.p60-js .lq-value-rail li {
|
|
202
|
+
opacity: 0;
|
|
203
|
+
transform: translateY(0.6rem);
|
|
204
|
+
transition: opacity 480ms ease, transform 480ms ease;
|
|
205
|
+
transition-delay: calc(var(--p60-reveal-index, 0) * 90ms);
|
|
206
|
+
}
|
|
207
|
+
.p60-js .lq-value-rail.is-revealed li { opacity: 1; transform: none; }
|
|
208
|
+
@media (prefers-reduced-motion: reduce) {
|
|
209
|
+
.p60-js .lq-value-rail li { opacity: 1; transform: none; transition: none; }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* Behaviour carousel: the standard hero's photo stack (the vista framings use the island). */
|
|
213
|
+
.lq-hero-slides { position: absolute; inset: 0; overflow: hidden; }
|
|
214
|
+
.lq-hero-slide { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
|
215
|
+
.p60-js .lq-hero-slide { opacity: 0; transition: opacity 700ms ease; }
|
|
216
|
+
.p60-js .lq-hero-slide.is-active { opacity: 1; }
|
|
217
|
+
.lq-hero-slides .p60-carousel-dots {
|
|
218
|
+
position: absolute;
|
|
219
|
+
inset-inline: 0;
|
|
220
|
+
bottom: 0.9rem;
|
|
221
|
+
display: flex;
|
|
222
|
+
justify-content: center;
|
|
223
|
+
gap: 0.45rem;
|
|
224
|
+
z-index: 2;
|
|
225
|
+
}
|
|
226
|
+
.lq-hero-slides .p60-carousel-dot {
|
|
227
|
+
width: 0.55rem;
|
|
228
|
+
height: 0.55rem;
|
|
229
|
+
border-radius: 50%;
|
|
230
|
+
border: 1px solid rgba(255, 255, 255, 0.9);
|
|
231
|
+
background: transparent;
|
|
232
|
+
padding: 0;
|
|
233
|
+
cursor: pointer;
|
|
234
|
+
}
|
|
235
|
+
.lq-hero-slides .p60-carousel-dot--active { background: rgba(255, 255, 255, 0.95); }
|
package/starter/layout.liquid
CHANGED
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
{% comment %}
|
|
2
|
-
The starter's chrome
|
|
2
|
+
The starter's chrome, header/nav/footer around the {% content %} slot. It reuses the platform
|
|
3
3
|
chrome classes (.site-header/.site-nav/…) so the base mechanics (sticky header, burger
|
|
4
4
|
breakpoint, dropdown hover) apply, and emits the documented behaviour attributes
|
|
5
5
|
(data-nav-burger, #site-nav) so the platform tail script wires the mobile burger. Identity is
|
|
6
|
-
PLACED, never rebuilt: {% island 'member_menu' %}. The footer derives everything from brand/nav
|
|
7
|
-
no fabricated copy (derive or omit).
|
|
6
|
+
PLACED, never rebuilt: {% island 'member_menu' %}. The footer derives everything from brand/nav, no fabricated copy (derive or omit).
|
|
8
7
|
{% endcomment %}
|
|
9
8
|
<header class="site-header">
|
|
10
9
|
<div class="container header-inner">
|
|
11
10
|
<a class="brand-lockup" href="/">
|
|
12
|
-
{% if brand.logoUrl and brand.logoType != 'icon' %}
|
|
13
|
-
<img class="brand-logo" src="{{ brand.logoUrl }}" alt="{{ brand.name }}">
|
|
14
|
-
{% elsif brand.logoUrl %}
|
|
15
|
-
<img class="brand-logo-icon" src="{{ brand.logoUrl }}" alt="">
|
|
16
|
-
<strong>{{ brand.name }}</strong>
|
|
11
|
+
{% if site.brand.logoUrl and site.brand.logoType != 'icon' %}
|
|
12
|
+
<img class="brand-logo" src="{{ site.brand.logoUrl }}" alt="{{ site.brand.name }}">
|
|
13
|
+
{% elsif site.brand.logoUrl %}
|
|
14
|
+
<img class="brand-logo-icon" src="{{ site.brand.logoUrl }}" alt="">
|
|
15
|
+
<strong>{{ site.brand.name }}</strong>
|
|
17
16
|
{% else %}
|
|
18
17
|
<span class="brand-mark" aria-hidden="true">♥</span>
|
|
19
|
-
<strong>{{ brand.name }}</strong>
|
|
18
|
+
<strong>{{ site.brand.name }}</strong>
|
|
20
19
|
{% endif %}
|
|
21
20
|
</a>
|
|
22
21
|
|
|
@@ -27,7 +26,7 @@
|
|
|
27
26
|
</button>
|
|
28
27
|
|
|
29
28
|
<nav class="site-nav" id="site-nav" aria-label="Primary">
|
|
30
|
-
{% for item in nav.items %}
|
|
29
|
+
{% for item in site.nav.items %}
|
|
31
30
|
{% if item.children %}
|
|
32
31
|
<span class="nav-dropdown">
|
|
33
32
|
<a href="{{ item.href }}" class="nav-dropdown-toggle" aria-haspopup="true">{{ item.label }} ▾</a>
|
|
@@ -55,20 +54,20 @@
|
|
|
55
54
|
<footer class="site-footer">
|
|
56
55
|
<div class="container footer-grid">
|
|
57
56
|
<div>
|
|
58
|
-
<p class="lq-kicker">{{ brand.name }}</p>
|
|
59
|
-
{% if brand.tagline %}<h2>{{ brand.tagline }}</h2>{% endif %}
|
|
57
|
+
<p class="lq-kicker">{{ site.brand.name }}</p>
|
|
58
|
+
{% if site.brand.tagline %}<h2>{{ site.brand.tagline }}</h2>{% endif %}
|
|
60
59
|
</div>
|
|
61
60
|
<div class="footer-links">
|
|
62
|
-
{% comment %} A tenant-curated footer menu when they've built one, else the main nav. {% endcomment %}
|
|
63
|
-
{% if nav.footer.size > 0 %}
|
|
64
|
-
{% for item in nav.footer %}
|
|
61
|
+
{% comment %} A tenant-curated footer menu when they've built one, else the main site.nav. {% endcomment %}
|
|
62
|
+
{% if site.nav.footer.size > 0 %}
|
|
63
|
+
{% for item in site.nav.footer %}
|
|
65
64
|
<a href="{{ item.href }}"{% if item.external %} target="_blank" rel="noopener"{% endif %}>{{ item.label }}</a>
|
|
66
65
|
{% for child in item.children %}
|
|
67
66
|
<a href="{{ child.href }}"{% if child.external %} target="_blank" rel="noopener"{% endif %}>{{ child.label }}</a>
|
|
68
67
|
{% endfor %}
|
|
69
68
|
{% endfor %}
|
|
70
69
|
{% else %}
|
|
71
|
-
{% for item in nav.items %}
|
|
70
|
+
{% for item in site.nav.items %}
|
|
72
71
|
<a href="{{ item.href }}">{{ item.label }}</a>
|
|
73
72
|
{% endfor %}
|
|
74
73
|
{% endif %}
|
package/starter/manifest.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "starter",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"format": "port60-liquid@1",
|
|
5
5
|
"label": "Starter",
|
|
6
|
-
"description": "The reference template for the Port60 dialect
|
|
6
|
+
"description": "The reference template for the Port60 dialect, the developer docs' worked example and the base to copy when building your own. 1.1.0 adds the layout slot: the template owns the header, navigation and footer chrome.",
|
|
7
7
|
"supports": {
|
|
8
8
|
"pages": [
|
|
9
9
|
"home",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"hero",
|
|
15
15
|
"values",
|
|
16
16
|
"cta",
|
|
17
|
-
"people"
|
|
17
|
+
"people",
|
|
18
|
+
"campaigns"
|
|
18
19
|
],
|
|
19
20
|
"islands": [
|
|
20
21
|
"donation_widget",
|
|
@@ -22,7 +23,11 @@
|
|
|
22
23
|
"hero_carousel"
|
|
23
24
|
],
|
|
24
25
|
"layout": true,
|
|
25
|
-
"heroImagery": true
|
|
26
|
+
"heroImagery": true,
|
|
27
|
+
"behaviors": [
|
|
28
|
+
"reveal",
|
|
29
|
+
"carousel"
|
|
30
|
+
]
|
|
26
31
|
},
|
|
27
32
|
"settings": {
|
|
28
33
|
"schema": [
|
|
@@ -41,7 +46,7 @@
|
|
|
41
46
|
}
|
|
42
47
|
]
|
|
43
48
|
},
|
|
44
|
-
"changelog": "
|
|
49
|
+
"changelog": "Demonstrates the behaviour catalogue: the values rail reveals with a stagger and a multi-photo standard hero becomes an engine-wired carousel.",
|
|
45
50
|
"imagery": {
|
|
46
51
|
"hero": {
|
|
47
52
|
"idealAspect": "16:9",
|