@tokenoftrust/storefront-runner 2.2.91 → 2.2.93
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/agency-kit/tools/lib/catalog-model/files.d.mts +8 -0
- package/agency-kit/tools/lib/catalog-model/files.mjs +93 -0
- package/apps/storefront/src/lib/storyblok/provider.ts +36 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +6 -1
- package/apps/storefront/src/lib/tot/tenantCatalog.ts +65 -18
- package/package.json +1 -1
- package/packages/public-runtime/src/catalog-files.ts +17 -0
- package/packages/public-runtime/src/customization-runtime.ts +44 -7
- package/apps/storefront/src/lib/compliance/rawComplianceNotices.ts +0 -72
|
@@ -46,3 +46,11 @@ export function readCatalogFiles(
|
|
|
46
46
|
entries: Iterable<{ path: string; raw: string | null }>,
|
|
47
47
|
store: CatalogStore,
|
|
48
48
|
): { products: CatalogRecord[]; collections: CatalogRecord[] };
|
|
49
|
+
|
|
50
|
+
export const CATALOG_LISTING_VERSION: number;
|
|
51
|
+
export const LISTING_DESCRIPTION_CHARS: number;
|
|
52
|
+
export function catalogListingProjection(entries: Iterable<{ path: string; raw: string | null }>): string | null;
|
|
53
|
+
export function readCatalogListing(
|
|
54
|
+
raw: string,
|
|
55
|
+
store: CatalogStore,
|
|
56
|
+
): { products: CatalogRecord[]; collections: CatalogRecord[] };
|
|
@@ -261,3 +261,96 @@ export function readCatalogFiles(entries, { tenantScope, inventoryProvider }) {
|
|
|
261
261
|
if (failures.length > 0) throw new CatalogFileError(failures);
|
|
262
262
|
return { products, collections };
|
|
263
263
|
}
|
|
264
|
+
|
|
265
|
+
// --- The listing projection ------------------------------------------------------------------
|
|
266
|
+
//
|
|
267
|
+
// A large catalog cannot be read file by file on every request where each read is a separate
|
|
268
|
+
// storage call (the hosted preview). The projection is one derived document built once per
|
|
269
|
+
// reconciled version: every product as a listing needs it (no `description_html`, no
|
|
270
|
+
// `metafields`, `description_text` cut to LISTING_DESCRIPTION_CHARS) plus every collection whole,
|
|
271
|
+
// or, when any file fails validation, the failures. A product page reads its full file.
|
|
272
|
+
|
|
273
|
+
/** Version of the projection document; a reader refuses any other. */
|
|
274
|
+
export const CATALOG_LISTING_VERSION = 1;
|
|
275
|
+
/** Characters of `description_text` a listing keeps: cards show 180, search weighs the rest lightly. */
|
|
276
|
+
export const LISTING_DESCRIPTION_CHARS = 400;
|
|
277
|
+
|
|
278
|
+
/** @param {Record<string, any>} record */
|
|
279
|
+
function listingProduct(record) {
|
|
280
|
+
const { description_html: _html, metafields: _metafields, ...rest } = record;
|
|
281
|
+
return {
|
|
282
|
+
...rest,
|
|
283
|
+
description_text:
|
|
284
|
+
typeof rest.description_text === "string"
|
|
285
|
+
? rest.description_text.slice(0, LISTING_DESCRIPTION_CHARS)
|
|
286
|
+
: rest.description_text,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Build the listing projection from a store's catalog files, validating each one exactly as the
|
|
292
|
+
* reader does. Records are kept as their files hold them (identity and availability are stamped
|
|
293
|
+
* when the projection is read). Returns the document's text, or null when there are no catalog
|
|
294
|
+
* files.
|
|
295
|
+
*
|
|
296
|
+
* @param {Iterable<{ path: string, raw: string | null }>} entries `content/`-relative paths
|
|
297
|
+
* @returns {string | null}
|
|
298
|
+
*/
|
|
299
|
+
export function catalogListingProjection(entries) {
|
|
300
|
+
const products = [];
|
|
301
|
+
const collections = [];
|
|
302
|
+
const failures = [];
|
|
303
|
+
let any = false;
|
|
304
|
+
for (const { path, raw } of entries) {
|
|
305
|
+
if (classifyCatalogPath(path) === null) continue;
|
|
306
|
+
any = true;
|
|
307
|
+
if (raw === null) {
|
|
308
|
+
failures.push({ path, issues: ["listed in the catalog but unreadable"] });
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
const parsed = parse(path, raw, "", { available: true });
|
|
312
|
+
if (parsed.issues.length > 0) {
|
|
313
|
+
failures.push({ path, issues: parsed.issues });
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const doc = JSON.parse(raw);
|
|
317
|
+
if (parsed.kind === "product") products.push(listingProduct(catalogFileRecord("product", doc)));
|
|
318
|
+
else collections.push(doc);
|
|
319
|
+
}
|
|
320
|
+
if (!any) return null;
|
|
321
|
+
return JSON.stringify(
|
|
322
|
+
failures.length > 0
|
|
323
|
+
? { version: CATALOG_LISTING_VERSION, failures }
|
|
324
|
+
: { version: CATALOG_LISTING_VERSION, products, collections },
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Read a listing projection for the store being read, records stamped exactly as file reads
|
|
330
|
+
* are. Throws `CatalogFileError` with the failures the projection recorded, or when the document
|
|
331
|
+
* is not a projection this module wrote.
|
|
332
|
+
*
|
|
333
|
+
* @param {string} raw
|
|
334
|
+
* @param {{ tenantScope: string, inventoryProvider?: string }} store
|
|
335
|
+
* @returns {{ products: object[], collections: object[] }}
|
|
336
|
+
*/
|
|
337
|
+
export function readCatalogListing(raw, { tenantScope, inventoryProvider }) {
|
|
338
|
+
const availability = declaredAvailability(inventoryProvider);
|
|
339
|
+
/** @type {any} */
|
|
340
|
+
let doc = null;
|
|
341
|
+
try {
|
|
342
|
+
doc = JSON.parse(raw);
|
|
343
|
+
} catch {
|
|
344
|
+
doc = null;
|
|
345
|
+
}
|
|
346
|
+
if (!isObject(doc) || doc.version !== CATALOG_LISTING_VERSION) {
|
|
347
|
+
throw new CatalogFileError([
|
|
348
|
+
{ path: "catalog/", issues: ["the catalog listing projection is unreadable; reconcile the preview again"] },
|
|
349
|
+
]);
|
|
350
|
+
}
|
|
351
|
+
if (Array.isArray(doc.failures)) throw new CatalogFileError(doc.failures);
|
|
352
|
+
return {
|
|
353
|
+
products: doc.products.map((/** @type {unknown} */ p) => stamp("product", p, tenantScope, availability)),
|
|
354
|
+
collections: doc.collections.map((/** @type {unknown} */ c) => stamp("collection", c, tenantScope, availability)),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
@@ -22,10 +22,13 @@ import type { ChromeConfig } from "../chrome/model.js";
|
|
|
22
22
|
import type { CustomizationEnv, KvGet, TenantConfig } from "@tot/public-runtime";
|
|
23
23
|
import {
|
|
24
24
|
contentArtifactPath,
|
|
25
|
+
derivedArtifactPath,
|
|
25
26
|
listPublishedArtifactPaths,
|
|
26
27
|
listVersionArtifactPaths,
|
|
27
28
|
readPublishedArtifact,
|
|
28
29
|
readVersionArtifact,
|
|
30
|
+
resolvePublishedArtifactKey,
|
|
31
|
+
resolveVersionArtifactKey,
|
|
29
32
|
tenantDirRelative,
|
|
30
33
|
tenantDirSegment,
|
|
31
34
|
} from "@tot/public-runtime";
|
|
@@ -86,6 +89,17 @@ export interface ContentProvider {
|
|
|
86
89
|
* providers), so a caller that reads many artifacts in one request budgets against it.
|
|
87
90
|
*/
|
|
88
91
|
readonly perRequestArtifactReads?: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* A document the reconcile derived for the selected version (`derivedArtifactPath`), as its
|
|
94
|
+
* immutable KV key plus a reader, or null when the version has none. The key names
|
|
95
|
+
* content-addressed bytes, so a caller may keep what it parsed from them under it.
|
|
96
|
+
*/
|
|
97
|
+
derivedArtifact?(name: string): Promise<DerivedArtifact | null>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface DerivedArtifact {
|
|
101
|
+
key: string;
|
|
102
|
+
read(): Promise<string | null>;
|
|
89
103
|
}
|
|
90
104
|
|
|
91
105
|
// ---------------------------------------------------------------------------
|
|
@@ -695,6 +709,14 @@ export class PublishedContentProvider implements ContentProvider {
|
|
|
695
709
|
return this.publishedRaw(relative);
|
|
696
710
|
}
|
|
697
711
|
|
|
712
|
+
async derivedArtifact(name: string): Promise<DerivedArtifact | null> {
|
|
713
|
+
const get = this.cust.get;
|
|
714
|
+
const key = await resolvePublishedArtifactKey(
|
|
715
|
+
get, this.tenantId, this.cust.env, derivedArtifactPath(this.tenantId, name),
|
|
716
|
+
);
|
|
717
|
+
return key ? { key, read: () => get(key) } : null;
|
|
718
|
+
}
|
|
719
|
+
|
|
698
720
|
async listArtifactPaths(prefix = ""): Promise<string[] | null> {
|
|
699
721
|
const root = contentArtifactPath(this.tenantId, "");
|
|
700
722
|
const paths = await listPublishedArtifactPaths(
|
|
@@ -801,6 +823,14 @@ export class VersionPinnedContentProvider implements ContentProvider {
|
|
|
801
823
|
async listArtifactPaths(prefix = ""): Promise<string[] | null> {
|
|
802
824
|
return listVersionArtifactPaths(this.get, this.tenantId, this.versionId, prefix);
|
|
803
825
|
}
|
|
826
|
+
|
|
827
|
+
async derivedArtifact(name: string): Promise<DerivedArtifact | null> {
|
|
828
|
+
const get = this.get;
|
|
829
|
+
const key = await resolveVersionArtifactKey(
|
|
830
|
+
get, this.tenantId, this.versionId, derivedArtifactPath(this.tenantId, name),
|
|
831
|
+
);
|
|
832
|
+
return key ? { key, read: () => get(key) } : null;
|
|
833
|
+
}
|
|
804
834
|
}
|
|
805
835
|
|
|
806
836
|
// ---------------------------------------------------------------------------
|
|
@@ -865,6 +895,12 @@ export class DevDraftContentProvider implements ContentProvider {
|
|
|
865
895
|
listArtifactPaths(prefix?: string): Promise<string[] | null> {
|
|
866
896
|
return this.base.listArtifactPaths(prefix);
|
|
867
897
|
}
|
|
898
|
+
get perRequestArtifactReads(): boolean | undefined {
|
|
899
|
+
return this.base.perRequestArtifactReads;
|
|
900
|
+
}
|
|
901
|
+
derivedArtifact(name: string): Promise<DerivedArtifact | null> {
|
|
902
|
+
return this.base.derivedArtifact?.(name) ?? Promise.resolve(null);
|
|
903
|
+
}
|
|
868
904
|
}
|
|
869
905
|
|
|
870
906
|
const PLACEHOLDER_TOKEN_RE = /PLACEHOLDER|^$/i;
|
|
@@ -46,6 +46,8 @@ export class FixtureToTClient implements ToTClient {
|
|
|
46
46
|
private readonly collections: CatalogCollection[],
|
|
47
47
|
private readonly scope: string,
|
|
48
48
|
private readonly reviews: Record<string, Review[]> = {},
|
|
49
|
+
/** The full record for a product page, when the products above are listing records. */
|
|
50
|
+
private readonly detail?: (handle: string) => Promise<CatalogProduct | null>,
|
|
49
51
|
) {}
|
|
50
52
|
|
|
51
53
|
async getReviews(handle: string): Promise<Review[]> {
|
|
@@ -75,6 +77,7 @@ export class FixtureToTClient implements ToTClient {
|
|
|
75
77
|
|
|
76
78
|
async getProductByHandle(handle: string): Promise<CatalogProduct | null> {
|
|
77
79
|
// NOTE: resolves drafts too (preview/direct links). Callers gate visibility.
|
|
80
|
+
if (this.detail) return this.detail(handle);
|
|
78
81
|
return this.scoped().find((p) => p.handle === handle) ?? null;
|
|
79
82
|
}
|
|
80
83
|
|
|
@@ -214,7 +217,9 @@ export async function createToTClient(
|
|
|
214
217
|
return new DeferredToTClient(async () => {
|
|
215
218
|
const files = await readTenantCatalogFiles(tenant, content);
|
|
216
219
|
if (files) {
|
|
217
|
-
return new FixtureToTClient(
|
|
220
|
+
return new FixtureToTClient(
|
|
221
|
+
files.products, files.collections, tenant.tot_data_scope, {}, files.productDetail,
|
|
222
|
+
);
|
|
218
223
|
}
|
|
219
224
|
const { products, collections, reviews } = await loadFixtures(tenant.tot_data_scope);
|
|
220
225
|
return new FixtureToTClient(products, collections, tenant.tot_data_scope, reviews);
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* uses (`ContentProvider.readArtifact` / `listArtifactPaths`), so it follows the store's
|
|
5
5
|
* `.tot/config.json` content mapping and whichever version the request selected:
|
|
6
6
|
*
|
|
7
|
-
* - hosted preview (Worker): the reconciled version
|
|
7
|
+
* - hosted preview (Worker): the reconciled version in KV. Listings, search and the
|
|
8
|
+
* sitemap read the version's derived listing projection (one read, parsed once per
|
|
9
|
+
* isolate and version); a product page reads that product's full file;
|
|
8
10
|
* - static publish (both CDNs) and `tot dev`: the store's checkout as materialized
|
|
9
11
|
* into the build (`LocalContentProvider`'s build-time globs).
|
|
10
12
|
*
|
|
@@ -13,31 +15,41 @@
|
|
|
13
15
|
* silently missing.
|
|
14
16
|
*/
|
|
15
17
|
import type { CatalogCollection, CatalogProduct, TenantConfig } from "@tot/public-runtime";
|
|
18
|
+
import { CATALOG_LISTING_ARTIFACT } from "@tot/public-runtime";
|
|
16
19
|
import {
|
|
17
20
|
CATALOG_ARTIFACT_PREFIX,
|
|
21
|
+
CATALOG_PRODUCTS_PREFIX,
|
|
22
|
+
CATALOG_HANDLE_RE,
|
|
18
23
|
CatalogFileError,
|
|
24
|
+
readCatalogFile,
|
|
19
25
|
readCatalogFiles,
|
|
26
|
+
readCatalogListing,
|
|
20
27
|
type CatalogStore,
|
|
21
28
|
} from "@tot/public-runtime/catalog-files";
|
|
22
29
|
import { LocalContentProvider, type ContentProvider } from "../storyblok/provider.js";
|
|
23
30
|
|
|
24
31
|
export type CatalogArtifactSource = Pick<
|
|
25
32
|
ContentProvider,
|
|
26
|
-
"readArtifact" | "listArtifactPaths" | "perRequestArtifactReads"
|
|
33
|
+
"readArtifact" | "listArtifactPaths" | "perRequestArtifactReads" | "derivedArtifact"
|
|
27
34
|
>;
|
|
28
35
|
|
|
29
36
|
export interface TenantCatalogFiles {
|
|
30
37
|
products: CatalogProduct[];
|
|
31
38
|
collections: CatalogCollection[];
|
|
39
|
+
/**
|
|
40
|
+
* The full record for a product page, when `products` are listing records (the listing
|
|
41
|
+
* projection drops `description_html` and `metafields`). Absent: `products` are full.
|
|
42
|
+
*/
|
|
43
|
+
productDetail?: (handle: string) => Promise<CatalogProduct | null>;
|
|
32
44
|
}
|
|
33
45
|
|
|
34
46
|
/**
|
|
35
|
-
* The most catalog files one request may read from a per-request source
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
47
|
+
* The most catalog files one request may read from a per-request source that has no listing
|
|
48
|
+
* projection (a version reconciled before projections existed). Each artifact read is its own
|
|
49
|
+
* KV call, and the rest of the page needs its own. Measured 2026-10-06 on giantvapes.com's
|
|
50
|
+
* catalog (2,959 products, 357 collections, 27.2 MB of files): read file by file it is 3,316
|
|
51
|
+
* calls and ~40 MB of parsed heap per request; its listing projection is one 11.4 MB read
|
|
52
|
+
* (1.55 MB gzipped), 18 MB of heap, parsed in ~20 ms once per isolate and version.
|
|
41
53
|
*/
|
|
42
54
|
export const CATALOG_REQUEST_READ_BUDGET = 800;
|
|
43
55
|
|
|
@@ -94,10 +106,32 @@ function readBuildCatalog(
|
|
|
94
106
|
return { products, collections };
|
|
95
107
|
}
|
|
96
108
|
|
|
109
|
+
/** Listing projections parsed in this isolate, by immutable KV key and the store reading them. */
|
|
110
|
+
const parsedListings = new Map<string, { products: CatalogProduct[]; collections: CatalogCollection[] }>();
|
|
111
|
+
const PARSED_LISTINGS_KEPT = 2;
|
|
112
|
+
|
|
113
|
+
async function readListing(
|
|
114
|
+
listing: { key: string; read(): Promise<string | null> },
|
|
115
|
+
store: CatalogStore,
|
|
116
|
+
): Promise<{ products: CatalogProduct[]; collections: CatalogCollection[] }> {
|
|
117
|
+
const cacheKey = `${listing.key}\0${store.tenantScope}\0${store.inventoryProvider ?? ""}`;
|
|
118
|
+
const hit = parsedListings.get(cacheKey);
|
|
119
|
+
if (hit) return hit;
|
|
120
|
+
const raw = await listing.read();
|
|
121
|
+
if (raw === null) {
|
|
122
|
+
throw new CatalogFileError([{ path: "catalog/", issues: ["the catalog listing projection is unreadable; reconcile the preview again"] }]);
|
|
123
|
+
}
|
|
124
|
+
const parsed = readCatalogListing(raw, store);
|
|
125
|
+
parsedListings.set(cacheKey, parsed);
|
|
126
|
+
while (parsedListings.size > PARSED_LISTINGS_KEPT) parsedListings.delete(parsedListings.keys().next().value!);
|
|
127
|
+
return parsed;
|
|
128
|
+
}
|
|
129
|
+
|
|
97
130
|
/**
|
|
98
131
|
* The store's catalog files, or null when it has none. Throws `CatalogFileError` naming
|
|
99
|
-
* every file that fails validation
|
|
100
|
-
*
|
|
132
|
+
* every file that fails validation. A per-request source serves its version's listing
|
|
133
|
+
* projection; one without a projection reads its files only up to
|
|
134
|
+
* `CATALOG_REQUEST_READ_BUDGET`, refusing above it before reading any of them.
|
|
101
135
|
*/
|
|
102
136
|
export async function readTenantCatalogFiles(
|
|
103
137
|
tenant: TenantConfig,
|
|
@@ -106,19 +140,32 @@ export async function readTenantCatalogFiles(
|
|
|
106
140
|
const { source, paths } = await catalogPaths(tenant, content);
|
|
107
141
|
if (paths.length === 0) return null;
|
|
108
142
|
|
|
143
|
+
const store: CatalogStore = {
|
|
144
|
+
tenantScope: tenant.tot_data_scope,
|
|
145
|
+
inventoryProvider: tenant.inventory?.provider,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const listing = await source.derivedArtifact?.(CATALOG_LISTING_ARTIFACT);
|
|
149
|
+
if (listing) {
|
|
150
|
+
const { products, collections } = await readListing(listing, store);
|
|
151
|
+
const productDetail = async (handle: string): Promise<CatalogProduct | null> => {
|
|
152
|
+
if (!CATALOG_HANDLE_RE.test(handle)) return null;
|
|
153
|
+
const path = `${CATALOG_PRODUCTS_PREFIX}${handle}.json`;
|
|
154
|
+
const raw = await source.readArtifact(path);
|
|
155
|
+
if (raw === null) return null;
|
|
156
|
+
const read = readCatalogFile(path, raw, store);
|
|
157
|
+
return read.kind === "product" ? read.record : null;
|
|
158
|
+
};
|
|
159
|
+
return { products, collections, productDetail };
|
|
160
|
+
}
|
|
161
|
+
|
|
109
162
|
if (source.perRequestArtifactReads && paths.length > CATALOG_REQUEST_READ_BUDGET) {
|
|
110
163
|
throw new Error(
|
|
111
|
-
`catalog: ${tenant.tenant_id} has ${paths.length} files in content/catalog
|
|
112
|
-
`
|
|
113
|
-
`each, inside the Worker's per-request limit). \`tot dev\` and the published site render ` +
|
|
114
|
-
`this catalog from the checkout.`,
|
|
164
|
+
`catalog: ${tenant.tenant_id} has ${paths.length} files in content/catalog/ and this preview ` +
|
|
165
|
+
`version has no catalog listing; reconcile the preview again to build it.`,
|
|
115
166
|
);
|
|
116
167
|
}
|
|
117
168
|
|
|
118
|
-
const store: CatalogStore = {
|
|
119
|
-
tenantScope: tenant.tot_data_scope,
|
|
120
|
-
inventoryProvider: tenant.inventory?.provider,
|
|
121
|
-
};
|
|
122
169
|
const entries = await Promise.all(paths.map(async (path) => ({ path, raw: await source.readArtifact(path) })));
|
|
123
170
|
return source.perRequestArtifactReads
|
|
124
171
|
? readCatalogFiles(entries, store)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.93",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import {
|
|
11
11
|
readCatalogFile as readKitCatalogFile,
|
|
12
12
|
readCatalogFiles as readKitCatalogFiles,
|
|
13
|
+
readCatalogListing as readKitCatalogListing,
|
|
13
14
|
type CatalogStore,
|
|
14
15
|
} from "../../../agency-kit/tools/lib/catalog-model/files.mjs";
|
|
15
16
|
import type { CatalogCollection, CatalogProduct } from "./product.js";
|
|
@@ -22,6 +23,7 @@ export {
|
|
|
22
23
|
CATALOG_PRODUCTS_PREFIX,
|
|
23
24
|
CatalogFileError,
|
|
24
25
|
catalogFileIssues,
|
|
26
|
+
catalogListingProjection,
|
|
25
27
|
classifyCatalogPath,
|
|
26
28
|
declaredAvailability,
|
|
27
29
|
planCatalogFiles,
|
|
@@ -49,3 +51,18 @@ export function readCatalogFiles(
|
|
|
49
51
|
collections: CatalogCollection[];
|
|
50
52
|
};
|
|
51
53
|
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The derived listing projection's catalog: listing records (no `description_html`, no
|
|
57
|
+
* `metafields`, `description_text` shortened) for every product, collections whole. Throws
|
|
58
|
+
* `CatalogFileError` with the failures the reconcile recorded.
|
|
59
|
+
*/
|
|
60
|
+
export function readCatalogListing(
|
|
61
|
+
raw: string,
|
|
62
|
+
store: CatalogStore,
|
|
63
|
+
): { products: CatalogProduct[]; collections: CatalogCollection[] } {
|
|
64
|
+
return readKitCatalogListing(raw, store) as unknown as {
|
|
65
|
+
products: CatalogProduct[];
|
|
66
|
+
collections: CatalogCollection[];
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -67,6 +67,18 @@ export function contentArtifactPath(tenantId: string, relative: string): string
|
|
|
67
67
|
return `tenants/${tenantId}/content/${relative}`;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Canonical artifact path for a document the reconcile DERIVES from a tenant's files (never one
|
|
72
|
+
* the tenant writes): `tenants/<id>/.derived/<name>`, outside `content/` so no repo file can
|
|
73
|
+
* collide with it. Recorded in the same immutable site version as the files it derives from.
|
|
74
|
+
*/
|
|
75
|
+
export function derivedArtifactPath(tenantId: string, name: string): string {
|
|
76
|
+
return `tenants/${tenantId}/.derived/${name}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The derived catalog listing projection (`catalogListingProjection` in the kit's catalog module). */
|
|
80
|
+
export const CATALOG_LISTING_ARTIFACT = "catalog-listing.json";
|
|
81
|
+
|
|
70
82
|
async function readPointer(
|
|
71
83
|
get: KvGet,
|
|
72
84
|
tenantId: string,
|
|
@@ -270,6 +282,21 @@ export async function readPublishedArtifact(
|
|
|
270
282
|
tenantId: string,
|
|
271
283
|
env: CustomizationEnv,
|
|
272
284
|
path: string,
|
|
285
|
+
): Promise<string | null> {
|
|
286
|
+
const key = await resolvePublishedArtifactKey(get, tenantId, env, path);
|
|
287
|
+
return key ? get(key) : null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The KV key holding the published artifact for (tenant, env, path), resolved exactly as
|
|
292
|
+
* {@link readPublishedArtifact} resolves it, or null. The key names immutable, content-addressed
|
|
293
|
+
* bytes, so a caller may cache what it parsed from them under the key.
|
|
294
|
+
*/
|
|
295
|
+
export async function resolvePublishedArtifactKey(
|
|
296
|
+
get: KvGet,
|
|
297
|
+
tenantId: string,
|
|
298
|
+
env: CustomizationEnv,
|
|
299
|
+
path: string,
|
|
273
300
|
): Promise<string | null> {
|
|
274
301
|
const pointer = await readPointer(get, tenantId);
|
|
275
302
|
if (!pointer) return null;
|
|
@@ -277,13 +304,25 @@ export async function readPublishedArtifact(
|
|
|
277
304
|
const channel = channelFromEnv(env);
|
|
278
305
|
if (pointer.channels?.[channel]) {
|
|
279
306
|
// Versioned model engaged for this channel — authoritative.
|
|
280
|
-
|
|
307
|
+
const { versionId } = await resolveServableChannelVersion(get, tenantId, channel);
|
|
308
|
+
return versionId ? resolveVersionArtifactKey(get, tenantId, versionId, path) : null;
|
|
281
309
|
}
|
|
282
310
|
|
|
283
311
|
// Legacy per-path selection.
|
|
284
312
|
const versionId = pointer?.[env]?.[path];
|
|
285
|
-
|
|
286
|
-
|
|
313
|
+
return versionId ? customizationArtifactKey(tenantId, versionId) : null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The KV key of one artifact in an immutable site version, or null when the version lacks it. */
|
|
317
|
+
export async function resolveVersionArtifactKey(
|
|
318
|
+
get: KvGet,
|
|
319
|
+
tenantId: string,
|
|
320
|
+
versionId: string,
|
|
321
|
+
path: string,
|
|
322
|
+
): Promise<string | null> {
|
|
323
|
+
const manifest = await readSiteVersion(get, tenantId, versionId);
|
|
324
|
+
const contentHash = manifest?.artifacts?.[path];
|
|
325
|
+
return contentHash ? customizationArtifactKey(tenantId, contentHash) : null;
|
|
287
326
|
}
|
|
288
327
|
|
|
289
328
|
/**
|
|
@@ -299,10 +338,8 @@ export async function readVersionArtifact(
|
|
|
299
338
|
versionId: string,
|
|
300
339
|
path: string,
|
|
301
340
|
): Promise<string | null> {
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
if (!contentHash) return null;
|
|
305
|
-
return get(customizationArtifactKey(tenantId, contentHash));
|
|
341
|
+
const key = await resolveVersionArtifactKey(get, tenantId, versionId, path);
|
|
342
|
+
return key ? get(key) : null;
|
|
306
343
|
}
|
|
307
344
|
|
|
308
345
|
/**
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Force-injectable compliance markup for the raw-HTML serve path.
|
|
3
|
-
*
|
|
4
|
-
* `rawHtmlResponse` (rawMarketingHtml.ts) bypasses Astro's Layout, so these are
|
|
5
|
-
* the platform-owned anchors that CAN be guaranteed on a raw document
|
|
6
|
-
* independent of its content: the FDA nicotine warning, the shipping/
|
|
7
|
-
* jurisdiction notice, and the excise-tax disclosure. Each mirrors the exact
|
|
8
|
-
* markup its Layout.astro sibling renders (NicotineWarning.astro "bar" variant,
|
|
9
|
-
* ShippingRestrictionNotice.astro "footer" variant via ComplianceNotice.astro,
|
|
10
|
-
* and Layout's own excise-tax <p>) so a raw and a block-composed page present
|
|
11
|
-
* these anchors identically. See `rawHtmlPolicy` for which anchors this does
|
|
12
|
-
* and doesn't cover (notably: NOT `age-gate`).
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
function escapeHtml(value: string): string {
|
|
16
|
-
return value
|
|
17
|
-
.replace(/&/g, "&")
|
|
18
|
-
.replace(/</g, "<")
|
|
19
|
-
.replace(/>/g, ">")
|
|
20
|
-
.replace(/"/g, """)
|
|
21
|
-
.replace(/'/g, "'");
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const NICOTINE_WARNING_TEXT =
|
|
25
|
-
"WARNING: This product contains nicotine. Nicotine is an addictive chemical.";
|
|
26
|
-
|
|
27
|
-
/** Mirrors NicotineWarning.astro's "bar" variant. */
|
|
28
|
-
export function nicotineWarningBarHtml(): string {
|
|
29
|
-
return (
|
|
30
|
-
'<div class="border-b-2 border-[var(--color-text)] bg-[var(--color-text)] text-[var(--color-bg)]" role="note" aria-label="Health warning" data-compliance="nicotine-warning">' +
|
|
31
|
-
'<p class="container-prose py-2 text-center text-[0.72rem] font-semibold uppercase tracking-[0.06em] sm:text-xs">' +
|
|
32
|
-
NICOTINE_WARNING_TEXT +
|
|
33
|
-
"</p></div>"
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Mirrors ShippingRestrictionNotice.astro's "footer" variant (via ComplianceNotice.astro). Empty when `text` is absent — same as the Astro component. */
|
|
38
|
-
export function shippingRestrictionNoticeHtml(text: string | undefined): string {
|
|
39
|
-
if (!text) return "";
|
|
40
|
-
return (
|
|
41
|
-
'<div class="border-t border-[var(--color-border)] bg-[var(--color-bg)]" data-compliance="shipping-restriction">' +
|
|
42
|
-
'<p class="container-prose py-3 text-center text-sm text-[var(--color-muted)]" role="note" aria-label="Shipping restriction notice">' +
|
|
43
|
-
escapeHtml(text) +
|
|
44
|
-
"</p></div>"
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Mirrors Layout.astro's excise-tax disclosure <p>. */
|
|
49
|
-
export function exciseTaxNoticeHtml(): string {
|
|
50
|
-
return (
|
|
51
|
-
'<p class="border-t border-[var(--color-border)] bg-[var(--color-bg)] py-2.5 text-center text-xs text-[var(--color-muted)]" data-capability="excise-tax">' +
|
|
52
|
-
"Applicable excise tax is calculated at checkout.</p>"
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export interface RawComplianceChromeInput {
|
|
57
|
-
nicotineWarning?: boolean;
|
|
58
|
-
shippingRestriction?: string;
|
|
59
|
-
exciseTaxEnabled?: boolean;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** The markup to inject at `rawHtmlResponse`'s body-start boundary. */
|
|
63
|
-
export function renderRawComplianceTop(input: RawComplianceChromeInput): string {
|
|
64
|
-
return input.nicotineWarning ? nicotineWarningBarHtml() : "";
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** The markup to inject at `rawHtmlResponse`'s body-deferred boundary. */
|
|
68
|
-
export function renderRawComplianceBottom(input: RawComplianceChromeInput): string {
|
|
69
|
-
let out = shippingRestrictionNoticeHtml(input.shippingRestriction);
|
|
70
|
-
if (input.exciseTaxEnabled) out += exciseTaxNoticeHtml();
|
|
71
|
-
return out;
|
|
72
|
-
}
|