@wocart/theme 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,480 @@
1
- # Temporary Holding Version
1
+ # @wocart/theme
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ | `color` | `#rgb` or `#rrggbb` | anything else (a color name, `url(…)`) is rejected at assembly and reported on read |
101
+ | `menu` | a menu handle (`main-menu`) | lowercase letters, digits and hyphens; the store's menu is read with `@wocart/storefront`'s `menus.get(handle)` |
102
+ | `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 |
103
+
104
+ Use `url` for every editor-settable `href`, never `text`: the reader drops an
105
+ unsafe saved link (`invalid-prop`) and falls back to the field's default, so a
106
+ document can never inject a script URL.
107
+
108
+ ## Languages
109
+
110
+ The contract is built for many languages; a store serves the ones it enables.
111
+ A single-language store is the same contract with one language.
112
+
113
+ - **The render context says which language to render**: `locale` (a BCP 47
114
+ tag: `ar`, `fr`, `ar-DZ`) and optionally `fallbackLocales`, tried in order.
115
+ `content` is the theme's pack for that language.
116
+ - **`text`, `textarea` and `url` values may be translated.** A saved value is
117
+ either one string, the same in every language (a brand name), or one value
118
+ per language: `{ "ar": "توصيل مجاني", "fr": "Livraison gratuite" }`.
119
+ Resolution takes the requested language, then the fallback chain, then
120
+ `content[contentKey]`, then `default`. A missing translation is not an error;
121
+ a malformed map (`FR`, a number, an empty map) is reported as `invalid-prop`.
122
+ Numbers, toggles and choices are shared by every language.
123
+ - **The writer checks every language.** `createInstance` refuses a map with an
124
+ unsafe link or a wrong type in any language, naming that language.
125
+ - **Editor-facing text can be translated**: a component's `label` and `group`,
126
+ a field's `label`, `description` and text `default`, a surface's `label`.
127
+ - **A theme declares the languages its packs cover**:
128
+ `defineTheme({ locales: ["ar", "fr", "en"], … })`. The validator then fails
129
+ any translated label or default that misses one of them, and the manifest
130
+ carries the list so an editor knows what the theme can serve.
131
+ - **`localize(value, locales)`** picks the text for the first preferred
132
+ language; use it wherever a theme or tool shows a translated label.
133
+
134
+ A page-document writer must save translatable copy as a per-language map.
135
+ Saving a plain string declares it the same in every language.
136
+
137
+ ## Typed props
138
+
139
+ A component's props come from its definition, so derive them instead of
140
+ writing them twice. `InferProps` types each field by kind (a `select` narrows
141
+ to its options), makes a field with a `default` always present and every other
142
+ field optional, and merges in whatever `load` returns, exactly as the renderer
143
+ does:
144
+
145
+ ```astro
146
+ ---
147
+ import type { InferProps } from "@wocart/theme";
148
+ import type { productGrid } from "../catalog";
149
+
150
+ const { products = [], limit } = Astro.props as InferProps<typeof productGrid>;
151
+ ---
152
+ ```
153
+
154
+ Define components with `defineComponent` so keys, kinds and options keep their
155
+ literal types. Reusable fields are plain values: declare one once and list it
156
+ in several definitions.
157
+
158
+ ## Composition rules (slots)
159
+
160
+ A component declares where children may go; the document says what is there.
161
+
162
+ - `slots: [{ name: "default" }]` accepts children. A component with no
163
+ `default` slot drops children with `undeclared-children`.
164
+ - `multiple: false` keeps the first child and reports the rest
165
+ (`slot-not-multiple`).
166
+ - `allow: ["product-grid", "note"]` limits a slot to those component types.
167
+ Assembly reports a type that is not in the catalog; the reader drops any other
168
+ child with `disallowed-child`. Omit `allow` to accept any type.
169
+
170
+ `allow` is applied before `multiple: false`, so a disallowed child never
171
+ displaces an allowed one. The rules travel in the manifest, so an editor can
172
+ offer only valid insertions.
173
+
174
+ ## Theme manifest (serializable catalogue)
175
+
176
+ A `ThemeDefinition` mixes data (types, fields, defaults) with runtime (the live
177
+ component reference, the server-only `load` hook), so tooling cannot read it
178
+ without executing theme modules. `themeManifest(theme)` derives the data-only
179
+ half — a JSON-serializable `ThemeManifest` — for editors, CLIs, and marketplaces:
180
+
181
+ ```ts
182
+ import { themeManifest, validateThemeManifest } from "@wocart/theme";
183
+
184
+ const manifest = themeManifest(theme);
185
+ // {
186
+ // contractVersion: 1,
187
+ // key: "my-theme", version: "1.0.0",
188
+ // surfaces: [], // declared surfaces (possibly none)
189
+ // components: { hero: { type, label, group, fields, slots, capabilities } },
190
+ // } — no `component`, no `load`, JSON-round-trip stable.
191
+
192
+ const { manifest: checked, issues } = validateThemeManifest(
193
+ JSON.parse(JSON.stringify(manifest)),
194
+ );
195
+ // `checked` is non-null only for a structurally sound manifest; a non-semver
196
+ // theme `version` is the one advisory case (manifest returned with an
197
+ // `invalid-version` issue). Declare extra surfaces on the theme when it can
198
+ // render them: `defineTheme({ key, version, surfaces: [{ key: "home",
199
+ // label: "Home" }], components })` — declaration only, never enforcement:
200
+ // page parsing and resolution never consult the list.
201
+ ```
202
+
203
+ ### Manifest guarantees
204
+
205
+ What a validated manifest promises a consumer:
206
+
207
+ - **Derived, not authored.** The manifest is generated from the theme's
208
+ `ThemeDefinition` by `themeManifest()` — there is exactly one source of truth.
209
+ A hand-written manifest is validated strictly, but never treated as the
210
+ catalogue; the theme's code is.
211
+ - **Data-only.** No component implementations, loaders, functions, symbols,
212
+ bigints, non-finite numbers, or circular references. `validateThemeManifest`
213
+ rejects all of these as `non-serializable`, including `component`/`load` keys
214
+ whose values would otherwise serialize.
215
+ - **Normalized shapes.** `fields` and `slots` are always arrays (absent means
216
+ `[]`); every record key equals its component's `type`. Unknown keys are
217
+ **stripped, not carried** — so an additive field introduced by a newer producer
218
+ under the same `contractVersion` is ignored by this reader rather than
219
+ failing it. That is the forward-compat tradeoff: old readers stay green, but
220
+ they cannot see new metadata until updated.
221
+ - **Validation mirrors assembly.** Relational rules (`contentKey`/`default`
222
+ exclusivity, number `default` within `min`/`max`, select `default` in
223
+ `options`, slot-name shape, key/type agreement) are checked identically by
224
+ `validateThemeDefinition` and `validateThemeManifest`, so a manifest derived
225
+ from a valid theme always validates clean.
226
+
227
+ What it deliberately excludes: implementations and loaders (runtime-only),
228
+ content-pack values (only `contentKey` references travel — the pack itself stays
229
+ theme-owned), and any write/editing affordance.
230
+
231
+ ### Versioning policy
232
+
233
+ Three versioned contracts coexist — keep them separate:
234
+
235
+ - `contractVersion` identifies the **manifest schema and interpretation
236
+ contract**. It starts at `1` and increments only on a breaking change to the
237
+ manifest shape or how consumers must interpret it. Ordinary additive,
238
+ backward-compatible metadata does not bump it. An unsupported version fails
239
+ clearly (`unsupported-contract`), never silently.
240
+ - The theme's own `version` (e.g. `"1.0.0"`) versions the **theme**. The runtime
241
+ `ThemeDefinition` contract accepts any non-empty string (unchanged); the
242
+ manifest validator additionally *reports* (never rejects) a non-semver value
243
+ so version hygiene is visible without breaking registration.
244
+ - `PageData.version` (currently `1`) versions the **page-document envelope** —
245
+ untouched by the manifest contract.
246
+
247
+ **One named slot per instance.** The reference renderer fills at most one named
248
+ slot; a second named slot *with content* is reported as
249
+ `multiple-slots-unsupported` and is never silently dropped. This is an Astro
250
+ compiler boundary (a dynamic child expression can lift only one slot attribute
251
+ into the slots-object key), not a temporary limitation — see `SlotDefinition`
252
+ for the escape hatch.
253
+
254
+ ## Ownership rules
255
+
256
+ The Theme SDK owns composition mechanics: code-owned catalogs, JSON page data,
257
+ field validation, defaults, slot gating, server-only loads, per-render read
258
+ sharing, and structured degradation reports. A theme owns its Astro markup,
259
+ layout, styles, copy packs, route adapters, and browser interaction wiring.
260
+ The Storefront SDK owns commerce data, browser state machines, server adapters,
261
+ screen-value projections, and platform error contracts.
262
+
263
+ `InstanceView` does not write page documents, mutate catalog definitions, call
264
+ the dashboard, or create themes. Editors, CLIs, publishing, and persistence
265
+ are outside this package.
266
+
267
+ For a real Astro theme, create one `ReadCache` outside the section map, pass a
268
+ request-built `ThemeRenderContext`, and pass the server-only `StoreClient`
269
+ explicitly. A `load` function must read through `reads.get(key, loader)` so
270
+ siblings share a resource within that render. Validate the catalog during the
271
+ theme's build/test path and treat non-empty issue arrays as actionable
272
+ diagnostics.
273
+
274
+ On a templated surface (`/products/[slug]`, `/category/[slug]`), pass
275
+ `params: Astro.params` in the render context. A section reads its route
276
+ resource from `context.params`, under a `reads` key that includes the
277
+ parameter, so the page and every section share one read:
278
+
279
+ ```ts
280
+ load: async ({ storefront, reads, context }) => ({
281
+ product: await reads.get(`product:${context.params?.slug}`, () =>
282
+ storefront.products.get(context.params?.slug ?? ""),
283
+ ),
284
+ }),
285
+ ```
286
+
287
+ ## Saved documents
288
+
289
+ A stored page document can be missing, malformed, or built for a catalog that
290
+ has since changed. The SDK reports every degradation and never throws the page
291
+ away. Which document to render instead is a product decision; the recommended
292
+ policy is:
293
+
294
+ 1. **No saved document** → render the theme's shipped default document.
295
+ 2. **Envelope invalid** (`parsePageData` returns `page: null`) → render the shipped default.
296
+ 3. **Saved sections map to no catalog type at all** (a theme switch left nothing
297
+ renderable) → render the shipped default.
298
+ 4. **Partial overlap** → render what maps. Unknown types render nothing and are
299
+ reported through `onIssue`.
300
+ 5. **An empty saved document** → honor it. A page the merchant emptied renders empty.
301
+
302
+ The SDK does not ship this policy as a function, because the shipped default
303
+ document is theme content.
304
+
305
+ ## Theme settings
306
+
307
+ A theme's global choices — its font, which menu the header shows, a header
308
+ color — are **settings**: declared once on the theme, in groups, with the same
309
+ field kinds as a section. The merchant's values are stored per store and per
310
+ theme (a value only means something against the theme that declared it) and
311
+ reach the theme as `StoreConfig.themeSettings` from `@wocart/storefront`.
312
+
313
+ ```ts
314
+ export const theme = defineTheme({
315
+ key: "my-theme",
316
+ version: "1.0.0",
317
+ locales: ["ar", "fr"],
318
+ settings: [
319
+ {
320
+ key: "header",
321
+ label: { ar: "الرأس", fr: "En-tête" },
322
+ fields: [
323
+ { key: "headerMenu", kind: "menu", label: { ar: "القائمة", fr: "Menu" }, default: "main-menu" },
324
+ { key: "font", kind: "select", label: { ar: "الخط", fr: "Police" }, options: ["Cairo", "Tajawal"], default: "Cairo" },
325
+ ],
326
+ },
327
+ ],
328
+ components: { … },
329
+ });
330
+
331
+ const { values, issues } = resolveThemeSettings(theme, config?.themeSettings, { locales: [locale] });
332
+ values.headerMenu; // "main-menu" — typed: InferSettings<typeof theme>
333
+ ```
334
+
335
+ - **Reading never fails.** Each setting takes the merchant's value when the
336
+ declaration accepts it, else the default (or the content pack, for text),
337
+ else it is absent. A value the theme no longer accepts — a removed option, a
338
+ color that is not one, a setting since deleted — is reported in `issues`
339
+ (`invalid-setting`, `unknown-setting`, `invalid-settings`) and falls back.
340
+ - **Keys are unique across groups**; groups are for the editor, values are flat.
341
+ - **A writer checks strictly** with `checkThemeSettings(theme, values)` before
342
+ saving: every key declared, every value accepted, in every language.
343
+ - Settings travel in the manifest (`manifest.settings`), so an editor lists
344
+ them without running the theme.
345
+ - Store identity — the logo, the brand colors — is not a theme setting: it
346
+ belongs to the store and survives a theme change.
347
+
348
+ ## Content and copy
349
+
350
+ The theme owns its content. The SDK does not model a content pack, a taxonomy,
351
+ or a copy system — it only reads the resolved pack the request hands it.
352
+
353
+ - **`ThemeRenderContext.content`** is an opaque, flat `Record<string, string>` —
354
+ the theme's pack with store overrides already merged.
355
+ - **`FieldDefinition.contentKey`** names a key *in that map*. It is the only
356
+ bridge between the contract and the theme's copy.
357
+ - **The precedence chain** is: instance value → `content[contentKey]` →
358
+ `field.default` → omit (the component's own default applies last). A field
359
+ with neither `contentKey` nor `default` is simply absent from `props`.
360
+ - **Typos are checkable, not inferred.** Pass the pack's key set to
361
+ `validateThemeDefinition(theme, { contentKeys })` and every `contentKey` that
362
+ is not in it is reported as `unknown-content-key`. Optional and additive:
363
+ with no options the validator behaves exactly as before, and passing an empty
364
+ array reports every declared key.
365
+
366
+ Both ways of consuming copy are legitimate, and the theme picks: a component can
367
+ take per-field props, or read the whole pack and overlay the resolved values
368
+ itself. The SDK deliberately ships neither helper — that would promote one
369
+ theme's implementation choice into platform API.
370
+
371
+ ### Messages: placeholders and plurals
372
+
373
+ `formatMessage(pack, key, values, { locale, formatNumber? })` is the SDK's one
374
+ message rule — Shopify's locale-file model on the platform's own CLDR data
375
+ (`Intl.PluralRules`), no message-format dependency:
376
+
377
+ - `{name}` placeholders: every occurrence is filled, numbers are formatted for
378
+ the language (or by `formatNumber`), an unknown placeholder stays visible.
379
+ - A plural message is flat keys, one per CLDR form — `cartItems_one`,
380
+ `cartItems_few`, `cartItems_other` — chosen by `values.count`; `_other` is
381
+ the fallback, and an explicit `_zero` is used for 0 in any language.
382
+ - `pluralFormIssues(pack, locale)` lists the forms a pack is missing for its
383
+ language, or carries but never uses — run it over every pack in a test.
384
+ - `messageForms(pack, key)` collects one message's keys to hand a browser
385
+ script.
386
+
387
+ Packs stay `Record<string, string>`, so store overrides and the editor treat
388
+ plural forms like any other key.
389
+
390
+ ## Capabilities are advisory
391
+
392
+ `ComponentDefinition.capabilities` is metadata for an **editor/CLI insertion
393
+ surface**, not renderer enforcement. Neither `parsePageData` nor
394
+ `resolveInstance` consults it: a saved document containing a component marked
395
+ `insertable: false` still renders, because the render path never second-guesses
396
+ a document that was already saved. Any future enforcement belongs at insertion
397
+ time, in the writer.
398
+
399
+ ## Compatibility
400
+
401
+ Page data is a persisted format, and the reader is deliberately forgiving — but
402
+ forgiveness is degradation with a report, not silent migration. There is no
403
+ migration engine, by design; what follows is the whole policy:
404
+
405
+ - **Envelope version.** `PageData.version` is `1`, and only `1` parses. Unknown
406
+ *top-level* keys are carried through verbatim (a writer may record metadata
407
+ without a version bump); unknown keys *inside* an instance are stripped.
408
+ - **Removed or renamed component type.** The section reports `unknown-type` and
409
+ renders nothing; siblings survive. Never rename a `type` once documents
410
+ reference it — treat it as a breaking change for saved pages.
411
+ - **Renamed, removed, or retyped field.** A stored value for an undeclared key
412
+ reports `unknown-prop` and is ignored; a value that fails the field's kind or
413
+ bounds reports `invalid-prop` and falls through the defaults chain
414
+ (instance → `content[contentKey]` → `field.default` → omit). A `required`
415
+ field with no resolvable value reports `missing-required-prop`.
416
+ - **Removed slot or narrowed `allow` list.** Saved children that no longer fit
417
+ report `unknown-slot` or `disallowed-child` and are dropped. Widen a slot's
418
+ rules freely, but narrow them only as a breaking change for saved pages.
419
+ - **Duplicate instance ids.** First wins depth-first; later duplicates are
420
+ dropped with `duplicate-id`. Generate stable unique ids at creation.
421
+ - **What never breaks the page.** Any section-level failure preserves the rest
422
+ of the page. Only an envelope failure (`version`, `surface`, `sections`
423
+ shape) returns `page: null`.
424
+ - **Theme versions.** The runtime accepts any non-empty `version` string;
425
+ stick to semver so the manifest validator stays quiet and future tooling can
426
+ reason about upgrades.
427
+
428
+ ## Testing your theme
429
+
430
+ A theme proves it honors the contract with four checks in its own test suite:
431
+
432
+ 1. **The catalog assembles clean**:
433
+ `expect(validateThemeDefinition(theme, { contentKeys: Object.keys(pack) })).toEqual([])`.
434
+ 2. **The manifest survives a JSON round trip**:
435
+ `validateThemeManifest(JSON.parse(JSON.stringify(themeManifest(theme))))`
436
+ returns no issues.
437
+ 3. **Every shipped document validates**:
438
+ `validatePageData(theme, document, { content: pack, locales: [locale] })`
439
+ returns no issues for each default page the theme ships.
440
+ 4. **Every shipped document renders**: render each section through
441
+ `InstanceView` with Astro's Container API, one `createReadCache()` per page
442
+ and a stand-in `StoreClient`, and assert that `onIssue` receives nothing
443
+ unexpected:
444
+
445
+ ```ts
446
+ import { experimental_AstroContainer as AstroContainer } from "astro/container";
447
+ import { createReadCache, type RendererIssue } from "@wocart/theme/astro";
448
+ import InstanceView from "@wocart/theme/astro/InstanceView.astro";
449
+ import type { StoreClient } from "@wocart/storefront/server";
450
+
451
+ const container = await AstroContainer.create();
452
+ const reads = createReadCache();
453
+ const issues: RendererIssue[] = [];
454
+ const storefront = { products: { list: async () => products } } as unknown as StoreClient;
455
+
456
+ for (const section of page.sections) {
457
+ await container.renderToString(InstanceView, {
458
+ props: { instance: section, theme, context, storefront, reads, onIssue: (i) => issues.push(i) },
459
+ });
460
+ }
461
+ expect(issues).toEqual([]);
462
+ ```
463
+
464
+ Also test the degradations you rely on, such as a failing `load` still
465
+ rendering base props.
466
+
467
+ `createInstance` refuses to build an instance the reader would flag: wrong-typed
468
+ values, unknown keys, and a `required` field with no value, default, or
469
+ `contentKey`.
470
+
471
+ ## Versioning
472
+
473
+ This package follows [semantic versioning](https://semver.org/): breaking
474
+ changes ship only in a new major version and are listed in the
475
+ [changelog](./CHANGELOG.md). The manifest's `contractVersion` and the page
476
+ document's `version` are versioned separately, as described above.
477
+
478
+ ## License
479
+
480
+ 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;