@tokenoftrust/storefront-runner 1.4.0 → 1.4.2-rc.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 (193) hide show
  1. package/apps/storefront/astro.config.mjs +17 -1
  2. package/apps/storefront/integrations/materialize-guard.mjs +56 -0
  3. package/apps/storefront/migrations-apps/0001_woozy_lyja.sql +22 -0
  4. package/apps/storefront/migrations-apps/0002_good_switch.sql +26 -0
  5. package/apps/storefront/migrations-apps/0003_mute_marauders.sql +12 -0
  6. package/apps/storefront/migrations-apps/meta/0001_snapshot.json +990 -0
  7. package/apps/storefront/migrations-apps/meta/0002_snapshot.json +1160 -0
  8. package/apps/storefront/migrations-apps/meta/0003_snapshot.json +1235 -0
  9. package/apps/storefront/migrations-apps/meta/_journal.json +21 -0
  10. package/apps/storefront/package.json +0 -1
  11. package/apps/storefront/public/js/dashboard-team.js +38 -2
  12. package/apps/storefront/src/components/Seo.astro +65 -1
  13. package/apps/storefront/src/components/admin/AdminBlogDetailRail.astro +878 -0
  14. package/apps/storefront/src/components/admin/AdminBlogTab.astro +1432 -0
  15. package/apps/storefront/src/components/admin/AdminCustomersTab.astro +3 -7
  16. package/apps/storefront/src/components/admin/AdminPublishTab.astro +1756 -1382
  17. package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +1 -3
  18. package/apps/storefront/src/components/blog/BlogPaywall.astro +87 -0
  19. package/apps/storefront/src/components/blog/PostAccessBadge.astro +43 -0
  20. package/apps/storefront/src/components/blog/PostGrid.astro +57 -0
  21. package/apps/storefront/src/components/blog/ReactionBar.astro +214 -0
  22. package/apps/storefront/src/layouts/Layout.astro +48 -3
  23. package/apps/storefront/src/lib/activity/alerts.ts +426 -0
  24. package/apps/storefront/src/lib/activity/changeActorAttribution.ts +85 -0
  25. package/apps/storefront/src/lib/activity/deployVersion.ts +120 -0
  26. package/apps/storefront/src/lib/activity/ingest.ts +188 -0
  27. package/apps/storefront/src/lib/activity/ingestAuth.ts +105 -0
  28. package/apps/storefront/src/lib/activity/killSwitch.ts +80 -0
  29. package/apps/storefront/src/lib/activity/query.ts +403 -0
  30. package/apps/storefront/src/lib/activity/recordActivity.ts +101 -0
  31. package/apps/storefront/src/lib/activity/store.ts +120 -0
  32. package/apps/storefront/src/lib/activity/uiActor.ts +150 -0
  33. package/apps/storefront/src/lib/activity/workerCommit.ts +70 -0
  34. package/apps/storefront/src/lib/admin/adminShell.ts +290 -0
  35. package/apps/storefront/src/lib/admin/ai/approvalMode.ts +2 -3
  36. package/apps/storefront/src/lib/admin/ai/audit.ts +3 -5
  37. package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +7 -7
  38. package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +3 -3
  39. package/apps/storefront/src/lib/admin/orderClient.ts +3 -5
  40. package/apps/storefront/src/lib/admin/services/orders.ts +17 -19
  41. package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +4 -6
  42. package/apps/storefront/src/lib/admin/viewmodels/settings.ts +1 -1
  43. package/apps/storefront/src/lib/apps/adminService.ts +4 -4
  44. package/apps/storefront/src/lib/apps/adminSession.ts +53 -44
  45. package/apps/storefront/src/lib/apps/apiAuth.ts +2 -2
  46. package/apps/storefront/src/lib/apps/apiRoute.ts +2 -2
  47. package/apps/storefront/src/lib/apps/catalogMapper.ts +1 -1
  48. package/apps/storefront/src/lib/apps/credentials.ts +2 -2
  49. package/apps/storefront/src/lib/apps/gatewayKeys.ts +4 -4
  50. package/apps/storefront/src/lib/apps/healthAggregate.ts +5 -5
  51. package/apps/storefront/src/lib/apps/orders/attributionService.ts +2 -2
  52. package/apps/storefront/src/lib/apps/orders/customerHash.ts +1 -1
  53. package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +12 -12
  54. package/apps/storefront/src/lib/apps/orders/idempotency.ts +7 -7
  55. package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +1 -1
  56. package/apps/storefront/src/lib/apps/orders/orderMapper.ts +2 -2
  57. package/apps/storefront/src/lib/apps/orders/ordersStore.ts +6 -6
  58. package/apps/storefront/src/lib/apps/registryService.ts +6 -6
  59. package/apps/storefront/src/lib/apps/scopes.ts +2 -2
  60. package/apps/storefront/src/lib/apps/tokenIssuer.ts +4 -4
  61. package/apps/storefront/src/lib/apps/tokenVerifier.ts +6 -6
  62. package/apps/storefront/src/lib/apps/widgets/eligibility.ts +2 -2
  63. package/apps/storefront/src/lib/apps/widgets/frameProps.ts +3 -3
  64. package/apps/storefront/src/lib/apps/widgets/launchToken.ts +4 -4
  65. package/apps/storefront/src/lib/apps/widgets/placements.ts +2 -2
  66. package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +1 -1
  67. package/apps/storefront/src/lib/auth/adminEntry.ts +9 -9
  68. package/apps/storefront/src/lib/auth/identityToken.ts +6 -14
  69. package/apps/storefront/src/lib/auth/loginGate.ts +75 -13
  70. package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +6 -4
  71. package/apps/storefront/src/lib/auth/operatorSession.ts +113 -0
  72. package/apps/storefront/src/lib/auth/route.ts +44 -4
  73. package/apps/storefront/src/lib/auth/session.ts +7 -8
  74. package/apps/storefront/src/lib/auth/totAccessClient.ts +8 -18
  75. package/apps/storefront/src/lib/basePath.ts +9 -2
  76. package/apps/storefront/src/lib/blog/access.ts +29 -0
  77. package/apps/storefront/src/lib/blog/collection.ts +313 -0
  78. package/apps/storefront/src/lib/blog/markdown.ts +174 -0
  79. package/apps/storefront/src/lib/blog/provider.ts +5 -0
  80. package/apps/storefront/src/lib/blog/reactions.ts +87 -0
  81. package/apps/storefront/src/lib/blog/rss.ts +92 -0
  82. package/apps/storefront/src/lib/blog/teaser.ts +83 -0
  83. package/apps/storefront/src/lib/blog/types.ts +34 -0
  84. package/apps/storefront/src/lib/blog/viewCounts.ts +243 -0
  85. package/apps/storefront/src/lib/d1/catalog.ts +2 -2
  86. package/apps/storefront/src/lib/d1/schema-apps.ts +147 -14
  87. package/apps/storefront/src/lib/dev/activityIngestToken.ts +2 -4
  88. package/apps/storefront/src/lib/dev/activityStore.ts +3 -5
  89. package/apps/storefront/src/lib/dev/apiBase.ts +1 -4
  90. package/apps/storefront/src/lib/dev/cockpitStore.ts +1 -9
  91. package/apps/storefront/src/lib/dev/devDraft.ts +1 -1
  92. package/apps/storefront/src/lib/dev/previewStatus.ts +9 -21
  93. package/apps/storefront/src/lib/dev/rendezvousBroker.ts +9 -24
  94. package/apps/storefront/src/lib/edgeCache.ts +17 -0
  95. package/apps/storefront/src/lib/env.ts +51 -0
  96. package/apps/storefront/src/lib/http/fetchTransport.ts +63 -0
  97. package/apps/storefront/src/lib/i18n.ts +25 -0
  98. package/apps/storefront/src/lib/jsonld.ts +118 -0
  99. package/apps/storefront/src/lib/monitoring/manifest.ts +11 -11
  100. package/apps/storefront/src/lib/newsletter/confirmationEmail.ts +30 -0
  101. package/apps/storefront/src/lib/newsletter/consent.ts +23 -0
  102. package/apps/storefront/src/lib/newsletter/digest.ts +117 -0
  103. package/apps/storefront/src/lib/newsletter/resendClient.ts +79 -0
  104. package/apps/storefront/src/lib/newsletter/send.ts +265 -0
  105. package/apps/storefront/src/lib/newsletter/subscribers.ts +156 -0
  106. package/apps/storefront/src/lib/publish/adminPublishTab.ts +3519 -0
  107. package/apps/storefront/src/lib/publish/apex-readiness.ts +41 -18
  108. package/apps/storefront/src/lib/publish/dispatchHealth.ts +5 -7
  109. package/apps/storefront/src/lib/publish/domainState.ts +22 -13
  110. package/apps/storefront/src/lib/publish/panelPolling.ts +78 -0
  111. package/apps/storefront/src/lib/publish/shipWorkspace.ts +100 -42
  112. package/apps/storefront/src/lib/publish/stageBundleUpload.ts +215 -0
  113. package/apps/storefront/src/lib/rate/kvWindow.ts +53 -0
  114. package/apps/storefront/src/lib/search/index.ts +2 -2
  115. package/apps/storefront/src/lib/seo/alternates.ts +42 -0
  116. package/apps/storefront/src/lib/seo/meta.ts +65 -0
  117. package/apps/storefront/src/lib/social/golive.ts +212 -0
  118. package/apps/storefront/src/lib/social/notify.ts +32 -0
  119. package/apps/storefront/src/lib/social/tokenStore.ts +171 -0
  120. package/apps/storefront/src/lib/the-build/provider.ts +27 -0
  121. package/apps/storefront/src/lib/the-build/readingTime.ts +35 -0
  122. package/apps/storefront/src/lib/the-build/toc.ts +95 -0
  123. package/apps/storefront/src/lib/tot/ToTClient.ts +1 -1
  124. package/apps/storefront/src/lib/tot/d1Client.ts +2 -2
  125. package/apps/storefront/src/lib/tot/totClientInterface.ts +3 -4
  126. package/apps/storefront/src/lib/webhooks/deliveryStore.ts +16 -17
  127. package/apps/storefront/src/middleware/index.ts +130 -2
  128. package/apps/storefront/src/pages/admin/ops-timeline.astro +420 -0
  129. package/apps/storefront/src/pages/admin.astro +52 -1206
  130. package/apps/storefront/src/pages/api/activity.ts +137 -0
  131. package/apps/storefront/src/pages/api/admin/activity-alerts.ts +73 -0
  132. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +27 -1
  133. package/apps/storefront/src/pages/api/apps/admin/install.ts +26 -1
  134. package/apps/storefront/src/pages/api/apps/admin/resume.ts +24 -1
  135. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +24 -1
  136. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +25 -1
  137. package/apps/storefront/src/pages/api/apps/admin/update.ts +24 -1
  138. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +11 -0
  139. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +11 -0
  140. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +20 -0
  141. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +12 -0
  142. package/apps/storefront/src/pages/api/blog/[collection]/[slug]/react.ts +104 -0
  143. package/apps/storefront/src/pages/api/cache-purge.ts +11 -0
  144. package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +30 -0
  145. package/apps/storefront/src/pages/api/newsletter/[collection]/subscribe.ts +146 -0
  146. package/apps/storefront/src/pages/api/newsletter/confirm.ts +75 -0
  147. package/apps/storefront/src/pages/api/newsletter/unsubscribe.ts +90 -0
  148. package/apps/storefront/src/pages/api/publish/stage-bundle.ts +70 -0
  149. package/apps/storefront/src/pages/api/rum/vitals.ts +21 -2
  150. package/apps/storefront/src/pages/auth/login.astro +5 -4
  151. package/apps/storefront/src/pages/blog/[slug].astro +96 -16
  152. package/apps/storefront/src/pages/blog/author/[author].astro +140 -0
  153. package/apps/storefront/src/pages/blog/category/[category].astro +67 -0
  154. package/apps/storefront/src/pages/blog/index.astro +2 -0
  155. package/apps/storefront/src/pages/blog/tag/[tag].astro +65 -0
  156. package/apps/storefront/src/pages/cockpit.astro +74 -12
  157. package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +15 -11
  158. package/apps/storefront/src/pages/search.astro +1 -1
  159. package/apps/storefront/src/pages/sitemap.xml.ts +20 -0
  160. package/apps/storefront/src/pages/the-build/[slug].astro +129 -0
  161. package/apps/storefront/src/pages/the-build/index.astro +146 -0
  162. package/apps/storefront/src/pages/the-build/rss.xml.ts +33 -0
  163. package/apps/storefront/src/styles/admin.css +1080 -0
  164. package/apps/storefront/tsconfig.json +5 -0
  165. package/package.json +1 -1
  166. package/packages/public-runtime/package.json +1 -0
  167. package/packages/public-runtime/src/activity/README.md +146 -0
  168. package/packages/public-runtime/src/activity/catalog.ts +553 -0
  169. package/packages/public-runtime/src/activity/event.ts +168 -0
  170. package/packages/public-runtime/src/activity/index.ts +16 -0
  171. package/packages/public-runtime/src/activity/redaction.ts +263 -0
  172. package/packages/public-runtime/src/candidate-index.ts +13 -0
  173. package/packages/public-runtime/src/catalog-d1.ts +1 -1
  174. package/packages/public-runtime/src/customization-preview.ts +25 -2
  175. package/packages/public-runtime/src/customization-runtime.ts +125 -3
  176. package/packages/public-runtime/src/customization-versioning.ts +10 -0
  177. package/packages/public-runtime/src/index.ts +7 -0
  178. package/packages/public-runtime/src/static-bundle.ts +357 -0
  179. package/packages/public-runtime/src/tenant.ts +36 -8
  180. package/scripts/build/copy-tenant-assets.mjs +14 -4
  181. package/scripts/dev/ai-edit.mjs +1 -1
  182. package/scripts/dev/checkout-watch.mjs +2 -2
  183. package/scripts/dev/file-browser.mjs +2 -4
  184. package/scripts/dev/file-writer.mjs +1 -1
  185. package/scripts/dev/git-status.mjs +2 -2
  186. package/scripts/dev/locate-handler.mjs +3 -3
  187. package/scripts/dev/port-check.mjs +3 -3
  188. package/scripts/dev/publish.mjs +5 -9
  189. package/scripts/dev/shot.mjs +1 -1
  190. package/scripts/dev/unified-diff.mjs +8 -3
  191. package/scripts/tot-dev.mjs +6 -9
  192. package/apps/storefront/src/lib/webhooks/signing.ts +0 -29
  193. package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +0 -146
