@tokenoftrust/storefront-runner 2.2.126 → 2.3.1

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.
Files changed (109) hide show
  1. package/apps/storefront/package.json +3 -1
  2. package/apps/storefront/public/shared/commerce-chrome.css +57 -15
  3. package/apps/storefront/public/shared/commerce-marketing.css +7 -7
  4. package/apps/storefront/src/components/Badge.astro +1 -1
  5. package/apps/storefront/src/components/Breadcrumbs.astro +8 -3
  6. package/apps/storefront/src/components/Section.astro +4 -1
  7. package/apps/storefront/src/components/Seo.astro +7 -5
  8. package/apps/storefront/src/components/blog/BlogIndexView.astro +22 -13
  9. package/apps/storefront/src/components/blog/ShareRow.astro +43 -0
  10. package/apps/storefront/src/components/chrome/AnnouncementBar.astro +1 -1
  11. package/apps/storefront/src/components/chrome/NavDropdown.astro +7 -7
  12. package/apps/storefront/src/components/chrome/SiteFooter.astro +17 -0
  13. package/apps/storefront/src/components/chrome/SiteHeader.astro +5 -5
  14. package/apps/storefront/src/components/commerce/TagFilterCloud.astro +1 -1
  15. package/apps/storefront/src/components/compliance/NicotineWarning.astro +12 -22
  16. package/apps/storefront/src/components/compliance/PactActNotice.astro +9 -10
  17. package/apps/storefront/src/components/content/ProseSections.astro +2 -2
  18. package/apps/storefront/src/components/home/Hero.astro +34 -12
  19. package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +39 -5
  20. package/apps/storefront/src/components/islands/VariantSelector.tsx +3 -3
  21. package/apps/storefront/src/components/marketing/CardGrid.astro +1 -1
  22. package/apps/storefront/src/components/marketing/CodeSample.astro +1 -1
  23. package/apps/storefront/src/components/marketing/CtaBand.astro +1 -1
  24. package/apps/storefront/src/components/marketing/Faq.astro +1 -1
  25. package/apps/storefront/src/components/marketing/Integrations.astro +1 -1
  26. package/apps/storefront/src/components/marketing/MarketingCtas.astro +1 -1
  27. package/apps/storefront/src/components/marketing/MarketingHero.astro +1 -1
  28. package/apps/storefront/src/components/marketing/ProofStrip.astro +1 -1
  29. package/apps/storefront/src/components/marketing/SplitCompare.astro +1 -1
  30. package/apps/storefront/src/components/marketing/Steps.astro +1 -1
  31. package/apps/storefront/src/components/marketing/Testimonials.astro +2 -2
  32. package/apps/storefront/src/components/marketing/TrustBar.astro +1 -1
  33. package/apps/storefront/src/components/membership/TierComparisonTable.astro +1 -1
  34. package/apps/storefront/src/components/plp/ActiveFilters.astro +1 -1
  35. package/apps/storefront/src/components/plp/FacetSidebar.astro +4 -4
  36. package/apps/storefront/src/components/plp/Pagination.astro +1 -1
  37. package/apps/storefront/src/components/plp/SortSelect.astro +1 -1
  38. package/apps/storefront/src/components/style-guide/StyleGuideNav.astro +18 -3
  39. package/apps/storefront/src/components/the-build/TheBuildIndexView.astro +9 -5
  40. package/apps/storefront/src/config/devTenantSeed.ts +23 -34
  41. package/apps/storefront/src/config/resolver.ts +0 -38
  42. package/apps/storefront/src/layouts/Layout.astro +8 -1
  43. package/apps/storefront/src/lib/assets/preview-version-cookie.ts +52 -5
  44. package/apps/storefront/src/lib/blog/pagination.ts +20 -10
  45. package/apps/storefront/src/lib/blog/presentation.ts +94 -0
  46. package/apps/storefront/src/lib/blog/rss.ts +1 -1
  47. package/apps/storefront/src/lib/blog/types.ts +24 -7
  48. package/apps/storefront/src/lib/breadcrumbs.ts +25 -0
  49. package/apps/storefront/src/lib/checkoutCommerce.ts +26 -3
  50. package/apps/storefront/src/lib/chrome/model.ts +4 -0
  51. package/apps/storefront/src/lib/cloudflare-workers.d.ts +53 -0
  52. package/apps/storefront/src/lib/compliance/rawComplianceNotices.ts +38 -18
  53. package/apps/storefront/src/lib/dashboard/delegateGate.ts +28 -0
  54. package/apps/storefront/src/lib/homeVisualParity.ts +8 -5
  55. package/apps/storefront/src/lib/jsonld.ts +5 -5
  56. package/apps/storefront/src/lib/pinnedRouteRewriteBoundary.ts +20 -2
  57. package/apps/storefront/src/lib/rawChrome.ts +74 -12
  58. package/apps/storefront/src/lib/runtimeEnv.ts +1 -1
  59. package/apps/storefront/src/lib/seo/documentTitle.ts +19 -0
  60. package/apps/storefront/src/lib/shared/addToRelease.ts +32 -0
  61. package/apps/storefront/src/lib/storyblok/content-model.ts +3 -0
  62. package/apps/storefront/src/lib/styleGuideThemes.ts +20 -22
  63. package/apps/storefront/src/pages/[...slug].astro +13 -8
  64. package/apps/storefront/src/pages/blog/[slug].astro +56 -24
  65. package/apps/storefront/src/pages/blog/author/[author].astro +17 -9
  66. package/apps/storefront/src/pages/blog/category/[category].astro +18 -10
  67. package/apps/storefront/src/pages/blog/index.astro +2 -0
  68. package/apps/storefront/src/pages/blog/page/[n].astro +2 -0
  69. package/apps/storefront/src/pages/blog/tag/[tag].astro +18 -10
  70. package/apps/storefront/src/pages/collections/[handle].astro +5 -4
  71. package/apps/storefront/src/pages/collections/index.astro +4 -3
  72. package/apps/storefront/src/pages/index.astro +34 -27
  73. package/apps/storefront/src/pages/products/[handle].astro +5 -4
  74. package/apps/storefront/src/pages/saved.astro +4 -3
  75. package/apps/storefront/src/pages/search.astro +4 -3
  76. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +129 -118
  77. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +19 -15
  78. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +23 -27
  79. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -14
  80. package/apps/storefront/src/pages/style-guide/index.astro +6 -5
  81. package/apps/storefront/src/pages/tenants/[id]/[...path].ts +32 -7
  82. package/apps/storefront/src/pages/the-build/[slug].astro +44 -20
  83. package/apps/storefront/src/pages/the-build/index.astro +2 -0
  84. package/apps/storefront/src/pages/the-build/page/[n].astro +2 -0
  85. package/apps/storefront/src/styles/admin.css +47 -0
  86. package/apps/storefront/src/styles/global.css +104 -56
  87. package/apps/storefront/src/themes/derivedTokens.ts +34 -0
  88. package/apps/storefront/src/themes/schema.ts +6 -0
  89. package/apps/storefront/test/pipeline/harness.ts +164 -0
  90. package/apps/storefront/test/pipeline/intake-worker.mjs +14 -0
  91. package/apps/storefront/test/pipeline/pipeline-host.ts +77 -0
  92. package/apps/storefront/test/pipeline/synthetic-job.ts +156 -0
  93. package/apps/storefront/vitest.workers.config.cts +67 -0
  94. package/docs/widget-library-guide.md +1496 -0
  95. package/package.json +1 -1
  96. package/packages/cli/src/declared-config.mjs +400 -5
  97. package/packages/public-runtime/package.json +1 -0
  98. package/packages/public-runtime/src/checkout.ts +13 -0
  99. package/packages/public-runtime/src/chrome.ts +93 -2
  100. package/packages/public-runtime/src/compliance-coherence.d.mts +17 -0
  101. package/packages/public-runtime/src/compliance-coherence.mjs +84 -0
  102. package/packages/public-runtime/src/declared-tenant-config.d.mts +1 -1
  103. package/packages/public-runtime/src/declared-tenant-config.mjs +6 -2
  104. package/packages/public-runtime/src/extension-contract-values.d.mts +4 -1
  105. package/packages/public-runtime/src/extension-contract-values.mjs +14 -1
  106. package/packages/public-runtime/src/extension-contract.ts +2 -0
  107. package/packages/public-runtime/src/tenant.ts +48 -4
  108. package/pnpm-lock.runner.yaml +469 -0
  109. package/scripts/tenant/validate.mjs +27 -1
