@port60/template-kit 0.21.0 → 1.1.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.
- package/README.md +58 -3
- package/bin/cli.mjs +10 -0
- package/package.json +4 -2
- package/src/commands/content.mjs +4 -3
- package/src/commands/create.mjs +11 -3
- package/src/commands/dev.mjs +9 -3
- package/src/commands/model.mjs +3 -3
- package/src/commands/packageCmd.mjs +1 -1
- package/src/commands/publish.mjs +1 -1
- package/src/commands/refresh.mjs +8 -3
- package/src/commands/release.mjs +22 -0
- package/src/commands/setupPreviews.mjs +19 -0
- package/src/commands/validate.mjs +1 -1
- package/src/lib/agentsMd.mjs +116 -161
- package/src/lib/designer-bridge.js +26 -0
- package/src/lib/designerPalette.mjs +62 -0
- package/src/lib/galleryPosters.mjs +105 -0
- package/src/lib/previewOptions.mjs +1 -1
- package/src/lib/releaseBundle.mjs +141 -0
- package/src/vendor/contract/v2/behaviours.json +346 -0
- package/src/vendor/contract/v2/content-bounds.json +117 -0
- package/src/vendor/contract/v2/content-model.json +766 -0
- package/src/vendor/contract/v2/context.json +1569 -0
- package/src/vendor/contract/v2/dialect.json +107 -0
- package/src/vendor/contract/v2/fonts.json +910 -0
- package/src/vendor/contract/v2/imagery.json +32 -0
- package/src/vendor/contract/v2/islands.json +263 -0
- package/src/vendor/contract/v2/layout.json +28 -0
- package/src/vendor/contract/v2/manifest.schema.json +388 -0
- package/src/vendor/contract/v2/sections.json +947 -0
- package/src/vendor/contract/v2/site.schema.json +1353 -0
- package/src/vendor/contract/v2/tokens.json +101 -0
- package/src/vendor/contract/v2.lock.json +6933 -0
- package/src/vendor/engine/content-footprint.mjs +76 -1
- package/src/vendor/engine/dialect.mjs +4 -0
- package/src/vendor/engine/locale.mjs +74 -0
- package/src/vendor/engine/majors.mjs +42 -0
- package/src/vendor/validator/gift-aid-logo.svg +6 -0
- package/src/vendor/validator/model-reference-v2.mjs +95 -0
- package/src/vendor/validator/platform-base.css +320 -11
- package/src/vendor/validator/preview-v1.mjs +691 -0
- package/src/vendor/validator/preview-v2.mjs +654 -0
- package/src/vendor/validator/preview.mjs +8 -688
- package/src/vendor/validator/site-context-v2.mjs +113 -0
- package/src/vendor/validator/validate-v1.mjs +666 -0
- package/src/vendor/validator/validate-v2.mjs +642 -0
- package/src/vendor/validator/validate.mjs +21 -663
- package/starter/assets/theme.css +3 -0
- package/starter/layout.liquid +15 -18
- package/starter/manifest.json +5 -4
- package/starter/preview/config.json +4 -0
- package/starter/preview/media/README.md +9 -0
- package/starter/sections/campaigns.liquid +5 -4
- package/starter/sections/homeHero.liquid +25 -21
- package/starter/sections/values.liquid +1 -1
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
// the production loader computes the same footprint the validator stamps, the render path
|
|
4
4
|
// fetches precisely what a template reads, and the two can never disagree.
|
|
5
5
|
import contentModel from '../contract/v1/content-model.json' with { type: 'json' };
|
|
6
|
+
import contentModelV2 from '../contract/v2/content-model.json' with { type: 'json' };
|
|
7
|
+
import siteSchemaV2 from '../contract/v2/site.schema.json' with { type: 'json' };
|
|
6
8
|
|
|
7
9
|
export { contentModel };
|
|
10
|
+
export { contentModelV2 };
|
|
8
11
|
|
|
9
12
|
// ── Extraction ─────────────────────────────────────────────────────────
|
|
10
13
|
// The dialect is closed, so the site paths a template reads are decidable from its sources: every
|
|
@@ -29,7 +32,7 @@ function segmentsOf(chain) {
|
|
|
29
32
|
* Returns { footprint: string[], minModelVersion: string|null, errors: string[] }, footprint
|
|
30
33
|
* entries are 'brand' | 'nav' | 'socials' | 'locale' | 'content.<collection>'.
|
|
31
34
|
*/
|
|
32
|
-
|
|
35
|
+
function extractV1ContentFootprint(files) {
|
|
33
36
|
const errors = [];
|
|
34
37
|
const touched = new Set();
|
|
35
38
|
const siblings = new Set(contentModel.siblings.keys);
|
|
@@ -77,3 +80,75 @@ export function extractContentFootprint(files) {
|
|
|
77
80
|
return { footprint: [...touched].sort(), minModelVersion, errors };
|
|
78
81
|
}
|
|
79
82
|
|
|
83
|
+
/** Metadata-only collection reads do not require fetching the collection's records. */
|
|
84
|
+
export function needsCollectionData(footprint, name) {
|
|
85
|
+
return footprint.includes(`content.${name}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function artifactFormat(files) {
|
|
89
|
+
try { return JSON.parse(files['manifest.json'] ?? '{}').format ?? 'port60-liquid@1'; }
|
|
90
|
+
catch { return 'port60-liquid@1'; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The same explicit major decision is used by validation and the public host. */
|
|
94
|
+
export function extractContentFootprint(files, options = {}) {
|
|
95
|
+
const format = options.format ?? artifactFormat(files);
|
|
96
|
+
if (format === 'port60-liquid@1') return extractV1ContentFootprint(files);
|
|
97
|
+
if (format !== 'port60-liquid@2') return { footprint: [], minModelVersion: null, errors: [`Unsupported content-model format '${format}'.`] };
|
|
98
|
+
const errors = [];
|
|
99
|
+
const touched = new Set();
|
|
100
|
+
const siblings = new Set(contentModelV2.siblings.keys);
|
|
101
|
+
for (const [path, source] of Object.entries(files)) {
|
|
102
|
+
if (!path.endsWith('.liquid')) continue;
|
|
103
|
+
const code = source.replace(/\{%[-]?\s*comment\s*[-]?%\}[\s\S]*?\{%[-]?\s*endcomment\s*[-]?%\}/g, '');
|
|
104
|
+
const bindings = new Set([...code.matchAll(/\b(?:assign|for)\s+([A-Za-z_][A-Za-z0-9_]*)\s+(?:=|in)\s+/g)].map(match => match[1]));
|
|
105
|
+
for (const token of code.matchAll(/\{(?:\{[\s\S]*?\}\}|%[\s\S]*?%\})/g)) {
|
|
106
|
+
for (const match of token[0].matchAll(/(?<![.\w])\b(brand|nav|socials|locale|worship|focus|events|infoEvents|latestArticles|campaigns|campaignsLabel|causes|services|courses|resources|locations|relatedArticles|backHref)(?=\.|\[|\s*(?:%\}|\}\}|\|))/g)) {
|
|
107
|
+
if (!bindings.has(match[1])) errors.push(`${path}: flat context '${match[1]}' is not available in v2; read the canonical site tree.`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (DYNAMIC_INDEX.test(source)) errors.push(`${path}: dynamic indexing into site.* is refused; use a named path.`);
|
|
111
|
+
if (BARE_SITE.test(source)) errors.push(`${path}: aliasing the bare site tree is refused; use a named path.`);
|
|
112
|
+
for (const match of source.matchAll(new RegExp(CHAIN.source, 'g'))) {
|
|
113
|
+
const [head, member, ...rest] = segmentsOf(match[1]);
|
|
114
|
+
if (head === 'content') {
|
|
115
|
+
const collection = contentModelV2.collections[member];
|
|
116
|
+
if (!collection) {
|
|
117
|
+
errors.push(`${path}: site.content.${member ?? ''} is not in content model 2.0. Use the canonical collection or site.page.`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (collection.shape === 'array') {
|
|
121
|
+
touched.add(`content.${member}`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const field = rest[0];
|
|
125
|
+
if (field && !['label', 'href', 'items', 'pagination'].includes(field)) {
|
|
126
|
+
errors.push(`${path}: site.content.${member} is an envelope with label, href, items and pagination; '${field}' is not a member.`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (field === 'items') {
|
|
130
|
+
const itemField = ['first', 'last'].includes(rest[1]) ? rest[2] : rest[1];
|
|
131
|
+
if (itemField && !['size', 'first', 'last'].includes(itemField) && !collection.item[itemField]) errors.push(`${path}: '${itemField}' is not a ${member} item field.`);
|
|
132
|
+
touched.add(`content.${member}`);
|
|
133
|
+
} else {
|
|
134
|
+
if (field === 'pagination' && rest[1] && !['page', 'size', 'totalElements', 'totalPages', 'nextHref', 'previousHref'].includes(rest[1])) errors.push(`${path}: '${rest[1]}' is not a pagination field.`);
|
|
135
|
+
touched.add(field ? `content.${member}.${field}` : `content.${member}`);
|
|
136
|
+
}
|
|
137
|
+
} else if (siblings.has(head)) {
|
|
138
|
+
const properties = siteSchemaV2.properties[head]?.properties;
|
|
139
|
+
if (member && properties && !properties[member]) errors.push(`${path}: site.${head}.${member} is not part of the v2 model.`);
|
|
140
|
+
touched.add(head);
|
|
141
|
+
} else errors.push(`${path}: site.${head} is not part of the v2 model.`);
|
|
142
|
+
}
|
|
143
|
+
// Local aliases of nav/collection objects are valid. Retired members are not.
|
|
144
|
+
const retired = [
|
|
145
|
+
[/\b(?:site\.)?nav\.(?:items|derived)\b/, 'nav.header and nav.footer replace nav.items/nav.derived'],
|
|
146
|
+
[/\b(?:site\.)?actions\.(?:primary|secondary)\b/, 'actions.header, actions.hero and actions.widget replace action aliases'],
|
|
147
|
+
[/\bsection\.(?:primary|secondary)\b/, 'use site.actions rather than section action aliases'],
|
|
148
|
+
[/\bmegaMenu\.columns\b|\.megaMenu\.columns\b/, 'the template owns responsive menu columns'],
|
|
149
|
+
[/\b(?:item|link|child|node|navItem)\.cta\b/, 'navigation CTA flags are retired; use site.actions.header']
|
|
150
|
+
];
|
|
151
|
+
for (const [pattern, advice] of retired) if (pattern.test(source)) errors.push(`${path}: ${advice}.`);
|
|
152
|
+
}
|
|
153
|
+
return { footprint: [...touched].sort(), minModelVersion: touched.size ? '2.0' : null, errors };
|
|
154
|
+
}
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// JSON); this module does no file loading.
|
|
6
6
|
|
|
7
7
|
/** Marker protocol for island placement: the island tag emits these; the renderer splits on them. */
|
|
8
|
+
import { templateMessage, templateDate } from './locale.mjs';
|
|
9
|
+
|
|
8
10
|
export const ISLAND_MARK_START = '\u0000P60_ISLAND:';
|
|
9
11
|
export const ISLAND_MARK_END = '\u0000';
|
|
10
12
|
|
|
@@ -31,6 +33,8 @@ export function configureDialect(liquid, dialect, islandNames) {
|
|
|
31
33
|
}
|
|
32
34
|
// Filters LiquidJS ships beyond our whitelist (e.g. its non-standard json/inspect debug filters).
|
|
33
35
|
const allowed = new Set(dialect.filters);
|
|
36
|
+
if (allowed.has('t')) liquid.registerFilter('t', templateMessage);
|
|
37
|
+
if (allowed.has('local_date')) liquid.registerFilter('local_date', templateDate);
|
|
34
38
|
for (const name of ['json', 'inspect', 'to_integer', 'normalize_whitespace', 'find', 'find_exp', 'group_by', 'group_by_exp', 'where_exp', 'sum']) {
|
|
35
39
|
if (!allowed.has(name)) {
|
|
36
40
|
liquid.registerFilter(name, () => {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Template interface copy only. Never translate tenant-authored content or source record labels.
|
|
2
|
+
// Plain JS so the host and kit use the exact same implementation, without network or tenant state.
|
|
3
|
+
const arabic = Object.freeze({
|
|
4
|
+
'Explore': 'تصفّح', 'Menu': 'القائمة', 'Open menu': 'فتح القائمة', 'Close menu': 'إغلاق القائمة',
|
|
5
|
+
'Skip to content': 'انتقل إلى المحتوى', 'Skip to main content': 'انتقل إلى المحتوى الرئيسي',
|
|
6
|
+
'Search the website': 'ابحث في الموقع', 'Search this site': 'ابحث في الموقع',
|
|
7
|
+
'Main': 'التنقل الرئيسي', 'Footer': 'تذييل الصفحة', 'Social links': 'حسابات التواصل',
|
|
8
|
+
'Cookies': 'ملفات تعريف الارتباط', 'Cookie settings': 'إعدادات ملفات تعريف الارتباط',
|
|
9
|
+
'Previous image': 'الصورة السابقة', 'Next image': 'الصورة التالية',
|
|
10
|
+
'Previous campaign': 'الحملة السابقة', 'Next campaign': 'الحملة التالية',
|
|
11
|
+
'At the mosque': 'في المسجد', 'Prayer times': 'مواقيت الصلاة',
|
|
12
|
+
'Prayer and Jumuah times': 'مواقيت الصلاة والجمعة', 'Prayer schedule': 'مواقيت الصلاة',
|
|
13
|
+
'Prayer timetable': 'جدول الصلاة', 'Daily prayer timetable': 'جدول الصلوات اليومية',
|
|
14
|
+
'Daily prayer times': 'مواقيت الصلوات اليومية', 'Daily prayers': 'الصلوات اليومية',
|
|
15
|
+
'Prayer': 'الصلاة', 'Jumuah': 'الجمعة', 'Friday prayer': 'صلاة الجمعة',
|
|
16
|
+
'Friday at the mosque': 'الجمعة في المسجد', 'Prayer information': 'معلومات الصلاة',
|
|
17
|
+
'Full timetable': 'الجدول الكامل', 'Next:': 'الصلاة القادمة:',
|
|
18
|
+
'Begins': 'بداية الوقت', 'Congregation': 'الجماعة', 'Jama’at': 'الجماعة',
|
|
19
|
+
'Read article': 'اقرأ الخبر', 'Read more': 'اقرأ المزيد', 'Find out more': 'اعرف المزيد',
|
|
20
|
+
'Event details': 'تفاصيل الفعالية', 'View event': 'عرض الفعالية',
|
|
21
|
+
'View course': 'عرض الدورة', 'Explore course': 'اكتشف الدورة',
|
|
22
|
+
'View campaign': 'عرض الحملة', 'Explore campaign': 'اكتشف الحملة',
|
|
23
|
+
'Support this campaign': 'ادعم هذه الحملة', 'Online': 'عبر الإنترنت',
|
|
24
|
+
'Tickets not on sale': 'التذاكر غير متاحة للبيع', 'Sold out': 'اكتمل العدد',
|
|
25
|
+
'Free': 'مجاناً', 'From': 'من', 'Registration': 'التسجيل', 'Tickets': 'تذاكر',
|
|
26
|
+
'Get tickets': 'احجز تذكرتك', 'Book a place': 'احجز مكانك',
|
|
27
|
+
'Download': 'تنزيل', 'Read document': 'اقرأ المستند', 'View document': 'عرض المستند',
|
|
28
|
+
'All articles': 'جميع الأخبار', 'All events': 'جميع الفعاليات', 'All courses': 'جميع الدورات',
|
|
29
|
+
'All campaigns': 'جميع الحملات', 'All services': 'جميع الخدمات',
|
|
30
|
+
'Article': 'خبر', 'Course': 'دورة', 'Campaign': 'حملة', 'Service': 'خدمة',
|
|
31
|
+
'Article unavailable': 'الخبر غير متاح', 'Course unavailable': 'الدورة غير متاحة',
|
|
32
|
+
'This article is not available to view.': 'هذا الخبر غير متاح للعرض.',
|
|
33
|
+
'This course is not available to view.': 'هذه الدورة غير متاحة للعرض.',
|
|
34
|
+
'Browse': 'تصفّح', 'Browse courses': 'تصفّح الدورات', 'Back to': 'العودة إلى',
|
|
35
|
+
'Availability': 'التوفّر', 'Course details': 'تفاصيل الدورة', 'Course options': 'خيارات الدورة',
|
|
36
|
+
'Course enrolment': 'التسجيل في الدورة', 'Open article photograph': 'فتح صورة الخبر',
|
|
37
|
+
'Children': 'الأطفال', 'Adults': 'البالغون', 'Families': 'العائلات', 'Everyone': 'الجميع',
|
|
38
|
+
'Starts': 'البداية', 'Ends': 'النهاية', 'Monthly': 'شهرياً', 'One payment': 'دفعة واحدة',
|
|
39
|
+
'No places currently available': 'لا توجد أماكن متاحة حالياً', 'Not supplied': 'غير محدد',
|
|
40
|
+
'Payment': 'الدفع', 'Price': 'السعر', 'Term fee': 'رسوم الفصل', 'Updated': 'آخر تحديث',
|
|
41
|
+
'Where': 'المكان', 'Who it is for': 'الفئة المستهدفة', 'Open document': 'فتح المستند',
|
|
42
|
+
'Word document': 'مستند Word', 'Support this appeal': 'ادعم هذا النداء',
|
|
43
|
+
'View available sessions and enrolment details.': 'اطّلع على الجلسات المتاحة وتفاصيل التسجيل.',
|
|
44
|
+
'min read': 'دقائق للقراءة', '% of target reached': '% من الهدف',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const templateMessageKeys = Object.freeze(Object.keys(arabic));
|
|
48
|
+
const language = value => typeof value === 'string' && value.length <= 35
|
|
49
|
+
? value.toLowerCase().split('-')[0] : 'en';
|
|
50
|
+
|
|
51
|
+
/** Unknown copy/languages retain the supplied English UI fallback. Output remains autoescaped. */
|
|
52
|
+
export function templateMessage(message, locale) {
|
|
53
|
+
if (typeof message !== 'string') return '';
|
|
54
|
+
return language(locale) === 'ar' && Object.hasOwn(arabic, message) ? arabic[message] : message;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Exactly six formatter instances at most. No per-tenant or arbitrary-pattern cache growth.
|
|
58
|
+
const formatters = new Map();
|
|
59
|
+
export function templateDate(value, locale, style = 'date') {
|
|
60
|
+
// A timestamp needs an explicit offset. Date.parse on a zone-less timestamp would depend
|
|
61
|
+
// on the kit author's machine rather than the host's UTC rendering contract.
|
|
62
|
+
if (typeof value !== 'string' || value.length > 64 || !/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:\d{2}))?$/.test(value)) return '';
|
|
63
|
+
const timestamp = Date.parse(value);
|
|
64
|
+
if (!Number.isFinite(timestamp) || !['date', 'datetime'].includes(style)) return '';
|
|
65
|
+
const day = value.slice(0, 10);
|
|
66
|
+
if (new Date(Date.parse(`${day}T00:00:00Z`)).toISOString().slice(0, 10) !== day) return '';
|
|
67
|
+
const lang = ['ar', 'cy'].includes(language(locale)) ? language(locale) : 'en-GB';
|
|
68
|
+
const key = `${lang}:${style}`;
|
|
69
|
+
if (!formatters.has(key)) formatters.set(key, new Intl.DateTimeFormat(lang, {
|
|
70
|
+
calendar: 'gregory', timeZone: 'UTC', day: '2-digit', month: 'long', year: 'numeric',
|
|
71
|
+
...(style === 'datetime' ? { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' } : {}),
|
|
72
|
+
}));
|
|
73
|
+
return formatters.get(key).format(timestamp);
|
|
74
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The CONTRACT MAJORS registry (roadmap 8.6, multi-major engine support): the engine resolves a
|
|
2
|
+
// template's declared `format` to the contract set that renders it, so `port60-liquid@1` keeps
|
|
3
|
+
// rendering after `@2` ships. Today one major exists; when `@2` arrives it gets its own entry
|
|
4
|
+
// (own dialect/catalogues, own configured Liquid instance) and @1 artifacts keep resolving here
|
|
5
|
+
// untouched, with the published deprecation window governing how long.
|
|
6
|
+
import dialect from '../contract/v1/dialect.json' with { type: 'json' };
|
|
7
|
+
import sections from '../contract/v1/sections.json' with { type: 'json' };
|
|
8
|
+
import islands from '../contract/v1/islands.json' with { type: 'json' };
|
|
9
|
+
import context from '../contract/v1/context.json' with { type: 'json' };
|
|
10
|
+
import dialectV2 from '../contract/v2/dialect.json' with { type: 'json' };
|
|
11
|
+
import sectionsV2 from '../contract/v2/sections.json' with { type: 'json' };
|
|
12
|
+
import islandsV2 from '../contract/v2/islands.json' with { type: 'json' };
|
|
13
|
+
import contextV2 from '../contract/v2/context.json' with { type: 'json' };
|
|
14
|
+
|
|
15
|
+
const MAJORS = {
|
|
16
|
+
1: { format: dialect.format, dialect, sections, islands, context },
|
|
17
|
+
2: { format: dialectV2.format, dialect: dialectV2, sections: sectionsV2, islands: islandsV2, context: contextV2 }
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Parse "port60-liquid@N" → N, or null when the string isn't even format-shaped. */
|
|
21
|
+
export function parseFormatMajor(format) {
|
|
22
|
+
const m = /^port60-liquid@(\d+)$/.exec(String(format ?? ''));
|
|
23
|
+
return m ? Number(m[1]) : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the contract set for a declared format. Throws a NAMED error for unknown majors,
|
|
28
|
+
* callers (the artifact loader) let that ride their normal artifact-failure fallback, so a
|
|
29
|
+
* template declaring a future major degrades the site to the built-in chrome, never downs it.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveMajor(format) {
|
|
32
|
+
const major = parseFormatMajor(format);
|
|
33
|
+
const entry = major === null ? undefined : MAJORS[major];
|
|
34
|
+
if (!entry) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`unsupported template format '${format}', this engine speaks major(s) [${Object.keys(MAJORS).join(', ')}]`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return entry;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const SUPPORTED_MAJORS = Object.freeze(Object.keys(MAJORS).map(Number));
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<!-- Static preview copy of components/GiftAidLogo.tsx. Provenance: docs/living-templates/gift-aid-logo-source.md. -->
|
|
2
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 671.722 236.156"
|
|
3
|
+
class="donation-giftaid-logo" aria-hidden="true" focusable="false">
|
|
4
|
+
<path fill="currentColor" d="M425.721,293c18.637-29.194,9.291-33.682,8.339-33.142-18.131,10.215-31.939,40.964-30.78,51.206,1.6,14.1,12.543-2.569,22.441-18.064m70.606-26.791a53.62,53.62,0,0,0-6.419,19.5c-.477,3.424-1.033,18.3-.91,20.322a70.845,70.845,0,0,0,1.253,9.775c.443,1.611,3.206,7.4-5.661,8.623-5.218.718-5.116-12.339-5.218-14.476-.2-4.554.555-20.554,1.038-22.929.51-2.429-.507.145-1.467,1.735-2.211,3.7-4.784,8.706-7.865,13.635-4.047,6.481-13.073,21.919-25.383,21-4.605-.348-9.289-.941-11.965-13.775-1.026-4.919-.628-11.386-.523-15.268-.128-.8-.019-1.717-.688-1.343C428.159,303.18,414,333.158,398,326.419c-7.41-3.112-11.061-12.678-2.881-31.358,7.969-18.206,28.071-40.784,36.078-42.3,7.137-1.355,7.879,2.24,7.879,2.24.651-1.327,5.1-1.171,6.073.732,1.3,2.545,2.168,2.986,1.3,9.7-.491,3.872-4.088,28.092-3.467,36.414.286,3.9.955,13.509,4.919,13.972,6.924.829,15.315-13.9,16.268-15.439,1.865-3.009,16.805-28.056,18.644-31.713,1.979-3.943,3.23-6.822,4.507-9.332s5.126-2.789,8.481-.5S497.476,264.27,496.327,266.211ZM180.189,278.478c-8.784.946-24.8,22.18-28.1,29.721-3.374,7.7-4.592,15.123-.588,18.22,8.285,6.433,16.856-4.472,19.375-7.007,1.559-1.56,12.459-13.45,15.2-25.526C187.888,285.912,188.727,277.564,180.189,278.478Zm4.578,94.246q-.909,1.625-1.869,3.221c-6.472,10.744-14.962,20.331-25.489,27.241-14.258,9.356-27.649,12.95-42.611,12.77-8.7-.1-17.09-1.509-24.244-6.059-5.864-3.73-6.7-9.562-4.924-13.412,2.469-5.363,10.176-4.841,13.455-1.763,8.187,7.677,13.074,10.654,23.6,10.144a56.707,56.707,0,0,0,24.69-7.26c43.542-25.427,43.144-71.137,44.912-98.68.187-1.675-.723-.457-1.031.455a108.567,108.567,0,0,1-7.775,16.943,58.19,58.19,0,0,1-18.424,20.1c-5.514,3.642-14.469,5.405-20.775,1.294-2.286-1.5-15.654-9.325-4.324-32.638,7.125-14.668,22.118-24.843,25.5-27,8.27-5.282,14.18-7.794,20.377-7.318,3.156.23,3.611,1.472,5.957,1.41,1.336-1.568,2.073-2.72,4.27-3.327,2.95-.471,6.453,2,7.8,6.683s-2.161,12.206-3.047,15.824C200.822,291.35,203.938,338.277,184.767,372.724Zm49.607-105.731c-5.337.1-4.9,3.952-6.019,7.322-1.083,3.36-10.28,33.575-11.448,38.992s-1.969,10.282-.28,17.716c1.718,7.6,10.429,5.379,11.353,4.045,4.256-5.96.486-7.716.349-11.879-.109-4.351,1.082-8.915,1.981-14.806.32-2.1.962-11.649,1.277-12.945.355-1.4,3.36-12.033,7.689-18.5C241.129,274.166,242.76,266.848,234.374,266.993ZM248.9,231.656c-.73-1.5-4.7-6.225-9.882-2.557-7.708,5.458,1.18,13,2.607,16.7s9.132,6.8,8.007-.5C249.468,244.249,251.02,236,248.9,231.656Zm85.108,35.59c-2.509.559-7.516,1.569-10.666,2.1-4.77.8-7.369,3.8-7.189,6.832.18,3.054,1.225,4.493,5.481,3.685,3.848-.728,7.132-4,10.5-4.014,0,0-6.954,29.319-7.409,33.248s-2.512,14.678,1.713,20.777c9.4,13.576,28.2-1.985,29.957-3.68,1.291-1.253,13.168-11.668,17.194-20.021,2.04-4.239-1.433-5.175-3.784-6.04s-7.318.63-8.012,3.73c-1.512,6.734-17.96,24.9-26.1,21.258-9.594-4.3,7.713-52.479,7.713-52.479,2.064-.926,12.517-3.62,22.413-6.165,11.417-2.925,46.312-9.815,38.436-20.24-3.854-5.1-13.816,2.666-17.192,4.292-12.095,5.8-41.7,14.315-41.7,14.315s3.844-17.32,5.088-20.732c1.429-3.905,6.7-6.46,7.467-13.412.905-8.086-8.835-11.768-13.484-2.915-.962,1.817-2.287,6.832-2.417,6.957-1.249,1.2-12,5.493-22.571,10.533-3.332,1.588-22.256,8.469-22.291,8.424.026-.144,5.746-18.281,12.374-33.412,4.41-10.061,10.623-24.066,21.63-26.909,10.308-2.657,5.448,8.08,10.086,10.871,5.883,3.538,9.492-.924,10.393-5.191.706-3.342.519-20.879-17.31-19.126-16.965,1.673-30.484,27.571-47.2,75.964-.18.194-13.457,8.77-24.464,10.974-5.443,1.088-7.851,1.559-8.571,3.5a8.571,8.571,0,0,0,2,9.168c3.137,3.744,27.646-12.479,27.62-12.367-.5.661-20.609,77.281-20.77,78.175-1.159,6.381-7.642,23.206-7.291,30.843.194,3.929,1.734,12.107,7.391,11.742,11.846.777,9.561-15.865,8.765-18.917-1.659-6.313,6.851-48.045,14.488-78.8,1.557-6.273,4.756-16.566,5.313-19.014a11.455,11.455,0,0,1,6.22-8.233s42.583-20.073,42.979-19.957C340.811,243.054,334.008,267.246,334.008,267.246Zm291.747,38.3C634,313.343,642.2,308.4,646.421,305c12.945-10.41,39.635-57.853,39.635-57.853.152,1.588-11.121,56,3.95,62.033,9.664,3.86,32.156-15.33,34.732-17.107,14.851-10.251,5.751-14,2.886-14-7.658-.024-6.279,5.521-8.374,8.011-3.455,4.114-17.341,18.4-24.14,14.607-3.661-2.052-2.758-14.14-2.393-16.926,2.14-28.8,2.711-34,4.389-40.624.94-3.682,5.426-3.585,8.173-4.089,37.08-6.851,43.732-4.088,48.355-6.342,3.67-1.805,4.327-9.575-.879-11.246-2.382-.749-5.4,1.064-10.858,3.1-5.431,2.007-32.791,8.057-36.815,8.654-3.706.536-4.5.014-5.4-1.147-.886-1.149.82-14.663,6.135-25.865,2.313-4.848,2.374-12.267-5.178-12.841-3.106-.223-5.744.661-6.924,3.308-1.175,2.63-3.438,26.63-3.967,28.228-.535,1.578-3.4,2.81-4.033,5.549s-.192,4.448-2.035,5.064c-1.863.633-6.965,1.123-9.064,1.628-2.209.538-3.519,4.763-3.6,7.206s3.86.991,5.761.714c2.17-.316,7.476-2.967,3.7,1.578-1.431,1.737-27.17,41.973-37.222,50.258-11.566,9.521-10.739-9.2-10.45-12.389,1.748-19.092,10.1-31.9,11.3-34.94,1.18-3.055,1.677-12.107-8.183-10.107a6.979,6.979,0,0,0-4.938,4.637.992.992,0,0,0-.029.2C629.964,253.222,612.841,293.331,625.755,305.549Zm15.029-93.416c3.038,5.924,8.229,3.829,9.113,2.3.887-1.517,1.595-1.832,4.112-2.846,4.621-1.846-3.358-10.14-10.948-7.8a2.9,2.9,0,0,0-1.737,2.466C641.53,207.986,639.75,210.149,640.784,212.133ZM547.7,258.685s4.981-4.292,7.09-4.181c.979.048-.728,2.759,2.694,4.434,1.818.9-1.988,13.007-9.512,24.628-17.279,26.682-25.471,25.478-26.182,17.374C520.329,284.033,542.689,262.128,547.7,258.685Zm8.772,21.876s-.064,5.749.384,10.664c.4,4.412,1.908,13.644,3.462,16.63,2.408,4.6,7.514,4.6,11.446,2.346,5.147-2.957,2.713-8.488.753-10.329-1.711-1.621-5.869-3.7-7.128-10.645-.741-4.111-.135-22.637,1.083-29.118a86.628,86.628,0,0,1,5.621-17.3c2.05-4.045,14.4-27.206,14.581-34.981.075-3.393-3.42-6.206-9.131-5.618s-7.132,5.161-7.142,8.159c-.014,2.981-6.061,23.973-6.374,24.928-.5,1.434-1.491,10.029-4.441,10.048a28.671,28.671,0,0,0-18.159,6.078c-2.846,2.156-12.509,9.673-18.087,17.516-2.531,3.55-14.038,18.342-12.737,36,1.185,16.056,12.419,13.215,15.642,12.7,14.661-2.355,24.969-26.469,26.462-29.426C555.348,282.964,554.455,284.376,556.471,280.561ZM498.716,221.9c-1.074,1.32-.806,5.509,1.533,7.675,1.761,1.633,2.007,1.934,2.064,3.254.1,2.434,1.654,2.569,4.047,2.535,2.927-.042,5.32-2.073,5.616-6.206s-3.708-7.668-6.931-8.464S500.109,220.173,498.716,221.9Z"
|
|
5
|
+
transform="translate(-84.802 -179.806)" />
|
|
6
|
+
</svg>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// THE MODEL, VISIBLE (content model v2): a self-contained reference page the kit's dev server
|
|
2
|
+
// serves at /model, the registry's shape beside the LIVE example data the preview is rendering
|
|
3
|
+
// right now (preview-content.json overlay included), so "what can I read?" is answered where the
|
|
4
|
+
// developer already lives. Network-dead like every preview document.
|
|
5
|
+
import { contentModelV2 as contentModel } from '../engine/content-footprint.mjs';
|
|
6
|
+
|
|
7
|
+
const escapeHtml = (s) =>
|
|
8
|
+
String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
9
|
+
|
|
10
|
+
function fieldRows(item) {
|
|
11
|
+
return Object.entries(item)
|
|
12
|
+
.map(([name, spec]) => {
|
|
13
|
+
const notes = [];
|
|
14
|
+
if (spec.nullable) notes.push('nullable, always branch');
|
|
15
|
+
if (spec.enumOpen) {
|
|
16
|
+
notes.push(spec.enumOpen.length
|
|
17
|
+
? `open enum: ${spec.enumOpen.join(', ')} today, more will arrive`
|
|
18
|
+
: 'open enum');
|
|
19
|
+
}
|
|
20
|
+
if (spec.max) notes.push(`max ${spec.max} chars`);
|
|
21
|
+
return `<tr><td><code>${escapeHtml(name)}</code></td><td>${escapeHtml(spec.type)}</td>` +
|
|
22
|
+
`<td>${escapeHtml(notes.join(' · '))}</td><td>${escapeHtml(spec.since)}</td></tr>`;
|
|
23
|
+
})
|
|
24
|
+
.join('');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function exampleOf(site, name) {
|
|
28
|
+
const value = site?.content?.[name];
|
|
29
|
+
const items = Array.isArray(value) ? value : value?.items;
|
|
30
|
+
if (!Array.isArray(items) || items.length === 0) return null;
|
|
31
|
+
return JSON.stringify(items[0], null, 2);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The /model page: registry + the live example values of THIS preview's site tree. */
|
|
35
|
+
export function renderModelReferenceHtml(site) {
|
|
36
|
+
const sectionsHtml = Object.entries(contentModel.collections)
|
|
37
|
+
.map(([name, model]) => {
|
|
38
|
+
const example = exampleOf(site, name);
|
|
39
|
+
const envelope = model.shape === "envelope" ? `<p>Envelope: <code>label</code>, nullable <code>href</code>, <code>items</code>, nullable <code>pagination</code>. Iterate <code>site.content.${name}.items</code>. Metadata reads do not fetch rows.</p>` : "<p>A bounded array.</p>";
|
|
40
|
+
return `<section id="${escapeHtml(name)}">
|
|
41
|
+
<h2><code>site.content.${escapeHtml(name)}</code></h2>
|
|
42
|
+
<p class="meta">bounded at <strong>${model.cap}</strong>${model.moreHref ? ` · more at <code>${escapeHtml(model.moreHref)}</code>` : ''} · since ${escapeHtml(model.since)}</p>
|
|
43
|
+
<p>${escapeHtml(model.description)}</p>
|
|
44
|
+
${envelope}
|
|
45
|
+
<table><thead><tr><th>Field</th><th>Type</th><th>Notes</th><th>Since</th></tr></thead>
|
|
46
|
+
<tbody>${fieldRows(model.item)}</tbody></table>
|
|
47
|
+
${example ? `<details><summary>Live example (item 0 of what this preview is rendering)</summary><pre>${escapeHtml(example)}</pre></details>` : '<p class="meta">No items in the current preview data.</p>'}
|
|
48
|
+
</section>`;
|
|
49
|
+
})
|
|
50
|
+
.join('\n');
|
|
51
|
+
|
|
52
|
+
const toc = Object.keys(contentModel.collections)
|
|
53
|
+
.map((name) => `<a href="#${escapeHtml(name)}">${escapeHtml(name)}</a>`)
|
|
54
|
+
.join(' · ');
|
|
55
|
+
|
|
56
|
+
return `<!doctype html>
|
|
57
|
+
<html lang="en">
|
|
58
|
+
<head>
|
|
59
|
+
<meta charset="utf-8">
|
|
60
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline';">
|
|
61
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
62
|
+
<title>site.content, the content model</title>
|
|
63
|
+
<style>
|
|
64
|
+
body { font: 15px/1.6 system-ui, sans-serif; margin: 0 auto; max-width: 56rem; padding: 24px 20px 64px; color: #172029; background: #fbfbf9; }
|
|
65
|
+
h1 { margin: 0 0 4px; } h2 { margin: 40px 0 2px; font-size: 20px; }
|
|
66
|
+
.meta { color: #5a6672; font-size: 13px; margin: 2px 0 8px; }
|
|
67
|
+
.lede { color: #3c4854; max-width: 46rem; }
|
|
68
|
+
nav.toc { font-size: 13px; margin: 14px 0 6px; color: #5a6672; }
|
|
69
|
+
table { border-collapse: collapse; width: 100%; font-size: 13.5px; }
|
|
70
|
+
th, td { text-align: left; border-bottom: 1px solid #e3e3dc; padding: 5px 10px 5px 0; vertical-align: top; }
|
|
71
|
+
th { font-size: 11px; letter-spacing: .05em; text-transform: uppercase; color: #5a6672; }
|
|
72
|
+
code { background: #eeeee6; border-radius: 4px; padding: 1px 5px; font-size: 12.5px; }
|
|
73
|
+
pre { background: #10161c; color: #dbe4ec; border-radius: 8px; padding: 12px 14px; overflow-x: auto; font-size: 12.5px; }
|
|
74
|
+
details { margin: 8px 0 0; } summary { cursor: pointer; font-size: 13px; color: #5a6672; }
|
|
75
|
+
.rules { background: #f0f0e8; border-radius: 10px; padding: 14px 18px; font-size: 13.5px; }
|
|
76
|
+
.back { font-size: 13px; }
|
|
77
|
+
</style>
|
|
78
|
+
</head>
|
|
79
|
+
<body>
|
|
80
|
+
<p class="back"><a href="/">← back to the preview</a></p>
|
|
81
|
+
<h1>The content model</h1>
|
|
82
|
+
<p class="lede">Everything your template reads lives on one tree: <code>site.brand</code>, <code>site.nav</code>, <code>site.socials</code>, <code>site.locale</code>, <code>site.actions</code>, <code>site.page</code> and the typed collections below. Model version <strong>${escapeHtml(contentModel.version)}</strong>. The validator computes your content footprint from the paths you read and stamps the minimum model version at publish, you never declare versions.</p>
|
|
83
|
+
<div class="rules">
|
|
84
|
+
<strong>Four rules the model never breaks (so neither should you):</strong>
|
|
85
|
+
every collection is bounded (cap + supplied <code>href</code> and <code>pagination</code>), link onward, never assume you have everything;
|
|
86
|
+
enum fields are open, branch on the values you style, fall back for the rest;
|
|
87
|
+
optional fields are explicitly nullable, always branch;
|
|
88
|
+
the model only grows, nothing you read today is ever removed within the major.
|
|
89
|
+
Replace the preview data with your own via <code>preview-content.json</code> (shape fixed, hot-reloaded, never packaged).
|
|
90
|
+
</div>
|
|
91
|
+
<nav class="toc">${toc}</nav>
|
|
92
|
+
${sectionsHtml}
|
|
93
|
+
</body>
|
|
94
|
+
</html>`;
|
|
95
|
+
}
|