@shop-on/theme-loader 0.5.0 → 0.6.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.
@@ -0,0 +1,61 @@
1
+ import { SectionInstance, ThemeLoaderConfig } from "./types.js";
2
+ import { DynamicSourceRoute } from "./dynamic-sources.js";
3
+ import { ClosestLookup, ClosestOverlays, ClosestResource } from "@shop-on/theme-contract";
4
+ //#region src/closest-sources.d.ts
5
+ /** What a provider's scope binds, per picker field: the product metafields
6
+ * (`ns.key`) its cards must carry (a fetch arg), and whether the collection
7
+ * description is read (a separate lean query, not a fetch arg). */
8
+ export interface ClosestNeeds {
9
+ metafieldKeys?: string[];
10
+ includeDescription?: boolean;
11
+ }
12
+ export interface ClosestNeedsMap {
13
+ /** Provider node → picker field id → needs. The spec context merges these
14
+ * into the field's fetch arg. */
15
+ providers: WeakMap<object, Record<string, ClosestNeeds>>;
16
+ /** Needs of the nodes the ROUTE resource serves (no provider above them). */
17
+ route: Partial<Record<ClosestResource, Pick<ClosestNeeds, "includeDescription">>>;
18
+ }
19
+ /** Per provider, what its scope binds — so the picker barrier fetches the
20
+ * bound metafield keys (and the collection description) for exactly the
21
+ * pickers whose items will be read, and nothing for the rest. */
22
+ export declare function collectClosestNeeds(cfg: ThemeLoaderConfig, template: unknown, route: DynamicSourceRoute): Promise<ClosestNeedsMap>;
23
+ /** The whole-value closest pass over every scope, and the lookup for the
24
+ * inline pass. Runs AFTER the picker barrier (the items are picker results)
25
+ * and before the inline pass. Exported so a route-fed list (v2) can resolve
26
+ * its own subtree the same way. */
27
+ export declare function resolveClosestResources(cfg: ThemeLoaderConfig, template: unknown, route: DynamicSourceRoute & {
28
+ locale?: string;
29
+ }, needs?: ClosestNeedsMap,
30
+ /** The entries of each `$theme` list, by provider node. Only
31
+ * `resolveListOverlays` passes it; in the page's own enrichment a fed
32
+ * list has no items yet, so its subtree's closest values blank. */
33
+ fed?: WeakMap<object, unknown[]>): Promise<ClosestLookup>;
34
+ export interface FedList {
35
+ /** The section's instance id in `template.sections`. A section-group
36
+ * section is never fed: groups render on pages that have no such list. */
37
+ sectionId: string;
38
+ /** Block ids from the section down to the provider; `[]` is the section. */
39
+ path: string[];
40
+ resource: ClosestResource;
41
+ /** The section as authored, cloned before enrichment touched it. */
42
+ section: SectionInstance;
43
+ /** The product metafields (`ns.key`) the per-item subtree binds — what each
44
+ * item's fetch must carry. */
45
+ metafieldKeys: string[];
46
+ }
47
+ /** Every `$theme` list on the template whose per-item subtree binds a closest
48
+ * source of its resource. Call it on the template as loaded, before
49
+ * enrichment. A list whose cards bind nothing is not returned, so a grid
50
+ * without closest sources costs its requests nothing. */
51
+ export declare function collectFedLists(cfg: ThemeLoaderConfig, template: unknown): Promise<FedList[]>;
52
+ /** The per-item values of one `$theme` list for a batch of its entries — the
53
+ * whole-value and inline passes run over a copy of the list's section alone,
54
+ * with the batch as the list's items. Only what the subtree binds is read, so
55
+ * an entry needs the `metafieldKeys` its list asked for. `route` resolves the
56
+ * route sources a per-item string mixes in; leave out what the request does
57
+ * not have and those tokens blank, as they would anywhere else. */
58
+ export declare function resolveListOverlays(cfg: ThemeLoaderConfig, list: FedList, entries: readonly unknown[], route?: DynamicSourceRoute & {
59
+ locale?: string;
60
+ }): Promise<ClosestOverlays>;
61
+ //#endregion
@@ -0,0 +1,287 @@
1
+ import { loadBlockSchemas, loadSectionSchemas } from "./disk.js";
2
+ import { collectionModule } from "./server-modules.js";
3
+ import { applyInlineSourceTokens, loadMetaobjectFieldSources, loadSinkTypeAt, productAttributes } from "./dynamic-sources.js";
4
+ import { CLOSEST_RESOURCES, THEME_FED, applyClosestBindings, closestResourceOf, closestScopeAt, nodeSourceDescriptors, providersOf, routeTwin } from "@shop-on/theme-contract";
5
+ import { serializeRichText } from "@shop-on/theme-runtime/richtext";
6
+ //#region src/closest-sources.ts
7
+ const ROUTE = Symbol("closest/route");
8
+ function schemaOf(map, type) {
9
+ if (!type) return void 0;
10
+ return map.get(type) ?? map.get(type.replace(/^_/, ""));
11
+ }
12
+ function* walkScoped(template, sectionSchemas, blockSchemas, root) {
13
+ const t = template;
14
+ const sectionMaps = [t.sections ?? {}];
15
+ for (const group of Object.values(t.section_groups ?? {})) sectionMaps.push(group.sections ?? {});
16
+ function* descend(node, key, providers, chain, nodes) {
17
+ if (node?.disabled) return;
18
+ const nextChain = [...chain, {
19
+ providers,
20
+ key
21
+ }];
22
+ const nextNodes = [...nodes, node];
23
+ yield {
24
+ node,
25
+ chain: nextChain,
26
+ nodes: nextNodes
27
+ };
28
+ for (const [blockId, block] of Object.entries(node.blocks ?? {})) {
29
+ const b = block;
30
+ yield* descend(b, blockId, providersOf(schemaOf(blockSchemas, b?.type)), nextChain, nextNodes);
31
+ }
32
+ }
33
+ for (const sections of sectionMaps) for (const [sectionId, section] of Object.entries(sections)) yield* descend(section, sectionId, providersOf(schemaOf(sectionSchemas, section?.type)), [root], [ROUTE]);
34
+ }
35
+ /** The route link: a single provider per resource the request carries. */
36
+ function routeLink(route) {
37
+ const providers = {};
38
+ for (const resource of CLOSEST_RESOURCES) if (route[resource]) providers[resource] = { from: "$route" };
39
+ return {
40
+ providers,
41
+ key: ""
42
+ };
43
+ }
44
+ async function loadSchemas(cfg) {
45
+ const [sectionSchemas, blockSchemas] = await Promise.all([loadSectionSchemas(cfg.configDir), loadBlockSchemas(cfg.configDir)]);
46
+ return {
47
+ sectionSchemas,
48
+ blockSchemas
49
+ };
50
+ }
51
+ /** Per provider, what its scope binds — so the picker barrier fetches the
52
+ * bound metafield keys (and the collection description) for exactly the
53
+ * pickers whose items will be read, and nothing for the rest. */
54
+ async function collectClosestNeeds(cfg, template, route) {
55
+ const { sectionSchemas, blockSchemas } = await loadSchemas(cfg);
56
+ const needs = {
57
+ providers: /* @__PURE__ */ new WeakMap(),
58
+ route: {}
59
+ };
60
+ const bindsDescription = (resource, twin) => resource === "collection" && twin?.source === "collection_attribute" && twin.attribute === "description";
61
+ const add = (slot, resource, twin) => {
62
+ if (!twin) return;
63
+ if (resource === "product" && twin.source === "product_metafield") {
64
+ const key = `${twin.namespace}.${twin.key}`;
65
+ if (!(slot.metafieldKeys ??= []).includes(key)) slot.metafieldKeys.push(key);
66
+ } else if (bindsDescription(resource, twin)) slot.includeDescription = true;
67
+ };
68
+ for (const { node, chain, nodes } of walkScoped(template, sectionSchemas, blockSchemas, routeLink(route))) for (const d of nodeSourceDescriptors(node)) {
69
+ const resource = closestResourceOf(d);
70
+ if (!resource) continue;
71
+ const scope = closestScopeAt(chain, resource);
72
+ if (!scope) continue;
73
+ if (scope.index === 0) {
74
+ if (bindsDescription(resource, routeTwin(d))) (needs.route[resource] ??= {}).includeDescription = true;
75
+ continue;
76
+ }
77
+ const provider = nodes[scope.index];
78
+ let byField = needs.providers.get(provider);
79
+ if (!byField) needs.providers.set(provider, byField = {});
80
+ add(byField[scope.decl.from] ??= {}, resource, routeTwin(d));
81
+ }
82
+ return needs;
83
+ }
84
+ function titled(data) {
85
+ const title = data?.title;
86
+ return typeof title === "string" ? { attributes: { title } } : null;
87
+ }
88
+ /** One picker result (or route resource) as the resolvers read it. */
89
+ async function toItem(resource, data, description) {
90
+ if (!data || typeof data !== "object") return null;
91
+ if (resource === "product") {
92
+ const p = data;
93
+ if (typeof p.title !== "string" || !p.price) return null;
94
+ return {
95
+ attributes: productAttributes(p),
96
+ metafields: p.metafields ?? {}
97
+ };
98
+ }
99
+ const item = titled(data);
100
+ if (!item || resource !== "collection") return item;
101
+ const handle = data.handle;
102
+ const descriptionHtml = description && typeof handle === "string" ? await description(handle) : null;
103
+ return {
104
+ ...item,
105
+ descriptionHtml
106
+ };
107
+ }
108
+ /** The items an `each` provider renders: the picker's `${from}Data`, read by
109
+ * the picker's own setting type. A declaration the loader does not know how
110
+ * to read (a type/resource pair not listed here) yields no items, so its
111
+ * subtree keeps the literal. Keyed by handle — the id the theme's list wraps
112
+ * each card's `ClosestScope` with. */
113
+ async function itemsOf(resource, pickerType, data, description) {
114
+ let list;
115
+ if (pickerType === "collection" && resource === "product") list = data?.products;
116
+ else if (pickerType === "product_list" && resource === "product") list = data;
117
+ else if (pickerType === "collection_list" && resource === "collection") list = data;
118
+ else if (pickerType === "blog" && resource === "article") list = data?.articles;
119
+ return toItems(resource, list, description);
120
+ }
121
+ /** A list's entries as items, keyed by handle; an entry without one (or one
122
+ * the resolvers cannot read) is dropped and its card keeps the literal. */
123
+ async function toItems(resource, list, description) {
124
+ if (!Array.isArray(list)) return [];
125
+ return (await Promise.all(list.map(async (entry) => {
126
+ const handle = entry?.handle;
127
+ if (typeof handle !== "string" || !handle) return null;
128
+ const sources = await toItem(resource, entry, description);
129
+ return sources ? {
130
+ id: handle,
131
+ sources
132
+ } : null;
133
+ }))).filter((item) => item !== null);
134
+ }
135
+ function settingTypeOf(schema, id) {
136
+ return schema?.settings?.find((s) => s.id === id)?.type;
137
+ }
138
+ /** The whole-value closest pass over every scope, and the lookup for the
139
+ * inline pass. Runs AFTER the picker barrier (the items are picker results)
140
+ * and before the inline pass. Exported so a route-fed list (v2) can resolve
141
+ * its own subtree the same way. */
142
+ async function resolveClosestResources(cfg, template, route, needs = {
143
+ providers: /* @__PURE__ */ new WeakMap(),
144
+ route: {}
145
+ }, fed) {
146
+ const [{ sectionSchemas, blockSchemas }, sinkTypeAt] = await Promise.all([loadSchemas(cfg), loadSinkTypeAt(cfg)]);
147
+ const opts = {
148
+ serializeRichText,
149
+ sinkTypeAt
150
+ };
151
+ const describe = async (handle) => (await collectionModule()).fetchCollectionDescriptionHtml(handle, route.locale);
152
+ const descriptionFor = (provider, from) => needs.providers.get(provider)?.[from]?.includeDescription ? describe : void 0;
153
+ const routeItems = {};
154
+ if (route.product) routeItems.product = await toItem("product", route.product) ?? void 0;
155
+ if (route.collection) routeItems.collection = {
156
+ attributes: { title: route.collection.title },
157
+ descriptionHtml: needs.route.collection?.includeDescription ? await route.collection.getDescriptionHtml() : null
158
+ };
159
+ for (const resource of [
160
+ "page",
161
+ "blog",
162
+ "article"
163
+ ]) {
164
+ const r = route[resource];
165
+ if (r) routeItems[resource] = { attributes: { title: r.title } };
166
+ }
167
+ const single = /* @__PURE__ */ new WeakMap();
168
+ const each = /* @__PURE__ */ new WeakMap();
169
+ const singleItems = /* @__PURE__ */ new WeakMap();
170
+ const itemLists = /* @__PURE__ */ new WeakMap();
171
+ const singleItemOf = async (provider, resource, from) => {
172
+ let byResource = singleItems.get(provider);
173
+ if (!byResource) singleItems.set(provider, byResource = {});
174
+ if (!(resource in byResource)) byResource[resource] = await toItem(resource, provider.settings?.[`${from}Data`], descriptionFor(provider, from));
175
+ return byResource[resource] ?? null;
176
+ };
177
+ for (const { node, chain, nodes } of walkScoped(template, sectionSchemas, blockSchemas, routeLink(route))) for (const resource of CLOSEST_RESOURCES) {
178
+ const scope = closestScopeAt(chain, resource);
179
+ if (!scope) continue;
180
+ const providerNode = nodes[scope.index];
181
+ if (!scope.decl.each) {
182
+ const item = scope.index === 0 ? routeItems[resource] : await singleItemOf(providerNode, resource, scope.decl.from);
183
+ if (!item) continue;
184
+ applyClosestBindings(node, resource, item, opts);
185
+ let byResource = single.get(node);
186
+ if (!byResource) single.set(node, byResource = {});
187
+ byResource[resource] = item;
188
+ continue;
189
+ }
190
+ let lists = itemLists.get(providerNode);
191
+ if (!lists) itemLists.set(providerNode, lists = {});
192
+ let items = lists[resource];
193
+ if (!items) {
194
+ const schema = schemaOf(scope.index === 1 ? sectionSchemas : blockSchemas, providerNode.type);
195
+ items = lists[resource] = scope.decl.from === THEME_FED ? await toItems(resource, fed?.get(providerNode)) : await itemsOf(resource, settingTypeOf(schema, scope.decl.from), providerNode.settings?.[`${scope.decl.from}Data`], descriptionFor(providerNode, scope.decl.from));
196
+ }
197
+ each.set(node, {
198
+ resource,
199
+ items
200
+ });
201
+ const n = node;
202
+ for (const item of items) {
203
+ const into = {};
204
+ applyClosestBindings(node, resource, item.sources, opts, into);
205
+ if (Object.keys(into).length === 0) continue;
206
+ const overlay = n.closestSettings ??= {};
207
+ const prior = Object.hasOwn(overlay, item.id) ? overlay[item.id] : void 0;
208
+ overlay[item.id] = {
209
+ ...prior,
210
+ ...into
211
+ };
212
+ }
213
+ }
214
+ return {
215
+ singleAt: (node, resource) => single.get(node)?.[resource] ?? null,
216
+ eachAt: (node) => each.get(node) ?? null
217
+ };
218
+ }
219
+ /** Every `$theme` list on the template whose per-item subtree binds a closest
220
+ * source of its resource. Call it on the template as loaded, before
221
+ * enrichment. A list whose cards bind nothing is not returned, so a grid
222
+ * without closest sources costs its requests nothing. */
223
+ async function collectFedLists(cfg, template) {
224
+ const { sectionSchemas, blockSchemas } = await loadSchemas(cfg);
225
+ const body = template.sections ?? {};
226
+ const lists = /* @__PURE__ */ new Map();
227
+ const clones = /* @__PURE__ */ new Map();
228
+ for (const { node, chain, nodes } of walkScoped(template, sectionSchemas, blockSchemas, {
229
+ providers: {},
230
+ key: ""
231
+ })) for (const d of nodeSourceDescriptors(node)) {
232
+ const resource = closestResourceOf(d);
233
+ if (!resource) continue;
234
+ const scope = closestScopeAt(chain, resource);
235
+ if (!scope || scope.decl.from !== THEME_FED) continue;
236
+ const sectionId = chain[1].key;
237
+ if (!Object.hasOwn(body, sectionId) || body[sectionId] !== nodes[1]) continue;
238
+ const provider = nodes[scope.index];
239
+ let list = lists.get(provider);
240
+ if (!list) {
241
+ let section = clones.get(sectionId);
242
+ if (!section) clones.set(sectionId, section = structuredClone(body[sectionId]));
243
+ list = {
244
+ sectionId,
245
+ path: chain.slice(2, scope.index + 1).map((link) => link.key),
246
+ resource,
247
+ section,
248
+ metafieldKeys: []
249
+ };
250
+ lists.set(provider, list);
251
+ }
252
+ const twin = routeTwin(d);
253
+ if (resource === "product" && twin?.source === "product_metafield") {
254
+ const key = `${twin.namespace}.${twin.key}`;
255
+ if (!list.metafieldKeys.includes(key)) list.metafieldKeys.push(key);
256
+ }
257
+ }
258
+ return [...lists.values()];
259
+ }
260
+ /** The per-item values of one `$theme` list for a batch of its entries — the
261
+ * whole-value and inline passes run over a copy of the list's section alone,
262
+ * with the batch as the list's items. Only what the subtree binds is read, so
263
+ * an entry needs the `metafieldKeys` its list asked for. `route` resolves the
264
+ * route sources a per-item string mixes in; leave out what the request does
265
+ * not have and those tokens blank, as they would anywhere else. */
266
+ async function resolveListOverlays(cfg, list, entries, route = {}) {
267
+ const section = structuredClone(list.section);
268
+ const template = { sections: { [list.sectionId]: section } };
269
+ let provider = section;
270
+ for (const id of list.path) provider = provider?.blocks?.[id];
271
+ if (!provider || entries.length === 0) return {};
272
+ const [metaobjectFields, closest] = await Promise.all([loadMetaobjectFieldSources(template, null), resolveClosestResources(cfg, template, route, void 0, new WeakMap([[provider, [...entries]]]))]);
273
+ applyInlineSourceTokens(template, route, metaobjectFields, closest);
274
+ const out = {};
275
+ const collect = (node, path) => {
276
+ for (const [id, child] of Object.entries(node.blocks ?? {})) {
277
+ const c = child;
278
+ const at = [...path, id];
279
+ if (c.closestSettings && Object.keys(c.closestSettings).length > 0) out[at.join("/")] = c.closestSettings;
280
+ collect(c, at);
281
+ }
282
+ };
283
+ collect(provider, []);
284
+ return out;
285
+ }
286
+ //#endregion
287
+ export { collectClosestNeeds, collectFedLists, resolveClosestResources, resolveListOverlays };
@@ -1,28 +1,61 @@
1
1
  import { DynamicSourceArticle, DynamicSourceBlog, DynamicSourceCollection, DynamicSourcePage, SectionGroupJson, TemplateJson, ThemeLoaderConfig } from "./types.js";