@@ -28,7 +28,7 @@ const isJson = url.searchParams.get("format") === "json";
28
28
  // Search goes through the ToTClient: D1 FTS5 when a D1 binding is wired, else a
29
29
  // Fuse index over the bundled snapshot (FixtureToTClient). It returns full
30
30
  // products, so the HTML grid renders directly and the typeahead just projects to
31
- // the compact shape. See epics/edge-read-replica.md.
31
+ // the compact shape.
32
32
 
33
33
  // --- JSON path: powers the header typeahead island. Must return BEFORE render.
34
34
  if (isJson) {
@@ -11,6 +11,8 @@
11
11
  import type { APIContext } from "astro";
12
12
  import { nonEmptyCollections } from "@/lib/tot/query.js";
13
13
  import { listBlogPosts } from "@/lib/blog/provider.js";
14
+ import { authorSlug } from "@/lib/blog/collection.js";
15
+ import { listBuildPosts } from "@/lib/the-build/provider.js";
14
16
 
15
17
  interface SitemapUrl {
16
18
  loc: string;
@@ -71,6 +73,24 @@ export async function GET(context: APIContext): Promise<Response> {
71
73
  for (const post of blogPosts) {
72
74
  urls.push({ loc: `${base}/blog/${post.slug}/`, lastmod: post.modifiedAt || post.publishedAt });
73
75
  }
76
+ // Author archives (blog/author/[author].astro): one per distinct byline,
77
+ // same source as the pages themselves so the sitemap never lists an
78
+ // archive that would 404 (and vice versa — the static publish's T3 gate
79
+ // requires every rendered route to appear here).
80
+ const authorSlugs = [
81
+ ...new Set(blogPosts.map((post) => authorSlug(post.author)).filter(Boolean)),
82
+ ].sort();
83
+ for (const slug of authorSlugs) {
84
+ urls.push({ loc: `${base}/blog/author/${slug}/` });
85
+ }
86
+ }
87
+
88
+ const buildPosts = await listBuildPosts(tenant.tenant_id);
89
+ if (buildPosts.length > 0) {
90
+ urls.push({ loc: `${base}/the-build/` });
91
+ for (const post of buildPosts) {
92
+ urls.push({ loc: `${base}/the-build/${post.slug}/`, lastmod: post.modifiedAt || post.publishedAt });
93
+ }
74
94
  }
75
95
 
76
96
  const body = `<?xml version="1.0" encoding="UTF-8"?>
@@ -0,0 +1,129 @@
1
+ ---
2
+ // Detail route for "the-build" collection posts — mirrors src/pages/blog/
3
+ // but reads the generic per-tenant × per-collection engine via
4
+ // the-build/provider.ts. Draft posts 404 on live (getBuildPost's default
5
+ // visibility chokepoint); unlisted posts resolve here but carry noindex and
6
+ // stay out of the index/sitemap/RSS (see collection.ts isVisibleOnLive).
7
+ import Layout from "@/layouts/Layout.astro";
8
+ import ReactionBar from "@/components/blog/ReactionBar.astro";
9
+ import { getBuildPost, resolveBuildRedirect, THE_BUILD } from "@/lib/the-build/provider.js";
10
+ import { getTranslations } from "@/lib/blog/collection.js";
11
+ import { buildAlternates } from "@/lib/seo/alternates.js";
12
+ import { breadcrumbLd } from "@/lib/jsonld.js";
13
+
14
+ const { tenant, canonicalBase, basePath } = Astro.locals;
15
+ const slug = Astro.params.slug ?? "";
16
+ const post = await getBuildPost(tenant.tenant_id, slug);
17
+ if (!post) {
18
+ const redirect = await resolveBuildRedirect(tenant.tenant_id, slug);
19
+ if (redirect) return Astro.redirect(`${basePath}/the-build/${redirect}/`, 301);
20
+ return new Response(null, { status: 404 });
21
+ }
22
+
23
+ const canonical = new URL(`/the-build/${post.slug}/`, canonicalBase + "/").toString();
24
+ const articleLd = {
25
+ "@context": "https://schema.org",
26
+ "@type": "BlogPosting",
27
+ headline: post.title,
28
+ description: post.description,
29
+ url: canonical,
30
+ mainEntityOfPage: canonical,
31
+ datePublished: post.publishedAt,
32
+ dateModified: post.modifiedAt,
33
+ author: { "@type": "Person", name: post.author },
34
+ publisher: { "@type": "Organization", name: tenant.displayName, url: canonicalBase },
35
+ ...(post.featuredImage ? { image: new URL(post.featuredImage.src, canonicalBase + "/").toString() } : {}),
36
+ };
37
+ const crumbs = breadcrumbLd([
38
+ { label: tenant.displayName, href: "/" },
39
+ { label: "The Build", href: "/the-build/" },
40
+ { label: post.title, href: `/the-build/${post.slug}/` },
41
+ ], canonicalBase);
42
+ const buildHref = `${basePath}/the-build/`;
43
+
44
+ // I2 — hreflang alternates + per-variant <html lang>. `getTranslations` returns
45
+ // the live-visible variant set (self + siblings); `buildAlternates` yields [] for
46
+ // a singleton, so a non-translated post gets no alternates and no `lang` override
47
+ // (byte-identical to today).
48
+ const translations = await getTranslations(tenant.tenant_id, THE_BUILD.id, post.slug);
49
+ const alternates = buildAlternates(translations, (s) =>
50
+ new URL(`/the-build/${s}/`, canonicalBase + "/").toString(),
51
+ );
52
+ const lang = alternates.length ? (post.inLanguage ?? "en") : undefined;
53
+ ---
54
+
55
+ <Layout
56
+ title={post.seoTitle || post.title}
57
+ description={post.description}
58
+ path={`/the-build/${post.slug}/`}
59
+ ogType="article"
60
+ ogImage={post.featuredImage?.src}
61
+ noindex={post.status === "unlisted"}
62
+ jsonLd={[crumbs, articleLd]}
63
+ alternates={alternates}
64
+ lang={lang}
65
+ >
66
+ <article class="article-shell">
67
+ <nav class="breadcrumbs" aria-label="Breadcrumb"><a href={buildHref}>The Build</a><span aria-hidden="true">/</span><span>{post.categories[0] ?? "Article"}</span></nav>
68
+ <header>
69
+ {post.tags[0] && <p class="eyebrow eyebrow--tick">{post.tags[0]}</p>}
70
+ <h1>{post.title}</h1>
71
+ <p class="dek">{post.excerpt}</p>
72
+ <div class="byline">
73
+ <span>By {post.author}</span>
74
+ <span aria-hidden="true">·</span>
75
+ <time datetime={post.publishedAt}>{new Date(post.publishedAt).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}</time>
76
+ </div>
77
+ </header>
78
+ {post.featuredImage && (
79
+ <img
80
+ class="featured"
81
+ src={post.featuredImage.src}
82
+ alt={post.featuredImage.alt}
83
+ width={post.featuredImage.width}
84
+ height={post.featuredImage.height}
85
+ fetchpriority="high"
86
+ decoding="async"
87
+ />
88
+ )}
89
+ <div class="article-grid">
90
+ <aside>
91
+ <a href={buildHref}>← All articles</a>
92
+ {post.tags.length > 0 && <div class="article-tags">{post.tags.map((tag) => <span>{tag}</span>)}</div>}
93
+ </aside>
94
+ <div class="blog-body" set:html={post.contentHtml}></div>
95
+ </div>
96
+ <ReactionBar collection="the-build" slug={post.slug} />
97
+ </article>
98
+ </Layout>
99
+
100
+ <style>
101
+ .article-shell { width: min(1120px, calc(100% - 2rem)); margin-inline: auto; padding-block: clamp(2rem, 5vw, 5rem); }
102
+ .breadcrumbs { display: flex; gap: .55rem; align-items: center; color: var(--color-muted); font-size: .84rem; }
103
+ .breadcrumbs a { color: var(--color-accent-ink); font-weight: 700; }
104
+ header { max-width: 880px; margin: clamp(2.5rem, 6vw, 5rem) auto clamp(2rem, 5vw, 3.5rem); text-align: center; }
105
+ header .eyebrow { justify-content: center; }
106
+ h1 { margin-top: .9rem; font-size: clamp(2.5rem, 6vw, 5rem); line-height: 1.02; }
107
+ .dek { max-width: 68ch; margin: 1.5rem auto 0; color: var(--color-muted); font-size: clamp(1rem, 2vw, 1.2rem); }
108
+ .byline { display: flex; justify-content: center; gap: .55rem; margin-top: 1.35rem; color: var(--color-muted); font-size: .85rem; }
109
+ .featured { display: block; width: 100%; max-height: 620px; object-fit: cover; border-radius: var(--radius-lg); box-shadow: var(--shadow-md); }
110
+ .article-grid { display: grid; grid-template-columns: 190px minmax(0, 720px); justify-content: center; gap: clamp(2rem, 6vw, 5rem); margin-top: clamp(3rem, 7vw, 5.5rem); }
111
+ aside { align-self: start; position: sticky; top: 7rem; }
112
+ aside > a { color: var(--color-accent-ink); font-size: .88rem; font-weight: 700; }
113
+ .article-tags { display: flex; flex-wrap: wrap; gap: .45rem; margin-top: 1.2rem; }
114
+ .article-tags span { padding: .3rem .55rem; border: 1px solid var(--color-border); border-radius: var(--radius-full); color: var(--color-muted); font-size: .7rem; }
115
+ .blog-body { min-width: 0; font-size: 1.05rem; line-height: 1.78; }
116
+ .blog-body :global(p), .blog-body :global(ul), .blog-body :global(ol), .blog-body :global(blockquote), .blog-body :global(figure), .blog-body :global(table), .blog-body :global(pre) { margin-block: 1.25rem; }
117
+ .blog-body :global(h2), .blog-body :global(h3), .blog-body :global(h4) { scroll-margin-top: 7rem; margin: 2.8rem 0 1rem; line-height: 1.18; }
118
+ .blog-body :global(h2) { font-size: clamp(1.75rem, 3vw, 2.35rem); }
119
+ .blog-body :global(h3) { font-size: clamp(1.35rem, 2.4vw, 1.75rem); }
120
+ .blog-body :global(a) { color: var(--color-accent-ink); text-decoration: underline; text-underline-offset: .18em; }
121
+ .blog-body :global(ul), .blog-body :global(ol) { padding-left: 1.4rem; }
122
+ .blog-body :global(li) { margin-block: .45rem; }
123
+ .blog-body :global(blockquote) { padding: .8rem 1.25rem; border-left: 4px solid var(--color-accent); background: var(--color-surface); color: var(--color-muted); }
124
+ .blog-body :global(img) { display: block; width: auto; max-width: 100%; margin-inline: auto; border-radius: var(--radius-md); }
125
+ .blog-body :global(figcaption) { margin-top: .5rem; color: var(--color-muted); text-align: center; font-size: .78rem; }
126
+ .blog-body :global(table) { display: block; width: 100%; overflow-x: auto; border-collapse: collapse; }
127
+ .blog-body :global(th), .blog-body :global(td) { padding: .7rem; border: 1px solid var(--color-border); text-align: left; }
128
+ @media (max-width: 760px) { .article-grid { grid-template-columns: 1fr; } aside { position: static; } .article-tags { display: none; } }
129
+ </style>
@@ -0,0 +1,146 @@
1
+ ---
2
+ // Base reader route for the "the-build" collection — mirrors src/pages/blog/
3
+ // but reads the generic per-tenant × per-collection engine (src/lib/blog/
4
+ // collection.ts) via the the-build/provider.ts thin wrapper, so any tenant
5
+ // with the-build content renders the same way (per-tenant/multi-collection
6
+ // invariant — see docs/plans/2026-08-21-blog-platform-workstream.md §1).
7
+ import Layout from "@/layouts/Layout.astro";
8
+ import PostAccessBadge from "@/components/blog/PostAccessBadge.astro";
9
+ import { listBuildPosts } from "@/lib/the-build/provider.js";
10
+
11
+ const { tenant, basePath, canonicalBase } = Astro.locals;
12
+ const posts = await listBuildPosts(tenant.tenant_id);
13
+ if (posts.length === 0) return new Response(null, { status: 404 });
14
+
15
+ const href = (path: string) => `${basePath}${path}` || "/";
16
+ const itemList = {
17
+ "@context": "https://schema.org",
18
+ "@type": "ItemList",
19
+ numberOfItems: posts.length,
20
+ itemListElement: posts.map((post, index) => ({
21
+ "@type": "ListItem",
22
+ position: index + 1,
23
+ name: post.title,
24
+ url: new URL(`/the-build/${post.slug}/`, canonicalBase + "/").toString(),
25
+ })),
26
+ };
27
+ ---
28
+
29
+ <Layout
30
+ title="The Build"
31
+ description="Short articles on building a compliant, resilient storefront — from Token of Trust."
32
+ path="/the-build/"
33
+ fullBleed
34
+ jsonLd={itemList}
35
+ >
36
+ <header class="blog-hero">
37
+ <div class="blog-shell">
38
+ <p class="eyebrow eyebrow--tick">The Build</p>
39
+ <h1>The Build</h1>
40
+ <p>Notes from building a compliant, resilient storefront.</p>
41
+ </div>
42
+ </header>
43
+
44
+ <div class="blog-shell">
45
+ <section class="post-grid" aria-label="The Build posts" id="the-build-posts">
46
+ {posts.map((post, index) => (
47
+ <article class="post-card">
48
+ <a href={href(`/the-build/${post.slug}/`)} aria-label={`Read ${post.title}`}>
49
+ {post.featuredImage ? (
50
+ <img
51
+ src={post.featuredImage.src}
52
+ alt={post.featuredImage.alt}
53
+ width={post.featuredImage.width}
54
+ height={post.featuredImage.height}
55
+ loading={index < 3 ? "eager" : "lazy"}
56
+ decoding="async"
57
+ />
58
+ ) : <div class="image-placeholder" aria-hidden="true"></div>}
59
+ <div class="post-card-copy">
60
+ <p class="post-meta"><time datetime={post.publishedAt}>{new Date(post.publishedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })}</time>{post.tags[0] && <> · {post.tags[0]}</>}</p>
61
+ <PostAccessBadge access={post.access} />
62
+ <h2>{post.title}</h2>
63
+ <p>{post.excerpt}</p>
64
+ <span>Read article <span aria-hidden="true">→</span></span>
65
+ </div>
66
+ </a>
67
+ </article>
68
+ ))}
69
+ </section>
70
+
71
+ <section class="news-signup" aria-labelledby="news-signup-title">
72
+ <h2 id="news-signup-title">Get The Build in your inbox</h2>
73
+ <p>A short note when something worth reading lands. Double opt-in — we'll email a link to confirm.</p>
74
+ <form class="news-form" method="post" action={href("/api/newsletter/the-build/subscribe")}>
75
+ <label class="sr-only" for="news-email">Email address</label>
76
+ <input id="news-email" name="email" type="email" inputmode="email" autocomplete="email"
77
+ required placeholder="you@example.com" />
78
+ <button type="submit">Subscribe</button>
79
+ </form>
80
+ <p class="news-status" role="status" aria-live="polite" hidden></p>
81
+ </section>
82
+ </div>
83
+
84
+ <script>
85
+ // Progressive enhancement: the form POSTs natively without JS (the endpoint
86
+ // redirects back with ?subscribed=1). With JS we intercept for inline feedback.
87
+ const form = document.querySelector<HTMLFormElement>(".news-form");
88
+ const status = document.querySelector<HTMLParagraphElement>(".news-status");
89
+ function show(msg: string) {
90
+ if (!status) return;
91
+ status.textContent = msg;
92
+ status.hidden = false;
93
+ }
94
+ if (new URLSearchParams(location.search).get("subscribed") === "1") {
95
+ show("Almost there — check your inbox to confirm.");
96
+ }
97
+ form?.addEventListener("submit", async (e) => {
98
+ e.preventDefault();
99
+ const email = new FormData(form).get("email");
100
+ try {
101
+ const res = await fetch(form.action, {
102
+ method: "POST",
103
+ headers: { "content-type": "application/json", accept: "application/json" },
104
+ body: JSON.stringify({ email }),
105
+ });
106
+ if (res.status === 429) show("You've tried a few times — give it a minute and retry.");
107
+ else if (res.ok) {
108
+ form.reset();
109
+ show("Almost there — check your inbox to confirm.");
110
+ } else show("That address didn't look right — try again.");
111
+ } catch {
112
+ show("Something went wrong — try again in a moment.");
113
+ }
114
+ });
115
+ </script>
116
+ </Layout>
117
+
118
+ <style>
119
+ .blog-shell { width: min(1180px, calc(100% - 2rem)); margin-inline: auto; }
120
+ .blog-hero { padding: clamp(4.5rem, 9vw, 8rem) 0 clamp(3rem, 6vw, 5rem); background: linear-gradient(145deg, var(--color-primary), color-mix(in srgb, var(--color-primary) 82%, var(--color-accent))); color: var(--color-primary-contrast); }
121
+ .blog-hero h1 { max-width: 12ch; margin-top: .75rem; color: inherit; font-size: clamp(2.9rem, 7vw, 5.6rem); }
122
+ .blog-hero p:last-child { max-width: 58ch; margin-top: 1.25rem; color: color-mix(in srgb, var(--color-primary-contrast) 82%, transparent); font-size: 1.12rem; }
123
+ .blog-hero .eyebrow { color: color-mix(in srgb, var(--color-primary-contrast) 75%, transparent); }
124
+ .post-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.35rem; align-items: start; padding-block: clamp(3rem, 7vw, 6rem); }
125
+ .post-card { height: 100%; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: var(--shadow-sm); transition: transform .2s ease, box-shadow .2s ease; }
126
+ .post-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-md); }
127
+ .post-card a { display: flex; height: 100%; flex-direction: column; }
128
+ .post-card img, .image-placeholder { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 12%, white), color-mix(in srgb, var(--color-accent) 24%, white)); }
129
+ .post-card-copy { display: flex; flex: 1; flex-direction: column; padding: 1.25rem; }
130
+ .post-meta { margin: 0 0 .7rem; color: var(--color-muted); font: 600 .72rem/1.45 var(--font-mono); text-transform: uppercase; letter-spacing: .05em; }
131
+ .post-card h2 { font-size: 1.18rem; line-height: 1.25; }
132
+ .post-card-copy > p:not(.post-meta) { display: -webkit-box; margin: .75rem 0 1rem; overflow: hidden; color: var(--color-muted); font-size: .92rem; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
133
+ .post-card-copy > span { margin-top: auto; color: var(--color-accent-ink); font-weight: 700; font-size: .88rem; }
134
+ @media (max-width: 980px) { .post-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
135
+ @media (max-width: 520px) { .post-grid { grid-template-columns: 1fr; } }
136
+
137
+ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
138
+ .news-signup { margin: 0 auto clamp(3rem, 7vw, 6rem); max-width: 40rem; padding: clamp(1.75rem, 4vw, 2.5rem); text-align: center; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface); }
139
+ .news-signup h2 { font-size: clamp(1.4rem, 3vw, 1.8rem); }
140
+ .news-signup p { margin: .6rem 0 0; color: var(--color-muted); line-height: 1.6; }
141
+ .news-form { display: flex; flex-wrap: wrap; gap: .6rem; justify-content: center; margin-top: 1.35rem; }
142
+ .news-form input { flex: 1 1 16rem; min-width: 0; padding: .7rem .9rem; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-bg); color: inherit; font-size: 1rem; }
143
+ .news-form button { padding: .7rem 1.4rem; border: 0; border-radius: var(--radius-md); background: var(--color-primary); color: var(--color-primary-contrast); font-weight: 700; cursor: pointer; }
144
+ .news-form button:hover { background: color-mix(in srgb, var(--color-primary) 88%, black); }
145
+ .news-status { margin-top: 1rem; font-weight: 600; color: var(--color-accent-ink); }
146
+ </style>
@@ -0,0 +1,33 @@
1
+ /**
2
+ * RSS 2.0 feed for the resolved tenant's `the-build` collection — the
3
+ * syndication mirror of `sitemap.xml.ts` and the newsletter on-ramp (B1.1).
4
+ * Reads the engine's live-visible list (`listBuildPosts` default), so drafts and
5
+ * unlisted posts never syndicate — the same visibility chokepoint the sitemap
6
+ * rides. The XML itself is hand-rolled in `lib/blog/rss.ts` (no `@astrojs/rss`),
7
+ * which is tenant/collection-generic so `/blog/rss.xml` can reuse it later.
8
+ */
9
+ import type { APIContext } from "astro";
10
+ import { listBuildPosts, THE_BUILD } from "@/lib/the-build/provider.js";
11
+ import { renderRssFeed } from "@/lib/blog/rss.js";
12
+
13
+ export async function GET(context: APIContext): Promise<Response> {
14
+ const { tenant, canonicalBase } = context.locals;
15
+ const posts = await listBuildPosts(tenant.tenant_id);
16
+ if (posts.length === 0) return new Response(null, { status: 404 });
17
+
18
+ const body = renderRssFeed({
19
+ channelTitle: `The Build — ${tenant.displayName}`,
20
+ channelDescription:
21
+ "Short articles on building a compliant, resilient storefront — from Token of Trust.",
22
+ canonicalBase,
23
+ collectionBase: THE_BUILD.base,
24
+ posts,
25
+ });
26
+
27
+ return new Response(body, {
28
+ headers: {
29
+ "content-type": "application/rss+xml; charset=utf-8",
30
+ "cache-control": "public, max-age=300, s-maxage=3600",
31
+ },
32
+ });
33
+ }