@tokenoftrust/storefront-runner 1.3.2

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 (310) hide show
  1. package/LICENSE +58 -0
  2. package/README.md +42 -0
  3. package/apps/storefront/astro.config.mjs +66 -0
  4. package/apps/storefront/dev-plugins/dev-loop-state.mjs +147 -0
  5. package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +238 -0
  6. package/apps/storefront/env.d.ts +162 -0
  7. package/apps/storefront/migrations/0000_baseline.sql +54 -0
  8. package/apps/storefront/migrations/0001_products_fts.sql +9 -0
  9. package/apps/storefront/migrations/README.md +54 -0
  10. package/apps/storefront/migrations/meta/0000_snapshot.json +347 -0
  11. package/apps/storefront/migrations/meta/0001_snapshot.json +347 -0
  12. package/apps/storefront/migrations/meta/_journal.json +20 -0
  13. package/apps/storefront/package.json +42 -0
  14. package/apps/storefront/public/brand/northwind-logo-dark.svg +4 -0
  15. package/apps/storefront/public/brand/northwind-logo-light.svg +4 -0
  16. package/apps/storefront/public/brand/northwind-og.svg +13 -0
  17. package/apps/storefront/public/favicon.svg +5 -0
  18. package/apps/storefront/public/js/dashboard-team.js +147 -0
  19. package/apps/storefront/public/js/dashboard-vendor-search.js +115 -0
  20. package/apps/storefront/public/shared/commerce-chrome.css +369 -0
  21. package/apps/storefront/src/components/Badge.astro +34 -0
  22. package/apps/storefront/src/components/Breadcrumbs.astro +45 -0
  23. package/apps/storefront/src/components/CollectionCard.astro +56 -0
  24. package/apps/storefront/src/components/EmptyState.astro +55 -0
  25. package/apps/storefront/src/components/Img.astro +68 -0
  26. package/apps/storefront/src/components/ProductCard.astro +228 -0
  27. package/apps/storefront/src/components/Section.astro +39 -0
  28. package/apps/storefront/src/components/Seo.astro +70 -0
  29. package/apps/storefront/src/components/WidgetFrame.astro +34 -0
  30. package/apps/storefront/src/components/admin/AdminAIWorkflowsTab.astro +531 -0
  31. package/apps/storefront/src/components/admin/AdminCustomersTab.astro +597 -0
  32. package/apps/storefront/src/components/admin/AdminFoxyCartSettingsPanel.astro +227 -0
  33. package/apps/storefront/src/components/admin/AdminFulfillmentTab.astro +465 -0
  34. package/apps/storefront/src/components/admin/AdminProductsTab.astro +901 -0
  35. package/apps/storefront/src/components/admin/AdminReportingTab.astro +435 -0
  36. package/apps/storefront/src/components/admin/AdminSettingsTab.astro +782 -0
  37. package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +1223 -0
  38. package/apps/storefront/src/components/auth/AuthBadge.astro +44 -0
  39. package/apps/storefront/src/components/chrome/AnnouncementBar.astro +108 -0
  40. package/apps/storefront/src/components/chrome/MegaMenu.astro +26 -0
  41. package/apps/storefront/src/components/chrome/NavDropdown.astro +201 -0
  42. package/apps/storefront/src/components/chrome/SiteFooter.astro +201 -0
  43. package/apps/storefront/src/components/chrome/SiteHeader.astro +234 -0
  44. package/apps/storefront/src/components/chrome/UtilityNav.astro +79 -0
  45. package/apps/storefront/src/components/commerce/ArticleCard.astro +242 -0
  46. package/apps/storefront/src/components/commerce/BrandGrid.astro +175 -0
  47. package/apps/storefront/src/components/commerce/FoxyLoader.astro +23 -0
  48. package/apps/storefront/src/components/commerce/FoxySidecartStyles.astro +61 -0
  49. package/apps/storefront/src/components/commerce/PriceDisplay.astro +91 -0
  50. package/apps/storefront/src/components/commerce/ProductCarousel.astro +197 -0
  51. package/apps/storefront/src/components/commerce/ProductGrid.astro +75 -0
  52. package/apps/storefront/src/components/commerce/RatingStars.astro +58 -0
  53. package/apps/storefront/src/components/commerce/ReviewList.astro +54 -0
  54. package/apps/storefront/src/components/commerce/ReviewSummary.astro +44 -0
  55. package/apps/storefront/src/components/commerce/TagFilterCloud.astro +149 -0
  56. package/apps/storefront/src/components/compliance/AdultSignatureNotice.astro +37 -0
  57. package/apps/storefront/src/components/compliance/ComplianceNotice.astro +99 -0
  58. package/apps/storefront/src/components/compliance/ExciseTaxNotice.astro +45 -0
  59. package/apps/storefront/src/components/compliance/NicotineWarning.astro +33 -0
  60. package/apps/storefront/src/components/compliance/PactActNotice.astro +35 -0
  61. package/apps/storefront/src/components/compliance/Prop65Warning.astro +80 -0
  62. package/apps/storefront/src/components/compliance/PurchaseLimitNotice.astro +38 -0
  63. package/apps/storefront/src/components/compliance/ShippingRestrictionNotice.astro +35 -0
  64. package/apps/storefront/src/components/compliance/StateEligibilityNotice.astro +53 -0
  65. package/apps/storefront/src/components/home/Hero.astro +164 -0
  66. package/apps/storefront/src/components/islands/AgeGate.tsx +109 -0
  67. package/apps/storefront/src/components/islands/ImageGallery.tsx +237 -0
  68. package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +73 -0
  69. package/apps/storefront/src/components/islands/MobileNav.tsx +239 -0
  70. package/apps/storefront/src/components/islands/QuickView.tsx +138 -0
  71. package/apps/storefront/src/components/islands/RecentlyViewed.tsx +122 -0
  72. package/apps/storefront/src/components/islands/SavedList.tsx +99 -0
  73. package/apps/storefront/src/components/islands/SearchTypeahead.tsx +248 -0
  74. package/apps/storefront/src/components/islands/VariantSelector.tsx +375 -0
  75. package/apps/storefront/src/components/marketing/CardGrid.astro +72 -0
  76. package/apps/storefront/src/components/marketing/CodeSample.astro +20 -0
  77. package/apps/storefront/src/components/marketing/CtaBand.astro +24 -0
  78. package/apps/storefront/src/components/marketing/Faq.astro +37 -0
  79. package/apps/storefront/src/components/marketing/Integrations.astro +63 -0
  80. package/apps/storefront/src/components/marketing/MarketingBlocks.astro +53 -0
  81. package/apps/storefront/src/components/marketing/MarketingCtas.astro +48 -0
  82. package/apps/storefront/src/components/marketing/MarketingHero.astro +72 -0
  83. package/apps/storefront/src/components/marketing/ProofStrip.astro +37 -0
  84. package/apps/storefront/src/components/marketing/SplitCompare.astro +53 -0
  85. package/apps/storefront/src/components/marketing/Steps.astro +36 -0
  86. package/apps/storefront/src/components/marketing/Testimonials.astro +38 -0
  87. package/apps/storefront/src/components/marketing/TrustBar.astro +22 -0
  88. package/apps/storefront/src/components/membership/IllustrativeLabel.astro +56 -0
  89. package/apps/storefront/src/components/membership/TierCards.astro +164 -0
  90. package/apps/storefront/src/components/membership/TierComparisonTable.astro +148 -0
  91. package/apps/storefront/src/components/plp/ActiveFilters.astro +79 -0
  92. package/apps/storefront/src/components/plp/FacetSidebar.astro +199 -0
  93. package/apps/storefront/src/components/plp/Pagination.astro +99 -0
  94. package/apps/storefront/src/components/plp/SortSelect.astro +64 -0
  95. package/apps/storefront/src/components/style-guide/StyleGuideNav.astro +95 -0
  96. package/apps/storefront/src/components/subscription/HowItWorksSteps.astro +67 -0
  97. package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +67 -0
  98. package/apps/storefront/src/components/subscription/SavingsExplainer.astro +101 -0
  99. package/apps/storefront/src/config/adminTargetTenant.ts +28 -0
  100. package/apps/storefront/src/config/devTenantSeed.ts +78 -0
  101. package/apps/storefront/src/config/devTenants.ts +16 -0
  102. package/apps/storefront/src/config/kvTenantResolver.ts +87 -0
  103. package/apps/storefront/src/config/resolver.ts +102 -0
  104. package/apps/storefront/src/config/tenants.ts +87 -0
  105. package/apps/storefront/src/layouts/Layout.astro +368 -0
  106. package/apps/storefront/src/lib/admin/README.md +122 -0
  107. package/apps/storefront/src/lib/admin/ai/approvalMode.ts +21 -0
  108. package/apps/storefront/src/lib/admin/ai/askEnvelope.ts +48 -0
  109. package/apps/storefront/src/lib/admin/ai/audit.ts +14 -0
  110. package/apps/storefront/src/lib/admin/ai/index.ts +6 -0
  111. package/apps/storefront/src/lib/admin/ai/operationalRuns.ts +155 -0
  112. package/apps/storefront/src/lib/admin/ai/recommendation.ts +20 -0
  113. package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +172 -0
  114. package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +65 -0
  115. package/apps/storefront/src/lib/admin/envelope.ts +10 -0
  116. package/apps/storefront/src/lib/admin/evidence/evidenceReference.ts +32 -0
  117. package/apps/storefront/src/lib/admin/memoizeByEnvelope.ts +27 -0
  118. package/apps/storefront/src/lib/admin/orderClient.ts +146 -0
  119. package/apps/storefront/src/lib/admin/seed.ts +54 -0
  120. package/apps/storefront/src/lib/admin/services/mock/aiWorkflows.ts +59 -0
  121. package/apps/storefront/src/lib/admin/services/mock/customers.ts +86 -0
  122. package/apps/storefront/src/lib/admin/services/mock/evidence.ts +99 -0
  123. package/apps/storefront/src/lib/admin/services/mock/foxyCartSettings.ts +78 -0
  124. package/apps/storefront/src/lib/admin/services/mock/fulfillment.ts +62 -0
  125. package/apps/storefront/src/lib/admin/services/mock/index.ts +12 -0
  126. package/apps/storefront/src/lib/admin/services/mock/orders.ts +184 -0
  127. package/apps/storefront/src/lib/admin/services/mock/products.ts +105 -0
  128. package/apps/storefront/src/lib/admin/services/mock/reporting.ts +51 -0
  129. package/apps/storefront/src/lib/admin/services/mock/settings.ts +70 -0
  130. package/apps/storefront/src/lib/admin/services/mock/tenants.ts +29 -0
  131. package/apps/storefront/src/lib/admin/services/orders.ts +300 -0
  132. package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +67 -0
  133. package/apps/storefront/src/lib/admin/viewmodels/customers.ts +56 -0
  134. package/apps/storefront/src/lib/admin/viewmodels/fulfillment.ts +49 -0
  135. package/apps/storefront/src/lib/admin/viewmodels/orders.ts +92 -0
  136. package/apps/storefront/src/lib/admin/viewmodels/products.ts +74 -0
  137. package/apps/storefront/src/lib/admin/viewmodels/reporting.ts +34 -0
  138. package/apps/storefront/src/lib/admin/viewmodels/settings.ts +64 -0
  139. package/apps/storefront/src/lib/adminTenantEnvelope.ts +90 -0
  140. package/apps/storefront/src/lib/analytics/index.ts +47 -0
  141. package/apps/storefront/src/lib/auth/brokerAssertion.ts +136 -0
  142. package/apps/storefront/src/lib/auth/devAuth.ts +90 -0
  143. package/apps/storefront/src/lib/auth/gatePage.ts +81 -0
  144. package/apps/storefront/src/lib/auth/identityToken.ts +341 -0
  145. package/apps/storefront/src/lib/auth/loginGate.ts +119 -0
  146. package/apps/storefront/src/lib/auth/route.ts +146 -0
  147. package/apps/storefront/src/lib/auth/session.ts +203 -0
  148. package/apps/storefront/src/lib/auth/totAccessClient.ts +503 -0
  149. package/apps/storefront/src/lib/basePath.ts +41 -0
  150. package/apps/storefront/src/lib/cfImage.ts +55 -0
  151. package/apps/storefront/src/lib/chrome/model.ts +244 -0
  152. package/apps/storefront/src/lib/chrome/parseConfig.ts +34 -0
  153. package/apps/storefront/src/lib/colors.ts +97 -0
  154. package/apps/storefront/src/lib/commerce/foxyCartSettings.ts +253 -0
  155. package/apps/storefront/src/lib/content-edit/applyDiff.ts +91 -0
  156. package/apps/storefront/src/lib/content-edit/client.ts +230 -0
  157. package/apps/storefront/src/lib/content-edit/index.ts +14 -0
  158. package/apps/storefront/src/lib/content-edit/route.ts +25 -0
  159. package/apps/storefront/src/lib/content-edit/types.ts +19 -0
  160. package/apps/storefront/src/lib/cspStaticHashes.ts +24 -0
  161. package/apps/storefront/src/lib/d1/catalog.ts +366 -0
  162. package/apps/storefront/src/lib/dashboard/staffAdmission.ts +43 -0
  163. package/apps/storefront/src/lib/dashboard/staffVendorAccess.ts +449 -0
  164. package/apps/storefront/src/lib/dashboard/tenantSelection.ts +136 -0
  165. package/apps/storefront/src/lib/dashboard/vendorQuery.ts +61 -0
  166. package/apps/storefront/src/lib/demoGate.ts +113 -0
  167. package/apps/storefront/src/lib/dev/activityIngestToken.ts +78 -0
  168. package/apps/storefront/src/lib/dev/activityStore.ts +72 -0
  169. package/apps/storefront/src/lib/dev/ai-widget-client.js +308 -0
  170. package/apps/storefront/src/lib/dev/apiBase.ts +14 -0
  171. package/apps/storefront/src/lib/dev/cliSignInCode.ts +155 -0
  172. package/apps/storefront/src/lib/dev/devDraft.ts +133 -0
  173. package/apps/storefront/src/lib/dev/hostedCockpit.ts +169 -0
  174. package/apps/storefront/src/lib/dev/livePreview.ts +77 -0
  175. package/apps/storefront/src/lib/dev/runtimeStore.ts +120 -0
  176. package/apps/storefront/src/lib/dev/storeStatus.ts +142 -0
  177. package/apps/storefront/src/lib/diag/redact.ts +20 -0
  178. package/apps/storefront/src/lib/edgeCache.ts +128 -0
  179. package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +62 -0
  180. package/apps/storefront/src/lib/env.ts +60 -0
  181. package/apps/storefront/src/lib/fixtures/sampleProducts.ts +130 -0
  182. package/apps/storefront/src/lib/format.ts +38 -0
  183. package/apps/storefront/src/lib/foxyCommerce.ts +49 -0
  184. package/apps/storefront/src/lib/imageAttrs.ts +55 -0
  185. package/apps/storefront/src/lib/jsonld.ts +166 -0
  186. package/apps/storefront/src/lib/maintenance.ts +71 -0
  187. package/apps/storefront/src/lib/membership/model.ts +97 -0
  188. package/apps/storefront/src/lib/memoryKv.ts +32 -0
  189. package/apps/storefront/src/lib/messaging/messagesClient.ts +143 -0
  190. package/apps/storefront/src/lib/placeholder.ts +99 -0
  191. package/apps/storefront/src/lib/previewReload.ts +125 -0
  192. package/apps/storefront/src/lib/priceBuckets.ts +33 -0
  193. package/apps/storefront/src/lib/rawChrome.ts +505 -0
  194. package/apps/storefront/src/lib/rawMarketingHtml.ts +136 -0
  195. package/apps/storefront/src/lib/search/index.ts +74 -0
  196. package/apps/storefront/src/lib/sections.ts +129 -0
  197. package/apps/storefront/src/lib/securityHeaders.ts +31 -0
  198. package/apps/storefront/src/lib/storyblok/content-model.ts +289 -0
  199. package/apps/storefront/src/lib/storyblok/provider.ts +607 -0
  200. package/apps/storefront/src/lib/subscription/model.ts +68 -0
  201. package/apps/storefront/src/lib/tenantRouting.ts +91 -0
  202. package/apps/storefront/src/lib/tot/ToTClient.ts +177 -0
  203. package/apps/storefront/src/lib/tot/d1Client.ts +62 -0
  204. package/apps/storefront/src/lib/tot/query.ts +249 -0
  205. package/apps/storefront/src/lib/tot/totClientInterface.ts +30 -0
  206. package/apps/storefront/src/lib/url.ts +142 -0
  207. package/apps/storefront/src/lib/wishlist.ts +63 -0
  208. package/apps/storefront/src/middleware/index.ts +455 -0
  209. package/apps/storefront/src/pages/404.astro +61 -0
  210. package/apps/storefront/src/pages/[...slug].astro +90 -0
  211. package/apps/storefront/src/pages/admin.astro +1675 -0
  212. package/apps/storefront/src/pages/api/admin/orders/[id].ts +53 -0
  213. package/apps/storefront/src/pages/api/admin/orders.ts +71 -0
  214. package/apps/storefront/src/pages/api/auth/logout.ts +21 -0
  215. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +107 -0
  216. package/apps/storefront/src/pages/api/auth/send.ts +85 -0
  217. package/apps/storefront/src/pages/api/auth/verify.ts +135 -0
  218. package/apps/storefront/src/pages/api/cache-purge.ts +44 -0
  219. package/apps/storefront/src/pages/api/checkout-style.css.ts +43 -0
  220. package/apps/storefront/src/pages/api/cli/policy.ts +70 -0
  221. package/apps/storefront/src/pages/api/content-edit/ai-edit.ts +164 -0
  222. package/apps/storefront/src/pages/api/csp-report.ts +25 -0
  223. package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +124 -0
  224. package/apps/storefront/src/pages/api/dashboard/vendor-search.ts +120 -0
  225. package/apps/storefront/src/pages/api/dev/activity.ts +124 -0
  226. package/apps/storefront/src/pages/api/dev/cli-signin-code.ts +73 -0
  227. package/apps/storefront/src/pages/api/dev/diff.ts +68 -0
  228. package/apps/storefront/src/pages/api/dev/file.ts +57 -0
  229. package/apps/storefront/src/pages/api/dev/locate.ts +125 -0
  230. package/apps/storefront/src/pages/api/dev/publish.ts +128 -0
  231. package/apps/storefront/src/pages/api/dev/tree.ts +70 -0
  232. package/apps/storefront/src/pages/api/dev/write.ts +116 -0
  233. package/apps/storefront/src/pages/api/newsletter.ts +104 -0
  234. package/apps/storefront/src/pages/api/request-access.ts +172 -0
  235. package/apps/storefront/src/pages/auth/login.astro +149 -0
  236. package/apps/storefront/src/pages/auth/magic.astro +105 -0
  237. package/apps/storefront/src/pages/capabilities.astro +292 -0
  238. package/apps/storefront/src/pages/collections/[handle].astro +177 -0
  239. package/apps/storefront/src/pages/collections/index.astro +59 -0
  240. package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +100 -0
  241. package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +154 -0
  242. package/apps/storefront/src/pages/dashboard/index.astro +160 -0
  243. package/apps/storefront/src/pages/index.astro +290 -0
  244. package/apps/storefront/src/pages/llms.txt.ts +92 -0
  245. package/apps/storefront/src/pages/products/[handle].astro +402 -0
  246. package/apps/storefront/src/pages/robots.txt.ts +24 -0
  247. package/apps/storefront/src/pages/saved.astro +35 -0
  248. package/apps/storefront/src/pages/search.astro +150 -0
  249. package/apps/storefront/src/pages/sitemap.xml.ts +78 -0
  250. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +1280 -0
  251. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +229 -0
  252. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +144 -0
  253. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +116 -0
  254. package/apps/storefront/src/pages/style-guide/index.astro +98 -0
  255. package/apps/storefront/src/pages/tenants/[id]/[...path].ts +57 -0
  256. package/apps/storefront/src/styles/global.css +292 -0
  257. package/apps/storefront/src/themes/reference.ts +68 -0
  258. package/apps/storefront/src/themes/schema.ts +111 -0
  259. package/apps/storefront/src/themes/tenants/index.ts +51 -0
  260. package/apps/storefront/tsconfig.json +16 -0
  261. package/apps/storefront/vitest.config.ts +22 -0
  262. package/package.json +26 -0
  263. package/packages/public-runtime/LICENSE +58 -0
  264. package/packages/public-runtime/package.json +22 -0
  265. package/packages/public-runtime/src/binary-path.ts +68 -0
  266. package/packages/public-runtime/src/capabilities.ts +89 -0
  267. package/packages/public-runtime/src/catalog-api.ts +81 -0
  268. package/packages/public-runtime/src/catalog-d1.ts +192 -0
  269. package/packages/public-runtime/src/checkout-style-publish.ts +200 -0
  270. package/packages/public-runtime/src/checkout-style.ts +375 -0
  271. package/packages/public-runtime/src/csp.ts +109 -0
  272. package/packages/public-runtime/src/customization-preview.ts +197 -0
  273. package/packages/public-runtime/src/customization-reconcile.ts +63 -0
  274. package/packages/public-runtime/src/customization-runtime.ts +157 -0
  275. package/packages/public-runtime/src/customization-scripts.ts +64 -0
  276. package/packages/public-runtime/src/customization-versioning.ts +95 -0
  277. package/packages/public-runtime/src/foxy-orders.ts +136 -0
  278. package/packages/public-runtime/src/foxy.ts +196 -0
  279. package/packages/public-runtime/src/hot-standby-readiness.ts +140 -0
  280. package/packages/public-runtime/src/index.ts +34 -0
  281. package/packages/public-runtime/src/product.ts +159 -0
  282. package/packages/public-runtime/src/search.ts +45 -0
  283. package/packages/public-runtime/src/tenant-assets.ts +444 -0
  284. package/packages/public-runtime/src/tenant.ts +384 -0
  285. package/packages/public-runtime/tsconfig.json +8 -0
  286. package/packages/public-runtime/vitest.config.ts +8 -0
  287. package/pnpm-workspace.yaml +7 -0
  288. package/scripts/build/copy-tenant-assets.mjs +69 -0
  289. package/scripts/dev/ai-edit.mjs +183 -0
  290. package/scripts/dev/checkout-watch.mjs +116 -0
  291. package/scripts/dev/file-browser.mjs +159 -0
  292. package/scripts/dev/file-writer.mjs +141 -0
  293. package/scripts/dev/git-status.mjs +171 -0
  294. package/scripts/dev/locate-handler.mjs +163 -0
  295. package/scripts/dev/locate.mjs +112 -0
  296. package/scripts/dev/port-check.mjs +48 -0
  297. package/scripts/dev/safe-path.mjs +109 -0
  298. package/scripts/dev/shot.mjs +46 -0
  299. package/scripts/dev/tenant-source-root.mjs +76 -0
  300. package/scripts/dev/unified-diff.mjs +81 -0
  301. package/scripts/tot-dev.mjs +376 -0
  302. package/tenants/README.md +14 -0
  303. package/tenants/home/content/chrome.json +7 -0
  304. package/tenants/home/content/home.html +58 -0
  305. package/tenants/home/content/home.json +11 -0
  306. package/tenants/home/public/js/storefront.js +210 -0
  307. package/tenants/home/public/logo-wordmark.png +0 -0
  308. package/tenants/home/public/pages/storefront.css +212 -0
  309. package/tenants/home/theme.json +6 -0
  310. package/tsconfig.base.json +20 -0
