@port60/template-kit 0.19.0 → 0.20.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.
@@ -8,16 +8,38 @@ import sectionCatalogue from '../contract/v1/sections.json' with { type: 'json'
8
8
  export { contentModel, extractContentFootprint } from '../engine/content-footprint.mjs';
9
9
  import { contentModel } from '../engine/content-footprint.mjs';
10
10
 
11
- /** The canonical site fixture with `about` composed for this manifest's declared sections. */
12
- export function buildSiteFixture(manifest) {
13
- const base = contextContract.fixtures.site;
14
- const catalogueByType = new Map(sectionCatalogue.sections.map((s) => [s.type, s]));
15
- const about = (manifest?.supports?.sections ?? [])
11
+ const catalogueByType = new Map(sectionCatalogue.sections.map((s) => [s.type, s]));
12
+
13
+ /** The section-based pages the contract knows (manifest supports.pages values). */
14
+ export const PAGE_KEYS = ['home', 'about'];
15
+
16
+ /**
17
+ * A page's default composition for this manifest: the declared sections the catalogue assigns to
18
+ * that page, in manifest order, each with its sample content. This is what the kit preview renders
19
+ * for /home and /about and what `content` ejects, so an author edits copy in place; a production
20
+ * site's composition is admin-authored, and the preview-content `pages` block plays that role.
21
+ */
22
+ export function composePage(manifest, page) {
23
+ return (manifest?.supports?.sections ?? [])
16
24
  .map((type) => {
17
25
  const entry = catalogueByType.get(type);
18
- return entry ? { type, content: entry.sample ?? {} } : null;
26
+ return entry && (entry.pages ?? []).includes(page) ? { type, content: entry.sample ?? {} } : null;
19
27
  })
20
28
  .filter(Boolean);
29
+ }
30
+
31
+ /** The canonical site fixture with `about` composed for this manifest's declared sections (every
32
+ * declared section when no page is named, the page's own composition when one is). */
33
+ export function buildSiteFixture(manifest, { page = null } = {}) {
34
+ const base = contextContract.fixtures.site;
35
+ const about = page
36
+ ? composePage(manifest, page)
37
+ : (manifest?.supports?.sections ?? [])
38
+ .map((type) => {
39
+ const entry = catalogueByType.get(type);
40
+ return entry ? { type, content: entry.sample ?? {} } : null;
41
+ })
42
+ .filter(Boolean);
21
43
  return { ...base, content: { ...base.content, about } };
22
44
  }
23
45
 
@@ -27,6 +49,74 @@ export function buildSiteFixture(manifest) {
27
49
  // so custom data can never dodge a gate; this file only feeds the dev preview.
28
50
 
29
51
  const BRAND_FIELDS = new Set(['name', 'tagline', 'logoUrl', 'logoType', 'footerLogoUrl']);
52
+ const NAV_ITEM_FIELDS = new Set(['label', 'href', 'cta', 'children', 'group', 'description', 'imageUrl', 'megaMenu', 'type']);
53
+
54
+ function validateNavItems(items, path, errors, depth = 0) {
55
+ if (!Array.isArray(items)) {
56
+ errors.push(`preview-content.json: ${path} must be an array of menu items`);
57
+ return;
58
+ }
59
+ items.forEach((item, i) => {
60
+ const at = `${path}[${i}]`;
61
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) {
62
+ errors.push(`preview-content.json: ${at} must be an object`);
63
+ return;
64
+ }
65
+ for (const field of Object.keys(item)) {
66
+ if (!NAV_ITEM_FIELDS.has(field)) errors.push(`preview-content.json: ${at}.${field} does not exist on a menu item (${[...NAV_ITEM_FIELDS].join(', ')})`);
67
+ }
68
+ if (typeof item.label !== 'string' || !item.label) errors.push(`preview-content.json: ${at}.label is required`);
69
+ if (typeof item.href !== 'string') errors.push(`preview-content.json: ${at}.href is required`);
70
+ if (item.children !== undefined) {
71
+ if (depth >= 2) errors.push(`preview-content.json: ${at}.children nests deeper than the platform renders`);
72
+ else validateNavItems(item.children, `${at}.children`, errors, depth + 1);
73
+ }
74
+ });
75
+ }
76
+
77
+ /** A page composition entry against the section catalogue: the type must exist and its content
78
+ * fields must be ones the section declares (items entries against itemFields). */
79
+ function validatePageSections(sections, path, errors) {
80
+ if (!Array.isArray(sections)) {
81
+ errors.push(`preview-content.json: ${path} must be an array of { type, content } sections`);
82
+ return;
83
+ }
84
+ sections.forEach((section, i) => {
85
+ const at = `${path}[${i}]`;
86
+ if (section === null || typeof section !== 'object' || Array.isArray(section)) {
87
+ errors.push(`preview-content.json: ${at} must be an object with type and content`);
88
+ return;
89
+ }
90
+ const entry = catalogueByType.get(section.type);
91
+ if (!entry) {
92
+ errors.push(`preview-content.json: ${at}.type '${section.type}' is not a section in the catalogue`);
93
+ return;
94
+ }
95
+ const content = section.content ?? {};
96
+ if (content === null || typeof content !== 'object' || Array.isArray(content)) {
97
+ errors.push(`preview-content.json: ${at}.content must be an object`);
98
+ return;
99
+ }
100
+ const fields = new Map((entry.fields ?? []).map((f) => [f.name, f]));
101
+ for (const [name, value] of Object.entries(content)) {
102
+ const field = fields.get(name);
103
+ if (!field) {
104
+ errors.push(`preview-content.json: ${at}.content.${name} is not a field of the ${section.type} section`);
105
+ continue;
106
+ }
107
+ if (field.kind === 'items' && Array.isArray(value)) {
108
+ const itemFields = new Set((field.itemFields ?? []).map((f) => f.name));
109
+ value.forEach((item, j) => {
110
+ if (item && typeof item === 'object') {
111
+ for (const k of Object.keys(item)) {
112
+ if (!itemFields.has(k)) errors.push(`preview-content.json: ${at}.content.${name}[${j}].${k} is not an item field of the ${section.type} section`);
113
+ }
114
+ }
115
+ });
116
+ }
117
+ }
118
+ });
119
+ }
30
120
 
31
121
  export function validatePreviewContent(json) {
32
122
  const errors = [];
@@ -34,7 +124,34 @@ export function validatePreviewContent(json) {
34
124
  return ['preview-content.json: must be an object of { collection: [items] } overrides'];
35
125
  }
36
126
  for (const [name, items] of Object.entries(json)) {
37
- // `brand` is the one non-collection override: the organisation's own name and marks.
127
+ // `nav` is the menu the layout renders (the shape of site.nav), `pages` the section
128
+ // compositions of the home and about pages (the admin-authored composition, previewed).
129
+ if (name === 'nav') {
130
+ if (items === null || typeof items !== 'object' || Array.isArray(items)) {
131
+ errors.push('preview-content.json: nav must be an object with an items array');
132
+ continue;
133
+ }
134
+ for (const field of Object.keys(items)) {
135
+ if (field !== 'items') errors.push(`preview-content.json: nav.${field} does not exist — nav carries items`);
136
+ }
137
+ validateNavItems(items.items, 'nav.items', errors);
138
+ continue;
139
+ }
140
+ if (name === 'pages') {
141
+ if (items === null || typeof items !== 'object' || Array.isArray(items)) {
142
+ errors.push('preview-content.json: pages must be an object keyed by page (home, about)');
143
+ continue;
144
+ }
145
+ for (const [page, sections] of Object.entries(items)) {
146
+ if (!PAGE_KEYS.includes(page)) {
147
+ errors.push(`preview-content.json: pages.${page} is not a section-based page (${PAGE_KEYS.join(', ')})`);
148
+ continue;
149
+ }
150
+ validatePageSections(sections, `pages.${page}`, errors);
151
+ }
152
+ continue;
153
+ }
154
+ // `brand` is the one other non-collection override: the organisation's own name and marks.
38
155
  if (name === 'brand') {
39
156
  if (items === null || typeof items !== 'object' || Array.isArray(items)) {
40
157
  errors.push('preview-content.json: brand must be an object');
@@ -93,5 +210,14 @@ export function applyPreviewContent(site, json) {
93
210
  const brand = json.brand && typeof json.brand === 'object' && !Array.isArray(json.brand)
94
211
  ? { ...site.brand, ...json.brand }
95
212
  : site.brand;
96
- return { ...site, brand, content };
213
+ const nav = json.nav && typeof json.nav === 'object' && Array.isArray(json.nav.items)
214
+ ? { ...site.nav, items: json.nav.items }
215
+ : site.nav;
216
+ return { ...site, brand, nav, content };
217
+ }
218
+
219
+ /** The composition to render for a page: the override's, else the manifest's default. */
220
+ export function pageComposition(manifest, page, json) {
221
+ const override = json?.pages?.[page];
222
+ return Array.isArray(override) ? override.map((s) => ({ type: s.type, content: s.content ?? {} })) : composePage(manifest, page);
97
223
  }
@@ -185,6 +185,16 @@ export async function validateArtifact(files) {
185
185
  if (/data-p60-nav-(item|burger)\b/.test(liquidSource) && !/data-p60-nav(?!-)\b/.test(liquidSource)) {
186
186
  errors.push("behaviour 'nav': data-p60-nav-item and data-p60-nav-burger need a data-p60-nav root around the menu");
187
187
  }
188
+ if (/data-p60-show-more(?!-)\b/.test(liquidSource)
189
+ && (!/data-p60-show-more-item\b/.test(liquidSource) || !/data-p60-show-more-toggle\b/.test(liquidSource))) {
190
+ errors.push("behaviour 'showMore': a data-p60-show-more list needs data-p60-show-more-item entries and a data-p60-show-more-toggle button");
191
+ }
192
+ if (/data-p60-show-more-toggle\b/.test(liquidSource) && !/<button\b[^>]*data-p60-show-more-toggle\b/.test(liquidSource)) {
193
+ errors.push("behaviour 'showMore': data-p60-show-more-toggle belongs on a <button> (a link would navigate; a div would not be operable)");
194
+ }
195
+ if (/data-p60-scrollspy\b/.test(liquidSource) && !/href=["']#[^"'\s]/.test(liquidSource)) {
196
+ errors.push("behaviour 'scrollspy': a data-p60-scrollspy menu needs links to in-page anchors (href=\"#section-id\")");
197
+ }
188
198
  }
189
199
 
190
200
  // Font knobs: the default family must be a real catalogue entry (the tenant-unset render uses