@wocart/theme 3.0.0 → 4.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 CHANGED
@@ -5,6 +5,52 @@ 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
+ ## 4.0.0 — 2026-11-01
9
+
10
+ Search and contact are platform pages a theme composes, and the theme
11
+ operations take the theme they check. Requires `@wocart/storefront` 3.0.0.
12
+
13
+ ### Added
14
+
15
+ - `SurfacePageType` and `PAGE_TYPES` take `search` (`/search`) and `contact`
16
+ (`/contact`), the storefront's new page types: a theme declares a surface
17
+ for each, so a merchant composes them like any page.
18
+
19
+ ### Removed
20
+
21
+ - `removeInstance(page, id)` and `setInstanceHidden(page, id, hidden)`, the
22
+ forms without the theme: pass the theme first, so a required block is
23
+ always kept and shown.
24
+
25
+ ### Changed (breaking)
26
+
27
+ - The peer `@wocart/storefront` is `^3.0.0`.
28
+
29
+ ## 3.1.0 — 2026-10-31
30
+
31
+ Landing pages are composed pages, each with its own document, and a page can
32
+ leave out the header or the footer.
33
+
34
+ ### Added
35
+
36
+ - `landingSurface(id)`: a landing page's own document is stored as
37
+ `lp:<id>`. `templateSurface(surface)` names the surface it follows: the
38
+ theme's `landing` surface gives every landing page its rules, its sections
39
+ and its starting document. `resolveSurfaceDocument`, `checkPageData`, the
40
+ document operations, `isInsertableOn` and `previewTarget` read an `lp:`
41
+ document by it; `LANDING_TEMPLATE` is its key.
42
+ - `SurfaceSpec.hideableAreas`: the areas a page of that surface may leave
43
+ out. `PageData.hiddenAreas` lists the ones it does; read it with
44
+ `isAreaShown(theme, page, key)`, change it with `setAreaHidden`, and list
45
+ the choices with `hideableAreas(theme, surface)`. The manifest carries it.
46
+
47
+ ### Changed
48
+
49
+ - A theme's `landing` surface renders the `landing` page type, a landing
50
+ page type is composed only by the `landing` surface, and, like a product
51
+ surface, it must always render an order form. A surface key cannot start
52
+ with `lp:` or `area:`.
53
+
8
54
  ## 3.0.0 — 2026-10-30
9
55
 
10
56
  A store's images can live on any media host: the image service takes the
package/dist/blocks.d.ts CHANGED
@@ -19,12 +19,30 @@ export declare const THEME_LIMITS: {
19
19
  };
20
20
  export declare const COMPONENT_PLACEMENTS: readonly ["section", "block", "both"];
21
21
  export declare const COMPONENT_ROLES: readonly ["order-form"];
22
+ /** The surface every landing page's own document follows: its rules, its shipped document. */
23
+ export declare const LANDING_TEMPLATE = "landing";
22
24
  /** The surface an area's document is stored under: `area:<key>`. */
23
25
  export declare function areaSurface(key: string): string;
24
- /** The shipped document for a surface or an area, when the theme declares one. */
26
+ /**
27
+ * The surface a landing page's own document is stored under: `lp:<id>`, by
28
+ * the page's id, which stays when its slug changes. A landing page is a
29
+ * campaign of its own, so each has its own document; the theme's `landing`
30
+ * surface gives every one of them its rules and its starting document.
31
+ */
32
+ export declare function landingSurface(id: string): string;
33
+ /** The surface whose rules and shipped document a document follows: `landing` for a landing page's own `lp:<id>`, the surface itself otherwise. */
34
+ export declare function templateSurface(surface: string): string;
35
+ /**
36
+ * The shipped document for a surface or an area, when the theme declares one.
37
+ * A landing page's own surface gets the `landing` document, addressed to it.
38
+ */
25
39
  export declare function shippedDocument(theme: ThemeDefinition, surface: string): PageData | undefined;
26
- /** Whether a theme composes a surface: one it declares, or one of its areas. */
40
+ /** Whether a theme composes a surface: one it declares, a landing page's own when it declares `landing`, or one of its areas. */
27
41
  export declare function composesSurface(theme: ThemeDefinition, surface: string): boolean;
42
+ /** The areas a page of this surface may leave out: the surface's `hideableAreas` the theme declares as areas. */
43
+ export declare function hideableAreas(theme: ThemeDefinition, surface: string): string[];
44
+ /** Whether a page shows one of the theme's areas: it does unless its document leaves it out and its surface allows that. */
45
+ export declare function isAreaShown(theme: ThemeDefinition, page: PageData, key: string): boolean;
28
46
  /** The id a fixed block renders with while the document does not store it. */
29
47
  export declare function fixedBlockId(ownerId: string, name: string): string;
30
48
  /** Whether a component may be placed at the top level (`"section"`) or in a slot (`"block"`). */
