@port60/template-kit 1.2.1 → 1.3.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.
Files changed (50) hide show
  1. package/README.md +38 -1
  2. package/package.json +1 -1
  3. package/src/commands/create.mjs +11 -0
  4. package/src/lib/agentsMd.mjs +176 -0
  5. package/src/vendor/contract/v2/dialect.json +2 -1
  6. package/src/vendor/contract/v2/manifest.schema.json +73 -2
  7. package/src/vendor/contract/v2/presentation.json +107 -0
  8. package/src/vendor/contract/v2/sections.json +120 -4
  9. package/src/vendor/contract/v2.lock.json +559 -7
  10. package/src/vendor/engine/budgets.mjs +0 -6
  11. package/src/vendor/engine/colour-roles.mjs +68 -0
  12. package/src/vendor/engine/content-footprint.mjs +0 -11
  13. package/src/vendor/engine/design-settings.mjs +34 -0
  14. package/src/vendor/engine/dialect.mjs +3 -10
  15. package/src/vendor/engine/locale.mjs +0 -5
  16. package/src/vendor/engine/majors.mjs +0 -5
  17. package/src/vendor/engine/presentation-capabilities.mjs +0 -2
  18. package/src/vendor/engine/section-fields.mjs +64 -0
  19. package/src/vendor/engine/section-heading-alignment.mjs +0 -2
  20. package/src/vendor/engine/section-presentation.mjs +127 -0
  21. package/src/vendor/validator/behaviors-runtime.js +1 -1
  22. package/src/vendor/validator/collection-link-visibility.mjs +0 -1
  23. package/src/vendor/validator/colour-treatment.mjs +154 -0
  24. package/src/vendor/validator/fixture-art.mjs +0 -15
  25. package/src/vendor/validator/focus.mjs +0 -5
  26. package/src/vendor/validator/fonts.mjs +0 -6
  27. package/src/vendor/validator/heading-alignment.mjs +0 -3
  28. package/src/vendor/validator/icons.mjs +0 -4
  29. package/src/vendor/validator/image-overlay.mjs +141 -0
  30. package/src/vendor/validator/intro-photo-framing.mjs +101 -0
  31. package/src/vendor/validator/model-reference-v2.mjs +0 -4
  32. package/src/vendor/validator/model-reference.mjs +0 -4
  33. package/src/vendor/validator/navigation-highlights.mjs +0 -3
  34. package/src/vendor/validator/presentation-proof.mjs +0 -2
  35. package/src/vendor/validator/preview-v1.mjs +3 -103
  36. package/src/vendor/validator/preview-v2.mjs +11 -105
  37. package/src/vendor/validator/section-fields.mjs +41 -0
  38. package/src/vendor/validator/section-layout.mjs +156 -0
  39. package/src/vendor/validator/section-presentation.mjs +225 -0
  40. package/src/vendor/validator/site-context-v2.mjs +17 -2
  41. package/src/vendor/validator/site-context.mjs +0 -14
  42. package/src/vendor/validator/validate-v1.mjs +4 -98
  43. package/src/vendor/validator/validate-v2.mjs +44 -103
  44. package/src/vendor/validator/validate.mjs +0 -2
  45. package/starter/assets/theme.css +55 -0
  46. package/starter/manifest.json +20 -0
  47. package/starter/sections/cta.liquid +2 -2
  48. package/starter/sections/hero.liquid +5 -3
  49. package/starter/sections/homeHero.liquid +6 -6
  50. package/starter/sections/values.liquid +2 -2
@@ -1,14 +1,3 @@
1
- // The Port60 template CONFORMANCE VALIDATOR as a PURE MODULE (developer program T1.3): the same
2
- // checks the publish-time CLI has always run, callable with an in-memory file map, no fs, no
3
- // argv, no process.exit, so the studio upload lane (an HTTP endpoint) and the CLI share ONE
4
- // implementation, and "validated ⇒ renders in production" keeps holding: the Liquid instance is
5
- // configured identically to the engine's, dialect enforcement is the same shared module, and the
6
- // render budgets here match production's.
7
- //
8
- // validateArtifact(files) → { errors: string[], warnings: string[], manifest: object|null }
9
- //
10
- // `files` is a plain object of artifact-relative path → string content (manifest.json,
11
- // layout.liquid, sections/*.liquid, pages/*.liquid, assets/theme.css).
12
1
  import { Liquid } from 'liquidjs';
13
2
  import Ajv2020 from 'ajv/dist/2020.js';