@@ -3,15 +3,17 @@
3
3
  *
4
4
  * `rawHtmlResponse` (rawMarketingHtml.ts) bypasses Astro's Layout, so these are
5
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
6
+ * independent of its content: the FDA nicotine warning, the PACT Act notice, the
7
+ * shipping/jurisdiction notice, and the excise-tax disclosure. Each mirrors the exact
8
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
9
+ * PactActNotice.astro and ShippingRestrictionNotice.astro "footer" variants via
10
+ * ComplianceNotice.astro, and Layout's own excise-tax <p>) so a raw and a block-composed page present
11
11
  * these anchors identically. See `rawHtmlPolicy` for which anchors this does
12
12
  * and doesn't cover (notably: NOT `age-gate`).
13
13
  */
14
14
 
15
+ import { nicotineWarningHtml } from "../../../../../packages/cli/src/declared-config.mjs";
16
+
15
17
  function escapeHtml(value: string): string {
16
18
  return value
17
19
  .replace(/&/g, "&amp;")
@@ -21,17 +23,9 @@ function escapeHtml(value: string): string {
21
23
  .replace(/'/g, "&#39;");
22
24
  }
23
25
 
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
- );
26
+ /** NicotineWarning.astro's "bar" variant — the same `nicotineWarningHtml` it renders. */
27
+ export function nicotineWarningBarHtml(declaration: unknown): string {
28
+ return nicotineWarningHtml(declaration, "bar");
35
29
  }
36
30
 
37
31
  /** Mirrors ShippingRestrictionNotice.astro's "footer" variant (via ComplianceNotice.astro). Empty when `text` is absent — same as the Astro component. */
@@ -45,6 +39,28 @@ export function shippingRestrictionNoticeHtml(text: string | undefined): string
45
39
  );
