@port60/template-kit 0.9.0 → 0.11.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 +6 -6
- package/src/commands/dev.mjs +97 -9
- 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 +52 -24
- package/src/vendor/contract/v1/behaviours.json +4 -4
- package/src/vendor/contract/v1/content-bounds.json +3 -3
- package/src/vendor/contract/v1/content-model.json +165 -0
- package/src/vendor/contract/v1/context.json +1497 -289
- 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 +242 -235
- package/src/vendor/contract/v1/layout.json +4 -4
- package/src/vendor/contract/v1/manifest.schema.json +10 -12
- package/src/vendor/contract/v1/sections.json +189 -107
- 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/fixture-art.mjs +107 -0
- package/src/vendor/validator/model-reference.mjs +92 -0
- package/src/vendor/validator/platform-base.css +21 -0
- package/src/vendor/validator/preview.mjs +144 -59
- package/src/vendor/validator/site-context.mjs +97 -0
- package/src/vendor/validator/validate.mjs +70 -17
- package/starter/assets/theme.css +10 -10
- package/starter/layout.liquid +15 -16
- package/starter/manifest.json +2 -2
- package/starter/sections/campaigns.liquid +5 -5
- package/starter/sections/homeHero.liquid +11 -11
- package/starter/sections/people.liquid +1 -1
- package/starter/sections/values.liquid +1 -1
|
@@ -22,6 +22,7 @@ import contextContract from '../contract/v1/context.json' with { type: 'json' };
|
|
|
22
22
|
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
|
+
import { buildSiteFixture, extractContentFootprint, contentModel } from './site-context.mjs';
|
|
25
26
|
|
|
26
27
|
const Ajv = Ajv2020.default ?? Ajv2020;
|
|
27
28
|
|
|
@@ -55,6 +56,18 @@ export async function validateArtifact(files) {
|
|
|
55
56
|
} catch (e) {
|
|
56
57
|
return { errors: [`manifest.json unreadable: ${e.message}`], warnings, manifest: null };
|
|
57
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
|
+
|
|
58
71
|
const ajv = new Ajv({ allErrors: true });
|
|
59
72
|
const validateManifest = ajv.compile(manifestSchema);
|
|
60
73
|
if (!validateManifest(manifest)) {
|
|
@@ -81,14 +94,12 @@ export async function validateArtifact(files) {
|
|
|
81
94
|
events: () => sections.has('events') || pages.has('events') || islands.has('events_carousel'),
|
|
82
95
|
articles: () => sections.has('articles') || hasAny(pages, ['articles', 'article']) || islands.has('latest_articles'),
|
|
83
96
|
services: () => sections.has('programmes'),
|
|
84
|
-
volunteering: () => sections.has('volunteering') || islands.has('volunteer_signup'),
|
|
85
97
|
forms: () => islands.has('form'),
|
|
86
98
|
resources: () => sections.has('resources'),
|
|
87
99
|
locations: () => sections.has('locations'),
|
|
88
100
|
newsletter: () => islands.has('newsletter_signup'),
|
|
89
101
|
i18n: () => islands.has('language_switch'),
|
|
90
|
-
search: () => islands.has('search')
|
|
91
|
-
media: () => sections.has('media')
|
|
102
|
+
search: () => islands.has('search')
|
|
92
103
|
};
|
|
93
104
|
for (const capability of manifest?.requiresCapabilities ?? []) {
|
|
94
105
|
if (!capabilitySurface[capability]?.()) {
|
|
@@ -212,15 +223,18 @@ export async function validateArtifact(files) {
|
|
|
212
223
|
// the data itself — in which case both directions are proven behaviourally (the worship
|
|
213
224
|
// pattern): the populated fixture's sentinel must appear, and the EMPTY context must render it
|
|
214
225
|
// away (derive or omit — nothing invented, nothing dangling).
|
|
226
|
+
// Sentinels are DERIVED from the canonical fixtures (the first item's display field), never
|
|
227
|
+
// hardcoded — the fixture data is free to become richer without touching a proof, and a proof
|
|
228
|
+
// can never drift from the data it renders.
|
|
229
|
+
const sentinelOf = (type, dataKey, field) =>
|
|
230
|
+
(contextContract.fixtures.sections?.[type]?.[dataKey] ?? [])[0]?.[field] ?? null;
|
|
215
231
|
const WIDGET_SECTIONS = {
|
|
216
|
-
events: { island: 'events_carousel', dataKey: 'events', sentinel: '
|
|
217
|
-
whatsOn: { island: 'whats_on_strip', dataKey: 'infoEvents', sentinel: '
|
|
218
|
-
articles: { island: 'latest_articles', dataKey: 'latestArticles', sentinel: '
|
|
219
|
-
campaigns: { island: null, dataKey: 'campaigns', sentinel: '
|
|
220
|
-
resources: { island: null, dataKey: 'resources', sentinel: '
|
|
221
|
-
locations: { island: null, dataKey: 'locations', sentinel: '
|
|
222
|
-
volunteering: { island: 'volunteer_signup', dataKey: 'opportunities', sentinel: 'Fixture Garden Volunteer' },
|
|
223
|
-
media: { island: null, dataKey: 'media', sentinel: 'Fixture Community Talk' }
|
|
232
|
+
events: { island: 'events_carousel', dataKey: 'events', sentinel: sentinelOf('events', 'events', 'name') },
|
|
233
|
+
whatsOn: { island: 'whats_on_strip', dataKey: 'infoEvents', sentinel: sentinelOf('whatsOn', 'infoEvents', 'name') },
|
|
234
|
+
articles: { island: 'latest_articles', dataKey: 'latestArticles', sentinel: sentinelOf('articles', 'latestArticles', 'title') },
|
|
235
|
+
campaigns: { island: null, dataKey: 'campaigns', sentinel: sentinelOf('campaigns', 'campaigns', 'title') },
|
|
236
|
+
resources: { island: null, dataKey: 'resources', sentinel: sentinelOf('resources', 'resources', 'title') },
|
|
237
|
+
locations: { island: null, dataKey: 'locations', sentinel: sentinelOf('locations', 'locations', 'name') }
|
|
224
238
|
};
|
|
225
239
|
|
|
226
240
|
// 2–4. Sections: catalogue membership, parse, fixture renders.
|
|
@@ -251,12 +265,13 @@ export async function validateArtifact(files) {
|
|
|
251
265
|
const populated = await liquid.render(parsed, {
|
|
252
266
|
section: {},
|
|
253
267
|
brand: contextContract.fixtures.brand,
|
|
268
|
+
site: siteFx,
|
|
254
269
|
...dataFixture
|
|
255
270
|
});
|
|
256
271
|
const placesIsland = widget.island !== null
|
|
257
272
|
&& splitIslandParts(populated).some((p) => p.island === widget.island);
|
|
258
273
|
if (!placesIsland) {
|
|
259
|
-
if (!populated.includes(widget.sentinel)) {
|
|
274
|
+
if (widget.sentinel && !populated.includes(widget.sentinel)) {
|
|
260
275
|
errors.push(
|
|
261
276
|
widget.island
|
|
262
277
|
? `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`
|
|
@@ -266,9 +281,10 @@ export async function validateArtifact(files) {
|
|
|
266
281
|
const empty = await liquid.render(parsed, {
|
|
267
282
|
section: {},
|
|
268
283
|
brand: contextContract.fixtures.brand,
|
|
284
|
+
site: { ...siteFx, content: Object.fromEntries(Object.keys(siteFx.content).map((k) => [k, []])) },
|
|
269
285
|
[widget.dataKey]: []
|
|
270
286
|
});
|
|
271
|
-
if (empty.includes(widget.sentinel)) {
|
|
287
|
+
if (widget.sentinel && empty.includes(widget.sentinel)) {
|
|
272
288
|
errors.push(`section '${type}': still shows fixture content with an empty ${widget.dataKey} — content must come from the context`);
|
|
273
289
|
}
|
|
274
290
|
if (/\bundefined\b|\bnull\b/.test(empty.replace(/data-[a-z-]+="[^"]*"/g, ''))) {
|
|
@@ -285,19 +301,23 @@ export async function validateArtifact(files) {
|
|
|
285
301
|
const html = await liquid.render(parsed, {
|
|
286
302
|
section: fixture,
|
|
287
303
|
brand: contextContract.fixtures.brand,
|
|
304
|
+
site: siteFx,
|
|
288
305
|
// Widget data rides the ordinary fixture renders too, so a hand-rendering section
|
|
289
306
|
// doesn't fail the generic pass for want of its context.
|
|
290
307
|
...(widget ? (contextContract.fixtures.sections?.[type] ?? {}) : {})
|
|
291
308
|
});
|
|
292
309
|
if (type === 'homeHero' && fixtureName === 'sample') {
|
|
293
|
-
|
|
310
|
+
// The sample's own first image reference is the probe: `p60fixture:` refs are quote-free
|
|
311
|
+
// and survive HTML escaping, so "does the hero display the photos?" stays a substring check.
|
|
312
|
+
const probe = (fixture.images ?? [])[0]?.imageUrl ?? 'p60fixture:';
|
|
294
313
|
homeHeroMultiShows = html.includes(probe)
|
|
295
314
|
|| splitIslandParts(html).some((p) => p.island === 'hero_carousel');
|
|
296
315
|
// The single-photo path proven separately: same fixture, first photo only.
|
|
297
316
|
try {
|
|
298
317
|
const single = await liquid.render(parsed, {
|
|
299
318
|
section: { ...fixture, images: (fixture.images ?? []).slice(0, 1) },
|
|
300
|
-
brand: contextContract.fixtures.brand
|
|
319
|
+
brand: contextContract.fixtures.brand,
|
|
320
|
+
site: siteFx
|
|
301
321
|
});
|
|
302
322
|
homeHeroSingleShows = single.includes(probe);
|
|
303
323
|
} catch {
|
|
@@ -359,6 +379,7 @@ export async function validateArtifact(files) {
|
|
|
359
379
|
if (parsedLayout) {
|
|
360
380
|
try {
|
|
361
381
|
const html = await liquid.render(parsedLayout, {
|
|
382
|
+
site: siteFx,
|
|
362
383
|
brand: contextContract.fixtures.brand,
|
|
363
384
|
nav: contextContract.fixtures.layout.nav,
|
|
364
385
|
socials: contextContract.fixtures.layout.socials ?? [],
|
|
@@ -394,10 +415,12 @@ export async function validateArtifact(files) {
|
|
|
394
415
|
}
|
|
395
416
|
if (manifest?.supports?.worship) {
|
|
396
417
|
const nullHtml = await liquid.render(parsedLayout, {
|
|
418
|
+
site: siteFx,
|
|
397
419
|
brand: contextContract.fixtures.brand,
|
|
398
420
|
nav: contextContract.fixtures.layout.nav,
|
|
399
421
|
socials: contextContract.fixtures.layout.socials ?? [],
|
|
400
422
|
worship: null,
|
|
423
|
+
site: { ...siteFx, content: { ...siteFx.content, schedules: [] } },
|
|
401
424
|
locale: contextContract.fixtures.layout.locale
|
|
402
425
|
});
|
|
403
426
|
if (nullHtml.includes(worshipProbe)) {
|
|
@@ -434,7 +457,7 @@ export async function validateArtifact(files) {
|
|
|
434
457
|
continue;
|
|
435
458
|
}
|
|
436
459
|
try {
|
|
437
|
-
const html = await liquid.render(parsedPage, { ...fixture, brand: contextContract.fixtures.brand });
|
|
460
|
+
const html = await liquid.render(parsedPage, { ...fixture, site: siteFx, brand: contextContract.fixtures.brand });
|
|
438
461
|
for (const part of splitIslandParts(html)) {
|
|
439
462
|
if (part.island === CONTENT_SLOT) {
|
|
440
463
|
errors.push(`page template '${pageName}': uses {% content %} — that tag is layout-only`);
|
|
@@ -495,5 +518,35 @@ export async function validateArtifact(files) {
|
|
|
495
518
|
}
|
|
496
519
|
}
|
|
497
520
|
|
|
498
|
-
|
|
521
|
+
// Open enums, proven survivable (content model v1 discipline 2): a template whose footprint
|
|
522
|
+
// reads site.content.events must survive a registration mode it has never heard of — new modes
|
|
523
|
+
// WILL arrive within the major. No throw, and no undefined/null literal leaking into markup.
|
|
524
|
+
if (contentAnalysis.footprint.includes('content.events')) {
|
|
525
|
+
const futureEvent = {
|
|
526
|
+
...(siteFx.content.events[0] ?? {}),
|
|
527
|
+
id: 'ffffffff-ffff-4fff-8fff-ffffffffffff',
|
|
528
|
+
name: 'Open Enum Probe Event',
|
|
529
|
+
registrationMode: 'X_FUTURE_MODE',
|
|
530
|
+
detailHref: '/events?event=ffffffff-ffff-4fff-8fff-ffffffffffff'
|
|
531
|
+
};
|
|
532
|
+
const doctored = { ...siteFx, content: { ...siteFx.content, events: [...siteFx.content.events, futureEvent] } };
|
|
533
|
+
for (const type of manifest?.supports?.sections ?? []) {
|
|
534
|
+
const file = `sections/${type}.liquid`;
|
|
535
|
+
if (!has(file) || !/\bsite\s*[.[]/.test(read(file))) continue;
|
|
536
|
+
try {
|
|
537
|
+
const html = await liquid.render(liquid.parse(read(file)), {
|
|
538
|
+
section: catalogueByType.get(type)?.sample ?? {},
|
|
539
|
+
brand: contextContract.fixtures.brand,
|
|
540
|
+
site: doctored
|
|
541
|
+
});
|
|
542
|
+
if (/\bundefined\b|\bnull\b/.test(html.replace(/data-[a-z-]+="[^"]*"/g, ''))) {
|
|
543
|
+
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`);
|
|
544
|
+
}
|
|
545
|
+
} catch (e) {
|
|
546
|
+
errors.push(`section '${type}': failed rendering an unknown event registrationMode — the enum is OPEN and new modes will arrive (${e.message})`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return { errors, warnings, manifest, contentFootprint: contentAnalysis.footprint, minContentVersion: contentAnalysis.minModelVersion };
|
|
499
552
|
}
|
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));
|
|
@@ -188,13 +188,13 @@ body { font-family: ' IBM Plex Sans Arabic', var(--p60s-siteFont, 'Inter'), ui-s
|
|
|
188
188
|
.lq-people { grid-template-columns: 1fr; }
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
/* Campaigns
|
|
191
|
+
/* Campaigns, the same quiet rail as values: title, summary, one action. */
|
|
192
192
|
.lq-campaigns { padding: 3.5rem 0; }
|
|
193
193
|
.lq-campaign-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 1.6rem; max-width: 700px; }
|
|
194
194
|
.lq-campaign h3 { margin: 0 0 0.25rem; }
|
|
195
195
|
.lq-campaign p { margin: 0 0 0.6rem; }
|
|
196
196
|
|
|
197
|
-
/* Behaviours (engine-wired via data-p60
|
|
197
|
+
/* Behaviours (engine-wired via data-p60-*, docs: Motion and behaviour guide). Everything that
|
|
198
198
|
hides pre-behaviour content is .p60-js-scoped: the no-JS render stays complete. */
|
|
199
199
|
|
|
200
200
|
/* Reveal: the values rail rises in with a stagger. */
|
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",
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{% comment %}
|
|
2
|
-
Live campaigns
|
|
2
|
+
Live site.content.campaigns, the starter's plain rendering of the `site.content.campaigns` context, kept simple on
|
|
3
3
|
purpose: this template is the worked example template authors copy from. The context is the
|
|
4
|
-
platform's published campaigns; hrefs are platform-derived
|
|
4
|
+
platform's published site.content.campaigns; hrefs are platform-derived, always link via campaign.href.
|
|
5
5
|
Derive or omit: nothing renders when no campaign is live.
|
|
6
6
|
{% endcomment %}
|
|
7
|
-
{% if campaigns.size > 0 %}
|
|
8
|
-
<section class="lq-campaigns">
|
|
7
|
+
{% if site.content.campaigns.size > 0 %}
|
|
8
|
+
<section class="lq-site.content.campaigns">
|
|
9
9
|
<div class="container">
|
|
10
10
|
<h2>{{ section.heading | default: campaignsLabel }}</h2>
|
|
11
11
|
{% if section.text %}<p>{{ section.text }}</p>{% endif %}
|
|
12
12
|
<ul class="lq-campaign-list">
|
|
13
|
-
{% for campaign in campaigns %}
|
|
13
|
+
{% for campaign in site.content.campaigns %}
|
|
14
14
|
<li class="lq-campaign">
|
|
15
15
|
<h3>{{ campaign.title }}</h3>
|
|
16
16
|
{% if campaign.summary %}<p>{{ campaign.summary }}</p>{% endif %}
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{% comment %}
|
|
2
|
-
The home opener
|
|
2
|
+
The home opener, and the REFERENCE IMPLEMENTATION of supports.heroImagery. Rules worth
|
|
3
3
|
copying:
|
|
4
4
|
|
|
5
5
|
1. EVERY field is optional. Copy derives from brand (| default) or omits; no photos means the
|
|
6
|
-
designed gradient below
|
|
6
|
+
designed gradient below, never a placeholder image.
|
|
7
7
|
2. Photos are NEVER placed raw. The treatments in theme.css lay scrims built from the palette
|
|
8
8
|
variables (color-mix over var(--bg)/var(--bg-tint)), so any uploaded photograph is toned
|
|
9
9
|
into the template. Tune the treatment, don't remove it.
|
|
10
10
|
3. Rotation is the PLATFORM's hero_carousel island (templates ship no JavaScript): with two or
|
|
11
11
|
more photos, place it and style its documented classes.
|
|
12
12
|
4. photoFraming is the charity's call, honour BOTH: 'fill' (default) covers your designed hero
|
|
13
|
-
shape (per-photo focus picks the surviving slice); 'whole' is the VISTA
|
|
13
|
+
shape (per-photo focus picks the surviving slice); 'whole' is the VISTA, the hero adapts
|
|
14
14
|
toward the photo's own shape (height-clamped) so a wide landscape stays entirely visible,
|
|
15
15
|
with your copy stacked after the band.
|
|
16
16
|
{% endcomment %}
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
{% if heroCount > 1 %}{% island 'hero_carousel' %}{% else %}<div class="lq-vista-scrim" aria-hidden="true"></div>{% endif %}
|
|
39
39
|
<div class="lq-vista-copy">
|
|
40
40
|
<div class="container">
|
|
41
|
-
<p class="lq-kicker">{{ section.eyebrow | default: brand.name }}</p>
|
|
42
|
-
<h1>{{ section.title | default: brand.tagline | default: brand.name }}</h1>
|
|
41
|
+
<p class="lq-kicker">{{ section.eyebrow | default: site.brand.name }}</p>
|
|
42
|
+
<h1>{{ section.title | default: site.brand.tagline | default: site.brand.name }}</h1>
|
|
43
43
|
{% if section.lead %}<p class="lq-homehero-lead">{{ section.lead }}</p>{% endif %}
|
|
44
44
|
<div class="lq-homehero-actions">
|
|
45
45
|
<a class="button button-primary" href="{{ section.primaryHref | default: '/donate' }}">{{ section.primaryLabel | default: 'Donate now' }}</a>
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
</div>
|
|
51
51
|
</section>
|
|
52
52
|
{% else %}
|
|
53
|
-
{% comment %} Form mode: the grid pulls up over the band
|
|
53
|
+
{% comment %} Form mode: the grid pulls up over the band, copy on the photo, the widget riding
|
|
54
54
|
the seam onto the gradient below (never a stacked strip). {% endcomment %}
|
|
55
55
|
<section class="lq-homehero lq-homehero--vista lq-homehero--vista-form{% if heroCount > 1 %} lq-homehero--carousel{% endif %}">
|
|
56
56
|
<div class="lq-vista">
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
</div>
|
|
60
60
|
<div class="container lq-homehero-inner lq-vista-overlap">
|
|
61
61
|
<div>
|
|
62
|
-
<p class="lq-kicker">{{ section.eyebrow | default: brand.name }}</p>
|
|
63
|
-
<h1>{{ section.title | default: brand.tagline | default: brand.name }}</h1>
|
|
62
|
+
<p class="lq-kicker">{{ section.eyebrow | default: site.brand.name }}</p>
|
|
63
|
+
<h1>{{ section.title | default: site.brand.tagline | default: site.brand.name }}</h1>
|
|
64
64
|
{% if section.lead %}<p class="lq-homehero-lead">{{ section.lead }}</p>{% endif %}
|
|
65
65
|
<div class="lq-homehero-actions">
|
|
66
66
|
<a class="button button-primary" href="#donate">{{ section.primaryLabel | default: 'Donate now' }}</a>
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
{% else %}
|
|
77
77
|
<section class="lq-homehero{% if heroPhoto %} lq-homehero--photo{% endif %}{% if heroCount > 1 %} lq-homehero--carousel{% endif %}"{% if heroPhoto %} style="background-image: url('{{ heroPhoto }}'); background-position: {{ heroFocus }}"{% endif %}>
|
|
78
78
|
{% comment %}
|
|
79
|
-
Several photos → the BEHAVIOUR carousel (data-p60-carousel, engine-wired
|
|
79
|
+
Several photos → the BEHAVIOUR carousel (data-p60-carousel, engine-wired, the template ships
|
|
80
80
|
no JavaScript): complete static markup, one data-p60-slide per photo, auto-advance opt-in.
|
|
81
81
|
Hiding non-active slides is .p60-js-scoped in theme.css so a no-JS visitor sees the photos
|
|
82
82
|
stacked, never a blank hero. The vista framings above show the other route: the ready-made
|
|
@@ -93,8 +93,8 @@
|
|
|
93
93
|
{% endif %}
|
|
94
94
|
<div class="container lq-homehero-inner">
|
|
95
95
|
<div>
|
|
96
|
-
<p class="lq-kicker">{{ section.eyebrow | default: brand.name }}</p>
|
|
97
|
-
<h1>{{ section.title | default: brand.tagline | default: brand.name }}</h1>
|
|
96
|
+
<p class="lq-kicker">{{ section.eyebrow | default: site.brand.name }}</p>
|
|
97
|
+
<h1>{{ section.title | default: site.brand.tagline | default: site.brand.name }}</h1>
|
|
98
98
|
{% if section.lead %}<p class="lq-homehero-lead">{{ section.lead }}</p>{% endif %}
|
|
99
99
|
<div class="lq-homehero-actions">
|
|
100
100
|
{% if section.givingStyle == 'button' %}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{% comment %}
|
|
2
|
-
People / team
|
|
2
|
+
People / team, the reference implementation: simple cards (name, role, optional photo and
|
|
3
3
|
one-line statement), a grid that collapses to one column on phones. No placeholder faces:
|
|
4
4
|
a person without a photo just renders text (derive or omit, never fabricate).
|
|
5
5
|
{% endcomment %}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<h2>{{ section.heading | default: "What guides us" }}</h2>
|
|
4
4
|
{% comment %}
|
|
5
5
|
data-p60-reveal-group: the engine stamps --p60-reveal-index on each child and adds
|
|
6
|
-
is-revealed when the rail scrolls into view
|
|
6
|
+
is-revealed when the rail scrolls into view, the stagger and motion are OURS, in theme.css,
|
|
7
7
|
and .p60-js-scoped so a no-JS visitor sees the rail complete and still.
|
|
8
8
|
{% endcomment %}
|
|
9
9
|
<ol class="lq-value-rail" data-p60-reveal-group>
|