@port60/template-kit 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/dev.mjs +3 -1
- package/src/vendor/contract/v1/context.json +2 -2
- package/src/vendor/contract/v1/islands.json +3 -3
- package/src/vendor/contract/v1/manifest.schema.json +1 -1
- package/src/vendor/validator/focus.mjs +49 -0
- package/src/vendor/validator/preview.mjs +36 -9
- package/src/vendor/validator/validate.mjs +6 -5
- package/starter/manifest.json +2 -2
- package/starter/sections/homeHero.liquid +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@port60/template-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Build Port60 site templates locally: scaffold, live-preview, validate against the platform contract, and package for studio upload. AI-agent ready: every scaffold ships AGENTS.md and validate emits machine-readable JSON.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/commands/dev.mjs
CHANGED
|
@@ -132,7 +132,9 @@ export async function dev(args) {
|
|
|
132
132
|
fixtureImageBase,
|
|
133
133
|
previewContent,
|
|
134
134
|
contentImageOrigins: imageOriginsOf(previewContent),
|
|
135
|
-
surface: surfaceFor(req.url ?? '/')
|
|
135
|
+
surface: surfaceFor(req.url ?? '/'),
|
|
136
|
+
// ?focus=donate|volunteer|none: what leads, so the hero shows each widget and its buttons.
|
|
137
|
+
focus: url.searchParams.get('focus') ?? undefined
|
|
136
138
|
});
|
|
137
139
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
138
140
|
res.end(html);
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"section",
|
|
32
32
|
"layout"
|
|
33
33
|
],
|
|
34
|
-
"description": "What
|
|
34
|
+
"description": "What the primary_action_widget island becomes: 'donate' (default, the donation widget), 'volunteer' (the volunteer sign-up) or 'none' (the charity chose no widget in the hero). The charity's one switch (docs/volunteering.md 'Site focus'); everything below follows from it."
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
"path": "site.actions",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"availableIn": [
|
|
47
47
|
"section"
|
|
48
48
|
],
|
|
49
|
-
"description": "homeHero only: the leading action { kind, label, href }, the same as site.actions.primary. Render it as a widget with {% island '
|
|
49
|
+
"description": "homeHero only: the leading action { kind, label, href }, the same as site.actions.primary. Render it as a widget with {% island 'primary_action_widget' %} when section.actionStyle is 'widget'; that island becomes this action's widget."
|
|
50
50
|
},
|
|
51
51
|
{
|
|
52
52
|
"path": "section.secondary",
|
|
@@ -283,10 +283,10 @@
|
|
|
283
283
|
]
|
|
284
284
|
},
|
|
285
285
|
{
|
|
286
|
-
"name": "
|
|
287
|
-
"label": "Primary action",
|
|
286
|
+
"name": "primary_action_widget",
|
|
287
|
+
"label": "Primary action widget",
|
|
288
288
|
"status": "available",
|
|
289
|
-
"description": "The
|
|
289
|
+
"description": "The widget that leads the site. It IS the donation widget when the charity chose giving to lead, the volunteer sign-up when volunteering leads, and nothing when the charity chose no widget in the hero; site.focus tells you which ('donate', 'volunteer' or 'none'). Place {% island 'primary_action_widget' %} in the home hero's widget slot INSTEAD of donation_widget whenever your template declares supports.focus: the platform decides which widget renders, the template never does, and the charity's switch moves it without a template change. Both widgets wear .action-card for shared treatment; .donate-card and .volunteer-card carry kind-specific styling. A template that wants a different layout per kind may branch on section.primary.kind and place donation_widget or volunteer_signup itself, but then it owns the switch.",
|
|
290
290
|
"params": [],
|
|
291
291
|
"stylingApi": [
|
|
292
292
|
".action-card",
|
|
@@ -185,7 +185,7 @@
|
|
|
185
185
|
"volunteer"
|
|
186
186
|
]
|
|
187
187
|
},
|
|
188
|
-
"description": "The site-focus kinds this template can LEAD with (docs/volunteering.md). Declaring 'volunteer' means the home hero places the
|
|
188
|
+
"description": "The site-focus kinds this template can LEAD with (docs/volunteering.md). Declaring 'volunteer' means the home hero places the primary_action_widget island (the widget that becomes the donation widget or the volunteer sign-up as the charity chooses) or branches on section.primary.kind, so the volunteer sign-up can take the widget slot; the picker marks templates that cannot lead with volunteering. Templates that predate the setting keep rendering the donation widget, since donate is the default."
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
191
|
},
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The site focus for the PREVIEW: a JavaScript twin of charity-site's lib/focus.ts, kept in step by
|
|
2
|
+
// hand (the validator is plain JS and is vendored into the kit). Same rules: the leading action is
|
|
3
|
+
// the widget in the hero; the other action is the one button in the header and beside the hero; the
|
|
4
|
+
// leading action never appears twice. The preview assumes volunteering is OPEN, so the pairing shows.
|
|
5
|
+
export const FOCUS_CHOICES = ['donate', 'volunteer', 'none'];
|
|
6
|
+
|
|
7
|
+
const ACTION = {
|
|
8
|
+
donate: { kind: 'donate', label: 'Donate', href: '/donate' },
|
|
9
|
+
volunteer: { kind: 'volunteer', label: 'Volunteer', href: '/volunteer' }
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** `?focus=` from the dev server, or nothing: 'donate' is the platform default. */
|
|
13
|
+
export function normaliseFocus(value) {
|
|
14
|
+
return FOCUS_CHOICES.includes(value) ? value : 'donate';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The resolved surfaces for one preview render, the shape of site.actions on a live site. */
|
|
18
|
+
export function previewActions(focus) {
|
|
19
|
+
if (focus === 'volunteer') {
|
|
20
|
+
return { primary: ACTION.volunteer, secondary: ACTION.donate, widget: 'volunteer', header: ACTION.donate, hero: ACTION.donate };
|
|
21
|
+
}
|
|
22
|
+
if (focus === 'none') {
|
|
23
|
+
// Buttons only (a Custom setting with no widget): the hero button leads with giving.
|
|
24
|
+
return { primary: ACTION.donate, secondary: ACTION.volunteer, widget: null, header: ACTION.donate, hero: ACTION.donate };
|
|
25
|
+
}
|
|
26
|
+
return { primary: ACTION.donate, secondary: ACTION.volunteer, widget: 'donate', header: ACTION.volunteer, hero: ACTION.volunteer };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The home hero's resolved fields, as the platform computes them (lib/focus.ts withResolvedActions). */
|
|
30
|
+
export function withResolvedActions(content, actions) {
|
|
31
|
+
const style = content.actionStyle ?? content.givingStyle;
|
|
32
|
+
const buttonMode = style === 'button' || actions.widget === null;
|
|
33
|
+
return {
|
|
34
|
+
...content,
|
|
35
|
+
actionStyle: buttonMode ? 'button' : 'widget',
|
|
36
|
+
primary: actions.primary,
|
|
37
|
+
secondary: actions.secondary,
|
|
38
|
+
action: buttonMode && actions.hero === null && style === 'button' ? actions.primary : actions.hero
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The site tree with the focus applied: site.focus, site.actions, and the nav's one button. */
|
|
43
|
+
export function applyFocus(site, actions) {
|
|
44
|
+
const swapCta = (items) => Array.isArray(items)
|
|
45
|
+
? items.map((item) => (item && item.cta ? (actions.header ? { ...item, label: actions.header.label, href: actions.header.href } : null) : item)).filter(Boolean)
|
|
46
|
+
: items;
|
|
47
|
+
const nav = site.nav ? { ...site.nav, items: swapCta(site.nav.items), derived: swapCta(site.nav.derived) } : site.nav;
|
|
48
|
+
return { ...site, focus: actions.widget ?? 'none', actions, nav };
|
|
49
|
+
}
|
|
@@ -17,6 +17,7 @@ import islandRegistry from '../contract/v1/islands.json' with { type: 'json' };
|
|
|
17
17
|
import contextContract from '../contract/v1/context.json' with { type: 'json' };
|
|
18
18
|
import { resolveFixtureArt } from './fixture-art.mjs';
|
|
19
19
|
import { buildSiteFixture, applyPreviewContent } from './site-context.mjs';
|
|
20
|
+
import { normaliseFocus, previewActions, withResolvedActions, applyFocus } from './focus.mjs';
|
|
20
21
|
|
|
21
22
|
// Fixture imagery resolved for the SEALED studio render (p60fixture: refs become inline-SVG data
|
|
22
23
|
// URIs the network-dead CSP can show). The dev preview may instead resolve them to the platform
|
|
@@ -143,8 +144,16 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
|
|
|
143
144
|
<label class="newsletter-label">Email address</label><div class="newsletter-fields"><input class="newsletter-email" type="email" disabled><button class="newsletter-submit" type="button" disabled>Join our newsletter</button></div>
|
|
144
145
|
<label class="newsletter-consent"><input type="checkbox" disabled><span>Email me about our work and appeals.</span></label>
|
|
145
146
|
</form>`;
|
|
146
|
-
case '
|
|
147
|
-
|
|
147
|
+
case 'primary_action_widget':
|
|
148
|
+
// What this island becomes follows the preview's focus (fixtures.focus, from ?focus= on the
|
|
149
|
+
// dev server): the volunteer sign-up, nothing, or the default, the donation widget.
|
|
150
|
+
if (fixtures.focus === 'volunteer') {
|
|
151
|
+
return islandSkeleton('volunteer_signup', ctx, fx).replace('data-p60-preview-island="volunteer_signup"', 'data-p60-preview-island="primary_action_widget"');
|
|
152
|
+
}
|
|
153
|
+
if (fixtures.focus === 'none') {
|
|
154
|
+
return `<div class="p60-preview-empty" data-p60-preview-island="primary_action_widget">${previewNote(name)}<p>No widget here: this charity leads with buttons only (site.focus is 'none').</p></div>`;
|
|
155
|
+
}
|
|
156
|
+
return islandSkeleton('donation_widget', ctx, fx).replace('data-p60-preview-island="donation_widget"', 'data-p60-preview-island="primary_action_widget"');
|
|
148
157
|
case 'volunteer_signup':
|
|
149
158
|
return `<section class="donate-card vol-card" data-p60-preview-island="volunteer_signup">
|
|
150
159
|
${previewNote(name)}
|
|
@@ -359,18 +368,27 @@ const SURFACES = {
|
|
|
359
368
|
/** The routed dev preview's surface names (plus 'home'). */
|
|
360
369
|
export const PREVIEW_SURFACES = ['home', ...Object.keys(SURFACES)];
|
|
361
370
|
|
|
362
|
-
function surfaceBar(active) {
|
|
371
|
+
function surfaceBar(active, focus = 'donate') {
|
|
372
|
+
const withFocus = (href) => (focus === 'donate' ? href : `${href}${href.includes('?') ? '&' : '?'}focus=${focus}`);
|
|
363
373
|
const link = (name, href) =>
|
|
364
|
-
`<a href="${href}"${name === active ? ' style="font-weight:700;text-decoration:underline"' : ''}>${name}</a>`;
|
|
374
|
+
`<a href="${withFocus(href)}"${name === active ? ' style="font-weight:700;text-decoration:underline"' : ''}>${name}</a>`;
|
|
365
375
|
const links = [
|
|
366
376
|
link('home', '/'), link('events', '/events'), link('event', '/events?event=fixture'),
|
|
367
377
|
link('services', '/services'), link('service', '/services?service=fixture'),
|
|
368
378
|
link('donate', '/donate'), link('articles', '/articles'), link('article', '/articles/fixture'),
|
|
369
379
|
link('campaigns', '/campaigns'), link('campaign', '/campaigns/fixture'), link('course', '/courses'),
|
|
370
|
-
`<a href="/model" style="margin-left:auto;font-weight:700">site.content model →</a>`,
|
|
380
|
+
`<a href="${withFocus('/model')}" style="margin-left:auto;font-weight:700">site.content model →</a>`,
|
|
371
381
|
];
|
|
382
|
+
// What leads: the charity's switch, here as three links, so a developer sees the hero widget as
|
|
383
|
+
// the donation widget, as the volunteer sign-up, or absent, with the buttons following each time.
|
|
384
|
+
const focusLink = (value, label) =>
|
|
385
|
+
`<a href="${value === 'donate' ? '/' : `/?focus=${value}`}"${value === focus ? ' style="font-weight:700;text-decoration:underline"' : ''}>${label}</a>`;
|
|
386
|
+
const focusLinks = [focusLink('donate', 'giving'), focusLink('volunteer', 'volunteering'), focusLink('none', 'buttons only')];
|
|
372
387
|
return `<nav class="p60-preview-surfaces" aria-label="Preview surfaces" style="position:sticky;top:0;z-index:99;display:flex;gap:12px;flex-wrap:wrap;padding:8px 14px;font:12px/1.4 system-ui,sans-serif;background:#0b1220;color:#e6e9f2;opacity:.94">
|
|
373
388
|
<strong style="letter-spacing:.06em;text-transform:uppercase;font-size:10px">Surfaces</strong>${links.join('')}
|
|
389
|
+
<span style="flex-basis:100%;height:0"></span>
|
|
390
|
+
<strong style="letter-spacing:.06em;text-transform:uppercase;font-size:10px">Leads with</strong>${focusLinks.join('')}
|
|
391
|
+
<span style="opacity:.7">the hero widget becomes the donation widget, the volunteer sign-up, or nothing; the buttons follow</span>
|
|
374
392
|
</nav>`;
|
|
375
393
|
}
|
|
376
394
|
|
|
@@ -420,12 +438,17 @@ export async function renderStudioPreview(files, options = {}) {
|
|
|
420
438
|
// imagery to the platform CDN instead of inline-SVG art (the dev-richer half of the split).
|
|
421
439
|
const artOptions = options.fixtureImageBase ? { imageBase: options.fixtureImageBase } : null;
|
|
422
440
|
let fx = artOptions ? resolveFixtureArt(contextContract.fixtures, artOptions) : STUDIO_FX;
|
|
441
|
+
// What leads (docs/volunteering.md 'Site focus'): the dev server passes ?focus=; the studio
|
|
442
|
+
// renders the platform default. The island skeletons read it from the fixtures they are handed.
|
|
443
|
+
const focus = normaliseFocus(options.focus);
|
|
444
|
+
const actions = previewActions(focus);
|
|
445
|
+
fx = { ...fx, focus };
|
|
423
446
|
// The one content tree (content model v1): about composed from this manifest's declared
|
|
424
447
|
// sections, dev preview-content overlaid when the kit passes it (validated there), imagery
|
|
425
448
|
// resolved exactly like the rest of the fixtures.
|
|
426
|
-
const site = resolveFixtureArt(
|
|
449
|
+
const site = applyFocus(resolveFixtureArt(
|
|
427
450
|
applyPreviewContent(buildSiteFixture(manifest), options.previewContent ?? null),
|
|
428
|
-
artOptions ?? {});
|
|
451
|
+
artOptions ?? {}), actions);
|
|
429
452
|
const brand = site.brand ?? fx.brand;
|
|
430
453
|
// With a content override, the TREE is the source of truth for every fixture view: the routed
|
|
431
454
|
// platform skeletons and island skeletons re-derive their slices from the overridden site, so
|
|
@@ -481,8 +504,12 @@ export async function renderStudioPreview(files, options = {}) {
|
|
|
481
504
|
const entry = catalogueByType.get(type);
|
|
482
505
|
const source = files[`sections/${type}.liquid`];
|
|
483
506
|
if (!entry || source == null) continue;
|
|
507
|
+
const sample = artOptions ? resolveFixtureArt(entry.sample ?? {}, artOptions) : resolveFixtureArt(entry.sample ?? {});
|
|
484
508
|
const context = {
|
|
485
|
-
|
|
509
|
+
// The home hero carries the resolved actions exactly as the platform hands them over, so a
|
|
510
|
+
// developer sees the widget AND the button that pairs with it. The catalogue sample's
|
|
511
|
+
// givingStyle is dropped here: in the preview the focus decides the style.
|
|
512
|
+
section: type === 'homeHero' ? withResolvedActions({ ...sample, givingStyle: undefined, actionStyle: undefined }, actions) : sample,
|
|
486
513
|
brand,
|
|
487
514
|
site,
|
|
488
515
|
...(fx.sections?.[type] ?? {})
|
|
@@ -580,7 +607,7 @@ export async function renderStudioPreview(files, options = {}) {
|
|
|
580
607
|
<style>${themeCss}</style>
|
|
581
608
|
</head>
|
|
582
609
|
<body ${attrs}>
|
|
583
|
-
${options.surface ? surfaceBar(surface) : ''}
|
|
610
|
+
${options.surface ? surfaceBar(surface, focus) : ''}
|
|
584
611
|
${bodyHtml}
|
|
585
612
|
${runtime ? `<script>${runtime}\np60Behaviors.initBehaviors();</script>` : ''}
|
|
586
613
|
</body>
|
|
@@ -100,7 +100,7 @@ export async function validateArtifact(files) {
|
|
|
100
100
|
newsletter: () => islands.has('newsletter_signup'),
|
|
101
101
|
i18n: () => islands.has('language_switch'),
|
|
102
102
|
search: () => islands.has('search'),
|
|
103
|
-
volunteering: () => islands.has('volunteer_signup') || islands.has('
|
|
103
|
+
volunteering: () => islands.has('volunteer_signup') || islands.has('primary_action_widget')
|
|
104
104
|
};
|
|
105
105
|
for (const capability of manifest?.requiresCapabilities ?? []) {
|
|
106
106
|
if (!capabilitySurface[capability]?.()) {
|
|
@@ -110,14 +110,15 @@ export async function validateArtifact(files) {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
// Site focus honesty (docs/volunteering.md): a template that claims it can lead with
|
|
113
|
-
// volunteering must give the volunteer sign-up a way into the hero, either the
|
|
114
|
-
//
|
|
113
|
+
// volunteering must give the volunteer sign-up a way into the hero, either the
|
|
114
|
+
// primary_action_widget island (which becomes the sign-up when volunteering leads) or the
|
|
115
|
+
// volunteer_signup island placed directly.
|
|
115
116
|
{
|
|
116
117
|
const focusKinds = new Set(manifest?.supports?.focus ?? []);
|
|
117
118
|
if (focusKinds.has('volunteer')) {
|
|
118
119
|
const liquidSource = Object.entries(files).filter(([path]) => path.endsWith('.liquid')).map(([, s]) => s).join('\n');
|
|
119
|
-
if (!/island\s+['"]
|
|
120
|
-
errors.push("manifest: supports.focus lists 'volunteer' but no section places {% island '
|
|
120
|
+
if (!/island\s+['"]primary_action_widget['"]/.test(liquidSource) && !/island\s+['"]volunteer_signup['"]/.test(liquidSource)) {
|
|
121
|
+
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");
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
124
|
}
|
package/starter/manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "starter",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"format": "port60-liquid@1",
|
|
5
5
|
"label": "Starter",
|
|
6
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.",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"member_menu",
|
|
24
24
|
"hero_carousel",
|
|
25
25
|
"map",
|
|
26
|
-
"
|
|
26
|
+
"primary_action_widget"
|
|
27
27
|
],
|
|
28
28
|
"layout": true,
|
|
29
29
|
"heroImagery": true,
|
|
@@ -68,8 +68,8 @@
|
|
|
68
68
|
</div>
|
|
69
69
|
</div>
|
|
70
70
|
<aside id="donate">
|
|
71
|
-
{%- comment -%} The site focus decides which widget leads (section.primary); the
|
|
72
|
-
{% island '
|
|
71
|
+
{%- comment -%} The site focus decides which widget leads (section.primary); this island becomes it: the donation widget or the volunteer sign-up. {%- endcomment -%}
|
|
72
|
+
{% island 'primary_action_widget' %}
|
|
73
73
|
</aside>
|
|
74
74
|
</div>
|
|
75
75
|
</section>
|
|
@@ -108,7 +108,7 @@
|
|
|
108
108
|
</div>
|
|
109
109
|
{% unless section.actionStyle == 'button' or section.givingStyle == 'button' %}
|
|
110
110
|
<aside id="donate">
|
|
111
|
-
{% island '
|
|
111
|
+
{% island 'primary_action_widget' %}
|
|
112
112
|
</aside>
|
|
113
113
|
{% endunless %}
|
|
114
114
|
</div>
|