@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.
@@ -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(files.products, files.collections, tenant.tot_data_scope);
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 manifest in KV, one read per file;
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. A Worker
36
- * invocation may make 1,000 calls to KV and other bindings, and each artifact read is
37
- * one of them; the rest of the budget is left to the page's other reads. Measured
38
- * against a 2,959-product, 357-collection catalog (3,316 files, 44.6 MB at 13.4 KB per
39
- * product, ~290 ms to validate): that store cannot be read file by file per request on
40
- * the hosted preview, while `tot dev` and the static publish read it from the checkout.
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, and refuses a per-request source holding more files
100
- * than `CATALOG_REQUEST_READ_BUDGET` before reading any of them.
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/, and the hosted ` +
112
- `preview reads at most ${CATALOG_REQUEST_READ_BUDGET} catalog files per request (one read ` +
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.91",
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
- return readVersionedArtifact(get, tenantId, channel, path);
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
- if (!versionId) return null;
286
- return get(customizationArtifactKey(tenantId, versionId));
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 manifest = await readSiteVersion(get, tenantId, versionId);
303
- const contentHash = manifest?.artifacts?.[path];
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, "&amp;")
18
- .replace(/</g, "&lt;")
19
- .replace(/>/g, "&gt;")
20
- .replace(/"/g, "&quot;")
21
- .replace(/'/g, "&#39;");
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
- }