2
+ import { ClosestLookup, ProductAttributeKey, RouteMetafieldSource } from "@shop-on/theme-contract";
3
+ import { Money } from "@shop-on/theme-runtime/money";
2
4
  import { ProductData } from "@shop-on/theme-server/product";
3
5
  //#region src/dynamic-sources.d.ts
4
6
  /** Route product projection the dynamic-source passes actually read. */
5
7
  export type DynamicSourceProduct = Pick<ProductData, "title" | "vendor" | "price" | "metafields"> | null;
8
+ /** The product projection every product source reads — the route
9
+ * `ProductData`, a `product` picker's, or a list's `CollectionProductData`
10
+ * card: all three carry these four. */
11
+ export interface ClosestProduct {
12
+ title: string;
13
+ vendor?: string | null;
14
+ price: Money;
15
+ metafields?: Record<string, {
16
+ type: string;
17
+ value: string;
18
+ }>;
19
+ }
20
+ /** The product's inline attributes — ONE builder for the route pass and the
21
+ * closest items. `satisfies` so adding a PRODUCT_ATTRIBUTE_KEYS entry without
22
+ * populating it here is a compile error, not a silent empty render. */
23
+ export declare function productAttributes(p: ClosestProduct): Record<ProductAttributeKey, string>;
6
24
  /** entry id → field key → { type, value } — the shape the whole-value + inline
7
25
  * binding passes read for `metaobject_field` sources. */