46
40
  }
47
41
 
42
+ /**
43
+ * The PACT Act notice wording — the one copy PactActNotice.astro and the raw path
44
+ * both render. The federal requirements it summarizes are fixed; only the age varies.
45
+ */
46
+ export function pactActNoticeText(minAge = 21): string {
47
+ return (
48
+ `In compliance with the federal PACT Act, all vapor and nicotine orders require ` +
49
+ `age verification (${minAge}+) and an adult signature on delivery. These products ` +
50
+ `cannot be shipped through the U.S. Postal Service.`
51
+ );
52
+ }
53
+
54
+ /** Mirrors PactActNotice.astro's "footer" variant (via ComplianceNotice.astro). */
55
+ export function pactActNoticeHtml(minAge?: number): string {
56
+ return (
57
+ '<div class="border-t border-[var(--color-border)] bg-[var(--color-bg)]" data-compliance="pact-act">' +
58
+ '<p class="container-prose py-3 text-center text-sm text-[var(--color-muted)]" role="note" aria-label="PACT Act shipping compliance notice">' +
59
+ escapeHtml(pactActNoticeText(minAge)) +
60
+ "</p></div>"
61
+ );
62
+ }
63
+
48
64
  /** Mirrors Layout.astro's excise-tax disclosure <p>. */
