@wocart/theme 0.0.0-stage → 1.0.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.
@@ -0,0 +1,363 @@
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 { 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
+ for (const [key, definition] of Object.entries(theme.components)) {
99
+ const path = `components.${key}`;
100
+ if (!TYPE_KEY_PATTERN.test(key)) {
101
+ addIssue(issues, path, "invalid-type", `catalog key "${key}" is not lowercase kebab-case`);
102
+ }
103
+ inspectDefinition(definition, path, issues, rules);
104
+ if (isRecord(definition) &&
105
+ typeof definition.type === "string" &&
106
+ definition.type !== key) {
107
+ addIssue(issues, `${path}.type`, "type-mismatch", `definition type "${definition.type}" must equal its catalog key "${key}"`);
108
+ }
109
+ }
110
+ return issues;
111
+ }
112
+ function inspectSurfaces(surfaces, issues, locales) {
113
+ const seenKeys = new Set();
114
+ surfaces.forEach((surface, index) => {
115
+ const surfacePath = `surfaces[${index}]`;
116
+ if (!isRecord(surface)) {
117
+ addIssue(issues, surfacePath, "invalid-theme", "surface must be an object");
118
+ return;
119
+ }
120
+ if (typeof surface.key !== "string" ||
121
+ !SURFACE_KEY_PATTERN.test(surface.key)) {
122
+ addIssue(issues, `${surfacePath}.key`, "invalid-theme", "`key` must be a surface key (lowercase, starting alphanumeric, kebab/dot/colon/underscore)");
123
+ }
124
+ else if (seenKeys.has(surface.key)) {
125
+ addIssue(issues, `${surfacePath}.key`, "invalid-theme", `duplicate surface key "${surface.key}"`);
126
+ }
127
+ else {
128
+ seenKeys.add(surface.key);
129
+ }
130
+ const labelProblem = localizedTextProblem(surface.label, locales, { allowEmpty: false });
131
+ if (labelProblem !== null) {
132
+ addIssue(issues, `${surfacePath}.label`, "invalid-theme", `\`label\` ${labelProblem}`);
133
+ }
134
+ });
135
+ }
136
+ function inspectDefinition(definition, path, issues, rules) {
137
+ if (!isRecord(definition)) {
138
+ addIssue(issues, path, "invalid-definition", "component definition must be an object");
139
+ return;
140
+ }
141
+ if (typeof definition.type !== "string" || !TYPE_KEY_PATTERN.test(definition.type)) {
142
+ addIssue(issues, `${path}.type`, "invalid-type", "`type` must be a lowercase kebab-case string");
143
+ }
144
+ if (definition.component === undefined) {
145
+ addIssue(issues, `${path}.component`, "invalid-definition", "`component` must be the real component reference");
146
+ }
147
+ else if (definition.component !== null &&
148
+ typeof definition.component !== "object" &&
149
+ typeof definition.component !== "function") {
150
+ // `null` is allowed through: the renderer degrades it to a reported
151
+ // `missing-component`, same as a missing reference. A primitive (usually a
152
+ // string left behind instead of an import) would reach `<Component>` and
153
+ // fail at render time, so it fails loudly here instead. The core stays
154
+ // framework-neutral — it checks reference shape, never Astro internals.
155
+ addIssue(issues, `${path}.component`, "invalid-definition", "`component` must be the real component reference (an object or function), not a placeholder value");
156
+ }
157
+ const labelProblem = localizedTextProblem(definition.label, rules.locales, { allowEmpty: false });
158
+ if (labelProblem !== null) {
159
+ addIssue(issues, `${path}.label`, "invalid-definition", `\`label\` ${labelProblem}`);
160
+ }
161
+ if (definition.group !== undefined) {
162
+ const problem = localizedTextProblem(definition.group, rules.locales, { allowEmpty: false });
163
+ if (problem !== null) {
164
+ addIssue(issues, `${path}.group`, "invalid-definition", `\`group\` ${problem}`);
165
+ }
166
+ }
167
+ if (definition.load !== undefined && typeof definition.load !== "function") {
168
+ addIssue(issues, `${path}.load`, "invalid-load", "`load` must be a function");
169
+ }
170
+ if (definition.capabilities !== undefined) {
171
+ if (!isRecord(definition.capabilities)) {
172
+ addIssue(issues, `${path}.capabilities`, "invalid-capability", "`capabilities` must be an object");
173
+ }
174
+ else if (definition.capabilities.insertable !== undefined &&
175
+ typeof definition.capabilities.insertable !== "boolean") {
176
+ addIssue(issues, `${path}.capabilities.insertable`, "invalid-capability", "`insertable` must be a boolean");
177
+ }
178
+ }
179
+ if (definition.fields !== undefined) {
180
+ if (!Array.isArray(definition.fields)) {
181
+ addIssue(issues, `${path}.fields`, "invalid-field", "`fields` must be an array");
182
+ }
183
+ else {
184
+ inspectFields(definition.fields, path, issues, rules);
185
+ }
186
+ }
187
+ if (definition.slots !== undefined) {
188
+ if (!Array.isArray(definition.slots)) {
189
+ addIssue(issues, `${path}.slots`, "invalid-slot", "`slots` must be an array");
190
+ }
191
+ else {
192
+ inspectSlots(definition.slots, path, issues, rules.catalogKeys);
193
+ }
194
+ }
195
+ }
196
+ function inspectFields(fields, path, issues, rules) {
197
+ const { contentKeys, locales } = rules;
198
+ const seenKeys = new Set();
199
+ fields.forEach((field, index) => {
200
+ const fieldPath = `${path}.fields[${index}]`;
201
+ if (!isRecord(field)) {
202
+ addIssue(issues, fieldPath, "invalid-field", "field must be an object");
203
+ return;
204
+ }
205
+ if (typeof field.key !== "string" || field.key.length === 0) {
206
+ addIssue(issues, `${fieldPath}.key`, "invalid-field", "`key` must be a non-empty string");
207
+ }
208
+ else if (field.key === "__proto__") {
209
+ addIssue(issues, `${fieldPath}.key`, "invalid-field", "`__proto__` cannot be a field key: as a prop name it would set a prototype");
210
+ }
211
+ else if (seenKeys.has(field.key)) {
212
+ addIssue(issues, `${fieldPath}.key`, "duplicate-field-key", `duplicate field key "${field.key}"`);
213
+ }
214
+ else {
215
+ seenKeys.add(field.key);
216
+ }
217
+ const labelProblem = localizedTextProblem(field.label, locales, { allowEmpty: false });
218
+ if (labelProblem !== null) {
219
+ addIssue(issues, `${fieldPath}.label`, "invalid-field", `\`label\` ${labelProblem}`);
220
+ }
221
+ if (field.description !== undefined) {
222
+ const problem = localizedTextProblem(field.description, locales, { allowEmpty: false });
223
+ if (problem !== null) {
224
+ addIssue(issues, `${fieldPath}.description`, "invalid-field", `\`description\` ${problem}`);
225
+ }
226
+ }
227
+ if (field.required !== undefined && typeof field.required !== "boolean") {
228
+ addIssue(issues, `${fieldPath}.required`, "invalid-field", "`required` must be a boolean");
229
+ }
230
+ switch (field.kind) {
231
+ case "text":
232
+ case "textarea": {
233
+ const hasContentKey = field.contentKey !== undefined;
234
+ const hasDefault = field.default !== undefined;
235
+ if (hasContentKey && typeof field.contentKey !== "string") {
236
+ addIssue(issues, `${fieldPath}.contentKey`, "invalid-field", "`contentKey` must be a string");
237
+ }
238
+ if (hasDefault) {
239
+ const problem = localizedTextProblem(field.default, locales, { allowEmpty: true });
240
+ if (problem !== null) {
241
+ addIssue(issues, `${fieldPath}.default`, "invalid-field", `\`default\` ${problem}`);
242
+ }
243
+ }
244
+ if (hasContentKey && hasDefault) {
245
+ addIssue(issues, fieldPath, "invalid-field", "`contentKey` and `default` are mutually exclusive");
246
+ }
247
+ // Opt-in only: with no `contentKeys` supplied the validator behaves
248
+ // exactly as it did before this check existed.
249
+ if (contentKeys !== undefined &&
250
+ typeof field.contentKey === "string" &&
251
+ !contentKeys.has(field.contentKey)) {
252
+ addIssue(issues, `${fieldPath}.contentKey`, "unknown-content-key", `"${field.contentKey}" is not a key of the supplied content pack`);
253
+ }
254
+ break;
255
+ }
256
+ case "number": {
257
+ for (const bound of ["default", "min", "max"]) {
258
+ const value = field[bound];
259
+ if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value))) {
260
+ addIssue(issues, `${fieldPath}.${bound}`, "invalid-field", `\`${bound}\` must be a finite number`);
261
+ }
262
+ }
263
+ if (typeof field.min === "number" &&
264
+ typeof field.max === "number" &&
265
+ field.min > field.max) {
266
+ addIssue(issues, fieldPath, "invalid-field", "`min` must be <= `max`");
267
+ }
268
+ // A default outside the declared bounds would sail through assembly
269
+ // and then behave incoherently at runtime (`resolveInstance` trusts
270
+ // authored defaults while rejecting the same out-of-range instance
271
+ // value). Catch the typo where it is authored, in the resolver's own
272
+ // vocabulary.
273
+ if (typeof field.default === "number" && Number.isFinite(field.default)) {
274
+ if (typeof field.min === "number" && field.default < field.min) {
275
+ addIssue(issues, `${fieldPath}.default`, "invalid-field", `\`default\` must be >= ${field.min}`);
276
+ }
277
+ if (typeof field.max === "number" && field.default > field.max) {
278
+ addIssue(issues, `${fieldPath}.default`, "invalid-field", `\`default\` must be <= ${field.max}`);
279
+ }
280
+ }
281
+ break;
282
+ }
283
+ case "boolean": {
284
+ if (field.default !== undefined && typeof field.default !== "boolean") {
285
+ addIssue(issues, `${fieldPath}.default`, "invalid-field", "`default` must be a boolean");
286
+ }
287
+ break;
288
+ }
289
+ case "select": {
290
+ const options = field.options;
291
+ if (!Array.isArray(options) ||
292
+ options.length === 0 ||
293
+ !options.every((option) => typeof option === "string")) {
294
+ addIssue(issues, `${fieldPath}.options`, "invalid-field", "`options` must be a non-empty array of strings");
295
+ }
296
+ else if (field.default !== undefined &&
297
+ (typeof field.default !== "string" || !options.includes(field.default))) {
298
+ addIssue(issues, `${fieldPath}.default`, "invalid-field", "`default` must be one of `options`");
299
+ }
300
+ break;
301
+ }
302
+ case "url": {
303
+ if (field.default !== undefined &&
304
+ (typeof field.default !== "string" || !isSafeLinkTarget(field.default))) {
305
+ addIssue(issues, `${fieldPath}.default`, "invalid-field", "`default` must be a site path, #fragment, ?query, or an http(s), mailto or tel URL");
306
+ }
307
+ break;
308
+ }
309
+ default:
310
+ addIssue(issues, `${fieldPath}.kind`, "invalid-field", `unknown field kind ${JSON.stringify(field.kind)}`);
311
+ }
312
+ });
313
+ }
314
+ function inspectSlots(slots, path, issues, catalogKeys) {
315
+ const seenNames = new Set();
316
+ slots.forEach((slot, index) => {
317
+ const slotPath = `${path}.slots[${index}]`;
318
+ if (!isRecord(slot)) {
319
+ addIssue(issues, slotPath, "invalid-slot", "slot must be an object");
320
+ return;
321
+ }
322
+ const name = slot.name;
323
+ if (typeof name !== "string" || name.length === 0) {
324
+ addIssue(issues, `${slotPath}.name`, "invalid-slot", "`name` must be a non-empty string");
325
+ }
326
+ else {
327
+ if (name !== "default" && !TYPE_KEY_PATTERN.test(name)) {
328
+ addIssue(issues, `${slotPath}.name`, "invalid-slot", `slot name "${name}" must be lowercase kebab-case (or "default")`);
329
+ }
330
+ if (seenNames.has(name)) {
331
+ addIssue(issues, `${slotPath}.name`, "duplicate-slot", `duplicate slot name "${name}"`);
332
+ }
333
+ else {
334
+ seenNames.add(name);
335
+ }
336
+ }
337
+ if (slot.multiple !== undefined && typeof slot.multiple !== "boolean") {
338
+ addIssue(issues, `${slotPath}.multiple`, "invalid-slot", "`multiple` must be a boolean");
339
+ }
340
+ if (slot.allow !== undefined) {
341
+ inspectAllow(slot.allow, `${slotPath}.allow`, issues, catalogKeys);
342
+ }
343
+ });
344
+ }
345
+ function inspectAllow(allow, path, issues, catalogKeys) {
346
+ if (!Array.isArray(allow) || allow.length === 0) {
347
+ addIssue(issues, path, "invalid-slot", "`allow` must be a non-empty array of component types (omit it to accept any type)");
348
+ return;
349
+ }
350
+ const seen = new Set();
351
+ allow.forEach((type, index) => {
352
+ const typePath = `${path}[${index}]`;
353
+ if (typeof type !== "string" || !catalogKeys.has(type)) {
354
+ addIssue(issues, typePath, "invalid-slot", `${JSON.stringify(type)} is not a component type in this catalog`);
355
+ }
356
+ else if (seen.has(type)) {
357
+ addIssue(issues, typePath, "invalid-slot", `duplicate allowed type "${type}"`);
358
+ }
359
+ else {
360
+ seen.add(type);
361
+ }
362
+ });
363
+ }
@@ -0,0 +1,19 @@
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 { localize } from "./locales.js";
16
+ export { PLURAL_CATEGORIES, formatMessage, messageForms, pluralFormIssues } from "./messages.js";
17
+ export type { MessageOptions, MessageValues, PluralCategory } from "./messages.js";
18
+ export { THEME_MANIFEST_CONTRACT_VERSION, themeManifest, validateThemeManifest, } from "./manifest.js";
19
+ export type { JsonValue, JsonObject, LocaleCode, LocalizedText, ResolutionScope, FieldKind, FieldDefinition, FieldValue, InferProps, SlotDefinition, ComponentCapabilities, ComponentDefinition, ComponentSpec, SurfaceSpec, ThemeDefinition, 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,17 @@
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 { localize } from "./locales.js";
16
+ export { PLURAL_CATEGORIES, formatMessage, messageForms, pluralFormIssues } from "./messages.js";
17
+ export { THEME_MANIFEST_CONTRACT_VERSION, themeManifest, validateThemeManifest, } from "./manifest.js";
@@ -0,0 +1,22 @@
1
+ import type { ComponentDefinition, ComponentInstance, JsonObject, 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
+ /**
9
+ * Create a new instance with insertion-time defaults:
10
+ * materialises `field.default` values only — content-keyed fields stay unset
11
+ * until a merchant overrides them. Throws on programmer errors (empty id,
12
+ * unknown keys, wrong-typed values, a required field with nothing to fill
13
+ * it); the storefront never calls this. A required field with a `contentKey`
14
+ * is filled from the content pack at render, so it may be left unset here.
15
+ *
16
+ * Authored defaults are validated exactly like provided values: a default that
17
+ * fails its field's rules (out-of-range number, off-list select option) is an
18
+ * authoring bug, and `validateThemeDefinition` reports it at assembly time —
19
+ * baking it into an instance silently here would let the writer and the reader
20
+ * disagree.
21
+ */
22
+ export declare function createInstance(definition: ComponentDefinition, id: string, props?: JsonObject): ComponentInstance;