8
26
  export type MetaobjectFieldSources = Record<string, Record<string, {
9
27
  type: string;
10
28
  value: string;
11
29
  }>>;
30
+ /** "namespace.key" → wire field — a resource's storefront metafields. */
31
+ export type MetafieldMap = Record<string, {
32
+ type: string;
33
+ value: string;
34
+ }>;
35
+ /** The route collection's, page's, blog's or article's metafields, keyed by
36
+ * the source that reads them. */
37
+ export type RouteMetafields = Partial<Record<RouteMetafieldSource, MetafieldMap>>;
38
+ type RouteResources = Pick<DynamicSourceRoute, "collection" | "page" | "blog" | "article">;
39
+ /** Each route resource's metafields, read only when the template binds that
40
+ * resource's source (sidecar or inline token) and the theme supplies the
41
+ * provider. A load that binds none returns `{}` without a request — which is
42
+ * what keeps the metafield read off every page that does not use it. */
43
+ export declare function loadRouteMetafields(template: unknown, route: RouteResources): Promise<RouteMetafields>;
12
44
  /** Fetch every metaobject entry the template needs, keyed by the numeric entry
13
45
  * id: the entries PINNED by `metaobject_field` descriptors (whole-value
14
46
  * bindings + inline tokens) UNION the entries a field-bearing
15
- * `product_metafield` binding traverses (each METAOBJECT_REFERENCE metafield
16
- * value on the route product). Early-out (no fetch) when the template
17
- * references none. `fetchMetaobject` is memoTTL'd + content-version keyed, so
18
- * repeats collapse to one trip. */
19
- export declare function loadMetaobjectFieldSources(template: unknown, product: Pick<ProductData, "metafields"> | null): Promise<MetaobjectFieldSources>;
20
- /** Whole-value binding pass (product_metafield + metaobject_field +
21
- * collection_attribute), sharing one schema-derived `sinkTypeAt` so every
47
+ * `product_metafield` or route-resource (`collection_metafield`, …) binding
48
+ * traverses (each METAOBJECT_REFERENCE metafield value on the route resource).
49
+ * Early-out (no fetch) when the template references none. `fetchMetaobject` is
50
+ * memoTTL'd + content-version keyed, so repeats collapse to one trip. */
51
+ export declare function loadMetaobjectFieldSources(template: unknown, product: Pick<ProductData, "metafields"> | null, routeMetafields?: RouteMetafields | Promise<RouteMetafields>): Promise<MetaobjectFieldSources>;
52
+ export declare function loadSinkTypeAt(cfg: ThemeLoaderConfig): Promise<(node: unknown, settingId: string) => string | undefined>;
53
+ /** Whole-value binding pass (product_metafield + the route-resource
54
+ * `*_metafield` sources + metaobject_field + collection_attribute), sharing one schema-derived `sinkTypeAt` so every
22
55
  * source routes by the theme's declared sink. Runs BEFORE the picker specs so a
23
56
  * bound GID is in place for the `${id}Data` resolution to dereference. The
24
57
  * collection description is fetched only when a sidecar binds it. */
