@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.
@@ -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);
@@ -30,6 +30,7 @@
30
30
  ],
31
31
  "layout": true,
32
32
  "heroImagery": true,
33
+ "heroImageLimit": 6,
33
34
  "behaviors": [
34
35
  "reveal",
35
36
  "carousel",