@port60/template-kit 1.1.0 → 1.2.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 +27 -1
- package/package.json +1 -1
- package/src/lib/agentsMd.mjs +48 -0
- package/src/vendor/contract/v2/islands.json +1 -1
- 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 +330 -2
- 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 +96 -0
- package/src/vendor/validator/presentation-proof.mjs +53 -0
- package/src/vendor/validator/preview-v2.mjs +9 -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
|
+
}
|
|
@@ -122,6 +122,15 @@ img {
|
|
|
122
122
|
/* Typography (heading scale/family, paragraph rhythm) is TEMPLATE territory, see
|
|
123
123
|
templates/default/theme.ts for Classic's. The base deliberately styles no bare h1-h4/p. */
|
|
124
124
|
|
|
125
|
+
/* Explicit per-section heading overrides. Only opt-in heading hooks participate; body copy,
|
|
126
|
+
cards, islands and grid placement are untouched. An empty hook means the template default.
|
|
127
|
+
Desktop overrides must not displace the template's mobile/tablet composition. */
|
|
128
|
+
@media (min-width: 1024px) {
|
|
129
|
+
[data-p60-heading-align="start"] { text-align: start !important; }
|
|
130
|
+
[data-p60-heading-align="center"] { text-align: center !important; }
|
|
131
|
+
[data-p60-heading-align="end"] { text-align: end !important; }
|
|
132
|
+
}
|
|
133
|
+
|
|
125
134
|
input, button, textarea, select {
|
|
126
135
|
font: inherit;
|
|
127
136
|
}
|
|
@@ -3758,6 +3767,90 @@ html:has(.donation-giftaid-dialog[open]) { overflow: hidden; }
|
|
|
3758
3767
|
font-size: 0.95rem;
|
|
3759
3768
|
line-height: 1.5;
|
|
3760
3769
|
}
|
|
3770
|
+
|
|
3771
|
+
/* Image credits (docs/imagery, IM-11): platform chrome in every render path, themed from the same
|
|
3772
|
+
surface and border tokens as the consent centre so it reads as the site's own. Closed, a dialog is
|
|
3773
|
+
display:none; the :target rule is the no-JavaScript path, the list shows inline at the page end. */
|
|
3774
|
+
.p60-credits {
|
|
3775
|
+
width: 100%;
|
|
3776
|
+
max-width: min(640px, calc(100vw - 2rem));
|
|
3777
|
+
padding: 0;
|
|
3778
|
+
border: 1px solid var(--border, rgba(0, 0, 0, 0.12));
|
|
3779
|
+
border-radius: 16px;
|
|
3780
|
+
background: var(--surface, #fff);
|
|
3781
|
+
color: inherit;
|
|
3782
|
+
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
|
|
3783
|
+
}
|
|
3784
|
+
.p60-credits::backdrop {
|
|
3785
|
+
background: rgba(0, 0, 0, 0.45);
|
|
3786
|
+
}
|
|
3787
|
+
.p60-credits-inner {
|
|
3788
|
+
padding: 1.1rem 1.3rem;
|
|
3789
|
+
}
|
|
3790
|
+
.p60-credits-title {
|
|
3791
|
+
margin: 0 0 0.75rem;
|
|
3792
|
+
font-size: 1.1rem;
|
|
3793
|
+
}
|
|
3794
|
+
.p60-credits-list {
|
|
3795
|
+
list-style: none;
|
|
3796
|
+
margin: 0;
|
|
3797
|
+
padding: 0;
|
|
3798
|
+
display: grid;
|
|
3799
|
+
gap: 0.6rem;
|
|
3800
|
+
max-height: 60vh;
|
|
3801
|
+
overflow: auto;
|
|
3802
|
+
}
|
|
3803
|
+
.p60-credits-item {
|
|
3804
|
+
display: flex;
|
|
3805
|
+
gap: 0.75rem;
|
|
3806
|
+
align-items: flex-start;
|
|
3807
|
+
font-size: 0.9rem;
|
|
3808
|
+
line-height: 1.45;
|
|
3809
|
+
}
|
|
3810
|
+
.p60-credits-thumb {
|
|
3811
|
+
flex: none;
|
|
3812
|
+
width: 3.2rem;
|
|
3813
|
+
height: 2.2rem;
|
|
3814
|
+
object-fit: cover;
|
|
3815
|
+
border-radius: 6px;
|
|
3816
|
+
}
|
|
3817
|
+
.p60-credits-links {
|
|
3818
|
+
display: block;
|
|
3819
|
+
font-size: 0.8rem;
|
|
3820
|
+
opacity: 0.8;
|
|
3821
|
+
}
|
|
3822
|
+
.p60-credits-links a + a::before {
|
|
3823
|
+
content: ' \00b7 ';
|
|
3824
|
+
}
|
|
3825
|
+
.p60-credits-note {
|
|
3826
|
+
opacity: 0.8;
|
|
3827
|
+
}
|
|
3828
|
+
.p60-credits-actions {
|
|
3829
|
+
margin: 1rem 0 0;
|
|
3830
|
+
display: flex;
|
|
3831
|
+
justify-content: flex-end;
|
|
3832
|
+
}
|
|
3833
|
+
.p60-credits-close {
|
|
3834
|
+
font: inherit;
|
|
3835
|
+
padding: 0.4rem 0.9rem;
|
|
3836
|
+
border-radius: 999px;
|
|
3837
|
+
border: 1px solid var(--border, rgba(0, 0, 0, 0.2));
|
|
3838
|
+
background: transparent;
|
|
3839
|
+
color: inherit;
|
|
3840
|
+
cursor: pointer;
|
|
3841
|
+
}
|
|
3842
|
+
.p60-credits-fallback {
|
|
3843
|
+
margin: 0;
|
|
3844
|
+
padding: 0.5rem 1rem;
|
|
3845
|
+
font-size: 0.8rem;
|
|
3846
|
+
text-align: center;
|
|
3847
|
+
opacity: 0.75;
|
|
3848
|
+
}
|
|
3849
|
+
.p60-credits:target {
|
|
3850
|
+
display: block;
|
|
3851
|
+
position: static;
|
|
3852
|
+
margin: 1rem auto;
|
|
3853
|
+
}
|
|
3761
3854
|
.consent-actions {
|
|
3762
3855
|
display: flex;
|
|
3763
3856
|
flex-wrap: wrap;
|
|
@@ -5357,3 +5450,6 @@ html:has(.donation-giftaid-dialog[open]) { overflow: hidden; }
|
|
|
5357
5450
|
.search-page-box .site-search-input {
|
|
5358
5451
|
flex: 1 1 auto;
|
|
5359
5452
|
}
|
|
5453
|
+
|
|
5454
|
+
/* A template's inline-flex/block styling must not defeat an editor-hidden onward link. */
|
|
5455
|
+
[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';
|
|
@@ -143,7 +144,7 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
|
|
|
143
144
|
</div>
|
|
144
145
|
</section>`;
|
|
145
146
|
case 'language_switch':
|
|
146
|
-
return
|
|
147
|
+
return ''; // Compatibility slot, not a promise of translated tenant content.
|
|
147
148
|
case 'search':
|
|
148
149
|
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
150
|
case 'map': {
|
|
@@ -465,6 +466,12 @@ export async function renderStudioPreview(files, options = {}) {
|
|
|
465
466
|
const treePage = surface;
|
|
466
467
|
const baseSite = buildSiteFixture(manifest, { page: treePage });
|
|
467
468
|
const site = resolveFixtureArt(applyPreviewContent(baseSite, options.previewContent ?? null), artOptions ?? {});
|
|
469
|
+
// Author fixtures may still carry the old selector options. Match the public host without
|
|
470
|
+
// changing their chosen locale/direction or mutating their source data.
|
|
471
|
+
site.locale.languages = [];
|
|
472
|
+
const headerMode = resolvedNavigationMode(manifest, site.nav.headerMode);
|
|
473
|
+
if (headerMode === undefined) delete site.nav.headerMode;
|
|
474
|
+
else site.nav.headerMode = headerMode;
|
|
468
475
|
site.page.sections = PAGE_KEYS.includes(surface) ? pageOf(surface) : [];
|
|
469
476
|
const listingFixture = contextContract.fixtures.pages?.[surface]?.collection;
|
|
470
477
|
if (listingFixture && site.content[surface] && !Object.hasOwn(options.previewContent ?? {}, surface)) {
|
|
@@ -525,7 +532,7 @@ export async function renderStudioPreview(files, options = {}) {
|
|
|
525
532
|
const source = files[`sections/${type}.liquid`];
|
|
526
533
|
if (!entry || source == null) continue;
|
|
527
534
|
const base = artOptions ? resolveFixtureArt(content ?? {}, artOptions) : resolveFixtureArt(content ?? {});
|
|
528
|
-
const section = resolveSectionFixture({ type, content: base }, site);
|
|
535
|
+
const section = resolveSectionFixture({ type, content: base }, site, manifest);
|
|
529
536
|
if (type === 'homeHero') {
|
|
530
537
|
const hasAuthoredStyle = options.previewContent?.pages?.[surface]?.some(entry => entry.key === entryInPage.key && Object.hasOwn(entry.content ?? {}, 'actionStyle'));
|
|
531
538
|
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) {
|