@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
|
@@ -14,9 +14,12 @@
|
|
|
14
14
|
* The tenant's identity IS its dir name (the colocation convention: dir ==
|
|
15
15
|
* tenant_id == appDomain == tot_data_scope). Content (content/home.html) and
|
|
16
16
|
* theme (theme.json) come straight from the checkout via the existing dir globs;
|
|
17
|
-
* catalog
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* catalog data it lacks resolves to an empty catalog, exactly as on the hosted
|
|
18
|
+
* Worker. Site type is never inferred here: like a provisioned tenant record the
|
|
19
|
+
* synthesized one leaves `siteType` undeclared (commerce), and the checkout's own
|
|
20
|
+
* `.tot/config.json` declaration — applied on top of this record on every surface
|
|
21
|
+
* (config/declaredTenantConfig.ts) — decides it. So `tot dev` renders a store's
|
|
22
|
+
* home.json with the same block vocabulary the hosted build renders.
|
|
20
23
|
*
|
|
21
24
|
* DEV ONLY: in prod the TENANT_CACHE KV drives the resolver and staticTenants is
|
|
22
25
|
* the seed; this path is never taken. Kept inside `src/config` per the
|
|
@@ -35,13 +38,6 @@ export interface DevTenantHints {
|
|
|
35
38
|
* Undefined ⇒ the colocation convention (dir == appDomain == scope).
|
|
36
39
|
*/
|
|
37
40
|
appDomain?: string;
|
|
38
|
-
/**
|
|
39
|
-
* True when the checkout has a catalog: its own `content/catalog/products/` files,
|
|
40
|
-
* or a grandfathered in-repo snapshot for its data scope. When true we serve a real
|
|
41
|
-
* COMMERCE tenant so /collections + /products render from it; when false we degrade
|
|
42
|
-
* to a marketing home (no catalog).
|
|
43
|
-
*/
|
|
44
|
-
hasCatalog?: boolean;
|
|
45
41
|
/**
|
|
46
42
|
* The tenant's own `tenants/<id>/analytics.json` content (already-parsed JSON),
|
|
47
43
|
* or undefined when the tenant declares none. Materialized into `integrations`
|
|
@@ -56,10 +52,9 @@ export interface DevTenantHints {
|
|
|
56
52
|
* colocated dir name (dir == id == appDomain == tot_data_scope); a slug tenant can
|
|
57
53
|
* override the resolvable appDomain via `hints.appDomain`. No Storyblok space
|
|
58
54
|
* (a "PLACEHOLDER" token → LocalContentProvider serves the checkout's content/).
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* Theme tokens come from the checkout's theme.json.
|
|
55
|
+
* `siteType` stays undeclared, as on a provisioned record, and the demo checkout
|
|
56
|
+
* store is wired so add-to-cart works on localhost. Theme tokens come from the
|
|
57
|
+
* checkout's theme.json.
|
|
63
58
|
*/
|
|
64
59
|
export function synthesizeDevTenant(
|
|
65
60
|
id: string,
|
|
@@ -98,27 +93,20 @@ export function synthesizeDevTenant(
|
|
|
98
93
|
...(integrations.length > 0 ? { integrations } : {}),
|
|
99
94
|
...(euUkVisitors ? { euUkVisitors } : {}),
|
|
100
95
|
};
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
// No catalog on disk → serve the raw marketing home and degrade commerce to
|
|
112
|
-
// empty rather than 404 the tenant root.
|
|
113
|
-
return { ...base, siteType: "marketing" };
|
|
96
|
+
// Cart wires to the shared ToT demo checkout store (test mode, no secret), the
|
|
97
|
+
// same as the bundled demo tenants. A marketing tenant never resolves it (the
|
|
98
|
+
// render chain gates on features.catalog); a commerce one gets its cart
|
|
99
|
+
// affordance and /cart on localhost whether or not it has catalog files yet.
|
|
100
|
+
return {
|
|
101
|
+
...base,
|
|
102
|
+
commerce: { checkout: { storeSubDomain: "tot-preview", mode: "unsigned-demo" } },
|
|
103
|
+
};
|
|
114
104
|
}
|
|
115
105
|
|
|
116
106
|
/** Per-dir disk facts the resolver supplies so synthesis can go beyond colocation. */
|
|
117
107
|
export interface DevTenantSeedOptions {
|
|
118
108
|
/** Resolved appDomain for a slug tenant (from `.tot/config.json` scope). */
|
|
119
109
|
scopeFor?: (id: string) => string | undefined;
|
|
120
|
-
/** Whether the checkout `id` (data scope `scope`) has a catalog on disk. */
|
|
121
|
-
hasCatalog?: (scope: string, id: string) => boolean;
|
|
122
110
|
/** The tenant's parsed `tenants/<id>/analytics.json`, or undefined when it has none. */
|
|
123
111
|
analyticsFor?: (id: string) => unknown;
|
|
124
112
|
}
|
|
@@ -127,12 +115,12 @@ export interface DevTenantSeedOptions {
|
|
|
127
115
|
* The dev tenants to seed ALONGSIDE staticTenants: one synthesized config per
|
|
128
116
|
* local tenant dir that isn't already a static tenant (those keep their richer
|
|
129
117
|
* hardcoded config). `themeFor` supplies each dir's theme tokens; `opts` carries
|
|
130
|
-
* the per-dir disk facts (
|
|
118
|
+
* the per-dir disk facts (slug scope, analytics) the resolver reads.
|
|
131
119
|
*
|
|
132
120
|
* @param localDirIds dir names discovered under `tenants/*` (dir == tenant id)
|
|
133
121
|
* @param staticIds tenant_ids already in the static registry (skipped)
|
|
134
122
|
* @param themeFor theme-token lookup by dir/tenant id
|
|
135
|
-
* @param opts optional per-dir hints (scopeFor,
|
|
123
|
+
* @param opts optional per-dir hints (scopeFor, analyticsFor)
|
|
136
124
|
*/
|
|
137
125
|
export function devTenantsToSeed(
|
|
138
126
|
localDirIds: Iterable<string>,
|
|
@@ -146,10 +134,8 @@ export function devTenantsToSeed(
|
|
|
146
134
|
if (!id || staticIds.has(id) || seen.has(id)) continue;
|
|
147
135
|
seen.add(id);
|
|
148
136
|
const appDomain = opts.scopeFor?.(id);
|
|
149
|
-
const scope = appDomain ?? id;
|
|
150
|
-
const hasCatalog = opts.hasCatalog?.(scope, id) ?? false;
|
|
151
137
|
const analyticsRaw = opts.analyticsFor?.(id);
|
|
152
|
-
out.push(synthesizeDevTenant(id, themeFor(id), { appDomain,
|
|
138
|
+
out.push(synthesizeDevTenant(id, themeFor(id), { appDomain, analyticsRaw }));
|
|
153
139
|
}
|
|
154
140
|
return out;
|
|
155
141
|
}
|
|
@@ -64,41 +64,6 @@ function localTenantDirIds(): string[] {
|
|
|
64
64
|
return [...ids];
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
// Catalog fixtures committed on disk, keyed by data scope (== out/catalog dir
|
|
68
|
-
// name). A dev tenant whose scope has fixtures is served as a real COMMERCE tenant
|
|
69
|
-
// (see devTenantSeed); one without them degrades to a marketing home. Keys only —
|
|
70
|
-
// the same fixtures the FixtureToTClient reads. Repo root is four up from config/.
|
|
71
|
-
const catalogFixtureFiles = import.meta.glob(
|
|
72
|
-
"../../../../packages/migration/out/catalog/*/products.json",
|
|
73
|
-
);
|
|
74
|
-
|
|
75
|
-
// A checkout's own catalog files (ADR 0033): `tot dev` grafts the store's
|
|
76
|
-
// `content/catalog/` here, and the catalog reader serves them.
|
|
77
|
-
const storeCatalogFiles = import.meta.glob([
|
|
78
|
-
"../../../../tenants/*/content/catalog/products/*.json",
|
|
79
|
-
"../../../../tenants/e2e/*/content/catalog/products/*.json",
|
|
80
|
-
]);
|
|
81
|
-
|
|
82
|
-
/** Tenant dir ids whose checkout carries its own catalog files. */
|
|
83
|
-
function storeCatalogIds(): Set<string> {
|
|
84
|
-
const ids = new Set<string>();
|
|
85
|
-
for (const key of Object.keys(storeCatalogFiles)) {
|
|
86
|
-
const id = tenantIdFromPath(key);
|
|
87
|
-
if (id) ids.add(id);
|
|
88
|
-
}
|
|
89
|
-
return ids;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Data scopes (== out/catalog dir names) that have committed catalog fixtures. */
|
|
93
|
-
function catalogScopes(): Set<string> {
|
|
94
|
-
const scopes = new Set<string>();
|
|
95
|
-
for (const key of Object.keys(catalogFixtureFiles)) {
|
|
96
|
-
const scope = /\/out\/catalog\/([^/]+)\//.exec(key)?.[1];
|
|
97
|
-
if (scope) scopes.add(scope);
|
|
98
|
-
}
|
|
99
|
-
return scopes;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
67
|
// A slug tenant (dir name != its domain) declares its resolvable appDomain in
|
|
103
68
|
// the checkout's `.tot/config.json` `scope`. Read eagerly (tiny JSON) so synthesis
|
|
104
69
|
// can resolve it at `/<domain>/`; trusted only when it looks like a hostname (has a
|
|
@@ -136,15 +101,12 @@ function slugScopes(): Map<string, string> {
|
|
|
136
101
|
function devFallbackResolver(): TenantResolver {
|
|
137
102
|
if (!import.meta.env.DEV) return staticResolver;
|
|
138
103
|
const staticIds = new Set(staticTenants.map((t) => t.tenant_id));
|
|
139
|
-
const withCatalog = catalogScopes();
|
|
140
|
-
const withStoreCatalog = storeCatalogIds();
|
|
141
104
|
const scopes = slugScopes();
|
|
142
105
|
// The SAME tenant-owned analytics read the static registry uses; here it serves only
|
|
143
106
|
// the tenants that have NO registry entry (see config/tenantAnalyticsFiles.ts).
|
|
144
107
|
const analytics = tenantAnalyticsRawById();
|
|
145
108
|
const dev = devTenantsToSeed(localTenantDirIds(), staticIds, getTenantTheme, {
|
|
146
109
|
scopeFor: (id) => scopes.get(id),
|
|
147
|
-
hasCatalog: (scope, id) => withStoreCatalog.has(id) || withCatalog.has(scope),
|
|
148
110
|
analyticsFor: (id) => analytics.get(id),
|
|
149
111
|
});
|
|
150
112
|
if (dev.length === 0) return staticResolver;
|
|
@@ -16,6 +16,7 @@ import QuickView from "@/components/islands/QuickView.tsx";
|
|
|
16
16
|
import CheckoutLoader from "@/components/commerce/CheckoutLoader.astro";
|
|
17
17
|
import CartDrawerStyles from "@/components/commerce/CartDrawerStyles.astro";
|
|
18
18
|
import NicotineWarning from "@/components/compliance/NicotineWarning.astro";
|
|
19
|
+
import PactActNotice from "@/components/compliance/PactActNotice.astro";
|
|
19
20
|
import ShippingRestrictionNotice from "@/components/compliance/ShippingRestrictionNotice.astro";
|
|
20
21
|
import IsolatedAgeGate from "@/components/islands/IsolatedAgeGate.tsx";
|
|
21
22
|
import AuthBadge from "@/components/auth/AuthBadge.astro";
|
|
@@ -38,6 +39,8 @@ import { renderWidgetSlot } from "@/lib/apps/widgets/renderSlot.js";
|
|
|
38
39
|
|
|
39
40
|
interface Props {
|
|
40
41
|
title: string;
|
|
42
|
+
/** The page's own complete `<title>`, rendered verbatim (threaded to <Seo>). */
|
|
43
|
+
seoTitle?: string;
|
|
41
44
|
description?: string;
|
|
42
45
|
path: string;
|
|
43
46
|
ogType?: "website" | "product" | "article";
|
|
@@ -80,6 +83,7 @@ interface Props {
|
|
|
80
83
|
|
|
81
84
|
const {
|
|
82
85
|
title,
|
|
86
|
+
seoTitle,
|
|
83
87
|
description,
|
|
84
88
|
path,
|
|
85
89
|
ogType,
|
|
@@ -298,6 +302,7 @@ const footerWidgets = await renderWidgetSlot(
|
|
|
298
302
|
|
|
299
303
|
<Seo
|
|
300
304
|
title={title}
|
|
305
|
+
seoTitle={seoTitle}
|
|
301
306
|
description={description}
|
|
302
307
|
path={path}
|
|
303
308
|
ogType={ogType}
|
|
@@ -331,7 +336,7 @@ const footerWidgets = await renderWidgetSlot(
|
|
|
331
336
|
|
|
332
337
|
<AuthBadge />
|
|
333
338
|
|
|
334
|
-
{compliance?.nicotineWarning && <NicotineWarning variant="bar" />}
|
|
339
|
+
{compliance?.nicotineWarning && <NicotineWarning variant="bar" declaration={compliance.nicotineWarning} />}
|
|
335
340
|
|
|
336
341
|
{sharedChrome ? (
|
|
337
342
|
<Fragment set:html={sharedChrome.header} />
|
|
@@ -369,6 +374,7 @@ const footerWidgets = await renderWidgetSlot(
|
|
|
369
374
|
<slot />
|
|
370
375
|
</main>
|
|
371
376
|
|
|
377
|
+
<PactActNotice enabled={compliance?.pactAct} minAge={compliance?.minAge} variant="footer" />
|
|
372
378
|
<ShippingRestrictionNotice text={compliance?.shippingRestriction} />
|
|
373
379
|
|
|
374
380
|
{sharedChrome ? (
|
|
@@ -400,6 +406,7 @@ const footerWidgets = await renderWidgetSlot(
|
|
|
400
406
|
}
|
|
401
407
|
payments={chrome.footer.payments}
|
|
402
408
|
social={chrome.footer.social}
|
|
409
|
+
disclaimer={chrome.footer.disclaimer}
|
|
403
410
|
compliance={footerCompliance}
|
|
404
411
|
legal={legalLine}
|
|
405
412
|
bottomLinks={bottomLinks}
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Resolve the
|
|
2
|
+
* Resolve the asset version a preview subresource reads.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* `resolvePreviewVersionFromCookies` is the candidate/revision pin: candidate and
|
|
5
|
+
* revision renderers set `tot_preview_version` (the exact sha) and `tot_preview`
|
|
6
|
+
* (capability token). Subresource GETs (img `/_a/…`, `/tenants/…`) carry those
|
|
7
|
+
* cookies but not `__tot_candidate_pin`, so this is the revision authority for
|
|
8
|
+
* revision-bound captured originals and preview assets.
|
|
9
|
+
*
|
|
10
|
+
* `resolveSharedPreviewAssetVersion` covers the shared preview, which sets no
|
|
11
|
+
* version cookie: it reads the version the preview channel serves.
|
|
8
12
|
*/
|
|
9
13
|
import { verifyPreviewToken } from "@tot/public-runtime";
|
|
10
14
|
|
|
@@ -29,3 +33,46 @@ export async function resolvePreviewVersionFromCookies(input: {
|
|
|
29
33
|
if (!verified.ok || verified.claims.tenantId !== input.tenantId) return null;
|
|
30
34
|
return versionId;
|
|
31
35
|
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Which asset snapshot a SHARED-preview subresource reads.
|
|
39
|
+
*
|
|
40
|
+
* The shared preview document renders the content `preview` channel's version — set
|
|
41
|
+
* by `reconcilePreview` and by the aggregate reconcile alike, and both ingest that
|
|
42
|
+
* version's `public/` subtree under the SAME versionId. So a subresource the preview
|
|
43
|
+
* document references must read that exact version too. It is NOT the assets `test`
|
|
44
|
+
* pointer: the aggregate reconcile ingests ingest-only and never moves it, so after
|
|
45
|
+
* "Add to release" that pointer names an older snapshot or nothing, and every tenant
|
|
46
|
+
* asset 404s under a page that rendered fine.
|
|
47
|
+
*
|
|
48
|
+
* A request is a shared-preview read when it is on the preview host (`previewHost`)
|
|
49
|
+
* or carries a valid `tot_preview` token for THIS tenant — the cookie the preview
|
|
50
|
+
* document sets from its `?__preview=` link, or the token on the asset URL itself.
|
|
51
|
+
* `versionId` is null for a non-preview read, or when the preview channel names no
|
|
52
|
+
* version yet (the caller then falls back to the assets `test` pointer).
|
|
53
|
+
*/
|
|
54
|
+
export async function resolveSharedPreviewAssetVersion(input: {
|
|
55
|
+
tenantId: string;
|
|
56
|
+
previewHost: boolean;
|
|
57
|
+
previewToken: string | null | undefined;
|
|
58
|
+
secret: string | null | undefined;
|
|
59
|
+
readPreviewChannelVersion: () => Promise<string | null>;
|
|
60
|
+
nowSeconds?: number;
|
|
61
|
+
}): Promise<{ preview: boolean; versionId: string | null }> {
|
|
62
|
+
let preview = input.previewHost;
|
|
63
|
+
const token = input.previewToken?.trim() || null;
|
|
64
|
+
if (!preview && token && input.secret && input.tenantId) {
|
|
65
|
+
const verified = await verifyPreviewToken(
|
|
66
|
+
input.secret,
|
|
67
|
+
token,
|
|
68
|
+
input.nowSeconds ?? Math.floor(Date.now() / 1000),
|
|
69
|
+
);
|
|
70
|
+
preview = verified.ok && verified.claims.tenantId === input.tenantId;
|
|
71
|
+
}
|
|
72
|
+
if (!preview) return { preview: false, versionId: null };
|
|
73
|
+
const versionId = await input.readPreviewChannelVersion();
|
|
74
|
+
return {
|
|
75
|
+
preview: true,
|
|
76
|
+
versionId: versionId && SAFE_VERSION_ID.test(versionId) && !versionId.includes("..") ? versionId : null,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -8,41 +8,51 @@ export const DEFAULT_BLOG_PAGE_SIZE = 12;
|
|
|
8
8
|
* an absent one falls back to a NEUTRAL default derived from the tenant's own
|
|
9
9
|
* `displayName` — never Token of Trust's own copy (fb-1791301493691-brzl6f). A
|
|
10
10
|
* tenant that wants ToT's current copy (tokenoftrust.com included) declares it
|
|
11
|
-
* in its own `content/blog/settings.json`.
|
|
11
|
+
* in its own `content/blog/settings.json`. `eyebrow`/`tagline` are null when the
|
|
12
|
+
* tenant declared them empty: the index renders no element for them.
|
|
12
13
|
*/
|
|
13
14
|
export interface BlogIndexCopy {
|
|
14
15
|
title: string;
|
|
15
|
-
eyebrow: string;
|
|
16
|
-
tagline: string;
|
|
16
|
+
eyebrow: string | null;
|
|
17
|
+
tagline: string | null;
|
|
17
18
|
description: string;
|
|
18
19
|
pageSize: number;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
function trimmed(value: string | undefined): string | undefined {
|
|
22
|
+
function trimmed(value: string | null | undefined): string | undefined {
|
|
22
23
|
const v = value?.trim();
|
|
23
24
|
return v ? v : undefined;
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* An optional line of copy: declared text wins, a declared `""`/`null` suppresses it, and only an
|
|
29
|
+
* ABSENT field takes the default — so a migrated index that has no eyebrow or tagline can say so.
|
|
30
|
+
*/
|
|
31
|
+
function optionalCopy(value: string | null | undefined, fallback: string): string | null {
|
|
32
|
+
if (value === undefined) return fallback;
|
|
33
|
+
return trimmed(value) ?? null;
|
|
34
|
+
}
|
|
35
|
+
|
|
26
36
|
/**
|
|
27
37
|
* Generic engine behind `resolveBlogIndexCopy` and every other per-tenant
|
|
28
38
|
* collection index (e.g. `resolveTheBuildIndexCopy` in `../the-build/pagination.js`):
|
|
29
39
|
* a declared settings field wins; an absent one falls back to the caller's own
|
|
30
40
|
* NEUTRAL, tenant-derived default — never a Token of Trust-branded one
|
|
31
|
-
* (fb-1791301493691-brzl6f, and its the-build sibling).
|
|
32
|
-
* also the description fallback, matching every collection's settings schema.
|
|
41
|
+
* (fb-1791301493691-brzl6f, and its the-build sibling). The tagline (declared or
|
|
42
|
+
* default) is also the description fallback, matching every collection's settings schema.
|
|
33
43
|
*/
|
|
34
44
|
export function resolveIndexCopy(
|
|
35
|
-
settings:
|
|
45
|
+
settings: Pick<BlogSettings, "title" | "eyebrow" | "tagline" | "description" | "pageSize"> | null | undefined,
|
|
36
46
|
defaults: { title: string; eyebrow: string; tagline: string; description?: string },
|
|
37
47
|
defaultPageSize: number,
|
|
38
48
|
): BlogIndexCopy {
|
|
39
|
-
const tagline =
|
|
49
|
+
const tagline = optionalCopy(settings?.tagline, defaults.tagline);
|
|
40
50
|
const pageSize = settings?.pageSize;
|
|
41
51
|
return {
|
|
42
52
|
title: trimmed(settings?.title) ?? defaults.title,
|
|
43
|
-
eyebrow:
|
|
53
|
+
eyebrow: optionalCopy(settings?.eyebrow, defaults.eyebrow),
|
|
44
54
|
tagline,
|
|
45
|
-
description: trimmed(settings?.description) ?? defaults.description ?? tagline,
|
|
55
|
+
description: trimmed(settings?.description) ?? defaults.description ?? tagline ?? defaults.tagline,
|
|
46
56
|
pageSize: Number.isInteger(pageSize) && pageSize! > 0 ? pageSize! : defaultPageSize,
|
|
47
57
|
};
|
|
48
58
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { BlogSettings } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which parts of the blog templates render, resolved from a collection's `settings.json`. Every
|
|
5
|
+
* part defaults ON; a tenant turns one off by declaring `false` (or `share: []`), so a migrated
|
|
6
|
+
* blog can match its source without hiding platform markup with CSS.
|
|
7
|
+
*/
|
|
8
|
+
export interface BlogPresentation {
|
|
9
|
+
hero: boolean;
|
|
10
|
+
topics: boolean;
|
|
11
|
+
postEyebrow: boolean;
|
|
12
|
+
dek: boolean;
|
|
13
|
+
aside: boolean;
|
|
14
|
+
reactions: boolean;
|
|
15
|
+
share: ShareTarget[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Every share action the post template knows how to render. */
|
|
19
|
+
export const SHARE_TARGETS = ["facebook", "email", "print", "x", "pinterest"] as const;
|
|
20
|
+
export type ShareTarget = (typeof SHARE_TARGETS)[number];
|
|
21
|
+
|
|
22
|
+
/** The share row a post renders when its collection declares no `share`. */
|
|
23
|
+
export const DEFAULT_SHARE_TARGETS: readonly ShareTarget[] = SHARE_TARGETS;
|
|
24
|
+
|
|
25
|
+
function isShareTarget(value: unknown): value is ShareTarget {
|
|
26
|
+
return typeof value === "string" && (SHARE_TARGETS as readonly string[]).includes(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Only an explicit `false` turns a part off; anything else keeps the platform default. */
|
|
30
|
+
function on(value: unknown): boolean {
|
|
31
|
+
return value !== false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolveBlogPresentation(settings: BlogSettings | null | undefined): BlogPresentation {
|
|
35
|
+
const share = Array.isArray(settings?.share)
|
|
36
|
+
? [...new Set(settings.share.filter(isShareTarget))]
|
|
37
|
+
: [...DEFAULT_SHARE_TARGETS];
|
|
38
|
+
return {
|
|
39
|
+
hero: on(settings?.hero),
|
|
40
|
+
topics: on(settings?.topics),
|
|
41
|
+
postEyebrow: on(settings?.postEyebrow),
|
|
42
|
+
dek: on(settings?.dek),
|
|
43
|
+
aside: on(settings?.aside),
|
|
44
|
+
reactions: on(settings?.reactions),
|
|
45
|
+
share,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The byline name, or null when the post names no author. A post without an author renders no
|
|
51
|
+
* byline at all: the platform never attributes a post to anyone its content does not name.
|
|
52
|
+
*/
|
|
53
|
+
export function bylineAuthor(author: string | null | undefined): string | null {
|
|
54
|
+
const name = author?.trim();
|
|
55
|
+
return name ? name : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ShareLink {
|
|
59
|
+
target: ShareTarget;
|
|
60
|
+
/** Visible text. */
|
|
61
|
+
text: string;
|
|
62
|
+
/** Accessible name. */
|
|
63
|
+
label: string;
|
|
64
|
+
/** Null for `print`, which is a button the page script wires to `window.print()`. */
|
|
65
|
+
href: string | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const SHARE_COPY: Record<ShareTarget, { text: string; label: string }> = {
|
|
69
|
+
facebook: { text: "Facebook", label: "Share on Facebook" },
|
|
70
|
+
email: { text: "Email", label: "Share by email" },
|
|
71
|
+
print: { text: "Print", label: "Print this article" },
|
|
72
|
+
x: { text: "X", label: "Share on X" },
|
|
73
|
+
pinterest: { text: "Pinterest", label: "Pin on Pinterest" },
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The share row for one post. `url` is the post's canonical URL, so a share from a preview host
|
|
78
|
+
* still points readers at the live article.
|
|
79
|
+
*/
|
|
80
|
+
export function shareLinks(
|
|
81
|
+
targets: readonly ShareTarget[],
|
|
82
|
+
post: { url: string; title: string; image?: string },
|
|
83
|
+
): ShareLink[] {
|
|
84
|
+
const u = encodeURIComponent(post.url);
|
|
85
|
+
const t = encodeURIComponent(post.title);
|
|
86
|
+
const href: Record<ShareTarget, string | null> = {
|
|
87
|
+
facebook: `https://www.facebook.com/sharer/sharer.php?u=${u}`,
|
|
88
|
+
email: `mailto:?subject=${t}&body=${u}`,
|
|
89
|
+
print: null,
|
|
90
|
+
x: `https://x.com/intent/tweet?url=${u}&text=${t}`,
|
|
91
|
+
pinterest: `https://pinterest.com/pin/create/button/?url=${u}&description=${t}${post.image ? `&media=${encodeURIComponent(post.image)}` : ""}`,
|
|
92
|
+
};
|
|
93
|
+
return targets.map((target) => ({ target, ...SHARE_COPY[target], href: href[target] }));
|
|
94
|
+
}
|
|
@@ -68,7 +68,7 @@ export function renderRssFeed(input: RssFeedInput): string {
|
|
|
68
68
|
` <link>${escapeXml(link)}</link>`,
|
|
69
69
|
` <guid isPermaLink="true">${escapeXml(link)}</guid>`,
|
|
70
70
|
pubDate ? ` <pubDate>${pubDate}</pubDate>` : "",
|
|
71
|
-
` <dc:creator>${escapeXml(post.author)}</dc:creator
|
|
71
|
+
post.author.trim() ? ` <dc:creator>${escapeXml(post.author.trim())}</dc:creator>` : "",
|
|
72
72
|
` <description>${escapeXml(post.excerpt)}</description>`,
|
|
73
73
|
contentEncoded,
|
|
74
74
|
categories,
|
|
@@ -45,21 +45,38 @@ export interface BlogPost extends BlogPostSummary {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
* Optional per-tenant blog
|
|
49
|
-
* `tenants/<id>/content
|
|
50
|
-
* field, or an absent file, resolves to a NEUTRAL default derived from the
|
|
48
|
+
* Optional per-tenant blog copy, page size and presentation, sourced from
|
|
49
|
+
* `tenants/<id>/content/<collection>/settings.json`. Every field is optional — an
|
|
50
|
+
* absent field, or an absent file, resolves to a NEUTRAL default derived from the
|
|
51
51
|
* tenant's own `displayName` (see `resolveBlogIndexCopy` in `./pagination.ts`),
|
|
52
|
-
* never Token of Trust's own copy
|
|
53
|
-
*
|
|
52
|
+
* never Token of Trust's own copy, and to the full platform presentation (see
|
|
53
|
+
* `resolveBlogPresentation` in `./presentation.ts`). A tenant that wants its own
|
|
54
|
+
* branding (tokenoftrust.com included) declares it here.
|
|
54
55
|
*/
|
|
55
56
|
export interface BlogSettings {
|
|
56
57
|
title?: string;
|
|
57
|
-
eyebrow
|
|
58
|
-
|
|
58
|
+
/** Index eyebrow. `""` or `null` renders none; absent uses the platform default. */
|
|
59
|
+
eyebrow?: string | null;
|
|
60
|
+
/** Index tagline. `""` or `null` renders none; absent uses the platform default. */
|
|
61
|
+
tagline?: string | null;
|
|
59
62
|
description?: string;
|
|
60
63
|
/** Posts per `/blog/` page. Must be a positive integer; anything else falls
|
|
61
64
|
* back to the platform default (`DEFAULT_BLOG_PAGE_SIZE`). */
|
|
62
65
|
pageSize?: number;
|
|
66
|
+
/** Coloured band behind the index and tag/category/author archive headings. Default true. */
|
|
67
|
+
hero?: boolean;
|
|
68
|
+
/** "Explore topics" tag list beside the index grid. Default true. */
|
|
69
|
+
topics?: boolean;
|
|
70
|
+
/** Post eyebrow (the post's first tag above its title). Default true. */
|
|
71
|
+
postEyebrow?: boolean;
|
|
72
|
+
/** Post dek (the excerpt repeated under the title). Default true. */
|
|
73
|
+
dek?: boolean;
|
|
74
|
+
/** Post side column ("← All articles" + tag links). Default true; when false the tags follow the body. */
|
|
75
|
+
aside?: boolean;
|
|
76
|
+
/** Reader reaction bar under the post. Default true. */
|
|
77
|
+
reactions?: boolean;
|
|
78
|
+
/** Share row after the post's tags, in this order. `[]` renders none; absent uses `DEFAULT_SHARE_TARGETS`. */
|
|
79
|
+
share?: string[];
|
|
63
80
|
}
|
|
64
81
|
|
|
65
82
|
/**
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ChromeConfig } from "./chrome/model.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One breadcrumb trail per page. A page builds it ONCE with `siteTrail` and hands the same array
|
|
5
|
+
* to the visible `<Breadcrumbs>` and to `breadcrumbLd`, so what a reader sees and what search
|
|
6
|
+
* engines read cannot disagree.
|
|
7
|
+
*/
|
|
8
|
+
export interface Crumb {
|
|
9
|
+
label: string;
|
|
10
|
+
/** Tenant-relative path; `<Breadcrumbs>` adds the base path, `breadcrumbLd` the canonical origin. */
|
|
11
|
+
href: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The first crumb's label when the tenant's chrome declares none. */
|
|
15
|
+
export const DEFAULT_HOME_CRUMB_LABEL = "Home";
|
|
16
|
+
|
|
17
|
+
/** The home crumb's label: the tenant's declared `chrome.json` `breadcrumbs.homeLabel`, else "Home". */
|
|
18
|
+
export function homeCrumbLabel(chrome: Pick<ChromeConfig, "breadcrumbs"> | null | undefined): string {
|
|
19
|
+
return chrome?.breadcrumbs?.homeLabel?.trim() || DEFAULT_HOME_CRUMB_LABEL;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The full trail: the home crumb, then `crumbs` in order. The last crumb is the current page. */
|
|
23
|
+
export function siteTrail(homeLabel: string, ...crumbs: Crumb[]): Crumb[] {
|
|
24
|
+
return [{ label: homeLabel, href: "/" }, ...crumbs];
|
|
25
|
+
}
|
|
@@ -23,6 +23,10 @@ export {
|
|
|
23
23
|
type FooterNewsletter,
|
|
24
24
|
type ChromeBrand,
|
|
25
25
|
type ChromeSearch,
|
|
26
|
+
HEADER_ACTION_KINDS,
|
|
27
|
+
type HeaderActionKind,
|
|
28
|
+
type ChromeHeaderActionLink,
|
|
29
|
+
type ChromeHeaderMobile,
|
|
26
30
|
type ChromeUtilityNav,
|
|
27
31
|
type ChromeAnnouncement,
|
|
28
32
|
type ChromeAgeGate,
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slice of the Workers runtime module `cloudflare:workers` this app uses.
|
|
3
|
+
*
|
|
4
|
+
* The storefront does not depend on @cloudflare/workers-types (its globals collide with the DOM
|
|
5
|
+
* lib Astro compiles against), so the Durable Object surface the pipeline needs is declared here.
|
|
6
|
+
* The module only resolves inside workerd; under Node it is imported lazily and guarded
|
|
7
|
+
* (`lib/runtimeEnv.ts`).
|
|
8
|
+
*/
|
|
9
|
+
declare module "cloudflare:workers" {
|
|
10
|
+
export type SqlStorageValue = ArrayBuffer | string | number | null;
|
|
11
|
+
|
|
12
|
+
export interface SqlStorageCursor<T extends Record<string, SqlStorageValue>> extends Iterable<T> {
|
|
13
|
+
toArray(): T[];
|
|
14
|
+
one(): T;
|
|
15
|
+
readonly rowsRead: number;
|
|
16
|
+
readonly rowsWritten: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SqlStorage {
|
|
20
|
+
exec<T extends Record<string, SqlStorageValue>>(query: string, ...bindings: unknown[]): SqlStorageCursor<T>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DurableObjectStorage {
|
|
24
|
+
readonly sql: SqlStorage;
|
|
25
|
+
transactionSync<T>(closure: () => T): T;
|
|
26
|
+
getAlarm(): Promise<number | null>;
|
|
27
|
+
setAlarm(scheduledTime: number | Date): Promise<void>;
|
|
28
|
+
deleteAlarm(): Promise<void>;
|
|
29
|
+
sync(): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DurableObjectId {
|
|
33
|
+
toString(): string;
|
|
34
|
+
readonly name?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface DurableObjectState {
|
|
38
|
+
readonly id: DurableObjectId;
|
|
39
|
+
readonly storage: DurableObjectStorage;
|
|
40
|
+
blockConcurrencyWhile<T>(callback: () => Promise<T>): Promise<T>;
|
|
41
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
42
|
+
abort(reason?: string): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export abstract class DurableObject<Env = unknown> {
|
|
46
|
+
protected readonly ctx: DurableObjectState;
|
|
47
|
+
protected readonly env: Env;
|
|
48
|
+
constructor(ctx: DurableObjectState, env: Env);
|
|
49
|
+
alarm?(alarmInfo?: { retryCount: number; isRetry: boolean }): void | Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const env: Record<string, unknown>;
|
|
53
|
+
}
|