@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.
- package/package.json +3 -2
- package/src/commands/content.mjs +15 -4
- package/src/commands/dev.mjs +37 -21
- package/src/lib/previewOptions.mjs +70 -0
- package/src/vendor/contract/v1/behaviours.json +235 -44
- package/src/vendor/contract/v1/manifest.schema.json +3 -0
- package/src/vendor/validator/behaviors-runtime.js +1 -1
- package/src/vendor/validator/fonts.mjs +53 -0
- package/src/vendor/validator/icons.mjs +37 -0
- package/src/vendor/validator/platform-base.css +23 -0
- package/src/vendor/validator/preview.mjs +116 -40
- package/src/vendor/validator/site-context.mjs +134 -8
- package/src/vendor/validator/validate.mjs +10 -0
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
// `
|
|
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
|
-
|
|
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
|