@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.
- package/CHANGELOG.md +54 -0
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +434 -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 +363 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +17 -0
- package/dist/instances.d.ts +22 -0
- package/dist/instances.js +295 -0
- package/dist/locales.d.ts +12 -0
- package/dist/locales.js +14 -0
- package/dist/manifest.d.ts +98 -0
- package/dist/manifest.js +308 -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 +49 -0
- package/dist/schemas.js +100 -0
- package/dist/types.d.ts +412 -0
- package/dist/types.js +1 -0
- package/package.json +80 -4
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The serializable half of the theme contract: a `ThemeDefinition` as data, for
|
|
3
|
+
* tooling that must read a theme without executing its modules.
|
|
4
|
+
*
|
|
5
|
+
* `themeManifest` derives the manifest from the code (one source of truth).
|
|
6
|
+
* `validateThemeManifest` is the boundary for a manifest that arrives as JSON:
|
|
7
|
+
* serializability is checked on the raw input, structure by `ThemeManifestSchema`
|
|
8
|
+
* (which also normalizes), and relational rules by `validateThemeDefinition` —
|
|
9
|
+
* so the manifest and assembly validators cannot disagree.
|
|
10
|
+
*/
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { validateThemeDefinition } from "./definitions.js";
|
|
13
|
+
/** Version of the manifest schema and its interpretation (not the theme's). */
|
|
14
|
+
export const THEME_MANIFEST_CONTRACT_VERSION = 1;
|
|
15
|
+
const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
16
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
17
|
+
// ── Derivation ───────────────────────────────────────────────────────────────
|
|
18
|
+
/** Derive the data-only manifest from a theme. `component` and `load` are dropped. */
|
|
19
|
+
export function themeManifest(theme) {
|
|
20
|
+
return {
|
|
21
|
+
contractVersion: THEME_MANIFEST_CONTRACT_VERSION,
|
|
22
|
+
key: theme.key,
|
|
23
|
+
version: theme.version,
|
|
24
|
+
surfaces: (theme.surfaces ?? []).map((surface) => ({
|
|
25
|
+
key: surface.key,
|
|
26
|
+
label: copyData(surface.label),
|
|
27
|
+
})),
|
|
28
|
+
locales: [...(theme.locales ?? [])],
|
|
29
|
+
components: Object.fromEntries(Object.entries(theme.components).map(([key, definition]) => [
|
|
30
|
+
key,
|
|
31
|
+
componentSpec(definition),
|
|
32
|
+
])),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function componentSpec(definition) {
|
|
36
|
+
return {
|
|
37
|
+
type: definition.type,
|
|
38
|
+
label: copyData(definition.label),
|
|
39
|
+
...(definition.group === undefined ? {} : { group: copyData(definition.group) }),
|
|
40
|
+
fields: (definition.fields ?? []).map((field) => copyData(field)),
|
|
41
|
+
slots: (definition.slots ?? []).map((slot) => copyData(slot)),
|
|
42
|
+
...(definition.capabilities === undefined
|
|
43
|
+
? {}
|
|
44
|
+
: { capabilities: copyData(definition.capabilities) }),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A deep copy of authored data (labels, fields, slots), so the manifest never
|
|
49
|
+
* aliases the theme. These values are JSON by definition; anything that is not
|
|
50
|
+
* is exactly what `validateThemeManifest` reports.
|
|
51
|
+
*/
|
|
52
|
+
function copyData(value) {
|
|
53
|
+
return value === undefined ? value : JSON.parse(JSON.stringify(value));
|
|
54
|
+
}
|
|
55
|
+
// ── Schema (structure + normalization) ───────────────────────────────────────
|
|
56
|
+
/** A label or text value: the same in every language, or one per language. */
|
|
57
|
+
const LocalizedTextSchema = z.union([z.string(), z.record(z.string(), z.string())]);
|
|
58
|
+
const FieldCommonSchema = {
|
|
59
|
+
key: z.string(),
|
|
60
|
+
label: LocalizedTextSchema,
|
|
61
|
+
description: z.exactOptional(LocalizedTextSchema),
|
|
62
|
+
required: z.exactOptional(z.boolean()),
|
|
63
|
+
};
|
|
64
|
+
const TextFieldSchema = z.object({
|
|
65
|
+
...FieldCommonSchema,
|
|
66
|
+
contentKey: z.exactOptional(z.string()),
|
|
67
|
+
default: z.exactOptional(LocalizedTextSchema),
|
|
68
|
+
});
|
|
69
|
+
const FieldSchema = z.discriminatedUnion("kind", [
|
|
70
|
+
TextFieldSchema.extend({ kind: z.literal("text") }),
|
|
71
|
+
TextFieldSchema.extend({ kind: z.literal("textarea") }),
|
|
72
|
+
z.object({
|
|
73
|
+
...FieldCommonSchema,
|
|
74
|
+
kind: z.literal("number"),
|
|
75
|
+
default: z.exactOptional(z.number()),
|
|
76
|
+
min: z.exactOptional(z.number()),
|
|
77
|
+
max: z.exactOptional(z.number()),
|
|
78
|
+
}),
|
|
79
|
+
z.object({
|
|
80
|
+
...FieldCommonSchema,
|
|
81
|
+
kind: z.literal("boolean"),
|
|
82
|
+
default: z.exactOptional(z.boolean()),
|
|
83
|
+
}),
|
|
84
|
+
z.object({
|
|
85
|
+
...FieldCommonSchema,
|
|
86
|
+
kind: z.literal("select"),
|
|
87
|
+
options: z.array(z.string()),
|
|
88
|
+
default: z.exactOptional(z.string()),
|
|
89
|
+
}),
|
|
90
|
+
z.object({
|
|
91
|
+
...FieldCommonSchema,
|
|
92
|
+
kind: z.literal("url"),
|
|
93
|
+
default: z.exactOptional(z.string()),
|
|
94
|
+
}),
|
|
95
|
+
]);
|
|
96
|
+
const ComponentSpecSchema = z.object({
|
|
97
|
+
type: z.string(),
|
|
98
|
+
label: LocalizedTextSchema,
|
|
99
|
+
group: z.exactOptional(LocalizedTextSchema),
|
|
100
|
+
fields: z.array(FieldSchema).default([]),
|
|
101
|
+
slots: z
|
|
102
|
+
.array(z.object({
|
|
103
|
+
name: z.string(),
|
|
104
|
+
multiple: z.exactOptional(z.boolean()),
|
|
105
|
+
allow: z.exactOptional(z.array(z.string())),
|
|
106
|
+
}))
|
|
107
|
+
.default([]),
|
|
108
|
+
capabilities: z.exactOptional(z.object({ insertable: z.exactOptional(z.boolean()) })),
|
|
109
|
+
});
|
|
110
|
+
/**
|
|
111
|
+
* The manifest's structural shape. Unknown keys are stripped (the forward-compat
|
|
112
|
+
* tradeoff documented in the README); absent `fields`/`slots` normalize to `[]`.
|
|
113
|
+
* Rules that span fields are not here — `validateThemeManifest` applies them.
|
|
114
|
+
*/
|
|
115
|
+
export const ThemeManifestSchema = z.object({
|
|
116
|
+
contractVersion: z.literal(THEME_MANIFEST_CONTRACT_VERSION),
|
|
117
|
+
key: z.string(),
|
|
118
|
+
version: z.string(),
|
|
119
|
+
surfaces: z
|
|
120
|
+
.array(z.object({ key: z.string(), label: LocalizedTextSchema }))
|
|
121
|
+
.default([]),
|
|
122
|
+
locales: z.array(z.string()).default([]),
|
|
123
|
+
components: z.record(z.string(), ComponentSpecSchema),
|
|
124
|
+
});
|
|
125
|
+
// ── Validation ───────────────────────────────────────────────────────────────
|
|
126
|
+
/**
|
|
127
|
+
* Validate a manifest that arrived from outside the code. A structural failure
|
|
128
|
+
* returns `{ manifest: null, issues }`; a non-semver `version` is advisory and
|
|
129
|
+
* returns the manifest with an `invalid-version` issue.
|
|
130
|
+
*/
|
|
131
|
+
export function validateThemeManifest(input) {
|
|
132
|
+
if (!isRecord(input)) {
|
|
133
|
+
return fatal("", "invalid-manifest", "manifest must be an object");
|
|
134
|
+
}
|
|
135
|
+
if (input.contractVersion !== THEME_MANIFEST_CONTRACT_VERSION) {
|
|
136
|
+
return fatal("contractVersion", "unsupported-contract", `unsupported manifest contractVersion ${JSON.stringify(input.contractVersion ?? null)}; this SDK understands version ${THEME_MANIFEST_CONTRACT_VERSION}`);
|
|
137
|
+
}
|
|
138
|
+
const serializability = [];
|
|
139
|
+
inspectSerializable(input, serializability);
|
|
140
|
+
if (serializability.length > 0) {
|
|
141
|
+
return { manifest: null, issues: serializability };
|
|
142
|
+
}
|
|
143
|
+
const parsed = ThemeManifestSchema.safeParse(input);
|
|
144
|
+
if (!parsed.success) {
|
|
145
|
+
const issues = parsed.error.issues.map((issue) => {
|
|
146
|
+
const path = toPath(issue.path);
|
|
147
|
+
return { path, code: codeAt(path), message: issue.message };
|
|
148
|
+
});
|
|
149
|
+
return { manifest: null, issues };
|
|
150
|
+
}
|
|
151
|
+
const manifest = parsed.data;
|
|
152
|
+
const issues = validateThemeDefinition(probeOf(manifest)).map((issue) => ({
|
|
153
|
+
path: issue.path,
|
|
154
|
+
code: issue.code === "type-mismatch" ? "type-mismatch" : codeAt(issue.path),
|
|
155
|
+
message: issue.message,
|
|
156
|
+
}));
|
|
157
|
+
if (issues.length > 0) {
|
|
158
|
+
return { manifest: null, issues };
|
|
159
|
+
}
|
|
160
|
+
if (!SEMVER_PATTERN.test(manifest.version)) {
|
|
161
|
+
issues.push({
|
|
162
|
+
path: "version",
|
|
163
|
+
code: "invalid-version",
|
|
164
|
+
message: `theme version "${manifest.version}" is not semver; the manifest is still valid`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return { manifest, issues };
|
|
168
|
+
}
|
|
169
|
+
function fatal(path, code, message) {
|
|
170
|
+
return { manifest: null, issues: [{ path, code, message }] };
|
|
171
|
+
}
|
|
172
|
+
/** Maps a path into the manifest to the issue code its section owns. */
|
|
173
|
+
function codeAt(path) {
|
|
174
|
+
if (path === "key")
|
|
175
|
+
return "invalid-identity";
|
|
176
|
+
if (path === "version")
|
|
177
|
+
return "invalid-version";
|
|
178
|
+
if (path === "surfaces" || path.startsWith("surfaces["))
|
|
179
|
+
return "invalid-surface";
|
|
180
|
+
if (path.startsWith("components."))
|
|
181
|
+
return "invalid-component";
|
|
182
|
+
return "invalid-manifest";
|
|
183
|
+
}
|
|
184
|
+
function toPath(segments) {
|
|
185
|
+
return segments.reduce((path, segment) => {
|
|
186
|
+
if (typeof segment === "number")
|
|
187
|
+
return `${path}[${segment}]`;
|
|
188
|
+
const name = String(segment);
|
|
189
|
+
return path === "" ? name : `${path}.${name}`;
|
|
190
|
+
}, "");
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The manifest as a `ThemeDefinition` with a placeholder component, so the
|
|
194
|
+
* assembly validator checks the relational rules without a real component.
|
|
195
|
+
*/
|
|
196
|
+
function probeOf(manifest) {
|
|
197
|
+
return {
|
|
198
|
+
key: manifest.key,
|
|
199
|
+
version: manifest.version,
|
|
200
|
+
surfaces: manifest.surfaces,
|
|
201
|
+
locales: manifest.locales,
|
|
202
|
+
components: Object.fromEntries(Object.entries(manifest.components).map(([key, spec]) => [
|
|
203
|
+
key,
|
|
204
|
+
{ ...spec, component: null },
|
|
205
|
+
])),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
// ── Serializability ──────────────────────────────────────────────────────────
|
|
209
|
+
function inspectSerializable(input, issues) {
|
|
210
|
+
for (const [key, value] of Object.entries(input)) {
|
|
211
|
+
if (key === "components")
|
|
212
|
+
continue;
|
|
213
|
+
if (rejectPrototypeKey(key, key, issues))
|
|
214
|
+
continue;
|
|
215
|
+
inspectValue(value, key, issues, new Set());
|
|
216
|
+
}
|
|
217
|
+
const components = input.components;
|
|
218
|
+
if (components === undefined)
|
|
219
|
+
return;
|
|
220
|
+
if (!isRecord(components)) {
|
|
221
|
+
inspectValue(components, "components", issues, new Set());
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
for (const [key, entry] of Object.entries(components)) {
|
|
225
|
+
const path = `components.${key}`;
|
|
226
|
+
if (rejectPrototypeKey(key, path, issues))
|
|
227
|
+
continue;
|
|
228
|
+
if (!isRecord(entry)) {
|
|
229
|
+
inspectValue(entry, path, issues, new Set());
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
for (const [field, value] of Object.entries(entry)) {
|
|
233
|
+
const fieldPath = `${path}.${field}`;
|
|
234
|
+
if (field === "component" || field === "load") {
|
|
235
|
+
issues.push({
|
|
236
|
+
path: fieldPath,
|
|
237
|
+
code: "non-serializable",
|
|
238
|
+
message: `\`${field}\` is runtime-only and does not belong in a manifest`,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
else if (!rejectPrototypeKey(field, fieldPath, issues)) {
|
|
242
|
+
inspectValue(value, fieldPath, issues, new Set());
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* `__proto__` is valid JSON but never valid data: as a record key it would set a
|
|
249
|
+
* prototype instead of a field, so the entry would vanish without a report.
|
|
250
|
+
*/
|
|
251
|
+
function rejectPrototypeKey(key, path, issues) {
|
|
252
|
+
if (key !== "__proto__")
|
|
253
|
+
return false;
|
|
254
|
+
issues.push({
|
|
255
|
+
path,
|
|
256
|
+
code: codeAt(path),
|
|
257
|
+
message: "`__proto__` cannot be a key in a manifest",
|
|
258
|
+
});
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
function inspectValue(value, path, issues, ancestors) {
|
|
262
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
263
|
+
return;
|
|
264
|
+
if (typeof value === "number") {
|
|
265
|
+
if (!Number.isFinite(value)) {
|
|
266
|
+
issues.push(nonSerializable(path, `${value} is not a finite JSON number`));
|
|
267
|
+
}
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (typeof value !== "object") {
|
|
271
|
+
issues.push(nonSerializable(path, `${typeof value} values cannot be serialized`));
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (ancestors.has(value)) {
|
|
275
|
+
issues.push(nonSerializable(path, "circular references cannot be serialized"));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (!Array.isArray(value) && !isPlainObject(value)) {
|
|
279
|
+
issues.push(nonSerializable(path, "only plain objects, arrays and JSON primitives can be serialized"));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
ancestors.add(value);
|
|
283
|
+
if (Array.isArray(value)) {
|
|
284
|
+
value.forEach((item, index) => {
|
|
285
|
+
inspectValue(item, `${path}[${index}]`, issues, ancestors);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
for (const [key, child] of Object.entries(value)) {
|
|
290
|
+
const childPath = `${path}.${key}`;
|
|
291
|
+
if (!rejectPrototypeKey(key, childPath, issues)) {
|
|
292
|
+
inspectValue(child, childPath, issues, ancestors);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
ancestors.delete(value);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* A plain object's prototype is `Object.prototype` (or none). Date, Map and
|
|
300
|
+
* class instances fail this: they do not survive a JSON round trip.
|
|
301
|
+
*/
|
|
302
|
+
function isPlainObject(value) {
|
|
303
|
+
const prototype = Object.getPrototypeOf(value);
|
|
304
|
+
return prototype === null || Object.getPrototypeOf(prototype) === null;
|
|
305
|
+
}
|
|
306
|
+
function nonSerializable(path, message) {
|
|
307
|
+
return { path, code: "non-serializable", message };
|
|
308
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-pack messages: placeholders and plurals.
|
|
3
|
+
*
|
|
4
|
+
* Shopify's locale-file model, on the platform's own CLDR data
|
|
5
|
+
* (`Intl.PluralRules`) instead of a message-format library:
|
|
6
|
+
*
|
|
7
|
+
* - A placeholder is `{name}`; every occurrence is filled, numbers are
|
|
8
|
+
* formatted for the language, and a placeholder without a value stays
|
|
9
|
+
* visible rather than vanishing.
|
|
10
|
+
* - A plural message is a family of flat pack keys, one per CLDR category:
|
|
11
|
+
* `cartItems_one`, `cartItems_few`, `cartItems_other`… The `count` value
|
|
12
|
+
* picks the form its language's rules select; `_other` is the fallback.
|
|
13
|
+
* As in Shopify, an explicit `_zero` form is used for a count of 0 in any
|
|
14
|
+
* language ("Your cart is empty" rather than "0 items").
|
|
15
|
+
*
|
|
16
|
+
* Keys stay flat strings, so packs, merchant overrides and editors keep
|
|
17
|
+
* treating content as a plain `Record<string, string>`.
|
|
18
|
+
*/
|
|
19
|
+
/** The CLDR plural categories, in CLDR order. */
|
|
20
|
+
export declare const PLURAL_CATEGORIES: readonly ["zero", "one", "two", "few", "many", "other"];
|
|
21
|
+
export type PluralCategory = (typeof PLURAL_CATEGORIES)[number];
|
|
22
|
+
export type MessageValues = Readonly<Record<string, string | number>>;
|
|
23
|
+
export interface MessageOptions {
|
|
24
|
+
/** The language whose plural rules pick a form (BCP 47). */
|
|
25
|
+
locale: string;
|
|
26
|
+
/**
|
|
27
|
+
* How numbers are written. Defaults to `Intl.NumberFormat(locale)`; a theme
|
|
28
|
+
* whose market writes Latin digits in Arabic passes its own rule.
|
|
29
|
+
*/
|
|
30
|
+
formatNumber?: (value: number) => string;
|
|
31
|
+
}
|
|
32
|
+
type Pack = Readonly<Record<string, string>>;
|
|
33
|
+
/** The plural categories a language distinguishes, per CLDR. */
|
|
34
|
+
export declare function pluralCategories(locale: string): PluralCategory[];
|
|
35
|
+
/**
|
|
36
|
+
* The template for `key` with `count`: `_zero` for 0 when the pack has one,
|
|
37
|
+
* then the category the language's rules select, then `_other`, then the
|
|
38
|
+
* plain key. Without a count, the plain key.
|
|
39
|
+
*/
|
|
40
|
+
export declare function messageTemplate(pack: Pack, key: string, count: number | undefined, locale: string): string | undefined;
|
|
41
|
+
/** Fill every `{name}` placeholder; numbers are formatted, unknown names stay visible. */
|
|
42
|
+
export declare function fillPlaceholders(template: string, values: MessageValues, formatNumber: (value: number) => string): string;
|
|
43
|
+
/**
|
|
44
|
+
* The message `key` from `pack`, with its plural form chosen by
|
|
45
|
+
* `values.count` and its placeholders filled. A key the pack does not carry
|
|
46
|
+
* renders as the key itself, so a missing string is visible, never blank.
|
|
47
|
+
*/
|
|
48
|
+
export declare function formatMessage(pack: Pack, key: string, values: MessageValues | undefined, options: MessageOptions): string;
|
|
49
|
+
/** The keys of every plural message in a pack (`cartItems` for `cartItems_other`). */
|
|
50
|
+
export declare function pluralMessageKeys(pack: Pack): string[];
|
|
51
|
+
/**
|
|
52
|
+
* A pack's plural messages checked against its language: the forms CLDR says
|
|
53
|
+
* the language needs but the pack lacks, and forms it carries that the
|
|
54
|
+
* language never selects (`_zero` is always allowed — it is the explicit
|
|
55
|
+
* empty-state form). Empty when the pack is complete.
|
|
56
|
+
*/
|
|
57
|
+
export declare function pluralFormIssues(pack: Pack, locale: string): string[];
|
|
58
|
+
/** One message's keys — the plain key and every plural form present — to ship to a browser script. */
|
|
59
|
+
export declare function messageForms(pack: Pack, key: string): Record<string, string>;
|
|
60
|
+
export {};
|
package/dist/messages.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-pack messages: placeholders and plurals.
|
|
3
|
+
*
|
|
4
|
+
* Shopify's locale-file model, on the platform's own CLDR data
|
|
5
|
+
* (`Intl.PluralRules`) instead of a message-format library:
|
|
6
|
+
*
|
|
7
|
+
* - A placeholder is `{name}`; every occurrence is filled, numbers are
|
|
8
|
+
* formatted for the language, and a placeholder without a value stays
|
|
9
|
+
* visible rather than vanishing.
|
|
10
|
+
* - A plural message is a family of flat pack keys, one per CLDR category:
|
|
11
|
+
* `cartItems_one`, `cartItems_few`, `cartItems_other`… The `count` value
|
|
12
|
+
* picks the form its language's rules select; `_other` is the fallback.
|
|
13
|
+
* As in Shopify, an explicit `_zero` form is used for a count of 0 in any
|
|
14
|
+
* language ("Your cart is empty" rather than "0 items").
|
|
15
|
+
*
|
|
16
|
+
* Keys stay flat strings, so packs, merchant overrides and editors keep
|
|
17
|
+
* treating content as a plain `Record<string, string>`.
|
|
18
|
+
*/
|
|
19
|
+
/** The CLDR plural categories, in CLDR order. */
|
|
20
|
+
export const PLURAL_CATEGORIES = ["zero", "one", "two", "few", "many", "other"];
|
|
21
|
+
const rulesCache = new Map();
|
|
22
|
+
function pluralRules(locale) {
|
|
23
|
+
let rules = rulesCache.get(locale);
|
|
24
|
+
if (!rules) {
|
|
25
|
+
rules = new Intl.PluralRules(locale);
|
|
26
|
+
rulesCache.set(locale, rules);
|
|
27
|
+
}
|
|
28
|
+
return rules;
|
|
29
|
+
}
|
|
30
|
+
/** The plural categories a language distinguishes, per CLDR. */
|
|
31
|
+
export function pluralCategories(locale) {
|
|
32
|
+
const used = new Set(pluralRules(locale).resolvedOptions().pluralCategories);
|
|
33
|
+
return PLURAL_CATEGORIES.filter((category) => used.has(category));
|
|
34
|
+
}
|
|
35
|
+
const own = (pack, key) => (Object.hasOwn(pack, key) ? pack[key] : undefined);
|
|
36
|
+
/**
|
|
37
|
+
* The template for `key` with `count`: `_zero` for 0 when the pack has one,
|
|
38
|
+
* then the category the language's rules select, then `_other`, then the
|
|
39
|
+
* plain key. Without a count, the plain key.
|
|
40
|
+
*/
|
|
41
|
+
export function messageTemplate(pack, key, count, locale) {
|
|
42
|
+
if (count === undefined)
|
|
43
|
+
return own(pack, key);
|
|
44
|
+
if (count === 0 && own(pack, `${key}_zero`) !== undefined)
|
|
45
|
+
return own(pack, `${key}_zero`);
|
|
46
|
+
return own(pack, `${key}_${pluralRules(locale).select(count)}`) ?? own(pack, `${key}_other`) ?? own(pack, key);
|
|
47
|
+
}
|
|
48
|
+
/** Fill every `{name}` placeholder; numbers are formatted, unknown names stay visible. */
|
|
49
|
+
export function fillPlaceholders(template, values, formatNumber) {
|
|
50
|
+
return template.replace(/\{(\w+)\}/g, (placeholder, name) => {
|
|
51
|
+
if (!Object.hasOwn(values, name))
|
|
52
|
+
return placeholder;
|
|
53
|
+
const value = values[name];
|
|
54
|
+
return typeof value === "number" ? formatNumber(value) : value;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The message `key` from `pack`, with its plural form chosen by
|
|
59
|
+
* `values.count` and its placeholders filled. A key the pack does not carry
|
|
60
|
+
* renders as the key itself, so a missing string is visible, never blank.
|
|
61
|
+
*/
|
|
62
|
+
export function formatMessage(pack, key, values = {}, options) {
|
|
63
|
+
const count = typeof values.count === "number" ? values.count : undefined;
|
|
64
|
+
const template = messageTemplate(pack, key, count, options.locale) ?? key;
|
|
65
|
+
const formatNumber = options.formatNumber ?? ((value) => new Intl.NumberFormat(options.locale).format(value));
|
|
66
|
+
return fillPlaceholders(template, values, formatNumber);
|
|
67
|
+
}
|
|
68
|
+
/** The keys of every plural message in a pack (`cartItems` for `cartItems_other`). */
|
|
69
|
+
export function pluralMessageKeys(pack) {
|
|
70
|
+
const suffix = new RegExp(`_(?:${PLURAL_CATEGORIES.join("|")})$`);
|
|
71
|
+
return [...new Set(Object.keys(pack).filter((key) => suffix.test(key)).map((key) => key.replace(suffix, "")))];
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A pack's plural messages checked against its language: the forms CLDR says
|
|
75
|
+
* the language needs but the pack lacks, and forms it carries that the
|
|
76
|
+
* language never selects (`_zero` is always allowed — it is the explicit
|
|
77
|
+
* empty-state form). Empty when the pack is complete.
|
|
78
|
+
*/
|
|
79
|
+
export function pluralFormIssues(pack, locale) {
|
|
80
|
+
const needed = new Set(pluralCategories(locale));
|
|
81
|
+
const issues = [];
|
|
82
|
+
for (const base of pluralMessageKeys(pack)) {
|
|
83
|
+
for (const category of PLURAL_CATEGORIES) {
|
|
84
|
+
const present = own(pack, `${base}_${category}`) !== undefined;
|
|
85
|
+
if (needed.has(category) && !present)
|
|
86
|
+
issues.push(`${base}_${category} is missing`);
|
|
87
|
+
if (!needed.has(category) && present && category !== "zero")
|
|
88
|
+
issues.push(`${base}_${category} is never used in ${locale}`);
|
|
89
|
+
}
|
|
90
|
+
if (own(pack, base) !== undefined)
|
|
91
|
+
issues.push(`${base} is both a plural message and a plain key`);
|
|
92
|
+
}
|
|
93
|
+
return issues;
|
|
94
|
+
}
|
|
95
|
+
/** One message's keys — the plain key and every plural form present — to ship to a browser script. */
|
|
96
|
+
export function messageForms(pack, key) {
|
|
97
|
+
return Object.fromEntries([key, ...PLURAL_CATEGORIES.map((category) => `${key}_${category}`)]
|
|
98
|
+
.filter((candidate) => own(pack, candidate) !== undefined)
|
|
99
|
+
.map((candidate) => [candidate, own(pack, candidate)]));
|
|
100
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PageDataResult, ResolutionScope, ThemeDefinition } from "./types.js";
|
|
2
|
+
/** Structural boundary: envelope + recursive instance shape + id uniqueness. */
|
|
3
|
+
export declare function parsePageData(input: unknown): PageDataResult;
|
|
4
|
+
/**
|
|
5
|
+
* Catalog conformance on top of the parse: unknown types, props and slots are
|
|
6
|
+
* reported (`resolveInstance` decides the same degradations the renderer will
|
|
7
|
+
* apply). Read-only.
|
|
8
|
+
*/
|
|
9
|
+
export declare function validatePageData(theme: ThemeDefinition, input: unknown, scope?: ResolutionScope): PageDataResult;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read boundary. Structural parse-and-degrade (`parsePageData`) and
|
|
3
|
+
* catalog conformance (`validatePageData`).
|
|
4
|
+
*
|
|
5
|
+
* Policy: parse, degrade, report — never throw the page
|
|
6
|
+
* away, never mutate the input. `page` is null only when the envelope itself
|
|
7
|
+
* failed; a structurally invalid section is dropped with an issue; the
|
|
8
|
+
* document itself is not rewritten by `validatePageData`.
|
|
9
|
+
*/
|
|
10
|
+
import { resolveInstance } from "./instances.js";
|
|
11
|
+
import { ComponentInstanceSchema, SURFACE_KEY_PATTERN } from "./schemas.js";
|
|
12
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13
|
+
const envelopeFailure = (message) => ({
|
|
14
|
+
page: null,
|
|
15
|
+
issues: [{ path: "", code: "envelope-invalid", message }],
|
|
16
|
+
});
|
|
17
|
+
/** Structural boundary: envelope + recursive instance shape + id uniqueness. */
|
|
18
|
+
export function parsePageData(input) {
|
|
19
|
+
if (!isRecord(input)) {
|
|
20
|
+
return envelopeFailure("page data must be a JSON object");
|
|
21
|
+
}
|
|
22
|
+
if (input.version !== 1) {
|
|
23
|
+
return envelopeFailure(`unsupported page-data version ${JSON.stringify(input.version ?? null)}; this SDK understands version 1`);
|
|
24
|
+
}
|
|
25
|
+
const surface = input.surface;
|
|
26
|
+
if (typeof surface !== "string" || !SURFACE_KEY_PATTERN.test(surface)) {
|
|
27
|
+
return envelopeFailure(`\`surface\` must be a surface key (lowercase, starting alphanumeric, kebab/dot/colon/underscore) — got ${JSON.stringify(surface ?? null)}`);
|
|
28
|
+
}
|
|
29
|
+
if (!Array.isArray(input.sections)) {
|
|
30
|
+
return envelopeFailure("`sections` must be an array of component instances");
|
|
31
|
+
}
|
|
32
|
+
const issues = [];
|
|
33
|
+
const seenIds = new Set();
|
|
34
|
+
const sections = [];
|
|
35
|
+
input.sections.forEach((raw, index) => {
|
|
36
|
+
const instance = sanitizeInstance(raw, `sections[${index}]`, seenIds, issues);
|
|
37
|
+
if (instance !== null) {
|
|
38
|
+
sections.push(instance);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
// The envelope is forward-compatible: unknown top-level keys are
|
|
42
|
+
// carried through verbatim so a later writer can record metadata without a
|
|
43
|
+
// `version` bump. `PageData` deliberately does not declare them — see its
|
|
44
|
+
// doc comment. `__proto__` is never carried: a document names data, it cannot
|
|
45
|
+
// name a prototype.
|
|
46
|
+
const page = { version: 1, surface, sections };
|
|
47
|
+
for (const key of Object.keys(input)) {
|
|
48
|
+
if (key === "version" || key === "surface" || key === "sections")
|
|
49
|
+
continue;
|
|
50
|
+
if (key === "__proto__")
|
|
51
|
+
continue;
|
|
52
|
+
page[key] = input[key];
|
|
53
|
+
}
|
|
54
|
+
return { page, issues };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Catalog conformance on top of the parse: unknown types, props and slots are
|
|
58
|
+
* reported (`resolveInstance` decides the same degradations the renderer will
|
|
59
|
+
* apply). Read-only.
|
|
60
|
+
*/
|
|
61
|
+
export function validatePageData(theme, input, scope = {}) {
|
|
62
|
+
const parsed = parsePageData(input);
|
|
63
|
+
if (parsed.page === null) {
|
|
64
|
+
return parsed;
|
|
65
|
+
}
|
|
66
|
+
const issues = [...parsed.issues];
|
|
67
|
+
const walk = (instance, path) => {
|
|
68
|
+
const definition = Object.hasOwn(theme.components, instance.type)
|
|
69
|
+
? theme.components[instance.type]
|
|
70
|
+
: undefined;
|
|
71
|
+
if (definition === undefined) {
|
|
72
|
+
issues.push({
|
|
73
|
+
path: `${path}.type`,
|
|
74
|
+
code: "unknown-type",
|
|
75
|
+
message: `"${instance.type}" is not in the theme catalog; this instance renders nothing`,
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const resolved = resolveInstance(definition, instance, scope);
|
|
80
|
+
for (const issue of resolved.issues) {
|
|
81
|
+
issues.push({ ...issue, path: `${path}.${issue.path}` });
|
|
82
|
+
}
|
|
83
|
+
resolved.children.forEach((child, index) => {
|
|
84
|
+
walk(child, `${path}.children[${index}]`);
|
|
85
|
+
});
|
|
86
|
+
for (const [name, list] of Object.entries(resolved.slots)) {
|
|
87
|
+
list.forEach((child, index) => {
|
|
88
|
+
walk(child, `${path}.slots.${name}[${index}]`);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
parsed.page.sections.forEach((section, index) => {
|
|
93
|
+
walk(section, `sections[${index}]`);
|
|
94
|
+
});
|
|
95
|
+
return { page: parsed.page, issues };
|
|
96
|
+
}
|
|
97
|
+
function sanitizeInstance(raw, path, seenIds, issues) {
|
|
98
|
+
const parsed = ComponentInstanceSchema.safeParse(raw);
|
|
99
|
+
if (!parsed.success) {
|
|
100
|
+
const detail = parsed.error.issues[0]?.message ?? "invalid shape";
|
|
101
|
+
issues.push({
|
|
102
|
+
path,
|
|
103
|
+
code: "instance-dropped",
|
|
104
|
+
message: `instance failed structural validation: ${detail}`,
|
|
105
|
+
});
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
return pruneDuplicateIds(parsed.data, path, seenIds, issues);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Deterministic rebuild with duplicate ids pruned (depth-first, first wins).
|
|
112
|
+
* Property presence is preserved — a valid document round-trips unchanged;
|
|
113
|
+
* pruning to empty (all duplicates dropped) keeps the empty container.
|
|
114
|
+
*/
|
|
115
|
+
function pruneDuplicateIds(instance, path, seenIds, issues) {
|
|
116
|
+
if (seenIds.has(instance.id)) {
|
|
117
|
+
issues.push({
|
|
118
|
+
path: `${path}.id`,
|
|
119
|
+
code: "duplicate-id",
|
|
120
|
+
message: `duplicate instance id "${instance.id}"; the later instance is dropped`,
|
|
121
|
+
});
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
seenIds.add(instance.id);
|
|
125
|
+
const pruned = { id: instance.id, type: instance.type };
|
|
126
|
+
if (instance.props !== undefined) {
|
|
127
|
+
pruned.props = instance.props;
|
|
128
|
+
}
|
|
129
|
+
if (instance.children !== undefined) {
|
|
130
|
+
const children = [];
|
|
131
|
+
instance.children.forEach((child, index) => {
|
|
132
|
+
const sane = pruneDuplicateIds(child, `${path}.children[${index}]`, seenIds, issues);
|
|
133
|
+
if (sane !== null) {
|
|
134
|
+
children.push(sane);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
pruned.children = children;
|
|
138
|
+
}
|
|
139
|
+
if (instance.slots !== undefined) {
|
|
140
|
+
const slots = {};
|
|
141
|
+
for (const [name, list] of Object.entries(instance.slots)) {
|
|
142
|
+
const saneList = [];
|
|
143
|
+
list.forEach((child, index) => {
|
|
144
|
+
const sane = pruneDuplicateIds(child, `${path}.slots.${name}[${index}]`, seenIds, issues);
|
|
145
|
+
if (sane !== null) {
|
|
146
|
+
saneList.push(sane);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
slots[name] = saneList;
|
|
150
|
+
}
|
|
151
|
+
pruned.slots = slots;
|
|
152
|
+
}
|
|
153
|
+
return pruned;
|
|
154
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod schemas for the persistence boundary — the only place runtime input is
|
|
3
|
+
* validated. Definitions are typechecked code; page data is parsed data.
|
|
4
|
+
*/
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import type { ComponentInstance, JsonValue } from "./types.js";
|
|
7
|
+
/**
|
|
8
|
+
* A theme's key: lowercase kebab-case, at most 64 characters. The platform
|
|
9
|
+
* stores a merchant's text edits under it, so it is the theme's identity on the
|
|
10
|
+
* platform, and the server applies the same rule.
|
|
11
|
+
*/
|
|
12
|
+
export declare const THEME_KEY_PATTERN: RegExp;
|
|
13
|
+
/** Type keys are lowercase kebab-case. */
|
|
14
|
+
export declare const TYPE_KEY_PATTERN: RegExp;
|
|
15
|
+
/**
|
|
16
|
+
* The page-document **surface** key — the address of a composed page
|
|
17
|
+
* (`home` today; `page:<slug>` / `lp:<slug>` are reserved conventions).
|
|
18
|
+
*
|
|
19
|
+
* The server applies the same pattern. Module-level on purpose: it is part of
|
|
20
|
+
* the persisted-data contract,
|
|
21
|
+
* not of the package's curated public surface (`src/index.ts`).
|
|
22
|
+
*/
|
|
23
|
+
export declare const SURFACE_KEY_PATTERN: RegExp;
|
|
24
|
+
export declare function isLocaleTag(value: string): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Whether `value` is a per-language map (`{ ar: "…", fr: "…" }`): a plain
|
|
27
|
+
* object with at least one entry, every key a locale tag, every value a string.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isLocaleMap(value: unknown): value is Readonly<Record<string, string>>;
|
|
30
|
+
/**
|
|
31
|
+
* Whether a `url` field value is safe to render as an `href`: a site path,
|
|
32
|
+
* fragment or query, or an `http(s)`, `mailto` or `tel` URL. Rejects
|
|
33
|
+
* `javascript:`/`data:` and every other scheme, protocol-relative `//host`,
|
|
34
|
+
* backslashes (browsers read `/\host` as `//host`), whitespace and control
|
|
35
|
+
* characters, and anything over 2048 characters.
|
|
36
|
+
*/
|
|
37
|
+
export declare function isSafeLinkTarget(value: string): boolean;
|
|
38
|
+
export declare const JsonValueSchema: z.ZodType<JsonValue>;
|
|
39
|
+
export declare const ComponentInstanceSchema: z.ZodType<ComponentInstance>;
|
|
40
|
+
/**
|
|
41
|
+
* The envelope schema. **Loose on purpose**: unknown top-level keys
|
|
42
|
+
* survive `.parse`, matching `parsePageData` — a writer that records metadata
|
|
43
|
+
* must not have it stripped by whichever reader runs first.
|
|
44
|
+
*/
|
|
45
|
+
export declare const PageDataSchema: z.ZodObject<{
|
|
46
|
+
version: z.ZodLiteral<1>;
|
|
47
|
+
surface: z.ZodString;
|
|
48
|
+
sections: z.ZodArray<z.ZodType<ComponentInstance, unknown, z.core.$ZodTypeInternals<ComponentInstance, unknown>>>;
|
|
49
|
+
}, z.core.$loose>;
|