@port60/template-kit 0.20.4 → 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 -155
- 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/v1/context.json +1 -1
- package/src/vendor/contract/v1/manifest.schema.json +6 -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 +359 -2
- 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 -621
- package/starter/assets/theme.css +3 -0
- package/starter/layout.liquid +15 -18
- package/starter/manifest.json +6 -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/cta.liquid +2 -2
- package/starter/sections/hero.liquid +9 -2
- package/starter/sections/homeHero.liquid +28 -24
- package/starter/sections/people.liquid +7 -6
- package/starter/sections/values.liquid +3 -3
|
@@ -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
|
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { readFileSync, existsSync, realpathSync, lstatSync } from 'node:fs';
|
|
2
|
+
import { join, relative, resolve } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { JSDOM } from 'jsdom';
|
|
5
|
+
import { loadArtifactDir } from './artifactFiles.mjs';
|
|
6
|
+
import { designerPalette } from './designerPalette.mjs';
|
|
7
|
+
import { createPosterRenderer, POSTER_WIDTH, POSTER_HEIGHT } from './galleryPosters.mjs';
|
|
8
|
+
import { validateNewArtifact } from '../vendor/validator/validate.mjs';
|
|
9
|
+
import { renderStudioPreview } from '../vendor/validator/preview-v2.mjs';
|
|
10
|
+
import { composePage, validatePreviewContent } from '../vendor/validator/site-context-v2.mjs';
|
|
11
|
+
|
|
12
|
+
export const digest = value => createHash('sha256').update(value).digest('hex');
|
|
13
|
+
export const artifactDigest = files => digest(JSON.stringify(Object.entries(files).sort(([a], [b]) => a.localeCompare(b))));
|
|
14
|
+
const MAX_MEDIA = 2 * 1024 * 1024;
|
|
15
|
+
const MAX_BUNDLE = 24 * 1024 * 1024;
|
|
16
|
+
const MAX_HTML = 2 * 1024 * 1024;
|
|
17
|
+
const SAFE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*\.(jpg|jpeg|png|webp)$/;
|
|
18
|
+
|
|
19
|
+
function ownedFile(root, path) {
|
|
20
|
+
const candidate = resolve(root, path);
|
|
21
|
+
const rel = relative(root, candidate);
|
|
22
|
+
if (!rel || rel.startsWith('..') || rel.includes('\\')) throw new Error(`Preview path outside template: ${path}`);
|
|
23
|
+
let part = root;
|
|
24
|
+
for (const segment of rel.split('/')) {
|
|
25
|
+
part = join(part, segment);
|
|
26
|
+
if (lstatSync(part).isSymbolicLink()) throw new Error(`Linked preview input refused: ${path}`);
|
|
27
|
+
}
|
|
28
|
+
if (!lstatSync(candidate).isFile()) throw new Error(`Preview input is not a file: ${path}`);
|
|
29
|
+
return candidate;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function imageType(bytes, filename) {
|
|
33
|
+
if (/\.jpe?g$/i.test(filename) && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return 'image/jpeg';
|
|
34
|
+
if (/\.png$/i.test(filename) && bytes.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10]))) return 'image/png';
|
|
35
|
+
if (/\.webp$/i.test(filename) && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP') return 'image/webp';
|
|
36
|
+
throw new Error(`Unsupported or mislabelled preview image: ${filename}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Builds two isolated file sets. Runtime sources are never expanded with demo media. */
|
|
40
|
+
export async function buildReleaseBundle(directory) {
|
|
41
|
+
const root = realpathSync(directory);
|
|
42
|
+
const runtime = loadArtifactDir(root);
|
|
43
|
+
const { errors, manifest } = await validateNewArtifact(runtime);
|
|
44
|
+
if (errors.length) throw new Error(errors.join('\n'));
|
|
45
|
+
if (!/^[a-z][a-z0-9-]{1,48}[a-z0-9]$/.test(manifest.name) || !/^\d+\.\d+\.\d+$/.test(manifest.version)) throw new Error('Store releases require a safe name and a stable version');
|
|
46
|
+
const config = JSON.parse(readFileSync(ownedFile(root, 'preview/config.json'), 'utf8'));
|
|
47
|
+
if (config.schemaVersion !== 1 || Object.keys(config).some(key => !['schemaVersion', 'content', 'focus'].includes(key))) throw new Error('Unsupported preview/config.json');
|
|
48
|
+
if (!['none', 'donate', 'volunteer'].includes(config.focus ?? 'none')) throw new Error('Unknown preview focus');
|
|
49
|
+
const content = config.content ? JSON.parse(readFileSync(ownedFile(root, `preview/${config.content}`), 'utf8')) : { pages: { home: composePage(manifest, 'home') } };
|
|
50
|
+
const problems = validatePreviewContent(content);
|
|
51
|
+
if (problems.length) throw new Error(problems.join('\n'));
|
|
52
|
+
const files = {};
|
|
53
|
+
const add = (path, content, contentType) => {
|
|
54
|
+
const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content);
|
|
55
|
+
files[path] = { bytes, contentType, sha256: digest(bytes) };
|
|
56
|
+
};
|
|
57
|
+
for (const [path, text] of Object.entries(runtime)) add(`template/${path}`, text, path.endsWith('.json') ? 'application/json' : path.endsWith('.css') ? 'text/css' : 'text/plain; charset=utf-8');
|
|
58
|
+
const images = new Map();
|
|
59
|
+
function localImage(value) {
|
|
60
|
+
if (typeof value !== 'string' || !value.startsWith('p60preview:')) return value;
|
|
61
|
+
const name = value.slice('p60preview:'.length);
|
|
62
|
+
if (!SAFE_NAME.test(name)) throw new Error(`Unsafe preview image: ${name}`);
|
|
63
|
+
if (images.has(value)) return images.get(value);
|
|
64
|
+
const bytes = readFileSync(ownedFile(root, `preview/media/${name}`));
|
|
65
|
+
if (bytes.length > MAX_MEDIA) throw new Error(`Preview image exceeds 2 MiB: ${name}`);
|
|
66
|
+
const type = imageType(bytes, name);
|
|
67
|
+
const path = `media/${digest(bytes).slice(0, 16)}-${name}`;
|
|
68
|
+
add(`preview/${path}`, bytes, type);
|
|
69
|
+
images.set(value, `../${path}`);
|
|
70
|
+
return `../${path}`;
|
|
71
|
+
}
|
|
72
|
+
function mapContent(value, key = '') {
|
|
73
|
+
if (Array.isArray(value)) return value.map(item => mapContent(item));
|
|
74
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, mapContent(v, k)]));
|
|
75
|
+
if (/^(imageUrl|logoUrl|footerLogoUrl)$/.test(key) && typeof value === 'string' && value && !value.startsWith('p60fixture:')) {
|
|
76
|
+
if (!value.startsWith('p60preview:')) throw new Error(`Use p60preview:filename for author imagery, not ${value.slice(0, 80)}`);
|
|
77
|
+
return localImage(value);
|
|
78
|
+
}
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
const authorContent = mapContent(content);
|
|
82
|
+
const artifactHash = artifactDigest(runtime);
|
|
83
|
+
const demo = { format: 'port60-designer-preview@1', name: manifest.name, version: manifest.version, label: manifest.label, artifactHash, looks: [] };
|
|
84
|
+
const bridge = readFileSync(new URL('./designer-bridge.js', import.meta.url), 'utf8');
|
|
85
|
+
const bridgeHash = createHash('sha256').update(bridge).digest('base64');
|
|
86
|
+
const looks = manifest.looks?.length ? manifest.looks : [{ name: 'Default', values: {} }];
|
|
87
|
+
if (looks.length > 24) throw new Error('At most 24 preview Looks per release');
|
|
88
|
+
for (const [index, look] of looks.entries()) {
|
|
89
|
+
const html = await renderStudioPreview(runtime, { surface: 'home', previewContent: authorContent, knobs: { ...look.values, motion: 'Minimal' }, look: look.name, focus: config.focus ?? 'none', webfonts: true, localImages: true });
|
|
90
|
+
const dom = new JSDOM(html);
|
|
91
|
+
try {
|
|
92
|
+
const doc = dom.window.document;
|
|
93
|
+
doc.documentElement.classList.add('p60-js');
|
|
94
|
+
doc.querySelectorAll('[data-p60-carousel]').forEach(carousel => {
|
|
95
|
+
[...carousel.querySelectorAll('[data-p60-slide]')].filter(slide => slide.closest('[data-p60-carousel]') === carousel).forEach((slide, i) => slide.classList.toggle('is-active', i === 0));
|
|
96
|
+
});
|
|
97
|
+
doc.querySelectorAll('script, iframe, object, embed, base, .p60-preview-surfaces, .p60-preview-badge, .p60-preview-divider').forEach(node => node.remove());
|
|
98
|
+
// A design impression shows idle search, not the validator's open sample results.
|
|
99
|
+
doc.querySelectorAll('[data-p60-preview-island="search"] .site-search-results').forEach(node => node.remove());
|
|
100
|
+
doc.querySelectorAll('*').forEach(node => {
|
|
101
|
+
for (const attr of [...node.attributes]) if (/^on/i.test(attr.name)) node.removeAttribute(attr.name);
|
|
102
|
+
});
|
|
103
|
+
doc.querySelectorAll('a').forEach(link => { link.removeAttribute('href'); link.removeAttribute('target'); link.setAttribute('aria-disabled', 'true'); });
|
|
104
|
+
doc.querySelectorAll('input,select,textarea,button').forEach(control => { control.disabled = true; });
|
|
105
|
+
doc.querySelectorAll('form').forEach(form => { form.removeAttribute('action'); form.removeAttribute('method'); });
|
|
106
|
+
doc.querySelectorAll('img').forEach(img => {
|
|
107
|
+
const src = img.getAttribute('src') ?? '';
|
|
108
|
+
if (!src.startsWith('data:') && ![...images.values()].includes(src)) img.remove();
|
|
109
|
+
img.removeAttribute('srcset');
|
|
110
|
+
});
|
|
111
|
+
doc.querySelectorAll('link').forEach(link => {
|
|
112
|
+
if (link.getAttribute('rel') !== 'stylesheet' || !/^https:\/\/fonts\.bunny\.net\/css2?\?/.test(link.getAttribute('href') ?? '')) link.remove();
|
|
113
|
+
});
|
|
114
|
+
const style = doc.createElement('style');
|
|
115
|
+
style.textContent = `html{scroll-behavior:auto}.menu-panel,[data-p60-nav-menu]{display:none!important}[data-p60-reveal]{opacity:1!important;transform:none!important}*,*::before,*::after{animation:none!important;transition:none!important}.p60-preview-slide:not(:first-child){display:none}a[aria-disabled]{cursor:default}`;
|
|
116
|
+
doc.head.append(style);
|
|
117
|
+
const script = doc.createElement('script'); script.textContent = bridge; doc.body.append(script);
|
|
118
|
+
doc.querySelector('meta[http-equiv="Content-Security-Policy"]').content = `default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline' https://fonts.bunny.net; font-src https://fonts.bunny.net; script-src 'sha256-${bridgeHash}'; connect-src 'none'; form-action 'none'; base-uri 'none'; frame-src 'none'`;
|
|
119
|
+
const output = dom.serialize();
|
|
120
|
+
if (Buffer.byteLength(output) > MAX_HTML) throw new Error('Preview page exceeds 2 MiB');
|
|
121
|
+
const path = `looks/${index}-${digest(output).slice(0, 12)}.html`;
|
|
122
|
+
add(`preview/${path}`, output, 'text/html; charset=utf-8');
|
|
123
|
+
demo.looks.push({ name: look.name, path, values: look.values, colours: designerPalette(doc) });
|
|
124
|
+
} finally { dom.window.close(); }
|
|
125
|
+
}
|
|
126
|
+
const posters = await createPosterRenderer(files);
|
|
127
|
+
try {
|
|
128
|
+
for (const [index, look] of demo.looks.entries()) {
|
|
129
|
+
const bytes = await posters.render(look.path);
|
|
130
|
+
const path = `gallery/${index}-${digest(bytes).slice(0, 16)}.webp`;
|
|
131
|
+
add(`preview/${path}`, bytes, 'image/webp');
|
|
132
|
+
look.thumbnail = { path, width: POSTER_WIDTH, height: POSTER_HEIGHT };
|
|
133
|
+
}
|
|
134
|
+
} finally { await posters.close(); }
|
|
135
|
+
add('preview/manifest.json', JSON.stringify(demo, null, 2) + '\n', 'application/json');
|
|
136
|
+
const inventory = Object.entries(files).sort(([a], [b]) => a.localeCompare(b)).map(([path, file]) => ({ path, size: file.bytes.length, sha256: file.sha256, contentType: file.contentType }));
|
|
137
|
+
if (inventory.reduce((sum, file) => sum + file.size, 0) > MAX_BUNDLE) throw new Error('Release exceeds 24 MiB');
|
|
138
|
+
const release = { format: 'port60-template-release@1', name: manifest.name, version: manifest.version, artifactHash, files: inventory };
|
|
139
|
+
add('release.json', JSON.stringify(release, null, 2) + '\n', 'application/json');
|
|
140
|
+
return { manifest, demo, release, files };
|
|
141
|
+
}
|
|
@@ -591,7 +591,7 @@
|
|
|
591
591
|
"availableIn": [
|
|
592
592
|
"section:campaigns"
|
|
593
593
|
],
|
|
594
|
-
"description": "
|
|
594
|
+
"description": "The platform word for the campaigns collection (\"Campaigns\"), the section's heading fallback. It does not vary: a tenant's own word for a link lives in their menu.",
|
|
595
595
|
"deprecated": true
|
|
596
596
|
},
|
|
597
597
|
{
|
|
@@ -154,6 +154,12 @@
|
|
|
154
154
|
"default": false,
|
|
155
155
|
"description": "True when the layout displays the optional single site.nav.items[].megaMenu.promo card inside each expanded top-level menu. Requires supports.layout. The validator proves title, text, image and linked label render together; image-free and absent highlights must remain safe. The platform resolves linked courses, events and campaigns into the same promo fields as custom highlights. This flag does not enable a general mega menu or control column counts. Missing or false disables highlight editing for this exact template version."
|
|
156
156
|
},
|
|
157
|
+
"fieldMarkers": {
|
|
158
|
+
"type": "boolean",
|
|
159
|
+
"default": false,
|
|
160
|
+
"description": "True when the sections mark which node shows which field with data-p60-field, so the editor can put the caret on the page instead of in a side panel. The address is inside that section's own content: data-p60-field=\"title\", or data-p60-field=\"items.{{ forloop.index0 }}.label\" for an entry in a list. Purely additive: the attribute is markup, the contract is untouched, and a template that marks nothing keeps the section-level editor. The validator reads the marks from the source and warns on any that does not name a field of that section. Missing or false leaves this exact template version editing in the panel.",
|
|
161
|
+
"$comment": "Follows the navigationHighlights pattern: declared honesty, proven by the validator."
|
|
162
|
+
},
|
|
157
163
|
"heroImagery": {
|
|
158
164
|
"type": "boolean",
|
|
159
165
|
"default": false,
|