25
- export declare function applyWholeValueBindings(cfg: ThemeLoaderConfig, template: unknown, product: DynamicSourceProduct, collection: DynamicSourceCollection, metaobjectFields: MetaobjectFieldSources): Promise<void>;
58
+ export declare function applyWholeValueBindings(cfg: ThemeLoaderConfig, template: unknown, product: DynamicSourceProduct, collection: DynamicSourceCollection, metaobjectFields: MetaobjectFieldSources, routeMetafields?: RouteMetafields): Promise<void>;
26
59
  /** The route resources an inline token can resolve against. Each is `null`
27
60
  * (or absent) on every template but its own, which is what makes a token in a
28
61
  * section group render "" instead of leaking its descriptor. One object rather
@@ -31,6 +64,9 @@ export declare function applyWholeValueBindings(cfg: ThemeLoaderConfig, template
31
64
  export interface DynamicSourceRoute {
32
65
  product?: DynamicSourceProduct;
33
66
  collection?: DynamicSourceCollection;
67
+ /** The route resource's metafields as `loadRouteMetafields` read them —
68
+ * resolved once, before the whole-value pass, and reused here. */
69
+ routeMetafields?: RouteMetafields;
34
70
  page?: DynamicSourcePage;
35
71
  blog?: DynamicSourceBlog;
36
72
  article?: DynamicSourceArticle;
@@ -38,9 +74,11 @@ export interface DynamicSourceRoute {
38
74
  /** The SINGLE inline-source pass — product attributes/metafields (PDP only) +
39
75
  * collection attributes (PLP only) + page attributes (CMS page only) + blog and
40
76
  * article attributes (their own templates only) + metaobject fields (every
41
- * template) folded into one substitution so a resolved value is never
42
- * re-scanned. Runs LAST in `runEnrichment`. */
43
- export declare function applyInlineSourceTokens(template: unknown, route: DynamicSourceRoute, metaobjectFields: MetaobjectFieldSources): void;
77
+ * template) + the `closest_*` arms through `closest` (the per-node scope
78
+ * lookup `resolveClosestResources` built; absent ⇒ they blank) folded into one
79
+ * substitution so a resolved value is never re-scanned. Runs LAST in
80
+ * `runEnrichment`. */
81
+ export declare function applyInlineSourceTokens(template: unknown, route: DynamicSourceRoute, metaobjectFields: MetaobjectFieldSources, closest?: ClosestLookup): void;
44
82
  /** Merchant translation overlay for editor-authored template content, applied
45
83
  * to the raw parsed template BEFORE picker enrichment (see
46
84
  * applyContentOverrides for why the order is load-bearing). One fetch per
@@ -57,4 +95,5 @@ export declare function overlayTemplateTranslations(cfg: ThemeLoaderConfig, temp
57
95
  /** SETTINGS_DATA sibling of the template overlay: flat setting-id keys applied
58
96
  * onto the raw theme-settings map, before its own enrichment pass. */
59
97
  export declare function overlayThemeSettingsTranslations(cfg: ThemeLoaderConfig, settings: Record<string, unknown>, locale: string): Promise<void>;
60
- //#endregion
98
+ //#endregion
99
+ export {};
@@ -1,45 +1,74 @@
1
1
  import { loadBlockSchemas, loadSectionSchemas } from "./disk.js";
2
2
  import { metaobjectModule, translationsModule } from "./server-modules.js";
3
- import { applyCollectionAttributeBindings, applyInlineSources, applyMetafieldBindings, applyMetaobjectFieldBindings, collectCollectionAttributeBindings, collectMetaobjectEntryIds, collectTraversedEntryIds, metaobjectGid } from "@shop-on/theme-contract";
3
+ import { ROUTE_METAFIELD_RESOURCE, ROUTE_METAFIELD_SOURCES, applyCollectionAttributeBindings, applyInlineSources, applyMetafieldBindings, applyMetaobjectFieldBindings, collectCollectionAttributeBindings, collectMetaobjectEntryIds, collectTraversedEntryIds, metaobjectGid, templateReferencesSource } from "@shop-on/theme-contract";
4
4
  import { formatMoney } from "@shop-on/theme-runtime/money";
5
5
  import { serializeRichText } from "@shop-on/theme-runtime/richtext";
6
6
  //#region src/dynamic-sources.ts
7
+ /** The product's inline attributes — ONE builder for the route pass and the
8
+ * closest items. `satisfies` so adding a PRODUCT_ATTRIBUTE_KEYS entry without
9
+ * populating it here is a compile error, not a silent empty render. */
10
+ function productAttributes(p) {
11
+ return {
12
+ title: p.title,
13
+ vendor: p.vendor ?? "",
14
+ price: formatMoney(p.price)
15
+ };
16
+ }
17
+ /** Each route resource's metafields, read only when the template binds that
18
+ * resource's source (sidecar or inline token) and the theme supplies the
19
+ * provider. A load that binds none returns `{}` without a request — which is
20
+ * what keeps the metafield read off every page that does not use it. */
21
+ async function loadRouteMetafields(template, route) {
22
+ const reads = ROUTE_METAFIELD_SOURCES.map(async (source) => {
23
+ const resource = route[ROUTE_METAFIELD_RESOURCE[source]];
24
+ if (!resource?.getMetafields || !templateReferencesSource(template, source)) return null;
25
+ try {
26
+ return [source, await resource.getMetafields()];
27
+ } catch (err) {
28
+ console.warn(`[loadRouteMetafields] ${source} read failed:`, err);
29
+ return null;
30
+ }
31
+ });
32
+ return Object.fromEntries((await Promise.all(reads)).filter((r) => r !== null));
33
+ }
7
34
  const MAX_METAOBJECT_ENTRIES = 50;
