@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/README.md CHANGED
@@ -1,3 +1,435 @@
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
+ | `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[];