@wocart/theme 2.1.0 → 2.3.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,44 @@ 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
+ ## 2.3.0 — 2026-10-24
9
+
10
+ ### Added
11
+
12
+ - `SurfacePageType` and the manifest's page types take `trackOrder`, the
13
+ storefront's new page type (`@wocart/storefront` 1.21.0, now the peer).
14
+
15
+ ## 2.2.0 — 2026-10-24
16
+
17
+ What every theme used to write itself is now the package's: binding browser
18
+ behaviour through page and section swaps, choosing what an editor's preview
19
+ shows, and a marker for color schemes. Browser code takes only what builds no
20
+ validator.
21
+
22
+ ### Added
23
+
24
+ - `@wocart/theme/lifecycle`: `bindEach(selector, bind)` binds each element
25
+ once — when the page is ready, after Astro swaps a page, after the editor
26
+ swaps a section — and runs a bind's teardown when its element leaves;
27
+ `onEachPage(boot)`; `PREVIEW_SECTION_LOAD_EVENT`, without the bridge.
28
+ - `@wocart/theme/messages`: the message functions for browser code.
29
+ `pluralMessageKeys` is now exported, here and from the root.
30
+ - `previewTarget(theme, draft, client)` in `@wocart/theme/astro`: the surface,
31
+ route parameters and document an editor's preview renders, chosen by page
32
+ type, or null for a surface the theme does not declare.
33
+ - `SCHEME_MARKER_CLASS`: `schemeClass(id)` gives `wc-scheme wc-scheme-<id>`,
34
+ so a theme derives its tokens on `:root, .wc-scheme`.
35
+ - `defineCapabilities(capabilities)` for capabilities several components share,
36
+ and `componentsOf(theme)` to iterate a theme's components as definitions.
37
+
38
+ ### Changed
39
+
40
+ - The rich-text types are plain JSON: a preset or a default holds a document
41
+ without a cast. `JsonValue` accepts read-only lists.
42
+ - The style and rich-text helpers no longer bring Zod into a browser bundle.
43
+ - The `@wocart/storefront` peer is `^1.20.0`: `previewTarget` uses its route
44
+ matcher and default collections.
45
+
8
46
  ## 2.1.0 — 2026-10-21
9
47
 
10
48
  The release check now sees where a section may be placed.
package/README.md CHANGED
@@ -39,12 +39,19 @@ Node 22.12 or newer.
39
39
  | Import | What it holds |
40
40
  | --- | --- |
41
41
  | `@wocart/theme` | definitions, validation, page documents, resolution, document operations, rich text output, the manifest and its diff, languages and messages — framework-neutral |
42
- | `@wocart/theme/astro` | `createReadCache`, `themeManifestEndpoint` (the manifest as a build artifact), `hasBlocks`, `isPreview`, `THEME_LIMITS`, `themeStylesEndpoint`, the preview markers and the `BlocksHandle` and `RendererIssue` types |
42
+ | `@wocart/theme/astro` | `createReadCache`, `themeManifestEndpoint` (the manifest as a build artifact), `hasBlocks`, `isPreview`, `THEME_LIMITS`, `themeStylesEndpoint`, `previewTarget`, the preview markers and the `BlocksHandle` and `RendererIssue` types |
43
43
  | `@wocart/theme/astro/InstanceView.astro` | the reference renderer component |
44
44
  | `@wocart/theme/astro/Blocks.astro`, `@wocart/theme/astro/Block.astro` | a slot's blocks and a fixed block, placed by a component (see [Blocks](#blocks)) |