8
35
  /** Fetch every metaobject entry the template needs, keyed by the numeric entry
9
36
  * id: the entries PINNED by `metaobject_field` descriptors (whole-value
10
37
  * bindings + inline tokens) UNION the entries a field-bearing
11
- * `product_metafield` binding traverses (each METAOBJECT_REFERENCE metafield
12
- * value on the route product). Early-out (no fetch) when the template
13
- * references none. `fetchMetaobject` is memoTTL'd + content-version keyed, so
14
- * repeats collapse to one trip. */
15
- async function loadMetaobjectFieldSources(template, product) {
16
- const pinned = collectMetaobjectEntryIds(template);
17
- const traversed = collectTraversedEntryIds(template, product?.metafields ?? {});
38
+ * `product_metafield` or route-resource (`collection_metafield`, …) binding
39
+ * traverses (each METAOBJECT_REFERENCE metafield value on the route resource).
40
+ * Early-out (no fetch) when the template references none. `fetchMetaobject` is
41
+ * memoTTL'd + content-version keyed, so repeats collapse to one trip. */
42
+ async function loadMetaobjectFieldSources(template, product, routeMetafields = {}) {
18
43
  const seen = /* @__PURE__ */ new Set();
19
44
  const ids = [];
20
- for (const id of pinned) {
21
- if (seen.has(id)) continue;
22
- seen.add(id);
23
- ids.push(id);
24
- }
45
+ const append = (list) => {
46
+ for (const id of list) {
47
+ if (seen.has(id)) continue;
48
+ seen.add(id);
49
+ ids.push(id);
50
+ }
51
+ };
52
+ append(collectMetaobjectEntryIds(template));
25
53
  const pinnedCount = ids.length;
26
- for (const id of traversed) {
27
- if (seen.has(id)) continue;
28
- seen.add(id);
29
- ids.push(id);
30
- }
31
- if (ids.length === 0) return {};
32
- let entryIds = ids;
33
- if (ids.length > MAX_METAOBJECT_ENTRIES) {
34
- console.warn(`[loadMetaobjectFieldSources] template references ${ids.length} metaobject entries (${pinnedCount} pinned + ${ids.length - pinnedCount} traversed); capping the fetch at ${MAX_METAOBJECT_ENTRIES}.`);
35
- entryIds = ids.slice(0, MAX_METAOBJECT_ENTRIES);
36
- }
37
- const { fetchMetaobject } = await metaobjectModule();
38
- const entries = await Promise.all(entryIds.map((id) => fetchMetaobject(metaobjectGid(id))));
54
+ append(collectTraversedEntryIds(template, product?.metafields ?? {}));
55
+ const fetchBatch = async (batch) => {
56
+ if (batch.length === 0) return [];
57
+ const { fetchMetaobject } = await metaobjectModule();
58
+ return Promise.all(batch.map(async (id) => [id, await fetchMetaobject(metaobjectGid(id))]));
59
+ };
60
+ const early = ids.length;
61
+ const batches = await Promise.all([fetchBatch(ids.slice(0, MAX_METAOBJECT_ENTRIES)), Promise.resolve(routeMetafields).then((maps) => {
62
+ for (const source of ROUTE_METAFIELD_SOURCES) {
63
+ const map = maps[source];
64
+ if (map) append(collectTraversedEntryIds(template, map, source));
65
+ }
66
+ return fetchBatch(ids.slice(early, MAX_METAOBJECT_ENTRIES));
67
+ })]);
68
+ if (ids.length > MAX_METAOBJECT_ENTRIES) console.warn(`[loadMetaobjectFieldSources] template references ${ids.length} metaobject entries (${pinnedCount} pinned + ${ids.length - pinnedCount} traversed); capping the fetch at ${MAX_METAOBJECT_ENTRIES}.`);
39
69
  const sources = {};
40
- entryIds.forEach((entryId, i) => {
41
- const entry = entries[i];
42
- if (!entry) return;
70
+ for (const [entryId, entry] of batches.flat()) {
71
+ if (!entry) continue;
43
72
  const fields = {};
44
73
  for (const [key, field] of Object.entries(entry.fields)) {
45
74
  if (field.value == null) continue;
@@ -49,7 +78,7 @@ async function loadMetaobjectFieldSources(template, product) {
49
78
  };
50
79
  }
51
80
  sources[entryId] = fields;
52
- });
81
+ }
53
82
  return sources;
54
83
  }
55
84
  async function loadSinkTypeAt(cfg) {
@@ -57,15 +86,15 @@ async function loadSinkTypeAt(cfg) {
57
86
  return (node, settingId) => {
58
87
  const type = node.type;
59
88
  if (!type) return void 0;
60
- return (sectionSchemas.get(type) ?? blockSchemas.get(type))?.settings?.find((s) => s.id === settingId)?.type;
89
+ return (sectionSchemas.get(type) ?? blockSchemas.get(type) ?? blockSchemas.get(type.replace(/^_/, "")))?.settings?.find((s) => s.id === settingId)?.type;
61
90
  };
62
91
  }
63
- /** Whole-value binding pass (product_metafield + metaobject_field +
64
- * collection_attribute), sharing one schema-derived `sinkTypeAt` so every
92
+ /** Whole-value binding pass (product_metafield + the route-resource
93
+ * `*_metafield` sources + metaobject_field + collection_attribute), sharing one schema-derived `sinkTypeAt` so every
65
94
  * source routes by the theme's declared sink. Runs BEFORE the picker specs so a
66
95
  * bound GID is in place for the `${id}Data` resolution to dereference. The
67
96
  * collection description is fetched only when a sidecar binds it. */
68
- async function applyWholeValueBindings(cfg, template, product, collection, metaobjectFields) {
97
+ async function applyWholeValueBindings(cfg, template, product, collection, metaobjectFields, routeMetafields = {}) {
69
98
  const sinkTypeAt = await loadSinkTypeAt(cfg);
70
99
  if (collection && collectCollectionAttributeBindings(template).includes("description")) {
71
100
  const descriptionHtml = await collection.getDescriptionHtml();
@@ -79,27 +108,35 @@ async function applyWholeValueBindings(cfg, template, product, collection, metao
79
108
  ...opts,
80
109
  metaobjectFields
81
110
  });
111
+ for (const source of ROUTE_METAFIELD_SOURCES) {
112
+ const map = routeMetafields[source];
113
+ if (map) applyMetafieldBindings(template, map, {
114
+ ...opts,
115
+ metaobjectFields,
116
+ source
117
+ });
118
+ }
82
119
  applyMetaobjectFieldBindings(template, metaobjectFields, opts);
83
120
  }
84
121
  /** The SINGLE inline-source pass — product attributes/metafields (PDP only) +
85
122
  * collection attributes (PLP only) + page attributes (CMS page only) + blog and
86
123
  * article attributes (their own templates only) + metaobject fields (every
87
- * template) folded into one substitution so a resolved value is never
88
- * re-scanned. Runs LAST in `runEnrichment`. */
89
- function applyInlineSourceTokens(template, route, metaobjectFields) {
90
- const { product = null, collection = null, page = null, blog = null, article = null } = route;
124
+ * template) + the `closest_*` arms through `closest` (the per-node scope
125
+ * lookup `resolveClosestResources` built; absent ⇒ they blank) folded into one
126
+ * substitution so a resolved value is never re-scanned. Runs LAST in
127
+ * `runEnrichment`. */
128
+ function applyInlineSourceTokens(template, route, metaobjectFields, closest) {
129
+ const { product = null, collection = null, routeMetafields = {}, page = null, blog = null, article = null } = route;
91
130
  applyInlineSources(template, {
92
131
  metafields: product?.metafields ?? {},
93
- attributes: product ? {
94
- title: product.title,
95
- vendor: product.vendor ?? "",
96
- price: formatMoney(product.price)
97
- } : {},
132
+ attributes: product ? productAttributes(product) : {},
98
133
  collectionAttributes: collection ? { title: collection.title } : {},
134
+ routeMetafields,
99
135
  pageAttributes: page ? { title: page.title } : {},
100
136
  blogAttributes: blog ? { title: blog.title } : {},
101
137
  articleAttributes: article ? { title: article.title } : {},
102
- metaobjectFields
138
+ metaobjectFields,
139
+ closest
103
140
  });
104
141
  }
105
142
  /** Merchant translation overlay for editor-authored template content, applied
@@ -146,4 +183,4 @@ async function overlayThemeSettingsTranslations(cfg, settings, locale) {
146
183
  } catch {}
147
184
  }
148
185
  //#endregion
149
- export { applyInlineSourceTokens, applyWholeValueBindings, loadMetaobjectFieldSources, overlayTemplateTranslations, overlayThemeSettingsTranslations };
186
+ export { applyInlineSourceTokens, applyWholeValueBindings, loadMetaobjectFieldSources, loadRouteMetafields, loadSinkTypeAt, overlayTemplateTranslations, overlayThemeSettingsTranslations, productAttributes };
package/dist/enrich.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { loadBlockSchemas, loadSectionSchemas } from "./disk.js";
2
- import { applyInlineSourceTokens, applyWholeValueBindings, loadMetaobjectFieldSources } from "./dynamic-sources.js";
2
+ import { applyInlineSourceTokens, applyWholeValueBindings, loadMetaobjectFieldSources, loadRouteMetafields } from "./dynamic-sources.js";
3
+ import { collectClosestNeeds, resolveClosestResources } from "./closest-sources.js";
3
4
  import { resolveReviewLists, standardSpecs } from "./specs.js";
4
5
  import { buildDisabledAncestorSet, buildParentMap, resolveField } from "@shop-on/theme-contract";
5
6
  //#region src/enrich.ts
@@ -43,21 +44,31 @@ async function runEnrichment(cfg, template, adapter, opts = {}) {
43
44
  flags: await adapter.requestFlags?.(opts.sid, opts.locale) ?? {}
44
45
  };
45
46
  const sections = flattenSections(template);
46
- const metaobjectFields = await loadMetaobjectFieldSources(template, req.product);
47
- await applyWholeValueBindings(cfg, template, req.product, req.collection, metaobjectFields);
47
+ const routeMetafieldsRead = loadRouteMetafields(template, req);
48
+ const [routeMetafields, metaobjectFields, closestNeeds] = await Promise.all([
49
+ routeMetafieldsRead,
50
+ loadMetaobjectFieldSources(template, req.product, routeMetafieldsRead),
51
+ collectClosestNeeds(cfg, template, req)
52
+ ]);
53
+ await applyWholeValueBindings(cfg, template, req.product, req.collection, metaobjectFields, routeMetafields);
48
54
  const resolverCtx = await buildResolverCtx(cfg, template);
49
55
  const specCtx = {
50
56
  req,
51
57
  adapter,
52
- disabledAncestors: resolverCtx.disabledAncestors
58
+ disabledAncestors: resolverCtx.disabledAncestors,
59
+ closestNeeds: closestNeeds.providers
53
60
  };
54
61
  await Promise.all([
55
62
  ...standardSpecs(specCtx).map((spec) => resolveField(template, spec, resolverCtx)),
56
63
  resolveReviewLists(template, resolverCtx, specCtx),
57
64
  ...(adapter.customPasses ?? []).map((pass) => pass(sections, req))
58
65
  ]);
66
+ const closest = await resolveClosestResources(cfg, template, req, closestNeeds);
59
67
  await adapter.postPickers?.(sections, req);
60
- applyInlineSourceTokens(template, req, metaobjectFields);
68
+ applyInlineSourceTokens(template, {
69
+ ...req,
70
+ routeMetafields
71
+ }, metaobjectFields, closest);
61
72
  return req.flags;
62
73
  }
63
74
  //#endregion
package/dist/index.d.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { ArgOf, ArgPolicy, BlogArg, CollectionArg, DynamicSourceArticle, DynamicSourceBlog, DynamicSourceCollection, DynamicSourcePage, EnrichOverride, EnrichPass, EnrichRequest, FileArg, LoadedTemplate, LocaleArg, PickerArgs, PickerType, ProductArg, SKIP, SectionGroupJson, SectionInstance, Skip, TemplateJson, ThemeArgPolicies, ThemeEnrichAdapter, ThemeLoaderConfig, Transform } from "./types.js";
2
2
  import { SAFE_TEMPLATE_ID, buildSectionInstance, loadBlockSchemas, loadPreviewLocale, loadSectionGroupsForLayout, loadSectionSchemaJson, loadSectionSchemas, loadSettingsSchema, loadTemplate, loadThemeSettings, resolveTranslations } from "./disk.js";
3
- import { DynamicSourceProduct, DynamicSourceRoute, MetaobjectFieldSources, applyInlineSourceTokens, applyWholeValueBindings, loadMetaobjectFieldSources, overlayTemplateTranslations, overlayThemeSettingsTranslations } from "./dynamic-sources.js";
3
+ import { ClosestProduct, DynamicSourceProduct, DynamicSourceRoute, MetaobjectFieldSources, RouteMetafields, applyInlineSourceTokens, applyWholeValueBindings, loadMetaobjectFieldSources, loadRouteMetafields, overlayTemplateTranslations, overlayThemeSettingsTranslations } from "./dynamic-sources.js";
4
4
  import { EnrichOptions, buildResolverCtx, flattenSections, runEnrichment } from "./enrich.js";
5
5
  import { buildOrchestratorUrl, fetchPreviewSettings, fetchPreviewTemplate, previewEnabled } from "./orchestrator.js";
6
6
  import { loadRawThemeSettings, loadTemplateForRequest, resolveTemplateVariant } from "./template-request.js";
7
+ import { ClosestNeeds, ClosestNeedsMap, FedList, collectClosestNeeds, collectFedLists, resolveClosestResources, resolveListOverlays } from "./closest-sources.js";
7
8
  import { AnySpec, SpecContext, resolveReviewLists, standardSpecs } from "./specs.js";
8
9
  import { enrichThemeSettings } from "./theme-settings.js";
9
10
  import { SectionPreviewInput, SectionPreviewOptions, buildSectionPreviewTemplate } from "./section-preview.js";
10
11
  import { SeoContext, blogPerPage, buildBreadcrumb, collectionPerPage, emptySearchPage, seoContext } from "./seo.js";
11
- export { type AnySpec, type ArgOf, type ArgPolicy, type BlogArg, type CollectionArg, type DynamicSourceArticle, type DynamicSourceBlog, type DynamicSourceCollection, type DynamicSourcePage, type DynamicSourceProduct, type DynamicSourceRoute, type EnrichOptions, type EnrichOverride, type EnrichPass, type EnrichRequest, type FileArg, type LoadedTemplate, type LocaleArg, type MetaobjectFieldSources, type PickerArgs, type PickerType, type ProductArg, SAFE_TEMPLATE_ID, SKIP, type SectionGroupJson, type SectionInstance, type SectionPreviewInput, type SectionPreviewOptions, type SeoContext, type Skip, type SpecContext, type TemplateJson, type ThemeArgPolicies, type ThemeEnrichAdapter, type ThemeLoaderConfig, type Transform, applyInlineSourceTokens, applyWholeValueBindings, blogPerPage, buildBreadcrumb, buildOrchestratorUrl, buildResolverCtx, buildSectionInstance, buildSectionPreviewTemplate, collectionPerPage, emptySearchPage, enrichThemeSettings, fetchPreviewSettings, fetchPreviewTemplate, flattenSections, loadBlockSchemas, loadMetaobjectFieldSources, loadPreviewLocale, loadRawThemeSettings, loadSectionGroupsForLayout, loadSectionSchemaJson, loadSectionSchemas, loadSettingsSchema, loadTemplate, loadTemplateForRequest, loadThemeSettings, overlayTemplateTranslations, overlayThemeSettingsTranslations, previewEnabled, resolveReviewLists, resolveTemplateVariant, resolveTranslations, runEnrichment, seoContext, standardSpecs };
12
+ export { type AnySpec, type ArgOf, type ArgPolicy, type BlogArg, type ClosestNeeds, type ClosestNeedsMap, type ClosestProduct, type CollectionArg, type DynamicSourceArticle, type DynamicSourceBlog, type DynamicSourceCollection, type DynamicSourcePage, type DynamicSourceProduct, type DynamicSourceRoute, type EnrichOptions, type EnrichOverride, type EnrichPass, type EnrichRequest, type FedList, type FileArg, type LoadedTemplate, type LocaleArg, type MetaobjectFieldSources, type PickerArgs, type PickerType, type ProductArg, type RouteMetafields, SAFE_TEMPLATE_ID, SKIP, type SectionGroupJson, type SectionInstance, type SectionPreviewInput, type SectionPreviewOptions, type SeoContext, type Skip, type SpecContext, type TemplateJson, type ThemeArgPolicies, type ThemeEnrichAdapter, type ThemeLoaderConfig, type Transform, applyInlineSourceTokens, applyWholeValueBindings, blogPerPage, buildBreadcrumb, buildOrchestratorUrl, buildResolverCtx, buildSectionInstance, buildSectionPreviewTemplate, collectClosestNeeds, collectFedLists, collectionPerPage, emptySearchPage, enrichThemeSettings, fetchPreviewSettings, fetchPreviewTemplate, flattenSections, loadBlockSchemas, loadMetaobjectFieldSources, loadPreviewLocale, loadRawThemeSettings, loadRouteMetafields, loadSectionGroupsForLayout, loadSectionSchemaJson, loadSectionSchemas, loadSettingsSchema, loadTemplate, loadTemplateForRequest, loadThemeSettings, overlayTemplateTranslations, overlayThemeSettingsTranslations, previewEnabled, resolveClosestResources, resolveListOverlays, resolveReviewLists, resolveTemplateVariant, resolveTranslations, runEnrichment, seoContext, standardSpecs };
package/dist/index.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { SKIP } from "./types.js";
2
2
  import { SAFE_TEMPLATE_ID, buildSectionInstance, loadBlockSchemas, loadPreviewLocale, loadSectionGroupsForLayout, loadSectionSchemaJson, loadSectionSchemas, loadSettingsSchema, loadTemplate, loadThemeSettings, resolveTranslations } from "./disk.js";
3
3
  import { buildOrchestratorUrl, fetchPreviewSettings, fetchPreviewTemplate, previewEnabled } from "./orchestrator.js";
4
- import { applyInlineSourceTokens, applyWholeValueBindings, loadMetaobjectFieldSources, overlayTemplateTranslations, overlayThemeSettingsTranslations } from "./dynamic-sources.js";
4
+ import { applyInlineSourceTokens, applyWholeValueBindings, loadMetaobjectFieldSources, loadRouteMetafields, overlayTemplateTranslations, overlayThemeSettingsTranslations } from "./dynamic-sources.js";
5
5
  import { loadRawThemeSettings, loadTemplateForRequest, resolveTemplateVariant } from "./template-request.js";
6
+ import { collectClosestNeeds, collectFedLists, resolveClosestResources, resolveListOverlays } from "./closest-sources.js";
6
7
  import { resolveReviewLists, standardSpecs } from "./specs.js";
7
8
  import { buildResolverCtx, flattenSections, runEnrichment } from "./enrich.js";
8
9
  import { enrichThemeSettings } from "./theme-settings.js";
9
10
  import { buildSectionPreviewTemplate } from "./section-preview.js";
10
11
  import { blogPerPage, buildBreadcrumb, collectionPerPage, emptySearchPage, seoContext } from "./seo.js";
11
- export { SAFE_TEMPLATE_ID, SKIP, applyInlineSourceTokens, applyWholeValueBindings, blogPerPage, buildBreadcrumb, buildOrchestratorUrl, buildResolverCtx, buildSectionInstance, buildSectionPreviewTemplate, collectionPerPage, emptySearchPage, enrichThemeSettings, fetchPreviewSettings, fetchPreviewTemplate, flattenSections, loadBlockSchemas, loadMetaobjectFieldSources, loadPreviewLocale, loadRawThemeSettings, loadSectionGroupsForLayout, loadSectionSchemaJson, loadSectionSchemas, loadSettingsSchema, loadTemplate, loadTemplateForRequest, loadThemeSettings, overlayTemplateTranslations, overlayThemeSettingsTranslations, previewEnabled, resolveReviewLists, resolveTemplateVariant, resolveTranslations, runEnrichment, seoContext, standardSpecs };
12
+ export { SAFE_TEMPLATE_ID, SKIP, applyInlineSourceTokens, applyWholeValueBindings, blogPerPage, buildBreadcrumb, buildOrchestratorUrl, buildResolverCtx, buildSectionInstance, buildSectionPreviewTemplate, collectClosestNeeds, collectFedLists, collectionPerPage, emptySearchPage, enrichThemeSettings, fetchPreviewSettings, fetchPreviewTemplate, flattenSections, loadBlockSchemas, loadMetaobjectFieldSources, loadPreviewLocale, loadRawThemeSettings, loadRouteMetafields, loadSectionGroupsForLayout, loadSectionSchemaJson, loadSectionSchemas, loadSettingsSchema, loadTemplate, loadTemplateForRequest, loadThemeSettings, overlayTemplateTranslations, overlayThemeSettingsTranslations, previewEnabled, resolveClosestResources, resolveListOverlays, resolveReviewLists, resolveTemplateVariant, resolveTranslations, runEnrichment, seoContext, standardSpecs };
package/dist/specs.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { CollectionArg, EnrichRequest, ProductArg, ThemeEnrichAdapter } from "./types.js";
2
+ import { ClosestNeedsMap } from "./closest-sources.js";
2
3
  import { ResolverCtx, ResolverSpec } from "@shop-on/theme-contract";
3
4
  //#region src/specs.d.ts
4
5
  export type AnySpec = ResolverSpec<any, any>;
@@ -10,6 +11,11 @@ export interface SpecContext {
10
11
  * answer "my section is off" — this answers "a container above me is off",
11
12
  * however many levels up. Optional: absent reads as "none". */
12
13
  disabledAncestors?: WeakSet<object>;
14
+ /** What the `closest_*` sources bound under each provider picker need its
15
+ * fetch to carry (`collectClosestNeeds`). Merged ON TOP of whatever arg the
16
+ * policy chain produced, so a theme's policy never learns about bindings.
17
+ * Optional: absent reads as "nothing bound". */
18
+ closestNeeds?: ClosestNeedsMap["providers"];
13
19
  }
14
20
  export declare function fetchCollectionByFlavor(ref: string, arg: CollectionArg): Promise<import("@shop-on/theme-server/collection").CollectionData | import("@shop-on/theme-server/collection").CollectionMeta | null>;
15
21
  export declare function fetchProductByFlavor(ref: string, arg: ProductArg): Promise<import("@shop-on/theme-server/product").ProductData | null>;
package/dist/specs.js CHANGED
@@ -15,7 +15,7 @@ function isClaimed(adapter, scope, nodeType, fieldId) {
15
15
  * theme policy → the package's lean default. Each layer either answers or
16
16
  * defers; only a policy's explicit `SKIP` and the disabled/claim checks
17
17
  * decline. */
18
- function composeArg(type, scope, { req, adapter, disabledAncestors }, lean) {
18
+ function composeArg(type, scope, { req, adapter, disabledAncestors, closestNeeds }, lean) {
19
19
  const policy = adapter.args?.[type]?.[scope];
20
20
  return (node, fieldId, parent) => {
21
21
  if (node?.disabled || parent?.disabled || node && disabledAncestors?.has(node)) return SKIP;
@@ -24,10 +24,39 @@ function composeArg(type, scope, { req, adapter, disabledAncestors }, lean) {
24
24
  parent,
25
25
  req
26
26
  });
27
- if (chosen !== void 0) return withReqLocale(type, chosen, req);
28
- return lean(node, fieldId, req);
27
+ const arg = chosen !== void 0 ? withReqLocale(type, chosen, req) : lean(node, fieldId, req);
28
+ const needs = node ? closestNeeds?.get(node)?.[fieldId] : void 0;
29
+ return needs ? withClosestNeeds(type, arg, needs) : arg;
29
30
  };
30
31
  }
32
+ /** The closest sources bound in this picker's scope, on top of the arg the
33
+ * policy chain chose: the product metafield keys a card must carry — on a
34
+ * product picker's own card, or on each card of the collection it lists. A
35
+ * SKIP stays a SKIP: a declined picker provides nothing. (The collection
36
+ * description is not a fetch arg; `resolveClosestResources` reads it through
37
+ * the lean description query, as the route collection does.) */
38
+ function withClosestNeeds(type, arg, needs) {
39
+ if (arg === SKIP || !needs.metafieldKeys?.length) return arg;
40
+ if (type === "product" || type === "product_list" || type === "collection") {
41
+ const a = arg;
42
+ return {
43
+ ...a,
44
+ metafieldKeys: unionKeys(a.metafieldKeys, needs.metafieldKeys)
45
+ };
46
+ }
47
+ return arg;
48
+ }
49
+ function unionKeys(a, b) {
50
+ if (!b?.length) return a;
51
+ const out = [...a ?? []];
52
+ for (const key of b) if (!out.includes(key)) out.push(key);
53
+ return out;
54
+ }
55
+ /** Sorted so two scopes binding the same keys in a different order share one
56
+ * cache entry and one fetch. */
57
+ function keysKey(keys) {
58
+ return keys?.length ? [...keys].sort().join(",") : "";
59
+ }
31
60
  /** File args carry no locale; every other picker arg does. */
32
61
  const LOCALE_FREE_TYPES = /* @__PURE__ */ new Set(["image_picker", "video"]);
33
62
  /** A policy that returns a concrete arg but forgets `locale` would silently
@@ -68,7 +97,7 @@ function localeKey(ref, arg) {
68
97
  return `${ref}|${arg.locale ?? ""}`;
69
98
  }
70
99
  function collectionKey(ref, arg) {
71
- return arg.flavor === "full" ? `full|${arg.first}|${arg.mediaFirst}|${arg.includeOptions}|${arg.includeVariants ?? false}|${arg.locale ?? ""}|${ref}` : `${arg.flavor}|${arg.locale ?? ""}|${ref}`;
100
+ return arg.flavor === "full" ? `full|${arg.first}|${arg.mediaFirst}|${arg.includeOptions}|${arg.includeVariants ?? false}|${arg.locale ?? ""}|${keysKey(arg.metafieldKeys)}|${ref}` : `${arg.flavor}|${arg.locale ?? ""}|${ref}`;
72
101
  }
73
102
  async function fetchCollectionByFlavor(ref, arg) {
74
103
  const collection = await collectionModule();
@@ -80,11 +109,12 @@ async function fetchCollectionByFlavor(ref, arg) {
80
109
  mediaFirst: arg.mediaFirst,
81
110
  includeOptions: arg.includeOptions,
82
111
  includeVariants: arg.includeVariants,
83
- locale: arg.locale
112
+ locale: arg.locale,
113
+ metafieldKeys: arg.metafieldKeys
84
114
  });
85
115
  }
86
116
  function productKey(ref, arg) {
87
- return `${arg.flavor}|${arg.includeOptions}|${arg.locale ?? ""}|${arg.metafield ?? ""}|${ref}`;
117
+ return `${arg.flavor}|${arg.includeOptions}|${arg.locale ?? ""}|${keysKey(arg.metafieldKeys)}|${ref}`;
88
118
  }
89
119
  async function fetchProductByFlavor(ref, arg) {
90
120
  const product = await productModule();
@@ -98,7 +128,7 @@ async function fetchProductByFlavor(ref, arg) {
98
128
  default: return product.fetchProduct(ref, {
99
129
  includeOptions: arg.includeOptions,
100
130
  locale: arg.locale,
101
- metafield: arg.metafield
131
+ metafieldKeys: arg.metafieldKeys
102
132
  });
103
133
  }
104
134
  }
package/dist/types.d.ts CHANGED
@@ -44,31 +44,42 @@ export type LoadedTemplate = TemplateJson & {
44
44
  section_groups?: Record<string, SectionGroupJson>;
45
45
  templateId?: string;
46
46
  };
47
+ /** A route resource's storefront-visible metafields keyed `"namespace.key"`,
48
+ * like `ProductData.metafields`. A provider, so the loader reads them only when
49
+ * the template binds one. Optional on every resource so a theme built before
50
+ * that resource's `*_metafield` source keeps type-checking; without it the
51
+ * source renders its literal. */
52
+ type MetafieldsProvider = {
53
+ getMetafields?: () => Promise<Record<string, {
54
+ type: string;
55
+ value: string;
56
+ }>>;
57
+ };
47
58
  /** Route collection the dynamic-source passes read. `title` rides the lean
48
- * meta read the loader already makes; the description HTML is a provider so it
49
- * is fetched only when the template binds it. */
50
- export type DynamicSourceCollection = {
59
+ * meta read the loader already makes; the description HTML is a provider so
60
+ * it is fetched only when the template binds it. */
61
+ export type DynamicSourceCollection = ({
51
62
  title: string;
52
63
  getDescriptionHtml: () => Promise<string | null>;
53
- } | null;
54
- /** Route CMS page the dynamic-source passes read. `title` only — it rides the
55
- * page read the loader already makes, and the page body reaches the storefront
56
- * through the theme's page-content block, not through a binding. */
57
- export type DynamicSourcePage = {
64
+ } & MetafieldsProvider) | null;
65
+ /** Route CMS page the dynamic-source passes read. `title` rides the page read
66
+ * the loader already makes, and the page body reaches the storefront through
67
+ * the theme's page-content block, not through a binding. */
68
+ export type DynamicSourcePage = ({
58
69
  title: string;
59
- } | null;
60
- /** Route blog the dynamic-source passes read. `title` only — it rides the blog
61
- * read the loader already makes, and the article list reaches the storefront
62
- * through the theme's blog section, not through a binding. */
63
- export type DynamicSourceBlog = {
70
+ } & MetafieldsProvider) | null;
71
+ /** Route blog the dynamic-source passes read. `title` rides the blog read the
72
+ * loader already makes, and the article list reaches the storefront through
73
+ * the theme's blog section, not through a binding. */
74
+ export type DynamicSourceBlog = ({
64
75
  title: string;
65
- } | null;
66
- /** Route article the dynamic-source passes read. `title` only — it rides the
67
- * article read the loader already makes; the body and the cover reach the
68
- * storefront through the theme's own blocks. */
69
- export type DynamicSourceArticle = {
76
+ } & MetafieldsProvider) | null;
77
+ /** Route article the dynamic-source passes read. `title` rides the article
78
+ * read the loader already makes; the body and the cover reach the storefront
79
+ * through the theme's own blocks. */
80
+ export type DynamicSourceArticle = ({
70
81
  title: string;
71
- } | null;
82
+ } & MetafieldsProvider) | null;
72
83
  /** Per-request context every spec's arg policy sees. `flags` is whatever the
73
84
  * theme's `requestFlags` hook returned (care: `{ cardOptions: boolean }`). */
74
85
  export interface EnrichRequest {
@@ -111,14 +122,22 @@ export interface CollectionArg {
111
122
  * whose swatches switch it between them. Off when omitted. */
112
123
  includeVariants?: boolean;
113
124
  locale?: string;
125
+ /** `full` only: storefront-visible metafields (`namespace.key`) to carry on
126
+ * each product card, for the `closest_product_metafield` sources bound in
127
+ * the list's per-item subtrees. The loader fills it from the bindings; a
128
+ * policy never has to. */
129
+ metafieldKeys?: string[];
114
130
  }
115
131
  export interface ProductArg {
116
132
  flavor: "card" | "showcase" | "listDetail" | "full";
117
133
  includeOptions: boolean;
118
134
  locale?: string;
119
- /** `card` only: one storefront-visible metafield (`namespace.key`) to carry
120
- * in the product's `metafields`. */
121
- metafield?: string;
135
+ /** `card` only: the storefront-visible metafields (`namespace.key`) to carry
136
+ * in the product's `metafields` — a theme's own needs (volt's variation
137
+ * swatches name one) plus whatever `closest_product_metafield` sources the
138
+ * loader finds bound in the picker's scope. `full` carries every visible
139
+ * metafield already; `showcase` / `listDetail` carry none. */
140
+ metafieldKeys?: string[];
122
141
  }
123
142
  export interface BlogArg {
124
143
  first: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shop-on/theme-loader",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "license": "SEE LICENSE IN dist/LICENSE.md",
5
5
  "files": [
6
6
  "dist"
@@ -22,10 +22,10 @@
22
22
  "./package.json": "./package.json"
23
23
  },
24
24
  "dependencies": {
25
- "@shop-on/theme-contract": "0.5.0",
26
- "@shop-on/theme-runtime": "0.5.0",
27
- "@shop-on/theme-system": "0.5.0",
28
- "@shop-on/theme-server": "0.5.0"
25
+ "@shop-on/theme-runtime": "0.6.0",
26
+ "@shop-on/theme-server": "0.6.0",
27
+ "@shop-on/theme-contract": "0.6.0",
28
+ "@shop-on/theme-system": "0.6.0"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "26.6.5",