package/dist/blocks.js CHANGED
@@ -12,19 +12,52 @@ export const THEME_LIMITS = {
12
12
  export const COMPONENT_PLACEMENTS = ["section", "block", "both"];
13
13
  export const COMPONENT_ROLES = ["order-form"];
14
14
  const AREA_PREFIX = "area:";
15
+ const LANDING_PREFIX = "lp:";
16
+ /** The surface every landing page's own document follows: its rules, its shipped document. */
17
+ export const LANDING_TEMPLATE = "landing";
15
18
  /** The surface an area's document is stored under: `area:<key>`. */
16
19
  export function areaSurface(key) {
17
20
  return `${AREA_PREFIX}${key}`;
18
21
  }
19
- /** The shipped document for a surface or an area, when the theme declares one. */
22
+ /**
23
+ * The surface a landing page's own document is stored under: `lp:<id>`, by
24
+ * the page's id, which stays when its slug changes. A landing page is a
25
+ * campaign of its own, so each has its own document; the theme's `landing`
26
+ * surface gives every one of them its rules and its starting document.
27
+ */
28
+ export function landingSurface(id) {
29
+ return `${LANDING_PREFIX}${id}`;
30
+ }
31
+ /** The surface whose rules and shipped document a document follows: `landing` for a landing page's own `lp:<id>`, the surface itself otherwise. */
32
+ export function templateSurface(surface) {
33
+ return surface.startsWith(LANDING_PREFIX) ? LANDING_TEMPLATE : surface;
34
+ }
35
+ /**
36
+ * The shipped document for a surface or an area, when the theme declares one.
37
+ * A landing page's own surface gets the `landing` document, addressed to it.
38
+ */
20
39
  export function shippedDocument(theme, surface) {
21
40
  if (surface.startsWith(AREA_PREFIX))
22
41
  return (theme.areas ?? []).find((area) => areaSurface(area.key) === surface)?.document;
23
- return (theme.surfaces ?? []).find((spec) => spec.key === surface)?.document;
42
+ const template = templateSurface(surface);
43
+ const document = (theme.surfaces ?? []).find((spec) => spec.key === template)?.document;
44
+ return document === undefined || template === surface ? document : { ...document, surface };
24
45
  }
25
- /** Whether a theme composes a surface: one it declares, or one of its areas. */
46
+ /** Whether a theme composes a surface: one it declares, a landing page's own when it declares `landing`, or one of its areas. */
26
47
  export function composesSurface(theme, surface) {
27
- return (theme.surfaces ?? []).some((spec) => spec.key === surface) || (theme.areas ?? []).some((area) => areaSurface(area.key) === surface);
48
+ const template = templateSurface(surface);
49
+ return (theme.surfaces ?? []).some((spec) => spec.key === template) || (theme.areas ?? []).some((area) => areaSurface(area.key) === surface);
50
+ }
51
+ /** The areas a page of this surface may leave out: the surface's `hideableAreas` the theme declares as areas. */
52
+ export function hideableAreas(theme, surface) {
53
+ const template = templateSurface(surface);
54
+ const spec = (theme.surfaces ?? []).find((candidate) => candidate.key === template);
55
+ const declared = new Set((theme.areas ?? []).map((area) => area.key));
56
+ return (spec?.hideableAreas ?? []).filter((key) => declared.has(key));
57
+ }
58
+ /** Whether a page shows one of the theme's areas: it does unless its document leaves it out and its surface allows that. */
59
+ export function isAreaShown(theme, page, key) {
60
+ return !(page.hiddenAreas ?? []).includes(key) || !hideableAreas(theme, page.surface).includes(key);
28
61
  }
29
62
  /** The id a fixed block renders with while the document does not store it. */
30
63
  export function fixedBlockId(ownerId, name) {
@@ -46,9 +79,10 @@ export function surfaceRules(theme, surface) {
46
79
  return { ...(area?.required === undefined ? {} : { required: area.required }), ...(area?.max === undefined ? {} : { max: area.max }) };
47
80
  }
48
81
  const surfaces = theme.surfaces ?? [];
49
- const own = surfaces.find((spec) => spec.key === surface);
50
- const dot = surface.indexOf(".");
51
- const base = dot > 0 ? surfaces.find((spec) => spec.key === surface.slice(0, dot)) : undefined;
82
+ const key = templateSurface(surface);
83
+ const own = surfaces.find((spec) => spec.key === key);
84
+ const dot = key.indexOf(".");
85
+ const base = dot > 0 ? surfaces.find((spec) => spec.key === key.slice(0, dot)) : undefined;
52
86
  const required = own?.required ?? base?.required;
53
87
  const max = own?.max ?? base?.max;
54
88
  return { ...(required === undefined ? {} : { required }), ...(max === undefined ? {} : { max }) };
@@ -1,5 +1,5 @@
1
1
  import type { ComponentDefinition, ComponentInstance, ComponentSpec, ThemeDefinition } from "./types.js";
2
- /** Whether a surface key is `entry` itself or one of its alternates (`product.compact` for `product`). */
2
+ /** Whether a surface key is `entry` itself or one of its alternates (`product.compact` for `product`); a landing page's own `lp:<id>` is covered by `landing`. */
3
3
  export declare function surfaceCovers(entry: string, surface: string): boolean;
4
4
  /**
5
5
  * Whether an editor may insert this component on `surface`: it is not marked
@@ -3,10 +3,12 @@
3
3
  * where a component may go, and how a preset becomes instances. Pure: no
4
4
  * I/O, no randomness — ids come from the caller.
5
5
  */
6
+ import { templateSurface } from "./blocks.js";
6
7
  import { createInstance } from "./instances.js";
7
- /** Whether a surface key is `entry` itself or one of its alternates (`product.compact` for `product`). */
8
+ /** Whether a surface key is `entry` itself or one of its alternates (`product.compact` for `product`); a landing page's own `lp:<id>` is covered by `landing`. */
8
9
  export function surfaceCovers(entry, surface) {
9
- return surface === entry || surface.startsWith(`${entry}.`);
10
+ const key = templateSurface(surface);
11
+ return key === entry || key.startsWith(`${entry}.`);
10
12
  }
11
13
  /**
12
14
  * Whether an editor may insert this component on `surface`: it is not marked
@@ -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 { COMPONENT_PLACEMENTS, COMPONENT_ROLES, THEME_LIMITS, areaSurface, guaranteedRole, innerGuarantees, placementAllows, surfaceRules, } from "./blocks.js";
7
+ import { COMPONENT_PLACEMENTS, COMPONENT_ROLES, THEME_LIMITS, areaSurface, LANDING_TEMPLATE, guaranteedRole, innerGuarantees, placementAllows, surfaceRules, } from "./blocks.js";
8
8
  import { instantiatePreset, surfaceCovers } from "./composition.js";
9
9
  import { conditionProblem } from "./conditions.js";
10
10
  import { COLOR_SCHEME_ID_PATTERN, STYLE_PANELS, STYLE_STEPS, STYLE_WIDTHS, colorSchemesProblem, styleSizeProblem, styleValueProblem, } from "./style.js";
@@ -117,7 +117,8 @@ export function validateThemeDefinition(theme, options = {}) {
117
117
  addIssue(issues, "surfaces", "invalid-theme", "`surfaces` must be an array of surface declarations");
118
118
  }
119
119
  else {
120
- inspectSurfaces(theme.surfaces, issues, locales, new Set(Object.keys(theme.components)));
120
+ const areaKeys = new Set((Array.isArray(theme.areas) ? theme.areas : []).map((area) => (isRecord(area) ? area.key : undefined)).filter((key) => typeof key === "string"));
121
+ inspectSurfaces(theme.surfaces, issues, locales, new Set(Object.keys(theme.components)), areaKeys);
121
122
  }
122
123
  }
123
124
  if (theme.areas !== undefined)
@@ -324,7 +325,7 @@ function inspectAreas(areas, issues, locales, catalogKeys) {
324
325
  inspectBlockBounds(area, path, issues, catalogKeys, THEME_LIMITS.sections, "invalid-theme");
325
326
  });
326
327
  }
327
- function inspectSurfaces(surfaces, issues, locales, catalogKeys) {
328
+ function inspectSurfaces(surfaces, issues, locales, catalogKeys, areaKeys) {
328
329
  const seenKeys = new Set();
329
330
  surfaces.forEach((surface, index) => {
330
331
  const surfacePath = `surfaces[${index}]`;
@@ -349,6 +350,28 @@ function inspectSurfaces(surfaces, issues, locales, catalogKeys) {
349
350
  if (surface.pageType !== undefined && !PAGE_TYPES.includes(surface.pageType)) {
350
351
  addIssue(issues, `${surfacePath}.pageType`, "invalid-theme", `\`pageType\` must be one of ${PAGE_TYPES.join(", ")}`);
351
352
  }
353
+ if (typeof surface.key === "string" && (surface.key.startsWith("lp:") || surface.key.startsWith("area:"))) {
354
+ addIssue(issues, `${surfacePath}.key`, "invalid-theme", `"${surface.key}" uses a prefix the platform keeps for a landing page's or an area's own document`);
355
+ }
356
+ if (surface.key === LANDING_TEMPLATE && surface.pageType !== "landing") {
357
+ addIssue(issues, `${surfacePath}.pageType`, "invalid-theme", `the "${LANDING_TEMPLATE}" surface renders landing pages: its \`pageType\` is "landing"`);
358
+ }
359
+ if (surface.pageType === "landing" && typeof surface.key === "string" && surface.key !== LANDING_TEMPLATE) {
360
+ addIssue(issues, `${surfacePath}.key`, "invalid-theme", `landing pages are composed by the "${LANDING_TEMPLATE}" surface; each page's own document follows it`);
361
+ }
362
+ if (surface.hideableAreas !== undefined) {
363
+ const listed = surface.hideableAreas;
364
+ if (!Array.isArray(listed) || new Set(listed).size !== listed.length) {
365
+ addIssue(issues, `${surfacePath}.hideableAreas`, "invalid-theme", "`hideableAreas` must be a list of distinct area keys");
366
+ }
367
+ else {
368
+ listed.forEach((key, position) => {
369
+ if (typeof key !== "string" || !areaKeys.has(key)) {
370
+ addIssue(issues, `${surfacePath}.hideableAreas[${position}]`, "invalid-theme", `${JSON.stringify(key)} is not an area this theme declares`);
371
+ }
372
+ });
373
+ }
374
+ }
352
375
  if (surface.document !== undefined && !isRecord(surface.document)) {
353
376
  addIssue(issues, `${surfacePath}.document`, "invalid-document", "`document` must be a page document");
354
377
  }
@@ -603,12 +626,12 @@ function inspectBlockRules(theme, issues) {
603
626
  if (issues.length > 0)
604
627
  return;
605
628
  (theme.surfaces ?? []).forEach((surface, index) => {
606
- if (surface.pageType !== "product")
629
+ if (surface.pageType !== "product" && surface.pageType !== "landing")
607
630
  return;
608
631
  const rules = surfaceRules(theme, surface.key);
609
632
  const guaranteed = (rules.required ?? []).reduce((count, entry) => count + (entry.min ?? 1) * guaranteedRole(theme, entry.type, "order-form"), 0);
610
633
  if (guaranteed === 0) {
611
- addIssue(issues, `surfaces[${index}]`, "order-form-not-guaranteed", `product surface "${surface.key}" must always render an order form: require a section that places a component with role "order-form" through fixed or required blocks`);
634
+ addIssue(issues, `surfaces[${index}]`, "order-form-not-guaranteed", `${surface.pageType} surface "${surface.key}" must always render an order form: require a section that places a component with role "order-form" through fixed or required blocks`);
612
635
  }
613
636
  });
614
637
  }
package/dist/index.d.ts CHANGED
@@ -17,12 +17,12 @@ export { resolveInstance, createInstance, checkStoredValue as checkFieldValue }
17
17
  export type { CheckedValue as FieldValueCheck } from "./instances.js";
18
18
  export { RICH_TEXT_FORMATS, richTextToHtml, richTextToPlainText } from "./rich-text.js";
19
19
  export { instantiatePreset, isInsertableOn } from "./composition.js";
20
- export { COMPONENT_PLACEMENTS, COMPONENT_ROLES, THEME_LIMITS, areaSurface, fixedBlockId } from "./blocks.js";
20
+ export { COMPONENT_PLACEMENTS, COMPONENT_ROLES, LANDING_TEMPLATE, THEME_LIMITS, areaSurface, fixedBlockId, hideableAreas, isAreaShown, landingSurface, templateSurface, } from "./blocks.js";
21
21
  export { completePage, resolveSurfaceDocument } from "./surface-document.js";
22
22
  export { CONDITION_DEPTH, isFieldVisible } from "./conditions.js";
23
23
  export { COLOR_ROLES, COLOR_SCHEMES_MAX, STYLE_BREAKPOINTS, STYLE_PANELS, STYLE_STEPS, STYLE_WIDTHS, THEME_STYLES_PATH, SCHEME_MARKER_CLASS, colorSchemeCss, contrastRatio, schemeClass, styleClasses, themeStyleSheet, } from "./style.js";
24
24
  export type { SurfaceDocument, SurfaceDocumentReason } from "./surface-document.js";
25
- export { checkPageData, duplicateInstance, findInstance, insertInstance, moveInstance, newInstanceId, removeInstance, setInstanceHidden, setInstanceProps, } from "./operations.js";
25
+ export { checkPageData, duplicateInstance, findInstance, insertInstance, moveInstance, newInstanceId, removeInstance, setAreaHidden, setInstanceHidden, setInstanceProps, } from "./operations.js";
26
26
  export type { DocumentEdit, DocumentIssue, DocumentIssueCode, InstanceLocation, InstancePlacement, } from "./operations.js";
27
27
  export { THEME_SDK_VERSION } from "./version.js";
28
28
  export type { RichTextHtmlOptions } from "./rich-text.js";
package/dist/index.js CHANGED
@@ -16,11 +16,11 @@ export { parsePageData, validatePageData } from "./page-data.js";
16
16
  export { resolveInstance, createInstance, checkStoredValue as checkFieldValue } from "./instances.js";
17
17
  export { RICH_TEXT_FORMATS, richTextToHtml, richTextToPlainText } from "./rich-text.js";
18
18
  export { instantiatePreset, isInsertableOn } from "./composition.js";
19
- export { COMPONENT_PLACEMENTS, COMPONENT_ROLES, THEME_LIMITS, areaSurface, fixedBlockId } from "./blocks.js";
19
+ export { COMPONENT_PLACEMENTS, COMPONENT_ROLES, LANDING_TEMPLATE, THEME_LIMITS, areaSurface, fixedBlockId, hideableAreas, isAreaShown, landingSurface, templateSurface, } from "./blocks.js";
20
20
  export { completePage, resolveSurfaceDocument } from "./surface-document.js";
21
21
  export { CONDITION_DEPTH, isFieldVisible } from "./conditions.js";
22
22
  export { COLOR_ROLES, COLOR_SCHEMES_MAX, STYLE_BREAKPOINTS, STYLE_PANELS, STYLE_STEPS, STYLE_WIDTHS, THEME_STYLES_PATH, SCHEME_MARKER_CLASS, colorSchemeCss, contrastRatio, schemeClass, styleClasses, themeStyleSheet, } from "./style.js";
23
- export { checkPageData, duplicateInstance, findInstance, insertInstance, moveInstance, newInstanceId, removeInstance, setInstanceHidden, setInstanceProps, } from "./operations.js";
23
+ export { checkPageData, duplicateInstance, findInstance, insertInstance, moveInstance, newInstanceId, removeInstance, setAreaHidden, setInstanceHidden, setInstanceProps, } from "./operations.js";
24
24
  export { THEME_SDK_VERSION } from "./version.js";
25
25
  export { resolveThemeSettings, checkThemeSettings } from "./settings.js";
26
26
  export { localize } from "./locales.js";
@@ -41,6 +41,7 @@ export declare const ThemeManifestSchema: z.ZodObject<{
41
41
  max: z.ZodExactOptional<z.ZodNumber>;
42
42
  }, z.core.$strip>>>;
43
43
  max: z.ZodExactOptional<z.ZodNumber>;
44
+ hideableAreas: z.ZodExactOptional<z.ZodArray<z.ZodString>>;
44
45
  }, z.core.$strip>>>;
45
46
  areas: z.ZodDefault<z.ZodArray<z.ZodObject<{
46
47
  key: z.ZodString;
package/dist/manifest.js CHANGED
@@ -37,6 +37,7 @@ export function themeManifest(theme) {
37
37
  ...(surface.document === undefined ? {} : { document: copyData(surface.document) }),
38
38
  ...(surface.required === undefined ? {} : { required: copyData(surface.required) }),
39
39
  ...(surface.max === undefined ? {} : { max: surface.max }),
40
+ ...(surface.hideableAreas === undefined ? {} : { hideableAreas: [...surface.hideableAreas] }),
40
41
  })),
41
42
  areas: (theme.areas ?? []).map((area) => copyData(area)),
42
43
  locales: [...(theme.locales ?? [])],
@@ -232,6 +233,7 @@ export const ThemeManifestSchema = z.object({
232
233
  document: z.exactOptional(z.custom((value) => isRecord(value))),
233
234
  required: z.exactOptional(z.array(RequiredBlockSchema)),
234
235
  max: z.exactOptional(z.number()),
236
+ hideableAreas: z.exactOptional(z.array(z.string())),
235
237
  }))
236
238
  .default([]),
237
239
  areas: z
@@ -51,14 +51,10 @@ export declare function findInstance(page: PageData, id: string): InstanceLocati
51
51
  */
52
52
  export declare function insertInstance(theme: ThemeDefinition, page: PageData, instance: ComponentInstance, placement?: InstancePlacement): DocumentEdit;
53
53
  /**
54
- * Remove an instance and everything inside it. Refused for a fixed block and,
55
- * given the theme, for the last required instance of its type in its list.
56
- * The form without a theme is kept for writers that predate blocks: it still
57
- * refuses a fixed block, but cannot know a slot's required types.
54
+ * Remove an instance and everything inside it. Refused for a fixed block and
55
+ * for the last required instance of its type in its list.
58
56
  */
59
57
  export declare function removeInstance(theme: ThemeDefinition, page: PageData, id: string): DocumentEdit;
60
- /** @deprecated Pass the theme first, so required blocks are kept. */
61
- export declare function removeInstance(page: PageData, id: string): DocumentEdit;
62
58
  /**
63
59
  * Move an instance (with everything inside it) to a placement. `index` counts
64
60
  * positions in the destination after the instance has left its old place.
@@ -81,12 +77,14 @@ export declare function duplicateInstance(theme: ThemeDefinition, page: PageData
81
77
  export declare function setInstanceProps(theme: ThemeDefinition, page: PageData, id: string, changes: Readonly<Record<string, JsonValue | undefined>>): DocumentEdit;
82
78
  /**
83
79
  * Switch an instance off (kept, not rendered) or back on. A fixed block is
84
- * never hidden and, given the theme, neither is the last required instance of
85
- * its type. The form without a theme refuses only a fixed block.
80
+ * never hidden, and neither is the last required instance of its type.
86
81
  */
87
82
  export declare function setInstanceHidden(theme: ThemeDefinition, page: PageData, id: string, hidden: boolean): DocumentEdit;
88
- /** @deprecated Pass the theme first, so required blocks stay shown. */
89
- export declare function setInstanceHidden(page: PageData, id: string, hidden: boolean): DocumentEdit;
83
+ /**
84
+ * Show or leave out one of the theme's areas on this page — the header, the
85
+ * footer — when its surface lets a page leave it out (`hideableAreas`).
86
+ */
87
+ export declare function setAreaHidden(theme: ThemeDefinition, page: PageData, key: string, hidden: boolean): DocumentEdit;
90
88
  /**
91
89
  * Everything a writer must fix before saving a document for this theme —
92
90
  * the strict counterpart of `validatePageData`: an envelope for one of the
@@ -14,7 +14,7 @@
14
14
  * nesting stay within their caps. Operations on a document assume it was
15
15
  * completed (`completePage`) when it was opened.
16
16
  */
17
- import { THEME_LIMITS, composesSurface, placementAllows, slotCap, surfaceRules } from "./blocks.js";
17
+ import { THEME_LIMITS, composesSurface, hideableAreas, placementAllows, slotCap, surfaceRules } from "./blocks.js";
18
18
  import { surfaceCovers } from "./composition.js";
19
19
  import { checkStoredValue, hasFallback, ownValue } from "./instances.js";
20
20
  import { ComponentInstanceSchema } from "./schemas.js";
@@ -29,7 +29,6 @@ export function newInstanceId() {
29
29
  return `cmp_${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
30
30
  }
31
31
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
32
- const isTheme = (value) => isRecord(value) && isRecord(value.components);
33
32
  const definitionOf = (theme, type) => Object.hasOwn(theme.components, type) ? theme.components[type] : undefined;
34
33
  const refuse = (code, message, path = "") => ({
35
34
  ok: false,
@@ -347,8 +346,11 @@ export function insertInstance(theme, page, instance, placement = {}) {
347
346
  list.splice(placement.index ?? list.length, 0, tree);
348
347
  return { ok: true, page: next };
349
348
  }
350
- export function removeInstance(...args) {
351
- const [theme, page, id] = isTheme(args[0]) ? args : [null, args[0], args[1]];
349
+ /**
350
+ * Remove an instance and everything inside it. Refused for a fixed block and
351
+ * for the last required instance of its type in its list.
352
+ */
353
+ export function removeInstance(theme, page, id) {
352
354
  const next = structuredClone(page);
353
355
  const location = findInstance(next, id);
354
356
  if (location === null)
@@ -360,11 +362,9 @@ export function removeInstance(...args) {
360
362
  : targetList(next, { parent: location.parent, slot: location.slot ?? "default" });
361
363
  if (list === null)
362
364
  return refuse("unknown-instance", `no instance "${location.parent}" in the document`);
363
- if (theme !== null) {
364
- const short = leavesRequiredShort(listRules(theme, next, location.parent, location.slot), list, location.instance);
365
- if (short !== null)
366
- return refuse("required-block", `"${location.instance.type}" is required here (at least ${short.min ?? 1})`);
367
- }
365
+ const short = leavesRequiredShort(listRules(theme, next, location.parent, location.slot), list, location.instance);
366
+ if (short !== null)
367
+ return refuse("required-block", `"${location.instance.type}" is required here (at least ${short.min ?? 1})`);
368
368
  list.splice(list.findIndex((candidate) => candidate.id === id), 1);
369
369
  return { ok: true, page: next };
370
370
  }
@@ -496,17 +496,18 @@ export function setInstanceProps(theme, page, id, changes) {
496
496
  delete instance.props;
497
497
  return { ok: true, page: next };
498
498
  }
499
- export function setInstanceHidden(...args) {
500
- const [theme, page, id, hidden] = isTheme(args[0])
501
- ? args
502
- : [null, args[0], args[1], args[2]];
499
+ /**
500
+ * Switch an instance off (kept, not rendered) or back on. A fixed block is
501
+ * never hidden, and neither is the last required instance of its type.
502
+ */
503
+ export function setInstanceHidden(theme, page, id, hidden) {
503
504
  const next = structuredClone(page);
504
505
  const location = findInstance(next, id);
505
506
  if (location === null)
506
507
  return refuse("unknown-instance", `no instance "${id}" in the document`);
507
508
  if (hidden && location.fixed !== undefined)
508
509
  return refuse("fixed-block", `"${id}" is placed by its component's code and cannot be hidden`);
509
- if (hidden && theme !== null) {
510
+ if (hidden) {
510
511
  const list = location.parent === null ? next.sections : (slotLists(findInstance(next, location.parent).instance).find(([name]) => name === (location.slot ?? "default"))?.[1] ?? []);
511
512
  const short = leavesRequiredShort(listRules(theme, next, location.parent, location.slot), list, location.instance);
512
513
  if (short !== null)
@@ -518,6 +519,24 @@ export function setInstanceHidden(...args) {
518
519
  delete location.instance.hidden;
519
520
  return { ok: true, page: next };
520
521
  }
522
+ /**
523
+ * Show or leave out one of the theme's areas on this page — the header, the
524
+ * footer — when its surface lets a page leave it out (`hideableAreas`).
525
+ */
526
+ export function setAreaHidden(theme, page, key, hidden) {
527
+ if (!hideableAreas(theme, page.surface).includes(key)) {
528
+ return refuse("not-allowed", `a page of "${page.surface}" cannot leave out the area "${key}"`);
529
+ }
530
+ const next = structuredClone(page);
531
+ const rest = (next.hiddenAreas ?? []).filter((candidate) => candidate !== key);
532
+ const order = (theme.areas ?? []).map((area) => area.key);
533
+ const hiddenAreas = hidden ? [...rest, key].sort((a, b) => order.indexOf(a) - order.indexOf(b)) : rest;
534
+ if (hiddenAreas.length > 0)
535
+ next.hiddenAreas = hiddenAreas;
536
+ else
537
+ delete next.hiddenAreas;
538
+ return { ok: true, page: next };
539
+ }
521
540
  /**
522
541
  * Everything a writer must fix before saving a document for this theme —
523
542
  * the strict counterpart of `validatePageData`: an envelope for one of the
@@ -538,6 +557,13 @@ export function checkPageData(theme, input) {
538
557
  if (input.theme !== undefined && !(isRecord(input.theme) && input.theme.key === theme.key && typeof input.theme.version === "string" && input.theme.version.length > 0)) {
539
558
  issues.push({ path: "theme", code: "invalid-document", message: `\`theme\` must name this theme: { key: "${theme.key}", version }` });
540
559
  }
560
+ if (input.hiddenAreas !== undefined) {
561
+ const hideable = hideableAreas(theme, surface);
562
+ const hidden = input.hiddenAreas;
563
+ if (!Array.isArray(hidden) || new Set(hidden).size !== hidden.length || !hidden.every((key) => typeof key === "string" && hideable.includes(key))) {
564
+ issues.push({ path: "hiddenAreas", code: "invalid-document", message: `\`hiddenAreas\` lists distinct areas this page may leave out: ${hideable.length > 0 ? hideable.join(", ") : "none"}` });
565
+ }
566
+ }
541
567
  const rules = surfaceRules(theme, surface);
542
568
  const cap = slotCap(rules, THEME_LIMITS.sections);
543
569
  if (input.sections.length > cap) {
package/dist/page-data.js CHANGED
@@ -7,10 +7,12 @@
7
7
  * failed; a structurally invalid section is dropped with an issue; the
8
8
  * document itself is not rewritten by `validatePageData`.
9
9
  */
10
- import { THEME_LIMITS, completeRequired, slotCap, surfaceRules } from "./blocks.js";
10
+ import { THEME_LIMITS, completeRequired, hideableAreas, slotCap, surfaceRules } from "./blocks.js";
11
11
  import { resolveInstance } from "./instances.js";
12
- import { ComponentInstanceSchema, SURFACE_KEY_PATTERN, THEME_KEY_PATTERN } from "./schemas.js";
12
+ import { ComponentInstanceSchema, SURFACE_KEY_PATTERN, THEME_KEY_PATTERN, TYPE_KEY_PATTERN } from "./schemas.js";
13
13
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
14
+ /** At most this many areas a document leaves out: a theme declares a handful. */
15
+ const MAX_HIDDEN_AREAS = 8;
14
16
  const envelopeFailure = (message) => ({
15
17
  page: null,
16
18
  issues: [{ path: "", code: "envelope-invalid", message }],
@@ -53,12 +55,25 @@ export function parsePageData(input) {
53
55
  // name a prototype.
54
56
  const page = { version: 1, surface, sections };
55
57
  for (const key of Object.keys(input)) {
56
- if (key === "version" || key === "surface" || key === "sections" || key === "theme")
58
+ if (key === "version" || key === "surface" || key === "sections" || key === "theme" || key === "hiddenAreas")
57
59
  continue;
58
60
  if (key === "__proto__")
59
61
  continue;
60
62
  page[key] = input[key];
61
63
  }
64
+ if (input.hiddenAreas !== undefined) {
65
+ const hidden = input.hiddenAreas;
66
+ if (Array.isArray(hidden) && hidden.length <= MAX_HIDDEN_AREAS && hidden.every((key) => typeof key === "string" && TYPE_KEY_PATTERN.test(key)) && new Set(hidden).size === hidden.length) {
67
+ page.hiddenAreas = [...hidden];
68
+ }
69
+ else {
70
+ issues.push({
71
+ path: "hiddenAreas",
72
+ code: "invalid-prop",
73
+ message: `\`hiddenAreas\` must list at most ${MAX_HIDDEN_AREAS} distinct area keys; ignored, every area shows`,
74
+ });
75
+ }
76
+ }
62
77
  if (input.theme !== undefined) {
63
78
  const theme = input.theme;
64
79
  if (isRecord(theme) &&
@@ -124,6 +139,12 @@ export function validatePageData(theme, input, scope = {}) {
124
139
  walk(block, `${path}.fixed.${name}`);
125
140
  }
126
141
  };
142
+ const hideable = hideableAreas(theme, parsed.page.surface);
143
+ (parsed.page.hiddenAreas ?? []).forEach((key, index) => {
144
+ if (!hideable.includes(key)) {
145
+ issues.push({ path: `hiddenAreas[${index}]`, code: "invalid-prop", message: `"${key}" is not an area this page may leave out; it shows` });
146
+ }
147
+ });
127
148
  const spec = surfaceRules(theme, parsed.page.surface);
128
149
  const cap = slotCap(spec, THEME_LIMITS.sections);
129
150
  if (parsed.page.sections.length > cap) {
@@ -17,7 +17,7 @@ export declare const TYPE_KEY_PATTERN: RegExp;
17
17
  */
18
18
  export declare const SURFACE_KEY_PATTERN: RegExp;
19
19
  /** The platform page types a surface can render (`SurfacePageType`), mirrored from the storefront's page types. */
20
- export declare const PAGE_TYPES: readonly ["home", "catalog", "category", "collection", "product", "page", "landing", "checkout", "thankYou", "trackOrder"];
20
+ export declare const PAGE_TYPES: readonly ["home", "catalog", "category", "collection", "product", "page", "landing", "checkout", "thankYou", "trackOrder", "search", "contact"];
21
21
  /** A `color` field's value: a hex color, `#rgb` or `#rrggbb`. */
22
22
  export declare const COLOR_PATTERN: RegExp;
23
23
  /**
package/dist/patterns.js CHANGED
@@ -28,6 +28,8 @@ export const PAGE_TYPES = [
28
28
  "checkout",
29
29
  "thankYou",
30
30
  "trackOrder",
31
+ "search",
32
+ "contact",
31
33
  ];
32
34
  /** A `color` field's value: a hex color, `#rgb` or `#rrggbb`. */
33
35
  export const COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
@@ -29,6 +29,9 @@ export interface PreviewTarget {
29
29
  * when the theme composes that page, else on the home page.
30
30
  * - The resource is chosen by the surface's `pageType`, never its key, so an
31
31
  * alternate (`product.compact`) previews like its base.
32
+ * - A landing page's own document (`lp:<id>`) previews with the theme's
33
+ * `landing` surface on the page its `path` names (`/lp/<slug>`), and the
34
+ * target keeps its own surface, so the sections render that page's document.
32
35
  *
33
36
  * ```astro
34
37
  * ---
@@ -4,7 +4,7 @@
4
4
  * document it renders.
5
5
  */
6
6
  import { DEFAULT_COLLECTION_HANDLES, matchStorefrontPath } from "@wocart/storefront";
7
- import { areaSurface } from "./blocks.js";
7
+ import { areaSurface, templateSurface } from "./blocks.js";
8
8
  /** The surface a page type is shown with: the one keyed by the type itself, else the first declaring it. */
9
9
  function surfaceFor(surfaces, pageType) {
10
10
  return surfaces.find((surface) => surface.key === pageType) ?? surfaces.find((surface) => surface.pageType === pageType);
@@ -45,6 +45,9 @@ async function resourceParams(pageType, named, client) {
45
45
  * when the theme composes that page, else on the home page.
46
46
  * - The resource is chosen by the surface's `pageType`, never its key, so an
47
47
  * alternate (`product.compact`) previews like its base.
48
+ * - A landing page's own document (`lp:<id>`) previews with the theme's
49
+ * `landing` surface on the page its `path` names (`/lp/<slug>`), and the
50
+ * target keeps its own surface, so the sections render that page's document.
48
51
  *
49
52
  * ```astro
50
53
  * ---
@@ -58,14 +61,16 @@ export async function previewTarget(theme, draft, client) {
58
61
  const surfaces = theme.surfaces ?? [];
59
62
  const match = draft.path ? matchStorefrontPath(draft.path) : null;
60
63
  const area = (theme.areas ?? []).some((candidate) => areaSurface(candidate.key) === draft.surface) ? draft.surface : null;
61
- const shown = area === null ? surfaces.find((surface) => surface.key === draft.surface) : (match ? surfaceFor(surfaces, match.type) : undefined) ?? surfaceFor(surfaces, "home");
64
+ const template = templateSurface(draft.surface);
65
+ const shown = area === null ? surfaces.find((surface) => surface.key === template) : (match ? surfaceFor(surfaces, match.type) : undefined) ?? surfaceFor(surfaces, "home");
62
66
  if (shown === undefined)
63
67
  return null;
68
+ const surface = area === null ? draft.surface : shown.key;
64
69
  const pageType = shown.pageType;
65
70
  const named = match !== null && match.type === pageType ? { ...match.params } : {};
66
71
  const [params, document] = await Promise.all([
67
72
  resourceParams(pageType, named, client),
68
- area === null && draft.document !== undefined ? draft.document : client.pageData.get(shown.key),
73
+ area === null && draft.document !== undefined ? draft.document : client.pageData.get(surface),
69
74
  ]);
70
- return { surface: shown.key, pageType, area, params, document };
75
+ return { surface, pageType, area, params, document };
71
76
  }
package/dist/types.d.ts CHANGED
@@ -728,6 +728,13 @@ export interface SurfaceSpec {
728
728
  required?: readonly RequiredBlock[];
729
729
  /** At most this many top-level sections (1–50). Absent: `THEME_LIMITS.sections`. */
730
730
  max?: number;
731
+ /**
732
+ * The theme's areas a page of this surface may leave out (`["header",
733
+ * "footer"]`): its document lists the ones it hides in `hiddenAreas`. A
734
+ * landing page that sends an ad's visitor straight to its order form is
735
+ * the case. Absent: every page shows every area.
736
+ */
737
+ hideableAreas?: readonly string[];
731
738
  }
732
739
  /**
733
740
  * A part every page shares — a header, a footer — composed of sections like a
@@ -749,7 +756,7 @@ export interface AreaSpec {
749
756
  * The storefront's page types — what a URL means, with the paths the platform
750
757
  * owns. The same names as `StorefrontPageType` in `@wocart/storefront`.
751
758
  */
752
- export type SurfacePageType = "home" | "catalog" | "category" | "collection" | "product" | "page" | "landing" | "checkout" | "thankYou" | "trackOrder";
759
+ export type SurfacePageType = "home" | "catalog" | "category" | "collection" | "product" | "page" | "landing" | "checkout" | "thankYou" | "trackOrder" | "search" | "contact";
753
760
  /**
754
761
  * One component as data: a `ComponentDefinition` minus its runtime-only
755
762
  * implementation. `component` (the live component reference) and `load` (the
@@ -868,6 +875,11 @@ export interface PageData {
868
875
  version: 1;
869
876
  surface: string;
870
877
  sections: ComponentInstance[];
878
+ /**
879
+ * The keys of the theme's areas this page leaves out. Only the areas its
880
+ * surface lists in `hideableAreas` are left out; read it with `isAreaShown`.
881
+ */
882
+ hiddenAreas?: string[];
871
883
  /**
872
884
  * The theme and theme version that wrote the document. A document that
873
885
  * names another theme is reported (`theme-mismatch`): its sections were
package/dist/version.d.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * reads. Always equal to `package.json`'s version; the package check refuses
5
5
  * a release where they differ.
6
6
  */
7
- export declare const THEME_SDK_VERSION = "3.0.0";
7
+ export declare const THEME_SDK_VERSION = "4.0.0";
package/dist/version.js CHANGED
@@ -4,4 +4,4 @@
4
4
  * reads. Always equal to `package.json`'s version; the package check refuses
5
5
  * a release where they differ.
6
6
  */
7
- export const THEME_SDK_VERSION = "3.0.0";
7
+ export const THEME_SDK_VERSION = "4.0.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wocart/theme",
3
- "version": "3.0.0",
3
+ "version": "4.0.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",
@@ -82,7 +82,7 @@
82
82
  "zod": "^4.4.3"
83
83
  },
84
84
  "peerDependencies": {
85
- "@wocart/storefront": "^2.0.0",
85
+ "@wocart/storefront": "^3.0.0",
86
86
  "astro": "^7.0.0"
87
87
  },
88
88
  "peerDependenciesMeta": {
@@ -92,7 +92,7 @@
92
92
  },
93
93
  "devDependencies": {
94
94
  "@types/node": "^26.2.0",
95
- "@wocart/storefront": "^2.0.0",
95
+ "@wocart/storefront": "^3.0.0",
96
96
  "astro": "^7.3.2",
97
97
  "fast-check": "^4.6.0",
98
98
  "happy-dom": "^20.11.6",