@wocart/theme 3.0.0 → 3.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,31 @@ 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
+ ## 3.1.0 — 2026-10-31
9
+
10
+ Landing pages are composed pages, each with its own document, and a page can
11
+ leave out the header or the footer.
12
+
13
+ ### Added
14
+
15
+ - `landingSurface(id)`: a landing page's own document is stored as
16
+ `lp:<id>`. `templateSurface(surface)` names the surface it follows: the
17
+ theme's `landing` surface gives every landing page its rules, its sections
18
+ and its starting document. `resolveSurfaceDocument`, `checkPageData`, the
19
+ document operations, `isInsertableOn` and `previewTarget` read an `lp:`
20
+ document by it; `LANDING_TEMPLATE` is its key.
21
+ - `SurfaceSpec.hideableAreas`: the areas a page of that surface may leave
22
+ out. `PageData.hiddenAreas` lists the ones it does; read it with
23
+ `isAreaShown(theme, page, key)`, change it with `setAreaHidden`, and list
24
+ the choices with `hideableAreas(theme, surface)`. The manifest carries it.
25
+
26
+ ### Changed
27
+
28
+ - A theme's `landing` surface renders the `landing` page type, a landing
29
+ page type is composed only by the `landing` surface, and, like a product
30
+ surface, it must always render an order form. A surface key cannot start
31
+ with `lp:` or `area:`.
32
+
8
33
  ## 3.0.0 — 2026-10-30
9
34
 
10
35
  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
@@ -87,6 +87,11 @@ export declare function setInstanceProps(theme: ThemeDefinition, page: PageData,
87
87
  export declare function setInstanceHidden(theme: ThemeDefinition, page: PageData, id: string, hidden: boolean): DocumentEdit;
88
88
  /** @deprecated Pass the theme first, so required blocks stay shown. */
89
89
  export declare function setInstanceHidden(page: PageData, id: string, hidden: boolean): DocumentEdit;
90
+ /**
91
+ * Show or leave out one of the theme's areas on this page — the header, the
92
+ * footer — when its surface lets a page leave it out (`hideableAreas`).
93
+ */
94
+ export declare function setAreaHidden(theme: ThemeDefinition, page: PageData, key: string, hidden: boolean): DocumentEdit;
90
95
  /**
91
96
  * Everything a writer must fix before saving a document for this theme —
92
97
  * 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";
@@ -518,6 +518,24 @@ export function setInstanceHidden(...args) {
518
518
  delete location.instance.hidden;
519
519
  return { ok: true, page: next };
520
520
  }
521
+ /**
522
+ * Show or leave out one of the theme's areas on this page — the header, the
523
+ * footer — when its surface lets a page leave it out (`hideableAreas`).
524
+ */
525
+ export function setAreaHidden(theme, page, key, hidden) {
526
+ if (!hideableAreas(theme, page.surface).includes(key)) {
527
+ return refuse("not-allowed", `a page of "${page.surface}" cannot leave out the area "${key}"`);
528
+ }
529
+ const next = structuredClone(page);
530
+ const rest = (next.hiddenAreas ?? []).filter((candidate) => candidate !== key);
531
+ const order = (theme.areas ?? []).map((area) => area.key);
532
+ const hiddenAreas = hidden ? [...rest, key].sort((a, b) => order.indexOf(a) - order.indexOf(b)) : rest;
533
+ if (hiddenAreas.length > 0)
534
+ next.hiddenAreas = hiddenAreas;
535
+ else
536
+ delete next.hiddenAreas;
537
+ return { ok: true, page: next };
538
+ }
521
539
  /**
522
540
  * Everything a writer must fix before saving a document for this theme —
523
541
  * the strict counterpart of `validatePageData`: an envelope for one of the
@@ -538,6 +556,13 @@ export function checkPageData(theme, input) {
538
556
  if (input.theme !== undefined && !(isRecord(input.theme) && input.theme.key === theme.key && typeof input.theme.version === "string" && input.theme.version.length > 0)) {
539
557
  issues.push({ path: "theme", code: "invalid-document", message: `\`theme\` must name this theme: { key: "${theme.key}", version }` });
540
558
  }
559
+ if (input.hiddenAreas !== undefined) {
560
+ const hideable = hideableAreas(theme, surface);
561
+ const hidden = input.hiddenAreas;
562
+ if (!Array.isArray(hidden) || new Set(hidden).size !== hidden.length || !hidden.every((key) => typeof key === "string" && hideable.includes(key))) {
563
+ issues.push({ path: "hiddenAreas", code: "invalid-document", message: `\`hiddenAreas\` lists distinct areas this page may leave out: ${hideable.length > 0 ? hideable.join(", ") : "none"}` });
564
+ }
565
+ }
541
566
  const rules = surfaceRules(theme, surface);
542
567
  const cap = slotCap(rules, THEME_LIMITS.sections);
543
568
  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) {
@@ -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
@@ -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 = "3.1.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 = "3.1.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wocart/theme",
3
- "version": "3.0.0",
3
+ "version": "3.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",