@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.
Files changed (107) 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 +20 -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/chrome/model.ts +4 -0
  50. package/apps/storefront/src/lib/cloudflare-workers.d.ts +53 -0
  51. package/apps/storefront/src/lib/compliance/rawComplianceNotices.ts +38 -18
  52. package/apps/storefront/src/lib/dashboard/delegateGate.ts +28 -0
  53. package/apps/storefront/src/lib/homeVisualParity.ts +8 -5
  54. package/apps/storefront/src/lib/jsonld.ts +5 -5
  55. package/apps/storefront/src/lib/pinnedRouteRewriteBoundary.ts +20 -2
  56. package/apps/storefront/src/lib/rawChrome.ts +74 -12
  57. package/apps/storefront/src/lib/runtimeEnv.ts +1 -1
  58. package/apps/storefront/src/lib/seo/documentTitle.ts +19 -0
  59. package/apps/storefront/src/lib/shared/addToRelease.ts +32 -0
  60. package/apps/storefront/src/lib/storyblok/content-model.ts +3 -0
  61. package/apps/storefront/src/lib/styleGuideThemes.ts +20 -22
  62. package/apps/storefront/src/pages/[...slug].astro +13 -8
  63. package/apps/storefront/src/pages/blog/[slug].astro +56 -24
  64. package/apps/storefront/src/pages/blog/author/[author].astro +17 -9
  65. package/apps/storefront/src/pages/blog/category/[category].astro +18 -10
  66. package/apps/storefront/src/pages/blog/index.astro +2 -0
  67. package/apps/storefront/src/pages/blog/page/[n].astro +2 -0
  68. package/apps/storefront/src/pages/blog/tag/[tag].astro +18 -10
  69. package/apps/storefront/src/pages/collections/[handle].astro +5 -4
  70. package/apps/storefront/src/pages/collections/index.astro +4 -3
  71. package/apps/storefront/src/pages/index.astro +34 -27
  72. package/apps/storefront/src/pages/products/[handle].astro +5 -4
  73. package/apps/storefront/src/pages/saved.astro +4 -3
  74. package/apps/storefront/src/pages/search.astro +4 -3
  75. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +129 -118
  76. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +19 -15
  77. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +23 -27
  78. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -14
  79. package/apps/storefront/src/pages/style-guide/index.astro +6 -5
  80. package/apps/storefront/src/pages/tenants/[id]/[...path].ts +32 -7
  81. package/apps/storefront/src/pages/the-build/[slug].astro +44 -20
  82. package/apps/storefront/src/pages/the-build/index.astro +2 -0
  83. package/apps/storefront/src/pages/the-build/page/[n].astro +2 -0
  84. package/apps/storefront/src/styles/admin.css +47 -0
  85. package/apps/storefront/src/styles/global.css +104 -56
  86. package/apps/storefront/src/themes/derivedTokens.ts +34 -0
  87. package/apps/storefront/src/themes/schema.ts +6 -0
  88. package/apps/storefront/test/pipeline/harness.ts +164 -0
  89. package/apps/storefront/test/pipeline/intake-worker.mjs +14 -0
  90. package/apps/storefront/test/pipeline/pipeline-host.ts +77 -0
  91. package/apps/storefront/test/pipeline/synthetic-job.ts +156 -0
  92. package/apps/storefront/vitest.workers.config.cts +67 -0
  93. package/docs/widget-library-guide.md +1496 -0
  94. package/package.json +1 -1
  95. package/packages/cli/src/declared-config.mjs +400 -5
  96. package/packages/public-runtime/package.json +1 -0
  97. package/packages/public-runtime/src/chrome.ts +93 -2
  98. package/packages/public-runtime/src/compliance-coherence.d.mts +17 -0
  99. package/packages/public-runtime/src/compliance-coherence.mjs +84 -0
  100. package/packages/public-runtime/src/declared-tenant-config.d.mts +1 -1
  101. package/packages/public-runtime/src/declared-tenant-config.mjs +6 -2
  102. package/packages/public-runtime/src/extension-contract-values.d.mts +4 -1
  103. package/packages/public-runtime/src/extension-contract-values.mjs +14 -1
  104. package/packages/public-runtime/src/extension-contract.ts +2 -0
  105. package/packages/public-runtime/src/tenant.ts +48 -4
  106. package/pnpm-lock.runner.yaml +469 -0
  107. 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/compliance data it lacks DEGRADES to empty — siteType "marketing"
18
- * means no catalog is required, and index.astro serves the raw home.html before
19
- * any product resolution, so the tenant root renders instead of 404ing.
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
- * Commerce is data-driven: with catalog fixtures on disk the tenant is
60
- * COMMERCE (catalog on); without them it degrades to a marketing home (siteType
61
- * "marketing" → no catalog required, so the root renders instead of 404ing).
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
- if (hints.hasCatalog) {
102
- // Catalog fixtures on disk → a real commerce tenant (siteType omitted ==
103
- // commerce → catalog on). Cart wires to the shared ToT demo checkout store
104
- // (test mode, no secret), the same as the bundled demo tenants — so
105
- // /collections + /products resolve and add-to-cart works on localhost.
106
- return {
107
- ...base,
108
- commerce: { checkout: { storeSubDomain: "tot-preview", mode: "unsigned-demo" } },
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 (catalog presence, slug scope) the resolver reads.
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, hasCatalog)
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, hasCatalog, analyticsRaw }));
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 candidate/revision asset pin from preview cookies.
2
+ * Resolve the asset version a preview subresource reads.
3
3
  *
4
- * Candidate and revision renderers set `tot_preview_version` (the exact sha) and
5
- * `tot_preview` (capability token). Subresource GETs (img `/_a/…`, `/tenants/…`)
6
- * carry those cookies but not `__tot_candidate_pin`, so this is the revision
7
- * authority for revision-bound captured originals and preview assets.
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). `defaults.tagline` is
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: { title?: string; eyebrow?: string; tagline?: string; description?: string; pageSize?: number } | null | undefined,
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 = trimmed(settings?.tagline) ?? defaults.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: trimmed(settings?.eyebrow) ?? defaults.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 index copy + page size, sourced from
49
- * `tenants/<id>/content/blog/settings.json`. Every field is optional — an absent
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. A tenant that wants its own branding
53
- * (tokenoftrust.com included) declares it here.
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?: string;
58
- tagline?: string;
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
+ }