@wocart/theme 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +434 -2
- package/dist/astro/InstanceView.astro +167 -0
- package/dist/astro/index.d.ts +34 -0
- package/dist/astro/index.js +21 -0
- package/dist/definitions.d.ts +7 -0
- package/dist/definitions.js +363 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +17 -0
- package/dist/instances.d.ts +22 -0
- package/dist/instances.js +295 -0
- package/dist/locales.d.ts +12 -0
- package/dist/locales.js +14 -0
- package/dist/manifest.d.ts +98 -0
- package/dist/manifest.js +308 -0
- package/dist/messages.d.ts +60 -0
- package/dist/messages.js +100 -0
- package/dist/page-data.d.ts +9 -0
- package/dist/page-data.js +154 -0
- package/dist/schemas.d.ts +49 -0
- package/dist/schemas.js +100 -0
- package/dist/types.d.ts +412 -0
- package/dist/types.js +1 -0
- package/package.json +80 -4
package/README.md
CHANGED
|
@@ -1,3 +1,435 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @wocart/theme
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The theme contract for [Wocart](https://github.com/bighadj22/codflow)
|
|
4
|
+
storefronts. A theme declares its components in code — real `.astro`
|
|
5
|
+
components, wrapped with typed, editable fields — and a store's pages are saved
|
|
6
|
+
as JSON documents that reference them. This package validates both sides,
|
|
7
|
+
resolves a saved document against the theme, and renders it with a reference
|
|
8
|
+
Astro renderer.
|
|
9
|
+
|
|
10
|
+
- **Code-owned definitions.** `defineComponent` and `defineTheme` describe
|
|
11
|
+
each component's fields, defaults, slots and server-side data loading.
|
|
12
|
+
- **JSON page documents.** `parsePageData` and `validatePageData` read a saved
|
|
13
|
+
page forgivingly: every problem is reported, and nothing a merchant saved can
|
|
14
|
+
break the rest of the page.
|
|
15
|
+
- **A serializable manifest.** `themeManifest` turns a theme into plain JSON
|
|
16
|
+
that editors and tools can read without running theme code.
|
|
17
|
+
- **Many languages.** Text values may be translated per language, and
|
|
18
|
+
`formatMessage` handles placeholders and CLDR plurals.
|
|
19
|
+
- **A reference renderer.** `InstanceView.astro` renders a document section by
|
|
20
|
+
section through your own components.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npm install @wocart/theme @wocart/storefront
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`@wocart/storefront` is a required peer: the render context carries its
|
|
29
|
+
`StoreConfig` and data loaders receive its `StoreClient`. `astro` (7.x) is an
|
|
30
|
+
optional peer, needed only for the `./astro` renderer. ESM only; tooling needs
|
|
31
|
+
Node 22.12 or newer.
|
|
32
|
+
|
|
33
|
+
## Entry points
|
|
34
|
+
|
|
35
|
+
| Import | What it holds |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `@wocart/theme` | definitions, validation, page documents, resolution, the manifest, languages and messages — framework-neutral |
|
|
38
|
+
| `@wocart/theme/astro` | `createReadCache` and the `RendererIssue` type |
|
|
39
|
+
| `@wocart/theme/astro/InstanceView.astro` | the reference renderer component |
|
|
40
|
+
|
|
41
|
+
## Use
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import {
|
|
45
|
+
defineComponent,
|
|
46
|
+
defineTheme,
|
|
47
|
+
parsePageData,
|
|
48
|
+
validateThemeDefinition,
|
|
49
|
+
} from "@wocart/theme";
|
|
50
|
+
import Hero from "./components/Hero.astro";
|
|
51
|
+
|
|
52
|
+
const hero = defineComponent({
|
|
53
|
+
type: "hero",
|
|
54
|
+
component: Hero, // the real .astro component — untouched
|
|
55
|
+
label: "Hero",
|
|
56
|
+
fields: [{ key: "heroTitle", kind: "text", label: "Title", contentKey: "heroTitle" }],
|
|
57
|
+
capabilities: { insertable: true },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export const theme = defineTheme({ key: "my-theme", version: "1.0.0", components: { hero } });
|
|
61
|
+
|
|
62
|
+
const definitionIssues = validateThemeDefinition(theme); // [] when the catalog is coherent
|
|
63
|
+
const { page, issues } = parsePageData(savedDocument); // parse-and-degrade
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Then render the page in an `.astro` file:
|
|
67
|
+
|
|
68
|
+
```astro
|
|
69
|
+
---
|
|
70
|
+
import InstanceView from "@wocart/theme/astro/InstanceView.astro";
|
|
71
|
+
import { createReadCache } from "@wocart/theme/astro";
|
|
72
|
+
|
|
73
|
+
const reads = createReadCache(); // one per render, shared by every section
|
|
74
|
+
---
|
|
75
|
+
{page?.sections.map((section) => (
|
|
76
|
+
<InstanceView instance={section} theme={theme} context={context}
|
|
77
|
+
storefront={store} reads={reads} />
|
|
78
|
+
))}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`context` is the request's `ThemeRenderContext` (the store config, your
|
|
82
|
+
content pack for the language, `locale`, `isRTL`, `url`), and `store` is the
|
|
83
|
+
server-side `StoreClient` from `@wocart/storefront/server`.
|
|
84
|
+
|
|
85
|
+
Renderer behavior: `resolveInstance` fills defaults and gates composition;
|
|
86
|
+
`load` (optional, server-only) may enrich props and degrades to base props on
|
|
87
|
+
failure; unknown types render nothing — every degradation, including invalid
|
|
88
|
+
props and undeclared children/slots, is reported through `onIssue` (default:
|
|
89
|
+
`console.warn`). The issue contains a stable `code`, `message`, `instanceId`,
|
|
90
|
+
and, for page-data resolution issues, `path`.
|
|
91
|
+
|
|
92
|
+
## Field kinds
|
|
93
|
+
|
|
94
|
+
| Kind | Value | Rules |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| `text`, `textarea` | string | `default` or `contentKey` (from the theme's content pack), never both |
|
|
97
|
+
| `number` | finite number | optional `min`/`max`; a `default` must sit inside them |
|
|
98
|
+
| `boolean` | boolean | |
|
|
99
|
+
| `select` | one of `options` | `default` must be one of the options |
|
|
100
|
+
| `url` | a link target | a site path (`/products`), `#fragment`, `?query`, `http(s)://`, `mailto:` or `tel:`; `javascript:`, `data:`, `//host` and backslash tricks are rejected at assembly and reported on read |
|
|
101
|
+
|
|
102
|
+
Use `url` for every editor-settable `href`, never `text`: the reader drops an
|
|
103
|
+
unsafe saved link (`invalid-prop`) and falls back to the field's default, so a
|
|
104
|
+
document can never inject a script URL.
|
|
105
|
+
|
|
106
|
+
## Languages
|
|
107
|
+
|
|
108
|
+
The contract is built for many languages; a store serves the ones it enables.
|
|
109
|
+
A single-language store is the same contract with one language.
|
|
110
|
+
|
|
111
|
+
- **The render context says which language to render**: `locale` (a BCP 47
|
|
112
|
+
tag: `ar`, `fr`, `ar-DZ`) and optionally `fallbackLocales`, tried in order.
|
|
113
|
+
`content` is the theme's pack for that language.
|
|
114
|
+
- **`text`, `textarea` and `url` values may be translated.** A saved value is
|
|
115
|
+
either one string, the same in every language (a brand name), or one value
|
|
116
|
+
per language: `{ "ar": "توصيل مجاني", "fr": "Livraison gratuite" }`.
|
|
117
|
+
Resolution takes the requested language, then the fallback chain, then
|
|
118
|
+
`content[contentKey]`, then `default`. A missing translation is not an error;
|
|
119
|
+
a malformed map (`FR`, a number, an empty map) is reported as `invalid-prop`.
|
|
120
|
+
Numbers, toggles and choices are shared by every language.
|
|
121
|
+
- **The writer checks every language.** `createInstance` refuses a map with an
|
|
122
|
+
unsafe link or a wrong type in any language, naming that language.
|
|
123
|
+
- **Editor-facing text can be translated**: a component's `label` and `group`,
|
|
124
|
+
a field's `label`, `description` and text `default`, a surface's `label`.
|
|
125
|
+
- **A theme declares the languages its packs cover**:
|
|
126
|
+
`defineTheme({ locales: ["ar", "fr", "en"], … })`. The validator then fails
|
|
127
|
+
any translated label or default that misses one of them, and the manifest
|
|
128
|
+
carries the list so an editor knows what the theme can serve.
|
|
129
|
+
- **`localize(value, locales)`** picks the text for the first preferred
|
|
130
|
+
language; use it wherever a theme or tool shows a translated label.
|
|
131
|
+
|
|
132
|
+
A page-document writer must save translatable copy as a per-language map.
|
|
133
|
+
Saving a plain string declares it the same in every language.
|
|
134
|
+
|
|
135
|
+
## Typed props
|
|
136
|
+
|
|
137
|
+
A component's props come from its definition, so derive them instead of
|
|
138
|
+
writing them twice. `InferProps` types each field by kind (a `select` narrows
|
|
139
|
+
to its options), makes a field with a `default` always present and every other
|
|
140
|
+
field optional, and merges in whatever `load` returns, exactly as the renderer
|
|
141
|
+
does:
|
|
142
|
+
|
|
143
|
+
```astro
|
|
144
|
+
---
|
|
145
|
+
import type { InferProps } from "@wocart/theme";
|
|
146
|
+
import type { productGrid } from "../catalog";
|
|
147
|
+
|
|
148
|
+
const { products = [], limit } = Astro.props as InferProps<typeof productGrid>;
|
|
149
|
+
---
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Define components with `defineComponent` so keys, kinds and options keep their
|
|
153
|
+
literal types. Reusable fields are plain values: declare one once and list it
|
|
154
|
+
in several definitions.
|
|
155
|
+
|
|
156
|
+
## Composition rules (slots)
|
|
157
|
+
|
|
158
|
+
A component declares where children may go; the document says what is there.
|
|
159
|
+
|
|
160
|
+
- `slots: [{ name: "default" }]` accepts children. A component with no
|
|
161
|
+
`default` slot drops children with `undeclared-children`.
|
|
162
|
+
- `multiple: false` keeps the first child and reports the rest
|
|
163
|
+
(`slot-not-multiple`).
|
|
164
|
+
- `allow: ["product-grid", "note"]` limits a slot to those component types.
|
|
165
|
+
Assembly reports a type that is not in the catalog; the reader drops any other
|
|
166
|
+
child with `disallowed-child`. Omit `allow` to accept any type.
|
|
167
|
+
|
|
168
|
+
`allow` is applied before `multiple: false`, so a disallowed child never
|
|
169
|
+
displaces an allowed one. The rules travel in the manifest, so an editor can
|
|
170
|
+
offer only valid insertions.
|
|
171
|
+
|
|
172
|
+
## Theme manifest (serializable catalogue)
|
|
173
|
+
|
|
174
|
+
A `ThemeDefinition` mixes data (types, fields, defaults) with runtime (the live
|
|
175
|
+
component reference, the server-only `load` hook), so tooling cannot read it
|
|
176
|
+
without executing theme modules. `themeManifest(theme)` derives the data-only
|
|
177
|
+
half — a JSON-serializable `ThemeManifest` — for editors, CLIs, and marketplaces:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
import { themeManifest, validateThemeManifest } from "@wocart/theme";
|
|
181
|
+
|
|
182
|
+
const manifest = themeManifest(theme);
|
|
183
|
+
// {
|
|
184
|
+
// contractVersion: 1,
|
|
185
|
+
// key: "my-theme", version: "1.0.0",
|
|
186
|
+
// surfaces: [], // declared surfaces (possibly none)
|
|
187
|
+
// components: { hero: { type, label, group, fields, slots, capabilities } },
|
|
188
|
+
// } — no `component`, no `load`, JSON-round-trip stable.
|
|
189
|
+
|
|
190
|
+
const { manifest: checked, issues } = validateThemeManifest(
|
|
191
|
+
JSON.parse(JSON.stringify(manifest)),
|
|
192
|
+
);
|
|
193
|
+
// `checked` is non-null only for a structurally sound manifest; a non-semver
|
|
194
|
+
// theme `version` is the one advisory case (manifest returned with an
|
|
195
|
+
// `invalid-version` issue). Declare extra surfaces on the theme when it can
|
|
196
|
+
// render them: `defineTheme({ key, version, surfaces: [{ key: "home",
|
|
197
|
+
// label: "Home" }], components })` — declaration only, never enforcement:
|
|
198
|
+
// page parsing and resolution never consult the list.
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Manifest guarantees
|
|
202
|
+
|
|
203
|
+
What a validated manifest promises a consumer:
|
|
204
|
+
|
|
205
|
+
- **Derived, not authored.** The manifest is generated from the theme's
|
|
206
|
+
`ThemeDefinition` by `themeManifest()` — there is exactly one source of truth.
|
|
207
|
+
A hand-written manifest is validated strictly, but never treated as the
|
|
208
|
+
catalogue; the theme's code is.
|
|
209
|
+
- **Data-only.** No component implementations, loaders, functions, symbols,
|
|
210
|
+
bigints, non-finite numbers, or circular references. `validateThemeManifest`
|
|
211
|
+
rejects all of these as `non-serializable`, including `component`/`load` keys
|
|
212
|
+
whose values would otherwise serialize.
|
|
213
|
+
- **Normalized shapes.** `fields` and `slots` are always arrays (absent means
|
|
214
|
+
`[]`); every record key equals its component's `type`. Unknown keys are
|
|
215
|
+
**stripped, not carried** — so an additive field introduced by a newer producer
|
|
216
|
+
under the same `contractVersion` is ignored by this reader rather than
|
|
217
|
+
failing it. That is the forward-compat tradeoff: old readers stay green, but
|
|
218
|
+
they cannot see new metadata until updated.
|
|
219
|
+
- **Validation mirrors assembly.** Relational rules (`contentKey`/`default`
|
|
220
|
+
exclusivity, number `default` within `min`/`max`, select `default` in
|
|
221
|
+
`options`, slot-name shape, key/type agreement) are checked identically by
|
|
222
|
+
`validateThemeDefinition` and `validateThemeManifest`, so a manifest derived
|
|
223
|
+
from a valid theme always validates clean.
|
|
224
|
+
|
|
225
|
+
What it deliberately excludes: implementations and loaders (runtime-only),
|
|
226
|
+
content-pack values (only `contentKey` references travel — the pack itself stays
|
|
227
|
+
theme-owned), and any write/editing affordance.
|
|
228
|
+
|
|
229
|
+
### Versioning policy
|
|
230
|
+
|
|
231
|
+
Three versioned contracts coexist — keep them separate:
|
|
232
|
+
|
|
233
|
+
- `contractVersion` identifies the **manifest schema and interpretation
|
|
234
|
+
contract**. It starts at `1` and increments only on a breaking change to the
|
|
235
|
+
manifest shape or how consumers must interpret it. Ordinary additive,
|
|
236
|
+
backward-compatible metadata does not bump it. An unsupported version fails
|
|
237
|
+
clearly (`unsupported-contract`), never silently.
|
|
238
|
+
- The theme's own `version` (e.g. `"1.0.0"`) versions the **theme**. The runtime
|
|
239
|
+
`ThemeDefinition` contract accepts any non-empty string (unchanged); the
|
|
240
|
+
manifest validator additionally *reports* (never rejects) a non-semver value
|
|
241
|
+
so version hygiene is visible without breaking registration.
|
|
242
|
+
- `PageData.version` (currently `1`) versions the **page-document envelope** —
|
|
243
|
+
untouched by the manifest contract.
|
|
244
|
+
|
|
245
|
+
**One named slot per instance.** The reference renderer fills at most one named
|
|
246
|
+
slot; a second named slot *with content* is reported as
|
|
247
|
+
`multiple-slots-unsupported` and is never silently dropped. This is an Astro
|
|
248
|
+
compiler boundary (a dynamic child expression can lift only one slot attribute
|
|
249
|
+
into the slots-object key), not a temporary limitation — see `SlotDefinition`
|
|
250
|
+
for the escape hatch.
|
|
251
|
+
|
|
252
|
+
## Ownership rules
|
|
253
|
+
|
|
254
|
+
The Theme SDK owns composition mechanics: code-owned catalogs, JSON page data,
|
|
255
|
+
field validation, defaults, slot gating, server-only loads, per-render read
|
|
256
|
+
sharing, and structured degradation reports. A theme owns its Astro markup,
|
|
257
|
+
layout, styles, copy packs, route adapters, and browser interaction wiring.
|
|
258
|
+
The Storefront SDK owns commerce data, browser state machines, server adapters,
|
|
259
|
+
screen-value projections, and platform error contracts.
|
|
260
|
+
|
|
261
|
+
`InstanceView` does not write page documents, mutate catalog definitions, call
|
|
262
|
+
the dashboard, or create themes. Editors, CLIs, publishing, and persistence
|
|
263
|
+
are outside this package.
|
|
264
|
+
|
|
265
|
+
For a real Astro theme, create one `ReadCache` outside the section map, pass a
|
|
266
|
+
request-built `ThemeRenderContext`, and pass the server-only `StoreClient`
|
|
267
|
+
explicitly. A `load` function must read through `reads.get(key, loader)` so
|
|
268
|
+
siblings share a resource within that render. Validate the catalog during the
|
|
269
|
+
theme's build/test path and treat non-empty issue arrays as actionable
|
|
270
|
+
diagnostics.
|
|
271
|
+
|
|
272
|
+
On a templated surface (`/products/[slug]`, `/category/[slug]`), pass
|
|
273
|
+
`params: Astro.params` in the render context. A section reads its route
|
|
274
|
+
resource from `context.params`, under a `reads` key that includes the
|
|
275
|
+
parameter, so the page and every section share one read:
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
load: async ({ storefront, reads, context }) => ({
|
|
279
|
+
product: await reads.get(`product:${context.params?.slug}`, () =>
|
|
280
|
+
storefront.products.get(context.params?.slug ?? ""),
|
|
281
|
+
),
|
|
282
|
+
}),
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## Saved documents
|
|
286
|
+
|
|
287
|
+
A stored page document can be missing, malformed, or built for a catalog that
|
|
288
|
+
has since changed. The SDK reports every degradation and never throws the page
|
|
289
|
+
away. Which document to render instead is a product decision; the recommended
|
|
290
|
+
policy is:
|
|
291
|
+
|
|
292
|
+
1. **No saved document** → render the theme's shipped default document.
|
|
293
|
+
2. **Envelope invalid** (`parsePageData` returns `page: null`) → render the shipped default.
|
|
294
|
+
3. **Saved sections map to no catalog type at all** (a theme switch left nothing
|
|
295
|
+
renderable) → render the shipped default.
|
|
296
|
+
4. **Partial overlap** → render what maps. Unknown types render nothing and are
|
|
297
|
+
reported through `onIssue`.
|
|
298
|
+
5. **An empty saved document** → honor it. A page the merchant emptied renders empty.
|
|
299
|
+
|
|
300
|
+
The SDK does not ship this policy as a function, because the shipped default
|
|
301
|
+
document is theme content.
|
|
302
|
+
|
|
303
|
+
## Content and copy
|
|
304
|
+
|
|
305
|
+
The theme owns its content. The SDK does not model a content pack, a taxonomy,
|
|
306
|
+
or a copy system — it only reads the resolved pack the request hands it.
|
|
307
|
+
|
|
308
|
+
- **`ThemeRenderContext.content`** is an opaque, flat `Record<string, string>` —
|
|
309
|
+
the theme's pack with store overrides already merged.
|
|
310
|
+
- **`FieldDefinition.contentKey`** names a key *in that map*. It is the only
|
|
311
|
+
bridge between the contract and the theme's copy.
|
|
312
|
+
- **The precedence chain** is: instance value → `content[contentKey]` →
|
|
313
|
+
`field.default` → omit (the component's own default applies last). A field
|
|
314
|
+
with neither `contentKey` nor `default` is simply absent from `props`.
|
|
315
|
+
- **Typos are checkable, not inferred.** Pass the pack's key set to
|
|
316
|
+
`validateThemeDefinition(theme, { contentKeys })` and every `contentKey` that
|
|
317
|
+
is not in it is reported as `unknown-content-key`. Optional and additive:
|
|
318
|
+
with no options the validator behaves exactly as before, and passing an empty
|
|
319
|
+
array reports every declared key.
|
|
320
|
+
|
|
321
|
+
Both ways of consuming copy are legitimate, and the theme picks: a component can
|
|
322
|
+
take per-field props, or read the whole pack and overlay the resolved values
|
|
323
|
+
itself. The SDK deliberately ships neither helper — that would promote one
|
|
324
|
+
theme's implementation choice into platform API.
|
|
325
|
+
|
|
326
|
+
### Messages: placeholders and plurals
|
|
327
|
+
|
|
328
|
+
`formatMessage(pack, key, values, { locale, formatNumber? })` is the SDK's one
|
|
329
|
+
message rule — Shopify's locale-file model on the platform's own CLDR data
|
|
330
|
+
(`Intl.PluralRules`), no message-format dependency:
|
|
331
|
+
|
|
332
|
+
- `{name}` placeholders: every occurrence is filled, numbers are formatted for
|
|
333
|
+
the language (or by `formatNumber`), an unknown placeholder stays visible.
|
|
334
|
+
- A plural message is flat keys, one per CLDR form — `cartItems_one`,
|
|
335
|
+
`cartItems_few`, `cartItems_other` — chosen by `values.count`; `_other` is
|
|
336
|
+
the fallback, and an explicit `_zero` is used for 0 in any language.
|
|
337
|
+
- `pluralFormIssues(pack, locale)` lists the forms a pack is missing for its
|
|
338
|
+
language, or carries but never uses — run it over every pack in a test.
|
|
339
|
+
- `messageForms(pack, key)` collects one message's keys to hand a browser
|
|
340
|
+
script.
|
|
341
|
+
|
|
342
|
+
Packs stay `Record<string, string>`, so store overrides and the editor treat
|
|
343
|
+
plural forms like any other key.
|
|
344
|
+
|
|
345
|
+
## Capabilities are advisory
|
|
346
|
+
|
|
347
|
+
`ComponentDefinition.capabilities` is metadata for an **editor/CLI insertion
|
|
348
|
+
surface**, not renderer enforcement. Neither `parsePageData` nor
|
|
349
|
+
`resolveInstance` consults it: a saved document containing a component marked
|
|
350
|
+
`insertable: false` still renders, because the render path never second-guesses
|
|
351
|
+
a document that was already saved. Any future enforcement belongs at insertion
|
|
352
|
+
time, in the writer.
|
|
353
|
+
|
|
354
|
+
## Compatibility
|
|
355
|
+
|
|
356
|
+
Page data is a persisted format, and the reader is deliberately forgiving — but
|
|
357
|
+
forgiveness is degradation with a report, not silent migration. There is no
|
|
358
|
+
migration engine, by design; what follows is the whole policy:
|
|
359
|
+
|
|
360
|
+
- **Envelope version.** `PageData.version` is `1`, and only `1` parses. Unknown
|
|
361
|
+
*top-level* keys are carried through verbatim (a writer may record metadata
|
|
362
|
+
without a version bump); unknown keys *inside* an instance are stripped.
|
|
363
|
+
- **Removed or renamed component type.** The section reports `unknown-type` and
|
|
364
|
+
renders nothing; siblings survive. Never rename a `type` once documents
|
|
365
|
+
reference it — treat it as a breaking change for saved pages.
|
|
366
|
+
- **Renamed, removed, or retyped field.** A stored value for an undeclared key
|
|
367
|
+
reports `unknown-prop` and is ignored; a value that fails the field's kind or
|
|
368
|
+
bounds reports `invalid-prop` and falls through the defaults chain
|
|
369
|
+
(instance → `content[contentKey]` → `field.default` → omit). A `required`
|
|
370
|
+
field with no resolvable value reports `missing-required-prop`.
|
|
371
|
+
- **Removed slot or narrowed `allow` list.** Saved children that no longer fit
|
|
372
|
+
report `unknown-slot` or `disallowed-child` and are dropped. Widen a slot's
|
|
373
|
+
rules freely, but narrow them only as a breaking change for saved pages.
|
|
374
|
+
- **Duplicate instance ids.** First wins depth-first; later duplicates are
|
|
375
|
+
dropped with `duplicate-id`. Generate stable unique ids at creation.
|
|
376
|
+
- **What never breaks the page.** Any section-level failure preserves the rest
|
|
377
|
+
of the page. Only an envelope failure (`version`, `surface`, `sections`
|
|
378
|
+
shape) returns `page: null`.
|
|
379
|
+
- **Theme versions.** The runtime accepts any non-empty `version` string;
|
|
380
|
+
stick to semver so the manifest validator stays quiet and future tooling can
|
|
381
|
+
reason about upgrades.
|
|
382
|
+
|
|
383
|
+
## Testing your theme
|
|
384
|
+
|
|
385
|
+
A theme proves it honors the contract with four checks in its own test suite:
|
|
386
|
+
|
|
387
|
+
1. **The catalog assembles clean**:
|
|
388
|
+
`expect(validateThemeDefinition(theme, { contentKeys: Object.keys(pack) })).toEqual([])`.
|
|
389
|
+
2. **The manifest survives a JSON round trip**:
|
|
390
|
+
`validateThemeManifest(JSON.parse(JSON.stringify(themeManifest(theme))))`
|
|
391
|
+
returns no issues.
|
|
392
|
+
3. **Every shipped document validates**:
|
|
393
|
+
`validatePageData(theme, document, { content: pack, locales: [locale] })`
|
|
394
|
+
returns no issues for each default page the theme ships.
|
|
395
|
+
4. **Every shipped document renders**: render each section through
|
|
396
|
+
`InstanceView` with Astro's Container API, one `createReadCache()` per page
|
|
397
|
+
and a stand-in `StoreClient`, and assert that `onIssue` receives nothing
|
|
398
|
+
unexpected:
|
|
399
|
+
|
|
400
|
+
```ts
|
|
401
|
+
import { experimental_AstroContainer as AstroContainer } from "astro/container";
|
|
402
|
+
import { createReadCache, type RendererIssue } from "@wocart/theme/astro";
|
|
403
|
+
import InstanceView from "@wocart/theme/astro/InstanceView.astro";
|
|
404
|
+
import type { StoreClient } from "@wocart/storefront/server";
|
|
405
|
+
|
|
406
|
+
const container = await AstroContainer.create();
|
|
407
|
+
const reads = createReadCache();
|
|
408
|
+
const issues: RendererIssue[] = [];
|
|
409
|
+
const storefront = { products: { list: async () => products } } as unknown as StoreClient;
|
|
410
|
+
|
|
411
|
+
for (const section of page.sections) {
|
|
412
|
+
await container.renderToString(InstanceView, {
|
|
413
|
+
props: { instance: section, theme, context, storefront, reads, onIssue: (i) => issues.push(i) },
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
expect(issues).toEqual([]);
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Also test the degradations you rely on, such as a failing `load` still
|
|
420
|
+
rendering base props.
|
|
421
|
+
|
|
422
|
+
`createInstance` refuses to build an instance the reader would flag: wrong-typed
|
|
423
|
+
values, unknown keys, and a `required` field with no value, default, or
|
|
424
|
+
`contentKey`.
|
|
425
|
+
|
|
426
|
+
## Versioning
|
|
427
|
+
|
|
428
|
+
This package follows [semantic versioning](https://semver.org/): breaking
|
|
429
|
+
changes ship only in a new major version and are listed in the
|
|
430
|
+
[changelog](./CHANGELOG.md). The manifest's `contractVersion` and the page
|
|
431
|
+
document's `version` are versioned separately, as described above.
|
|
432
|
+
|
|
433
|
+
## License
|
|
434
|
+
|
|
435
|
+
Apache-2.0 — see [LICENSE](./LICENSE) and [NOTICE](./NOTICE).
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The reference renderer: one recursive component that turns a page-data
|
|
4
|
+
* instance into the real theme component it names.
|
|
5
|
+
*
|
|
6
|
+
* instance → definition (catalog) → resolveInstance (chain + gating)
|
|
7
|
+
* → definition.load (optional, server-only, degrading)
|
|
8
|
+
* → <Component {...props}> with children + one named slot
|
|
9
|
+
*
|
|
10
|
+
* Named slots: the Astro compiler lifts the first slot-attribute expression of
|
|
11
|
+
* a dynamic child expression into the slots-object key, evaluated in component
|
|
12
|
+
* scope — so exactly ONE named slot per instance can be routed generically.
|
|
13
|
+
* Extra named slots with content are reported, never silently dropped.
|
|
14
|
+
*
|
|
15
|
+
* Server-only. No hydration, no directives, no second component system —
|
|
16
|
+
* the theme's own `.astro` files do all rendering.
|
|
17
|
+
*/
|
|
18
|
+
import { resolveInstance, type RendererIssue } from "./index.js";
|
|
19
|
+
import type {
|
|
20
|
+
ComponentInstance,
|
|
21
|
+
ReadCache,
|
|
22
|
+
ThemeDefinition,
|
|
23
|
+
ThemeRenderContext,
|
|
24
|
+
} from "../types.js";
|
|
25
|
+
import type { StoreClient } from "@wocart/storefront/server";
|
|
26
|
+
|
|
27
|
+
interface Props {
|
|
28
|
+
instance: ComponentInstance;
|
|
29
|
+
theme: ThemeDefinition;
|
|
30
|
+
context: ThemeRenderContext;
|
|
31
|
+
storefront: StoreClient;
|
|
32
|
+
reads: ReadCache;
|
|
33
|
+
/** Overrides the default reporter (console.warn with a stable prefix). */
|
|
34
|
+
onIssue?: ((issue: RendererIssue) => void) | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const { instance, theme, context, storefront, reads, onIssue } = Astro.props as Props;
|
|
38
|
+
|
|
39
|
+
const report = (issue: RendererIssue): void => {
|
|
40
|
+
if (onIssue) {
|
|
41
|
+
onIssue(issue);
|
|
42
|
+
} else {
|
|
43
|
+
console.warn(`[@wocart/theme/astro] ${issue.code}: ${issue.message}`);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const definition = Object.hasOwn(theme.components, instance.type)
|
|
48
|
+
? theme.components[instance.type]
|
|
49
|
+
: undefined;
|
|
50
|
+
if (definition === undefined) {
|
|
51
|
+
report({
|
|
52
|
+
code: "unknown-type",
|
|
53
|
+
message: `"${instance.type}" is not in the theme catalog; nothing rendered`,
|
|
54
|
+
instanceId: instance.id,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The catalog holds the real component reference as `unknown` (the core is
|
|
59
|
+
// framework-neutral). Here it is narrowed to the shape Astro renders.
|
|
60
|
+
type RenderableComponent = (props: Record<string, unknown>) => unknown;
|
|
61
|
+
|
|
62
|
+
let Component: RenderableComponent | undefined;
|
|
63
|
+
let renderProps: Record<string, unknown> = {};
|
|
64
|
+
let children: ComponentInstance[] = [];
|
|
65
|
+
let firstNamedSlotName: string | undefined;
|
|
66
|
+
let firstNamedSlotChildren: ComponentInstance[] = [];
|
|
67
|
+
|
|
68
|
+
if (definition !== undefined) {
|
|
69
|
+
// Only object/function references reach `<Component>`: `null` keeps its
|
|
70
|
+
// historical `missing-component` degradation, and primitives (usually a
|
|
71
|
+
// string left behind instead of an import) are reported rather than rendered
|
|
72
|
+
// into a meaningless tag or thrown at render time.
|
|
73
|
+
const reference = definition.component;
|
|
74
|
+
if (
|
|
75
|
+
reference === undefined ||
|
|
76
|
+
reference === null ||
|
|
77
|
+
(typeof reference !== "object" && typeof reference !== "function")
|
|
78
|
+
) {
|
|
79
|
+
report({
|
|
80
|
+
code: "missing-component",
|
|
81
|
+
message: `"${instance.type}" has no usable component reference; nothing rendered`,
|
|
82
|
+
instanceId: instance.id,
|
|
83
|
+
});
|
|
84
|
+
} else {
|
|
85
|
+
const resolved = resolveInstance(definition, instance, {
|
|
86
|
+
content: context.content,
|
|
87
|
+
locales: [context.locale, ...(context.fallbackLocales ?? [])],
|
|
88
|
+
});
|
|
89
|
+
for (const issue of resolved.issues) {
|
|
90
|
+
report({
|
|
91
|
+
code: issue.code,
|
|
92
|
+
message: issue.message,
|
|
93
|
+
instanceId: instance.id,
|
|
94
|
+
path: issue.path,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let loaded: Record<string, unknown> = {};
|
|
99
|
+
if (definition.load !== undefined) {
|
|
100
|
+
try {
|
|
101
|
+
loaded = await definition.load({
|
|
102
|
+
storefront,
|
|
103
|
+
context,
|
|
104
|
+
props: resolved.props,
|
|
105
|
+
reads,
|
|
106
|
+
});
|
|
107
|
+
} catch (error) {
|
|
108
|
+
report({
|
|
109
|
+
code: "load-failed",
|
|
110
|
+
message: `load failed for "${instance.type}": ${
|
|
111
|
+
error instanceof Error ? error.message : String(error)
|
|
112
|
+
}`,
|
|
113
|
+
instanceId: instance.id,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
Component = definition.component as RenderableComponent;
|
|
119
|
+
renderProps = { ...resolved.props, ...loaded };
|
|
120
|
+
children = resolved.children;
|
|
121
|
+
|
|
122
|
+
const namedEntries = Object.entries(resolved.slots);
|
|
123
|
+
const first = namedEntries[0];
|
|
124
|
+
if (first !== undefined) {
|
|
125
|
+
firstNamedSlotName = first[0];
|
|
126
|
+
firstNamedSlotChildren = first[1];
|
|
127
|
+
}
|
|
128
|
+
for (const [name] of namedEntries.slice(1)) {
|
|
129
|
+
report({
|
|
130
|
+
code: "multiple-slots-unsupported",
|
|
131
|
+
message: `"${instance.type}" has content for named slot "${name}", but the reference renderer fills one named slot per component (Astro compiler constraint); it was not rendered`,
|
|
132
|
+
instanceId: instance.id,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
---
|
|
138
|
+
{
|
|
139
|
+
Component !== undefined ? (
|
|
140
|
+
<Component {...renderProps}>
|
|
141
|
+
{children.map((child) => (
|
|
142
|
+
<Astro.self
|
|
143
|
+
instance={child}
|
|
144
|
+
theme={theme}
|
|
145
|
+
context={context}
|
|
146
|
+
storefront={storefront}
|
|
147
|
+
reads={reads}
|
|
148
|
+
onIssue={onIssue}
|
|
149
|
+
/>
|
|
150
|
+
))}
|
|
151
|
+
{firstNamedSlotName !== undefined && (
|
|
152
|
+
<Fragment slot={firstNamedSlotName}>
|
|
153
|
+
{firstNamedSlotChildren.map((child) => (
|
|
154
|
+
<Astro.self
|
|
155
|
+
instance={child}
|
|
156
|
+
theme={theme}
|
|
157
|
+
context={context}
|
|
158
|
+
storefront={storefront}
|
|
159
|
+
reads={reads}
|
|
160
|
+
onIssue={onIssue}
|
|
161
|
+
/>
|
|
162
|
+
))}
|
|
163
|
+
</Fragment>
|
|
164
|
+
)}
|
|
165
|
+
</Component>
|
|
166
|
+
) : null
|
|
167
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@wocart/theme/astro` — the Astro-specific side of the theme SDK.
|
|
3
|
+
*
|
|
4
|
+
* Exports:
|
|
5
|
+
* - `createReadCache` — the per-render read sharing the contract requires
|
|
6
|
+
* (one read per resource per render).
|
|
7
|
+
* - `RendererIssue` — the renderer-specific degradations, reportable via
|
|
8
|
+
* `InstanceView`'s `onIssue` prop.
|
|
9
|
+
* - `resolveInstance` — re-exported so `InstanceView.astro`'s co-located
|
|
10
|
+
* import (`./index.js`) resolves identically in the dev source layout and in
|
|
11
|
+
* the published `dist/astro` layout. Same function as the
|
|
12
|
+
* root export — never a second implementation.
|
|
13
|
+
*
|
|
14
|
+
* The renderer component itself:
|
|
15
|
+
* `@wocart/theme/astro/InstanceView.astro`.
|
|
16
|
+
*/
|
|
17
|
+
import type { PageDataIssueCode, ReadCache } from "../types.js";
|
|
18
|
+
export { resolveInstance } from "../instances.js";
|
|
19
|
+
/** One renderer-specific degradation, reported through `onIssue`. */
|
|
20
|
+
export interface RendererIssue {
|
|
21
|
+
code: PageDataIssueCode | "missing-component" | "load-failed" | "multiple-slots-unsupported";
|
|
22
|
+
message: string;
|
|
23
|
+
/** The instance the degradation belongs to. */
|
|
24
|
+
instanceId: string;
|
|
25
|
+
/** The resolved instance path when the issue came from page-data gating. */
|
|
26
|
+
path?: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The per-render `ReadCache`: the first call for a key
|
|
30
|
+
* runs the loader; later calls in the same render reuse the in-flight or
|
|
31
|
+
* settled value — including a rejection (failures are shared, not retried per
|
|
32
|
+
* section). Scope this to ONE page render; it makes no cross-request promises.
|
|
33
|
+
*/
|
|
34
|
+
export declare function createReadCache(): ReadCache;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export { resolveInstance } from "../instances.js";
|
|
2
|
+
/**
|
|
3
|
+
* The per-render `ReadCache`: the first call for a key
|
|
4
|
+
* runs the loader; later calls in the same render reuse the in-flight or
|
|
5
|
+
* settled value — including a rejection (failures are shared, not retried per
|
|
6
|
+
* section). Scope this to ONE page render; it makes no cross-request promises.
|
|
7
|
+
*/
|
|
8
|
+
export function createReadCache() {
|
|
9
|
+
const entries = new Map();
|
|
10
|
+
return {
|
|
11
|
+
get(key, load) {
|
|
12
|
+
const existing = entries.get(key);
|
|
13
|
+
if (existing !== undefined) {
|
|
14
|
+
return existing;
|
|
15
|
+
}
|
|
16
|
+
const pending = Promise.resolve().then(load);
|
|
17
|
+
entries.set(key, pending);
|
|
18
|
+
return pending;
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ComponentDefinition, ThemeDefinition, ThemeDefinitionIssue, ThemeValidationOptions } from "./types.js";
|
|
2
|
+
/** Identity helper: returns the definition unchanged so literal types survive. */
|
|
3
|
+
export declare function defineComponent<const T extends ComponentDefinition>(definition: T): T;
|
|
4
|
+
/** Identity helper for the catalog. */
|
|
5
|
+
export declare function defineTheme<const T extends ThemeDefinition>(definition: T): T;
|
|
6
|
+
/** Check the whole catalog: key/type identity, field rules, slot rules. */
|
|
7
|
+
export declare function validateThemeDefinition(theme: ThemeDefinition, options?: ThemeValidationOptions): ThemeDefinitionIssue[];
|