14
3
  import { CONTENT_SLOT, configureDialect, splitIslandParts } from '../engine/dialect.mjs';
@@ -29,11 +18,6 @@ const Ajv = Ajv2020.default ?? Ajv2020;
29
18
 
30
19
  export { dialect as contractDialect, sectionCatalogue, islandRegistry, contextContract, behaviourCatalogue };
31
20
 
32
- // The behaviour to opt-in-attribute map is DERIVED from the catalogue (FR-5): `primaryAttribute` on
33
- // each entry feeds the checks below, the generated reference and the kit, so adding a behaviour is
34
- // one catalogue entry plus its runtime initialiser. The trailing word boundary keeps the original
35
- // semantics: a companion attribute that starts with the primary one (data-p60-reveal-group,
36
- // data-p60-nav-item) counts as the behaviour in use.
37
21
  const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
38
22
  export const BEHAVIOUR_PRIMARY_ATTR = Object.fromEntries(
39
23
  behaviourCatalogue.behaviours.map((b) => [b.name, b.primaryAttribute])
@@ -42,10 +26,6 @@ const PRIMARY_ATTR = Object.fromEntries(
42
26
  behaviourCatalogue.behaviours.map((b) => [b.name, [new RegExp(`${escapeRegExp(b.primaryAttribute)}\\b`), b.primaryAttribute]])
43
27
  );
44
28
 
45
- // Templates are markup and attributes, NEVER code (docs/template-behaviours.md). These are hard
46
- // errors over the RAW liquid source, even inside comments, because there is no legitimate reason
47
- // for the tokens to appear at all. The handler pattern names real DOM event families rather than
48
- // matching any on* word, so attributes like `once` or `online` never false-positive.
49
29
  const FORBIDDEN_MARKUP = [
50
30
  [/<script\b/i, 'a <script> tag'],
51
31
  [/<(iframe|object|embed)\b/i, 'an embedded frame or plugin element'],
@@ -60,7 +40,6 @@ export async function validateArtifact(files) {
60
40
  const has = (path) => Object.hasOwn(files, path);
61
41
  const read = (path) => files[path];
62
42
 
63
- // 1. Manifest against the schema.
64
43
  let manifest = null;
65
44
  if (!has('manifest.json')) {
66
45
  return { errors: ['manifest.json is missing, every artifact starts with its manifest'], warnings, manifest };
@@ -70,14 +49,10 @@ export async function validateArtifact(files) {
70
49
  } catch (e) {
71
50
  return { errors: [`manifest.json unreadable: ${e.message}`], warnings, manifest: null };
72
51
  }
73
- // preview-content.json is a DEV-ONLY data override: package excludes it and the intake
74
- // refuses it, an artifact must never carry data, only shape.
75
52
  if (has('preview-content.json')) {
76
53
  errors.push('preview-content.json: development preview data never ships in an artifact, remove it (package excludes it automatically)');
77
54
  }
78
55
 
79
- // Content model v1: the footprint is decidable from the sources (closed dialect); unknown
80
- // paths, dynamic indexing and tree aliasing are errors from site-context.mjs.
81
56
  const contentAnalysis = extractContentFootprint(files);
82
57
  errors.push(...contentAnalysis.errors);
83
58
  const siteFx = buildSiteFixture(manifest);
@@ -90,10 +65,6 @@ export async function validateArtifact(files) {
90
65
  }
91
66
  }
92
67
 
93
- // Capability declarations are catalogue MATCHING metadata, not an entitlement shortcut. Keep
94
- // them honest by requiring one corresponding template-facing surface. The mapping is deliberately
95
- // structural: it proves the design can present a capability without inspecting tenant data or
96
- // crossing the platform-owned transaction, identity and consent boundaries.
97
68
  {
98
69
  const sections = new Set(manifest?.supports?.sections ?? []);
99
70
  const islands = new Set(manifest?.supports?.islands ?? []);
@@ -123,10 +94,6 @@ export async function validateArtifact(files) {
123
94
  }
124
95
  }
125
96
 
126
- // Site focus honesty (docs/volunteering.md): a template that claims it can lead with
127
- // volunteering must give the volunteer sign-up a way into the hero, either the
128
- // primary_action_widget island (which becomes the sign-up when volunteering leads) or the
129
- // volunteer_signup island placed directly.
130
97
  {
131
98
  const focusKinds = new Set(manifest?.supports?.focus ?? []);
132
99
  if (focusKinds.has('volunteer')) {
@@ -137,9 +104,6 @@ export async function validateArtifact(files) {
137
104
  }
138
105
  }
139
106
 
140
- // Markup and attributes, NEVER code, the machine-enforced JavaScript ban over every liquid
141
- // source (docs/template-behaviours.md). Behaviour is engine-owned; a template wanting motion
142
- // declares supports.behaviors and uses the data-p60-* grammar.
143
107
  for (const [path, source] of Object.entries(files)) {
144
108
  if (!path.endsWith('.liquid')) continue;
145
109
  for (const [pattern, what] of FORBIDDEN_MARKUP) {
@@ -149,9 +113,6 @@ export async function validateArtifact(files) {
149
113
  }
150
114
  }
151
115
 
152
- // Behaviour declaration and usage must agree in BOTH directions. Source-level, deliberately:
153
- // usage often sits inside content-dependent branches the fixtures never take, so the grammar's
154
- // presence in the source is the honest minimal proof.
155
116
  {
156
117
  const declaredBehaviours = new Set(manifest?.supports?.behaviors ?? []);
157
118
  const liquidSource = Object.entries(files)
@@ -198,8 +159,6 @@ export async function validateArtifact(files) {
198
159
  }
199
160
  }
200
161
 
201
- // Font knobs: the default family must be a real catalogue entry (the tenant-unset render uses
202
- // it), and the slot must declare the weights the template's typographic system needs.
203
162
  {
204
163
  const familyNames = new Set(fontCatalogue.families.map((f) => f.name));
205
164
  for (const knob of manifest?.settings?.schema ?? []) {
@@ -212,8 +171,6 @@ export async function validateArtifact(files) {
212
171
  }
213
172
  }
214
173
 
215
- // Looks: every value must target a declared knob and be valid for it, a look that half-applies
216
- // would leave the tenant in a state no author designed.
217
174
  {
218
175
  const knobByKey = new Map((manifest?.settings?.schema ?? []).map((k) => [k.key, k]));
219
176
  const familyNames = new Set(fontCatalogue.families.map((f) => f.name));
@@ -236,8 +193,6 @@ export async function validateArtifact(files) {
236
193
  }
237
194
  }
238
195
 
239
- // Engine identical to production: escape-by-default, strict filters, restricted dialect,
240
- // and the SAME DoS budgets the live renderer runs with.
241
196
  const availableIslands = new Set(
242
197
  islandRegistry.islands.filter((i) => i.status === 'available').map((i) => i.name)
243
198
  );
@@ -248,18 +203,9 @@ export async function validateArtifact(files) {
248
203
  const catalogueByType = new Map(sectionCatalogue.sections.map((s) => [s.type, s]));
249
204
  const declaredIslands = new Set(manifest?.supports?.islands ?? []);
250
205
  const placedIslands = new Set();
251
- // heroImagery proof state (filled by the homeHero renders below).
252
- let homeHeroMultiShows = null; // sample (several photos): probe in html OR hero_carousel placed
253
- let homeHeroSingleShows = null; // single-photo variant: probe rendered directly
206
+ let homeHeroMultiShows = null;
207
+ let homeHeroSingleShows = null;
254
208
 
255
- // Author-renderable widget sections (the flip): each carries a curated data context; a template
256
- // either places the section's DEFAULT ISLAND (which owns rendering + empty states) or renders
257
- // the data itself, in which case both directions are proven behaviourally (the worship
258
- // pattern): the populated fixture's sentinel must appear, and the EMPTY context must render it
259
- // away (derive or omit, nothing invented, nothing dangling).
260
- // Sentinels are DERIVED from the canonical fixtures (the first item's display field), never
261
- // hardcoded, the fixture data is free to become richer without touching a proof, and a proof
262
- // can never drift from the data it renders.
263
209
  const sentinelOf = (type, dataKey, field) =>
264
210
  (contextContract.fixtures.sections?.[type]?.[dataKey] ?? [])[0]?.[field] ?? null;
265
211
  const WIDGET_SECTIONS = {
@@ -271,8 +217,6 @@ export async function validateArtifact(files) {
271
217
  locations: { island: null, dataKey: 'locations', sentinel: sentinelOf('locations', 'locations', 'name') }
272
218
  };
273
219
 
274
- // 2b. Compositions (site editor stage 4): each page must be a supported page, each type a
275
- // supported section the catalogue assigns to that page, listed once, with a role.
276
220
  const compositions = manifest?.compositions ?? {};
277
221
  for (const [page, entries] of Object.entries(compositions)) {
278
222
  if (!(manifest?.supports?.pages ?? []).includes(page)) {
@@ -295,7 +239,6 @@ export async function validateArtifact(files) {
295
239
  }
296
240
  }
297
241
 
298
- // 2-4. Sections: catalogue membership, parse, fixture renders.
299
242
  for (const type of manifest?.supports?.sections ?? []) {
300
243
  const entry = catalogueByType.get(type);
301
244
  if (!entry) {
@@ -314,8 +257,6 @@ export async function validateArtifact(files) {
314
257
  errors.push(`section '${type}': does not parse under the dialect, ${e.message}`);
315
258
  continue;
316
259
  }
317
- // Widget-section proof (independent of the minimal/sample loop): island placed → the island
318
- // owns everything; hand-rendered → sentinel appears with data, vanishes without.
319
260
  const widget = WIDGET_SECTIONS[type];
320
261
  if (widget) {
321
262
  const dataFixture = contextContract.fixtures.sections?.[type] ?? {};
@@ -360,17 +301,12 @@ export async function validateArtifact(files) {
360
301
  section: fixture,
361
302
  brand: contextContract.fixtures.brand,
362
303
  site: siteFx,
363
- // Widget data rides the ordinary fixture renders too, so a hand-rendering section
364
- // doesn't fail the generic pass for want of its context.
365
304
  ...(widget ? (contextContract.fixtures.sections?.[type] ?? {}) : {})
366
305
  });
367
306
  if (type === 'homeHero' && fixtureName === 'sample') {
368
- // The sample's own first image reference is the probe: `p60fixture:` refs are quote-free
369
- // and survive HTML escaping, so "does the hero display the photos?" stays a substring check.
370
307
  const probe = (fixture.images ?? [])[0]?.imageUrl ?? 'p60fixture:';
371
308
  homeHeroMultiShows = html.includes(probe)
372
309
  || splitIslandParts(html).some((p) => p.island === 'hero_carousel');
373
- // The single-photo path proven separately: same fixture, first photo only.
374
310
  try {
375
311
  const single = await liquid.render(parsed, {
376
312
  section: { ...fixture, images: (fixture.images ?? []).slice(0, 1) },
@@ -395,11 +331,6 @@ export async function validateArtifact(files) {
395
331
  }
396
332
  }
397
333
 
398
- // 5. FIELD MARKERS: which node shows which field, so the editor can put the caret on the page
399
- // instead of in a side panel. Source-level, like behaviours: a marker often sits inside a
400
- // content-dependent branch the fixtures never take, and its presence in the source is the honest
401
- // proof. A marker is an address inside that section's own content: `title`, or `items.0.label`
402
- // for a list, where the index is usually a Liquid expression and stands for any position.
403
334
  {
404
335
  const MARKER = /data-p60-field="([^"]*)"/g;
405
336
  const fieldsOf = (type) => new Map((catalogueByType.get(type)?.fields ?? []).map((f) => [f.name, f]));
@@ -409,7 +340,6 @@ export async function validateArtifact(files) {
409
340
  const fields = fieldsOf(type);
410
341
  for (const [, raw] of source.matchAll(MARKER)) {
411
342
  marked.push(type);
412
- // An index written as Liquid stands for whichever item this is.
413
343
  const steps = raw.replace(/\{\{[^}]*\}\}/g, '#').split('.');
414
344
  const field = fields.get(steps[0]);
415
345
  const named = steps.length === 1
@@ -437,14 +367,6 @@ export async function validateArtifact(files) {
437
367
  }
438
368
  }
439
369
 
440
- // Hero-imagery honesty, checked BEHAVIOURALLY (the worship pattern). The homeHero sample
441
- // fixture carries photographs whose data URIs embed a quote-free marker that survives HTML
442
- // escaping, so "does the rendered hero display the tenant's photos?" is a substring check,
443
- // and with several photos, placing the hero_carousel island IS displaying them (the island
444
- // renders the slides at runtime). The single-photo path is proven separately: images[0] must
445
- // appear directly. Declaration and behaviour must agree; the choosers badge photo-led tenants
446
- // by supports.heroImagery. Legacy imageUrl-only renderers never match (the fixture's photos
447
- // ride `images`), so they pass undeclared, they just don't earn the badge.
448
370
  {
449
371
  const declaresHero = manifest?.supports?.heroImagery === true;
450
372
  if (declaresHero && !(manifest?.supports?.sections ?? []).includes('homeHero')) {
@@ -462,7 +384,6 @@ export async function validateArtifact(files) {
462
384
  }
463
385
  }
464
386
 
465
- // 7. Layout (when declared): parse + render the layout fixture + exactly one content slot.
466
387
  if (manifest?.supports?.worship && !manifest?.supports?.layout) {
467
388
  errors.push('manifest: supports.worship requires supports.layout, the worship rail is layout chrome');
468
389
  }
@@ -513,11 +434,6 @@ export async function validateArtifact(files) {
513
434
  errors.push(...highlights.errors);
514
435
  warnings.push(...highlights.warnings);
515
436
 
516
- // Two-sided worship honesty, checked BEHAVIOURALLY: does the rendered layout actually
517
- // display the worship fixture's times? Declaration and behaviour must agree, the
518
- // choosers steer worship-enabled tenants by supports.worship, so a false declaration
519
- // either hides their times (undeclared but rendered is fine to fix by declaring) or
520
- // promises a rail that never appears.
521
437
  const worshipProbe = contextContract.fixtures.layout.worship?.times?.[0]?.name;
522
438
  if (worshipProbe) {
523
439
  const rendersWorship = html.includes(worshipProbe);
@@ -551,7 +467,6 @@ export async function validateArtifact(files) {
551
467
  warnings.push('layout.liquid present but manifest.supports.layout is not true, it will be ignored');
552
468
  }
553
469
 
554
- // 8. Page templates (when declared): file exists, parses, renders the page's data fixture.
555
470
  for (const pageName of manifest?.supports?.pageTemplates ?? []) {
556
471
  const fixture = contextContract.fixtures.pages?.[pageName];
557
472
  if (!fixture) {
@@ -584,7 +499,6 @@ export async function validateArtifact(files) {
584
499
  }
585
500
  }
586
501
 
587
- // 5. Island discipline: placed ⊆ declared ⊆ registry (and available).
588
502
  for (const name of placedIslands) {
589
503
  if (!declaredIslands.has(name)) {
590
504
  errors.push(`island '${name}': placed in a section but not declared in manifest.supports.islands`);
@@ -598,18 +512,13 @@ export async function validateArtifact(files) {
598
512
  }
599
513
  }
600
514
 
601
- // 6. Theme.
602
515
  if (!has('assets/theme.css') || read('assets/theme.css').trim() === '') {
603
516
  errors.push('assets/theme.css missing or empty, a template must ship its look');
604
517
  }
605
518
 
606
- // 6b. Layout contract (contract/v1/layout.json). Platform pages render through the content SEAM
607
- // (.container / .full); a template STYLES those to place content, never a parallel content container.
608
- // The rule is DATA, the seam token, the allowed selectors and the message all come from the contract
609
- // file; this only implements the check KIND (a non-seam selector sizing its width off the token).
610
519
  {
611
520
  const css = has('assets/theme.css') ? read('assets/theme.css') : '';
612
- const stripped = css.replace(/\/\*[\s\S]*?\*\//g, ' '); // drop comments so an example can't trip it
521
+ const stripped = css.replace(/\/\*[\s\S]*?\*\//g, ' ');
613
522
  const RULE = /([^{}]+)\{([^{}]*)\}/g;
614
523
  for (const rule of layoutContract.rules ?? []) {
615
524
  if (rule.kind !== 'css-width-off-token') continue;
@@ -620,7 +529,7 @@ export async function validateArtifact(files) {
620
529
  let m;
621
530
  RULE.lastIndex = 0;
622
531
  while ((m = RULE.exec(stripped)) !== null) {
623
- if (!sizesOffToken.test(m[2])) continue; // only rules that size a box off the token
532
+ if (!sizesOffToken.test(m[2])) continue;
624
533
  for (const sel of m[1].split(',')) {
625
534
  const s = sel.trim();
626
535
  if (s && !isSeam.test(s)) offenders.add(s);
@@ -632,9 +541,6 @@ export async function validateArtifact(files) {
632
541
  }
633
542
  }
634
543
 
635
- // Open enums, proven survivable (content model v1 discipline 2): a template whose footprint
636
- // reads site.content.events must survive a registration mode it has never heard of, new modes
637
- // WILL arrive within the major. No throw, and no undefined/null literal leaking into markup.
638
544
  if (contentAnalysis.footprint.includes('content.events')) {
639
545
  const futureEvent = {
640
546
  ...(siteFx.content.events[0] ?? {}),