@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.
@@ -0,0 +1,57 @@
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
+ /** A `color` field's value: a hex color, `#rgb` or `#rrggbb`. */
25
+ export declare const COLOR_PATTERN: RegExp;
26
+ /**
27
+ * A `menu` field's value: the handle of one of the store's menus
28
+ * (`main-menu`, `footer`). The server applies the same pattern to menu
29
+ * handles; a menu that does not exist reads as no menu.
30
+ */
31
+ export declare const MENU_HANDLE_PATTERN: RegExp;
32
+ export declare function isLocaleTag(value: string): boolean;
33
+ /**
34
+ * Whether `value` is a per-language map (`{ ar: "…", fr: "…" }`): a plain
35
+ * object with at least one entry, every key a locale tag, every value a string.
36
+ */
37
+ export declare function isLocaleMap(value: unknown): value is Readonly<Record<string, string>>;
38
+ /**
39
+ * Whether a `url` field value is safe to render as an `href`: a site path,
40
+ * fragment or query, or an `http(s)`, `mailto` or `tel` URL. Rejects
41
+ * `javascript:`/`data:` and every other scheme, protocol-relative `//host`,
42
+ * backslashes (browsers read `/\host` as `//host`), whitespace and control
43
+ * characters, and anything over 2048 characters.
44
+ */
45
+ export declare function isSafeLinkTarget(value: string): boolean;
46
+ export declare const JsonValueSchema: z.ZodType<JsonValue>;
47
+ export declare const ComponentInstanceSchema: z.ZodType<ComponentInstance>;
48
+ /**
49
+ * The envelope schema. **Loose on purpose**: unknown top-level keys
50
+ * survive `.parse`, matching `parsePageData` — a writer that records metadata
51
+ * must not have it stripped by whichever reader runs first.
52
+ */
53
+ export declare const PageDataSchema: z.ZodObject<{
54
+ version: z.ZodLiteral<1>;
55
+ surface: z.ZodString;
56
+ sections: z.ZodArray<z.ZodType<ComponentInstance, unknown, z.core.$ZodTypeInternals<ComponentInstance, unknown>>>;
57
+ }, z.core.$loose>;
@@ -0,0 +1,108 @@
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
+ /**
7
+ * A theme's key: lowercase kebab-case, at most 64 characters. The platform
8
+ * stores a merchant's text edits under it, so it is the theme's identity on the
9
+ * platform, and the server applies the same rule.
10
+ */
11
+ export const THEME_KEY_PATTERN = /^(?=.{1,64}$)[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
12
+ /** Type keys are lowercase kebab-case. */
13
+ export const TYPE_KEY_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
14
+ /**
15
+ * The page-document **surface** key — the address of a composed page
16
+ * (`home` today; `page:<slug>` / `lp:<slug>` are reserved conventions).
17
+ *
18
+ * The server applies the same pattern. Module-level on purpose: it is part of
19
+ * the persisted-data contract,
20
+ * not of the package's curated public surface (`src/index.ts`).
21
+ */
22
+ export const SURFACE_KEY_PATTERN = /^[a-z0-9][a-z0-9._:-]{0,63}$/;
23
+ /** A `color` field's value: a hex color, `#rgb` or `#rrggbb`. */
24
+ export const COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
25
+ /**
26
+ * A `menu` field's value: the handle of one of the store's menus
27
+ * (`main-menu`, `footer`). The server applies the same pattern to menu
28
+ * handles; a menu that does not exist reads as no menu.
29
+ */
30
+ export const MENU_HANDLE_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
31
+ /**
32
+ * A BCP 47 language tag in canonical case: a 2–3 letter language, an optional
33
+ * 4-letter script, an optional 2-letter or 3-digit region (`ar`, `zgh`,
34
+ * `ar-DZ`, `zh-Hant-TW`, `es-419`). Canonical case keeps map keys unique:
35
+ * `fr` and `FR` can never both be present.
36
+ */
37
+ const LOCALE_TAG_PATTERN = /^[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|\d{3}))?$/;
38
+ export function isLocaleTag(value) {
39
+ return LOCALE_TAG_PATTERN.test(value);
40
+ }
41
+ /**
42
+ * Whether `value` is a per-language map (`{ ar: "…", fr: "…" }`): a plain
43
+ * object with at least one entry, every key a locale tag, every value a string.
44
+ */
45
+ export function isLocaleMap(value) {
46
+ if (typeof value !== "object" || value === null || Array.isArray(value))
47
+ return false;
48
+ const entries = Object.entries(value);
49
+ return (entries.length > 0 &&
50
+ entries.every(([key, text]) => isLocaleTag(key) && typeof text === "string"));
51
+ }
52
+ /**
53
+ * Whether a `url` field value is safe to render as an `href`: a site path,
54
+ * fragment or query, or an `http(s)`, `mailto` or `tel` URL. Rejects
55
+ * `javascript:`/`data:` and every other scheme, protocol-relative `//host`,
56
+ * backslashes (browsers read `/\host` as `//host`), whitespace and control
57
+ * characters, and anything over 2048 characters.
58
+ */
59
+ export function isSafeLinkTarget(value) {
60
+ if (value.length === 0 || value.length > 2048)
61
+ return false;
62
+ if (/[\s\\\u0000-\u001f\u007f]/.test(value))
63
+ return false;
64
+ if (value.startsWith("/"))
65
+ return !value.startsWith("//");
66
+ if (value.startsWith("#") || value.startsWith("?"))
67
+ return true;
68
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1]?.toLowerCase();
69
+ if (scheme === "http" || scheme === "https") {
70
+ return /^https?:\/\/[a-z0-9.-]+(:\d{1,5})?([/?#]|$)/i.test(value);
71
+ }
72
+ if (scheme === "mailto" || scheme === "tel")
73
+ return value.length > scheme.length + 1;
74
+ return false;
75
+ }
76
+ export const JsonValueSchema = z.lazy(() => z.union([
77
+ z.string(),
78
+ z
79
+ .number()
80
+ .refine(Number.isFinite, { message: "numbers must be finite" })
81
+ // JSON has no -0: it serializes as 0, so keeping it would break round-trips.
82
+ .transform((value) => (Object.is(value, -0) ? 0 : value)),
83
+ z.boolean(),
84
+ z.null(),
85
+ z.array(JsonValueSchema),
86
+ z.record(z.string(), JsonValueSchema),
87
+ ]));
88
+ export const ComponentInstanceSchema = z.lazy(() => z.object({
89
+ id: z.string().min(1),
90
+ type: z
91
+ .string()
92
+ .regex(TYPE_KEY_PATTERN, "type keys are lowercase kebab-case"),
93
+ props: z.exactOptional(z.record(z.string(), JsonValueSchema)),
94
+ children: z.exactOptional(z.array(ComponentInstanceSchema)),
95
+ slots: z.exactOptional(z.record(z.string(), z.array(ComponentInstanceSchema))),
96
+ }));
97
+ /**
98
+ * The envelope schema. **Loose on purpose**: unknown top-level keys
99
+ * survive `.parse`, matching `parsePageData` — a writer that records metadata
100
+ * must not have it stripped by whichever reader runs first.
101
+ */
102
+ export const PageDataSchema = z.looseObject({
103
+ version: z.literal(1),
104
+ surface: z
105
+ .string()
106
+ .regex(SURFACE_KEY_PATTERN, "surface keys are lowercase, start alphanumeric, and use kebab/dot/colon/underscore"),
107
+ sections: z.array(ComponentInstanceSchema),
108
+ });
@@ -0,0 +1,15 @@
1
+ import type { ResolutionScope, ThemeDefinition, ThemeSettingsIssue, ThemeSettingsResult } from "./types.js";
2
+ /**
3
+ * The effective settings for rendering: every declared setting, from the
4
+ * store's value when the declaration accepts it, else from the theme's
5
+ * default. `stored` is the persisted object as it arrived (anything else —
6
+ * null, an array — reads as no values and is reported). Translated text uses
7
+ * `scope.locales`; text settings may default from `scope.content`.
8
+ */
9
+ export declare function resolveThemeSettings(theme: ThemeDefinition, stored: unknown, scope?: ResolutionScope): ThemeSettingsResult;
10
+ /**
11
+ * What a writer must check before saving a store's settings for a theme:
12
+ * every key declared, every value accepted by its setting — in every language
13
+ * for a translated one. An empty list means the values may be saved.
14
+ */
15
+ export declare function checkThemeSettings(theme: ThemeDefinition, values: unknown): ThemeSettingsIssue[];
@@ -0,0 +1,80 @@
1
+ /**
2
+ * A store's theme settings: the merchant's values for the settings a theme
3
+ * declares (`ThemeDefinition.settings`), read against that declaration.
4
+ *
5
+ * Values are stored flat by setting key, for one theme (a value is only
6
+ * meaningful against the theme that declared it). Reading never fails: a
7
+ * value the declaration does not accept — a setting since removed, a color
8
+ * that is not a color, an option no longer offered — is reported and the
9
+ * setting falls back to the theme's default, exactly as a page document's
10
+ * field does. Precedence: the merchant's value → the theme's default (or its
11
+ * content pack, for text) → absent.
12
+ */
13
+ import { checkStoredValue, ownValue, resolveField } from "./instances.js";
14
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
15
+ function declaredSettings(theme) {
16
+ const fields = new Map();
17
+ for (const group of theme.settings ?? [])
18
+ for (const field of group.fields)
19
+ if (!fields.has(field.key))
20
+ fields.set(field.key, field);
21
+ return fields;
22
+ }
23
+ const PAGE_TO_SETTINGS = {
24
+ "invalid-prop": "invalid-setting",
25
+ "missing-required-prop": "missing-required-setting",
26
+ };
27
+ /**
28
+ * The effective settings for rendering: every declared setting, from the
29
+ * store's value when the declaration accepts it, else from the theme's
30
+ * default. `stored` is the persisted object as it arrived (anything else —
31
+ * null, an array — reads as no values and is reported). Translated text uses
32
+ * `scope.locales`; text settings may default from `scope.content`.
33
+ */
34
+ export function resolveThemeSettings(theme, stored, scope = {}) {
35
+ const issues = [];
36
+ const declared = declaredSettings(theme);
37
+ let values = {};
38
+ if (stored !== undefined && stored !== null && !isRecord(stored)) {
39
+ issues.push({ path: "settings", code: "invalid-settings", message: "stored settings must be an object; the theme's defaults apply" });
40
+ }
41
+ else if (isRecord(stored)) {
42
+ values = stored;
43
+ }
44
+ for (const key of Object.keys(values)) {
45
+ if (!declared.has(key))
46
+ issues.push({ path: `settings.${key}`, code: "unknown-setting", message: `"${key}" is not a setting of "${theme.key}"; ignored` });
47
+ }
48
+ const resolved = {};
49
+ const fieldIssues = [];
50
+ for (const [key, field] of declared) {
51
+ const value = resolveField(field, ownValue(values, key), scope.content ?? {}, scope.locales ?? [], fieldIssues, "settings");
52
+ if (value !== undefined)
53
+ resolved[key] = value;
54
+ }
55
+ for (const issue of fieldIssues)
56
+ issues.push({ path: issue.path, code: PAGE_TO_SETTINGS[issue.code] ?? "invalid-setting", message: issue.message });
57
+ return { values: resolved, issues };
58
+ }
59
+ /**
60
+ * What a writer must check before saving a store's settings for a theme:
61
+ * every key declared, every value accepted by its setting — in every language
62
+ * for a translated one. An empty list means the values may be saved.
63
+ */
64
+ export function checkThemeSettings(theme, values) {
65
+ if (!isRecord(values))
66
+ return [{ path: "settings", code: "invalid-settings", message: "settings must be an object" }];
67
+ const declared = declaredSettings(theme);
68
+ const issues = [];
69
+ for (const [key, value] of Object.entries(values)) {
70
+ const field = declared.get(key);
71
+ if (field === undefined) {
72
+ issues.push({ path: `settings.${key}`, code: "unknown-setting", message: `"${key}" is not a setting of "${theme.key}"` });
73
+ continue;
74
+ }
75
+ const checked = checkStoredValue(field, value);
76
+ if (!checked.ok)
77
+ issues.push({ path: `settings.${key}`, code: "invalid-setting", message: `"${key}" ${checked.message}` });
78
+ }
79
+ return issues;
80
+ }