@wocart/theme 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to `@wocart/theme` are recorded here. The package follows
5
5
  major version, and an export is marked `@deprecated` for at least one minor
6
6
  release before a major removes it.
7
7
 
8
+ ## 1.1.0 — 2026-10-17
9
+
10
+ ### Added
11
+
12
+ - **Theme settings.** `defineTheme({ settings })` declares a theme's global
13
+ settings in groups, with the field system sections use; they travel in the
14
+ manifest (`ThemeManifest.settings`, `[]` when none).
15
+ `resolveThemeSettings(theme, stored, scope)` reads a store's saved values
16
+ against the declaration — the merchant's value, else the default, with
17
+ every fallback reported (`invalid-settings`, `unknown-setting`,
18
+ `invalid-setting`, `missing-required-setting`). `checkThemeSettings` is the
19
+ writer's strict check. `InferSettings<typeof theme>` types the values.
20
+ - Field kinds `color` (`#rgb`/`#rrggbb`) and `menu` (a menu handle), in
21
+ sections and settings alike.
22
+ - Types `SettingsGroup`, `ThemeSettingsIssue`, `ThemeSettingsIssueCode`,
23
+ `ThemeSettingsResult`, `ColorFieldDefinition`, `MenuFieldDefinition`.
24
+
8
25
  ## 1.0.0 — 2026-10-13
9
26
 
10
27
  The first public release.
package/README.md CHANGED
@@ -97,6 +97,8 @@ and, for page-data resolution issues, `path`.
97
97
  | `number` | finite number | optional `min`/`max`; a `default` must sit inside them |
98
98
  | `boolean` | boolean | |
99
99
  | `select` | one of `options` | `default` must be one of the options |
100
+ | `color` | `#rgb` or `#rrggbb` | anything else (a color name, `url(…)`) is rejected at assembly and reported on read |
101
+ | `menu` | a menu handle (`main-menu`) | lowercase letters, digits and hyphens; the store's menu is read with `@wocart/storefront`'s `menus.get(handle)` |
100
102
  | `url` | a link target | a site path (`/products`), `#fragment`, `?query`, `http(s)://`, `mailto:` or `tel:`; `javascript:`, `data:`, `//host` and backslash tricks are rejected at assembly and reported on read |
101
103
 
102
104
  Use `url` for every editor-settable `href`, never `text`: the reader drops an
@@ -300,6 +302,49 @@ policy is:
300
302
  The SDK does not ship this policy as a function, because the shipped default
301
303
  document is theme content.
302
304
 
305
+ ## Theme settings
306
+
307
+ A theme's global choices — its font, which menu the header shows, a header
308
+ color — are **settings**: declared once on the theme, in groups, with the same
309
+ field kinds as a section. The merchant's values are stored per store and per
310
+ theme (a value only means something against the theme that declared it) and
311
+ reach the theme as `StoreConfig.themeSettings` from `@wocart/storefront`.
312
+
313
+ ```ts
314
+ export const theme = defineTheme({
315
+ key: "my-theme",
316
+ version: "1.0.0",
317
+ locales: ["ar", "fr"],
318
+ settings: [
319
+ {
320
+ key: "header",
321
+ label: { ar: "الرأس", fr: "En-tête" },
322
+ fields: [
323
+ { key: "headerMenu", kind: "menu", label: { ar: "القائمة", fr: "Menu" }, default: "main-menu" },
324
+ { key: "font", kind: "select", label: { ar: "الخط", fr: "Police" }, options: ["Cairo", "Tajawal"], default: "Cairo" },
325
+ ],
326
+ },
327
+ ],
328
+ components: { … },
329
+ });
330
+
331
+ const { values, issues } = resolveThemeSettings(theme, config?.themeSettings, { locales: [locale] });
332
+ values.headerMenu; // "main-menu" — typed: InferSettings<typeof theme>
333
+ ```
334
+
335
+ - **Reading never fails.** Each setting takes the merchant's value when the
336
+ declaration accepts it, else the default (or the content pack, for text),
337
+ else it is absent. A value the theme no longer accepts — a removed option, a
338
+ color that is not one, a setting since deleted — is reported in `issues`
339
+ (`invalid-setting`, `unknown-setting`, `invalid-settings`) and falls back.
340
+ - **Keys are unique across groups**; groups are for the editor, values are flat.
341
+ - **A writer checks strictly** with `checkThemeSettings(theme, values)` before
342
+ saving: every key declared, every value accepted, in every language.
343
+ - Settings travel in the manifest (`manifest.settings`), so an editor lists
344
+ them without running the theme.
345
+ - Store identity — the logo, the brand colors — is not a theme setting: it
346
+ belongs to the store and survives a theme change.
347
+
303
348
  ## Content and copy
