@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
|
@@ -24,6 +24,11 @@ import layoutContract from '../contract/v2/layout.json' with { type: 'json' };
|
|
|
24
24
|
import behaviourCatalogue from '../contract/v2/behaviours.json' with { type: 'json' };
|
|
25
25
|
import { buildSiteFixture, extractContentFootprint, contentModel, emptyCollections, resolveSectionFixture } from './site-context-v2.mjs';
|
|
26
26
|
import { proveNavigationHighlights } from './navigation-highlights.mjs';
|
|
27
|
+
import { proveHeadingAlignment } from './heading-alignment.mjs';
|
|
28
|
+
import { proveCollectionLinkVisibility } from './collection-link-visibility.mjs';
|
|
29
|
+
import { proveNavigationModes } from './navigation-modes.mjs';
|
|
30
|
+
import { resolvedNavigationMode } from '../engine/presentation-capabilities.mjs';
|
|
31
|
+
import { parsePresentationMarkup } from './presentation-proof.mjs';
|
|
27
32
|
|
|
28
33
|
const Ajv = Ajv2020.default ?? Ajv2020;
|
|
29
34
|
|
|
@@ -42,6 +47,70 @@ const PRIMARY_ATTR = Object.fromEntries(
|
|
|
42
47
|
behaviourCatalogue.behaviours.map((b) => [b.name, [new RegExp(`${escapeRegExp(b.primaryAttribute)}\\b`), b.primaryAttribute]])
|
|
43
48
|
);
|
|
44
49
|
|
|
50
|
+
// Inline declarations only: preserve quoted/function values while discarding CSS comments.
|
|
51
|
+
// A URL in a custom property, quoted string or comment is not a rendered background image.
|
|
52
|
+
function heroStyleDeclarations(style = '') {
|
|
53
|
+
const declarations = [];
|
|
54
|
+
let value = '', quote = '', depth = 0;
|
|
55
|
+
const append = () => {
|
|
56
|
+
const colon = value.indexOf(':');
|
|
57
|
+
if (colon > 0) declarations.push([value.slice(0, colon).trim().toLowerCase(), value.slice(colon + 1).trim()]);
|
|
58
|
+
value = '';
|
|
59
|
+
};
|
|
60
|
+
for (let index = 0; index < style.length; index++) {
|
|
61
|
+
const char = style[index];
|
|
62
|
+
if (char === '\\') { value += char + (style[++index] ?? ''); continue; }
|
|
63
|
+
if (quote) { value += char; if (char === quote) quote = ''; continue; }
|
|
64
|
+
if (char === '/' && style[index + 1] === '*') {
|
|
65
|
+
const end = style.indexOf('*/', index + 2);
|
|
66
|
+
if (end < 0) break;
|
|
67
|
+
index = end + 1;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (char === '"' || char === "'") quote = char;
|
|
71
|
+
if (char === '(') depth++;
|
|
72
|
+
if (char === ')') depth = Math.max(0, depth - 1);
|
|
73
|
+
if (char === ';' && depth === 0) append();
|
|
74
|
+
else value += char;
|
|
75
|
+
}
|
|
76
|
+
append();
|
|
77
|
+
return declarations;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function heroInlineValue(declarations, properties) {
|
|
81
|
+
let selected = '', important = false;
|
|
82
|
+
for (const [property, value] of declarations) {
|
|
83
|
+
if (!properties.includes(property)) continue;
|
|
84
|
+
const nextImportant = /!\s*important\s*$/i.test(value);
|
|
85
|
+
if (!important || nextImportant) {
|
|
86
|
+
selected = value.replace(/!\s*important\s*$/i, '').trim();
|
|
87
|
+
important = nextImportant;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return selected;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function heroNodeCanRender(node) {
|
|
94
|
+
for (let current = node; current; current = current.parent) {
|
|
95
|
+
if (['template', 'script', 'style', 'noscript', 'textarea', 'select'].includes(current.tag)
|
|
96
|
+
|| Object.hasOwn(current.attrs, 'hidden')) return false;
|
|
97
|
+
const declarations = heroStyleDeclarations(current.attrs.style);
|
|
98
|
+
if (/^none$/i.test(heroInlineValue(declarations, ['display']))
|
|
99
|
+
|| /^(?:hidden|collapse)$/i.test(heroInlineValue(declarations, ['visibility']))
|
|
100
|
+
|| /^0(?:\.0*)?%?$/.test(heroInlineValue(declarations, ['opacity']))) return false;
|
|
101
|
+
// aria-hidden and inert do not visually hide decorative photographs or carousel slides.
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function heroBackgroundShows(node, imageUrl) {
|
|
107
|
+
const background = heroInlineValue(heroStyleDeclarations(node.attrs.style), ['background', 'background-image']);
|
|
108
|
+
for (const match of background.matchAll(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|(?<![\w-])url\(\s*(?:"([^"]*)"|'([^']*)'|([^\s)]+))\s*\)/gi)) {
|
|
109
|
+
if ((match[1] ?? match[2] ?? match[3]) === imageUrl) return true;
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
45
114
|
// Templates are markup and attributes, NEVER code (docs/template-behaviours.md). These are hard
|
|
46
115
|
// errors over the RAW liquid source, even inside comments, because there is no legitimate reason
|
|
47
116
|
// for the tokens to appear at all. The handler pattern names real DOM event families rather than
|
|
@@ -245,11 +314,20 @@ export async function validateArtifact(files) {
|
|
|
245
314
|
configureDialect(liquid, dialect, allIslands);
|
|
246
315
|
|
|
247
316
|
const catalogueByType = new Map(sectionCatalogue.sections.map((s) => [s.type, s]));
|
|
317
|
+
for (const type of Object.keys(manifest?.supports?.sectionHeadingAlignment ?? {})) {
|
|
318
|
+
if (!(manifest?.supports?.sections ?? []).includes(type)) {
|
|
319
|
+
errors.push(`manifest: supports.sectionHeadingAlignment '${type}' must also be declared in supports.sections`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
for (const type of Array.isArray(manifest?.supports?.sectionCollectionLinkVisibility) ? manifest.supports.sectionCollectionLinkVisibility : []) {
|
|
323
|
+
if (!(manifest?.supports?.sections ?? []).includes(type)) errors.push(`manifest: supports.sectionCollectionLinkVisibility '${type}' must also be declared in supports.sections`);
|
|
324
|
+
}
|
|
248
325
|
const declaredIslands = new Set(manifest?.supports?.islands ?? []);
|
|
249
326
|
const placedIslands = new Set();
|
|
250
327
|
// heroImagery proof state (filled by the homeHero renders below).
|
|
251
328
|
let homeHeroMultiShows = null; // sample (several photos): probe in html OR hero_carousel placed
|
|
252
329
|
let homeHeroSingleShows = null; // single-photo variant: probe rendered directly
|
|
330
|
+
let homeHeroShowsMultiple = false; // explicit multi-photo capability, distinct rendered images
|
|
253
331
|
|
|
254
332
|
// V2 collection sections render the supplied bounded envelopes, without independently
|
|
255
333
|
// fetching legacy display islands. The populated fixture's sentinel must appear, and the
|
|
@@ -310,6 +388,25 @@ export async function validateArtifact(files) {
|
|
|
310
388
|
errors.push(`section '${type}': does not parse under the dialect, ${e.message}`);
|
|
311
389
|
continue;
|
|
312
390
|
}
|
|
391
|
+
if (Object.hasOwn(manifest?.supports?.sectionHeadingAlignment ?? {}, type) || /data-p60-heading-align\b/.test(read(file))) {
|
|
392
|
+
if (!Object.hasOwn(manifest?.supports?.sectionHeadingAlignment ?? {}, type)) {
|
|
393
|
+
warnings.push(`section '${type}': data-p60-heading-align is inert without supports.sectionHeadingAlignment.${type}; the editor will not offer heading alignment`);
|
|
394
|
+
}
|
|
395
|
+
errors.push(...await proveHeadingAlignment(content => liquid.render(parsed, {
|
|
396
|
+
section: resolveSectionFixture({ type, content }, siteFx, manifest),
|
|
397
|
+
site: siteFx,
|
|
398
|
+
...(contextContract.fixtures.sections?.[type] ?? {})
|
|
399
|
+
}), entry, manifest?.supports?.sectionHeadingAlignment?.[type], { inheritedHeading: siteFx.content[type]?.label }));
|
|
400
|
+
}
|
|
401
|
+
if ((Array.isArray(manifest?.supports?.sectionCollectionLinkVisibility) && manifest.supports.sectionCollectionLinkVisibility.includes(type)) || /data-p60-collection-link\b/.test(read(file))) {
|
|
402
|
+
const declared = Array.isArray(manifest?.supports?.sectionCollectionLinkVisibility) && manifest.supports.sectionCollectionLinkVisibility.includes(type);
|
|
403
|
+
const collection = type === 'appealGrid' ? 'causes' : type;
|
|
404
|
+
errors.push(...await proveCollectionLinkVisibility((content, href) => {
|
|
405
|
+
const site = { ...siteFx, content: { ...siteFx.content, [collection]: { ...siteFx.content[collection], href } } };
|
|
406
|
+
return liquid.render(parsed, { section: resolveSectionFixture({ type, content }, site, manifest), site });
|
|
407
|
+
}, entry, declared));
|
|
408
|
+
if (!declared) warnings.push(`section '${type}': data-p60-collection-link is inert without supports.sectionCollectionLinkVisibility; the editor will not offer visibility`);
|
|
409
|
+
}
|
|
313
410
|
// Widget-section proof (independent of the minimal/sample loop): island placed → the island
|
|
314
411
|
// owns everything; hand-rendered → sentinel appears with data, vanishes without.
|
|
315
412
|
const widget = WIDGET_SECTIONS[type];
|
|
@@ -317,7 +414,7 @@ export async function validateArtifact(files) {
|
|
|
317
414
|
const dataFixture = contextContract.fixtures.sections?.[type] ?? {};
|
|
318
415
|
try {
|
|
319
416
|
const populated = await liquid.render(parsed, {
|
|
320
|
-
section: resolveSectionFixture({ type, content: {} }, siteFx),
|
|
417
|
+
section: resolveSectionFixture({ type, content: {} }, siteFx, manifest),
|
|
321
418
|
site: siteFx,
|
|
322
419
|
...dataFixture
|
|
323
420
|
});
|
|
@@ -350,7 +447,7 @@ export async function validateArtifact(files) {
|
|
|
350
447
|
const fixture = entry[fixtureName] ?? {};
|
|
351
448
|
try {
|
|
352
449
|
const html = await liquid.render(parsed, {
|
|
353
|
-
section: resolveSectionFixture({ type, content: fixture }, siteFx),
|
|
450
|
+
section: resolveSectionFixture({ type, content: fixture }, siteFx, manifest),
|
|
354
451
|
site: siteFx,
|
|
355
452
|
// Widget data rides the ordinary fixture renders too, so a hand-rendering section
|
|
356
453
|
// doesn't fail the generic pass for want of its context.
|
|
@@ -362,6 +459,24 @@ export async function validateArtifact(files) {
|
|
|
362
459
|
const probe = (fixture.images ?? [])[0]?.imageUrl ?? 'p60fixture:';
|
|
363
460
|
homeHeroMultiShows = html.includes(probe)
|
|
364
461
|
|| splitIslandParts(html).some((p) => p.island === 'hero_carousel');
|
|
462
|
+
// Prove the advertised capacity, not just the two-photo catalogue sample. Real image
|
|
463
|
+
// attributes prevent comments, copied text or repeated first images from opting in.
|
|
464
|
+
const limit = manifest?.supports?.heroImageLimit;
|
|
465
|
+
if (Number.isInteger(limit) && limit > 1 && limit <= 6) {
|
|
466
|
+
const images = Array.from({ length: limit }, (_, index) => ({
|
|
467
|
+
...(fixture.images?.[index % fixture.images.length] ?? {}), imageUrl: `p60fixture:hero-capacity-${index + 1}.jpg`
|
|
468
|
+
}));
|
|
469
|
+
const capacity = await liquid.render(parsed, {
|
|
470
|
+
section: resolveSectionFixture({ type, content: { ...fixture, images } }, siteFx, manifest), site: siteFx
|
|
471
|
+
});
|
|
472
|
+
const { root, nodes } = parsePresentationMarkup(capacity);
|
|
473
|
+
const rendered = [root, ...nodes].filter(heroNodeCanRender);
|
|
474
|
+
homeHeroShowsMultiple = rendered.some(node => node.children.some(child => typeof child === 'string'
|
|
475
|
+
&& splitIslandParts(child).some(part => part.island === 'hero_carousel')))
|
|
476
|
+
|| images.every(image => rendered.some(node =>
|
|
477
|
+
(node.tag === 'img' && node.attrs.src === image.imageUrl)
|
|
478
|
+
|| heroBackgroundShows(node, image.imageUrl)));
|
|
479
|
+
}
|
|
365
480
|
// The single-photo path proven separately: same fixture, first photo only.
|
|
366
481
|
try {
|
|
367
482
|
const single = await liquid.render(parsed, {
|
|
@@ -438,6 +553,9 @@ export async function validateArtifact(files) {
|
|
|
438
553
|
// ride `images`), so they pass undeclared, they just don't earn the badge.
|
|
439
554
|
{
|
|
440
555
|
const declaresHero = manifest?.supports?.heroImagery === true;
|
|
556
|
+
if (declaresHero && manifest?.supports?.heroImageLimit > 1 && !homeHeroShowsMultiple) {
|
|
557
|
+
errors.push(`homeHero: supports.heroImageLimit ${manifest.supports.heroImageLimit} must render all ${manifest.supports.heroImageLimit} distinct fixture photographs or place the hero_carousel island`);
|
|
558
|
+
}
|
|
441
559
|
if (declaresHero && !(manifest?.supports?.sections ?? []).includes('homeHero')) {
|
|
442
560
|
errors.push('manifest: supports.heroImagery requires the homeHero section, the photographs live on it');
|
|
443
561
|
} else if (homeHeroMultiShows !== null) {
|
|
@@ -460,6 +578,9 @@ export async function validateArtifact(files) {
|
|
|
460
578
|
if (manifest?.supports?.navigationHighlights && !manifest?.supports?.layout) {
|
|
461
579
|
errors.push('manifest: supports.navigationHighlights requires supports.layout, highlights belong to navigation chrome');
|
|
462
580
|
}
|
|
581
|
+
if (manifest?.supports?.navigationModes && !manifest?.supports?.layout) {
|
|
582
|
+
errors.push('manifest: supports.navigationModes requires supports.layout');
|
|
583
|
+
}
|
|
463
584
|
if (manifest?.supports?.layout) {
|
|
464
585
|
if (!has('layout.liquid')) {
|
|
465
586
|
errors.push('layout: manifest declares supports.layout but layout.liquid is missing');
|
|
@@ -488,8 +609,11 @@ export async function validateArtifact(files) {
|
|
|
488
609
|
warnings.push("layout: no {% island 'member_menu' %}, member sign-in will be unreachable on tenants that allow sign-ups; place it in your header");
|
|
489
610
|
}
|
|
490
611
|
|
|
612
|
+
errors.push(...await proveNavigationModes((saved, header) => liquid.render(parsedLayout, {
|
|
613
|
+
site: { ...siteFx, nav: { ...siteFx.nav, header, headerMode: resolvedNavigationMode(manifest, saved) } }
|
|
614
|
+
}), manifest?.supports?.navigationModes, read('assets/theme.css') ?? ''));
|
|
491
615
|
const highlights = await proveNavigationHighlights((nav) => liquid.render(parsedLayout, {
|
|
492
|
-
site: { ...siteFx, nav: { header: nav.items.map(item => ({ ...item, kind: 'link', children: item.children.map(child => ({ ...child, kind: 'link' })) })), footer: [] } },
|
|
616
|
+
site: { ...siteFx, nav: { ...siteFx.nav, ...(manifest?.supports?.navigationModes ? { headerMode: resolvedNavigationMode(manifest, 'mega') } : {}), header: nav.items.map(item => ({ ...item, kind: 'link', children: item.children.map(child => ({ ...child, kind: 'link' })) })), footer: [] } },
|
|
493
617
|
}), manifest?.supports?.navigationHighlights);
|
|
494
618
|
errors.push(...highlights.errors);
|
|
495
619
|
warnings.push(...highlights.warnings);
|