@port60/template-kit 0.21.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +58 -3
  2. package/bin/cli.mjs +10 -0
  3. package/package.json +4 -2
  4. package/src/commands/content.mjs +4 -3
  5. package/src/commands/create.mjs +11 -3
  6. package/src/commands/dev.mjs +9 -3
  7. package/src/commands/model.mjs +3 -3
  8. package/src/commands/packageCmd.mjs +1 -1
  9. package/src/commands/publish.mjs +1 -1
  10. package/src/commands/refresh.mjs +8 -3
  11. package/src/commands/release.mjs +22 -0
  12. package/src/commands/setupPreviews.mjs +19 -0
  13. package/src/commands/validate.mjs +1 -1
  14. package/src/lib/agentsMd.mjs +116 -161
  15. package/src/lib/designer-bridge.js +26 -0
  16. package/src/lib/designerPalette.mjs +62 -0
  17. package/src/lib/galleryPosters.mjs +105 -0
  18. package/src/lib/previewOptions.mjs +1 -1
  19. package/src/lib/releaseBundle.mjs +141 -0
  20. package/src/vendor/contract/v2/behaviours.json +346 -0
  21. package/src/vendor/contract/v2/content-bounds.json +117 -0
  22. package/src/vendor/contract/v2/content-model.json +766 -0
  23. package/src/vendor/contract/v2/context.json +1569 -0
  24. package/src/vendor/contract/v2/dialect.json +107 -0
  25. package/src/vendor/contract/v2/fonts.json +910 -0
  26. package/src/vendor/contract/v2/imagery.json +32 -0
  27. package/src/vendor/contract/v2/islands.json +263 -0
  28. package/src/vendor/contract/v2/layout.json +28 -0
  29. package/src/vendor/contract/v2/manifest.schema.json +388 -0
  30. package/src/vendor/contract/v2/sections.json +947 -0
  31. package/src/vendor/contract/v2/site.schema.json +1353 -0
  32. package/src/vendor/contract/v2/tokens.json +101 -0
  33. package/src/vendor/contract/v2.lock.json +6933 -0
  34. package/src/vendor/engine/content-footprint.mjs +76 -1
  35. package/src/vendor/engine/dialect.mjs +4 -0
  36. package/src/vendor/engine/locale.mjs +74 -0
  37. package/src/vendor/engine/majors.mjs +42 -0
  38. package/src/vendor/validator/gift-aid-logo.svg +6 -0
  39. package/src/vendor/validator/model-reference-v2.mjs +95 -0
  40. package/src/vendor/validator/platform-base.css +320 -11
  41. package/src/vendor/validator/preview-v1.mjs +691 -0
  42. package/src/vendor/validator/preview-v2.mjs +654 -0
  43. package/src/vendor/validator/preview.mjs +8 -688
  44. package/src/vendor/validator/site-context-v2.mjs +113 -0
  45. package/src/vendor/validator/validate-v1.mjs +666 -0
  46. package/src/vendor/validator/validate-v2.mjs +642 -0
  47. package/src/vendor/validator/validate.mjs +21 -663
  48. package/starter/assets/theme.css +3 -0
  49. package/starter/layout.liquid +15 -18
  50. package/starter/manifest.json +5 -4
  51. package/starter/preview/config.json +4 -0
  52. package/starter/preview/media/README.md +9 -0
  53. package/starter/sections/campaigns.liquid +5 -4
  54. package/starter/sections/homeHero.liquid +25 -21
  55. package/starter/sections/values.liquid +1 -1
@@ -1,167 +1,122 @@
1
- // The scaffold's AI-agent briefing (T3, user requirement: the kit must let people put an AI
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.
1
+ import { readFileSync } from 'node:fs';
5
2
 
