@wocart/theme 0.0.0-stage → 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/CHANGELOG.md +71 -0
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +479 -2
- package/dist/astro/InstanceView.astro +167 -0
- package/dist/astro/index.d.ts +34 -0
- package/dist/astro/index.js +21 -0
- package/dist/definitions.d.ts +7 -0
- package/dist/definitions.js +422 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +18 -0
- package/dist/instances.d.ts +43 -0
- package/dist/instances.js +307 -0
- package/dist/locales.d.ts +12 -0
- package/dist/locales.js +14 -0
- package/dist/manifest.d.ts +178 -0
- package/dist/manifest.js +325 -0
- package/dist/messages.d.ts +60 -0
- package/dist/messages.js +100 -0
- package/dist/page-data.d.ts +9 -0
- package/dist/page-data.js +154 -0
- package/dist/schemas.d.ts +57 -0
- package/dist/schemas.js +108 -0
- package/dist/settings.d.ts +15 -0
- package/dist/settings.js +80 -0
- package/dist/types.d.ts +479 -0
- package/dist/types.js +1 -0
- package/package.json +80 -4
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export { resolveInstance } from "../instances.js";
|
|
2
|
+
/**
|
|
3
|
+
* The per-render `ReadCache`: the first call for a key
|
|
4
|
+
* runs the loader; later calls in the same render reuse the in-flight or
|
|
5
|
+
* settled value — including a rejection (failures are shared, not retried per
|
|
6
|
+
* section). Scope this to ONE page render; it makes no cross-request promises.
|
|
7
|
+
*/
|
|
8
|
+
export function createReadCache() {
|
|
9
|
+
const entries = new Map();
|
|
10
|
+
return {
|
|
11
|
+
get(key, load) {
|
|
12
|
+
const existing = entries.get(key);
|
|
13
|
+
if (existing !== undefined) {
|
|
14
|
+
return existing;
|
|
15
|
+
}
|
|
16
|
+
const pending = Promise.resolve().then(load);
|
|
17
|
+
entries.set(key, pending);
|
|
18
|
+
return pending;
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ComponentDefinition, ThemeDefinition, ThemeDefinitionIssue, ThemeValidationOptions } from "./types.js";
|
|
2
|
+
/** Identity helper: returns the definition unchanged so literal types survive. */
|
|
3
|
+
export declare function defineComponent<const T extends ComponentDefinition>(definition: T): T;
|
|
4
|
+
/** Identity helper for the catalog. */
|
|
5
|
+
export declare function defineTheme<const T extends ThemeDefinition>(definition: T): T;
|
|
6
|
+
/** Check the whole catalog: key/type identity, field rules, slot rules. */
|
|
7
|
+
export declare function validateThemeDefinition(theme: ThemeDefinition, options?: ThemeValidationOptions): ThemeDefinitionIssue[];
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The code-owned side: identity helpers (inference-only) and the
|
|
3
|
+
* assembly-time validator. Definitions are typechecked code, not persisted
|
|
4
|
+
* data — the validator exists so a theme's test suite fails loudly on catalog
|
|
5
|
+
* bugs when the catalog is assembled.
|
|
6
|
+
*/
|
|
7
|
+
import { COLOR_PATTERN, MENU_HANDLE_PATTERN, SURFACE_KEY_PATTERN, THEME_KEY_PATTERN, TYPE_KEY_PATTERN, isLocaleMap, isLocaleTag, isSafeLinkTarget, } from "./schemas.js";
|
|
8
|
+
/** Identity helper: returns the definition unchanged so literal types survive. */
|
|
9
|
+
export function defineComponent(definition) {
|
|
10
|
+
return definition;
|
|
11
|
+
}
|
|
12
|
+
/** Identity helper for the catalog. */
|
|
13
|
+
export function defineTheme(definition) {
|
|
14
|
+
return definition;
|
|
15
|
+
}
|
|
16
|
+
function addIssue(issues, path, code, message) {
|
|
17
|
+
issues.push({ path, code, message });
|
|
18
|
+
}
|
|
19
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20
|
+
/**
|
|
21
|
+
* Why `value` is not valid translatable text, or `null` when it is: one
|
|
22
|
+
* string, or a map from language tags to strings that covers every language
|
|
23
|
+
* the theme declares. Labels must not be empty; defaults may be.
|
|
24
|
+
*/
|
|
25
|
+
function localizedTextProblem(value, locales, { allowEmpty }) {
|
|
26
|
+
if (typeof value === "string") {
|
|
27
|
+
return value.length > 0 || allowEmpty ? null : "must be a non-empty string";
|
|
28
|
+
}
|
|
29
|
+
if (!isLocaleMap(value)) {
|
|
30
|
+
return "must be a string or a map from language tags (ar, fr, ar-DZ) to strings";
|
|
31
|
+
}
|
|
32
|
+
if (!allowEmpty) {
|
|
33
|
+
const empty = Object.keys(value).filter((locale) => value[locale] === "");
|
|
34
|
+
if (empty.length > 0)
|
|
35
|
+
return `has an empty translation for ${empty.join(", ")}`;
|
|
36
|
+
}
|
|
37
|
+
const missing = locales.filter((locale) => !Object.hasOwn(value, locale));
|
|
38
|
+
return missing.length > 0 ? `is missing the theme's languages: ${missing.join(", ")}` : null;
|
|
39
|
+
}
|
|
40
|
+
/** The theme's declared languages, reporting malformed or repeated tags. */
|
|
41
|
+
function inspectLocales(locales, issues) {
|
|
42
|
+
if (locales === undefined)
|
|
43
|
+
return [];
|
|
44
|
+
if (!Array.isArray(locales)) {
|
|
45
|
+
addIssue(issues, "locales", "invalid-theme", "`locales` must be an array of language tags");
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
const valid = [];
|
|
49
|
+
locales.forEach((locale, index) => {
|
|
50
|
+
if (typeof locale !== "string" || !isLocaleTag(locale)) {
|
|
51
|
+
addIssue(issues, `locales[${index}]`, "invalid-theme", `${JSON.stringify(locale)} is not a BCP 47 language tag in canonical case (ar, fr, ar-DZ)`);
|
|
52
|
+
}
|
|
53
|
+
else if (valid.includes(locale)) {
|
|
54
|
+
addIssue(issues, `locales[${index}]`, "invalid-theme", `duplicate language "${locale}"`);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
valid.push(locale);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
return valid;
|
|
61
|
+
}
|
|
62
|
+
/** Check the whole catalog: key/type identity, field rules, slot rules. */
|
|
63
|
+
export function validateThemeDefinition(theme, options = {}) {
|
|
64
|
+
const issues = [];
|
|
65
|
+
const contentKeys = options.contentKeys === undefined ? undefined : new Set(options.contentKeys);
|
|
66
|
+
if (!isRecord(theme)) {
|
|
67
|
+
addIssue(issues, "", "invalid-theme", "theme must be an object");
|
|
68
|
+
return issues;
|
|
69
|
+
}
|
|
70
|
+
if (typeof theme.key !== "string" || !THEME_KEY_PATTERN.test(theme.key)) {
|
|
71
|
+
addIssue(issues, "key", "invalid-theme", "`key` must be lowercase kebab-case, at most 64 characters");
|
|
72
|
+
}
|
|
73
|
+
if (typeof theme.version !== "string" || theme.version.length === 0) {
|
|
74
|
+
addIssue(issues, "version", "invalid-theme", "`version` must be a non-empty string");
|
|
75
|
+
}
|
|
76
|
+
if (!isRecord(theme.components)) {
|
|
77
|
+
addIssue(issues, "components", "invalid-theme", "`components` must be a record of component definitions");
|
|
78
|
+
return issues;
|
|
79
|
+
}
|
|
80
|
+
const locales = inspectLocales(theme.locales, issues);
|
|
81
|
+
// Surfaces are optional theme-level metadata for the serializable manifest
|
|
82
|
+
// (`themeManifest`). Absent means "declares none"; present entries are
|
|
83
|
+
// checked so a typo fails loudly at assembly time. Never consulted by page
|
|
84
|
+
// data parsing or resolution.
|
|
85
|
+
if (theme.surfaces !== undefined) {
|
|
86
|
+
if (!Array.isArray(theme.surfaces)) {
|
|
87
|
+
addIssue(issues, "surfaces", "invalid-theme", "`surfaces` must be an array of surface declarations");
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
inspectSurfaces(theme.surfaces, issues, locales);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const rules = {
|
|
94
|
+
contentKeys,
|
|
95
|
+
catalogKeys: new Set(Object.keys(theme.components)),
|
|
96
|
+
locales,
|
|
97
|
+
};
|
|
98
|
+
if (theme.settings !== undefined)
|
|
99
|
+
inspectSettings(theme.settings, issues, rules);
|
|
100
|
+
for (const [key, definition] of Object.entries(theme.components)) {
|
|
101
|
+
const path = `components.${key}`;
|
|
102
|
+
if (!TYPE_KEY_PATTERN.test(key)) {
|
|
103
|
+
addIssue(issues, path, "invalid-type", `catalog key "${key}" is not lowercase kebab-case`);
|
|
104
|
+
}
|
|
105
|
+
inspectDefinition(definition, path, issues, rules);
|
|
106
|
+
if (isRecord(definition) &&
|
|
107
|
+
typeof definition.type === "string" &&
|
|
108
|
+
definition.type !== key) {
|
|
109
|
+
addIssue(issues, `${path}.type`, "type-mismatch", `definition type "${definition.type}" must equal its catalog key "${key}"`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return issues;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The theme's settings groups: an array of `{ key, label, fields }`, group keys
|
|
116
|
+
* unique and kebab-case, fields checked like a component's, and every setting
|
|
117
|
+
* key unique across all groups — values are stored flat, by setting key.
|
|
118
|
+
*/
|
|
119
|
+
function inspectSettings(settings, issues, rules) {
|
|
120
|
+
if (!Array.isArray(settings)) {
|
|
121
|
+
addIssue(issues, "settings", "invalid-settings", "`settings` must be an array of setting groups");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const groupKeys = new Set();
|
|
125
|
+
const settingKeys = new Set();
|
|
126
|
+
settings.forEach((group, index) => {
|
|
127
|
+
const path = `settings[${index}]`;
|
|
128
|
+
if (!isRecord(group)) {
|
|
129
|
+
addIssue(issues, path, "invalid-settings", "a settings group must be an object");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (typeof group.key !== "string" || !TYPE_KEY_PATTERN.test(group.key)) {
|
|
133
|
+
addIssue(issues, `${path}.key`, "invalid-settings", "`key` must be lowercase kebab-case");
|
|
134
|
+
}
|
|
135
|
+
else if (groupKeys.has(group.key)) {
|
|
136
|
+
addIssue(issues, `${path}.key`, "invalid-settings", `duplicate settings group "${group.key}"`);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
groupKeys.add(group.key);
|
|
140
|
+
}
|
|
141
|
+
const labelProblem = localizedTextProblem(group.label, rules.locales, { allowEmpty: false });
|
|
142
|
+
if (labelProblem !== null)
|
|
143
|
+
addIssue(issues, `${path}.label`, "invalid-settings", `\`label\` ${labelProblem}`);
|
|
144
|
+
if (!Array.isArray(group.fields)) {
|
|
145
|
+
addIssue(issues, `${path}.fields`, "invalid-settings", "`fields` must be an array");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
inspectFields(group.fields, path, issues, rules);
|
|
149
|
+
for (const [fieldIndex, field] of group.fields.entries()) {
|
|
150
|
+
if (!isRecord(field) || typeof field.key !== "string")
|
|
151
|
+
continue;
|
|
152
|
+
if (settingKeys.has(field.key)) {
|
|
153
|
+
addIssue(issues, `${path}.fields[${fieldIndex}].key`, "duplicate-field-key", `setting "${field.key}" is declared in another group too`);
|
|
154
|
+
}
|
|
155
|
+
settingKeys.add(field.key);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
function inspectSurfaces(surfaces, issues, locales) {
|
|
160
|
+
const seenKeys = new Set();
|
|
161
|
+
surfaces.forEach((surface, index) => {
|
|
162
|
+
const surfacePath = `surfaces[${index}]`;
|
|
163
|
+
if (!isRecord(surface)) {
|
|
164
|
+
addIssue(issues, surfacePath, "invalid-theme", "surface must be an object");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (typeof surface.key !== "string" ||
|
|
168
|
+
!SURFACE_KEY_PATTERN.test(surface.key)) {
|
|
169
|
+
addIssue(issues, `${surfacePath}.key`, "invalid-theme", "`key` must be a surface key (lowercase, starting alphanumeric, kebab/dot/colon/underscore)");
|
|
170
|
+
}
|
|
171
|
+
else if (seenKeys.has(surface.key)) {
|
|
172
|
+
addIssue(issues, `${surfacePath}.key`, "invalid-theme", `duplicate surface key "${surface.key}"`);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
seenKeys.add(surface.key);
|
|
176
|
+
}
|
|
177
|
+
const labelProblem = localizedTextProblem(surface.label, locales, { allowEmpty: false });
|
|
178
|
+
if (labelProblem !== null) {
|
|
179
|
+
addIssue(issues, `${surfacePath}.label`, "invalid-theme", `\`label\` ${labelProblem}`);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
function inspectDefinition(definition, path, issues, rules) {
|
|
184
|
+
if (!isRecord(definition)) {
|
|
185
|
+
addIssue(issues, path, "invalid-definition", "component definition must be an object");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (typeof definition.type !== "string" || !TYPE_KEY_PATTERN.test(definition.type)) {
|
|
189
|
+
addIssue(issues, `${path}.type`, "invalid-type", "`type` must be a lowercase kebab-case string");
|
|
190
|
+
}
|
|
191
|
+
if (definition.component === undefined) {
|
|
192
|
+
addIssue(issues, `${path}.component`, "invalid-definition", "`component` must be the real component reference");
|
|
193
|
+
}
|
|
194
|
+
else if (definition.component !== null &&
|
|
195
|
+
typeof definition.component !== "object" &&
|
|
196
|
+
typeof definition.component !== "function") {
|
|
197
|
+
// `null` is allowed through: the renderer degrades it to a reported
|
|
198
|
+
// `missing-component`, same as a missing reference. A primitive (usually a
|
|
199
|
+
// string left behind instead of an import) would reach `<Component>` and
|
|
200
|
+
// fail at render time, so it fails loudly here instead. The core stays
|
|
201
|
+
// framework-neutral — it checks reference shape, never Astro internals.
|
|
202
|
+
addIssue(issues, `${path}.component`, "invalid-definition", "`component` must be the real component reference (an object or function), not a placeholder value");
|
|
203
|
+
}
|
|
204
|
+
const labelProblem = localizedTextProblem(definition.label, rules.locales, { allowEmpty: false });
|
|
205
|
+
if (labelProblem !== null) {
|
|
206
|
+
addIssue(issues, `${path}.label`, "invalid-definition", `\`label\` ${labelProblem}`);
|
|
207
|
+
}
|
|
208
|
+
if (definition.group !== undefined) {
|
|
209
|
+
const problem = localizedTextProblem(definition.group, rules.locales, { allowEmpty: false });
|
|
210
|
+
if (problem !== null) {
|
|
211
|
+
addIssue(issues, `${path}.group`, "invalid-definition", `\`group\` ${problem}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (definition.load !== undefined && typeof definition.load !== "function") {
|
|
215
|
+
addIssue(issues, `${path}.load`, "invalid-load", "`load` must be a function");
|
|
216
|
+
}
|
|
217
|
+
if (definition.capabilities !== undefined) {
|
|
218
|
+
if (!isRecord(definition.capabilities)) {
|
|
219
|
+
addIssue(issues, `${path}.capabilities`, "invalid-capability", "`capabilities` must be an object");
|
|
220
|
+
}
|
|
221
|
+
else if (definition.capabilities.insertable !== undefined &&
|
|
222
|
+
typeof definition.capabilities.insertable !== "boolean") {
|
|
223
|
+
addIssue(issues, `${path}.capabilities.insertable`, "invalid-capability", "`insertable` must be a boolean");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (definition.fields !== undefined) {
|
|
227
|
+
if (!Array.isArray(definition.fields)) {
|
|
228
|
+
addIssue(issues, `${path}.fields`, "invalid-field", "`fields` must be an array");
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
inspectFields(definition.fields, path, issues, rules);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (definition.slots !== undefined) {
|
|
235
|
+
if (!Array.isArray(definition.slots)) {
|
|
236
|
+
addIssue(issues, `${path}.slots`, "invalid-slot", "`slots` must be an array");
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
inspectSlots(definition.slots, path, issues, rules.catalogKeys);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function inspectFields(fields, path, issues, rules) {
|
|
244
|
+
const { contentKeys, locales } = rules;
|
|
245
|
+
const seenKeys = new Set();
|
|
246
|
+
fields.forEach((field, index) => {
|
|
247
|
+
const fieldPath = `${path}.fields[${index}]`;
|
|
248
|
+
if (!isRecord(field)) {
|
|
249
|
+
addIssue(issues, fieldPath, "invalid-field", "field must be an object");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (typeof field.key !== "string" || field.key.length === 0) {
|
|
253
|
+
addIssue(issues, `${fieldPath}.key`, "invalid-field", "`key` must be a non-empty string");
|
|
254
|
+
}
|
|
255
|
+
else if (field.key === "__proto__") {
|
|
256
|
+
addIssue(issues, `${fieldPath}.key`, "invalid-field", "`__proto__` cannot be a field key: as a prop name it would set a prototype");
|
|
257
|
+
}
|
|
258
|
+
else if (seenKeys.has(field.key)) {
|
|
259
|
+
addIssue(issues, `${fieldPath}.key`, "duplicate-field-key", `duplicate field key "${field.key}"`);
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
seenKeys.add(field.key);
|
|
263
|
+
}
|
|
264
|
+
const labelProblem = localizedTextProblem(field.label, locales, { allowEmpty: false });
|
|
265
|
+
if (labelProblem !== null) {
|
|
266
|
+
addIssue(issues, `${fieldPath}.label`, "invalid-field", `\`label\` ${labelProblem}`);
|
|
267
|
+
}
|
|
268
|
+
if (field.description !== undefined) {
|
|
269
|
+
const problem = localizedTextProblem(field.description, locales, { allowEmpty: false });
|
|
270
|
+
if (problem !== null) {
|
|
271
|
+
addIssue(issues, `${fieldPath}.description`, "invalid-field", `\`description\` ${problem}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (field.required !== undefined && typeof field.required !== "boolean") {
|
|
275
|
+
addIssue(issues, `${fieldPath}.required`, "invalid-field", "`required` must be a boolean");
|
|
276
|
+
}
|
|
277
|
+
switch (field.kind) {
|
|
278
|
+
case "text":
|
|
279
|
+
case "textarea": {
|
|
280
|
+
const hasContentKey = field.contentKey !== undefined;
|
|
281
|
+
const hasDefault = field.default !== undefined;
|
|
282
|
+
if (hasContentKey && typeof field.contentKey !== "string") {
|
|
283
|
+
addIssue(issues, `${fieldPath}.contentKey`, "invalid-field", "`contentKey` must be a string");
|
|
284
|
+
}
|
|
285
|
+
if (hasDefault) {
|
|
286
|
+
const problem = localizedTextProblem(field.default, locales, { allowEmpty: true });
|
|
287
|
+
if (problem !== null) {
|
|
288
|
+
addIssue(issues, `${fieldPath}.default`, "invalid-field", `\`default\` ${problem}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (hasContentKey && hasDefault) {
|
|
292
|
+
addIssue(issues, fieldPath, "invalid-field", "`contentKey` and `default` are mutually exclusive");
|
|
293
|
+
}
|
|
294
|
+
// Opt-in only: with no `contentKeys` supplied the validator behaves
|
|
295
|
+
// exactly as it did before this check existed.
|
|
296
|
+
if (contentKeys !== undefined &&
|
|
297
|
+
typeof field.contentKey === "string" &&
|
|
298
|
+
!contentKeys.has(field.contentKey)) {
|
|
299
|
+
addIssue(issues, `${fieldPath}.contentKey`, "unknown-content-key", `"${field.contentKey}" is not a key of the supplied content pack`);
|
|
300
|
+
}
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
case "number": {
|
|
304
|
+
for (const bound of ["default", "min", "max"]) {
|
|
305
|
+
const value = field[bound];
|
|
306
|
+
if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value))) {
|
|
307
|
+
addIssue(issues, `${fieldPath}.${bound}`, "invalid-field", `\`${bound}\` must be a finite number`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (typeof field.min === "number" &&
|
|
311
|
+
typeof field.max === "number" &&
|
|
312
|
+
field.min > field.max) {
|
|
313
|
+
addIssue(issues, fieldPath, "invalid-field", "`min` must be <= `max`");
|
|
314
|
+
}
|
|
315
|
+
// A default outside the declared bounds would sail through assembly
|
|
316
|
+
// and then behave incoherently at runtime (`resolveInstance` trusts
|
|
317
|
+
// authored defaults while rejecting the same out-of-range instance
|
|
318
|
+
// value). Catch the typo where it is authored, in the resolver's own
|
|
319
|
+
// vocabulary.
|
|
320
|
+
if (typeof field.default === "number" && Number.isFinite(field.default)) {
|
|
321
|
+
if (typeof field.min === "number" && field.default < field.min) {
|
|
322
|
+
addIssue(issues, `${fieldPath}.default`, "invalid-field", `\`default\` must be >= ${field.min}`);
|
|
323
|
+
}
|
|
324
|
+
if (typeof field.max === "number" && field.default > field.max) {
|
|
325
|
+
addIssue(issues, `${fieldPath}.default`, "invalid-field", `\`default\` must be <= ${field.max}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
case "boolean": {
|
|
331
|
+
if (field.default !== undefined && typeof field.default !== "boolean") {
|
|
332
|
+
addIssue(issues, `${fieldPath}.default`, "invalid-field", "`default` must be a boolean");
|
|
333
|
+
}
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
case "select": {
|
|
337
|
+
const options = field.options;
|
|
338
|
+
if (!Array.isArray(options) ||
|
|
339
|
+
options.length === 0 ||
|
|
340
|
+
!options.every((option) => typeof option === "string")) {
|
|
341
|
+
addIssue(issues, `${fieldPath}.options`, "invalid-field", "`options` must be a non-empty array of strings");
|
|
342
|
+
}
|
|
343
|
+
else if (field.default !== undefined &&
|
|
344
|
+
(typeof field.default !== "string" || !options.includes(field.default))) {
|
|
345
|
+
addIssue(issues, `${fieldPath}.default`, "invalid-field", "`default` must be one of `options`");
|
|
346
|
+
}
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
case "color": {
|
|
350
|
+
if (field.default !== undefined && (typeof field.default !== "string" || !COLOR_PATTERN.test(field.default))) {
|
|
351
|
+
addIssue(issues, `${fieldPath}.default`, "invalid-field", "`default` must be a hex color, #rgb or #rrggbb");
|
|
352
|
+
}
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
case "menu": {
|
|
356
|
+
if (field.default !== undefined && (typeof field.default !== "string" || !MENU_HANDLE_PATTERN.test(field.default))) {
|
|
357
|
+
addIssue(issues, `${fieldPath}.default`, "invalid-field", "`default` must be a menu handle: lowercase letters, digits and dashes");
|
|
358
|
+
}
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
case "url": {
|
|
362
|
+
if (field.default !== undefined &&
|
|
363
|
+
(typeof field.default !== "string" || !isSafeLinkTarget(field.default))) {
|
|
364
|
+
addIssue(issues, `${fieldPath}.default`, "invalid-field", "`default` must be a site path, #fragment, ?query, or an http(s), mailto or tel URL");
|
|
365
|
+
}
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
default:
|
|
369
|
+
addIssue(issues, `${fieldPath}.kind`, "invalid-field", `unknown field kind ${JSON.stringify(field.kind)}`);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
function inspectSlots(slots, path, issues, catalogKeys) {
|
|
374
|
+
const seenNames = new Set();
|
|
375
|
+
slots.forEach((slot, index) => {
|
|
376
|
+
const slotPath = `${path}.slots[${index}]`;
|
|
377
|
+
if (!isRecord(slot)) {
|
|
378
|
+
addIssue(issues, slotPath, "invalid-slot", "slot must be an object");
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const name = slot.name;
|
|
382
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
383
|
+
addIssue(issues, `${slotPath}.name`, "invalid-slot", "`name` must be a non-empty string");
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
if (name !== "default" && !TYPE_KEY_PATTERN.test(name)) {
|
|
387
|
+
addIssue(issues, `${slotPath}.name`, "invalid-slot", `slot name "${name}" must be lowercase kebab-case (or "default")`);
|
|
388
|
+
}
|
|
389
|
+
if (seenNames.has(name)) {
|
|
390
|
+
addIssue(issues, `${slotPath}.name`, "duplicate-slot", `duplicate slot name "${name}"`);
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
seenNames.add(name);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (slot.multiple !== undefined && typeof slot.multiple !== "boolean") {
|
|
397
|
+
addIssue(issues, `${slotPath}.multiple`, "invalid-slot", "`multiple` must be a boolean");
|
|
398
|
+
}
|
|
399
|
+
if (slot.allow !== undefined) {
|
|
400
|
+
inspectAllow(slot.allow, `${slotPath}.allow`, issues, catalogKeys);
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
function inspectAllow(allow, path, issues, catalogKeys) {
|
|
405
|
+
if (!Array.isArray(allow) || allow.length === 0) {
|
|
406
|
+
addIssue(issues, path, "invalid-slot", "`allow` must be a non-empty array of component types (omit it to accept any type)");
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const seen = new Set();
|
|
410
|
+
allow.forEach((type, index) => {
|
|
411
|
+
const typePath = `${path}[${index}]`;
|
|
412
|
+
if (typeof type !== "string" || !catalogKeys.has(type)) {
|
|
413
|
+
addIssue(issues, typePath, "invalid-slot", `${JSON.stringify(type)} is not a component type in this catalog`);
|
|
414
|
+
}
|
|
415
|
+
else if (seen.has(type)) {
|
|
416
|
+
addIssue(issues, typePath, "invalid-slot", `duplicate allowed type "${type}"`);
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
seen.add(type);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@wocart/theme` — the theme contract.
|
|
3
|
+
*
|
|
4
|
+
* Code-owned: `defineComponent` / `defineTheme` / `validateThemeDefinition`.
|
|
5
|
+
* Data-owned: `parsePageData` / `validatePageData` / `resolveInstance` /
|
|
6
|
+
* `createInstance` (+ schemas).
|
|
7
|
+
* Serializable: `themeManifest` / `validateThemeManifest`.
|
|
8
|
+
*
|
|
9
|
+
* This entry is framework-neutral. The reference Astro renderer lives in the
|
|
10
|
+
* `./astro` subpath.
|
|
11
|
+
*/
|
|
12
|
+
export { defineComponent, defineTheme, validateThemeDefinition } from "./definitions.js";
|
|
13
|
+
export { parsePageData, validatePageData } from "./page-data.js";
|
|
14
|
+
export { resolveInstance, createInstance } from "./instances.js";
|
|
15
|
+
export { resolveThemeSettings, checkThemeSettings } from "./settings.js";
|
|
16
|
+
export { localize } from "./locales.js";
|
|
17
|
+
export { PLURAL_CATEGORIES, formatMessage, messageForms, pluralFormIssues } from "./messages.js";
|
|
18
|
+
export type { MessageOptions, MessageValues, PluralCategory } from "./messages.js";
|
|
19
|
+
export { THEME_MANIFEST_CONTRACT_VERSION, themeManifest, validateThemeManifest, } from "./manifest.js";
|
|
20
|
+
export type { JsonValue, JsonObject, LocaleCode, LocalizedText, ResolutionScope, FieldKind, FieldDefinition, FieldValue, InferProps, SlotDefinition, ComponentCapabilities, ComponentDefinition, ComponentSpec, SurfaceSpec, ThemeDefinition, SettingsGroup, InferSettings, ThemeSettingsIssue, ThemeSettingsIssueCode, ThemeSettingsResult, ColorFieldDefinition, MenuFieldDefinition, ThemeManifest, ThemeManifestIssue, ThemeManifestIssueCode, ThemeManifestResult, ComponentInstance, PageData, ThemeRenderContext, LoadContext, LoadFn, ReadCache, PageDataIssue, PageDataIssueCode, PageDataResult, ResolvedInstance, ThemeDefinitionIssue, ThemeDefinitionIssueCode, ThemeValidationOptions, } from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@wocart/theme` — the theme contract.
|
|
3
|
+
*
|
|
4
|
+
* Code-owned: `defineComponent` / `defineTheme` / `validateThemeDefinition`.
|
|
5
|
+
* Data-owned: `parsePageData` / `validatePageData` / `resolveInstance` /
|
|
6
|
+
* `createInstance` (+ schemas).
|
|
7
|
+
* Serializable: `themeManifest` / `validateThemeManifest`.
|
|
8
|
+
*
|
|
9
|
+
* This entry is framework-neutral. The reference Astro renderer lives in the
|
|
10
|
+
* `./astro` subpath.
|
|
11
|
+
*/
|
|
12
|
+
export { defineComponent, defineTheme, validateThemeDefinition } from "./definitions.js";
|
|
13
|
+
export { parsePageData, validatePageData } from "./page-data.js";
|
|
14
|
+
export { resolveInstance, createInstance } from "./instances.js";
|
|
15
|
+
export { resolveThemeSettings, checkThemeSettings } from "./settings.js";
|
|
16
|
+
export { localize } from "./locales.js";
|
|
17
|
+
export { PLURAL_CATEGORIES, formatMessage, messageForms, pluralFormIssues } from "./messages.js";
|
|
18
|
+
export { THEME_MANIFEST_CONTRACT_VERSION, themeManifest, validateThemeManifest, } from "./manifest.js";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ComponentDefinition, ComponentInstance, FieldDefinition, JsonObject, LocaleCode, PageDataIssue, ResolutionScope, ResolvedInstance } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Gate one instance for rendering: field values through the chain, children
|
|
4
|
+
* and named slots gated against the declared slots, every degradation
|
|
5
|
+
* reported. The instance (and page data) is never modified.
|
|
6
|
+
*/
|
|
7
|
+
export declare function resolveInstance(definition: ComponentDefinition, instance: ComponentInstance, scope?: ResolutionScope): ResolvedInstance;
|
|
8
|
+
export type CheckedValue = {
|
|
9
|
+
ok: true;
|
|
10
|
+
value: unknown;
|
|
11
|
+
} | {
|
|
12
|
+
ok: false;
|
|
13
|
+
message: string;
|
|
14
|
+
};
|
|
15
|
+
/** An own property only: `constructor` or `toString` must never read the prototype. */
|
|
16
|
+
export declare function ownValue<T>(record: Record<string, T>, key: string): T | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* One field through the chain. `undefined` = omit the value. Issues are
|
|
19
|
+
* reported under `${prefix}.${field.key}` — a component's `props`, or a theme's
|
|
20
|
+
* `settings`.
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveField(field: FieldDefinition, raw: unknown, content: Record<string, string>, locales: readonly LocaleCode[], issues: PageDataIssue[], prefix: string): unknown;
|
|
23
|
+
/**
|
|
24
|
+
* What a writer may store: a value that passes the field's rules, or — for a
|
|
25
|
+
* translatable field — a per-language map whose every entry passes them, so no
|
|
26
|
+
* language can hold an unsafe link or a wrong type.
|
|
27
|
+
*/
|
|
28
|
+
export declare function checkStoredValue(field: FieldDefinition, value: unknown): CheckedValue;
|
|
29
|
+
/**
|
|
30
|
+
* Create a new instance with insertion-time defaults:
|
|
31
|
+
* materialises `field.default` values only — content-keyed fields stay unset
|
|
32
|
+
* until a merchant overrides them. Throws on programmer errors (empty id,
|
|
33
|
+
* unknown keys, wrong-typed values, a required field with nothing to fill
|
|
34
|
+
* it); the storefront never calls this. A required field with a `contentKey`
|
|
35
|
+
* is filled from the content pack at render, so it may be left unset here.
|
|
36
|
+
*
|
|
37
|
+
* Authored defaults are validated exactly like provided values: a default that
|
|
38
|
+
* fails its field's rules (out-of-range number, off-list select option) is an
|
|
39
|
+
* authoring bug, and `validateThemeDefinition` reports it at assembly time —
|
|
40
|
+
* baking it into an instance silently here would let the writer and the reader
|
|
41
|
+
* disagree.
|
|
42
|
+
*/
|
|
43
|
+
export declare function createInstance(definition: ComponentDefinition, id: string, props?: JsonObject): ComponentInstance;
|