49
65
  export function exciseTaxNoticeHtml(): string {
50
66
  return (
@@ -54,19 +70,23 @@ export function exciseTaxNoticeHtml(): string {
54
70
  }
55
71
 
56
72
  export interface RawComplianceChromeInput {
57
- nicotineWarning?: boolean;
73
+ /** The tenant's `compliance.nicotineWarning` declaration (`true` or a presentation). */
74
+ nicotineWarning?: unknown;
75
+ pactAct?: boolean;
76
+ minAge?: number;
58
77
  shippingRestriction?: string;
59
78
  exciseTaxEnabled?: boolean;
60
79
  }
61
80
 
62
81
  /** The markup to inject at `rawHtmlResponse`'s body-start boundary. */
63
82
  export function renderRawComplianceTop(input: RawComplianceChromeInput): string {
64
- return input.nicotineWarning ? nicotineWarningBarHtml() : "";
83
+ return input.nicotineWarning ? nicotineWarningBarHtml(input.nicotineWarning) : "";
65
84
  }
66
85
 
67
86
  /** The markup to inject at `rawHtmlResponse`'s body-deferred boundary. */
68
87
  export function renderRawComplianceBottom(input: RawComplianceChromeInput): string {
69
- let out = shippingRestrictionNoticeHtml(input.shippingRestriction);
88
+ let out = input.pactAct ? pactActNoticeHtml(input.minAge) : "";
89
+ out += shippingRestrictionNoticeHtml(input.shippingRestriction);
70
90
  if (input.exciseTaxEnabled) out += exciseTaxNoticeHtml();
71
91
  return out;
72
92
  }
@@ -21,9 +21,37 @@
21
21
  * PURE (no I/O, no Astro) so all four states are unit-testable without rendering
22
22
  * the page, matching `@/lib/dashboard/tenantSelection` and `@/lib/auth/loginGate`.
23
23
  */
24
+ import { storefrontModeAllowsGoLive } from "@tot/public-runtime";
24
25
  import { membershipFor, staffAdmits } from "@/lib/dashboard/tenantSelection";
25
26
  import type { StaffAdmission, TenantMembership } from "@/lib/dashboard/tenantSelection";
26
27
 
28
+ /** What every viewer of an unpaid store is told in place of the delegate section. */
29
+ export const UNPAID_DELEGATION_NOTE =
30
+ "Developers with preview access can already add changes to the site. Ship-on-behalf " +
31
+ "delegation becomes available once the live site is activated.";
32
+
33
+ /** The delegate section: an unpaid store's note, or the paid store's {@link DelegateGate}. */
34
+ export type DelegateSection = { mode: "unpaid"; role: null; explanation: string } | DelegateGate;
35
+
36
+ /**
37
+ * Resolve the whole delegate section. Before a store is paid there is no live
38
+ * site and preview-access developers can already add changes to the release
39
+ * (`canAddToRelease`), so delegation has nothing to grant: every viewer, the
40
+ * owner included, gets {@link UNPAID_DELEGATION_NOTE}. A paid store resolves
41
+ * {@link resolveDelegateGate} unchanged.
42
+ */
43
+ export function resolveDelegateSection(
44
+ storefrontMode: string | null | undefined,
45
+ tenants: readonly TenantMembership[] | undefined,
46
+ resource: string,
47
+ staff?: StaffAdmission,
48
+ ): DelegateSection {
49
+ if (!storefrontModeAllowsGoLive(storefrontMode)) {
50
+ return { mode: "unpaid", role: null, explanation: UNPAID_DELEGATION_NOTE };
51
+ }
52
+ return resolveDelegateGate(tenants, resource, staff);
53
+ }
54
+
27
55
  /**
28
56
  * What the delegate section renders for this viewer.
29
57
  *
@@ -92,6 +92,10 @@ export function promoTilesView(
92
92
  };
93
93
  }
94
94
 
95
+ /**
96
+ * The editorial block's media column: the rehosted image, else generated art when
97
+ * the block asks for it with `imageSeed`, else none — a text-only block.
98
+ */
95
99
  export function editorialMediaView(block: EditorialBlock, tenantId: string) {
96
100
  if (
97
101
  isSafeRehostedAssetPathForTenant(block.image, tenantId) &&
@@ -99,9 +103,8 @@ export function editorialMediaView(block: EditorialBlock, tenantId: string) {
99
103
  ) {
100
104
  return { kind: "image" as const, src: block.image, alt: block.imageAlt };
101
105
  }
102
- return {
103
- kind: "placeholder" as const,
104
- seed: block.imageSeed ?? block.title,
105
- label: block.title,
106
- };
106
+ if (block.imageSeed) {
107
+ return { kind: "placeholder" as const, seed: block.imageSeed, label: block.title };
108
+ }
109
+ return { kind: "none" as const };
107
110
  }
@@ -133,11 +133,11 @@ export function blogPostingLd(
133
133
  mainEntityOfPage: url,
134
134
  datePublished: post.publishedAt,
135
135
  dateModified: post.modifiedAt,
136
- author: {
137
- "@type": "Person",
138
- name: post.author,
139
- ...(authorUrl ? { url: authorUrl } : {}),
140
- },
136
+ // A post that names no author carries no `author`: an empty Person is invalid markup, and
137
+ // naming anyone else would attribute the post to someone its content does not.
138
+ ...(post.author.trim()
139
+ ? { author: { "@type": "Person", name: post.author.trim(), ...(authorUrl ? { url: authorUrl } : {}) } }
140
+ : {}),
141
141
  publisher: { "@type": "Organization", name: publisherName, url: canonicalBase },
142
142
  wordCount: wordCount(post.contentHtml),
143
143
  ...(post.categories[0] ? { articleSection: post.categories[0] } : {}),
@@ -1,14 +1,19 @@
1
1
  /**
2
- * Private response marker for the in-process pinned-route rewrite boundary.
2
+ * Private response marker for the in-process route rewrite boundary.
3
3
  *
4
4
  * `context.rewrite()` runs the rewritten request through middleware and returns
5
5
  * a complete response: preview helpers, security headers, CSP, and the live
6
6
  * nonce have already been applied. The outer render chain must pass those bytes
7
- * through instead of decorating and nonce-stamping them a second time.
7
+ * through instead of decorating and nonce-stamping them a second time — a second
8
+ * stamp replaces the CSP header's nonce while the document keeps the inner one,
9
+ * so the browser blocks every inline style and script on the page.
8
10
  *
9
11
  * The marker exists only while the inner response unwinds through the outer
10
12
  * middleware pass. `takePinnedRouteRewriteResponse` removes it before the
11
13
  * response reaches a client.
14
+ *
15
+ * Every in-process rewrite goes through {@link rewriteThroughRenderChain};
16
+ * `pinnedRouteRewriteBoundary.test.ts` refuses a bare `.rewrite(` anywhere else.
12
17
  */
13
18
  export const PINNED_ROUTE_REWRITE_COMPLETE_HEADER =
14
19
  "x-tot-internal-pinned-route-rewrite-complete";
@@ -29,3 +34,16 @@ export function takePinnedRouteRewriteResponse(response: Response): Response | n
29
34
  headers,
30
35
  });
31
36
  }
37
+
38
+ /**
39
+ * Rewrite in-process and mark the completed response so the outer middleware pass
40
+ * serves it verbatim. The response a rewrite hands back may be immutable, so it is
41
+ * copied before the marker (and any caller headers) are set.
42
+ */
43
+ export async function rewriteThroughRenderChain(
44
+ context: { rewrite(path: string): Promise<Response> },
45
+ path: string,
46
+ ): Promise<Response> {
47
+ const rendered = await context.rewrite(path);
48
+ return markPinnedRouteRewriteComplete(new Response(rendered.body, rendered));
49
+ }
@@ -39,8 +39,10 @@ import type {
39
39
  ChromeConfig,
40
40
  ChromeFooter,
41
41
  ChromeHeader,
42
+ ChromeHeaderActionLink,
42
43
  ChromeLink,
43
44
  ChromeNavItem,
45
+ ChromeSearch,
44
46
  ChromeUtilityNav,
45
47
  HeaderVariant,
46
48
  SocialLink,
@@ -273,27 +275,79 @@ function renderNavItem(item: ChromeNavItem, index: number, currentPath?: string)
273
275
  * than read off `header` so the rendered markup and its `data-variant` can
274
276
  * never disagree with the variant the assignment actually decided.
275
277
  */
278
+ /**
279
+ * The header search form. `inMenu` is the copy placed inside the menu drawer
280
+ * (header.mobile.search "menu"); it carries its own input id so both can exist.
281
+ */
282
+ function renderSearchForm(search: ChromeSearch, inMenu = false): string {
283
+ const id = inMenu ? "hdr-q-menu" : "hdr-q";
284
+ const button = search.buttonLabel
285
+ ? `<button type="submit" class="header-search__submit">${esc(search.buttonLabel)}</button>`
286
+ : `<button type="submit" aria-label="Search">${ICON_SEARCH}</button>`;
287
+ return (
288
+ `<form class="header-search${inMenu ? " header-search--menu" : ""}${search.buttonLabel ? " header-search--labelled" : ""}" action="${esc(search.action ?? "/search")}" method="get" role="search">\n` +
289
+ ` <label class="sr-only" for="${id}">${esc(search.label ?? "Search")}</label>\n` +
290
+ ` <input id="${id}" type="search" name="q" placeholder="${esc(search.placeholder ?? "Search")}" autocomplete="off">\n` +
291
+ ` ${button}\n` +
292
+ ` </form>`
293
+ );
294
+ }
295
+
296
+ /**
297
+ * Labelled header action links, in authored order. A `kind: "cart"` link carries
298
+ * the `cart` class, so bindManagedCartAnchors (lib/chrome/managedCart.ts) binds it
299
+ * to the managed checkout exactly like the icon cart.
300
+ */
301
+ function renderActionLinks(links: ChromeHeaderActionLink[]): string {
302
+ return links
303
+ .map((l, i) => {
304
+ const kind = l.kind ?? "link";
305
+ const icon = !l.icon ? "" : kind === "account" ? ICON_ACCOUNT : kind === "cart" ? ICON_CART : "";
306
+ const stamp = trackingAttributesHtml({
307
+ el: l.id,
308
+ action: l.action,
309
+ tracking: l.tracking,
310
+ placement: CHROME_PLACEMENT.header,
311
+ scope: "header-actions",
312
+ index: i,
313
+ });
314
+ const cls = kind === "cart" ? "header-action cart" : "header-action";
315
+ const count = kind === "cart" ? `<span class="count">0</span>` : "";
316
+ return (
317
+ `<a class="${cls}" data-kind="${kind}" href="${esc(l.href)}"${relFor(l)}${stamp}>` +
318
+ `${icon}<span class="header-action__label">${esc(l.label)}</span>${count}</a>`
319
+ );
320
+ })
321
+ .join("");
322
+ }
323
+
276
324
  function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?: string): string {
277
- const search = header.search
278
- ? `<form class="header-search" action="${esc(header.search.action ?? "/search")}" method="get" role="search">\n` +
279
- ` <label class="sr-only" for="hdr-q">${esc(header.search.label ?? "Search")}</label>\n` +
280
- ` <input id="hdr-q" type="search" name="q" placeholder="${esc(header.search.placeholder ?? "Search")}" autocomplete="off">\n` +
281
- ` <button type="submit" aria-label="Search">${ICON_SEARCH}</button>\n` +
282
- ` </form>`
283
- : "";
325
+ const search = header.search ? renderSearchForm(header.search) : "";
284
326
  const memberCue = header.memberCue
285
327
  ? `<a class="member-cue" href="${esc(header.memberCue.href)}" aria-label="${esc(header.memberCue.label)}">${esc(header.memberCue.label)}</a>`
286
328
  : "";
287
- const account = header.accountHref
329
+ // Labelled action links replace the icon account/cart pair rather than adding to it.
330
+ const actionLinks = header.actionLinks?.length ? renderActionLinks(header.actionLinks) : "";
331
+ const account = !actionLinks && header.accountHref
288
332
  ? `<a href="${esc(header.accountHref)}" aria-label="Account">${ICON_ACCOUNT}</a>`
289
333
  : "";
290
- const cart = header.cartHref
334
+ const cart = !actionLinks && header.cartHref
291
335
  ? `<a class="cart" href="${esc(header.cartHref)}" aria-label="Cart">${ICON_CART}<span class="count">0</span></a>`
292
336
  : "";
293
337
  const compact = variant === "compact-centered";
294
338
  // "minimal" (e.g. checkout/policy pages): brand + a reduced action set only —
295
339
  // no primary nav bar, no search, no mobile-menu toggle. "primary": the full header.
296
340
  const minimal = variant === "minimal";
341
+ // Narrow-viewport arrangement, applied by commerce-chrome.css's max-width:860px
342
+ // block through these attributes. compact-centered already puts its menu
343
+ // button on the left, so `menu: "left"` moves the button only for the others.
344
+ const mobile = header.mobile ?? {};
345
+ const menuLeft = mobile.menu === "left" && !compact;
346
+ const searchInMenu = mobile.search === "menu" && !minimal && header.search !== undefined;
347
+ const mobileAttrs =
348
+ (mobile.menu === "left" ? ' data-mobile-menu="left"' : "") +
349
+ (mobile.brand === "center" ? ' data-mobile-brand="center"' : "") +
350
+ (searchInMenu ? ' data-mobile-search="menu"' : "");
297
351
  // Header CTAs, on the same `!minimal` rule the Astro SiteHeader applies, so the
298
352
  // two surfaces render the same governed config identically. Each carries its
299
353
  // identity attribute (ctaIdentityAttr, shared with SiteHeader.astro), so the
@@ -325,8 +379,10 @@ function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?
325
379
  const navItems = header.nav
326
380
  .map((item, i) => renderNavItem(item, i, currentPath))
327
381
  .join("\n ");
382
+ const menuSearch = searchInMenu ? ` ${renderSearchForm(header.search!, true)}\n` : "";
328
383
  const primaryNav =
329
384
  `<nav class="primary-nav" id="primaryNav" aria-label="Primary">\n` +
385
+ menuSearch +
330
386
  ` <ul>\n ${navItems}\n </ul>\n` +
331
387
  ` </nav>`;
332
388
  const navBar = minimal
@@ -358,12 +414,13 @@ function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?
358
414
  `</nav>`
359
415
  : "";
360
416
  return (
361
- `<header class="site-header" data-variant="${esc(variant)}">\n` +
417
+ `<header class="site-header" data-variant="${esc(variant)}"${mobileAttrs}>\n` +
362
418
  ` <div class="container header-row">\n` +
419
+ (menuLeft && hamburger ? ` ${hamburger}\n` : "") +
363
420
  (compact ? ` ${primaryNav}\n` : "") +
364
421
  ` ${renderBrand(header.brand)}\n` +
365
422
  (!minimal && !compact && search ? ` ${search}\n` : "") +
366
- ` <div class="header-actions">${memberCue}${ctas}${compactSearch}${account}${cart}${hamburger}</div>\n` +
423
+ ` <div class="header-actions">${memberCue}${ctas}${compactSearch}${actionLinks}${account}${cart}${menuLeft ? "" : hamburger}</div>\n` +
367
424
  ` </div>\n` +
368
425
  navBar +
369
426
  `</header>` +
@@ -496,6 +553,11 @@ function renderFooter(footer: ChromeFooter): string {
496
553
  .map((p) => `<span>${esc(p)}</span>`)
497
554
  .join("")}</div>`
498
555
  : "";
556
+ // The merchant's own legal copy renders as written; the warning block always
557
+ // keeps its "WARNING:" label.
558
+ const disclaimer = footer.disclaimer?.trim()
559
+ ? `\n <div class="footer-disclaimer"><p>${withBreaks(footer.disclaimer.trim())}</p></div>`
560
+ : "";
499
561
  const warning = footer.warning
500
562
  ? `\n <div class="footer-warning"><p><strong>WARNING:</strong> ${esc(footer.warning)}</p></div>`
501
563
  : "";
@@ -525,7 +587,7 @@ function renderFooter(footer: ChromeFooter): string {
525
587
  `<footer class="site-footer" data-variant="${esc(footer.variant)}">\n` +
526
588
  ` <div class="container">\n` +
527
589
  ` <div class="footer-grid">\n${brandBlock}${columns}\n </div>` +
528
- `${trust}${social}${payments}${warning}${bottom}\n` +
590
+ `${trust}${social}${payments}${disclaimer}${warning}${bottom}\n` +
529
591
  ` </div>\n` +
530
592
  `</footer>`
531
593
  );
@@ -22,7 +22,7 @@ let workerEnv: Record<string, unknown> | null | undefined;
22
22
  export async function getWorkerEnv(): Promise<Record<string, unknown> | null> {
23
23
  if (workerEnv !== undefined) return workerEnv;
24
24
  try {
25
- // @ts-expect-error — virtual module, only resolvable in the Workers runtime.
25
+ // Only resolvable in the Workers runtime; types in lib/cloudflare-workers.d.ts.
26
26
  const mod = await import("cloudflare:workers");
27
27
  workerEnv = (mod.env ?? null) as Record<string, unknown> | null;
28
28
  } catch {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The document `<title>` (also og:title / twitter:title).
3
+ *
4
+ * A page's own `seoTitle` is the merchant's complete title and renders verbatim,
5
+ * so a migrated page keeps its source title exactly ("About Us", or
6
+ * "Your first blog post! - Prime Vapor Club"). Without one, the page title is
7
+ * suffixed with the store's display name — unless it already carries it, so a
8
+ * title that names the store never repeats it.
9
+ *
10
+ * Pages and the home page pass their optional `seoTitle` as `seoTitle`. A blog
11
+ * post's `seoTitle` is required and importers default it to the post title, so
12
+ * posts pass it as `title`: one that names the store ("… - Prime Vapor Club")
13
+ * renders as written, any other is suffixed.
14
+ */
15
+ export function documentTitle(title: string, seoTitle: string | undefined, storeName: string): string {
16
+ const verbatim = seoTitle?.trim();
17
+ if (verbatim) return verbatim;
18
+ return title.includes(storeName) ? title : `${title} — ${storeName}`;
19
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Who may Add to release — integrate an accepted change into the store's
3
+ * `preview` release. Pure; shared by the integrate/resolve admission, the
4
+ * Publish console, and the Team page so all three answer one question.
5
+ */
6
+ import { storefrontModeAllowsGoLive } from "@tot/public-runtime";
7
+ import { isOwnerCapability, type Capability } from "./capability";
8
+
9
+ /** Pure inputs to the Add-to-release decision. */
10
+ export interface AddToReleaseInput {
11
+ /** The viewer's standing over THIS tenant (owner/admin/member), if any. */
12
+ capability: string | null | undefined;
13
+ /** The viewer's live ship grant over this tenant. */
14
+ shipGrant: Capability;
15
+ /** True only when the viewer's access is bound to THIS tenant. */
16
+ tenantScoped: boolean;
17
+ /** The tenant's storefront mode — `"enabled"` means the store is paid. */
18
+ storefrontMode: string | null | undefined;
19
+ }
20
+
21
+ /**
22
+ * May this viewer add an accepted change to the store's `preview` release?
23
+ *
24
+ * Owner/admin standing or a ship-on-behalf grant always may. Before a store is
25
+ * paid there is no live site, so preview IS the site: a developer whose access is
26
+ * scoped to this tenant may add without a ship-on-behalf grant. Once the store is
27
+ * paid, the preview/live split and ship-on-behalf delegation apply.
28
+ */
29
+ export function canAddToRelease(input: AddToReleaseInput): boolean {
30
+ if (isOwnerCapability(input.capability) || input.shipGrant === "ship-on-behalf") return true;
31
+ return input.tenantScoped && !storefrontModeAllowsGoLive(input.storefrontMode);
32
+ }
@@ -59,6 +59,8 @@ export interface PromoTilesBlock {
59
59
 
60
60
  export interface HeroBlock {
61
61
  component: "hero";
62
+ /** Short plain-text status pill above the eyebrow (e.g. a fulfilment notice). */
63
+ notice?: string;
62
64
  /** The "thesis" line — the most characteristic thing about the catalog. */
63
65
  eyebrow?: string;
64
66
  headline: string;
@@ -121,6 +123,7 @@ export interface EditorialBlock {
121
123
  eyebrow?: string;
122
124
  title: string;
123
125
  body_html: string;
126
+ /** Generated placeholder art. With neither `image` nor `imageSeed` the block is text-only. */
124
127
  imageSeed?: string;
125
128
  /** Rehosted editorial photography; preferred over a generated placeholder. */
126
129
  image?: string;
@@ -17,14 +17,6 @@ const localThemeModules = import.meta.glob<Record<string, unknown>>(
17
17
  { eager: true, import: "default" },
18
18
  );
19
19
 
20
- const localChromeModules = import.meta.glob<Record<string, unknown>>(
21
- [
22
- "../../../../tenants/*/content/chrome.json",
23
- "../../../../tenants/e2e/*/content/chrome.json",
24
- ],
25
- { eager: true, import: "default" },
26
- );
27
-
28
20
  function isTheme(value: unknown): value is StyleGuideTheme {
29
21
  return Boolean(
30
22
  value &&
@@ -89,19 +81,25 @@ export async function loadStyleGuideThemes(
89
81
  return localThemes(requestedTenant);
90
82
  }
91
83
 
92
- export async function loadStyleGuideChrome(
84
+ /**
85
+ * Extra composition stylesheets a theme descriptor declares for its guide
86
+ * (`preview.stylesheets`). Only root-relative paths inside the requested
87
+ * tenant's own `/tenants/<tenant>/` tree are kept: a descriptor is tenant data,
88
+ * and the guide links nothing it cannot vouch is that tenant's.
89
+ */
90
+ export function styleGuideCompositionSheets(
91
+ theme: Record<string, unknown>,
93
92
  requestedTenant: string,
94
- resolvedTenant: { tenant_id: string; appDomain?: string },
95
- content: Pick<ContentProvider, "getChrome">,
96
- ): Promise<Record<string, unknown> | null> {
97
- if (contentBelongsToRequestedTenant(requestedTenant, resolvedTenant)) {
98
- try {
99
- return (await content.getChrome()) as unknown as Record<string, unknown>;
100
- } catch {
101
- // A colocated build-time descriptor is the availability fallback.
102
- }
103
- }
104
-
105
- const segment = `${tenantDirSegment(requestedTenant)}content/chrome.json`;
106
- return Object.entries(localChromeModules).find(([path]) => path.includes(segment))?.[1] ?? null;
93
+ ): string[] {
94
+ const preview = theme.preview as { stylesheets?: unknown } | undefined;
95
+ const declared = Array.isArray(preview?.stylesheets) ? preview.stylesheets : [];
96
+ const prefix = `/tenants/${requestedTenant}/`;
97
+ return declared.filter(
98
+ (href): href is string =>
99
+ typeof href === "string" &&
100
+ href.startsWith(prefix) &&
101
+ href.endsWith(".css") &&
102
+ !href.includes("..") &&
103
+ !/[\s"'<>]/.test(href),
104
+ );
107
105
  }
@@ -7,13 +7,14 @@
7
7
  */
8
8
  import Layout from "@/layouts/Layout.astro";
9
9
  import Breadcrumbs from "@/components/Breadcrumbs.astro";
10
+ import { homeCrumbLabel, siteTrail } from "@/lib/breadcrumbs";
10
11
  import ProseSections from "@/components/content/ProseSections.astro";
11
12
  import ProseToc from "@/components/content/ProseToc.astro";
12
13
  import { breadcrumbLd } from "@/lib/jsonld";
13
14
  import { truncate } from "@/lib/format";
14
15
  import { rawHtmlPolicy, rawHtmlResponse, rawPageCanonicalUrl } from "@/lib/rawMarketingHtml";
15
16
  import { renderRawComplianceTop, renderRawComplianceBottom } from "@/lib/compliance/rawComplianceNotices";
16
- import { markPinnedRouteRewriteComplete } from "@/lib/pinnedRouteRewriteBoundary";
17
+ import { rewriteThroughRenderChain } from "@/lib/pinnedRouteRewriteBoundary";
17
18
 
18
19
  const { tenant, canonicalBase, content, basePath, capabilities, features } = Astro.locals;
19
20
  const slug = (Astro.params.slug ?? "").replace(/^\/+|\/+$/g, "");
@@ -34,8 +35,9 @@ if (
34
35
  // dir, so the pages live under `/style-guide/…` and we rewrite the underscored URL
35
36
  // onto them. These are noindex + sitemap-excluded and never reskin the live site.
36
37
  if (slug === "_style-guide" || slug.startsWith("_style-guide/")) {
37
- return markPinnedRouteRewriteComplete(
38
- await Astro.rewrite("/style-guide/" + slug.slice("_style-guide".length + 1)),
38
+ return rewriteThroughRenderChain(
39
+ Astro,
40
+ "/style-guide/" + slug.slice("_style-guide".length + 1),
39
41
  );
40
42
  }
41
43
 
@@ -50,7 +52,9 @@ if (rawHtml && rawPolicy.allowed) {
50
52
  // a full document serves verbatim (backward-compatible). See rawMarketingHtml.ts.
51
53
  const chromeWrapper = await content.getChromeHtml(`/${slug}`);
52
54
  const notices = {
53
- nicotineWarning: !!tenant.compliance?.nicotineWarning,
55
+ nicotineWarning: tenant.compliance?.nicotineWarning,
56
+ pactAct: !!tenant.compliance?.pactAct,
57
+ minAge: tenant.compliance?.minAge,
54
58
  shippingRestriction: tenant.compliance?.shippingRestriction,
55
59
  exciseTaxEnabled: capabilities.exciseTax.enabled && features.catalog,
56
60
  };
@@ -69,10 +73,10 @@ if (rawHtml && rawPolicy.allowed) {
69
73
  const page = await content.getPage(slug);
70
74
  if (!page) return new Response(null, { status: 404 });
71
75
 
72
- const crumbs = [
73
- { label: tenant.displayName, href: "/" },
76
+ const crumbs = siteTrail(
77
+ homeCrumbLabel(await Astro.locals.content.getChrome()),
74
78
  { label: page.title, href: `/${page.slug}` },
75
- ];
79
+ );
76
80
 
77
81
  const description =
78
82
  page.seoDescription ||
@@ -101,7 +105,8 @@ const updatedLabel = page.updated
101
105
  ---
102
106
 
103
107
  <Layout
104
- title={page.seoTitle || page.title}
108
+ title={page.title}
109
+ seoTitle={page.seoTitle}
105
110
  description={description}
106
111
  path={`/${page.slug}`}
107
112
  ogType="article"