45
45
  | `@wocart/theme/astro/ThemeStyles.astro` | the stylesheet link and the store's color schemes, for a layout's `<head>` (see [Style](#style)) |
46
46
  | `@wocart/theme/image-service` | `mediaImageService` — an Astro image service that resizes media-domain images on the media domain (any storefront host) |
47
47
  | `@wocart/theme/preview` | `startPreviewBridge` — the browser module of a theme's preview page (see [Preview](#preview)) |
48
+ | `@wocart/theme/lifecycle` | `bindEach`, `onEachPage`, `PREVIEW_SECTION_LOAD_EVENT` — binding browser behaviour so it survives page and section swaps; tiny, without the bridge (see [Browser scripts](#browser-scripts)) |
49
+ | `@wocart/theme/messages` | `formatMessage`, `messageForms`, `pluralMessageKeys`, `pluralFormIssues` for browser code (see [Messages](#messages-placeholders-and-plurals)) |
50
+
51
+ Browser code imports only what builds no validator: `./lifecycle`,
52
+ `./messages`, and from the root the style, rich-text and message helpers. A
53
+ test of this package bundles each of them and fails if Zod or the editor's
54
+ bridge reaches a shopper's page.
48
55
 
49
56
  ## Use
50
57
 
@@ -207,6 +214,9 @@ localize site links. `richTextToPlainText` gives the words for a meta
207
214
  description or an `aria-label`. A field's `formats` (`["bold", "link"]`)
208
215
  narrows what a merchant may use.
209
216
 
217
+ The rich-text types are plain JSON (`RichTextDocument` is a `JsonObject`), so
218
+ a preset or a default can hold a document without a cast.
219
+
210
220
  ### Lists
211
221
 
212
222
  ```ts
@@ -429,7 +439,21 @@ export const GET = themeStylesEndpoint(theme);
429
439
 
430
440
  The schemes become custom properties on `.wc-scheme-<id>`
431
441
  (`--wc-color-background`, `--wc-color-button-text`, …) with the background
432
- and text applied. On a page with a Content-Security-Policy, `ThemeStyles` adds
442
+ and text applied. `schemeClass(id)` also gives the element `wc-scheme`
443
+ (`SCHEME_MARKER_CLASS`), the one class every element wearing a scheme
444
+ carries. Derive the theme's own tokens there and on the page, so they follow
445
+ the scheme of the nearest element that wears one:
446
+
447
+ ```css
448
+ :root, .wc-scheme {
449
+ --surface: var(--wc-color-background, #fff);
450
+ --ink: var(--wc-color-text, #1d1d1f);
451
+ --line: color-mix(in srgb, var(--wc-color-border, #e5e5e5) 70%, transparent);
452
+ }
453
+ ```
454
+
455
+ Without that rule a token computed once on `:root` keeps the page's colors
456
+ inside a section that wears another scheme. On a page with a Content-Security-Policy, `ThemeStyles` adds
433
457
  its inline sheet's hash to the policy, so `style-src` needs no
434
458
  `'unsafe-inline'`. `contrastRatio(a, b)` gives the WCAG ratio an editor warns
435
459
  on (below 4.5 for text).
@@ -500,7 +524,13 @@ presets: [
500
524
  ```
501
525
 
502
526
  `instantiatePreset(theme, type, key, newId)` builds the instances, each with an
503
- id from `newId`. Presets are checked at assembly by building them.
527
+ id from `newId`. Presets are checked at assembly by building them. A preset's
528
+ props may hold rich text as it is written:
529
+
530
+ ```ts
531
+ const welcome: RichTextDocument = { type: "doc", version: 1, content: [{ type: "paragraph", content: [{ type: "text", text: "Bienvenue" }] }] };
532
+ presets: [{ key: "welcome", label: "Welcome", props: { body: welcome } }],
533
+ ```
504
534
 
505
535
  ## Hidden sections
506
536
 
@@ -622,12 +652,62 @@ later.
622
652
  A swapped section's scripts do not run again. A section with browser behavior
623
653
  binds it again on `PREVIEW_SECTION_LOAD_EVENT` (`wocart:section-load`,
624
654
  dispatched on `document` with `detail.id`) — also for a section a patch
625
- copied, restored or showed again. When the bridge replaces the whole
655
+ copied, restored or showed again. `bindEach` (see [Browser scripts](#browser-scripts))
656
+ does it for you. When the bridge replaces the whole
626
657
  page instead, it does what Astro's router does after a navigation — a new
627
658
  `<body>`, the root element's attributes, the title — and dispatches
628
659
  `astro:page-load`, so a script written for the router mounts again as it does
629
660
  there. Changed stylesheets are replaced in place; unchanged ones never reload.
630
661
 
662
+ ### Which page a preview shows
663
+
664
+ `previewTarget(theme, draft, client)` from `@wocart/theme/astro` decides it
665
+ for every theme: the surface to render, its route parameters and its
666
+ document — or null, answered 404, for a surface the theme does not declare.
667
+
668
+ - A template previews the resource the draft's path names, else the store's
669
+ first product, category, default collection or text page; a store with
670
+ none still previews, with empty `params`, so its sections can be arranged.
671
+ - An area (`area:header`) previews on the page its path names when the theme
672
+ composes it, else home; `area` names it, and the page's own document is
673
+ the saved one.
674
+ - The resource is chosen by the surface's `pageType`, so an alternate
675
+ (`product.compact`) previews like its base.
676
+
677
+ ```astro
678
+ ---
679
+ const target = await previewTarget(theme, Astro.locals.preview.draft, Astro.locals.store.client);
680
+ if (!target) return new Response(null, { status: 404, headers: { "Cache-Control": "no-store" } });
681
+ ---
682
+ <Sections surface={target.surface} params={target.params} document={target.document} preview={target.area === null} />
683
+ ```
684
+
685
+ ## Browser scripts
686
+
687
+ A theme's browser behaviour meets markup three ways: the page loads, Astro's
688
+ router swaps the page (`astro:page-load`), an editor's preview swaps one
689
+ section (`PREVIEW_SECTION_LOAD_EVENT`). `@wocart/theme/lifecycle` binds it
690
+ once per element whichever way it came:
691
+
692
+ ```ts
693
+ import { bindEach, onEachPage } from "@wocart/theme/lifecycle";
694
+
695
+ bindEach<HTMLFormElement>("[data-order-form]", (form) => {
696
+ const onResize = () => layout(form);
697
+ window.addEventListener("resize", onResize);
698
+ return () => window.removeEventListener("resize", onResize);
699
+ });
700
+
701
+ onEachPage(() => {
702
+ const timer = setInterval(tick, 1000);
703
+ return () => clearInterval(timer);
704
+ });
705
+ ```
706
+
707
+ A bind's teardown runs when its element leaves — before Astro swaps the page
708
+ out, or when a later pass finds it gone — so document and window listeners
709
+ never pile up. Both return a function that stops them.
710
+
631
711
  ## Theme manifest (serializable catalogue)
632
712
 
633
713
  A `ThemeDefinition` mixes data (types, fields, defaults) with runtime (the live
@@ -877,7 +957,10 @@ message rule — Shopify's locale-file model on the platform's own CLDR data
877
957
  - `pluralFormIssues(pack, locale)` lists the forms a pack is missing for its
878
958
  language, or carries but never uses — run it over every pack in a test.
879
959
  - `messageForms(pack, key)` collects one message's keys to hand a browser
880
- script.
960
+ script; `pluralMessageKeys(pack)` names every plural message in a pack.
961
+
962
+ Browser code imports the same functions from `@wocart/theme/messages`, an
963
+ entry with nothing else in it.
881
964
 
882
965
  Packs stay `Record<string, string>`, so store overrides and the editor treat
883
966
  plural forms like any other key.
@@ -885,7 +968,17 @@ plural forms like any other key.
885
968
  ## Capabilities are advisory
886
969
 
887
970
  `ComponentDefinition.capabilities` (`insertable`, `surfaces`) is metadata for an **editor/CLI insertion
888
- surface**, not renderer enforcement. Neither `parsePageData` nor
971
+ surface**, not renderer enforcement. Capabilities several components share go
972
+ through `defineCapabilities`:
973
+
974
+ ```ts
975
+ const ANY_PAGE = defineCapabilities({ surfaces: ["home", "product", "collection"] });
976
+ const grid = defineComponent({ type: "product-grid", capabilities: ANY_PAGE, /* … */ });
977
+ ```
978
+
979
+ A plain constant inferred from a literal (`{ surfaces: [...] as const }`) can
980
+ make TypeScript read a definition's `fields` in two shapes when its `load`
981
+ types its props from them; the error then names an unrelated field. Neither `parsePageData` nor
889
982
  `resolveInstance` consults it: a saved document containing a component marked
890
983
  `insertable: false` still renders, because the render path never second-guesses
891
984
  a document that was already saved. Any future enforcement belongs at insertion
@@ -962,7 +1055,9 @@ A theme proves it honors the contract with four checks in its own test suite:
962
1055
 
963
1056
  1. **The catalog assembles clean** — with its packs declared on the theme,
964
1057
  its default documents and presets are checked too:
965
- `expect(validateThemeDefinition(theme)).toEqual([])`.
1058
+ `expect(validateThemeDefinition(theme)).toEqual([])`. To check something
1059
+ of every component, iterate `componentsOf(theme)`, which lists them as
1060
+ `ComponentDefinition`s.
966
1061
  2. **The manifest survives a JSON round trip**:
967
1062
  `validateThemeManifest(JSON.parse(JSON.stringify(themeManifest(theme))))`
968
1063
  returns no issues.
@@ -4,6 +4,8 @@ export { resolveInstance } from "../instances.js";
4
4
  export { THEME_LIMITS } from "../blocks.js";
5
5
  export { THEME_STYLES_PATH, colorSchemeCss } from "../style.js";
6
6
  export { previewEndMarker, previewStartMarker } from "../preview-protocol.js";
7
+ export { previewTarget } from "../preview-target.js";
8
+ export type { PreviewTarget } from "../preview-target.js";
7
9
  /**
8
10
  * The theme's manifest as a prerendered endpoint, the file a release and an
9
11
  * editor read (`THEME_MANIFEST_PATH`):
@@ -11,6 +11,8 @@
11
11
  * `InstanceView`'s `onIssue` prop.
12
12
  * - `BlocksHandle` and `hasBlocks` — what a component receives as `blocks`,
13
13
  * and whether one of its slots has anything to render.
14
+ * - `previewTarget` — which surface, resource and document an editor's
15
+ * preview renders.
14
16
  * - `resolveInstance` and `THEME_LIMITS` — re-exported so `InstanceView.astro`'s
15
17
  * co-located import (`./index.js`) resolves identically in the dev source
16
18
  * layout and in the published `dist/astro` layout. The same values as the
@@ -26,6 +28,7 @@ export { resolveInstance } from "../instances.js";
26
28
  export { THEME_LIMITS } from "../blocks.js";
27
29
  export { THEME_STYLES_PATH, colorSchemeCss } from "../style.js";
28
30
  export { previewEndMarker, previewStartMarker } from "../preview-protocol.js";
31
+ export { previewTarget } from "../preview-target.js";
29
32
  /**
30
33
  * The theme's manifest as a prerendered endpoint, the file a release and an
31
34
  * editor read (`THEME_MANIFEST_PATH`):
@@ -1,4 +1,4 @@
1
- import type { ComponentDefinition, FieldDefinition, FieldValues, LoadContext, ThemeDefinition, ThemeDefinitionIssue, ThemeValidationOptions } from "./types.js";
1
+ import type { ComponentCapabilities, ComponentDefinition, FieldDefinition, FieldValues, LoadContext, ThemeDefinition, ThemeDefinitionIssue, ThemeValidationOptions } from "./types.js";
2
2
  /**
3
3
  * Identity helper: returns the definition unchanged so literal types survive.
4
4
  * `load` receives the component's props typed from its own `fields`, so a
@@ -20,6 +20,21 @@ type ComponentBase = Omit<ComponentDefinition, "fields" | "load">;
20
20
  type FieldsPart<Fields extends readonly FieldDefinition[]> = [Fields] extends [readonly []] ? {} : {
21
21
  fields: Fields;
22
22
  };
23
+ /**
24
+ * Identity helper for capabilities several components share:
25
+ *
26
+ * ```ts
27
+ * const ANY_PAGE = defineCapabilities({ surfaces: ["home", "product", "collection"] });
28
+ * ```
29
+ *
30
+ * Share them through it rather than as a plain constant. A constant inferred
31
+ * from a literal (`{ surfaces: [...] as const }`) can make TypeScript read a
32
+ * definition's `fields` twice in two shapes when its `load` types its props
33
+ * from them, and the error then names an unrelated field.
34
+ */
35
+ export declare function defineCapabilities(capabilities: ComponentCapabilities): ComponentCapabilities;
36
+ /** A theme's components as definitions, in its catalog's order — what a test or a tool iterates. */
37
+ export declare function componentsOf(theme: Pick<ThemeDefinition, "components">): ComponentDefinition[];
23
38
  /** Identity helper for the catalog. */
24
39
  export declare function defineTheme<const T extends ThemeDefinition>(definition: T): T;
25
40
  /** Check the whole catalog: key/type identity, field rules, slot rules. */
@@ -15,6 +15,25 @@ import { COLOR_PATTERN, MENU_HANDLE_PATTERN, COLLECTION_HANDLE_PATTERN, PAGE_TYP
15
15
  export function defineComponent(definition) {
16
16
  return definition;
17
17
  }
18
+ /**
19
+ * Identity helper for capabilities several components share:
20
+ *
21
+ * ```ts
22
+ * const ANY_PAGE = defineCapabilities({ surfaces: ["home", "product", "collection"] });
23
+ * ```
24
+ *
25
+ * Share them through it rather than as a plain constant. A constant inferred
26
+ * from a literal (`{ surfaces: [...] as const }`) can make TypeScript read a
27
+ * definition's `fields` twice in two shapes when its `load` types its props
28
+ * from them, and the error then names an unrelated field.
29
+ */
30
+ export function defineCapabilities(capabilities) {
31
+ return capabilities;
32
+ }
33
+ /** A theme's components as definitions, in its catalog's order — what a test or a tool iterates. */
34
+ export function componentsOf(theme) {
35
+ return Object.values(theme.components);
36
+ }
18
37
  /** Identity helper for the catalog. */
19
38
  export function defineTheme(definition) {
20
39
  return definition;
package/dist/index.d.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * This entry is framework-neutral. The reference Astro renderer lives in the
12
12
  * `./astro` subpath.
13
13
  */
14
- export { defineComponent, defineTheme, validateThemeDefinition } from "./definitions.js";
14
+ export { componentsOf, defineCapabilities, defineComponent, defineTheme, validateThemeDefinition } from "./definitions.js";
15
15
  export { parsePageData, validatePageData } from "./page-data.js";
16
16
  export { resolveInstance, createInstance, checkStoredValue as checkFieldValue } from "./instances.js";
17
17
  export type { CheckedValue as FieldValueCheck } from "./instances.js";
@@ -20,7 +20,7 @@ export { instantiatePreset, isInsertableOn } from "./composition.js";
20
20
  export { COMPONENT_PLACEMENTS, COMPONENT_ROLES, THEME_LIMITS, areaSurface, fixedBlockId } from "./blocks.js";
21
21
  export { completePage, resolveSurfaceDocument } from "./surface-document.js";
22
22
  export { CONDITION_DEPTH, isFieldVisible } from "./conditions.js";
23
- export { COLOR_ROLES, COLOR_SCHEMES_MAX, STYLE_BREAKPOINTS, STYLE_PANELS, STYLE_STEPS, STYLE_WIDTHS, THEME_STYLES_PATH, colorSchemeCss, contrastRatio, schemeClass, styleClasses, themeStyleSheet, } from "./style.js";
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
25
  export { checkPageData, duplicateInstance, findInstance, insertInstance, moveInstance, newInstanceId, removeInstance, setInstanceHidden, setInstanceProps, } from "./operations.js";
26
26
  export type { DocumentEdit, DocumentIssue, DocumentIssueCode, InstanceLocation, InstancePlacement, } from "./operations.js";
@@ -28,7 +28,7 @@ export { THEME_SDK_VERSION } from "./version.js";
28
28
  export type { RichTextHtmlOptions } from "./rich-text.js";
29
29
  export { resolveThemeSettings, checkThemeSettings } from "./settings.js";
30
30
  export { localize } from "./locales.js";
31
- export { PLURAL_CATEGORIES, formatMessage, messageForms, pluralFormIssues } from "./messages.js";
31
+ export { PLURAL_CATEGORIES, formatMessage, messageForms, pluralFormIssues, pluralMessageKeys } from "./messages.js";
32
32
  export type { MessageOptions, MessageValues, PluralCategory } from "./messages.js";
33
33
  export { THEME_MANIFEST_CONTRACT_VERSION, THEME_MANIFEST_PATH, manifestCatalog, themeManifest, validateThemeManifest, } from "./manifest.js";
34
34
  export { diffThemeManifests } from "./manifest-diff.js";
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * This entry is framework-neutral. The reference Astro renderer lives in the
12
12
  * `./astro` subpath.
13
13
  */
14
- export { defineComponent, defineTheme, validateThemeDefinition } from "./definitions.js";
14
+ export { componentsOf, defineCapabilities, defineComponent, defineTheme, validateThemeDefinition } from "./definitions.js";
15
15
  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";
@@ -19,12 +19,12 @@ export { instantiatePreset, isInsertableOn } from "./composition.js";
19
19
  export { COMPONENT_PLACEMENTS, COMPONENT_ROLES, THEME_LIMITS, areaSurface, fixedBlockId } from "./blocks.js";
20
20
  export { completePage, resolveSurfaceDocument } from "./surface-document.js";
21
21
  export { CONDITION_DEPTH, isFieldVisible } from "./conditions.js";
22
- export { COLOR_ROLES, COLOR_SCHEMES_MAX, STYLE_BREAKPOINTS, STYLE_PANELS, STYLE_STEPS, STYLE_WIDTHS, THEME_STYLES_PATH, colorSchemeCss, contrastRatio, schemeClass, styleClasses, themeStyleSheet, } from "./style.js";
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
23
  export { checkPageData, duplicateInstance, findInstance, insertInstance, moveInstance, newInstanceId, removeInstance, 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";
27
- export { PLURAL_CATEGORIES, formatMessage, messageForms, pluralFormIssues } from "./messages.js";
27
+ export { PLURAL_CATEGORIES, formatMessage, messageForms, pluralFormIssues, pluralMessageKeys } from "./messages.js";
28
28
  export { THEME_MANIFEST_CONTRACT_VERSION, THEME_MANIFEST_PATH, manifestCatalog, themeManifest, validateThemeManifest, } from "./manifest.js";
29
29
  export { diffThemeManifests } from "./manifest-diff.js";
30
30
  export { PREVIEW_PROTOCOL_VERSION, THEME_PREVIEW_PATH, parsePreviewMarker, previewEndMarker, previewStartMarker, } from "./preview-protocol.js";
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `@wocart/theme/lifecycle` — binding a theme's browser behaviour to its
3
+ * markup, so it survives the page being swapped.
4
+ *
5
+ * Markup arrives three ways: the page loads; Astro's router swaps the page
6
+ * (`astro:page-load`); an editor's preview swaps one section in
7
+ * (`PREVIEW_SECTION_LOAD_EVENT`). A script that binds once on import misses
8
+ * the last two, and one that binds on every event binds twice. `bindEach`
9
+ * binds each matching element exactly once, whichever way it arrived, and
10
+ * `onEachPage` runs a page's behaviour once per page.
11
+ *
12
+ * This module is tiny and loads nothing else: a shopper's page imports it, and
13
+ * the editor's bridge (`@wocart/theme/preview`) stays on the preview route.
14
+ */
15
+ /**
16
+ * Dispatched on `document` for each section the editor's preview swapped in,
17
+ * with the instance id as `detail.id`. When the whole page was replaced, the
18
+ * preview dispatches Astro's own `astro:page-load` instead.
19
+ */
20
+ export declare const PREVIEW_SECTION_LOAD_EVENT = "wocart:section-load";
21
+ /** What a bind may return: a function that undoes it — listeners on `document` or `window`, timers. */
22
+ export type Teardown = void | (() => void);
23
+ export interface LifecycleOptions {
24
+ /** The document to bind in; the page's own by default. */
25
+ document?: Document;
26
+ }
27
+ /**
28
+ * Runs `boot` for the page the shopper is on, and again for each page Astro's
29
+ * router swaps in. Its teardown runs just before the page is swapped out, so
30
+ * listeners on `document` or `window` never pile up from one page to the next.
31
+ * Returns a function that stops it.
32
+ */
33
+ export declare function onEachPage(boot: () => Teardown, options?: LifecycleOptions): () => void;
34
+ /**
35
+ * Runs `bind` once on every element `selector` matches: when the page is
36
+ * ready, after Astro's router swaps a page in, and after an editor's preview
37
+ * swaps a section in. A bind may return a teardown, run when its element
38
+ * leaves — before Astro swaps the page out, or when a later pass finds the
39
+ * element gone. Returns a function that stops it and runs every teardown.
40
+ *
41
+ * ```ts
42
+ * bindEach<HTMLFormElement>("[data-order-form]", (form) => {
43
+ * const onResize = () => layout(form);
44
+ * window.addEventListener("resize", onResize);
45
+ * return () => window.removeEventListener("resize", onResize);
46
+ * });
47
+ * ```
48
+ */
49
+ export declare function bindEach<T extends Element = HTMLElement>(selector: string, bind: (element: T) => Teardown, options?: LifecycleOptions): () => void;
@@ -0,0 +1,120 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * `@wocart/theme/lifecycle` — binding a theme's browser behaviour to its
4
+ * markup, so it survives the page being swapped.
5
+ *
6
+ * Markup arrives three ways: the page loads; Astro's router swaps the page
7
+ * (`astro:page-load`); an editor's preview swaps one section in
8
+ * (`PREVIEW_SECTION_LOAD_EVENT`). A script that binds once on import misses
9
+ * the last two, and one that binds on every event binds twice. `bindEach`
10
+ * binds each matching element exactly once, whichever way it arrived, and
11
+ * `onEachPage` runs a page's behaviour once per page.
12
+ *
13
+ * This module is tiny and loads nothing else: a shopper's page imports it, and
14
+ * the editor's bridge (`@wocart/theme/preview`) stays on the preview route.
15
+ */
16
+ /**
17
+ * Dispatched on `document` for each section the editor's preview swapped in,
18
+ * with the instance id as `detail.id`. When the whole page was replaced, the
19
+ * preview dispatches Astro's own `astro:page-load` instead.
20
+ */
21
+ export const PREVIEW_SECTION_LOAD_EVENT = "wocart:section-load";
22
+ const PAGE_LOAD = "astro:page-load";
23
+ const BEFORE_SWAP = "astro:before-swap";
24
+ function whenReady(doc, run) {
25
+ if (doc.readyState === "loading")
26
+ doc.addEventListener("DOMContentLoaded", run, { once: true });
27
+ else
28
+ run();
29
+ }
30
+ /**
31
+ * Runs `boot` for the page the shopper is on, and again for each page Astro's
32
+ * router swaps in. Its teardown runs just before the page is swapped out, so
33
+ * listeners on `document` or `window` never pile up from one page to the next.
34
+ * Returns a function that stops it.
35
+ */
36
+ export function onEachPage(boot, options = {}) {
37
+ const doc = options.document ?? document;
38
+ let mounted = null;
39
+ let teardown;
40
+ const unmount = () => {
41
+ const previous = teardown;
42
+ teardown = undefined;
43
+ if (typeof previous === "function")
44
+ previous();
45
+ };
46
+ const mount = () => {
47
+ mounted = doc.body;
48
+ teardown = boot();
49
+ };
50
+ const pageLoad = () => {
51
+ if (doc.body === mounted)
52
+ return;
53
+ unmount();
54
+ mount();
55
+ };
56
+ doc.addEventListener(BEFORE_SWAP, unmount);
57
+ doc.addEventListener(PAGE_LOAD, pageLoad);
58
+ whenReady(doc, () => {
59
+ if (mounted === null)
60
+ mount();
61
+ });
62
+ return () => {
63
+ doc.removeEventListener(BEFORE_SWAP, unmount);
64
+ doc.removeEventListener(PAGE_LOAD, pageLoad);
65
+ unmount();
66
+ };
67
+ }
68
+ /**
69
+ * Runs `bind` once on every element `selector` matches: when the page is
70
+ * ready, after Astro's router swaps a page in, and after an editor's preview
71
+ * swaps a section in. A bind may return a teardown, run when its element
72
+ * leaves — before Astro swaps the page out, or when a later pass finds the
73
+ * element gone. Returns a function that stops it and runs every teardown.
74
+ *
75
+ * ```ts
76
+ * bindEach<HTMLFormElement>("[data-order-form]", (form) => {
77
+ * const onResize = () => layout(form);
78
+ * window.addEventListener("resize", onResize);
79
+ * return () => window.removeEventListener("resize", onResize);
80
+ * });
81
+ * ```
82
+ */
83
+ export function bindEach(selector, bind, options = {}) {
84
+ const doc = options.document ?? document;
85
+ const bound = new WeakSet();
86
+ const teardowns = new Map();
87
+ const release = (element) => {
88
+ const teardown = teardowns.get(element);
89
+ teardowns.delete(element);
90
+ bound.delete(element);
91
+ teardown?.();
92
+ };
93
+ const run = () => {
94
+ for (const element of [...teardowns.keys()])
95
+ if (!element.isConnected)
96
+ release(element);
97
+ doc.querySelectorAll(selector).forEach((element) => {
98
+ if (bound.has(element))
99
+ return;
100
+ bound.add(element);
101
+ const teardown = bind(element);
102
+ if (typeof teardown === "function")
103
+ teardowns.set(element, teardown);
104
+ });
105
+ };
106
+ const leave = () => {
107
+ for (const element of [...teardowns.keys()])
108
+ release(element);
109
+ };
110
+ doc.addEventListener(PAGE_LOAD, run);
111
+ doc.addEventListener(PREVIEW_SECTION_LOAD_EVENT, run);
112
+ doc.addEventListener(BEFORE_SWAP, leave);
113
+ whenReady(doc, run);
114
+ return () => {
115
+ doc.removeEventListener(PAGE_LOAD, run);
116
+ doc.removeEventListener(PREVIEW_SECTION_LOAD_EVENT, run);
117
+ doc.removeEventListener(BEFORE_SWAP, leave);
118
+ leave();
119
+ };
120
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * `@wocart/theme/messages` — a content pack's messages, for browser code:
3
+ * placeholders and CLDR plurals, with nothing else from the theme contract.
4
+ * The same functions as the package's root entry.
5
+ */
6
+ export { PLURAL_CATEGORIES, formatMessage, messageForms, pluralFormIssues, pluralMessageKeys } from "./messages.js";
7
+ export type { MessageOptions, MessageValues, PluralCategory } from "./messages.js";
@@ -0,0 +1,6 @@
1
+ /**
2
+ * `@wocart/theme/messages` — a content pack's messages, for browser code:
3
+ * placeholders and CLDR plurals, with nothing else from the theme contract.
4
+ * The same functions as the package's root entry.
5
+ */
6
+ export { PLURAL_CATEGORIES, formatMessage, messageForms, pluralFormIssues, pluralMessageKeys } from "./messages.js";
@@ -0,0 +1,62 @@
1
+ /**
2
+ * A theme's key: lowercase kebab-case, at most 64 characters. The platform
3
+ * stores a merchant's text edits under it, so it is the theme's identity on the
4
+ * platform, and the server applies the same rule.
5
+ */
6
+ export declare const THEME_KEY_PATTERN: RegExp;
7
+ /** Type keys are lowercase kebab-case. */
8
+ export declare const TYPE_KEY_PATTERN: RegExp;
9
+ /**
10
+ * The page-document **surface** key — the address of a composed page
11
+ * (`home`, `product`, …; `area:<key>` is an area's document, and
12
+ * `page:<slug>` / `lp:<slug>` are reserved conventions).
13
+ *
14
+ * The server applies the same pattern. Module-level on purpose: it is part of
15
+ * the persisted-data contract,
16
+ * not of the package's curated public surface (`src/index.ts`).
17
+ */
18
+ export declare const SURFACE_KEY_PATTERN: RegExp;
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"];
21
+ /** A `color` field's value: a hex color, `#rgb` or `#rrggbb`. */
22
+ export declare const COLOR_PATTERN: RegExp;
23
+ /**
24
+ * A `menu` field's value: the handle of one of the store's menus
25
+ * (`main-menu`, `footer`). The server applies the same pattern to menu
26
+ * handles; a menu that does not exist reads as no menu.
27
+ */
28
+ export declare const MENU_HANDLE_PATTERN: RegExp;
29
+ export declare const COLLECTION_HANDLE_PATTERN: RegExp;
30
+ /**
31
+ * A `product` or `category` value: a handle or slug as the store's URLs use
32
+ * it. Merchants may type their own, so the rule only keeps out what cannot be
33
+ * one URL path segment: whitespace, control characters, `/`, `\`, `?`, `#`
34
+ * and `%`, and anything over 255 characters.
35
+ */
36
+ export declare const RESOURCE_HANDLE_PATTERN: RegExp;
37
+ /**
38
+ * A media object key in the store's bucket: a namespace and a name
39
+ * (`products/3f2a9c.webp`), segments of letters, digits, `.`, `_` and `-`
40
+ * that never start with a dot — so no `..`, no leading slash, no empty
41
+ * segment — and at most 512 characters.
42
+ */
43
+ export declare const MEDIA_KEY_PATTERN: RegExp;
44
+ export declare function isLocaleTag(value: string): boolean;
45
+ /**
46
+ * Whether `value` is a per-language map (`{ ar: "…", fr: "…" }`): a plain
47
+ * object with at least one entry, every key a locale tag, every value a string.
48
+ */
49
+ export declare function isLocaleMap(value: unknown): value is Readonly<Record<string, string>>;
50
+ /**
51
+ * Whether `value` is a per-language map whose entries pass `isEntry`: a plain
52
+ * object with at least one entry, every key a locale tag.
53
+ */
54
+ export declare function isLocaleMapOf<T>(value: unknown, isEntry: (entry: unknown) => entry is T): value is Readonly<Record<string, T>>;
55
+ /**
56
+ * Whether a `url` field value is safe to render as an `href`: a site path,
57
+ * fragment or query, or an `http(s)`, `mailto` or `tel` URL. Rejects
58
+ * `javascript:`/`data:` and every other scheme, protocol-relative `//host`,
59
+ * backslashes (browsers read `/\host` as `//host`), whitespace and control
60
+ * characters, and anything over 2048 characters.
61
+ */
62
+ export declare function isSafeLinkTarget(value: string): boolean;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * A theme's key: lowercase kebab-case, at most 64 characters. The platform
3
+ * stores a merchant's text edits under it, so it is the theme's identity on the
4
+ * platform, and the server applies the same rule.
5
+ */
6
+ export const THEME_KEY_PATTERN = /^(?=.{1,64}$)[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
7
+ /** Type keys are lowercase kebab-case. */
8
+ export const TYPE_KEY_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
9
+ /**
10
+ * The page-document **surface** key — the address of a composed page
11
+ * (`home`, `product`, …; `area:<key>` is an area's document, and
12
+ * `page:<slug>` / `lp:<slug>` are reserved conventions).
13
+ *
14
+ * The server applies the same pattern. Module-level on purpose: it is part of
15
+ * the persisted-data contract,
16
+ * not of the package's curated public surface (`src/index.ts`).
17
+ */
18
+ export const SURFACE_KEY_PATTERN = /^[a-z0-9][a-z0-9._:-]{0,63}$/;
19
+ /** The platform page types a surface can render (`SurfacePageType`), mirrored from the storefront's page types. */
20
+ export const PAGE_TYPES = [
21
+ "home",
22
+ "catalog",
23
+ "category",
24
+ "collection",
25
+ "product",
26
+ "page",
27
+ "landing",
28
+ "checkout",
29
+ "thankYou",
30
+ "trackOrder",
31
+ ];
32
+ /** A `color` field's value: a hex color, `#rgb` or `#rrggbb`. */
33
+ export const COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
34
+ /**
35
+ * A `menu` field's value: the handle of one of the store's menus
36
+ * (`main-menu`, `footer`). The server applies the same pattern to menu
37
+ * handles; a menu that does not exist reads as no menu.
38
+ */
39
+ export const MENU_HANDLE_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
40
+ export const COLLECTION_HANDLE_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
41
+ /**
42
+ * A `product` or `category` value: a handle or slug as the store's URLs use
43
+ * it. Merchants may type their own, so the rule only keeps out what cannot be
44
+ * one URL path segment: whitespace, control characters, `/`, `\`, `?`, `#`
45
+ * and `%`, and anything over 255 characters.
46
+ */
47
+ export const RESOURCE_HANDLE_PATTERN = /^[^\s/\\?#%\u0000-\u001f\u007f]{1,255}$/u;
48
+ /**
49
+ * A media object key in the store's bucket: a namespace and a name
50
+ * (`products/3f2a9c.webp`), segments of letters, digits, `.`, `_` and `-`
51
+ * that never start with a dot — so no `..`, no leading slash, no empty
52
+ * segment — and at most 512 characters.
53
+ */
54
+ export const MEDIA_KEY_PATTERN = /^(?=.{3,512}$)[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)+$/;
55
+ /**
56
+ * A BCP 47 language tag in canonical case: a 2–3 letter language, an optional
57
+ * 4-letter script, an optional 2-letter or 3-digit region (`ar`, `zgh`,
58
+ * `ar-DZ`, `zh-Hant-TW`, `es-419`). Canonical case keeps map keys unique:
59
+ * `fr` and `FR` can never both be present.
60
+ */
61
+ const LOCALE_TAG_PATTERN = /^[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|\d{3}))?$/;
62
+ export function isLocaleTag(value) {
63
+ return LOCALE_TAG_PATTERN.test(value);
64
+ }
65
+ /**
66
+ * Whether `value` is a per-language map (`{ ar: "…", fr: "…" }`): a plain
67
+ * object with at least one entry, every key a locale tag, every value a string.
68
+ */
69
+ export function isLocaleMap(value) {
70
+ return isLocaleMapOf(value, (entry) => typeof entry === "string");
71
+ }
72
+ /**
73
+ * Whether `value` is a per-language map whose entries pass `isEntry`: a plain
74
+ * object with at least one entry, every key a locale tag.
75
+ */
76
+ export function isLocaleMapOf(value, isEntry) {
77
+ if (typeof value !== "object" || value === null || Array.isArray(value))
78
+ return false;
79
+ const entries = Object.entries(value);
80
+ return entries.length > 0 && entries.every(([key, entry]) => isLocaleTag(key) && isEntry(entry));
81
+ }
82
+ /**
83
+ * Whether a `url` field value is safe to render as an `href`: a site path,
84
+ * fragment or query, or an `http(s)`, `mailto` or `tel` URL. Rejects
85
+ * `javascript:`/`data:` and every other scheme, protocol-relative `//host`,
86
+ * backslashes (browsers read `/\host` as `//host`), whitespace and control
87
+ * characters, and anything over 2048 characters.
88
+ */
89
+ export function isSafeLinkTarget(value) {
90
+ if (value.length === 0 || value.length > 2048)
91
+ return false;
92
+ if (/[\s\\\u0000-\u001f\u007f]/.test(value))
93
+ return false;
94
+ if (value.startsWith("/"))
95
+ return !value.startsWith("//");
96
+ if (value.startsWith("#") || value.startsWith("?"))
97
+ return true;
98
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1]?.toLowerCase();
99
+ if (scheme === "http" || scheme === "https") {
100
+ return /^https?:\/\/[a-z0-9.-]+(:\d{1,5})?([/?#]|$)/i.test(value);
101
+ }
102
+ if (scheme === "mailto" || scheme === "tel")
103
+ return value.length > scheme.length + 1;
104
+ return false;
105
+ }
@@ -1,3 +1,4 @@
1
+ import { PREVIEW_SECTION_LOAD_EVENT } from "./lifecycle.js";
1
2
  export interface PreviewBridgeOptions {
2
3
  /** The editor's origin: the verified token's `editor` claim. */
3
4
  editorOrigin: string;
@@ -14,11 +15,12 @@ export interface PreviewBridge {
14
15
  }
15
16
  /**
16
17
  * Dispatched on `document` for each section the bridge swapped in, with the
17
- * instance id as `detail.id`: a section with browser behavior binds it again.
18
- * When the whole page was replaced instead, the bridge dispatches Astro's own
19
- * `astro:page-load`, as Astro's router does after it swaps a page.
18
+ * instance id as `detail.id`: a section with browser behavior binds it again
19
+ * (`bindEach` from `@wocart/theme/lifecycle` does). When the whole page was
20
+ * replaced instead, the bridge dispatches Astro's own `astro:page-load`, as
21
+ * Astro's router does after it swaps a page.
20
22
  */
21
- export declare const PREVIEW_SECTION_LOAD_EVENT = "wocart:section-load";
23
+ export { PREVIEW_SECTION_LOAD_EVENT };
22
24
  /**
23
25
  * Starts the bridge on a preview page. It posts `ready` with every marked
24
26
  * instance, `layout` as the page scrolls or resizes, `hover` as the merchant
@@ -14,13 +14,15 @@
14
14
  * from the framing window.
15
15
  */
16
16
  import { PREVIEW_PROTOCOL_VERSION, THEME_PREVIEW_PATH, parsePreviewMarker, previewEndMarker, previewStartMarker, } from "./preview-protocol.js";
17
+ import { PREVIEW_SECTION_LOAD_EVENT } from "./lifecycle.js";
17
18
  /**
18
19
  * Dispatched on `document` for each section the bridge swapped in, with the
19
- * instance id as `detail.id`: a section with browser behavior binds it again.
20
- * When the whole page was replaced instead, the bridge dispatches Astro's own
21
- * `astro:page-load`, as Astro's router does after it swaps a page.
20
+ * instance id as `detail.id`: a section with browser behavior binds it again
21
+ * (`bindEach` from `@wocart/theme/lifecycle` does). When the whole page was
22
+ * replaced instead, the bridge dispatches Astro's own `astro:page-load`, as
23
+ * Astro's router does after it swaps a page.
22
24
  */
23
- export const PREVIEW_SECTION_LOAD_EVENT = "wocart:section-load";
25
+ export { PREVIEW_SECTION_LOAD_EVENT };
24
26
  const ASTRO_PAGE_LOAD_EVENT = "astro:page-load";
25
27
  const SHOW_COMMENT = 0x80;
26
28
  const SHOW_TEXT = 0x4;
@@ -0,0 +1,41 @@
1
+ import type { PreviewDraft, StoreClient } from "@wocart/storefront/server";
2
+ import type { SurfacePageType, ThemeDefinition } from "./types.js";
3
+ /** What a preview renders. */
4
+ export interface PreviewTarget {
5
+ /** The surface the page renders: the draft's own, or for an area the page it is shown on. */
6
+ surface: string;
7
+ /** That surface's page type, when it declares one. */
8
+ pageType: SurfacePageType | undefined;
9
+ /** The area the draft edits (`area:header`), or null when it edits the page itself. */
10
+ area: string | null;
11
+ /**
12
+ * The route's parameters for the resource a template previews — `{ handle }`
13
+ * for a product or collection, `{ slug }` for a category, page or landing
14
+ * page — or none when the store has none of its kind yet.
15
+ */
16
+ params: Record<string, string>;
17
+ /** The page's document: the draft's when the page is what is edited, else what the store saved (null for none). */
18
+ document: unknown;
19
+ }
20
+ /**
21
+ * What an editor's preview of `draft` renders, or null — answer 404 — for a
22
+ * surface the theme does not declare:
23
+ *
24
+ * - A template previews the resource the draft's `path` names
25
+ * (`/products/robe`), else the store's first product, category, default
26
+ * collection or text page. A store with none still previews: `params` is
27
+ * empty and the template renders empty, so its sections can be arranged.
28
+ * - An area (the header, the footer) previews on the page its `path` names
29
+ * when the theme composes that page, else on the home page.
30
+ * - The resource is chosen by the surface's `pageType`, never its key, so an
31
+ * alternate (`product.compact`) previews like its base.
32
+ *
33
+ * ```astro
34
+ * ---
35
+ * const target = await previewTarget(theme, Astro.locals.preview.draft, Astro.locals.store.client);
36
+ * if (!target) return new Response(null, { status: 404, headers: { "Cache-Control": "no-store" } });
37
+ * ---
38
+ * <Sections surface={target.surface} params={target.params} document={target.document} preview={target.area === null} />
39
+ * ```
40
+ */
41
+ export declare function previewTarget(theme: ThemeDefinition, draft: PreviewDraft, client: StoreClient): Promise<PreviewTarget | null>;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Which page an editor's preview shows, decided once for every theme: the
3
+ * surface a draft previews, the resource it previews it with, and the
4
+ * document it renders.
5
+ */
6
+ import { DEFAULT_COLLECTION_HANDLES, matchStorefrontPath } from "@wocart/storefront";
7
+ import { areaSurface } from "./blocks.js";
8
+ /** The surface a page type is shown with: the one keyed by the type itself, else the first declaring it. */
9
+ function surfaceFor(surfaces, pageType) {
10
+ return surfaces.find((surface) => surface.key === pageType) ?? surfaces.find((surface) => surface.pageType === pageType);
11
+ }
12
+ async function firstCollection(client) {
13
+ for (const handle of DEFAULT_COLLECTION_HANDLES)
14
+ if (await client.collections.get(handle))
15
+ return handle;
16
+ return undefined;
17
+ }
18
+ /** The resource a template of `pageType` previews: the one the path names, else the store's first of its kind. */
19
+ async function resourceParams(pageType, named, client) {
20
+ const only = (key, value) => (value === undefined ? {} : { [key]: value });
21
+ switch (pageType) {
22
+ case "product":
23
+ return only("handle", named.handle ?? (await client.products.list({ limit: 1 }))[0]?.handle);
24
+ case "collection":
25
+ return only("handle", named.handle ?? (await firstCollection(client)));
26
+ case "category":
27
+ return only("slug", named.slug ?? (await client.categories.list())[0]?.slug);
28
+ case "page":
29
+ return only("slug", named.slug ?? (await client.config.get())?.pages[0]?.slug);
30
+ case "landing":
31
+ return only("slug", named.slug);
32
+ default:
33
+ return {};
34
+ }
35
+ }
36
+ /**
37
+ * What an editor's preview of `draft` renders, or null — answer 404 — for a
38
+ * surface the theme does not declare:
39
+ *
40
+ * - A template previews the resource the draft's `path` names
41
+ * (`/products/robe`), else the store's first product, category, default
42
+ * collection or text page. A store with none still previews: `params` is
43
+ * empty and the template renders empty, so its sections can be arranged.
44
+ * - An area (the header, the footer) previews on the page its `path` names
45
+ * when the theme composes that page, else on the home page.
46
+ * - The resource is chosen by the surface's `pageType`, never its key, so an
47
+ * alternate (`product.compact`) previews like its base.
48
+ *
49
+ * ```astro
50
+ * ---
51
+ * const target = await previewTarget(theme, Astro.locals.preview.draft, Astro.locals.store.client);
52
+ * if (!target) return new Response(null, { status: 404, headers: { "Cache-Control": "no-store" } });
53
+ * ---
54
+ * <Sections surface={target.surface} params={target.params} document={target.document} preview={target.area === null} />
55
+ * ```
56
+ */
57
+ export async function previewTarget(theme, draft, client) {
58
+ const surfaces = theme.surfaces ?? [];
59
+ const match = draft.path ? matchStorefrontPath(draft.path) : null;
60
+ 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");
62
+ if (shown === undefined)
63
+ return null;
64
+ const pageType = shown.pageType;
65
+ const named = match !== null && match.type === pageType ? { ...match.params } : {};
66
+ const [params, document] = await Promise.all([
67
+ resourceParams(pageType, named, client),
68
+ area === null && draft.document !== undefined ? draft.document : client.pageData.get(shown.key),
69
+ ]);
70
+ return { surface: shown.key, pageType, area, params, document };
71
+ }
package/dist/rich-text.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * value invalid. Rendering re-checks what it emits, so a value that skipped
8
8
  * validation still cannot produce markup.
9
9
  */
10
- import { isSafeLinkTarget } from "./schemas.js";
10
+ import { isSafeLinkTarget } from "./patterns.js";
11
11
  /** Every format a rich-text field can allow, in the order an editor lists them. */
12
12
  export const RICH_TEXT_FORMATS = [
13
13
  "bold",
package/dist/schemas.d.ts CHANGED
@@ -4,68 +4,8 @@
4
4
  */
5
5
  import { z } from "zod";
6
6
  import type { ComponentInstance, JsonValue } from "./types.js";
7
- /**
8
- * A theme's key: lowercase kebab-case, at most 64 characters. The platform
9
- * stores a merchant's text edits under it, so it is the theme's identity on the
10
- * platform, and the server applies the same rule.
11
- */
12
- export declare const THEME_KEY_PATTERN: RegExp;
13
- /** Type keys are lowercase kebab-case. */
14
- export declare const TYPE_KEY_PATTERN: RegExp;
15
- /**
16
- * The page-document **surface** key — the address of a composed page
17
- * (`home`, `product`, …; `area:<key>` is an area's document, and
18
- * `page:<slug>` / `lp:<slug>` are reserved conventions).
19
- *
20
- * The server applies the same pattern. Module-level on purpose: it is part of
21
- * the persisted-data contract,
22
- * not of the package's curated public surface (`src/index.ts`).
23
- */
24
- export declare const SURFACE_KEY_PATTERN: RegExp;
25
- /** The platform page types a surface can render (`SurfacePageType`), mirrored from the storefront's page types. */
26
- export declare const PAGE_TYPES: readonly ["home", "catalog", "category", "collection", "product", "page", "landing", "checkout", "thankYou"];
27
- /** A `color` field's value: a hex color, `#rgb` or `#rrggbb`. */
28
- export declare const COLOR_PATTERN: RegExp;
29
- /**
30
- * A `menu` field's value: the handle of one of the store's menus
31
- * (`main-menu`, `footer`). The server applies the same pattern to menu
32
- * handles; a menu that does not exist reads as no menu.
33
- */
34
- export declare const MENU_HANDLE_PATTERN: RegExp;
35
- export declare const COLLECTION_HANDLE_PATTERN: RegExp;
36
- /**
37
- * A `product` or `category` value: a handle or slug as the store's URLs use
38
- * it. Merchants may type their own, so the rule only keeps out what cannot be
39
- * one URL path segment: whitespace, control characters, `/`, `\`, `?`, `#`
40
- * and `%`, and anything over 255 characters.
41
- */
42
- export declare const RESOURCE_HANDLE_PATTERN: RegExp;
43
- /**
44
- * A media object key in the store's bucket: a namespace and a name
45
- * (`products/3f2a9c.webp`), segments of letters, digits, `.`, `_` and `-`
46
- * that never start with a dot — so no `..`, no leading slash, no empty
47
- * segment — and at most 512 characters.
48
- */
49
- export declare const MEDIA_KEY_PATTERN: RegExp;
50
- export declare function isLocaleTag(value: string): boolean;
51
- /**
52
- * Whether `value` is a per-language map (`{ ar: "…", fr: "…" }`): a plain
53
- * object with at least one entry, every key a locale tag, every value a string.
54
- */
55
- export declare function isLocaleMap(value: unknown): value is Readonly<Record<string, string>>;
56
- /**
57
- * Whether `value` is a per-language map whose entries pass `isEntry`: a plain
58
- * object with at least one entry, every key a locale tag.
59
- */
60
- export declare function isLocaleMapOf<T>(value: unknown, isEntry: (entry: unknown) => entry is T): value is Readonly<Record<string, T>>;
61
- /**
62
- * Whether a `url` field value is safe to render as an `href`: a site path,
63
- * fragment or query, or an `http(s)`, `mailto` or `tel` URL. Rejects
64
- * `javascript:`/`data:` and every other scheme, protocol-relative `//host`,
65
- * backslashes (browsers read `/\host` as `//host`), whitespace and control
66
- * characters, and anything over 2048 characters.
67
- */
68
- export declare function isSafeLinkTarget(value: string): boolean;
7
+ import { COLLECTION_HANDLE_PATTERN, COLOR_PATTERN, isLocaleMap, isLocaleMapOf, isLocaleTag, isSafeLinkTarget, MEDIA_KEY_PATTERN, MENU_HANDLE_PATTERN, PAGE_TYPES, RESOURCE_HANDLE_PATTERN, SURFACE_KEY_PATTERN, THEME_KEY_PATTERN, TYPE_KEY_PATTERN } from "./patterns.js";
8
+ export { COLLECTION_HANDLE_PATTERN, COLOR_PATTERN, isLocaleMap, isLocaleMapOf, isLocaleTag, isSafeLinkTarget, MEDIA_KEY_PATTERN, MENU_HANDLE_PATTERN, PAGE_TYPES, RESOURCE_HANDLE_PATTERN, SURFACE_KEY_PATTERN, THEME_KEY_PATTERN, TYPE_KEY_PATTERN, };
69
9
  export declare const JsonValueSchema: z.ZodType<JsonValue>;
70
10
  export declare const ComponentInstanceSchema: z.ZodType<ComponentInstance>;
71
11
  /**
package/dist/schemas.js CHANGED
@@ -3,110 +3,8 @@
3
3
  * validated. Definitions are typechecked code; page data is parsed data.
4
4
  */
5
5
  import { z } from "zod";
6
- /**
7
- * A theme's key: lowercase kebab-case, at most 64 characters. The platform
8
- * stores a merchant's text edits under it, so it is the theme's identity on the
9
- * platform, and the server applies the same rule.
10
- */
11
- export const THEME_KEY_PATTERN = /^(?=.{1,64}$)[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
12
- /** Type keys are lowercase kebab-case. */
13
- export const TYPE_KEY_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
14
- /**
15
- * The page-document **surface** key — the address of a composed page
16
- * (`home`, `product`, …; `area:<key>` is an area's document, and
17
- * `page:<slug>` / `lp:<slug>` are reserved conventions).
18
- *
19
- * The server applies the same pattern. Module-level on purpose: it is part of
20
- * the persisted-data contract,
21
- * not of the package's curated public surface (`src/index.ts`).
22
- */
23
- export const SURFACE_KEY_PATTERN = /^[a-z0-9][a-z0-9._:-]{0,63}$/;
24
- /** The platform page types a surface can render (`SurfacePageType`), mirrored from the storefront's page types. */
25
- export const PAGE_TYPES = [
26
- "home",
27
- "catalog",
28
- "category",
29
- "collection",
30
- "product",
31
- "page",
32
- "landing",
33
- "checkout",
34
- "thankYou",
35
- ];
36
- /** A `color` field's value: a hex color, `#rgb` or `#rrggbb`. */
37
- export const COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
38
- /**
39
- * A `menu` field's value: the handle of one of the store's menus
40
- * (`main-menu`, `footer`). The server applies the same pattern to menu
41
- * handles; a menu that does not exist reads as no menu.
42
- */
43
- export const MENU_HANDLE_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
44
- export const COLLECTION_HANDLE_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
45
- /**
46
- * A `product` or `category` value: a handle or slug as the store's URLs use
47
- * it. Merchants may type their own, so the rule only keeps out what cannot be
48
- * one URL path segment: whitespace, control characters, `/`, `\`, `?`, `#`
49
- * and `%`, and anything over 255 characters.
50
- */
51
- export const RESOURCE_HANDLE_PATTERN = /^[^\s/\\?#%\u0000-\u001f\u007f]{1,255}$/u;
52
- /**
53
- * A media object key in the store's bucket: a namespace and a name
54
- * (`products/3f2a9c.webp`), segments of letters, digits, `.`, `_` and `-`
55
- * that never start with a dot — so no `..`, no leading slash, no empty
56
- * segment — and at most 512 characters.
57
- */
58
- export const MEDIA_KEY_PATTERN = /^(?=.{3,512}$)[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)+$/;
59
- /**
60
- * A BCP 47 language tag in canonical case: a 2–3 letter language, an optional
61
- * 4-letter script, an optional 2-letter or 3-digit region (`ar`, `zgh`,
62
- * `ar-DZ`, `zh-Hant-TW`, `es-419`). Canonical case keeps map keys unique:
63
- * `fr` and `FR` can never both be present.
64
- */
65
- const LOCALE_TAG_PATTERN = /^[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|\d{3}))?$/;
66
- export function isLocaleTag(value) {
67
- return LOCALE_TAG_PATTERN.test(value);
68
- }
69
- /**
70
- * Whether `value` is a per-language map (`{ ar: "…", fr: "…" }`): a plain
71
- * object with at least one entry, every key a locale tag, every value a string.
72
- */
73
- export function isLocaleMap(value) {
74
- return isLocaleMapOf(value, (entry) => typeof entry === "string");
75
- }
76
- /**
77
- * Whether `value` is a per-language map whose entries pass `isEntry`: a plain
78
- * object with at least one entry, every key a locale tag.
79
- */
80
- export function isLocaleMapOf(value, isEntry) {
81
- if (typeof value !== "object" || value === null || Array.isArray(value))
82
- return false;
83
- const entries = Object.entries(value);
84
- return entries.length > 0 && entries.every(([key, entry]) => isLocaleTag(key) && isEntry(entry));
85
- }
86
- /**
87
- * Whether a `url` field value is safe to render as an `href`: a site path,
88
- * fragment or query, or an `http(s)`, `mailto` or `tel` URL. Rejects
89
- * `javascript:`/`data:` and every other scheme, protocol-relative `//host`,
90
- * backslashes (browsers read `/\host` as `//host`), whitespace and control
91
- * characters, and anything over 2048 characters.
92
- */
93
- export function isSafeLinkTarget(value) {
94
- if (value.length === 0 || value.length > 2048)
95
- return false;
96
- if (/[\s\\\u0000-\u001f\u007f]/.test(value))
97
- return false;
98
- if (value.startsWith("/"))
99
- return !value.startsWith("//");
100
- if (value.startsWith("#") || value.startsWith("?"))
101
- return true;
102
- const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1]?.toLowerCase();
103
- if (scheme === "http" || scheme === "https") {
104
- return /^https?:\/\/[a-z0-9.-]+(:\d{1,5})?([/?#]|$)/i.test(value);
105
- }
106
- if (scheme === "mailto" || scheme === "tel")
107
- return value.length > scheme.length + 1;
108
- return false;
109
- }
6
+ import { COLLECTION_HANDLE_PATTERN, COLOR_PATTERN, isLocaleMap, isLocaleMapOf, isLocaleTag, isSafeLinkTarget, MEDIA_KEY_PATTERN, MENU_HANDLE_PATTERN, PAGE_TYPES, RESOURCE_HANDLE_PATTERN, SURFACE_KEY_PATTERN, THEME_KEY_PATTERN, TYPE_KEY_PATTERN, } from "./patterns.js";
7
+ export { COLLECTION_HANDLE_PATTERN, COLOR_PATTERN, isLocaleMap, isLocaleMapOf, isLocaleTag, isSafeLinkTarget, MEDIA_KEY_PATTERN, MENU_HANDLE_PATTERN, PAGE_TYPES, RESOURCE_HANDLE_PATTERN, SURFACE_KEY_PATTERN, THEME_KEY_PATTERN, TYPE_KEY_PATTERN, };
110
8
  export const JsonValueSchema = z.lazy(() => z.union([
111
9
  z.string(),
112
10
  z
package/dist/style.d.ts CHANGED
@@ -29,7 +29,16 @@ export declare function styleClasses(value: StyleValue | undefined): string;
29
29
  export declare function themeStyleSheet(style: ThemeStyle | undefined): string;
30
30
  /** Why `value` is not a list of color schemes, or `null`. */
31
31
  export declare function colorSchemesProblem(value: unknown): string | null;
32
- /** The class that gives an element a color scheme, or `""` for what is not a scheme id. */
32
+ /**
33
+ * The class every element wearing a color scheme carries, whichever scheme it
34
+ * is. A theme derives its own tokens from the roles on `:root, .wc-scheme`,
35
+ * so they follow the scheme of the nearest element that wears one.
36
+ */
37
+ export declare const SCHEME_MARKER_CLASS = "wc-scheme";
38
+ /**
39
+ * The classes that give an element a color scheme — `wc-scheme
40
+ * wc-scheme-<id>` — or `""` for what is not a scheme id.
41
+ */
33
42
  export declare function schemeClass(id: string | undefined): string;
34
43
  /**
35
44
  * The store's color schemes as CSS: each scheme's roles as custom properties
package/dist/style.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * as custom properties on `.wc-scheme-<id>`, dropping anything that is not
12
12
  * a scheme id and a hex color.
13
13
  */
14
- import { COLOR_PATTERN, isLocaleMap } from "./schemas.js";
14
+ import { COLOR_PATTERN, isLocaleMap } from "./patterns.js";
15
15
  /** The platform's breakpoints, phone first, with the width each starts at. */
16
16
  export const STYLE_BREAKPOINTS = [
17
17
  ["base", 0],
@@ -203,9 +203,18 @@ export function colorSchemesProblem(value) {
203
203
  }
204
204
  return null;
205
205
  }
206
- /** The class that gives an element a color scheme, or `""` for what is not a scheme id. */
206
+ /**
207
+ * The class every element wearing a color scheme carries, whichever scheme it
208
+ * is. A theme derives its own tokens from the roles on `:root, .wc-scheme`,
209
+ * so they follow the scheme of the nearest element that wears one.
210
+ */
211
+ export const SCHEME_MARKER_CLASS = "wc-scheme";
212
+ /**
213
+ * The classes that give an element a color scheme — `wc-scheme
214
+ * wc-scheme-<id>` — or `""` for what is not a scheme id.
215
+ */
207
216
  export function schemeClass(id) {
208
- return typeof id === "string" && COLOR_SCHEME_ID_PATTERN.test(id) ? `wc-scheme-${id}` : "";
217
+ return typeof id === "string" && COLOR_SCHEME_ID_PATTERN.test(id) ? `${SCHEME_MARKER_CLASS} wc-scheme-${id}` : "";
209
218
  }
210
219
  const roleProperty = (role) => `--wc-color-${role.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`;
211
220
  /**
package/dist/types.d.ts CHANGED
@@ -7,8 +7,8 @@
7
7
  */
8
8
  import type { StoreClient } from "@wocart/storefront/server";
9
9
  import type { StoreConfig } from "@wocart/storefront";
10
- /** Every value page data may hold. */
11
- export type JsonValue = string | number | boolean | null | JsonValue[] | {
10
+ /** Every value page data may hold. Lists may be read-only, as the rich-text format's are. */
11
+ export type JsonValue = string | number | boolean | null | readonly JsonValue[] | {
12
12
  [key: string]: JsonValue;
13
13
  };
14
14
  /** A JSON object, as stored in `ComponentInstance.props`. */
@@ -352,46 +352,47 @@ export interface ListItem {
352
352
  }
353
353
  export type RichTextMark = "bold" | "italic" | "underline" | "strike";
354
354
  /** A run of text. `link` is a safe link target, as a `url` field's. */
355
- export interface RichTextText {
355
+ export type RichTextText = {
356
356
  type: "text";
357
357
  text: string;
358
358
  marks?: readonly RichTextMark[];
359
359
  link?: string;
360
- }
360
+ };
361
361
  /** A line break inside a block. */
362
- export interface RichTextBreak {
362
+ export type RichTextBreak = {
363
363
  type: "break";
364
- }
364
+ };
365
365
  export type RichTextInline = RichTextText | RichTextBreak;
366
- export interface RichTextParagraph {
366
+ export type RichTextParagraph = {
367
367
  type: "paragraph";
368
368
  content?: readonly RichTextInline[];
369
- }
369
+ };
370
370
  /** Levels 2–4: the page owns its level-1 heading. */
371
- export interface RichTextHeading {
371
+ export type RichTextHeading = {
372
372
  type: "heading";
373
373
  level: 2 | 3 | 4;
374
374
  content?: readonly RichTextInline[];
375
- }
376
- export interface RichTextListItem {
375
+ };
376
+ export type RichTextListItem = {
377
377
  content?: readonly RichTextInline[];
378
- }
379
- export interface RichTextList {
378
+ };
379
+ export type RichTextList = {
380
380
  type: "list";
381
381
  style: "bullet" | "ordered";
382
382
  items: readonly RichTextListItem[];
383
- }
383
+ };
384
384
  export type RichTextBlock = RichTextParagraph | RichTextHeading | RichTextList;
385
385
  /**
386
386
  * A rich-text value. A closed format: anything not listed here is invalid, so
387
387
  * no stored value can carry markup, scripts or styles. `version` lets the
388
- * format grow; a reader rejects a version it does not know.
388
+ * format grow; a reader rejects a version it does not know. Like every
389
+ * rich-text type it is plain JSON, so a preset's props can hold one.
389
390
  */
390
- export interface RichTextDocument {
391
+ export type RichTextDocument = {
391
392
  type: "doc";
392
393
  version: 1;
393
394
  content: readonly RichTextBlock[];
394
- }
395
+ };
395
396
  /** A rich-text field's stored value: one document, or one per language. */
396
397
  export type RichTextValue = RichTextDocument | Readonly<Record<LocaleCode, RichTextDocument>>;
397
398
  /**
@@ -748,7 +749,7 @@ export interface AreaSpec {
748
749
  * The storefront's page types — what a URL means, with the paths the platform
749
750
  * owns. The same names as `StorefrontPageType` in `@wocart/storefront`.
750
751
  */
751
- export type SurfacePageType = "home" | "catalog" | "category" | "collection" | "product" | "page" | "landing" | "checkout" | "thankYou";
752
+ export type SurfacePageType = "home" | "catalog" | "category" | "collection" | "product" | "page" | "landing" | "checkout" | "thankYou" | "trackOrder";
752
753
  /**
753
754
  * One component as data: a `ComponentDefinition` minus its runtime-only
754
755
  * implementation. `component` (the live component reference) and `load` (the
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 = "2.1.0";
7
+ export declare const THEME_SDK_VERSION = "2.3.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 = "2.1.0";
7
+ export const THEME_SDK_VERSION = "2.3.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wocart/theme",
3
- "version": "2.1.0",
3
+ "version": "2.3.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",
@@ -49,6 +49,14 @@
49
49
  "types": "./dist/preview-bridge.d.ts",
50
50
  "default": "./dist/preview-bridge.js"
51
51
  },
52
+ "./lifecycle": {
53
+ "types": "./dist/lifecycle.d.ts",
54
+ "default": "./dist/lifecycle.js"
55
+ },
56
+ "./messages": {
57
+ "types": "./dist/messages-entry.d.ts",
58
+ "default": "./dist/messages-entry.js"
59
+ },
52
60
  "./package.json": "./package.json"
53
61
  },
54
62
  "files": [
@@ -74,7 +82,7 @@
74
82
  "zod": "^4.4.3"
75
83
  },
76
84
  "peerDependencies": {
77
- "@wocart/storefront": "^1.14.0",
85
+ "@wocart/storefront": "^1.21.0",
78
86
  "astro": "^7.0.0"
79
87
  },
80
88
  "peerDependenciesMeta": {