304
349
 
305
350
  The theme owns its content. The SDK does not model a content pack, a taxonomy,
@@ -4,7 +4,7 @@
4
4
  * data — the validator exists so a theme's test suite fails loudly on catalog
5
5
  * bugs when the catalog is assembled.
6
6
  */
7
- import { SURFACE_KEY_PATTERN, THEME_KEY_PATTERN, TYPE_KEY_PATTERN, isLocaleMap, isLocaleTag, isSafeLinkTarget, } from "./schemas.js";
7
+ import { COLOR_PATTERN, MENU_HANDLE_PATTERN, SURFACE_KEY_PATTERN, THEME_KEY_PATTERN, TYPE_KEY_PATTERN, isLocaleMap, isLocaleTag, isSafeLinkTarget, } from "./schemas.js";
8
8
  /** Identity helper: returns the definition unchanged so literal types survive. */
9
9
  export function defineComponent(definition) {
10
10
  return definition;
@@ -95,6 +95,8 @@ export function validateThemeDefinition(theme, options = {}) {
95
95
  catalogKeys: new Set(Object.keys(theme.components)),
96
96
  locales,
97
97
  };
98
+ if (theme.settings !== undefined)
99
+ inspectSettings(theme.settings, issues, rules);
98
100
  for (const [key, definition] of Object.entries(theme.components)) {
99
101
  const path = `components.${key}`;
100
102
  if (!TYPE_KEY_PATTERN.test(key)) {
@@ -109,6 +111,51 @@ export function validateThemeDefinition(theme, options = {}) {
109
111
  }
110
112
  return issues;
111
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
+ }
112
159
  function inspectSurfaces(surfaces, issues, locales) {
113
160
  const seenKeys = new Set();
114
161
  surfaces.forEach((surface, index) => {
@@ -299,6 +346,18 @@ function inspectFields(fields, path, issues, rules) {
299
346
  }
300
347
  break;
301
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
+ }
302
361
  case "url": {
303
362
  if (field.default !== undefined &&
304
363
  (typeof field.default !== "string" || !isSafeLinkTarget(field.default))) {
package/dist/index.d.ts CHANGED
@@ -12,8 +12,9 @@
12
12
  export { defineComponent, defineTheme, validateThemeDefinition } from "./definitions.js";
13
13
  export { parsePageData, validatePageData } from "./page-data.js";
14
14
  export { resolveInstance, createInstance } from "./instances.js";
15
+ export { resolveThemeSettings, checkThemeSettings } from "./settings.js";
15
16
  export { localize } from "./locales.js";
16
17
  export { PLURAL_CATEGORIES, formatMessage, messageForms, pluralFormIssues } from "./messages.js";
17
18
  export type { MessageOptions, MessageValues, PluralCategory } from "./messages.js";
18
19
  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";
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 CHANGED
@@ -12,6 +12,7 @@
12
12
  export { defineComponent, defineTheme, validateThemeDefinition } from "./definitions.js";
13
13
  export { parsePageData, validatePageData } from "./page-data.js";
14
14
  export { resolveInstance, createInstance } from "./instances.js";
15
+ export { resolveThemeSettings, checkThemeSettings } from "./settings.js";
15
16
  export { localize } from "./locales.js";
16
17
  export { PLURAL_CATEGORIES, formatMessage, messageForms, pluralFormIssues } from "./messages.js";
17
18
  export { THEME_MANIFEST_CONTRACT_VERSION, themeManifest, validateThemeManifest, } from "./manifest.js";
@@ -1,10 +1,31 @@
1
- import type { ComponentDefinition, ComponentInstance, JsonObject, ResolutionScope, ResolvedInstance } from "./types.js";
1
+ import type { ComponentDefinition, ComponentInstance, FieldDefinition, JsonObject, LocaleCode, PageDataIssue, ResolutionScope, ResolvedInstance } from "./types.js";
2
2
  /**
3
3
  * Gate one instance for rendering: field values through the chain, children
4
4
  * and named slots gated against the declared slots, every degradation
5
5
  * reported. The instance (and page data) is never modified.
6
6
  */
7
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;
8
29
  /**
9
30
  * Create a new instance with insertion-time defaults:
10
31
  * materialises `field.default` values only — content-keyed fields stay unset
package/dist/instances.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * default applies last).
10
10
  */
11
11
  import { localize } from "./locales.js";
12
- import { isLocaleMap, isSafeLinkTarget } from "./schemas.js";
12
+ import { COLOR_PATTERN, MENU_HANDLE_PATTERN, isLocaleMap, isSafeLinkTarget } from "./schemas.js";
13
13
  /** Field kinds whose values may differ per language. Numbers, toggles and choices are shared. */
14
14
  const TRANSLATABLE_KINDS = new Set(["text", "textarea", "url"]);
15
15
  const isTranslatable = (field) => TRANSLATABLE_KINDS.has(field.kind);
@@ -36,7 +36,7 @@ export function resolveInstance(definition, instance, scope = {}) {
36
36
  }
37
37
  const props = {};
38
38
  for (const field of fields) {
39
- const resolved = resolveField(field, ownValue(rawProps, field.key), content, locales, issues);
39
+ const resolved = resolveField(field, ownValue(rawProps, field.key), content, locales, issues, "props");
40
40
  if (resolved !== undefined) {
41
41
  props[field.key] = resolved;
42
42
  }
@@ -82,7 +82,7 @@ export function resolveInstance(definition, instance, scope = {}) {
82
82
  return { props, children, slots, issues };
83
83
  }
84
84
  /** An own property only: `constructor` or `toString` must never read the prototype. */
85
- function ownValue(record, key) {
85
+ export function ownValue(record, key) {
86
86
  return Object.hasOwn(record, key) ? record[key] : undefined;
87
87
  }
88
88
  /**
@@ -103,8 +103,12 @@ function storedValueFor(field, raw, locales) {
103
103
  const picked = localize(raw, locales);
104
104
  return picked === undefined ? { kind: "untranslated" } : { kind: "value", value: picked };
105
105
  }
106
- /** One field through the chain. `undefined` = omit the prop. */
107
- function resolveField(field, raw, content, locales, issues) {
106
+ /**
107
+ * One field through the chain. `undefined` = omit the value. Issues are
108
+ * reported under `${prefix}.${field.key}` — a component's `props`, or a theme's
109
+ * `settings`.
110
+ */
111
+ export function resolveField(field, raw, content, locales, issues, prefix) {
108
112
  if (raw !== undefined) {
109
113
  const stored = storedValueFor(field, raw, locales);
110
114
  const checked = stored.kind === "value"
@@ -117,7 +121,7 @@ function resolveField(field, raw, content, locales, issues) {
117
121
  }
118
122
  if (checked !== undefined) {
119
123
  issues.push({
120
- path: `props.${field.key}`,
124
+ path: `${prefix}.${field.key}`,
121
125
  code: "invalid-prop",
122
126
  message: `"${field.key}" ${checked.message}; treated as absent`,
123
127
  });
@@ -142,7 +146,7 @@ function resolveField(field, raw, content, locales, issues) {
142
146
  }
143
147
  if (field.required === true) {
144
148
  issues.push({
145
- path: `props.${field.key}`,
149
+ path: `${prefix}.${field.key}`,
146
150
  code: "missing-required-prop",
147
151
  message: `required field "${field.key}" has no value`,
148
152
  });
@@ -154,7 +158,7 @@ function resolveField(field, raw, content, locales, issues) {
154
158
  * translatable field — a per-language map whose every entry passes them, so no
155
159
  * language can hold an unsafe link or a wrong type.
156
160
  */
157
- function checkStoredValue(field, value) {
161
+ export function checkStoredValue(field, value) {
158
162
  if (!isTranslatable(field) || typeof value !== "object" || value === null || Array.isArray(value)) {
159
163
  return checkFieldValue(field, value);
160
164
  }
@@ -199,6 +203,14 @@ function checkFieldValue(field, value) {
199
203
  ok: false,
200
204
  message: "must be a site path, #fragment, ?query, or an http(s), mailto or tel URL",
201
205
  };
206
+ case "color":
207
+ return typeof value === "string" && COLOR_PATTERN.test(value)
208
+ ? { ok: true, value }
209
+ : { ok: false, message: "must be a hex color, #rgb or #rrggbb" };
210
+ case "menu":
211
+ return typeof value === "string" && MENU_HANDLE_PATTERN.test(value)
212
+ ? { ok: true, value }
213
+ : { ok: false, message: "must be a menu handle" };
202
214
  case "select":
203
215
  return typeof value === "string" && field.options.includes(value)
204
216
  ? { ok: true, value }
@@ -28,6 +28,72 @@ export declare const ThemeManifestSchema: z.ZodObject<{
28
28
  label: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
29
29
  }, z.core.$strip>>>;
30
30
  locales: z.ZodDefault<z.ZodArray<z.ZodString>>;
31
+ settings: z.ZodDefault<z.ZodArray<z.ZodObject<{
32
+ key: z.ZodString;
33
+ label: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
34
+ fields: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
35
+ key: z.ZodString;
36
+ label: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
37
+ description: z.ZodExactOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
38
+ required: z.ZodExactOptional<z.ZodBoolean>;
39
+ contentKey: z.ZodExactOptional<z.ZodString>;
40
+ default: z.ZodExactOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
41
+ kind: z.ZodLiteral<"text">;
42
+ }, z.core.$strip>, z.ZodObject<{
43
+ key: z.ZodString;
44
+ label: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
45
+ description: z.ZodExactOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
46
+ required: z.ZodExactOptional<z.ZodBoolean>;
47
+ contentKey: z.ZodExactOptional<z.ZodString>;
48
+ default: z.ZodExactOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
49
+ kind: z.ZodLiteral<"textarea">;
50
+ }, z.core.$strip>, z.ZodObject<{
51
+ key: z.ZodString;
52
+ label: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
53
+ description: z.ZodExactOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
54
+ required: z.ZodExactOptional<z.ZodBoolean>;
55
+ kind: z.ZodLiteral<"number">;
56
+ default: z.ZodExactOptional<z.ZodNumber>;
57
+ min: z.ZodExactOptional<z.ZodNumber>;
58
+ max: z.ZodExactOptional<z.ZodNumber>;
59
+ }, z.core.$strip>, z.ZodObject<{
60
+ key: z.ZodString;
61
+ label: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
62
+ description: z.ZodExactOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
63
+ required: z.ZodExactOptional<z.ZodBoolean>;
64
+ kind: z.ZodLiteral<"boolean">;
65
+ default: z.ZodExactOptional<z.ZodBoolean>;
66
+ }, z.core.$strip>, z.ZodObject<{
67
+ key: z.ZodString;
68
+ label: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
69
+ description: z.ZodExactOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
70
+ required: z.ZodExactOptional<z.ZodBoolean>;
71
+ kind: z.ZodLiteral<"select">;
72
+ options: z.ZodArray<z.ZodString>;
73
+ default: z.ZodExactOptional<z.ZodString>;
74
+ }, z.core.$strip>, z.ZodObject<{
75
+ key: z.ZodString;
76
+ label: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
77
+ description: z.ZodExactOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
78
+ required: z.ZodExactOptional<z.ZodBoolean>;
79
+ kind: z.ZodLiteral<"url">;
80
+ default: z.ZodExactOptional<z.ZodString>;
81
+ }, z.core.$strip>, z.ZodObject<{
82
+ key: z.ZodString;
83
+ label: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
84
+ description: z.ZodExactOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
85
+ required: z.ZodExactOptional<z.ZodBoolean>;
86
+ kind: z.ZodLiteral<"color">;
87
+ default: z.ZodExactOptional<z.ZodString>;
88
+ }, z.core.$strip>, z.ZodObject<{
89
+ key: z.ZodString;
90
+ label: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
91
+ description: z.ZodExactOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
92
+ required: z.ZodExactOptional<z.ZodBoolean>;
93
+ kind: z.ZodLiteral<"menu">;
94
+ default: z.ZodExactOptional<z.ZodString>;
95
+ }, z.core.$strip>], "kind">>;
96
+ }, z.core.$strip>>>;
31
97
  components: z.ZodRecord<z.ZodString, z.ZodObject<{
32
98
  type: z.ZodString;
33
99
  label: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
@@ -79,6 +145,20 @@ export declare const ThemeManifestSchema: z.ZodObject<{
79
145
  required: z.ZodExactOptional<z.ZodBoolean>;
80
146
  kind: z.ZodLiteral<"url">;
81
147
  default: z.ZodExactOptional<z.ZodString>;
148
+ }, z.core.$strip>, z.ZodObject<{
149
+ key: z.ZodString;
150
+ label: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
151
+ description: z.ZodExactOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
152
+ required: z.ZodExactOptional<z.ZodBoolean>;
153
+ kind: z.ZodLiteral<"color">;
154
+ default: z.ZodExactOptional<z.ZodString>;
155
+ }, z.core.$strip>, z.ZodObject<{
156
+ key: z.ZodString;
157
+ label: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
158
+ description: z.ZodExactOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
159
+ required: z.ZodExactOptional<z.ZodBoolean>;
160
+ kind: z.ZodLiteral<"menu">;
161
+ default: z.ZodExactOptional<z.ZodString>;
82
162
  }, z.core.$strip>], "kind">>>;
83
163
  slots: z.ZodDefault<z.ZodArray<z.ZodObject<{
84
164
  name: z.ZodString;
package/dist/manifest.js CHANGED
@@ -26,6 +26,7 @@ export function themeManifest(theme) {
26
26
  label: copyData(surface.label),
27
27
  })),
28
28
  locales: [...(theme.locales ?? [])],
29
+ settings: (theme.settings ?? []).map((group) => copyData(group)),
29
30
  components: Object.fromEntries(Object.entries(theme.components).map(([key, definition]) => [
30
31
  key,
31
32
  componentSpec(definition),
@@ -92,6 +93,16 @@ const FieldSchema = z.discriminatedUnion("kind", [
92
93
  kind: z.literal("url"),
93
94
  default: z.exactOptional(z.string()),
94
95
  }),
96
+ z.object({
97
+ ...FieldCommonSchema,
98
+ kind: z.literal("color"),
99
+ default: z.exactOptional(z.string()),
100
+ }),
101
+ z.object({
102
+ ...FieldCommonSchema,
103
+ kind: z.literal("menu"),
104
+ default: z.exactOptional(z.string()),
105
+ }),
95
106
  ]);
96
107
  const ComponentSpecSchema = z.object({
97
108
  type: z.string(),
@@ -120,6 +131,9 @@ export const ThemeManifestSchema = z.object({
120
131
  .array(z.object({ key: z.string(), label: LocalizedTextSchema }))
121
132
  .default([]),
122
133
  locales: z.array(z.string()).default([]),
134
+ settings: z
135
+ .array(z.object({ key: z.string(), label: LocalizedTextSchema, fields: z.array(FieldSchema) }))
136
+ .default([]),
123
137
  components: z.record(z.string(), ComponentSpecSchema),
124
138
  });
125
139
  // ── Validation ───────────────────────────────────────────────────────────────
@@ -177,6 +191,8 @@ function codeAt(path) {
177
191
  return "invalid-version";
178
192
  if (path === "surfaces" || path.startsWith("surfaces["))
179
193
  return "invalid-surface";
194
+ if (path === "settings" || path.startsWith("settings["))
195
+ return "invalid-settings";
180
196
  if (path.startsWith("components."))
181
197
  return "invalid-component";
182
198
  return "invalid-manifest";
@@ -199,6 +215,7 @@ function probeOf(manifest) {
199
215
  version: manifest.version,
200
216
  surfaces: manifest.surfaces,
201
217
  locales: manifest.locales,
218
+ settings: manifest.settings,
202
219
  components: Object.fromEntries(Object.entries(manifest.components).map(([key, spec]) => [
203
220
  key,
204
221
  { ...spec, component: null },
package/dist/schemas.d.ts CHANGED
@@ -21,6 +21,14 @@ export declare const TYPE_KEY_PATTERN: RegExp;
21
21
  * not of the package's curated public surface (`src/index.ts`).
22
22
  */
23
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;
24
32
  export declare function isLocaleTag(value: string): boolean;
25
33
  /**
26
34
  * Whether `value` is a per-language map (`{ ar: "…", fr: "…" }`): a plain
package/dist/schemas.js CHANGED
@@ -20,6 +20,14 @@ export const TYPE_KEY_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
20
20
  * not of the package's curated public surface (`src/index.ts`).
21
21
  */
22
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}$/;
23
31
  /**
24
32
  * A BCP 47 language tag in canonical case: a 2–3 letter language, an optional
25
33
  * 4-letter script, an optional 2-letter or 3-digit region (`ar`, `zgh`,
@@ -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
+ }
package/dist/types.d.ts CHANGED
@@ -36,8 +36,8 @@ export interface ResolutionScope {
36
36
  content?: Record<string, string>;
37
37
  locales?: readonly LocaleCode[];
38
38
  }
39
- /** The V1 field kinds. */
40
- export type FieldKind = "text" | "textarea" | "number" | "boolean" | "select" | "url";
39
+ /** The field kinds. `color` and `menu` were added after V1, additively. */
40
+ export type FieldKind = "text" | "textarea" | "number" | "boolean" | "select" | "url" | "color" | "menu";
41
41
  interface FieldDefinitionBase {
42
42
  /** The exact prop name the component expects. */
43
43
  key: string;
@@ -97,7 +97,22 @@ export interface UrlFieldDefinition extends FieldDefinitionBase {
97
97
  kind: "url";
98
98
  default?: string;
99
99
  }
100
- export type FieldDefinition = TextFieldDefinition | NumberFieldDefinition | BooleanFieldDefinition | SelectFieldDefinition | UrlFieldDefinition;
100
+ /** `color`: a hex color, `#rgb` or `#rrggbb`. Not translatable. */
101
+ export interface ColorFieldDefinition extends FieldDefinitionBase {
102
+ kind: "color";
103
+ default?: string;
104
+ }
105
+ /**
106
+ * `menu`: which of the store's menus to show here — Shopify's `link_list`.
107
+ * The value is a menu handle (`main-menu`); the theme reads the menu with
108
+ * the storefront client's `menus.get(handle)`. A theme never assumes a menu
109
+ * exists: an unknown handle reads as no menu.
110
+ */
111
+ export interface MenuFieldDefinition extends FieldDefinitionBase {
112
+ kind: "menu";
113
+ default?: string;
114
+ }
115
+ export type FieldDefinition = TextFieldDefinition | NumberFieldDefinition | BooleanFieldDefinition | SelectFieldDefinition | UrlFieldDefinition | ColorFieldDefinition | MenuFieldDefinition;
101
116
  /**
102
117
  * One composition point a component declares (`"default"` = children).
103
118
  *
@@ -270,8 +285,23 @@ export interface ThemeDefinition {
270
285
  * languages the theme can serve. Absent means "not declared".
271
286
  */
272
287
  locales?: readonly LocaleCode[];
288
+ /**
289
+ * The theme's global settings: what a merchant may change about the whole
290
+ * theme (its font, its colors, which menu the header shows), declared with
291
+ * the same fields sections use, in named groups. Optional and additive. A
292
+ * store's values are read with `resolveThemeSettings`; a writer checks them
293
+ * with `checkThemeSettings`. Setting keys are unique across all groups.
294
+ */
295
+ settings?: readonly SettingsGroup[];
273
296
  components: Record<string, ComponentDefinition>;
274
297
  }
298
+ /** One group of theme settings, as an editor lists them ("Typography", "Header"). */
299
+ export interface SettingsGroup {
300
+ /** Lowercase kebab-case, unique in the theme. */
301
+ key: string;
302
+ label: LocalizedText;
303
+ fields: readonly FieldDefinition[];
304
+ }
275
305
  /**
276
306
  * One surface a theme declares it can render.
277
307
  *
@@ -322,9 +352,11 @@ export interface ThemeManifest {
322
352
  surfaces: readonly SurfaceSpec[];
323
353
  /** The languages the theme's packs cover (possibly none declared). */
324
354
  locales: readonly LocaleCode[];
355
+ /** The theme's global settings (possibly none). */
356
+ settings: readonly SettingsGroup[];
325
357
  components: Record<string, ComponentSpec>;
326
358
  }
327
- export type ThemeManifestIssueCode = "invalid-manifest" | "unsupported-contract" | "invalid-identity" | "invalid-version" | "invalid-surface" | "invalid-component" | "type-mismatch" | "non-serializable";
359
+ export type ThemeManifestIssueCode = "invalid-manifest" | "unsupported-contract" | "invalid-identity" | "invalid-version" | "invalid-surface" | "invalid-settings" | "invalid-component" | "type-mismatch" | "non-serializable";
328
360
  /** One reported manifest problem; `path` points into the manifest (e.g. `components.hero.fields[0].kind`). */
329
361
  export interface ThemeManifestIssue {
330
362
  path: string;
@@ -389,7 +421,7 @@ export interface ResolvedInstance {
389
421
  slots: Record<string, ComponentInstance[]>;
390
422
  issues: PageDataIssue[];
391
423
  }
392
- export type ThemeDefinitionIssueCode = "invalid-theme" | "invalid-definition" | "invalid-type" | "type-mismatch" | "invalid-field" | "duplicate-field-key" | "unknown-content-key" | "invalid-slot" | "duplicate-slot" | "invalid-capability" | "invalid-load";
424
+ export type ThemeDefinitionIssueCode = "invalid-theme" | "invalid-settings" | "invalid-definition" | "invalid-type" | "type-mismatch" | "invalid-field" | "duplicate-field-key" | "unknown-content-key" | "invalid-slot" | "duplicate-slot" | "invalid-capability" | "invalid-load";
393
425
  export interface ThemeDefinitionIssue {
394
426
  path: string;
395
427
  code: ThemeDefinitionIssueCode;
@@ -409,4 +441,39 @@ export interface ThemeDefinitionIssue {
409
441
  export interface ThemeValidationOptions {
410
442
  contentKeys?: readonly string[];
411
443
  }
444
+ export type ThemeSettingsIssueCode = "invalid-settings" | "unknown-setting" | "invalid-setting" | "missing-required-setting";
445
+ /** One reported problem with a store's theme settings; `path` is `settings.<key>`. */
446
+ export interface ThemeSettingsIssue {
447
+ path: string;
448
+ code: ThemeSettingsIssueCode;
449
+ message: string;
450
+ }
451
+ /** The effective settings: every value checked, the rest from the theme's defaults. */
452
+ export interface ThemeSettingsResult {
453
+ values: Record<string, unknown>;
454
+ issues: ThemeSettingsIssue[];
455
+ }
456
+ type SettingsFieldsOf<T> = T extends {
457
+ settings: readonly (infer G)[];
458
+ } ? G extends {
459
+ fields: readonly (infer F extends FieldDefinition)[];
460
+ } ? F : never : never;
461
+ /**
462
+ * The effective settings of a theme, typed from its declaration: a setting
463
+ * with a `default` is always present, any other may be absent.
464
+ *
465
+ * ```ts
466
+ * const { values } = resolveThemeSettings(theme, stored);
467
+ * const settings = values as InferSettings<typeof theme>;
468
+ * ```
469
+ */
470
+ export type InferSettings<T extends ThemeDefinition> = {
471
+ [F in SettingsFieldsOf<T> as F extends {
472
+ default: unknown;
473
+ } ? F["key"] : never]: FieldValue<F>;
474
+ } & {
475
+ [F in SettingsFieldsOf<T> as F extends {
476
+ default: unknown;
477
+ } ? never : F["key"]]?: FieldValue<F>;
478
+ };
412
479
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wocart/theme",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "The Wocart theme contract: code-owned component definitions, JSON page documents, validation and resolution, and a reference Astro renderer.",
5
5
  "keywords": [
6
6
  "wocart",