3
+ const KIT_VERSION = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
4
+
5
+ // A contract briefing shared by every generated coding-agent instruction file.
6
6
  export function agentsMd(name) {
7
7
  return `# Working on the "${name}" Port60 template
8
8
 
9
- You are working on a **Port60 site template**, a small, versioned artifact of Liquid renderers
10
- and CSS that a charity's site is rendered through. It contains **no application code**: no
11
- JavaScript, no API calls, no payment logic. Templates decide how a site *looks*; the platform
12
- owns what it *does*.
13
-
14
- ## The iteration loop (use this constantly)
15
-
16
- \`\`\`bash
17
- npm run validate # human-readable conformance check
18
- npm run validate:json # machine-readable: {ok, errors[], warnings[], provenSupports}
19
- npm run dev # local preview at http://localhost:4400 (re-renders on refresh)
20
- npm run package # validate + produce the uploadable <name>-<version>.zip
21
- \`\`\`
22
-
23
- **After every meaningful edit, run \`npm run validate:json\` and fix every error before moving
24
- on.** The validator is the exact code the platform runs at upload, if it passes here, the
25
- platform accepts it; if it fails here, the upload will fail identically.
26
-
27
- ## The file layout (nothing else is accepted)
28
-
29
- - \`manifest.json\`, identity + what you support. \`name\` and \`version\` are immutable
30
- identity; bump \`version\` (semver) for every published change.
31
- - \`layout.liquid\`, the page chrome (header/nav/footer). Must contain **exactly one**
32
- \`{% content %}\` slot. Only needed when \`supports.layout\` is true.
33
- - \`sections/<type>.liquid\`, one renderer per section type you declare in
34
- \`supports.sections\`. Charities compose pages from a **closed catalogue** of section types
35
- (see \`npm run validate\` output or the docs), you cannot invent new types.
36
- - \`pages/<page>.liquid\`, optional full-page templates for \`supports.pageTemplates\`.
37
- - \`assets/theme.css\`, required, your entire look, and the ONLY stylesheet the platform loads
38
- (other \`.css\` files under \`assets/\` are packaged but never loaded, so keep everything in
39
- it). **No images, no fonts, no JS**: \`package\` and \`publish\` leave them out and list what
40
- they left out; the upload refuses them. Photographs belong in the charity's media library;
41
- decorative textures go inline in the CSS as data URIs.
42
-
43
- ## The rules the validator enforces (do not fight them)
44
-
45
- 1. **Escape-by-default.** Every output is HTML-escaped unless you use \`| raw\`, and the only
46
- values you may pass through raw are the contract's sanitised richtext fields.
47
- 2. **The dialect is a whitelist.** \`{% include %}\`, \`{% render %}\`, \`{% layout %}\` and
48
- several other tags are excluded and fail at parse. Unknown filters throw.
49
- 3. **Islands are placed, never implemented.** Live functionality (donations, sign-in, events) is
50
- \`{% island 'donation_widget' %}\` etc. Every island you place must be declared in
51
- \`supports.islands\` and exist in the platform registry. Style them via their stable class
52
- API; never reimplement them.
53
- 4. **Declared ⇒ rendered, rendered ⇒ declared.** Supports flags are PROVEN behaviourally: if you
54
- declare \`supports.worship\` the layout must actually render the worship fixture's times, must
55
- hide the rail when \`worship\` is null, and rendering it undeclared is equally an error. The
56
- same honesty applies across the contract.
57
- 5. **Render budgets are real.** Runaway loops are killed (~1s per render). Keep renderers simple.
58
- 6. **Context is a whitelist.** Sections see \`{section, brand}\` plus only the collection named by
59
- that section in the contract; layouts see \`{brand, nav, socials, worship, locale}\`; page
60
- templates see their documented fixture + \`brand\`. Nothing else exists, do not invent
61
- variables.
62
- 7. **Capabilities are matching metadata, never entitlements.** Every value in
63
- \`requiresCapabilities\` must have a declared section, page template or island that presents it.
64
- \`suitsProfiles\` describes design intent and changes catalogue ordering only.
65
-
66
- ## What each declaration owns
67
-
68
- - \`supports.layout\` owns the visible header, navigation and footer around platform pages. The
69
- platform still owns the document head, consent and identity.
70
- - \`supports.pages\` owns section based bodies for \`home\` and \`about\` through the declared
71
- renderers in \`sections/\`.
72
- - \`compositions\` owns each page's preferred order: the section types the design is built around,
73
- each \`core\`, \`recommended\` or \`optional\`. An organisation may reorder, add or remove;
74
- removing a core section warns them, it never stops them.
75
- - \`supports.pageTemplates: ["events"]\` owns the events listing only. Event details, RSVP and
76
- ticket purchase remain platform owned.
77
- - \`supports.pageTemplates: ["course"]\` owns a course detail presentation only. The course
78
- listing stays platform owned and enrolment remains the \`course_enrol\` island.
79
- - \`supports.pageTemplates: ["articles"]\` owns the article front page and archive listings.
80
- - \`supports.pageTemplates: ["article"]\` owns article detail presentation; engagement and
81
- comments stay the \`article_engagement\` and \`article_comments\` islands.
82
- - Other platform routes keep their platform body and render inside your layout. Capability flags
83
- expose documented optional context; they do not transfer transaction or route ownership.
84
-
85
- ## The content model
86
-
87
- Everything you read comes from ONE tree: \`site\`, \`site.brand\`, \`site.nav\`,
88
- \`site.socials\`, \`site.locale\` and the typed collections under \`site.content.*\`
89
- (services, events, articles, campaigns, causes, courses, resources, locations,
90
- schedules, about). Four rules it never breaks, so neither should you:
91
-
92
- - Every collection is BOUNDED (a documented cap plus a \`moreHref\`), link onward, never
93
- assume you have everything.
94
- - Enum fields are OPEN, branch on the values you style and fall back for the rest; the
95
- validator proves your template survives values it has never seen.
96
- - Optional fields are explicitly nullable, always branch.
97
- - The model only grows. The validator computes your content footprint from the paths you read
98
- and stamps the minimum model version at publish; you never declare versions, and dynamic
99
- indexing into \`site.content\` is refused so that stays decidable.
100
-
101
- SEE the model: \`npm run dev\` serves the full reference with live example data at \`/model\`;
102
- \`npx p60-template-kit model --json\` prints the machine-readable registry; the same reference
103
- lives at https://developers.port60.com/reference/content-model/.
104
-
105
- Bring your own content and imagery: \`npx p60-template-kit content .\` ejects every collection,
106
- fully populated, as an editable JSON file; replace the copy and the imageUrl values, then
107
- \`npx p60-template-kit dev . --content my-org.json\` renders YOUR data (the preview admits
108
- exactly the image hosts your file names, nothing else).
109
-
110
- You may replace the preview DATA with your own via \`preview-content.json\` beside the
111
- manifest ({ collection: [items] }, schema-checked, hot-reloaded). The shape is fixed, packaging
112
- excludes it, and conformance proofs always run on the canonical fixtures.
113
-
114
- ## What to build with
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
9
+ This is a Liquid and CSS artifact, not an application. The platform owns public eligibility,
10
+ routes, consent, authentication, payments and interactive islands. Preserve the design's visual
11
+ identity, authored content and inline editing markers while changing presentation.
12
+
13
+ ## Versions and iteration
14
+
15
+ Use format port60-liquid@2, content model 2.0 and kit ${KIT_VERSION}. Existing v1 platform pins retain
16
+ their historical contract; this kit explicitly rejects v1 for new authoring and uploads.
17
+ Never relabel v1 without migrating its reads. Published name/version identities are immutable.
18
+
19
+ - npm run validate:json is the machine-readable feedback loop. Fix all errors after each edit.
20
+ - npm run validate checks the same contract as upload.
21
+ - npm run dev previews all supported pages locally.
22
+ - npm run package validates and writes the uploadable zip.
23
+ - npm run release builds a store release with separate template/ and preview/ bundles.
24
+ - p60-template-kit setup-previews installs the pinned build browser once (CI: --with-deps).
25
+ - Check all Looks, empty states, long text and mobile layouts. Validation is not visual QA.
26
+
27
+ ## Artifact shape
28
+
29
+ manifest.json declares support. layout.liquid has exactly one {% content %} slot.
30
+ sections/<type>.liquid implements catalogued types; pages/<page>.liquid implements declared
31
+ page templates. assets/theme.css is the only loaded stylesheet. No JavaScript, fonts, API calls,
32
+ remote CSS imports or image files belong in the runtime artifact. preview/ contains independent
33
+ author-demo inputs. Its config.json names a content JSON file and optional widget focus. Put
34
+ author JPEG/PNG/WebP imagery in preview/media/ and use p60preview:filename references in that
35
+ content. The release builder seals every Look and publishes that imagery only in the separate
36
+ preview/ bundle. Runtime package/publish ZIPs never contain it. Studio preview-bundle intake is
37
+ separate from the first-party store release lane. Use platform media URLs in runtime content.
38
+
39
+ Release automatically captures each Look as a 960x600 WebP under preview/gallery/, from a
40
+ 1440x900 desktop render. Do not author separate screenshots. Posters have a 160 KiB cap;
41
+ HTML, images and metadata remain beside them. The gallery loads posters; details load HTML.
42
+ The build needs Chromium plus access to fonts.bunny.net, and refuses failed required assets.
43
+ Use the same kit/browser/OS for immutable-upload retries; bump the version for changed output.
44
+ Arabic-specific poster font fidelity is deferred, not proof of Arabic-locale conformance.
45
+
46
+ ## The only public site tree
47
+
48
+ Read site.brand, site.nav, site.socials, site.locale, site.actions, site.page and site.content.
49
+ No flat brand/nav/collection aliases or site.focus exist. A section also receives section, its
50
+ current instance's raw authored content. Article/course details retain their documented record
51
+ context. impactMap receives the selected map with its contained points, never root locations.
52
+
53
+ services/events/articles/campaigns/causes/courses/documents are envelopes:
54
+ {label, href, items, pagination}. Iterate site.content.events.items, not the envelope.
55
+ Documents href can be null. Pagination is null outside listings, otherwise it carries page,
56
+ size, totalElements, totalPages, nextHref and previousHref. Use supplied URLs, not guessed routes.
57
+ Only site.content.schedules stays an array. Lists are bounded; enum values are open, so always
58
+ include fallbacks. Nullable values need guards. Metadata-only label/href/pagination reads do
59
+ not fetch items, but whole-envelope aliases do. Dynamic indexing of the site tree is refused.
60
+
61
+ The current render is site.page = {key, path, sections:[{key,type,content}]}.
62
+ Repeated section types have independent stable keys. Canonical section types include services,
63
+ courses, events and documents. No programmes, whatsOn, infoEvents, resources, locations or
64
+ content.about public aliases exist. Documents remain selected existing public records, never
65
+ an automatically exposed media library. Section support does not confer source entitlements.
66
+
67
+ ## Authored ownership and clearing
68
+
69
+ For collection introductions, absent section.title inherits site.content.<collection>.label;
70
+ an explicit empty string hides it; other text overrides it. subtitle and eyebrow are authored.
71
+ Use nil checks, not Liquid default, wherever clearing has meaning. Keep generated labels out
72
+ of raw section content. Mark an authored title only in the nonempty override branch. An inherited
73
+ heading has no data-p60-field marker.
74
+
75
+ Declare supports.fieldMarkers:true when showing authored field markers. A marker such as
76
+ data-p60-field="title" or data-p60-field="items.{{ forloop.index0 }}.label" addresses only that
77
+ section's content. Its node must contain exactly the authored value. Use a span when punctuation
78
+ or generated text surrounds it. Never mark source records, generated labels or resolved actions.
79
+
80
+ ## Navigation and actions
81
+
82
+ site.nav.header and site.nav.footer are independent arrays. kind link has href; kind group has
83
+ null href and children. Use disclosure controls for groups, not fake links. Render two child
84
+ levels and preserve description, imageUrl and optional megaMenu.promo. No derived menus, CTA
85
+ flags or generated columns exist. The template owns responsive menu layout.
86
+ supports.navigationHighlights:true must render supplied promos, including text-only cards,
87
+ without losing normal links. The nav behaviour alone never enables this feature.
88
+ site.actions.header and site.actions.hero are resolved actions or null. site.actions.widget is
89
+ donate, volunteer or none. Do not infer actions from navigation. Authored hero override text
90
+ retains its field marker; resolved fallback actions do not.
91
+
92
+ ## Safety and design
93
+
94
+ - Output is escaped. Use raw only for contract-sanitised richtext.
95
+ - The dialect is whitelisted. include/render/layout and unknown filters are rejected.
96
+ - Place declared islands with {% island 'donation_widget' %}. Style their stable API and never
97
+ recreate transactions, API calls, forms, identity or consent logic.
98
+ - Render collection envelopes with template markup and declared behaviours. The historical
99
+ events_carousel, whats_on_strip and latest_articles islands are v1-only and rejected by v2.
100
+ Collection route continuation belongs to the host, not a second template data fetch or pager.
101
+ - Declare only what you render, and render what you declare. Capability matching is metadata,
102
+ not a transfer of route ownership or tenant entitlements.
103
+ - Hero photos need a palette scrim, a carousel for multiple photos and a designed no-photo state.
104
+ - Preserve settings and Looks. Use platform fonts and declared CSS tokens.
105
+ - The host owns lang/dir and Arabic fonts. Use logical CSS properties. Read site.locale.code.
106
+ - The next coordinated kit release adds t for supported interface phrases and local_date for
107
+ fixed Gregorian date/datetime formatting in UTC. Do not use these on registry kit 1.0.0.
108
+ Never translate authored text, infer prayer identities from translated names or convert
109
+ supplied prayer wall-clock strings. Details: /guides/localisation/ in the developer docs.
110
+ - Scope behaviour-dependent hidden content under .p60-js so no-JavaScript stays readable.
111
+ - Keep loops bounded. Test empty collections, cleared text and unknown enum values.
112
+
113
+ ## Data and reference
114
+
115
+ p60-template-kit content . writes editable envelope fixtures, independent header/footer menus
116
+ and keyed page compositions. p60-template-kit dev . --content my.json previews those fixtures.
117
+ Overrides are schema-checked, never packaged and never replace canonical conformance fixtures.
118
+ The dev server /model shows live values beside the registry. p60-template-kit model --json
119
+ prints the current model. Generated reference: https://developers.port60.com/reference/content-model/
120
+ Full agent reference: https://developers.port60.com/llms-full.txt
166
121
  `;
167
122
  }
@@ -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