@wocart/theme 2.0.0 → 2.2.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,48 @@ 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.2.0 — 2026-10-24
9
+
10
+ What every theme used to write itself is now the package's: binding browser
11
+ behaviour through page and section swaps, choosing what an editor's preview
12
+ shows, and a marker for color schemes. Browser code takes only what builds no
13
+ validator.
14
+
15
+ ### Added
16
+
17
+ - `@wocart/theme/lifecycle`: `bindEach(selector, bind)` binds each element
18
+ once — when the page is ready, after Astro swaps a page, after the editor
19
+ swaps a section — and runs a bind's teardown when its element leaves;
20
+ `onEachPage(boot)`; `PREVIEW_SECTION_LOAD_EVENT`, without the bridge.
21
+ - `@wocart/theme/messages`: the message functions for browser code.
22
+ `pluralMessageKeys` is now exported, here and from the root.
23
+ - `previewTarget(theme, draft, client)` in `@wocart/theme/astro`: the surface,
24
+ route parameters and document an editor's preview renders, chosen by page
25
+ type, or null for a surface the theme does not declare.
26
+ - `SCHEME_MARKER_CLASS`: `schemeClass(id)` gives `wc-scheme wc-scheme-<id>`,
27
+ so a theme derives its tokens on `:root, .wc-scheme`.
28
+ - `defineCapabilities(capabilities)` for capabilities several components share,
29
+ and `componentsOf(theme)` to iterate a theme's components as definitions.
30
+
31
+ ### Changed
32
+
33
+ - The rich-text types are plain JSON: a preset or a default holds a document
34
+ without a cast. `JsonValue` accepts read-only lists.
35
+ - The style and rich-text helpers no longer bring Zod into a browser bundle.
36
+ - The `@wocart/storefront` peer is `^1.20.0`: `previewTarget` uses its route
37
+ matcher and default collections.
38
+
39
+ ## 2.1.0 — 2026-10-21
40
+
41
+ The release check now sees where a section may be placed.
42
+
43
+ ### Changed
44
+
45
+ - `diffThemeManifests` treats a component that is no longer insertable, or that
46
+ is limited to a narrower list of surfaces, as a breaking change. Widening a
47
+ component's surfaces, or making it insertable again, is additive. Before, a
48
+ theme could narrow where a section sits without the release check noticing.
49
+
8
50
  ## 2.0.0 — 2026-10-20
9
51
 
10
52
  Blocks: components place other components inside them, some fixed by the
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
+ }
@@ -82,6 +82,7 @@ export function diffThemeManifests(previous, next) {
82
82
  }
83
83
  function compareComponents(previous, next, path, breaking, additive) {
84
84
  compareFields(previous.fields, next.fields, `${path}.fields`, breaking, additive);
85
+ compareCapabilities(previous.capabilities, next.capabilities, `${path}.capabilities`, breaking, additive);
85
86
  const nextSlots = new Map(next.slots.map((slot) => [slot.name, slot]));
86
87
  for (const slot of previous.slots) {
87
88
  const after = nextSlots.get(slot.name);
@@ -108,6 +109,37 @@ function compareComponents(previous, next, path, breaking, additive) {
108
109
  additive(`${path}.fixed.${block.name}`, `fixed block "${block.name}" was added`);
109
110
  }
110
111
  }
112
+ /**
113
+ * Where an editor may place a component. A narrowed rule is breaking: a section
114
+ * saved on a surface it no longer covers may stop rendering there, once a theme
115
+ * renders each surface from the components it covers, and it cannot be placed
116
+ * again. Widening only adds.
117
+ */
118
+ function compareCapabilities(previous, next, path, breaking, additive) {
119
+ const wasInsertable = previous?.insertable !== false;
120
+ const isInsertable = next?.insertable !== false;
121
+ if (wasInsertable && !isInsertable)
122
+ breaking(`${path}.insertable`, "no longer insertable: an editor cannot place it again");
123
+ else if (!wasInsertable && isInsertable)
124
+ additive(`${path}.insertable`, "insertable again");
125
+ const before = previous?.surfaces;
126
+ const after = next?.surfaces;
127
+ if (after === undefined) {
128
+ if (before !== undefined)
129
+ additive(`${path}.surfaces`, "now on any surface");
130
+ return;
131
+ }
132
+ if (before === undefined) {
133
+ breaking(`${path}.surfaces`, `now only on ${after.join(", ")}`);
134
+ return;
135
+ }
136
+ const removed = before.filter((surface) => !after.includes(surface));
137
+ if (removed.length > 0)
138
+ breaking(`${path}.surfaces`, `no longer on ${removed.join(", ")}`);
139
+ const added = after.filter((surface) => !before.includes(surface));
140
+ if (added.length > 0)
141
+ additive(`${path}.surfaces`, `now also on ${added.join(", ")}`);
142
+ }
111
143
  /** A list's cap and required types: a lower cap hides saved instances; a new requirement only adds. */
112
144
  function compareCaps(previous, next, path, what, breaking, additive) {
113
145
  if (next.max !== undefined && (previous.max === undefined || next.max < previous.max)) {
@@ -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"];
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;