@port60/template-kit 0.21.0 → 1.0.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 +42 -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 +108 -162
- 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 +105 -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 +6931 -0
- package/src/vendor/engine/content-footprint.mjs +76 -1
- 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 +310 -0
- 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
package/src/lib/agentsMd.mjs
CHANGED
|
@@ -1,167 +1,113 @@
|
|
|
1
|
-
//
|
|
2
|
-
// engine on template work and be productive immediately). Written for ANY coding agent, // AGENTS.md is the cross-tool convention and CLAUDE.md carries the identical content for tools
|
|
3
|
-
// that read that name. The briefing is the contract's rules + the iteration loop, so an agent's
|
|
4
|
-
// first move is always the same: read this, edit, validate --json, repeat until clean.
|
|
5
|
-
|
|
1
|
+
// A contract briefing shared by every generated coding-agent instruction file.
|
|
6
2
|
export function agentsMd(name) {
|
|
7
3
|
return `# Working on the "${name}" Port60 template
|
|
8
4
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
npm run
|
|
20
|
-
npm run
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
##
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
-
|
|
95
|
-
|
|
96
|
-
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
- Theme via CSS custom properties and the settings knobs you declare in
|
|
117
|
-
\`manifest.settings.schema\`, they surface in the charity's Appearance editor as
|
|
118
|
-
\`--p60s-<key>\` variables and \`data-p60s-<key>\` body attributes.
|
|
119
|
-
- Hero photographs (\`homeHero.images\`, when you declare \`supports.heroImagery\`): render one
|
|
120
|
-
photo directly as a TREATED backdrop (a scrim/tint built from your own palette variables via
|
|
121
|
-
color-mix, never raw), place the \`hero_carousel\` island for two or more (style its
|
|
122
|
-
\`.hero-slide-scrim\`), and design the no-photo state as a gradient/colour, never a
|
|
123
|
-
placeholder. The starter's \`.lq-homehero\` is the reference; the validator checks all of this
|
|
124
|
-
behaviourally.
|
|
125
|
-
- "Looks" = named one-click bundles of knob values in \`manifest.looks\`.
|
|
126
|
-
- Fonts: only families from the platform font catalogue, declared with the weights you use.
|
|
127
|
-
- Navigation can contain two levels below a top item. Render every supplied child and branch on
|
|
128
|
-
optional \`group\`, \`description\`, \`imageUrl\` and \`megaMenu\` promo metadata. Never hardcode
|
|
129
|
-
menu groups that are not in \`nav\`.
|
|
130
|
-
- Navigation highlights are optional design support, not implied by the \`nav\` behaviour.
|
|
131
|
-
Declare \`supports.navigationHighlights: true\` only when your layout renders one supplied
|
|
132
|
-
\`site.nav.items[].megaMenu.promo\` card per expanded top-level menu. Otherwise declare false.
|
|
133
|
-
The platform resolves linked content into \`title\`, \`text\`, \`href\`, \`label\` and optional
|
|
134
|
-
\`imageUrl\`; no entity lookup belongs in a template. Preserve a text-only card when its image
|
|
135
|
-
is absent, omit an absent card and keep normal navigation links. Validation proves the explicit
|
|
136
|
-
declaration; missing declarations never enable the editor feature automatically.
|
|
137
|
-
- Field markers are optional and additive: mark the node that shows a field with
|
|
138
|
-
\`data-p60-field="title"\`, or \`data-p60-field="items.{{ forloop.index0 }}.label"\` for an
|
|
139
|
-
entry in a list, and declare \`supports.fieldMarkers: true\`. The charity then types into the
|
|
140
|
-
real heading on the page instead of into a side panel. The address is the field's name inside
|
|
141
|
-
that section's own content, and the marked node holds that field and nothing else, so wrap the
|
|
142
|
-
value in a span when punctuation or other copy sits beside it. Mark what you like; the editor
|
|
143
|
-
puts a caret only in plain text, and a marker naming nothing is a warning, never a failure.
|
|
144
|
-
- Dynamic sections include appeals (\`causes\`), programmes (\`services\`), resources and
|
|
145
|
-
locations. Derive or omit when a collection is empty and use the supplied URLs rather than
|
|
146
|
-
constructing routes.
|
|
147
|
-
- Submission and behaviour surfaces such as \`newsletter_signup\`, \`form\`, \`search\`,
|
|
148
|
-
\`language_switch\` and \`next_prayer\` are platform islands. Place and style them;
|
|
149
|
-
never reproduce their API calls or consent behaviour.
|
|
150
|
-
- \`manifest.imagery.hero\` (optional but recommended): declare the photo shape YOUR hero
|
|
151
|
-
composes best with (\`idealAspect\`, \`minWidth\`, a one-line \`note\`), the charity's editor
|
|
152
|
-
measures their actual upload against it and advises. Advice, never enforcement.
|
|
153
|
-
|
|
154
|
-
## Which contract this is
|
|
155
|
-
|
|
156
|
-
The section catalogue, islands and fixtures here are the **Charity Platform contract v1**, the
|
|
157
|
-
platform's first product surface. The dialect, the rules above and this toolchain are
|
|
158
|
-
platform-wide; other Port60 products will ship their own contract packs. Do not assume the
|
|
159
|
-
current section list is universal.
|
|
160
|
-
|
|
161
|
-
## Reference
|
|
162
|
-
|
|
163
|
-
The full generated reference (sections, islands, context variables, tokens, dialect) lives at
|
|
164
|
-
https://developers.port60.com, also available in one file for agents at
|
|
165
|
-
https://developers.port60.com/llms-full.txt
|
|
5
|
+
This is a Liquid and CSS artifact, not an application. The platform owns public eligibility,
|
|
6
|
+
routes, consent, authentication, payments and interactive islands. Preserve the design's visual
|
|
7
|
+
identity, authored content and inline editing markers while changing presentation.
|
|
8
|
+
|
|
9
|
+
## Versions and iteration
|
|
10
|
+
|
|
11
|
+
Use format port60-liquid@2, content model2.0 and kit1.0.0. Existing v1 platform pins retain
|
|
12
|
+
their historical contract; this kit explicitly rejects v1 for new authoring and uploads.
|
|
13
|
+
Never relabel v1 without migrating its reads. Published name/version identities are immutable.
|
|
14
|
+
|
|
15
|
+
- npm run validate:json is the machine-readable feedback loop. Fix all errors after each edit.
|
|
16
|
+
- npm run validate checks the same contract as upload.
|
|
17
|
+
- npm run dev previews all supported pages locally.
|
|
18
|
+
- npm run package validates and writes the uploadable zip.
|
|
19
|
+
- npm run release builds a store release with separate template/ and preview/ bundles.
|
|
20
|
+
- p60-template-kit setup-previews installs the pinned build browser once (CI: --with-deps).
|
|
21
|
+
- Check all Looks, empty states, long text and mobile layouts. Validation is not visual QA.
|
|
22
|
+
|
|
23
|
+
## Artifact shape
|
|
24
|
+
|
|
25
|
+
manifest.json declares support. layout.liquid has exactly one {% content %} slot.
|
|
26
|
+
sections/<type>.liquid implements catalogued types; pages/<page>.liquid implements declared
|
|
27
|
+
page templates. assets/theme.css is the only loaded stylesheet. No JavaScript, fonts, API calls,
|
|
28
|
+
remote CSS imports or image files belong in the runtime artifact. preview/ contains independent
|
|
29
|
+
author-demo inputs. Its config.json names a content JSON file and optional widget focus. Put
|
|
30
|
+
author JPEG/PNG/WebP imagery in preview/media/ and use p60preview:filename references in that
|
|
31
|
+
content. The release builder seals every Look and publishes that imagery only in the separate
|
|
32
|
+
preview/ bundle. Runtime package/publish ZIPs never contain it. Studio preview-bundle intake is
|
|
33
|
+
separate from the first-party store release lane. Use platform media URLs in runtime content.
|
|
34
|
+
|
|
35
|
+
Release automatically captures each Look as a 960x600 WebP under preview/gallery/, from a
|
|
36
|
+
1440x900 desktop render. Do not author separate screenshots. Posters have a 160 KiB cap;
|
|
37
|
+
HTML, images and metadata remain beside them. The gallery loads posters; details load HTML.
|
|
38
|
+
The build needs Chromium plus access to fonts.bunny.net, and refuses failed required assets.
|
|
39
|
+
Use the same kit/browser/OS for immutable-upload retries; bump the version for changed output.
|
|
40
|
+
Arabic-specific poster font fidelity is deferred, not proof of Arabic-locale conformance.
|
|
41
|
+
|
|
42
|
+
## The only public site tree
|
|
43
|
+
|
|
44
|
+
Read site.brand, site.nav, site.socials, site.locale, site.actions, site.page and site.content.
|
|
45
|
+
No flat brand/nav/collection aliases or site.focus exist. A section also receives section, its
|
|
46
|
+
current instance's raw authored content. Article/course details retain their documented record
|
|
47
|
+
context. impactMap receives the selected map with its contained points, never root locations.
|
|
48
|
+
|
|
49
|
+
services/events/articles/campaigns/causes/courses/documents are envelopes:
|
|
50
|
+
{label, href, items, pagination}. Iterate site.content.events.items, not the envelope.
|
|
51
|
+
Documents href can be null. Pagination is null outside listings, otherwise it carries page,
|
|
52
|
+
size, totalElements, totalPages, nextHref and previousHref. Use supplied URLs, not guessed routes.
|
|
53
|
+
Only site.content.schedules stays an array. Lists are bounded; enum values are open, so always
|
|
54
|
+
include fallbacks. Nullable values need guards. Metadata-only label/href/pagination reads do
|
|
55
|
+
not fetch items, but whole-envelope aliases do. Dynamic indexing of the site tree is refused.
|
|
56
|
+
|
|
57
|
+
The current render is site.page = {key, path, sections:[{key,type,content}]}.
|
|
58
|
+
Repeated section types have independent stable keys. Canonical section types include services,
|
|
59
|
+
courses, events and documents. No programmes, whatsOn, infoEvents, resources, locations or
|
|
60
|
+
content.about public aliases exist. Documents remain selected existing public records, never
|
|
61
|
+
an automatically exposed media library. Section support does not confer source entitlements.
|
|
62
|
+
|
|
63
|
+
## Authored ownership and clearing
|
|
64
|
+
|
|
65
|
+
For collection introductions, absent section.title inherits site.content.<collection>.label;
|
|
66
|
+
an explicit empty string hides it; other text overrides it. subtitle and eyebrow are authored.
|
|
67
|
+
Use nil checks, not Liquid default, wherever clearing has meaning. Keep generated labels out
|
|
68
|
+
of raw section content. Mark an authored title only in the nonempty override branch. An inherited
|
|
69
|
+
heading has no data-p60-field marker.
|
|
70
|
+
|
|
71
|
+
Declare supports.fieldMarkers:true when showing authored field markers. A marker such as
|
|
72
|
+
data-p60-field="title" or data-p60-field="items.{{ forloop.index0 }}.label" addresses only that
|
|
73
|
+
section's content. Its node must contain exactly the authored value. Use a span when punctuation
|
|
74
|
+
or generated text surrounds it. Never mark source records, generated labels or resolved actions.
|
|
75
|
+
|
|
76
|
+
## Navigation and actions
|
|
77
|
+
|
|
78
|
+
site.nav.header and site.nav.footer are independent arrays. kind link has href; kind group has
|
|
79
|
+
null href and children. Use disclosure controls for groups, not fake links. Render two child
|
|
80
|
+
levels and preserve description, imageUrl and optional megaMenu.promo. No derived menus, CTA
|
|
81
|
+
flags or generated columns exist. The template owns responsive menu layout.
|
|
82
|
+
supports.navigationHighlights:true must render supplied promos, including text-only cards,
|
|
83
|
+
without losing normal links. The nav behaviour alone never enables this feature.
|
|
84
|
+
site.actions.header and site.actions.hero are resolved actions or null. site.actions.widget is
|
|
85
|
+
donate, volunteer or none. Do not infer actions from navigation. Authored hero override text
|
|
86
|
+
retains its field marker; resolved fallback actions do not.
|
|
87
|
+
|
|
88
|
+
## Safety and design
|
|
89
|
+
|
|
90
|
+
- Output is escaped. Use raw only for contract-sanitised richtext.
|
|
91
|
+
- The dialect is whitelisted. include/render/layout and unknown filters are rejected.
|
|
92
|
+
- Place declared islands with {% island 'donation_widget' %}. Style their stable API and never
|
|
93
|
+
recreate transactions, API calls, forms, identity or consent logic.
|
|
94
|
+
- Render collection envelopes with template markup and declared behaviours. The historical
|
|
95
|
+
events_carousel, whats_on_strip and latest_articles islands are v1-only and rejected by v2.
|
|
96
|
+
Collection route continuation belongs to the host, not a second template data fetch or pager.
|
|
97
|
+
- Declare only what you render, and render what you declare. Capability matching is metadata,
|
|
98
|
+
not a transfer of route ownership or tenant entitlements.
|
|
99
|
+
- Hero photos need a palette scrim, a carousel for multiple photos and a designed no-photo state.
|
|
100
|
+
- Preserve settings and Looks. Use platform fonts and declared CSS tokens.
|
|
101
|
+
- Scope behaviour-dependent hidden content under .p60-js so no-JavaScript stays readable.
|
|
102
|
+
- Keep loops bounded. Test empty collections, cleared text and unknown enum values.
|
|
103
|
+
|
|
104
|
+
## Data and reference
|
|
105
|
+
|
|
106
|
+
p60-template-kit content . writes editable envelope fixtures, independent header/footer menus
|
|
107
|
+
and keyed page compositions. p60-template-kit dev . --content my.json previews those fixtures.
|
|
108
|
+
Overrides are schema-checked, never packaged and never replace canonical conformance fixtures.
|
|
109
|
+
The dev server /model shows live values beside the registry. p60-template-kit model --json
|
|
110
|
+
prints the current model. Generated reference: https://developers.port60.com/reference/content-model/
|
|
111
|
+
Full agent reference: https://developers.port60.com/llms-full.txt
|
|
166
112
|
`;
|
|
167
113
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Presentation only. The sandbox has no tenant data, forms, API or same-origin privileges.
|
|
2
|
+
(() => {
|
|
3
|
+
const ready = () => parent.postMessage({ type: 'p60-designer-ready' }, '*');
|
|
4
|
+
addEventListener('message', event => {
|
|
5
|
+
// The parent can live on any authorised admin host. Window identity, not the asset
|
|
6
|
+
// origin, binds this channel. No secret or HTML crosses it in either direction.
|
|
7
|
+
if (event.source !== parent) return;
|
|
8
|
+
if (event.data?.type === 'p60-designer-handshake') { ready(); return; }
|
|
9
|
+
if (event.data?.type !== 'p60-designer-settings') return;
|
|
10
|
+
for (const [key, value] of Object.entries(event.data.vars || {})) {
|
|
11
|
+
if (!/^[a-zA-Z][a-zA-Z0-9]{0,40}$/.test(key) || typeof value !== 'string' || value.length > 250 || /[;{}<>\\]|url\s*\(/i.test(value)) continue;
|
|
12
|
+
document.documentElement.style.setProperty(`--p60s-${key}`, value);
|
|
13
|
+
}
|
|
14
|
+
document.querySelectorAll('[data-designer-font]').forEach(link => link.remove());
|
|
15
|
+
for (const href of [...new Set(Array.isArray(event.data.fonts) ? event.data.fonts : [])].slice(0, 4)) {
|
|
16
|
+
try {
|
|
17
|
+
const url = new URL(href);
|
|
18
|
+
if (url.origin !== 'https://fonts.bunny.net' || url.pathname !== '/css2' || url.username || url.password) continue;
|
|
19
|
+
const link = document.createElement('link');
|
|
20
|
+
link.rel = 'stylesheet'; link.href = url.href; link.dataset.designerFont = '';
|
|
21
|
+
document.head.append(link);
|
|
22
|
+
} catch { /* Unknown font sources are not loaded. */ }
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
ready();
|
|
26
|
+
})();
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { JSDOM } from 'jsdom';
|
|
2
|
+
|
|
3
|
+
// Split selector lists and var() arguments without splitting nested functions or strings.
|
|
4
|
+
function splitList(value) {
|
|
5
|
+
const parts = []; let start = 0, depth = 0, quote = '';
|
|
6
|
+
for (let i = 0; i < value.length; i++) {
|
|
7
|
+
const char = value[i];
|
|
8
|
+
if (char === '\\') { i++; continue; }
|
|
9
|
+
if (quote) { if (char === quote) quote = ''; continue; }
|
|
10
|
+
if (char === '"' || char === "'") { quote = char; continue; }
|
|
11
|
+
if (char === '(' || char === '[') depth++;
|
|
12
|
+
else if (char === ')' || char === ']') depth--;
|
|
13
|
+
else if (char === ',' && depth === 0) { parts.push(value.slice(start, i).trim()); start = i + 1; }
|
|
14
|
+
}
|
|
15
|
+
parts.push(value.slice(start).trim()); return parts;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Resolve the actual authored body palette, including inherited tokens and nested fallbacks. */
|
|
19
|
+
export function designerPalette(document) {
|
|
20
|
+
const page = new JSDOM('<!doctype html><html><head></head><body></body></html>');
|
|
21
|
+
const doc = page.window.document;
|
|
22
|
+
try {
|
|
23
|
+
for (const [source, target] of [[document.documentElement, doc.documentElement], [document.body, doc.body]]) {
|
|
24
|
+
for (const attr of source.attributes) target.setAttribute(attr.name, attr.value);
|
|
25
|
+
}
|
|
26
|
+
const rules = [];
|
|
27
|
+
// Only unqualified page-level rules contribute to this desktop author palette.
|
|
28
|
+
// Separate matching selectors: JSDOM otherwise incorrectly applies :root's specificity
|
|
29
|
+
// to body in a selector list such as :root:root, body (Mosaic's shared defaults).
|
|
30
|
+
for (const sheet of document.styleSheets) for (const rule of sheet.cssRules) {
|
|
31
|
+
if (rule.type !== 1 || !rule.style.cssText.includes('--')) continue;
|
|
32
|
+
for (const selector of splitList(rule.selectorText)) {
|
|
33
|
+
if (selector.includes('::')) continue;
|
|
34
|
+
if (document.documentElement.matches(selector) || document.body.matches(selector)) rules.push(`${selector}{${rule.style.cssText}}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const style = doc.createElement('style'); style.textContent = rules.join('\n'); doc.head.append(style);
|
|
38
|
+
const computed = page.window.getComputedStyle(doc.body);
|
|
39
|
+
function resolve(value, seen = new Set()) {
|
|
40
|
+
const text = value.trim();
|
|
41
|
+
if (!text.startsWith('var(') || !text.endsWith(')')) return text;
|
|
42
|
+
const [key, ...fallback] = splitList(text.slice(4, -1));
|
|
43
|
+
if (!/^--[\w-]+$/.test(key) || seen.has(key)) return '';
|
|
44
|
+
const next = new Set(seen); next.add(key);
|
|
45
|
+
return resolve(computed.getPropertyValue(key), next) || resolve(fallback.join(','), next);
|
|
46
|
+
}
|
|
47
|
+
const colours = [];
|
|
48
|
+
for (const token of ['primary', 'bg', 'accent', 'gold', 'ink']) {
|
|
49
|
+
let colour = resolve(computed.getPropertyValue(`--${token}`)).toLowerCase();
|
|
50
|
+
if (/^#[\da-f]{3}$/.test(colour)) colour = `#${[...colour.slice(1)].map(c => c + c).join('')}`;
|
|
51
|
+
// Do not invent a colour when an author value cannot be resolved.
|
|
52
|
+
const probe = doc.createElement('span'); probe.style.color = colour;
|
|
53
|
+
if (colour && !colour.includes('var(') && probe.style.color && !colours.some(existing => {
|
|
54
|
+
const other = doc.createElement('span'); other.style.color = existing;
|
|
55
|
+
return other.style.color === probe.style.color;
|
|
56
|
+
})) colours.push(colour);
|
|
57
|
+
if (colours.length === 3) break;
|
|
58
|
+
}
|
|
59
|
+
if (colours.length < 2) throw new Error('Designer palette needs at least two resolved authored colours');
|
|
60
|
+
return colours;
|
|
61
|
+
} finally { page.window.close(); }
|
|
62
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { chromium } from 'playwright';
|
|
2
|
+
|
|
3
|
+
export const POSTER_WIDTH = 960;
|
|
4
|
+
export const POSTER_HEIGHT = 600;
|
|
5
|
+
export const MAX_POSTER_BYTES = 160 * 1024;
|
|
6
|
+
const ORIGIN = 'https://p60-preview.invalid';
|
|
7
|
+
const VIEWPORT = { width: 1440, height: 900 };
|
|
8
|
+
const fontCache = new Map();
|
|
9
|
+
|
|
10
|
+
// Gallery posters are a Latin-script design impression. Arabic font fidelity is deferred
|
|
11
|
+
// by product choice. Remove only the platform-only faces from the capture copy, never the
|
|
12
|
+
// published HTML, platform CSS or runtime template. This prevents broken /fonts requests.
|
|
13
|
+
export function galleryCaptureHtml(html) {
|
|
14
|
+
return html.replace(/@font-face\s*\{[^{}]*font-family:\s*'(?:KFGQPC HAFS Uthmanic Script|KFGQPC Nastaleeq|IBM Plex Sans Arabic)'[^{}]*\}/g, '');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Only sealed bundle assets and the preview's existing font host may be requested. */
|
|
18
|
+
export function posterResource(url, files) {
|
|
19
|
+
const parsed = new URL(url);
|
|
20
|
+
if (parsed.username || parsed.password || parsed.hash) return null;
|
|
21
|
+
if (parsed.origin === ORIGIN && !parsed.search) {
|
|
22
|
+
const path = parsed.pathname.slice(1);
|
|
23
|
+
if (path.startsWith('preview/') && Object.hasOwn(files, path)) return { file: files[path] };
|
|
24
|
+
}
|
|
25
|
+
if (parsed.origin === 'https://fonts.bunny.net' && (
|
|
26
|
+
/^\/css2?$/.test(parsed.pathname) || /^\/[a-zA-Z0-9_./-]+\.woff2?$/.test(parsed.pathname)
|
|
27
|
+
)) return { font: parsed.href };
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One browser per release; one isolated, script-disabled context per Look. */
|
|
32
|
+
export async function createPosterRenderer(files) {
|
|
33
|
+
let browser;
|
|
34
|
+
try { browser = await chromium.launch({ headless: true, chromiumSandbox: true }); }
|
|
35
|
+
catch (cause) {
|
|
36
|
+
throw new Error('Gallery rendering needs the kit browser. Run p60-template-kit setup-previews (CI: add --with-deps).', { cause });
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
close: () => browser.close(),
|
|
40
|
+
async render(path) {
|
|
41
|
+
const context = await browser.newContext({ viewport: VIEWPORT, deviceScaleFactor: 1,
|
|
42
|
+
colorScheme: 'light', reducedMotion: 'reduce', locale: 'en-GB', timezoneId: 'UTC',
|
|
43
|
+
javaScriptEnabled: false, serviceWorkers: 'block' });
|
|
44
|
+
const failures = new Set();
|
|
45
|
+
const deadline = setTimeout(() => { void context.close(); }, 45000);
|
|
46
|
+
try {
|
|
47
|
+
await context.route('**/*', async route => {
|
|
48
|
+
const resource = posterResource(route.request().url(), files);
|
|
49
|
+
try {
|
|
50
|
+
if (resource?.file) {
|
|
51
|
+
const body = resource.file.contentType.startsWith('text/html')
|
|
52
|
+
? galleryCaptureHtml(resource.file.bytes.toString()) : resource.file.bytes;
|
|
53
|
+
await route.fulfill({ status: 200, contentType: resource.file.contentType, body });
|
|
54
|
+
} else if (resource?.font) {
|
|
55
|
+
let font = fontCache.get(resource.font);
|
|
56
|
+
if (!font) {
|
|
57
|
+
const response = await route.fetch({ timeout: 15000, maxRedirects: 0 });
|
|
58
|
+
try {
|
|
59
|
+
const body = await response.body();
|
|
60
|
+
if (!response.ok() || body.length > 2 * 1024 * 1024) throw new Error('Font unavailable or too large');
|
|
61
|
+
font = { body, contentType: response.headers()['content-type'] };
|
|
62
|
+
if (fontCache.size >= 128) fontCache.delete(fontCache.keys().next().value);
|
|
63
|
+
fontCache.set(resource.font, font);
|
|
64
|
+
} finally { await response.dispose(); }
|
|
65
|
+
}
|
|
66
|
+
await route.fulfill({ status: 200, ...font, headers: { 'access-control-allow-origin': '*' } });
|
|
67
|
+
} else {
|
|
68
|
+
failures.add('A preview requested an asset outside the sealed bundle/font host');
|
|
69
|
+
await route.abort('blockedbyclient');
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
failures.add(`Could not load preview asset: ${route.request().url()}`);
|
|
73
|
+
await route.abort('failed').catch(() => {});
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
const page = await context.newPage();
|
|
77
|
+
page.setDefaultTimeout(20000);
|
|
78
|
+
page.on('requestfailed', request => failures.add(`Could not load preview asset: ${request.url()}`));
|
|
79
|
+
await page.goto(`${ORIGIN}/preview/${path}`, { waitUntil: 'load', timeout: 30000 });
|
|
80
|
+
await page.evaluate(async () => {
|
|
81
|
+
await document.fonts.ready;
|
|
82
|
+
const visible = [...document.images].filter(image => {
|
|
83
|
+
const bounds = image.getBoundingClientRect();
|
|
84
|
+
return bounds.bottom > 0 && bounds.top < innerHeight && bounds.right > 0 && bounds.left < innerWidth;
|
|
85
|
+
});
|
|
86
|
+
await Promise.all(visible.map(image => image.decode()));
|
|
87
|
+
const errors = [...document.fonts].filter(font => font.status === 'error').map(font => font.family);
|
|
88
|
+
if (errors.length) throw new Error(`Preview font failed: ${errors.join(', ')}`);
|
|
89
|
+
});
|
|
90
|
+
if (failures.size) throw new Error([...failures].join('; '));
|
|
91
|
+
const session = await context.newCDPSession(page);
|
|
92
|
+
for (const quality of [82, 70, 58]) {
|
|
93
|
+
// Chromium encodes WebP directly: no separate image library or full-page screenshot.
|
|
94
|
+
const { data } = await session.send('Page.captureScreenshot', { format: 'webp', quality,
|
|
95
|
+
captureBeyondViewport: false, clip: { x: 0, y: 0, ...VIEWPORT, scale: POSTER_WIDTH / VIEWPORT.width } });
|
|
96
|
+
const bytes = Buffer.from(data, 'base64');
|
|
97
|
+
if (bytes.length <= MAX_POSTER_BYTES) return bytes;
|
|
98
|
+
}
|
|
99
|
+
throw new Error('Gallery poster exceeds 160 KiB; simplify the above-the-fold designer imagery');
|
|
100
|
+
} catch (cause) {
|
|
101
|
+
throw new Error(`Could not render gallery poster for ${path}: ${cause.message}; ${[...failures].join('; ')}`, { cause });
|
|
102
|
+
} finally { clearTimeout(deadline); await context.close(); }
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -16,7 +16,7 @@ export function surfaceFor(rawUrl) {
|
|
|
16
16
|
if (path.startsWith('/articles/')) return 'article';
|
|
17
17
|
if (path === '/campaigns') return 'campaigns';
|
|
18
18
|
if (path.startsWith('/campaigns/')) return 'campaign';
|
|
19
|
-
if (path === '/courses') return 'course';
|
|
19
|
+
if (path === '/courses') return url.searchParams.has('course') ? 'course' : 'courses';
|
|
20
20
|
return 'home';
|
|
21
21
|
}
|
|
22
22
|
|