@tokenoftrust/storefront-runner 2.2.126 → 2.3.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/apps/storefront/package.json +3 -1
- package/apps/storefront/public/shared/commerce-chrome.css +57 -15
- package/apps/storefront/public/shared/commerce-marketing.css +7 -7
- package/apps/storefront/src/components/Badge.astro +1 -1
- package/apps/storefront/src/components/Breadcrumbs.astro +8 -3
- package/apps/storefront/src/components/Section.astro +4 -1
- package/apps/storefront/src/components/Seo.astro +7 -5
- package/apps/storefront/src/components/blog/BlogIndexView.astro +22 -13
- package/apps/storefront/src/components/blog/ShareRow.astro +43 -0
- package/apps/storefront/src/components/chrome/AnnouncementBar.astro +1 -1
- package/apps/storefront/src/components/chrome/NavDropdown.astro +7 -7
- package/apps/storefront/src/components/chrome/SiteFooter.astro +17 -0
- package/apps/storefront/src/components/chrome/SiteHeader.astro +5 -5
- package/apps/storefront/src/components/commerce/TagFilterCloud.astro +1 -1
- package/apps/storefront/src/components/compliance/NicotineWarning.astro +12 -22
- package/apps/storefront/src/components/compliance/PactActNotice.astro +9 -10
- package/apps/storefront/src/components/content/ProseSections.astro +2 -2
- package/apps/storefront/src/components/home/Hero.astro +34 -12
- package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +39 -5
- package/apps/storefront/src/components/islands/VariantSelector.tsx +3 -3
- package/apps/storefront/src/components/marketing/CardGrid.astro +1 -1
- package/apps/storefront/src/components/marketing/CodeSample.astro +1 -1
- package/apps/storefront/src/components/marketing/CtaBand.astro +1 -1
- package/apps/storefront/src/components/marketing/Faq.astro +1 -1
- package/apps/storefront/src/components/marketing/Integrations.astro +1 -1
- package/apps/storefront/src/components/marketing/MarketingCtas.astro +1 -1
- package/apps/storefront/src/components/marketing/MarketingHero.astro +1 -1
- package/apps/storefront/src/components/marketing/ProofStrip.astro +1 -1
- package/apps/storefront/src/components/marketing/SplitCompare.astro +1 -1
- package/apps/storefront/src/components/marketing/Steps.astro +1 -1
- package/apps/storefront/src/components/marketing/Testimonials.astro +2 -2
- package/apps/storefront/src/components/marketing/TrustBar.astro +1 -1
- package/apps/storefront/src/components/membership/TierComparisonTable.astro +1 -1
- package/apps/storefront/src/components/plp/ActiveFilters.astro +1 -1
- package/apps/storefront/src/components/plp/FacetSidebar.astro +4 -4
- package/apps/storefront/src/components/plp/Pagination.astro +1 -1
- package/apps/storefront/src/components/plp/SortSelect.astro +1 -1
- package/apps/storefront/src/components/style-guide/StyleGuideNav.astro +18 -3
- package/apps/storefront/src/components/the-build/TheBuildIndexView.astro +9 -5
- package/apps/storefront/src/config/devTenantSeed.ts +20 -34
- package/apps/storefront/src/config/resolver.ts +0 -38
- package/apps/storefront/src/layouts/Layout.astro +8 -1
- package/apps/storefront/src/lib/assets/preview-version-cookie.ts +52 -5
- package/apps/storefront/src/lib/blog/pagination.ts +20 -10
- package/apps/storefront/src/lib/blog/presentation.ts +94 -0
- package/apps/storefront/src/lib/blog/rss.ts +1 -1
- package/apps/storefront/src/lib/blog/types.ts +24 -7
- package/apps/storefront/src/lib/breadcrumbs.ts +25 -0
- package/apps/storefront/src/lib/chrome/model.ts +4 -0
- package/apps/storefront/src/lib/cloudflare-workers.d.ts +53 -0
- package/apps/storefront/src/lib/compliance/rawComplianceNotices.ts +38 -18
- package/apps/storefront/src/lib/dashboard/delegateGate.ts +28 -0
- package/apps/storefront/src/lib/homeVisualParity.ts +8 -5
- package/apps/storefront/src/lib/jsonld.ts +5 -5
- package/apps/storefront/src/lib/pinnedRouteRewriteBoundary.ts +20 -2
- package/apps/storefront/src/lib/rawChrome.ts +74 -12
- package/apps/storefront/src/lib/runtimeEnv.ts +1 -1
- package/apps/storefront/src/lib/seo/documentTitle.ts +19 -0
- package/apps/storefront/src/lib/shared/addToRelease.ts +32 -0
- package/apps/storefront/src/lib/storyblok/content-model.ts +3 -0
- package/apps/storefront/src/lib/styleGuideThemes.ts +20 -22
- package/apps/storefront/src/pages/[...slug].astro +13 -8
- package/apps/storefront/src/pages/blog/[slug].astro +56 -24
- package/apps/storefront/src/pages/blog/author/[author].astro +17 -9
- package/apps/storefront/src/pages/blog/category/[category].astro +18 -10
- package/apps/storefront/src/pages/blog/index.astro +2 -0
- package/apps/storefront/src/pages/blog/page/[n].astro +2 -0
- package/apps/storefront/src/pages/blog/tag/[tag].astro +18 -10
- package/apps/storefront/src/pages/collections/[handle].astro +5 -4
- package/apps/storefront/src/pages/collections/index.astro +4 -3
- package/apps/storefront/src/pages/index.astro +34 -27
- package/apps/storefront/src/pages/products/[handle].astro +5 -4
- package/apps/storefront/src/pages/saved.astro +4 -3
- package/apps/storefront/src/pages/search.astro +4 -3
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +129 -118
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +19 -15
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +23 -27
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -14
- package/apps/storefront/src/pages/style-guide/index.astro +6 -5
- package/apps/storefront/src/pages/tenants/[id]/[...path].ts +32 -7
- package/apps/storefront/src/pages/the-build/[slug].astro +44 -20
- package/apps/storefront/src/pages/the-build/index.astro +2 -0
- package/apps/storefront/src/pages/the-build/page/[n].astro +2 -0
- package/apps/storefront/src/styles/admin.css +47 -0
- package/apps/storefront/src/styles/global.css +104 -56
- package/apps/storefront/src/themes/derivedTokens.ts +34 -0
- package/apps/storefront/src/themes/schema.ts +6 -0
- package/apps/storefront/test/pipeline/harness.ts +164 -0
- package/apps/storefront/test/pipeline/intake-worker.mjs +14 -0
- package/apps/storefront/test/pipeline/pipeline-host.ts +77 -0
- package/apps/storefront/test/pipeline/synthetic-job.ts +156 -0
- package/apps/storefront/vitest.workers.config.cts +67 -0
- package/docs/widget-library-guide.md +1496 -0
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +400 -5
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/src/chrome.ts +93 -2
- package/packages/public-runtime/src/compliance-coherence.d.mts +17 -0
- package/packages/public-runtime/src/compliance-coherence.mjs +84 -0
- package/packages/public-runtime/src/declared-tenant-config.d.mts +1 -1
- package/packages/public-runtime/src/declared-tenant-config.mjs +6 -2
- package/packages/public-runtime/src/extension-contract-values.d.mts +4 -1
- package/packages/public-runtime/src/extension-contract-values.mjs +14 -1
- package/packages/public-runtime/src/extension-contract.ts +2 -0
- package/packages/public-runtime/src/tenant.ts +48 -4
- package/pnpm-lock.runner.yaml +469 -0
- 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
|
|
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"
|
|
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, "&")
|
|
@@ -21,17 +23,9 @@ function escapeHtml(value: string): string {
|
|
|
21
23
|
.replace(/'/g, "'");
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
103
|
-
kind: "placeholder" as const,
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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 {
|
|
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
|
|
38
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
108
|
+
title={page.title}
|
|
109
|
+
seoTitle={page.seoTitle}
|
|
105
110
|
description={description}
|
|
106
111
|
path={`/${page.slug}`}
|
|
107
112
|
ogType="article"
|