@@ -0,0 +1,607 @@
1
+ /**
2
+ * ContentProvider — the Storyblok seam (brief §7 CMS).
3
+ *
4
+ * Two implementations behind one interface:
5
+ * - StoryblokContentProvider — calls the Storyblok delivery API for a tenant's
6
+ * child space, maps stories -> our content model.
7
+ * - LocalContentProvider — reads src/content/pilot/*.json, so the site
8
+ * renders fully OFFLINE with no Storyblok token.
9
+ *
10
+ * Selection: if the tenant has a usable delivery token AND the Storyblok client
11
+ * is configured, use Storyblok; otherwise fall back to local. Phase 1 ships with
12
+ * a placeholder token, so the LOCAL provider is what actually renders. The
13
+ * mapping layer is the documented swap point — Storyblok story JSON shares the
14
+ * same `component` discriminators as our blocks, so the parent-space schemas map
15
+ * 1:1. See DECISIONS.md.
16
+ */
17
+ import type {
18
+ EditorialPage,
19
+ HomeContent,
20
+ SiteChrome,
21
+ } from "./content-model.js";
22
+ import type { CustomizationEnv, KvGet, TenantConfig } from "@tot/public-runtime";
23
+ import { contentArtifactPath, readPublishedArtifact } from "@tot/public-runtime";
24
+ import { renderRawChrome } from "../rawChrome.js";
25
+ import { parseChromeConfig } from "../chrome/parseConfig.js";
26
+
27
+ export interface ContentProvider {
28
+ getHome(): Promise<HomeContent>;
29
+ /**
30
+ * A marketing tenant's self-authored home document (raw HTML), or null. When
31
+ * present the homepage route serves it VERBATIM (bypassing the block Layout) —
32
+ * the "vendor injects their own HTML" path. Full `<!doctype html>` document;
33
+ * the route nonces its inline <style>/<script> so it still satisfies CSP.
34
+ */
35
+ getHomeHtml(): Promise<string | null>;
36
+ /**
37
+ * A marketing tenant's self-authored SUBPAGE document (raw HTML) for `slug`,
38
+ * or null. Same verbatim-serve path as getHomeHtml — the catch-all route
39
+ * (`[...slug].astro`) serves it bypassing the block Layout and nonces its
40
+ * inline tags. Files live at `content/<tenant>/pages-html/<slug>.html`.
41
+ */
42
+ getPageHtml(slug: string): Promise<string | null>;
43
+ /** Slugs of this tenant's raw subpages (for the sitemap). */
44
+ listPageHtmlSlugs(): Promise<string[]>;
45
+ /**
46
+ * The tenant's shared-chrome WRAPPER document (raw HTML), or null. A full
47
+ * document containing the CHROME_BODY_MARKER where a body-only page fragment is
48
+ * spliced in (see lib/rawMarketingHtml.ts). Lets raw pages stop duplicating
49
+ * chrome: chrome lives once here, pages become fragments. File:
50
+ * `content/<tenant>/chrome.html`. Tenant-owned (no cross-tenant fallback).
51
+ */
52
+ getChromeHtml(): Promise<string | null>;
53
+ getChrome(): Promise<SiteChrome>;
54
+ getPage(slug: string): Promise<EditorialPage | null>;
55
+ listPages(): Promise<EditorialPage[]>;
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // LocalContentProvider — static JSON bundled at build time.
60
+ // ---------------------------------------------------------------------------
61
+
62
+ // Per-tenant local content lives under the colocated repo-root layout
63
+ // tenants/<tenant_id>/content/. Vite globs bundle every tenant's JSON at build
64
+ // time; a file missing for a tenant falls back to the default scope, so a
65
+ // partially-adopted tenant still renders. Depth: from src/lib/storyblok/ the
66
+ // repo root is five levels up (storyblok→lib→src→storefront→apps→root).
67
+ const homeGlobs = import.meta.glob<HomeContent>("../../../../../tenants/*/content/home.json", {
68
+ import: "default",
69
+ });
70
+ const chromeGlobs = import.meta.glob<SiteChrome>("../../../../../tenants/*/content/chrome.json", {
71
+ import: "default",
72
+ });
73
+ const pageGlobs = import.meta.glob<EditorialPage>("../../../../../tenants/*/content/pages/*.json", {
74
+ import: "default",
75
+ });
76
+ // Raw self-authored home documents (marketing tenants). `?raw` bundles the file
77
+ // as a string; a tenant with no home.html simply has no entry here.
78
+ const homeHtmlGlobs = import.meta.glob<string>("../../../../../tenants/*/content/home.html", {
79
+ query: "?raw",
80
+ import: "default",
81
+ });
82
+ // Raw self-authored SUBPAGE documents: tenants/<tenant>/content/pages-html/<slug>.html
83
+ // (e.g. pages-html/pricing.html -> /pricing/). `**` supports nested slugs.
84
+ const pageHtmlGlobs = import.meta.glob<string>("../../../../../tenants/*/content/pages-html/**/*.html", {
85
+ query: "?raw",
86
+ import: "default",
87
+ });
88
+ // Shared-chrome wrapper document (marketing tenants that author body-only page
89
+ // fragments). One per tenant; a tenant without it keeps serving full documents.
90
+ const chromeHtmlGlobs = import.meta.glob<string>("../../../../../tenants/*/content/chrome.html", {
91
+ query: "?raw",
92
+ import: "default",
93
+ });
94
+
95
+ const DEFAULT_SCOPE = "home";
96
+
97
+ function pickContent<T>(
98
+ globs: Record<string, () => Promise<T>>,
99
+ scope: string,
100
+ ): (() => Promise<T>) | null {
101
+ const seg = `/tenants/${scope}/content/`;
102
+ const key = Object.keys(globs).find((k) => k.includes(seg));
103
+ return (key ? globs[key] : null) ?? null;
104
+ }
105
+
106
+ function pickPages(scope: string): Array<() => Promise<EditorialPage>> {
107
+ const seg = `/tenants/${scope}/content/pages/`;
108
+ return Object.keys(pageGlobs)
109
+ .filter((k) => k.includes(seg))
110
+ .map((k) => pageGlobs[k]!);
111
+ }
112
+
113
+ /** Normalize a route slug to the file stem: strip surrounding slashes + .html. */
114
+ function normalizeRawPageSlug(slug: string): string {
115
+ return slug.replace(/^\/+|\/+$/g, "").replace(/\.html$/i, "");
116
+ }
117
+
118
+ /** The subpage slug a glob key encodes for `scope`, or null if it's not one. */
119
+ function rawPageSlugForKey(scope: string, key: string): string | null {
120
+ const seg = `/tenants/${scope}/content/pages-html/`;
121
+ const start = key.indexOf(seg);
122
+ if (start < 0 || !key.endsWith(".html")) return null;
123
+ return key.slice(start + seg.length, -".html".length);
124
+ }
125
+
126
+ function pickPageHtml(scope: string, slug: string): (() => Promise<string>) | null {
127
+ const wanted = normalizeRawPageSlug(slug);
128
+ if (!wanted) return null;
129
+ const key = Object.keys(pageHtmlGlobs).find(
130
+ (k) => rawPageSlugForKey(scope, k) === wanted,
131
+ );
132
+ return key ? pageHtmlGlobs[key]! : null;
133
+ }
134
+
135
+ /**
136
+ * DEV hot-reload for raw tenant HTML. The `?raw` import.meta.glob above bundles
137
+ * each file's contents at server boot, so an edit to tenants/<id>/content/*.html
138
+ * would NOT preview until a full rebuild — the "magic on save" gap. In dev we read
139
+ * the file from disk at REQUEST time instead, so an edit shows on the next request.
140
+ *
141
+ * Prod: import.meta.env.DEV is statically false, so this whole branch tree-shakes
142
+ * out and the bundled glob is used — node:fs never enters the Worker. If fs is
143
+ * unavailable (e.g. the workerd dev runtime) or the file is missing, we return null
144
+ * and the caller falls back to the glob, so behavior is never worse than before.
145
+ */
146
+ async function readRawDevFile(scope: string, relative: string): Promise<string | null> {
147
+ if (!import.meta.env.DEV) return null;
148
+ try {
149
+ const [{ readFile }, { fileURLToPath }, { resolve, dirname }] = await Promise.all([
150
+ import("node:fs/promises"),
151
+ import("node:url"),
152
+ import("node:path"),
153
+ ]);
154
+ // provider.ts lives at apps/storefront/src/lib/storyblok/ — the repo root is five
155
+ // levels up, mirroring the import.meta.glob prefix ("../../../../../tenants/...").
156
+ const here = dirname(fileURLToPath(import.meta.url));
157
+ const abs = resolve(here, "../../../../..", "tenants", scope, "content", relative);
158
+ return await readFile(abs, "utf8");
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ export class LocalContentProvider implements ContentProvider {
165
+ constructor(private readonly scope: string = DEFAULT_SCOPE) {}
166
+
167
+ async getHome(): Promise<HomeContent> {
168
+ const loader = pickContent(homeGlobs, this.scope) ?? pickContent(homeGlobs, DEFAULT_SCOPE);
169
+ if (!loader) throw new Error("no home content");
170
+ return loader();
171
+ }
172
+
173
+ async getHomeHtml(): Promise<string | null> {
174
+ // DEV: serve fresh from disk so an edit previews on save (see readRawDevFile).
175
+ const dev = await readRawDevFile(this.scope, "home.html");
176
+ if (dev !== null) return dev;
177
+ // Tenant-owned only (no cross-tenant fallback): a raw home document is a
178
+ // whole-page identity, never inherited from the default scope.
179
+ const loader = pickContent(homeHtmlGlobs, this.scope);
180
+ return loader ? loader() : null;
181
+ }
182
+
183
+ async getPageHtml(slug: string): Promise<string | null> {
184
+ // DEV: serve fresh from disk so a subpage edit previews on save.
185
+ const dev = await readRawDevFile(this.scope, `pages-html/${normalizeRawPageSlug(slug)}.html`);
186
+ if (dev !== null) return dev;
187
+ // Tenant-owned only, like getHomeHtml — raw subpages are whole-page identity.
188
+ const loader = pickPageHtml(this.scope, slug);
189
+ return loader ? loader() : null;
190
+ }
191
+
192
+ async listPageHtmlSlugs(): Promise<string[]> {
193
+ return Object.keys(pageHtmlGlobs)
194
+ .map((k) => rawPageSlugForKey(this.scope, k))
195
+ .filter((slug): slug is string => !!slug)
196
+ .sort((a, b) => a.localeCompare(b));
197
+ }
198
+
199
+ async getChromeHtml(): Promise<string | null> {
200
+ // (1) A hand-authored chrome.html wins — keeps a not-yet-adopted tenant's chrome
201
+ // byte-identical (dev: fresh from disk so an edit previews on save).
202
+ const dev = await readRawDevFile(this.scope, "chrome.html");
203
+ if (dev !== null) return dev;
204
+ const loader = pickContent(chromeHtmlGlobs, this.scope);
205
+ if (loader) return loader();
206
+ // (2) No chrome.html: render the CANONICAL shared chrome (unit F3) from the
207
+ // tenant's chrome.json — but only when it opted in (sharedChrome: true).
208
+ // Tenant-owned only, like the raw HTML above (no cross-tenant fallback).
209
+ const config = await this.loadChromeConfig();
210
+ return config ? renderRawChrome(config) : null;
211
+ }
212
+
213
+ /** The tenant's parsed raw-chrome config (chrome.json), or null if not adopted. */
214
+ private async loadChromeConfig(): Promise<ReturnType<typeof parseChromeConfig>> {
215
+ // DEV: read chrome.json fresh from disk so a config edit previews on save.
216
+ const devRaw = await readRawDevFile(this.scope, "chrome.json");
217
+ if (devRaw !== null) {
218
+ try {
219
+ return parseChromeConfig(JSON.parse(devRaw));
220
+ } catch {
221
+ return null;
222
+ }
223
+ }
224
+ const loader = pickContent(chromeGlobs, this.scope);
225
+ return loader ? parseChromeConfig(await loader()) : null;
226
+ }
227
+
228
+ async getChrome(): Promise<SiteChrome> {
229
+ const defL = pickContent(chromeGlobs, DEFAULT_SCOPE);
230
+ const def: SiteChrome = defL
231
+ ? await defL()
232
+ : { nav: [], footer: { columns: [], social: [] } };
233
+ const ownL = pickContent(chromeGlobs, this.scope);
234
+ if (!ownL) return def;
235
+ const c = await ownL();
236
+ if (this.scope === DEFAULT_SCOPE) return c;
237
+ // Per-field fallback fills only STRUCTURAL scaffolding (nav/footer) so a
238
+ // partially-adopted tenant still renders. Promotional/identity/legal fields
239
+ // (announcement, headerCtas, disclaimers) are tenant-OWNED and never inherit
240
+ // from the default scope — otherwise e.g. a marketing tenant would show the
241
+ // default store's shipping banner or nicotine disclaimers.
242
+ return {
243
+ nav: c.nav?.length ? c.nav : def.nav,
244
+ footer:
245
+ c.footer && (c.footer.columns?.length || c.footer.social?.length)
246
+ ? c.footer
247
+ : def.footer,
248
+ headerCtas: c.headerCtas,
249
+ announcement: c.announcement,
250
+ disclaimer: c.disclaimer,
251
+ disclaimers: c.disclaimers,
252
+ };
253
+ }
254
+
255
+ async getPage(slug: string): Promise<EditorialPage | null> {
256
+ return (await this.listPages()).find((p) => p.slug === slug) ?? null;
257
+ }
258
+
259
+ async listPages(): Promise<EditorialPage[]> {
260
+ // Editorial pages are NOT cross-tenant: a distinct tenant with no pages of
261
+ // its own returns none rather than inheriting the default scope's pages.
262
+ // (Serving e.g. pilot's privacy/legal copy under another brand would be a
263
+ // content/compliance leak; marketing tenants keep legal pages external.)
264
+ const loaders = pickPages(this.scope);
265
+ return Promise.all(loaders.map((l) => l()));
266
+ }
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // StoryblokContentProvider — live delivery API, mapping stories -> our model.
271
+ //
272
+ // Every method tries the Storyblok delivery API and falls back to
273
+ // LocalContentProvider on ANY failure (no token, network error, non-2xx, or a
274
+ // story whose shape doesn't map) — so the site never breaks. The map* fns assume
275
+ // the Storyblok components mirror content-model.ts (same `component`
276
+ // discriminators / shared parent-space schema, per DECISIONS.md). If your space
277
+ // uses different field names, adjust the map* fns — that's the single seam.
278
+ //
279
+ // Config via env: STORYBLOK_REGION (eu|us|ap|ca, default eu),
280
+ // STORYBLOK_VERSION (published|draft, default published).
281
+ // ---------------------------------------------------------------------------
282
+
283
+ const STORYBLOK_REGION_BASES: Record<string, string> = {
284
+ eu: "https://api.storyblok.com",
285
+ us: "https://api-us.storyblok.com",
286
+ ap: "https://api-ap.storyblok.com",
287
+ ca: "https://api-ca.storyblok.com",
288
+ };
289
+
290
+ function storyblokBase(): string {
291
+ const region = (import.meta.env.STORYBLOK_REGION ?? "eu").toLowerCase();
292
+ return STORYBLOK_REGION_BASES[region] ?? STORYBLOK_REGION_BASES.eu!;
293
+ }
294
+
295
+ function storyblokVersion(): "published" | "draft" {
296
+ return import.meta.env.STORYBLOK_VERSION === "draft" ? "draft" : "published";
297
+ }
298
+
299
+ interface StoryblokStory {
300
+ slug: string;
301
+ full_slug: string;
302
+ name: string;
303
+ content: Record<string, any>;
304
+ }
305
+
306
+ // Cap Storyblok latency so a slow/unreachable CMS can't hang the Worker — on
307
+ // timeout (or any error) the caller falls back to LocalContentProvider. The
308
+ // storefront must always render. (Epic S4 / S3.)
309
+ const STORYBLOK_TIMEOUT_MS = 1500;
310
+
311
+ async function fetchStoryblokJson<T>(url: string): Promise<T | null> {
312
+ try {
313
+ const res = await fetch(url, { signal: AbortSignal.timeout(STORYBLOK_TIMEOUT_MS) });
314
+ if (!res.ok) return null;
315
+ return (await res.json()) as T;
316
+ } catch {
317
+ // Network error, non-2xx already handled, or AbortSignal timeout.
318
+ return null;
319
+ }
320
+ }
321
+
322
+ async function fetchStory(token: string, slug: string): Promise<StoryblokStory | null> {
323
+ const url =
324
+ `${storyblokBase()}/v2/cdn/stories/${slug}` +
325
+ `?token=${encodeURIComponent(token)}&version=${storyblokVersion()}`;
326
+ const json = await fetchStoryblokJson<{ story?: StoryblokStory }>(url);
327
+ return json?.story ?? null;
328
+ }
329
+
330
+ async function fetchStories(token: string, startsWith: string): Promise<StoryblokStory[]> {
331
+ const url =
332
+ `${storyblokBase()}/v2/cdn/stories` +
333
+ `?starts_with=${encodeURIComponent(startsWith)}` +
334
+ `&token=${encodeURIComponent(token)}&version=${storyblokVersion()}&per_page=100`;
335
+ const json = await fetchStoryblokJson<{ stories?: StoryblokStory[] }>(url);
336
+ return json?.stories ?? [];
337
+ }
338
+
339
+ function mapHome(content: Record<string, any>): HomeContent {
340
+ // Storyblok nests the block list under `body` (array of bloks, each with its
341
+ // own `component`); our HomeBlock union uses the same discriminator.
342
+ const blocks = Array.isArray(content.body)
343
+ ? content.body
344
+ : Array.isArray(content.blocks)
345
+ ? content.blocks
346
+ : [];
347
+ if (blocks.length === 0) throw new Error("storyblok home: no blocks");
348
+ return {
349
+ seoTitle: content.seoTitle ?? content.seo_title,
350
+ seoDescription: content.seoDescription ?? content.seo_description,
351
+ blocks: blocks as HomeContent["blocks"],
352
+ };
353
+ }
354
+
355
+ function mapPage(content: Record<string, any>, slug: string): EditorialPage {
356
+ // Expect a rendered-HTML field (text/markdown component). A raw richtext object
357
+ // would need a resolver — we throw so the local fallback serves it instead.
358
+ const body_html = content.body_html ?? content.body;
359
+ if (typeof body_html !== "string") throw new Error(`storyblok page ${slug}: body not HTML`);
360
+ return {
361
+ slug: slug.replace(/^pages\//, ""),
362
+ title: content.title ?? slug,
363
+ seoTitle: content.seoTitle ?? content.seo_title,
364
+ seoDescription: content.seoDescription ?? content.seo_description,
365
+ body_html,
366
+ eyebrow: content.eyebrow,
367
+ intro: content.intro,
368
+ isPolicy: !!content.isPolicy,
369
+ };
370
+ }
371
+
372
+ export class StoryblokContentProvider implements ContentProvider {
373
+ constructor(
374
+ private readonly token: string,
375
+ private readonly fallback: ContentProvider = new LocalContentProvider(),
376
+ ) {}
377
+
378
+ async getHome(): Promise<HomeContent> {
379
+ try {
380
+ const story = await fetchStory(this.token, "home");
381
+ if (story) return mapHome(story.content);
382
+ } catch {
383
+ /* fall back */
384
+ }
385
+ return this.fallback.getHome();
386
+ }
387
+
388
+ getHomeHtml(): Promise<string | null> {
389
+ // Raw home documents live in tenant content files, not Storyblok — delegate.
390
+ return this.fallback.getHomeHtml();
391
+ }
392
+
393
+ getPageHtml(slug: string): Promise<string | null> {
394
+ return this.fallback.getPageHtml(slug);
395
+ }
396
+
397
+ listPageHtmlSlugs(): Promise<string[]> {
398
+ return this.fallback.listPageHtmlSlugs();
399
+ }
400
+
401
+ getChromeHtml(): Promise<string | null> {
402
+ // The chrome wrapper lives in tenant content files, not Storyblok — delegate.
403
+ return this.fallback.getChromeHtml();
404
+ }
405
+
406
+ async getChrome(): Promise<SiteChrome> {
407
+ // Editorial chrome (nav/footer/tagline) is authored in Storyblok; the
408
+ // announcement + legal disclaimers are MIGRATION-sourced (written to local
409
+ // chrome.json), so overlay those from the fallback regardless.
410
+ const local = await this.fallback.getChrome();
411
+ try {
412
+ const story = await fetchStory(this.token, "chrome");
413
+ const c = story?.content;
414
+ if (c && Array.isArray(c.nav)) {
415
+ return {
416
+ nav: c.nav,
417
+ footer: c.footer ?? local.footer,
418
+ announcement: local.announcement ?? c.announcement,
419
+ disclaimer: local.disclaimer,
420
+ disclaimers: local.disclaimers,
421
+ };
422
+ }
423
+ } catch {
424
+ /* fall back */
425
+ }
426
+ return local;
427
+ }
428
+
429
+ async getPage(slug: string): Promise<EditorialPage | null> {
430
+ try {
431
+ const story = await fetchStory(this.token, `pages/${slug}`);
432
+ if (story) return mapPage(story.content, story.full_slug ?? slug);
433
+ } catch {
434
+ /* fall back */
435
+ }
436
+ return this.fallback.getPage(slug);
437
+ }
438
+
439
+ async listPages(): Promise<EditorialPage[]> {
440
+ try {
441
+ const stories = await fetchStories(this.token, "pages/");
442
+ const pages = stories
443
+ .map((s) => {
444
+ try {
445
+ return mapPage(s.content, s.full_slug ?? s.slug);
446
+ } catch {
447
+ return null;
448
+ }
449
+ })
450
+ .filter((p): p is EditorialPage => p !== null);
451
+ if (pages.length > 0) return pages;
452
+ } catch {
453
+ /* fall back */
454
+ }
455
+ return this.fallback.listPages();
456
+ }
457
+ }
458
+
459
+ // ---------------------------------------------------------------------------
460
+ // PublishedContentProvider — overlays published customization content for the
461
+ // resolved environment (store->app wiring). For each content artifact it checks
462
+ // the customization store ({live,test} pointer -> versioned JSON); if present it
463
+ // serves that, otherwise it delegates to the base provider (Storyblok/local).
464
+ // listPages() stays from the base (per-page overrides flow through getPage).
465
+ // ---------------------------------------------------------------------------
466
+
467
+ export interface ContentCustomization {
468
+ get: KvGet;
469
+ env: CustomizationEnv;
470
+ }
471
+
472
+ export class PublishedContentProvider implements ContentProvider {
473
+ constructor(
474
+ private readonly base: ContentProvider,
475
+ private readonly tenantId: string,
476
+ private readonly cust: ContentCustomization,
477
+ ) {}
478
+
479
+ /** Raw published-artifact string for a tenant file, or null to fall back to base. */
480
+ private publishedRaw(relative: string): Promise<string | null> {
481
+ return readPublishedArtifact(
482
+ this.cust.get,
483
+ this.tenantId,
484
+ this.cust.env,
485
+ contentArtifactPath(this.tenantId, relative),
486
+ );
487
+ }
488
+
489
+ private async published<T>(relative: string): Promise<T | null> {
490
+ const raw = await this.publishedRaw(relative);
491
+ if (!raw) return null;
492
+ try {
493
+ return JSON.parse(raw) as T;
494
+ } catch {
495
+ return null;
496
+ }
497
+ }
498
+
499
+ async getHome(): Promise<HomeContent> {
500
+ return (await this.published<HomeContent>("home.json")) ?? this.base.getHome();
501
+ }
502
+ // Raw whole-page documents (T0 marketing tenants) publish via KV too, so a
503
+ // vendor edit previews / goes live with NO Worker rebuild. Artifact path mirrors
504
+ // the source file: content/<tenant>/home.html and .../pages-html/<slug>.html.
505
+ async getHomeHtml(): Promise<string | null> {
506
+ return (await this.publishedRaw("home.html")) ?? this.base.getHomeHtml();
507
+ }
508
+ async getPageHtml(slug: string): Promise<string | null> {
509
+ const rel = `pages-html/${normalizeRawPageSlug(slug)}.html`;
510
+ return (await this.publishedRaw(rel)) ?? this.base.getPageHtml(slug);
511
+ }
512
+ listPageHtmlSlugs(): Promise<string[]> {
513
+ return this.base.listPageHtmlSlugs();
514
+ }
515
+ async getChromeHtml(): Promise<string | null> {
516
+ // A published raw chrome.html override (parallels the home.html/pages-html
517
+ // publish path) wins, so a vendor edit previews / goes live with no rebuild.
518
+ const publishedHtml = await this.publishedRaw("chrome.html");
519
+ if (publishedHtml) return publishedHtml;
520
+ // Else, if the PUBLISHED chrome.json opts into shared chrome (unit F3), render
521
+ // the canonical wrapper from it — so a config edit previews without a rebuild.
522
+ const config = parseChromeConfig(await this.published<unknown>("chrome.json"));
523
+ if (config) return renderRawChrome(config);
524
+ // Otherwise fall back to the base chain (bundled chrome.html or config render).
525
+ return this.base.getChromeHtml();
526
+ }
527
+ async getChrome(): Promise<SiteChrome> {
528
+ return (await this.published<SiteChrome>("chrome.json")) ?? this.base.getChrome();
529
+ }
530
+ async getPage(slug: string): Promise<EditorialPage | null> {
531
+ return (await this.published<EditorialPage>(`pages/${slug}.json`)) ?? this.base.getPage(slug);
532
+ }
533
+ listPages(): Promise<EditorialPage[]> {
534
+ return this.base.listPages();
535
+ }
536
+ }
537
+
538
+ // ---------------------------------------------------------------------------
539
+ // DevDraftContentProvider — the per-developer DRAFT overlay (decision
540
+ // draft-overlay-same-origin). Wraps the resolved provider chain and serves a
541
+ // draft artifact (written by /api/content-edit/ai-edit into TENANT_CACHE under
542
+ // `dev-draft:<tenantId>:<draftId>:<path>`) when one exists, else delegates.
543
+ // The middleware constructs this ONLY for a request whose signed tot_dev_draft
544
+ // cookie verifies for the resolved tenant — every other viewer gets the base
545
+ // chain untouched. Covers the raw-HTML surfaces the AI edit loop writes
546
+ // (home.html / pages-html); structured content stays base.
547
+ // ---------------------------------------------------------------------------
548
+
549
+ export class DevDraftContentProvider implements ContentProvider {
550
+ constructor(
551
+ private readonly base: ContentProvider,
552
+ private readonly tenantId: string,
553
+ private readonly draftId: string,
554
+ private readonly get: KvGet,
555
+ ) {}
556
+
557
+ /** Draft artifact for a tenant-repo-relative path, or null to fall back. */
558
+ private draftRaw(path: string): Promise<string | null> {
559
+ // Key shape mirrors lib/dev/devDraft.ts devDraftKey — duplicated as a plain
560
+ // template to keep this module's imports renderer-safe (public-runtime only).
561
+ return this.get(`dev-draft:${this.tenantId}:${this.draftId}:${path}`);
562
+ }
563
+
564
+ async getHomeHtml(): Promise<string | null> {
565
+ return (await this.draftRaw("content/home.html")) ?? this.base.getHomeHtml();
566
+ }
567
+ async getPageHtml(slug: string): Promise<string | null> {
568
+ const rel = `content/pages-html/${normalizeRawPageSlug(slug)}.html`;
569
+ return (await this.draftRaw(rel)) ?? this.base.getPageHtml(slug);
570
+ }
571
+ getHome(): Promise<HomeContent> {
572
+ return this.base.getHome();
573
+ }
574
+ listPageHtmlSlugs(): Promise<string[]> {
575
+ return this.base.listPageHtmlSlugs();
576
+ }
577
+ getChromeHtml(): Promise<string | null> {
578
+ return this.base.getChromeHtml();
579
+ }
580
+ getChrome(): Promise<SiteChrome> {
581
+ return this.base.getChrome();
582
+ }
583
+ getPage(slug: string): Promise<EditorialPage | null> {
584
+ return this.base.getPage(slug);
585
+ }
586
+ listPages(): Promise<EditorialPage[]> {
587
+ return this.base.listPages();
588
+ }
589
+ }
590
+
591
+ const PLACEHOLDER_TOKEN_RE = /PLACEHOLDER|^$/i;
592
+
593
+ export function createContentProvider(
594
+ tenant: TenantConfig,
595
+ customization?: ContentCustomization,
596
+ ): ContentProvider {
597
+ // Content dirs are keyed by tenant_id (the adopter writes content/<tenant>/).
598
+ const local = new LocalContentProvider(tenant.tenant_id);
599
+ const token = tenant.storyblok_space_token;
600
+ const usable = token && !PLACEHOLDER_TOKEN_RE.test(token);
601
+ const base: ContentProvider = usable
602
+ ? new StoryblokContentProvider(token, local)
603
+ : local;
604
+ return customization
605
+ ? new PublishedContentProvider(base, tenant.tenant_id, customization)
606
+ : base;
607
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Subscription / Autopilot education model — the typed input for the platform
3
+ * subscription-education widgets (<HowItWorksSteps>, <SavingsExplainer>,
4
+ * <ManageSubscriptionEntry>). These are presentational, EDUCATION-ONLY
5
+ * widgets: they explain how a recurring/Autopilot order works and hand off to
6
+ * the tenant's real subscribe/manage flow, but ship NO subscription,
7
+ * billing, or scheduling mechanics of their own.
8
+ *
9
+ * Because a savings headline (a "%", a "$" amount) is a claim, {@link
10
+ * SubscriptionFigure} is treated as ILLUSTRATIVE by default — same discipline
11
+ * as the membership tier figures (decision compliance-widgets-platform-owned):
12
+ * unless a figure explicitly asserts `real: true`, the widget marks it as an
13
+ * example so it is never presented as a live offer.
14
+ */
15
+
16
+ /**
17
+ * One step in a "how it works" explainer (e.g. "Choose your products" →
18
+ * "Set your schedule" → "We ship on autopilot"). Display only.
19
+ */
20
+ export interface SubscriptionStep {
21
+ /** Step title, e.g. "Set your schedule". */
22
+ title: string;
23
+ /** One or two sentences explaining the step. */
24
+ description: string;
25
+ }
26
+
27
+ /**
28
+ * A savings/pricing headline figure (e.g. "Save 15%", "$5 off every order").
29
+ * Display only — a string the widget renders verbatim, NEVER a number it
30
+ * computes a discount from. Marked illustrative unless `real` is set.
31
+ */
32
+ export interface SubscriptionFigure {
33
+ /** The headline figure text, e.g. "Save 15%". Display only. */
34
+ value: string;
35
+ /** Optional caption under the figure, e.g. "vs. one-time purchase". */
36
+ caption?: string;
37
+ /**
38
+ * Assert this figure is a REAL, live value (opt out of the illustrative
39
+ * affordance). Default (undefined/false) → treated as an illustrative
40
+ * example. Only set when the tenant confirms the number is an accurate
41
+ * live offer.
42
+ */
43
+ real?: boolean;
44
+ }
45
+
46
+ /**
47
+ * One manage-subscription entry point (e.g. "Update delivery date", "Pause or
48
+ * cancel"). This is an EDUCATION/HANDOFF link only — it points at the
49
+ * tenant's product-owned manage-subscription flow; the widget never renders
50
+ * scheduling, billing, or cancellation controls of its own.
51
+ */
52
+ export interface SubscriptionManageEntry {
53
+ /** Entry label, e.g. "Pause or cancel anytime". */
54
+ label: string;
55
+ /** Optional supporting sentence under the label. */
56
+ description?: string;
57
+ /** Handoff URL into the tenant's product-owned manage-subscription flow. */
58
+ href: string;
59
+ }
60
+
61
+ /**
62
+ * True when the widget should mark this figure as illustrative. A figure
63
+ * that asserts `real: true` opts out; everything else is illustrative by
64
+ * default so example numbers are never shown as a live offer.
65
+ */
66
+ export function isIllustrative(figure: Pick<SubscriptionFigure, "real">): boolean {
67
+ return figure.real !== true;
68
+ }