@port60/template-kit 0.20.3 → 0.20.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/lib/agentsMd.mjs +10 -0
- package/src/vendor/contract/v1/context.json +1 -1
- package/src/vendor/contract/v1/fonts.json +4 -2
- package/src/vendor/contract/v1/manifest.schema.json +27 -0
- package/src/vendor/contract/v1.lock.json +252 -0
- package/src/vendor/engine/budgets.mjs +1 -1
- package/src/vendor/engine/content-footprint.mjs +9 -9
- package/src/vendor/engine/dialect.mjs +4 -4
- package/src/vendor/validator/behaviors-runtime.js +1 -1
- package/src/vendor/validator/fixture-art.mjs +5 -5
- package/src/vendor/validator/fonts.mjs +13 -3
- package/src/vendor/validator/navigation-highlights.mjs +149 -0
- package/src/vendor/validator/platform-base.css +119 -86
- package/src/vendor/validator/preview.mjs +18 -18
- package/src/vendor/validator/site-context.mjs +18 -6
- package/src/vendor/validator/validate.mjs +83 -44
- package/starter/manifest.json +39 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// FIXTURE ART
|
|
1
|
+
// FIXTURE ART, the studio's sealed imagery. Fixtures reference imagery as `p60fixture:<kind>/<seed>`
|
|
2
2
|
// and this module resolves every reference before a render: by default to a deterministic inline-SVG
|
|
3
|
-
// scene (a data: URI, so the network-dead studio CSP renders it), or
|
|
4
|
-
// `imageBase
|
|
3
|
+
// scene (a data: URI, so the network-dead studio CSP renders it), or, when the caller passes an
|
|
4
|
+
// `imageBase`, to `<imageBase>/<kind>-<seed>.jpg`, the dev-richer half of the dev-richer /
|
|
5
5
|
// studio-sealed split (the same shape as the behaviour runtime: the kit's dev server may opt into
|
|
6
6
|
// the one platform CDN host; the studio document never fetches anything).
|
|
7
7
|
//
|
|
8
|
-
// The art is deliberately scenery, not labels: layered gradients, a horizon, soft shapes
|
|
8
|
+
// The art is deliberately scenery, not labels: layered gradients, a horizon, soft shapes, enough
|
|
9
9
|
// composition that cards, carousels and banners read like a real site rather than a wireframe.
|
|
10
10
|
// Deterministic by seed so goldens stay byte-identical run to run.
|
|
11
11
|
|
|
@@ -23,7 +23,7 @@ const PALETTES = {
|
|
|
23
23
|
|
|
24
24
|
const SIZES = { wide: [160, 90], photo: [150, 100], portrait: [96, 120] };
|
|
25
25
|
|
|
26
|
-
// A tiny deterministic PRNG seeded from the reference string
|
|
26
|
+
// A tiny deterministic PRNG seeded from the reference string, good enough for composition,
|
|
27
27
|
// stable across runs and platforms.
|
|
28
28
|
function rng(seedText) {
|
|
29
29
|
let h = 2166136261;
|
|
@@ -14,10 +14,19 @@ export function knownFamily(name) {
|
|
|
14
14
|
return typeof name === 'string' && byName.has(name) ? name : null;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
/** The
|
|
17
|
+
/** The platform's Arabic faces, the same rule and order as the engine's fonts.ts: the preview base
|
|
18
|
+
* stylesheet (a generated copy of global.css) declares each as a unicode-range @font-face, so every
|
|
19
|
+
* slot names them first unless the chosen family is itself an Arabic one. */
|
|
20
|
+
export const ARABIC_FACES = ['KFGQPC HAFS Uthmanic Script', 'IBM Plex Sans Arabic'];
|
|
21
|
+
const ARABIC_PREFIX = ARABIC_FACES.map((f) => `'${f}'`).join(', ');
|
|
22
|
+
|
|
23
|
+
/** The CSS font-family value for a slot: the platform Arabic faces (unless the family IS an Arabic
|
|
24
|
+
* one), the quoted family, then its honest generic fallback. */
|
|
18
25
|
export function fontStackFor(name) {
|
|
19
26
|
const family = byName.get(name);
|
|
20
|
-
|
|
27
|
+
if (!family) return `${ARABIC_PREFIX}, '${name}', sans-serif`;
|
|
28
|
+
const arabicFirst = family.category === 'arabic' ? '' : `${ARABIC_PREFIX}, `;
|
|
29
|
+
return `${arabicFirst}'${family.name}', ${family.stack}`;
|
|
21
30
|
}
|
|
22
31
|
|
|
23
32
|
function nearestWeight(target, available) {
|
|
@@ -35,7 +44,8 @@ export function fontCssHref(slots) {
|
|
|
35
44
|
const weightsByFamily = new Map();
|
|
36
45
|
for (const slot of slots) {
|
|
37
46
|
const family = byName.get(slot.family);
|
|
38
|
-
|
|
47
|
+
// A platform-hosted family is declared in global.css (served from /fonts/), never fetched.
|
|
48
|
+
if (!family || family.hosted) continue;
|
|
39
49
|
const set = weightsByFamily.get(family.name) ?? new Set();
|
|
40
50
|
const asked = slot.weights?.length > 0 ? slot.weights : [400, 700];
|
|
41
51
|
for (const weight of asked) set.add(nearestWeight(weight, family.weights));
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Structural render proof, not a CSS or accessibility audit. Menus may start closed and their
|
|
2
|
+
// responsive styling belongs to the author. Browser checks still establish visual usability.
|
|
3
|
+
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
|
4
|
+
const NON_CONTENT = new Set(['script', 'style', 'template', 'noscript']);
|
|
5
|
+
const CARDS = new Set(['aside', 'article', 'section', 'div', 'a']);
|
|
6
|
+
|
|
7
|
+
function parseMarkup(html) {
|
|
8
|
+
const root = { tag: 'root', attrs: {}, children: [], parent: null, ownText: '' };
|
|
9
|
+
const stack = [root];
|
|
10
|
+
const nodes = [];
|
|
11
|
+
for (const match of html.matchAll(/<!--[\s\S]*?-->|<(?:[^>"']|"[^"]*"|'[^']*')*>|[^<]+/g)) {
|
|
12
|
+
const token = match[0];
|
|
13
|
+
if (token.startsWith('<!--') || token.startsWith('<!')) continue;
|
|
14
|
+
const close = /^<\/\s*([\w:-]+)/.exec(token);
|
|
15
|
+
if (close) {
|
|
16
|
+
const index = stack.findLastIndex((node) => node.tag === close[1].toLowerCase());
|
|
17
|
+
if (index > 0) stack.length = index;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const open = /^<\s*([\w:-]+)/.exec(token);
|
|
21
|
+
if (!open) {
|
|
22
|
+
stack.at(-1).ownText += token;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const attrs = {};
|
|
26
|
+
for (const attribute of token.slice(open[0].length, -1).matchAll(/([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) {
|
|
27
|
+
attrs[attribute[1].toLowerCase()] = attribute[2] ?? attribute[3] ?? attribute[4] ?? '';
|
|
28
|
+
}
|
|
29
|
+
const node = { tag: open[1].toLowerCase(), attrs, children: [], parent: stack.at(-1), ownText: '' };
|
|
30
|
+
node.parent.children.push(node);
|
|
31
|
+
nodes.push(node);
|
|
32
|
+
if (!VOID.has(node.tag) && !token.endsWith('/>')) stack.push(node);
|
|
33
|
+
}
|
|
34
|
+
return { root, nodes };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isHidden(node) {
|
|
38
|
+
if (NON_CONTENT.has(node.tag)) return true;
|
|
39
|
+
// A closed navigation panel is expected. Hiding the card or its fields explicitly is not.
|
|
40
|
+
if ('data-p60-nav-menu' in node.attrs || 'data-p60-nav' in node.attrs) return false;
|
|
41
|
+
return 'hidden' in node.attrs || node.attrs['aria-hidden'] === 'true' ||
|
|
42
|
+
/(?:display\s*:\s*none|visibility\s*:\s*hidden|opacity\s*:\s*0(?:[;\s]|$))/i.test(node.attrs.style ?? '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function rendered(node) {
|
|
46
|
+
for (let current = node; current; current = current.parent) if (isHidden(current)) return false;
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function textOf(node) {
|
|
51
|
+
if (isHidden(node)) return '';
|
|
52
|
+
return `${node.ownText} ${node.children.map(textOf).join(' ')}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function descendants(node) {
|
|
56
|
+
return node.children.flatMap((child) => [child, ...descendants(child)]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function inNavigation(node) {
|
|
60
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
61
|
+
if (current.tag === 'nav' || current.attrs.role === 'navigation') return true;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function inspectNavigationHighlights(html, probes, { images = true } = {}) {
|
|
67
|
+
const { nodes, root } = parseMarkup(html);
|
|
68
|
+
const errors = [];
|
|
69
|
+
for (const probe of probes) {
|
|
70
|
+
const links = nodes.filter((node) => node.tag === 'a' && node.attrs.href === probe.promo.href && rendered(node));
|
|
71
|
+
if (links.length !== 1 || !textOf(links[0]).includes(probe.promo.label)) {
|
|
72
|
+
errors.push(`highlight '${probe.label}' must have exactly one visible link with the supplied href and label`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const card = nodes.find((node) => {
|
|
76
|
+
if (!CARDS.has(node.tag) || !rendered(node) || !inNavigation(node)) return false;
|
|
77
|
+
const text = textOf(node);
|
|
78
|
+
const inside = [node, ...descendants(node)];
|
|
79
|
+
return text.includes(probe.promo.title) && text.includes(probe.promo.text) &&
|
|
80
|
+
!text.includes(probe.childLabel) && inside.includes(links[0]) &&
|
|
81
|
+
(!images || inside.some((child) => child.tag === 'img' && child.attrs.src === probe.promo.imageUrl && rendered(child)));
|
|
82
|
+
});
|
|
83
|
+
if (!card) errors.push(`highlight '${probe.label}' must render its title, description, link and${images ? ' image' : ' optional image fallback'} together in a navigation card`);
|
|
84
|
+
if (card) {
|
|
85
|
+
let ownMenu = false;
|
|
86
|
+
for (let parent = card.parent; parent; parent = parent.parent) {
|
|
87
|
+
const text = textOf(parent);
|
|
88
|
+
if (text.includes(probe.label) && text.includes(probe.childLabel) &&
|
|
89
|
+
!probes.some((other) => other !== probe && text.includes(other.childLabel))) ownMenu = true;
|
|
90
|
+
}
|
|
91
|
+
if (!ownMenu) errors.push(`highlight '${probe.label}' must stay inside its own parent menu`);
|
|
92
|
+
}
|
|
93
|
+
if (!textOf(root).includes(probe.childLabel)) errors.push(`highlight '${probe.label}' must not replace the menu links`);
|
|
94
|
+
}
|
|
95
|
+
return errors;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const NAVIGATION_HIGHLIGHT_PROBES = [1, 2].map((index) => ({
|
|
99
|
+
label: `P60 highlight menu ${index}`,
|
|
100
|
+
href: `/p60-highlight-menu-${index}`,
|
|
101
|
+
childLabel: `P60 normal menu link ${index}`,
|
|
102
|
+
promo: {
|
|
103
|
+
title: `P60 highlight title ${index}`,
|
|
104
|
+
text: `P60 highlight summary ${index}`,
|
|
105
|
+
href: `/p60-highlight-destination-${index}`,
|
|
106
|
+
label: `P60 highlight action ${index}`,
|
|
107
|
+
imageUrl: `https://static.port60.com/p60-highlight-${index}.webp`
|
|
108
|
+
}
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
export async function proveNavigationHighlights(render, declared) {
|
|
112
|
+
const errors = [];
|
|
113
|
+
const warnings = [];
|
|
114
|
+
const navFor = (mode) => ({
|
|
115
|
+
items: NAVIGATION_HIGHLIGHT_PROBES.map((probe) => ({
|
|
116
|
+
label: probe.label, href: probe.href,
|
|
117
|
+
children: [{ label: probe.childLabel, href: `${probe.href}/child` }],
|
|
118
|
+
...(mode === 'absent' ? {} : { megaMenu: { promo: { ...probe.promo, ...(mode === 'image-free' ? { imageUrl: null } : {}) } } })
|
|
119
|
+
})),
|
|
120
|
+
footer: []
|
|
121
|
+
});
|
|
122
|
+
const populated = await render(navFor('populated'));
|
|
123
|
+
const hasOutput = NAVIGATION_HIGHLIGHT_PROBES.some((probe) => Object.values(probe.promo).some((value) => populated.includes(value)));
|
|
124
|
+
if (declared !== true) {
|
|
125
|
+
if (hasOutput) {
|
|
126
|
+
const message = 'layout: renders navigation highlight content but supports.navigationHighlights is not true';
|
|
127
|
+
if (declared === false) errors.push(`${message}, declare support or omit the card`);
|
|
128
|
+
else warnings.push(`${message}, older manifests remain valid but the editor will not offer highlights`);
|
|
129
|
+
}
|
|
130
|
+
return { errors, warnings };
|
|
131
|
+
}
|
|
132
|
+
errors.push(...inspectNavigationHighlights(populated, NAVIGATION_HIGHLIGHT_PROBES));
|
|
133
|
+
const imageFree = await render(navFor('image-free'));
|
|
134
|
+
errors.push(...inspectNavigationHighlights(imageFree, NAVIGATION_HIGHLIGHT_PROBES, { images: false }));
|
|
135
|
+
const absent = await render(navFor('absent'));
|
|
136
|
+
for (const probe of NAVIGATION_HIGHLIGHT_PROBES) {
|
|
137
|
+
if (Object.values(probe.promo).some((sentinel) => absent.includes(sentinel))) {
|
|
138
|
+
errors.push('highlight content appears when no highlight is supplied, branch on the optional promo object');
|
|
139
|
+
}
|
|
140
|
+
if (!textOf(parseMarkup(absent).root).includes(probe.childLabel)) {
|
|
141
|
+
errors.push('normal menu links must remain available when no highlight is supplied');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const empty = await render({ items: [], footer: [] });
|
|
145
|
+
if (/<img\b[^>]*\bsrc\s*=\s*["']\s*(?:null|undefined)?\s*["']/i.test(imageFree + absent + empty)) {
|
|
146
|
+
errors.push('missing highlights or images must not produce an empty image URL');
|
|
147
|
+
}
|
|
148
|
+
return { errors: errors.map((message) => `layout navigationHighlights: ${message}`), warnings };
|
|
149
|
+
}
|