@shop-on/theme-loader 0.5.0 → 0.7.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/dist/closest-sources.d.ts +61 -0
- package/dist/closest-sources.js +287 -0
- package/dist/dynamic-sources.d.ts +51 -12
- package/dist/dynamic-sources.js +82 -45
- package/dist/enrich.js +16 -5
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/specs.d.ts +6 -0
- package/dist/specs.js +37 -7
- package/dist/types.d.ts +41 -22
- package/package.json +5 -5
|
@@ -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`
|
|
16
|
-
* value on the route
|
|
17
|
-
* references none. `fetchMetaobject` is
|
|
18
|
-
* repeats collapse to one trip. */
|
|
19
|
-
export declare function loadMetaobjectFieldSources(template: unknown, product: Pick<ProductData, "metafields"> | null): Promise<MetaobjectFieldSources>;
|
|
20
|
-
|
|
21
|
-
|
|
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)
|
|
42
|
-
*
|
|
43
|
-
|
|
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 {};
|
package/dist/dynamic-sources.js
CHANGED
|
@@ -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`
|
|
12
|
-
* value on the route
|
|
13
|
-
* references none. `fetchMetaobject` is
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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 +
|
|
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)
|
|
88
|
-
*
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
47
|
-
|
|
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,
|
|
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
|
-
|
|
28
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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`
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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`
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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`
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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:
|
|
120
|
-
* in the product's `metafields
|
|
121
|
-
|
|
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.
|
|
3
|
+
"version": "0.7.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.
|
|
26
|
-
"@shop-on/theme-runtime": "0.
|
|
27
|
-
"@shop-on/theme-system": "0.
|
|
28
|
-
"@shop-on/theme-server": "0.
|
|
25
|
+
"@shop-on/theme-contract": "0.7.0",
|
|
26
|
+
"@shop-on/theme-runtime": "0.7.0",
|
|
27
|
+
"@shop-on/theme-system": "0.7.0",
|
|
28
|
+
"@shop-on/theme-server": "0.7.0"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@types/node": "26.6.5",
|