@port60/template-kit 1.1.0 → 1.2.1
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 +35 -1
- package/package.json +1 -1
- package/src/lib/agentsMd.mjs +48 -0
- package/src/vendor/contract/v2/islands.json +4 -3
- package/src/vendor/contract/v2/manifest.schema.json +49 -0
- package/src/vendor/contract/v2/sections.json +219 -1
- package/src/vendor/contract/v2/site.schema.json +4 -0
- package/src/vendor/contract/v2.lock.json +333 -4
- package/src/vendor/engine/presentation-capabilities.mjs +45 -0
- package/src/vendor/engine/section-heading-alignment.mjs +28 -0
- package/src/vendor/validator/collection-link-visibility.mjs +47 -0
- package/src/vendor/validator/heading-alignment.mjs +148 -0
- package/src/vendor/validator/navigation-modes.mjs +60 -0
- package/src/vendor/validator/platform-base.css +119 -0
- package/src/vendor/validator/presentation-proof.mjs +53 -0
- package/src/vendor/validator/preview-v2.mjs +10 -2
- package/src/vendor/validator/site-context-v2.mjs +17 -2
- package/src/vendor/validator/validate-v2.mjs +127 -3
- package/starter/manifest.json +1 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// This is a structural render proof, not a CSS audit. The platform's desktop-only rule
|
|
2
|
+
// aligns the marked heading; authors still check every Look at desktop/mobile and in RTL.
|
|
3
|
+
import { HEADING_ALIGNMENTS } from '../engine/section-heading-alignment.mjs';
|
|
4
|
+
|
|
5
|
+
const ATTRIBUTE = 'data-p60-heading-align';
|
|
6
|
+
const VALUES = HEADING_ALIGNMENTS;
|
|
7
|
+
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
|
8
|
+
const NON_CONTENT = new Set(['script', 'style', 'template', 'noscript', 'textarea', 'xmp', 'plaintext', 'title', 'select']);
|
|
9
|
+
const NON_HEADING = new Set(['img', 'picture', 'svg', 'video', 'audio', 'canvas', 'iframe', 'ul', 'ol', 'li', 'dl', 'table', 'form', 'button', 'input', 'article', 'aside']);
|
|
10
|
+
const GROUPS = new Set(['div', 'header', 'hgroup', 'section']);
|
|
11
|
+
const isHeading = node => /^h[1-6]$/.test(node.tag);
|
|
12
|
+
|
|
13
|
+
function markup(html) {
|
|
14
|
+
const root = { tag: 'root', attrs: {}, children: [], parent: null };
|
|
15
|
+
const stack = [root];
|
|
16
|
+
const nodes = [];
|
|
17
|
+
for (const [token] of html.matchAll(/<!--[\s\S]*?-->|<(?:[^>"']|"[^"]*"|'[^']*')*>|[^<]+/g)) {
|
|
18
|
+
if (token.startsWith('<!--') || token.startsWith('<!')) continue;
|
|
19
|
+
const close = /^<\/\s*([\w:-]+)/.exec(token);
|
|
20
|
+
if (close) {
|
|
21
|
+
const index = stack.findLastIndex(node => node.tag === close[1].toLowerCase());
|
|
22
|
+
if (index > 0) stack.length = index;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const open = /^<\s*([\w:-]+)/.exec(token);
|
|
26
|
+
if (!open) { stack.at(-1).children.push(token); continue; }
|
|
27
|
+
const attrs = {};
|
|
28
|
+
for (const attribute of token.slice(open[0].length, -1).matchAll(/([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) {
|
|
29
|
+
const name = attribute[1].toLowerCase();
|
|
30
|
+
// HTML keeps the first duplicate attribute; do not prove a later value the browser ignores.
|
|
31
|
+
if (!Object.hasOwn(attrs, name)) attrs[name] = decodeEntities(attribute[2] ?? attribute[3] ?? attribute[4] ?? '');
|
|
32
|
+
}
|
|
33
|
+
const node = { tag: open[1].toLowerCase(), attrs, children: [], parent: stack.at(-1) };
|
|
34
|
+
node.parent.children.push(node);
|
|
35
|
+
nodes.push(node);
|
|
36
|
+
if (!VOID.has(node.tag) && !token.endsWith('/>')) stack.push(node);
|
|
37
|
+
}
|
|
38
|
+
return nodes;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function hidden(node) {
|
|
42
|
+
return NON_CONTENT.has(node.tag) || 'hidden' in node.attrs || node.attrs['aria-hidden'] === 'true' ||
|
|
43
|
+
/(?:display\s*:\s*none|visibility\s*:\s*hidden|opacity\s*:\s*0(?:[;\s]|$))/i.test(node.attrs.style ?? '');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function visible(node) {
|
|
47
|
+
for (let current = node; current; current = current.parent) if (hidden(current)) return false;
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function decodeEntities(text) {
|
|
52
|
+
const entities = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
|
|
53
|
+
return text.replace(/&(#x[\da-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi, (match, entity) => {
|
|
54
|
+
if (entity.startsWith('#')) {
|
|
55
|
+
const value = Number.parseInt(entity.slice(entity[1].toLowerCase() === 'x' ? 2 : 1), entity[1].toLowerCase() === 'x' ? 16 : 10);
|
|
56
|
+
return value > 0 && value <= 0x10ffff ? String.fromCodePoint(value) : match;
|
|
57
|
+
}
|
|
58
|
+
return entities[entity.toLowerCase()];
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalise(text) {
|
|
63
|
+
return decodeEntities(text).replace(/\s+/g, ' ').trim();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function textOf(node) {
|
|
67
|
+
if (hidden(node)) return '';
|
|
68
|
+
return node.children.map(child => {
|
|
69
|
+
if (typeof child === 'string') return child;
|
|
70
|
+
if (child.tag === 'br') return ' ';
|
|
71
|
+
const text = textOf(child);
|
|
72
|
+
return isHeading(child) || GROUPS.has(child.tag) || child.tag === 'p' ? ` ${text} ` : text;
|
|
73
|
+
}).join('');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function inside(node) {
|
|
77
|
+
return [node, ...node.children.flatMap(child => typeof child === 'string' ? [] : inside(child))];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Every hook must target the section introduction, not cards, media, or the whole section. */
|
|
81
|
+
export function inspectHeadingAlignment(html, requested, { heading = '', eyebrow = '', requireHook = false } = {}) {
|
|
82
|
+
const hooks = markup(html).filter(node => Object.hasOwn(node.attrs, ATTRIBUTE));
|
|
83
|
+
const errors = [];
|
|
84
|
+
if (requested === undefined) {
|
|
85
|
+
if (hooks.some(node => VALUES.includes(node.attrs[ATTRIBUTE]))) {
|
|
86
|
+
errors.push('default/unsupported alignment must not emit an active data-p60-heading-align override');
|
|
87
|
+
}
|
|
88
|
+
if (!requireHook) return errors;
|
|
89
|
+
}
|
|
90
|
+
const label = requested ?? 'default';
|
|
91
|
+
const matches = value => requested === undefined ? !VALUES.includes(value) : value === requested;
|
|
92
|
+
const headingText = normalise(heading);
|
|
93
|
+
const eyebrowText = normalise(eyebrow);
|
|
94
|
+
let alignedHeading = false;
|
|
95
|
+
if (!hooks.length) errors.push(`'${label}' must render a permanent data-p60-heading-align hook on the section heading, including an inactive hook at the template default`);
|
|
96
|
+
for (const hook of hooks) {
|
|
97
|
+
if (!matches(hook.attrs[ATTRIBUTE])) errors.push(`every heading hook must render the requested '${label}' value (found '${hook.attrs[ATTRIBUTE]}')`);
|
|
98
|
+
const descendants = inside(hook);
|
|
99
|
+
const text = normalise(textOf(hook));
|
|
100
|
+
const headings = descendants.filter(node => isHeading(node) && visible(node));
|
|
101
|
+
const containsHeading = headings.length === 1 && headingText !== '' && normalise(textOf(headings[0])) === headingText;
|
|
102
|
+
const isEyebrow = !headings.length && eyebrowText !== '' && text === eyebrowText && ['p', 'span', 'div', 'strong', 'small'].includes(hook.tag);
|
|
103
|
+
const onlyHeadingCopy = [headingText, `${eyebrowText} ${headingText}`, `${headingText} ${eyebrowText}`].map(normalise).includes(text);
|
|
104
|
+
const headingGroup = containsHeading && (isHeading(hook) || GROUPS.has(hook.tag)) && onlyHeadingCopy;
|
|
105
|
+
if (!visible(hook) || descendants.some(node => NON_HEADING.has(node.tag)) || (!headingGroup && !isEyebrow)) {
|
|
106
|
+
errors.push(`'${label}' hook must mark the real section heading, its eyebrow, or a heading-only group; not hidden/empty markup, body copy, media or cards`);
|
|
107
|
+
} else if (headingGroup && matches(hook.attrs[ATTRIBUTE])) alignedHeading = true;
|
|
108
|
+
}
|
|
109
|
+
if (!alignedHeading) errors.push(`'${label}' must mark a visible nonempty section heading (h1 to h6), not only its eyebrow or unrelated card headings`);
|
|
110
|
+
return [...new Set(errors)];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Canonical sample data, with optional introduction copy populated so every claim is exercised. */
|
|
114
|
+
export function headingAlignmentFixture(entry) {
|
|
115
|
+
const content = structuredClone(entry.sample ?? {});
|
|
116
|
+
delete content.headingAlignment;
|
|
117
|
+
const headingField = entry.fields.find(field => field.name === 'title' || field.name === 'heading')?.name;
|
|
118
|
+
if (headingField && !content[headingField]) content[headingField] = `P60 ${entry.type} section heading`;
|
|
119
|
+
if (entry.fields.some(field => field.name === 'eyebrow') && !content.eyebrow) content.eyebrow = `P60 ${entry.type} eyebrow`;
|
|
120
|
+
return { content, heading: content[headingField] ?? '', eyebrow: content.eyebrow ?? '' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** render receives raw content; the caller uses the same manifest-aware resolver as preview. */
|
|
124
|
+
export async function proveHeadingAlignment(render, entry, declared, { inheritedHeading } = {}) {
|
|
125
|
+
const values = Array.isArray(declared) ? declared.filter(value => VALUES.includes(value)) : [];
|
|
126
|
+
const fixture = headingAlignmentFixture(entry);
|
|
127
|
+
const fixtures = [{ ...fixture, name: 'authored' }];
|
|
128
|
+
if (typeof inheritedHeading === 'string' && inheritedHeading !== '') {
|
|
129
|
+
const content = { ...fixture.content };
|
|
130
|
+
delete content.title;
|
|
131
|
+
fixtures.push({ content, heading: inheritedHeading, eyebrow: fixture.eyebrow, name: 'inherited' });
|
|
132
|
+
}
|
|
133
|
+
const errors = [];
|
|
134
|
+
for (const fixture of fixtures) {
|
|
135
|
+
for (const requested of [undefined, ...VALUES, 'p60-unknown-alignment']) {
|
|
136
|
+
const content = { ...fixture.content, ...(requested === undefined ? {} : { headingAlignment: requested }) };
|
|
137
|
+
const expected = values.includes(requested) ? requested : undefined;
|
|
138
|
+
const label = `${fixture.name} ${requested ?? 'default'}`;
|
|
139
|
+
try {
|
|
140
|
+
const html = await render(content);
|
|
141
|
+
errors.push(...inspectHeadingAlignment(html, expected, { ...fixture, requireHook: values.length > 0 }).map(message => `${label}: ${message}`));
|
|
142
|
+
} catch (error) {
|
|
143
|
+
errors.push(`${label}: failed rendering headingAlignment fixture, ${error.message}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return errors.map(message => `section '${entry.type}' headingAlignment: ${message}`);
|
|
148
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { parsePresentationMarkup, presentationText, presentationVisible, presentationSignature } from './presentation-proof.mjs';
|
|
2
|
+
|
|
3
|
+
const ATTRIBUTE = 'data-p60-navigation-mode';
|
|
4
|
+
export const NAVIGATION_MODE_PROBES = [
|
|
5
|
+
{ kind: 'link', label: 'P60 navigation parent', href: '/p60-nav-parent', children: [
|
|
6
|
+
{ kind: 'link', label: 'P60 navigation child', href: '/p60-nav-child', children: [
|
|
7
|
+
{ kind: 'link', label: 'P60 navigation grandchild', href: '/p60-nav-grandchild', children: [] }
|
|
8
|
+
] },
|
|
9
|
+
{ kind: 'group', label: 'P60 navigation child group', href: null, children: [
|
|
10
|
+
{ kind: 'link', label: 'P60 grouped grandchild', href: '/p60-nav-grouped-grandchild', children: [] }
|
|
11
|
+
] }
|
|
12
|
+
] },
|
|
13
|
+
{ kind: 'group', label: 'P60 navigation root group', href: null, children: [
|
|
14
|
+
{ kind: 'link', label: 'P60 root-group child', href: '/p60-nav-group-child', children: [] }
|
|
15
|
+
] },
|
|
16
|
+
{ kind: 'link', label: 'P60 final navigation link', href: '/p60-nav-last', children: [] }
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/** Structural proof of both presentations; author browser tests establish layout and usability. */
|
|
20
|
+
export async function proveNavigationModes(render, declaration, css = '') {
|
|
21
|
+
const errors = [], signatures = new Map();
|
|
22
|
+
const declared = declaration && Array.isArray(declaration.options) && declaration.options.length === 2
|
|
23
|
+
&& new Set(declaration.options).size === 2 && declaration.options.every(value => ['simple', 'mega'].includes(value))
|
|
24
|
+
&& declaration.options.includes(declaration.default);
|
|
25
|
+
if (!declared) return errors;
|
|
26
|
+
const flatten = items => items.flatMap(node => [node, ...flatten(node.children ?? [])]);
|
|
27
|
+
const supplied = flatten(NAVIGATION_MODE_PROBES);
|
|
28
|
+
for (const requested of [undefined, 'simple', 'mega', 'p60-unknown-mode']) {
|
|
29
|
+
const expected = declaration.options.includes(requested) ? requested : declaration.default;
|
|
30
|
+
try {
|
|
31
|
+
const { root, nodes } = parsePresentationMarkup(await render(requested, NAVIGATION_MODE_PROBES));
|
|
32
|
+
const hooks = nodes.filter(node => Object.hasOwn(node.attrs, ATTRIBUTE));
|
|
33
|
+
if (!hooks.length) errors.push(`${requested ?? 'default'}: must mark a real navigation root with ${ATTRIBUTE}`);
|
|
34
|
+
for (const hook of hooks) {
|
|
35
|
+
if (!(hook.tag === 'nav' || hook.attrs.role === 'navigation' || Object.hasOwn(hook.attrs, 'data-p60-nav'))
|
|
36
|
+
|| hook.attrs[ATTRIBUTE] !== expected || !presentationVisible(hook, { navigation: true })) {
|
|
37
|
+
errors.push(`${requested ?? 'default'}: navigation root must render the resolved '${expected}' mode`);
|
|
38
|
+
}
|
|
39
|
+
const contains = node => { for (let parent = node.parent; parent; parent = parent.parent) if (parent === hook) return true; return false; };
|
|
40
|
+
const nonLink = node => { for (let current = node; current && current !== hook; current = current.parent) if (current.tag === 'a') return false; return true; };
|
|
41
|
+
let lastIndex = -1;
|
|
42
|
+
for (const item of supplied) {
|
|
43
|
+
const index = nodes.findIndex(node => contains(node) && presentationVisible(node, { navigation: true })
|
|
44
|
+
&& (item.href ? node.tag === 'a' && node.attrs.href === item.href && presentationText(node).includes(item.label)
|
|
45
|
+
: nonLink(node) && node.children.some(child => typeof child === 'string' && child.replace(/\s+/g, ' ').includes(item.label))));
|
|
46
|
+
if (index < 0) errors.push(`${requested ?? 'default'}: both navigation modes must retain '${item.label}' and its supplied destination or non-link group label at every supported depth`);
|
|
47
|
+
else if (index < lastIndex) errors.push(`${requested ?? 'default'}: both navigation modes must retain supplied navigation node order`);
|
|
48
|
+
else lastIndex = index;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
signatures.set(requested ?? 'default', presentationSignature(root, () => [ATTRIBUTE]));
|
|
52
|
+
} catch (error) { errors.push(`${requested ?? 'default'}: failed rendering navigation fixture, ${error.message}`); }
|
|
53
|
+
}
|
|
54
|
+
if (signatures.get('default') !== signatures.get(declaration.default) || signatures.get('p60-unknown-mode') !== signatures.get(declaration.default)) {
|
|
55
|
+
errors.push('missing or unsupported preferences must use the declared default without altering navigation content');
|
|
56
|
+
}
|
|
57
|
+
const modeStyles = /\[data-p60-navigation-mode\s*=\s*["']?(?:simple|mega)["']?\s*\][^{]*\{[^}]*[a-z-]+\s*:\s*[^;}\s]+/i.test(css.replace(/\/\*[\s\S]*?\*\//g, ''));
|
|
58
|
+
if (signatures.get('simple') === signatures.get('mega') && !modeStyles) errors.push('simple and mega must implement distinct markup or explicit mode-scoped presentation, not only change a marker');
|
|
59
|
+
return [...new Set(errors)].map(message => `layout navigationModes: ${message}`);
|
|
60
|
+
}
|
|
@@ -8,6 +8,29 @@
|
|
|
8
8
|
template ships a themeCss injected at body-end. Fonts load from the layout <head> (preconnect +
|
|
9
9
|
stylesheet link) instead of a CSS @import, an @import chains render-blocking requests. */
|
|
10
10
|
|
|
11
|
+
/* Photo-only hero rotation. Templates style these hooks, not the campaign carousel. */
|
|
12
|
+
.hero-carousel .hero-playback {
|
|
13
|
+
position: absolute; z-index: 5; inset-block-start: 12px; inset-inline-end: 12px;
|
|
14
|
+
display: grid; place-items: center; width: 44px; height: 44px; padding: 8px;
|
|
15
|
+
border: 1px solid #ffffffb3; border-radius: 50%; background: #14241ce6; color: #fff;
|
|
16
|
+
cursor: pointer; touch-action: manipulation;
|
|
17
|
+
}
|
|
18
|
+
.hero-carousel .hero-playback svg { width: 20px; height: 20px; fill: currentColor; }
|
|
19
|
+
.hero-carousel .hero-playback:disabled { cursor: default; opacity: .65; }
|
|
20
|
+
.hero-carousel:focus-visible, .hero-carousel .hero-playback:focus-visible {
|
|
21
|
+
outline: 2px solid var(--ink); outline-offset: 4px;
|
|
22
|
+
}
|
|
23
|
+
.hero-carousel .hero-playback[hidden], .hero-carousel .hero-dots[hidden] { display: none !important; }
|
|
24
|
+
.hero-carousel-instructions {
|
|
25
|
+
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
26
|
+
overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
|
|
27
|
+
}
|
|
28
|
+
@media (prefers-reduced-motion: reduce) {
|
|
29
|
+
.hero-carousel .hero-slide, .hero-carousel .hero-slide-img {
|
|
30
|
+
animation: none !important; transition: none !important;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
11
34
|
:root {
|
|
12
35
|
--bg: #fbf6ef;
|
|
13
36
|
--bg-tint: #f4ece1;
|
|
@@ -122,6 +145,15 @@ img {
|
|
|
122
145
|
/* Typography (heading scale/family, paragraph rhythm) is TEMPLATE territory, see
|
|
123
146
|
templates/default/theme.ts for Classic's. The base deliberately styles no bare h1-h4/p. */
|
|
124
147
|
|
|
148
|
+
/* Explicit per-section heading overrides. Only opt-in heading hooks participate; body copy,
|
|
149
|
+
cards, islands and grid placement are untouched. An empty hook means the template default.
|
|
150
|
+
Desktop overrides must not displace the template's mobile/tablet composition. */
|
|
151
|
+
@media (min-width: 1024px) {
|
|
152
|
+
[data-p60-heading-align="start"] { text-align: start !important; }
|
|
153
|
+
[data-p60-heading-align="center"] { text-align: center !important; }
|
|
154
|
+
[data-p60-heading-align="end"] { text-align: end !important; }
|
|
155
|
+
}
|
|
156
|
+
|
|
125
157
|
input, button, textarea, select {
|
|
126
158
|
font: inherit;
|
|
127
159
|
}
|
|
@@ -3758,6 +3790,90 @@ html:has(.donation-giftaid-dialog[open]) { overflow: hidden; }
|
|
|
3758
3790
|
font-size: 0.95rem;
|
|
3759
3791
|
line-height: 1.5;
|
|
3760
3792
|
}
|
|
3793
|
+
|
|
3794
|
+
/* Image credits (docs/imagery, IM-11): platform chrome in every render path, themed from the same
|
|
3795
|
+
surface and border tokens as the consent centre so it reads as the site's own. Closed, a dialog is
|
|
3796
|
+
display:none; the :target rule is the no-JavaScript path, the list shows inline at the page end. */
|
|
3797
|
+
.p60-credits {
|
|
3798
|
+
width: 100%;
|
|
3799
|
+
max-width: min(640px, calc(100vw - 2rem));
|
|
3800
|
+
padding: 0;
|
|
3801
|
+
border: 1px solid var(--border, rgba(0, 0, 0, 0.12));
|
|
3802
|
+
border-radius: 16px;
|
|
3803
|
+
background: var(--surface, #fff);
|
|
3804
|
+
color: inherit;
|
|
3805
|
+
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
|
|
3806
|
+
}
|
|
3807
|
+
.p60-credits::backdrop {
|
|
3808
|
+
background: rgba(0, 0, 0, 0.45);
|
|
3809
|
+
}
|
|
3810
|
+
.p60-credits-inner {
|
|
3811
|
+
padding: 1.1rem 1.3rem;
|
|
3812
|
+
}
|
|
3813
|
+
.p60-credits-title {
|
|
3814
|
+
margin: 0 0 0.75rem;
|
|
3815
|
+
font-size: 1.1rem;
|
|
3816
|
+
}
|
|
3817
|
+
.p60-credits-list {
|
|
3818
|
+
list-style: none;
|
|
3819
|
+
margin: 0;
|
|
3820
|
+
padding: 0;
|
|
3821
|
+
display: grid;
|
|
3822
|
+
gap: 0.6rem;
|
|
3823
|
+
max-height: 60vh;
|
|
3824
|
+
overflow: auto;
|
|
3825
|
+
}
|
|
3826
|
+
.p60-credits-item {
|
|
3827
|
+
display: flex;
|
|
3828
|
+
gap: 0.75rem;
|
|
3829
|
+
align-items: flex-start;
|
|
3830
|
+
font-size: 0.9rem;
|
|
3831
|
+
line-height: 1.45;
|
|
3832
|
+
}
|
|
3833
|
+
.p60-credits-thumb {
|
|
3834
|
+
flex: none;
|
|
3835
|
+
width: 3.2rem;
|
|
3836
|
+
height: 2.2rem;
|
|
3837
|
+
object-fit: cover;
|
|
3838
|
+
border-radius: 6px;
|
|
3839
|
+
}
|
|
3840
|
+
.p60-credits-links {
|
|
3841
|
+
display: block;
|
|
3842
|
+
font-size: 0.8rem;
|
|
3843
|
+
opacity: 0.8;
|
|
3844
|
+
}
|
|
3845
|
+
.p60-credits-links a + a::before {
|
|
3846
|
+
content: ' \00b7 ';
|
|
3847
|
+
}
|
|
3848
|
+
.p60-credits-note {
|
|
3849
|
+
opacity: 0.8;
|
|
3850
|
+
}
|
|
3851
|
+
.p60-credits-actions {
|
|
3852
|
+
margin: 1rem 0 0;
|
|
3853
|
+
display: flex;
|
|
3854
|
+
justify-content: flex-end;
|
|
3855
|
+
}
|
|
3856
|
+
.p60-credits-close {
|
|
3857
|
+
font: inherit;
|
|
3858
|
+
padding: 0.4rem 0.9rem;
|
|
3859
|
+
border-radius: 999px;
|
|
3860
|
+
border: 1px solid var(--border, rgba(0, 0, 0, 0.2));
|
|
3861
|
+
background: transparent;
|
|
3862
|
+
color: inherit;
|
|
3863
|
+
cursor: pointer;
|
|
3864
|
+
}
|
|
3865
|
+
.p60-credits-fallback {
|
|
3866
|
+
margin: 0;
|
|
3867
|
+
padding: 0.5rem 1rem;
|
|
3868
|
+
font-size: 0.8rem;
|
|
3869
|
+
text-align: center;
|
|
3870
|
+
opacity: 0.75;
|
|
3871
|
+
}
|
|
3872
|
+
.p60-credits:target {
|
|
3873
|
+
display: block;
|
|
3874
|
+
position: static;
|
|
3875
|
+
margin: 1rem auto;
|
|
3876
|
+
}
|
|
3761
3877
|
.consent-actions {
|
|
3762
3878
|
display: flex;
|
|
3763
3879
|
flex-wrap: wrap;
|
|
@@ -5357,3 +5473,6 @@ html:has(.donation-giftaid-dialog[open]) { overflow: hidden; }
|
|
|
5357
5473
|
.search-page-box .site-search-input {
|
|
5358
5474
|
flex: 1 1 auto;
|
|
5359
5475
|
}
|
|
5476
|
+
|
|
5477
|
+
/* A template's inline-flex/block styling must not defeat an editor-hidden onward link. */
|
|
5478
|
+
[data-p60-collection-link][hidden] { display: none !important; }
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Small structural parser for capability proofs. CSS geometry and accessibility still need browser QA.
|
|
2
|
+
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
|
3
|
+
export function parsePresentationMarkup(html) {
|
|
4
|
+
const root = { tag: 'root', attrs: {}, children: [], parent: null };
|
|
5
|
+
const nodes = [], stack = [root];
|
|
6
|
+
for (const [token] of html.matchAll(/<!--[\s\S]*?-->|<(?:[^>"']|"[^"]*"|'[^']*')*>|[^<]+/g)) {
|
|
7
|
+
if (token.startsWith('<!--') || token.startsWith('<!')) continue;
|
|
8
|
+
const close = /^<\/\s*([\w:-]+)/.exec(token);
|
|
9
|
+
if (close) {
|
|
10
|
+
const index = stack.findLastIndex(node => node.tag === close[1].toLowerCase());
|
|
11
|
+
if (index > 0) stack.length = index;
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
const open = /^<\s*([\w:-]+)/.exec(token);
|
|
15
|
+
if (!open) { stack.at(-1).children.push(token); continue; }
|
|
16
|
+
const attrs = {};
|
|
17
|
+
for (const attribute of token.slice(open[0].length, -1).matchAll(/([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) {
|
|
18
|
+
const name = attribute[1].toLowerCase();
|
|
19
|
+
if (!Object.hasOwn(attrs, name)) attrs[name] = decode(attribute[2] ?? attribute[3] ?? attribute[4] ?? '');
|
|
20
|
+
}
|
|
21
|
+
const node = { tag: open[1].toLowerCase(), attrs, children: [], parent: stack.at(-1) };
|
|
22
|
+
node.parent.children.push(node);
|
|
23
|
+
nodes.push(node);
|
|
24
|
+
if (!VOID.has(node.tag) && !token.endsWith('/>')) stack.push(node);
|
|
25
|
+
}
|
|
26
|
+
return { root, nodes };
|
|
27
|
+
}
|
|
28
|
+
function decode(text) {
|
|
29
|
+
return text.replace(/&(#x[\da-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi, (match, entity) => {
|
|
30
|
+
if (!entity.startsWith('#')) return { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }[entity.toLowerCase()];
|
|
31
|
+
const hex = entity[1].toLowerCase() === 'x';
|
|
32
|
+
const value = Number.parseInt(entity.slice(hex ? 2 : 1), hex ? 16 : 10);
|
|
33
|
+
return value > 0 && value <= 0x10ffff ? String.fromCodePoint(value) : match;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
export function presentationText(node) {
|
|
37
|
+
return node.children.map(child => typeof child === 'string' ? decode(child) : presentationText(child)).join(' ').replace(/\s+/g, ' ').trim();
|
|
38
|
+
}
|
|
39
|
+
export function presentationVisible(node, { navigation = false } = {}) {
|
|
40
|
+
for (let current = node; current; current = current.parent) {
|
|
41
|
+
if (['template', 'script', 'style', 'noscript', 'textarea', 'select'].includes(current.tag)) return false;
|
|
42
|
+
// Dropdown panels may start closed. Hidden links and their own fields may not.
|
|
43
|
+
if (navigation && current !== node && 'data-p60-nav-menu' in current.attrs) continue;
|
|
44
|
+
if ('hidden' in current.attrs || current.attrs['aria-hidden'] === 'true' || /(?:display\s*:\s*none|visibility\s*:\s*hidden|opacity\s*:\s*0(?:[;\s]|$))/i.test(current.attrs.style ?? '')) return false;
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
export function presentationSignature(root, ignoredAttributes = () => []) {
|
|
49
|
+
const visit = node => typeof node === 'string' ? node.replace(/\s+/g, ' ').trim() : [node.tag,
|
|
50
|
+
Object.entries(node.attrs).filter(([key]) => !ignoredAttributes(node).includes(key)).sort(([a], [b]) => a.localeCompare(b)),
|
|
51
|
+
node.children.map(visit).filter(value => value !== '')];
|
|
52
|
+
return JSON.stringify(visit(root));
|
|
53
|
+
}
|
|
@@ -17,6 +17,7 @@ import islandRegistry from '../contract/v2/islands.json' with { type: 'json' };
|
|
|
17
17
|
import contextContract from '../contract/v2/context.json' with { type: 'json' };
|
|
18
18
|
import { resolveFixtureArt } from './fixture-art.mjs';
|
|
19
19
|
import { buildSiteFixture, applyPreviewContent, pageComposition, PAGE_KEYS, resolveSectionFixture } from './site-context-v2.mjs';
|
|
20
|
+
import { resolvedNavigationMode } from '../engine/presentation-capabilities.mjs';
|
|
20
21
|
import { withResolvedIcons } from './icons.mjs';
|
|
21
22
|
import { FONT_PROVIDER_ORIGIN, fontCssHref, fontStackFor, knownFamily, toProvider } from './fonts.mjs';
|
|
22
23
|
import { normaliseFocus, previewActions } from './focus.mjs';
|
|
@@ -114,6 +115,7 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
|
|
|
114
115
|
return `<section class="hero-carousel" data-p60-preview-island="hero_carousel">
|
|
115
116
|
${previewNote(name)}
|
|
116
117
|
<div class="hero-slides">${slides}</div>
|
|
118
|
+
<button class="hero-playback" type="button" aria-label="Pause photographs" disabled><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M7 5h4v14H7zm6 0h4v14h-4z" /></svg></button>
|
|
117
119
|
<div class="hero-dots" aria-hidden="true">${Array.from({ length: dots }, (_, i) => `<span class="hero-dot${i === 0 ? ' hero-dot--active' : ''}"></span>`).join('')}</div>
|
|
118
120
|
</section>`;
|
|
119
121
|
}
|
|
@@ -143,7 +145,7 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
|
|
|
143
145
|
</div>
|
|
144
146
|
</section>`;
|
|
145
147
|
case 'language_switch':
|
|
146
|
-
return
|
|
148
|
+
return ''; // Compatibility slot, not a promise of translated tenant content.
|
|
147
149
|
case 'search':
|
|
148
150
|
return `<div class="site-search" data-p60-preview-island="search">${previewNote(name)}<form class="site-search-form"><label class="site-search-label">Search this site</label><div class="site-search-fields"><input class="site-search-input" type="search" disabled><button class="site-search-submit" type="button" disabled>Search</button></div></form><ul class="site-search-results"><li class="site-search-result"><span class="site-search-kind">Article</span><a class="site-search-link" href="#">The Community Garden Opens Its Gates</a><p class="site-search-summary">Two years of digging and Saturday mornings in the rain: the Foundry Lane garden is open.</p></li></ul></div>`;
|
|
149
151
|
case 'map': {
|
|
@@ -465,6 +467,12 @@ export async function renderStudioPreview(files, options = {}) {
|
|
|
465
467
|
const treePage = surface;
|
|
466
468
|
const baseSite = buildSiteFixture(manifest, { page: treePage });
|
|
467
469
|
const site = resolveFixtureArt(applyPreviewContent(baseSite, options.previewContent ?? null), artOptions ?? {});
|
|
470
|
+
// Author fixtures may still carry the old selector options. Match the public host without
|
|
471
|
+
// changing their chosen locale/direction or mutating their source data.
|
|
472
|
+
site.locale.languages = [];
|
|
473
|
+
const headerMode = resolvedNavigationMode(manifest, site.nav.headerMode);
|
|
474
|
+
if (headerMode === undefined) delete site.nav.headerMode;
|
|
475
|
+
else site.nav.headerMode = headerMode;
|
|
468
476
|
site.page.sections = PAGE_KEYS.includes(surface) ? pageOf(surface) : [];
|
|
469
477
|
const listingFixture = contextContract.fixtures.pages?.[surface]?.collection;
|
|
470
478
|
if (listingFixture && site.content[surface] && !Object.hasOwn(options.previewContent ?? {}, surface)) {
|
|
@@ -525,7 +533,7 @@ export async function renderStudioPreview(files, options = {}) {
|
|
|
525
533
|
const source = files[`sections/${type}.liquid`];
|
|
526
534
|
if (!entry || source == null) continue;
|
|
527
535
|
const base = artOptions ? resolveFixtureArt(content ?? {}, artOptions) : resolveFixtureArt(content ?? {});
|
|
528
|
-
const section = resolveSectionFixture({ type, content: base }, site);
|
|
536
|
+
const section = resolveSectionFixture({ type, content: base }, site, manifest);
|
|
529
537
|
if (type === 'homeHero') {
|
|
530
538
|
const hasAuthoredStyle = options.previewContent?.pages?.[surface]?.some(entry => entry.key === entryInPage.key && Object.hasOwn(entry.content ?? {}, 'actionStyle'));
|
|
531
539
|
section.actionStyle = (hasAuthoredStyle && section.actionStyle === 'button') || site.actions.widget === 'none' ? 'button' : 'widget';
|
|
@@ -3,6 +3,8 @@ import context from '../contract/v2/context.json' with { type: 'json' };
|
|
|
3
3
|
import sections from '../contract/v2/sections.json' with { type: 'json' };
|
|
4
4
|
import schema from '../contract/v2/site.schema.json' with { type: 'json' };
|
|
5
5
|
import contentModel from '../contract/v2/content-model.json' with { type: 'json' };
|
|
6
|
+
import { withHeadingAlignment } from '../engine/section-heading-alignment.mjs';
|
|
7
|
+
import { resolvedNavigationMode, withCollectionLinkVisibility } from '../engine/presentation-capabilities.mjs';
|
|
6
8
|
export { extractContentFootprint } from '../engine/content-footprint.mjs';
|
|
7
9
|
export { contentModel };
|
|
8
10
|
|
|
@@ -25,6 +27,10 @@ export function composePage(manifest, page) {
|
|
|
25
27
|
|
|
26
28
|
export function buildSiteFixture(manifest, { page = 'home', path } = {}) {
|
|
27
29
|
const site = structuredClone(context.fixtures.site);
|
|
30
|
+
site.locale.languages = []; // One site's language, not a visitor language catalogue.
|
|
31
|
+
const headerMode = resolvedNavigationMode(manifest, site.nav.headerMode);
|
|
32
|
+
if (headerMode === undefined) delete site.nav.headerMode;
|
|
33
|
+
else site.nav.headerMode = headerMode;
|
|
28
34
|
site.page = { key: page, path: path ?? (page === 'home' ? '/' : `/${page}`), sections: PAGE_KEYS.includes(page) ? composePage(manifest, page) : [] };
|
|
29
35
|
return site;
|
|
30
36
|
}
|
|
@@ -56,6 +62,14 @@ function validatePageSections(value, path, errors) {
|
|
|
56
62
|
for (const [name, fieldValue] of Object.entries(section.content)) {
|
|
57
63
|
const field = fields.get(name);
|
|
58
64
|
if (!field) { errors.push(`${at}.content.${name}: not an authored ${section.type} field`); continue; }
|
|
65
|
+
if (name === 'headingAlignment' && !field.options?.includes(fieldValue)) {
|
|
66
|
+
errors.push(`${at}.content.headingAlignment: must be start, center or end; omit it for the template default`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (name === 'showCollectionLink') {
|
|
70
|
+
if (typeof fieldValue !== 'boolean') errors.push(`${at}.content.showCollectionLink: must be a boolean; omit it for the template default`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
59
73
|
if (field.kind === 'items') {
|
|
60
74
|
if (!Array.isArray(fieldValue)) { errors.push(`${at}.content.${name}: must be an array`); continue; }
|
|
61
75
|
const itemFields = new Set(field.itemFields.map(item => item.name));
|
|
@@ -104,8 +118,9 @@ export function pageComposition(manifest, page, json) {
|
|
|
104
118
|
}
|
|
105
119
|
|
|
106
120
|
/** Keep missing fields missing so a generated label never becomes an authored field marker. */
|
|
107
|
-
export function resolveSectionFixture(section, _site) {
|
|
108
|
-
|
|
121
|
+
export function resolveSectionFixture(section, _site, manifest) {
|
|
122
|
+
const content = structuredClone(section.content ?? {});
|
|
123
|
+
return manifest === undefined ? content : withCollectionLinkVisibility(withHeadingAlignment(content, section.type, manifest), section.type, manifest);
|
|
109
124
|
}
|
|
110
125
|
|
|
111
126
|
export function emptyCollections(site) {
|