@tokenoftrust/storefront-runner 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +58 -0
- package/README.md +40 -0
- package/apps/storefront/.dev.vars.example +41 -0
- package/apps/storefront/.env.example +24 -0
- package/apps/storefront/astro.config.mjs +245 -0
- package/apps/storefront/env.d.ts +155 -0
- package/apps/storefront/lib/ensure-preview-reconcile-secret.sh +31 -0
- package/apps/storefront/lib/resolve-cf-token.sh +48 -0
- package/apps/storefront/lib/resolve-tot-credentials.sh +69 -0
- package/apps/storefront/migrations/0000_baseline.sql +54 -0
- package/apps/storefront/migrations/0001_products_fts.sql +9 -0
- package/apps/storefront/migrations/README.md +54 -0
- package/apps/storefront/migrations/meta/0000_snapshot.json +347 -0
- package/apps/storefront/migrations/meta/0001_snapshot.json +347 -0
- package/apps/storefront/migrations/meta/_journal.json +20 -0
- package/apps/storefront/package.json +41 -0
- package/apps/storefront/public/brand/northwind-logo-dark.svg +4 -0
- package/apps/storefront/public/brand/northwind-logo-light.svg +4 -0
- package/apps/storefront/public/brand/northwind-og.svg +13 -0
- package/apps/storefront/public/favicon.svg +5 -0
- package/apps/storefront/public/js/dashboard-team.js +147 -0
- package/apps/storefront/public/js/dashboard-vendor-search.js +115 -0
- package/apps/storefront/src/components/Badge.astro +34 -0
- package/apps/storefront/src/components/Breadcrumbs.astro +45 -0
- package/apps/storefront/src/components/CollectionCard.astro +56 -0
- package/apps/storefront/src/components/EmptyState.astro +35 -0
- package/apps/storefront/src/components/Img.astro +68 -0
- package/apps/storefront/src/components/ProductCard.astro +228 -0
- package/apps/storefront/src/components/Section.astro +39 -0
- package/apps/storefront/src/components/Seo.astro +70 -0
- package/apps/storefront/src/components/WidgetFrame.astro +34 -0
- package/apps/storefront/src/components/auth/AuthBadge.astro +44 -0
- package/apps/storefront/src/components/chrome/AnnouncementBar.astro +108 -0
- package/apps/storefront/src/components/chrome/MegaMenu.astro +26 -0
- package/apps/storefront/src/components/chrome/NavDropdown.astro +116 -0
- package/apps/storefront/src/components/chrome/SiteFooter.astro +181 -0
- package/apps/storefront/src/components/chrome/SiteHeader.astro +181 -0
- package/apps/storefront/src/components/chrome/UtilityNav.astro +79 -0
- package/apps/storefront/src/components/commerce/FoxyLoader.astro +23 -0
- package/apps/storefront/src/components/commerce/PriceDisplay.astro +91 -0
- package/apps/storefront/src/components/commerce/ProductCarousel.astro +197 -0
- package/apps/storefront/src/components/commerce/ProductGrid.astro +75 -0
- package/apps/storefront/src/components/commerce/RatingStars.astro +58 -0
- package/apps/storefront/src/components/compliance/NicotineWarning.astro +33 -0
- package/apps/storefront/src/components/home/Hero.astro +164 -0
- package/apps/storefront/src/components/islands/AgeGate.tsx +105 -0
- package/apps/storefront/src/components/islands/ImageGallery.tsx +237 -0
- package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +73 -0
- package/apps/storefront/src/components/islands/MobileNav.tsx +239 -0
- package/apps/storefront/src/components/islands/QuickView.tsx +138 -0
- package/apps/storefront/src/components/islands/RecentlyViewed.tsx +122 -0
- package/apps/storefront/src/components/islands/SavedList.tsx +99 -0
- package/apps/storefront/src/components/islands/SearchTypeahead.tsx +248 -0
- package/apps/storefront/src/components/islands/VariantSelector.tsx +378 -0
- package/apps/storefront/src/components/marketing/CardGrid.astro +72 -0
- package/apps/storefront/src/components/marketing/CodeSample.astro +20 -0
- package/apps/storefront/src/components/marketing/CtaBand.astro +24 -0
- package/apps/storefront/src/components/marketing/Faq.astro +37 -0
- package/apps/storefront/src/components/marketing/Integrations.astro +63 -0
- package/apps/storefront/src/components/marketing/MarketingBlocks.astro +53 -0
- package/apps/storefront/src/components/marketing/MarketingCtas.astro +48 -0
- package/apps/storefront/src/components/marketing/MarketingHero.astro +72 -0
- package/apps/storefront/src/components/marketing/ProofStrip.astro +37 -0
- package/apps/storefront/src/components/marketing/SplitCompare.astro +53 -0
- package/apps/storefront/src/components/marketing/Steps.astro +36 -0
- package/apps/storefront/src/components/marketing/Testimonials.astro +38 -0
- package/apps/storefront/src/components/marketing/TrustBar.astro +22 -0
- package/apps/storefront/src/components/nav/Footer.astro +148 -0
- package/apps/storefront/src/components/nav/Header.astro +190 -0
- package/apps/storefront/src/components/plp/ActiveFilters.astro +79 -0
- package/apps/storefront/src/components/plp/FacetSidebar.astro +146 -0
- package/apps/storefront/src/components/plp/Pagination.astro +99 -0
- package/apps/storefront/src/components/plp/SortSelect.astro +64 -0
- package/apps/storefront/src/config/devTenants.ts +16 -0
- package/apps/storefront/src/config/kvTenantResolver.ts +87 -0
- package/apps/storefront/src/config/resolver.ts +46 -0
- package/apps/storefront/src/config/tenants.ts +193 -0
- package/apps/storefront/src/layouts/Layout.astro +303 -0
- package/apps/storefront/src/lib/analytics/index.ts +47 -0
- package/apps/storefront/src/lib/auth/brokerAssertion.ts +136 -0
- package/apps/storefront/src/lib/auth/devAuth.ts +90 -0
- package/apps/storefront/src/lib/auth/gatePage.ts +81 -0
- package/apps/storefront/src/lib/auth/identityToken.ts +341 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +107 -0
- package/apps/storefront/src/lib/auth/route.ts +146 -0
- package/apps/storefront/src/lib/auth/session.ts +203 -0
- package/apps/storefront/src/lib/auth/totAccessClient.ts +503 -0
- package/apps/storefront/src/lib/basePath.ts +41 -0
- package/apps/storefront/src/lib/cfImage.ts +55 -0
- package/apps/storefront/src/lib/chrome/model.ts +65 -0
- package/apps/storefront/src/lib/colors.ts +97 -0
- package/apps/storefront/src/lib/content-edit/client.ts +230 -0
- package/apps/storefront/src/lib/content-edit/index.ts +14 -0
- package/apps/storefront/src/lib/content-edit/route.ts +25 -0
- package/apps/storefront/src/lib/content-edit/types.ts +19 -0
- package/apps/storefront/src/lib/cspStaticHashes.ts +24 -0
- package/apps/storefront/src/lib/d1/catalog.ts +289 -0
- package/apps/storefront/src/lib/dashboard/staffAdmission.ts +43 -0
- package/apps/storefront/src/lib/dashboard/staffVendorAccess.ts +449 -0
- package/apps/storefront/src/lib/dashboard/tenantSelection.ts +136 -0
- package/apps/storefront/src/lib/dashboard/vendorQuery.ts +61 -0
- package/apps/storefront/src/lib/demoGate.ts +113 -0
- package/apps/storefront/src/lib/dev/ai-widget-client.js +127 -0
- package/apps/storefront/src/lib/dev/cliSignInCode.ts +132 -0
- package/apps/storefront/src/lib/diag/redact.ts +20 -0
- package/apps/storefront/src/lib/edgeCache.ts +128 -0
- package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +62 -0
- package/apps/storefront/src/lib/env.ts +60 -0
- package/apps/storefront/src/lib/fixtures/sampleProducts.ts +130 -0
- package/apps/storefront/src/lib/format.ts +29 -0
- package/apps/storefront/src/lib/foxyCommerce.ts +49 -0
- package/apps/storefront/src/lib/imageAttrs.ts +55 -0
- package/apps/storefront/src/lib/jsonld.ts +136 -0
- package/apps/storefront/src/lib/maintenance.ts +71 -0
- package/apps/storefront/src/lib/memoryKv.ts +32 -0
- package/apps/storefront/src/lib/messaging/messagesClient.ts +143 -0
- package/apps/storefront/src/lib/placeholder.ts +99 -0
- package/apps/storefront/src/lib/previewReload.ts +125 -0
- package/apps/storefront/src/lib/rawMarketingHtml.ts +133 -0
- package/apps/storefront/src/lib/search/index.ts +74 -0
- package/apps/storefront/src/lib/sections.ts +129 -0
- package/apps/storefront/src/lib/securityHeaders.ts +31 -0
- package/apps/storefront/src/lib/storyblok/content-model.ts +289 -0
- package/apps/storefront/src/lib/storyblok/provider.ts +524 -0
- package/apps/storefront/src/lib/tenantRouting.ts +91 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +177 -0
- package/apps/storefront/src/lib/tot/d1Client.ts +62 -0
- package/apps/storefront/src/lib/tot/query.ts +187 -0
- package/apps/storefront/src/lib/tot/totClientInterface.ts +30 -0
- package/apps/storefront/src/lib/url.ts +119 -0
- package/apps/storefront/src/lib/wishlist.ts +63 -0
- package/apps/storefront/src/middleware/index.ts +413 -0
- package/apps/storefront/src/pages/404.astro +61 -0
- package/apps/storefront/src/pages/[...slug].astro +90 -0
- package/apps/storefront/src/pages/api/auth/logout.ts +21 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +107 -0
- package/apps/storefront/src/pages/api/auth/send.ts +85 -0
- package/apps/storefront/src/pages/api/auth/verify.ts +135 -0
- package/apps/storefront/src/pages/api/cache-purge.ts +44 -0
- package/apps/storefront/src/pages/api/content-edit/ai-edit.ts +64 -0
- package/apps/storefront/src/pages/api/csp-report.ts +25 -0
- package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +124 -0
- package/apps/storefront/src/pages/api/dashboard/vendor-search.ts +120 -0
- package/apps/storefront/src/pages/api/dev/cli-signin-code.ts +44 -0
- package/apps/storefront/src/pages/api/newsletter.ts +104 -0
- package/apps/storefront/src/pages/api/request-access.ts +172 -0
- package/apps/storefront/src/pages/auth/login.astro +149 -0
- package/apps/storefront/src/pages/auth/magic.astro +105 -0
- package/apps/storefront/src/pages/capabilities.astro +292 -0
- package/apps/storefront/src/pages/collections/[handle].astro +154 -0
- package/apps/storefront/src/pages/collections/index.astro +59 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +100 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +154 -0
- package/apps/storefront/src/pages/dashboard/index.astro +160 -0
- package/apps/storefront/src/pages/dev.astro +653 -0
- package/apps/storefront/src/pages/index.astro +290 -0
- package/apps/storefront/src/pages/llms.txt.ts +92 -0
- package/apps/storefront/src/pages/products/[handle].astro +443 -0
- package/apps/storefront/src/pages/robots.txt.ts +24 -0
- package/apps/storefront/src/pages/saved.astro +35 -0
- package/apps/storefront/src/pages/search.astro +150 -0
- package/apps/storefront/src/pages/sitemap.xml.ts +78 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +646 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +85 -0
- package/apps/storefront/src/pages/style-guide/index.astro +94 -0
- package/apps/storefront/src/pages/tenants/[id]/[...path].ts +57 -0
- package/apps/storefront/src/styles/global.css +277 -0
- package/apps/storefront/src/themes/reference.ts +61 -0
- package/apps/storefront/src/themes/schema.ts +111 -0
- package/apps/storefront/src/themes/tenants/index.ts +51 -0
- package/apps/storefront/tsconfig.json +16 -0
- package/apps/storefront/vitest.config.ts +22 -0
- package/apps/storefront/wrangler.toml +175 -0
- package/package.json +23 -0
- package/packages/public-runtime/LICENSE +58 -0
- package/packages/public-runtime/package.json +22 -0
- package/packages/public-runtime/src/binary-path.ts +68 -0
- package/packages/public-runtime/src/capabilities.ts +89 -0
- package/packages/public-runtime/src/catalog-api.ts +81 -0
- package/packages/public-runtime/src/catalog-d1.ts +192 -0
- package/packages/public-runtime/src/csp.ts +109 -0
- package/packages/public-runtime/src/customization-preview.ts +197 -0
- package/packages/public-runtime/src/customization-reconcile.ts +63 -0
- package/packages/public-runtime/src/customization-runtime.ts +157 -0
- package/packages/public-runtime/src/customization-scripts.ts +64 -0
- package/packages/public-runtime/src/customization-versioning.ts +95 -0
- package/packages/public-runtime/src/foxy.ts +184 -0
- package/packages/public-runtime/src/index.ts +30 -0
- package/packages/public-runtime/src/product.ts +159 -0
- package/packages/public-runtime/src/search.ts +45 -0
- package/packages/public-runtime/src/tenant-assets.ts +444 -0
- package/packages/public-runtime/src/tenant.ts +243 -0
- package/packages/public-runtime/tsconfig.json +8 -0
- package/packages/public-runtime/vitest.config.ts +8 -0
- package/pnpm-workspace.yaml +7 -0
- package/scripts/build/copy-tenant-assets.mjs +69 -0
- package/scripts/tot-dev.mjs +439 -0
- package/tenants/bigdvapor/content/blog.json +52 -0
- package/tenants/bigdvapor/content/chrome.html +149 -0
- package/tenants/bigdvapor/content/chrome.json +47 -0
- package/tenants/bigdvapor/content/home.html +463 -0
- package/tenants/bigdvapor/content/home.json +73 -0
- package/tenants/bigdvapor/content/pages/about.json +5 -0
- package/tenants/bigdvapor/content/pages/privacy.json +6 -0
- package/tenants/bigdvapor/content/pages/shipping-returns.json +6 -0
- package/tenants/bigdvapor/content/pages-html/blogs/news.html +86 -0
- package/tenants/bigdvapor/content/pages-html/pages/about-us.html +106 -0
- package/tenants/bigdvapor/content/pages-html/pages/contact-us.html +61 -0
- package/tenants/bigdvapor/content/pages-html/pages/privacy-policy.html +72 -0
- package/tenants/bigdvapor/content/pages-html/pages/shipping-returns.html +172 -0
- package/tenants/bigdvapor/content/themes/bigd-navy.json +109 -0
- package/tenants/bigdvapor/public/img/brands/adjust-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/again-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/al-fakher-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/arro-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/cookies-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/core-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/cyclone-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/dinner-lady-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/extre-bar-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/fifty-bar-vape-disposable.jpg +0 -0
- package/tenants/bigdvapor/public/img/brands/firerose-vape-disposable.jpg +0 -0
- package/tenants/bigdvapor/public/img/brands/flavor-beast-vape-disposable.jpg +0 -0
- package/tenants/bigdvapor/public/img/brands/foger-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/kk-energy-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/kumi-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/lost-vape-dispoable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/melo-labs-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/mixo-vape-disposable.webp +0 -0
- package/tenants/bigdvapor/public/img/brands/one-tank-vape-disposable.jpg +0 -0
- package/tenants/bigdvapor/public/img/brands/oxbar-vape-disposable.webp +0 -0
- package/tenants/bigdvapor/public/img/brands/pillow-talk-vape-disposable.jpg +0 -0
- package/tenants/bigdvapor/public/img/brands/pod-king-vape-disposable.jpg +0 -0
- package/tenants/bigdvapor/public/img/brands/raz-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/hero-texas.jpg +0 -0
- package/tenants/bigdvapor/public/img/og.jpg +0 -0
- package/tenants/bigdvapor/public/img/pages/AdobeStock_202106312_web.jpg +0 -0
- package/tenants/bigdvapor/public/img/pages/CREW_web.jpg +0 -0
- package/tenants/bigdvapor/public/img/pages/Hero-Image.jpg +0 -0
- package/tenants/bigdvapor/public/img/tiles/adjust.png +0 -0
- package/tenants/bigdvapor/public/img/tiles/again.png +0 -0
- package/tenants/bigdvapor/public/img/tiles/al-fakher.png +0 -0
- package/tenants/bigdvapor/public/img/tiles/arro.png +0 -0
- package/tenants/bigdvapor/public/img/tiles/cookies.png +0 -0
- package/tenants/bigdvapor/public/img/tiles/core.png +0 -0
- package/tenants/bigdvapor/public/img/tiles/cyclone.png +0 -0
- package/tenants/bigdvapor/public/img/tiles/disposables.svg +23 -0
- package/tenants/bigdvapor/public/logo-wordmark.png +0 -0
- package/tenants/bigdvapor/public/pages/home.css +118 -0
- package/tenants/bigdvapor/public/pages/mkt.css +185 -0
- package/tenants/bigdvapor/public/pages/page.css +148 -0
- package/tenants/bigdvapor/public/themes/bigd-navy.css +73 -0
- package/tenants/bigdvapor/theme.json +12 -0
- package/tenants/giantvapes/content/chrome.html +152 -0
- package/tenants/giantvapes/content/chrome.json +94 -0
- package/tenants/giantvapes/content/home.html +194 -0
- package/tenants/giantvapes/content/home.json +50 -0
- package/tenants/giantvapes/content/pages/about.json +10 -0
- package/tenants/giantvapes/content/pages/privacy.json +6 -0
- package/tenants/giantvapes/content/pages/shipping-returns.json +6 -0
- package/tenants/giantvapes/content/pages-html/blogs/news.html +68 -0
- package/tenants/giantvapes/content/pages-html/pages/about-us.html +95 -0
- package/tenants/giantvapes/content/pages-html/pages/contact-us.html +68 -0
- package/tenants/giantvapes/content/pages-html/pages/privacy-policy.html +65 -0
- package/tenants/giantvapes/content/pages-html/pages/shipping-returns.html +77 -0
- package/tenants/giantvapes/content/themes/giant-navy.json +73 -0
- package/tenants/giantvapes/public/img/hero-suicide-bunny.jpg +0 -0
- package/tenants/giantvapes/public/img/hero.webp +0 -0
- package/tenants/giantvapes/public/img/og.jpg +0 -0
- package/tenants/giantvapes/public/logo-wordmark.png +0 -0
- package/tenants/giantvapes/public/pages/home.css +120 -0
- package/tenants/giantvapes/public/pages/mkt.css +185 -0
- package/tenants/giantvapes/public/pages/page.css +155 -0
- package/tenants/giantvapes/public/themes/giant-navy.css +76 -0
- package/tenants/giantvapes/theme.json +39 -0
- package/tenants/home/content/chrome.json +11 -0
- package/tenants/home/content/home.html +304 -0
- package/tenants/home/content/home.json +13 -0
- package/tenants/home/public/js/storefront.js +210 -0
- package/tenants/home/public/logo-wordmark.png +0 -0
- package/tenants/home/public/pages/storefront.css +212 -0
- package/tenants/home/theme.json +6 -0
- package/tenants/tokenoftrust/.tot/config.json +9 -0
- package/tenants/tokenoftrust/content/chrome.json +55 -0
- package/tenants/tokenoftrust/content/home.html +723 -0
- package/tenants/tokenoftrust/content/home.json +233 -0
- package/tenants/tokenoftrust/content/pages-html/careers.html +231 -0
- package/tenants/tokenoftrust/content/pages-html/company.html +275 -0
- package/tenants/tokenoftrust/content/pages-html/contact/contact-sales.html +188 -0
- package/tenants/tokenoftrust/content/pages-html/contact/startup-apply.html +206 -0
- package/tenants/tokenoftrust/content/pages-html/contact.html +178 -0
- package/tenants/tokenoftrust/content/pages-html/industries/adult-content-age-verification.html +286 -0
- package/tenants/tokenoftrust/content/pages-html/industries/cannabis.html +277 -0
- package/tenants/tokenoftrust/content/pages-html/pact-act-pricing.html +289 -0
- package/tenants/tokenoftrust/content/pages-html/pricing.html +483 -0
- package/tenants/tokenoftrust/content/pages-html/product/age-assurance/age-estimation.html +278 -0
- package/tenants/tokenoftrust/content/pages-html/product/age-assurance/age-gate.html +279 -0
- package/tenants/tokenoftrust/content/pages-html/product/age-assurance/age-verification.html +265 -0
- package/tenants/tokenoftrust/content/pages-html/product/age-assurance.html +173 -0
- package/tenants/tokenoftrust/content/pages-html/product/compliance/aml.html +214 -0
- package/tenants/tokenoftrust/content/pages-html/product/compliance/pact-act.html +226 -0
- package/tenants/tokenoftrust/content/pages-html/product/fraud-prevention.html +254 -0
- package/tenants/tokenoftrust/content/pages-html/product/social-profile-verification.html +212 -0
- package/tenants/tokenoftrust/content/pages-html/product/tax/excise-tax.html +213 -0
- package/tenants/tokenoftrust/content/pages-html/product/verification/aml.html +259 -0
- package/tenants/tokenoftrust/content/pages-html/product/verification/biometric-selfie.html +305 -0
- package/tenants/tokenoftrust/content/pages-html/product/verification/coverage.html +254 -0
- package/tenants/tokenoftrust/content/pages-html/product/verification/document-verification.html +306 -0
- package/tenants/tokenoftrust/content/pages-html/product/verification/electronic-identity-verification.html +304 -0
- package/tenants/tokenoftrust/content/pages-html/product/verification/government-id-verification.html +308 -0
- package/tenants/tokenoftrust/content/pages-html/product/verification.html +183 -0
- package/tenants/tokenoftrust/content/pages-html/product.html +228 -0
- package/tenants/tokenoftrust/content/pages-html/resources/integrations/bigcommerce-integration.html +220 -0
- package/tenants/tokenoftrust/content/pages-html/resources/integrations/shopify-integration.html +221 -0
- package/tenants/tokenoftrust/content/pages-html/resources/integrations/wordpress.html +220 -0
- package/tenants/tokenoftrust/content/pages-html/resources/integrations.html +172 -0
- package/tenants/tokenoftrust/content/pages-html/resources/refer-a-friend-program.html +317 -0
- package/tenants/tokenoftrust/content/pages-html/resources.html +177 -0
- package/tenants/tokenoftrust/content/pages-html/reviews.html +273 -0
- package/tenants/tokenoftrust/content/pages-html/solutions.html +292 -0
- package/tenants/tokenoftrust/content/pages-html/vlp-alcohol.html +289 -0
- package/tenants/tokenoftrust/content/pages-html/vlp-cigars-and-tobacco.html +286 -0
- package/tenants/tokenoftrust/content/pages-html/vlp-firearm-accessories.html +267 -0
- package/tenants/tokenoftrust/content/pages-html/vlp-peptides.html +273 -0
- package/tenants/tokenoftrust/content/themes/tot-navy.json +88 -0
- package/tenants/tokenoftrust/public/favicon.png +0 -0
- package/tenants/tokenoftrust/public/logo-icon.png +0 -0
- package/tenants/tokenoftrust/public/logo-wordmark.png +0 -0
- package/tenants/tokenoftrust/public/pages/company.css +108 -0
- package/tenants/tokenoftrust/public/pages/home.css +457 -0
- package/tenants/tokenoftrust/public/pages/mkt.css +482 -0
- package/tenants/tokenoftrust/public/pages/pricing.css +214 -0
- package/tenants/tokenoftrust/public/pages/solutions.css +66 -0
- package/tenants/tokenoftrust/public/themes/tot-navy-1.0.0.css +136 -0
- package/tenants/tokenoftrust/public/themes/tot-navy.css +358 -0
- package/tenants/tokenoftrust/theme.json +31 -0
- package/tsconfig.base.json +20 -0
|
@@ -0,0 +1,524 @@
|
|
|
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
|
+
|
|
25
|
+
export interface ContentProvider {
|
|
26
|
+
getHome(): Promise<HomeContent>;
|
|
27
|
+
/**
|
|
28
|
+
* A marketing tenant's self-authored home document (raw HTML), or null. When
|
|
29
|
+
* present the homepage route serves it VERBATIM (bypassing the block Layout) —
|
|
30
|
+
* the "vendor injects their own HTML" path. Full `<!doctype html>` document;
|
|
31
|
+
* the route nonces its inline <style>/<script> so it still satisfies CSP.
|
|
32
|
+
*/
|
|
33
|
+
getHomeHtml(): Promise<string | null>;
|
|
34
|
+
/**
|
|
35
|
+
* A marketing tenant's self-authored SUBPAGE document (raw HTML) for `slug`,
|
|
36
|
+
* or null. Same verbatim-serve path as getHomeHtml — the catch-all route
|
|
37
|
+
* (`[...slug].astro`) serves it bypassing the block Layout and nonces its
|
|
38
|
+
* inline tags. Files live at `content/<tenant>/pages-html/<slug>.html`.
|
|
39
|
+
*/
|
|
40
|
+
getPageHtml(slug: string): Promise<string | null>;
|
|
41
|
+
/** Slugs of this tenant's raw subpages (for the sitemap). */
|
|
42
|
+
listPageHtmlSlugs(): Promise<string[]>;
|
|
43
|
+
/**
|
|
44
|
+
* The tenant's shared-chrome WRAPPER document (raw HTML), or null. A full
|
|
45
|
+
* document containing the CHROME_BODY_MARKER where a body-only page fragment is
|
|
46
|
+
* spliced in (see lib/rawMarketingHtml.ts). Lets raw pages stop duplicating
|
|
47
|
+
* chrome: chrome lives once here, pages become fragments. File:
|
|
48
|
+
* `content/<tenant>/chrome.html`. Tenant-owned (no cross-tenant fallback).
|
|
49
|
+
*/
|
|
50
|
+
getChromeHtml(): Promise<string | null>;
|
|
51
|
+
getChrome(): Promise<SiteChrome>;
|
|
52
|
+
getPage(slug: string): Promise<EditorialPage | null>;
|
|
53
|
+
listPages(): Promise<EditorialPage[]>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// LocalContentProvider — static JSON bundled at build time.
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
// Per-tenant local content lives under the colocated repo-root layout
|
|
61
|
+
// tenants/<tenant_id>/content/. Vite globs bundle every tenant's JSON at build
|
|
62
|
+
// time; a file missing for a tenant falls back to the default scope, so a
|
|
63
|
+
// partially-adopted tenant still renders. Depth: from src/lib/storyblok/ the
|
|
64
|
+
// repo root is five levels up (storyblok→lib→src→storefront→apps→root).
|
|
65
|
+
const homeGlobs = import.meta.glob<HomeContent>("../../../../../tenants/*/content/home.json", {
|
|
66
|
+
import: "default",
|
|
67
|
+
});
|
|
68
|
+
const chromeGlobs = import.meta.glob<SiteChrome>("../../../../../tenants/*/content/chrome.json", {
|
|
69
|
+
import: "default",
|
|
70
|
+
});
|
|
71
|
+
const pageGlobs = import.meta.glob<EditorialPage>("../../../../../tenants/*/content/pages/*.json", {
|
|
72
|
+
import: "default",
|
|
73
|
+
});
|
|
74
|
+
// Raw self-authored home documents (marketing tenants). `?raw` bundles the file
|
|
75
|
+
// as a string; a tenant with no home.html simply has no entry here.
|
|
76
|
+
const homeHtmlGlobs = import.meta.glob<string>("../../../../../tenants/*/content/home.html", {
|
|
77
|
+
query: "?raw",
|
|
78
|
+
import: "default",
|
|
79
|
+
});
|
|
80
|
+
// Raw self-authored SUBPAGE documents: tenants/<tenant>/content/pages-html/<slug>.html
|
|
81
|
+
// (e.g. pages-html/pricing.html -> /pricing/). `**` supports nested slugs.
|
|
82
|
+
const pageHtmlGlobs = import.meta.glob<string>("../../../../../tenants/*/content/pages-html/**/*.html", {
|
|
83
|
+
query: "?raw",
|
|
84
|
+
import: "default",
|
|
85
|
+
});
|
|
86
|
+
// Shared-chrome wrapper document (marketing tenants that author body-only page
|
|
87
|
+
// fragments). One per tenant; a tenant without it keeps serving full documents.
|
|
88
|
+
const chromeHtmlGlobs = import.meta.glob<string>("../../../../../tenants/*/content/chrome.html", {
|
|
89
|
+
query: "?raw",
|
|
90
|
+
import: "default",
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const DEFAULT_SCOPE = "home";
|
|
94
|
+
|
|
95
|
+
function pickContent<T>(
|
|
96
|
+
globs: Record<string, () => Promise<T>>,
|
|
97
|
+
scope: string,
|
|
98
|
+
): (() => Promise<T>) | null {
|
|
99
|
+
const seg = `/tenants/${scope}/content/`;
|
|
100
|
+
const key = Object.keys(globs).find((k) => k.includes(seg));
|
|
101
|
+
return (key ? globs[key] : null) ?? null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function pickPages(scope: string): Array<() => Promise<EditorialPage>> {
|
|
105
|
+
const seg = `/tenants/${scope}/content/pages/`;
|
|
106
|
+
return Object.keys(pageGlobs)
|
|
107
|
+
.filter((k) => k.includes(seg))
|
|
108
|
+
.map((k) => pageGlobs[k]!);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Normalize a route slug to the file stem: strip surrounding slashes + .html. */
|
|
112
|
+
function normalizeRawPageSlug(slug: string): string {
|
|
113
|
+
return slug.replace(/^\/+|\/+$/g, "").replace(/\.html$/i, "");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The subpage slug a glob key encodes for `scope`, or null if it's not one. */
|
|
117
|
+
function rawPageSlugForKey(scope: string, key: string): string | null {
|
|
118
|
+
const seg = `/tenants/${scope}/content/pages-html/`;
|
|
119
|
+
const start = key.indexOf(seg);
|
|
120
|
+
if (start < 0 || !key.endsWith(".html")) return null;
|
|
121
|
+
return key.slice(start + seg.length, -".html".length);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function pickPageHtml(scope: string, slug: string): (() => Promise<string>) | null {
|
|
125
|
+
const wanted = normalizeRawPageSlug(slug);
|
|
126
|
+
if (!wanted) return null;
|
|
127
|
+
const key = Object.keys(pageHtmlGlobs).find(
|
|
128
|
+
(k) => rawPageSlugForKey(scope, k) === wanted,
|
|
129
|
+
);
|
|
130
|
+
return key ? pageHtmlGlobs[key]! : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* DEV hot-reload for raw tenant HTML. The `?raw` import.meta.glob above bundles
|
|
135
|
+
* each file's contents at server boot, so an edit to tenants/<id>/content/*.html
|
|
136
|
+
* would NOT preview until a full rebuild — the "magic on save" gap. In dev we read
|
|
137
|
+
* the file from disk at REQUEST time instead, so an edit shows on the next request.
|
|
138
|
+
*
|
|
139
|
+
* Prod: import.meta.env.DEV is statically false, so this whole branch tree-shakes
|
|
140
|
+
* out and the bundled glob is used — node:fs never enters the Worker. If fs is
|
|
141
|
+
* unavailable (e.g. the workerd dev runtime) or the file is missing, we return null
|
|
142
|
+
* and the caller falls back to the glob, so behavior is never worse than before.
|
|
143
|
+
*/
|
|
144
|
+
async function readRawDevFile(scope: string, relative: string): Promise<string | null> {
|
|
145
|
+
if (!import.meta.env.DEV) return null;
|
|
146
|
+
try {
|
|
147
|
+
const [{ readFile }, { fileURLToPath }, { resolve, dirname }] = await Promise.all([
|
|
148
|
+
import("node:fs/promises"),
|
|
149
|
+
import("node:url"),
|
|
150
|
+
import("node:path"),
|
|
151
|
+
]);
|
|
152
|
+
// provider.ts lives at apps/storefront/src/lib/storyblok/ — the repo root is five
|
|
153
|
+
// levels up, mirroring the import.meta.glob prefix ("../../../../../tenants/...").
|
|
154
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
155
|
+
const abs = resolve(here, "../../../../..", "tenants", scope, "content", relative);
|
|
156
|
+
return await readFile(abs, "utf8");
|
|
157
|
+
} catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export class LocalContentProvider implements ContentProvider {
|
|
163
|
+
constructor(private readonly scope: string = DEFAULT_SCOPE) {}
|
|
164
|
+
|
|
165
|
+
async getHome(): Promise<HomeContent> {
|
|
166
|
+
const loader = pickContent(homeGlobs, this.scope) ?? pickContent(homeGlobs, DEFAULT_SCOPE);
|
|
167
|
+
if (!loader) throw new Error("no home content");
|
|
168
|
+
return loader();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async getHomeHtml(): Promise<string | null> {
|
|
172
|
+
// DEV: serve fresh from disk so an edit previews on save (see readRawDevFile).
|
|
173
|
+
const dev = await readRawDevFile(this.scope, "home.html");
|
|
174
|
+
if (dev !== null) return dev;
|
|
175
|
+
// Tenant-owned only (no cross-tenant fallback): a raw home document is a
|
|
176
|
+
// whole-page identity, never inherited from the default scope.
|
|
177
|
+
const loader = pickContent(homeHtmlGlobs, this.scope);
|
|
178
|
+
return loader ? loader() : null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async getPageHtml(slug: string): Promise<string | null> {
|
|
182
|
+
// DEV: serve fresh from disk so a subpage edit previews on save.
|
|
183
|
+
const dev = await readRawDevFile(this.scope, `pages-html/${normalizeRawPageSlug(slug)}.html`);
|
|
184
|
+
if (dev !== null) return dev;
|
|
185
|
+
// Tenant-owned only, like getHomeHtml — raw subpages are whole-page identity.
|
|
186
|
+
const loader = pickPageHtml(this.scope, slug);
|
|
187
|
+
return loader ? loader() : null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async listPageHtmlSlugs(): Promise<string[]> {
|
|
191
|
+
return Object.keys(pageHtmlGlobs)
|
|
192
|
+
.map((k) => rawPageSlugForKey(this.scope, k))
|
|
193
|
+
.filter((slug): slug is string => !!slug)
|
|
194
|
+
.sort((a, b) => a.localeCompare(b));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async getChromeHtml(): Promise<string | null> {
|
|
198
|
+
// DEV: serve fresh from disk so a chrome edit previews on save.
|
|
199
|
+
const dev = await readRawDevFile(this.scope, "chrome.html");
|
|
200
|
+
if (dev !== null) return dev;
|
|
201
|
+
// Tenant-owned only (no cross-tenant fallback) — chrome is whole-page identity.
|
|
202
|
+
const loader = pickContent(chromeHtmlGlobs, this.scope);
|
|
203
|
+
return loader ? loader() : null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async getChrome(): Promise<SiteChrome> {
|
|
207
|
+
const defL = pickContent(chromeGlobs, DEFAULT_SCOPE);
|
|
208
|
+
const def: SiteChrome = defL
|
|
209
|
+
? await defL()
|
|
210
|
+
: { nav: [], footer: { columns: [], social: [] } };
|
|
211
|
+
const ownL = pickContent(chromeGlobs, this.scope);
|
|
212
|
+
if (!ownL) return def;
|
|
213
|
+
const c = await ownL();
|
|
214
|
+
if (this.scope === DEFAULT_SCOPE) return c;
|
|
215
|
+
// Per-field fallback fills only STRUCTURAL scaffolding (nav/footer) so a
|
|
216
|
+
// partially-adopted tenant still renders. Promotional/identity/legal fields
|
|
217
|
+
// (announcement, headerCtas, disclaimers) are tenant-OWNED and never inherit
|
|
218
|
+
// from the default scope — otherwise e.g. a marketing tenant would show the
|
|
219
|
+
// default store's shipping banner or nicotine disclaimers.
|
|
220
|
+
return {
|
|
221
|
+
nav: c.nav?.length ? c.nav : def.nav,
|
|
222
|
+
footer:
|
|
223
|
+
c.footer && (c.footer.columns?.length || c.footer.social?.length)
|
|
224
|
+
? c.footer
|
|
225
|
+
: def.footer,
|
|
226
|
+
headerCtas: c.headerCtas,
|
|
227
|
+
announcement: c.announcement,
|
|
228
|
+
disclaimer: c.disclaimer,
|
|
229
|
+
disclaimers: c.disclaimers,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async getPage(slug: string): Promise<EditorialPage | null> {
|
|
234
|
+
return (await this.listPages()).find((p) => p.slug === slug) ?? null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async listPages(): Promise<EditorialPage[]> {
|
|
238
|
+
// Editorial pages are NOT cross-tenant: a distinct tenant with no pages of
|
|
239
|
+
// its own returns none rather than inheriting the default scope's pages.
|
|
240
|
+
// (Serving e.g. pilot's privacy/legal copy under another brand would be a
|
|
241
|
+
// content/compliance leak; marketing tenants keep legal pages external.)
|
|
242
|
+
const loaders = pickPages(this.scope);
|
|
243
|
+
return Promise.all(loaders.map((l) => l()));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// StoryblokContentProvider — live delivery API, mapping stories -> our model.
|
|
249
|
+
//
|
|
250
|
+
// Every method tries the Storyblok delivery API and falls back to
|
|
251
|
+
// LocalContentProvider on ANY failure (no token, network error, non-2xx, or a
|
|
252
|
+
// story whose shape doesn't map) — so the site never breaks. The map* fns assume
|
|
253
|
+
// the Storyblok components mirror content-model.ts (same `component`
|
|
254
|
+
// discriminators / shared parent-space schema, per DECISIONS.md). If your space
|
|
255
|
+
// uses different field names, adjust the map* fns — that's the single seam.
|
|
256
|
+
//
|
|
257
|
+
// Config via env: STORYBLOK_REGION (eu|us|ap|ca, default eu),
|
|
258
|
+
// STORYBLOK_VERSION (published|draft, default published).
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
const STORYBLOK_REGION_BASES: Record<string, string> = {
|
|
262
|
+
eu: "https://api.storyblok.com",
|
|
263
|
+
us: "https://api-us.storyblok.com",
|
|
264
|
+
ap: "https://api-ap.storyblok.com",
|
|
265
|
+
ca: "https://api-ca.storyblok.com",
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
function storyblokBase(): string {
|
|
269
|
+
const region = (import.meta.env.STORYBLOK_REGION ?? "eu").toLowerCase();
|
|
270
|
+
return STORYBLOK_REGION_BASES[region] ?? STORYBLOK_REGION_BASES.eu!;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function storyblokVersion(): "published" | "draft" {
|
|
274
|
+
return import.meta.env.STORYBLOK_VERSION === "draft" ? "draft" : "published";
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
interface StoryblokStory {
|
|
278
|
+
slug: string;
|
|
279
|
+
full_slug: string;
|
|
280
|
+
name: string;
|
|
281
|
+
content: Record<string, any>;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Cap Storyblok latency so a slow/unreachable CMS can't hang the Worker — on
|
|
285
|
+
// timeout (or any error) the caller falls back to LocalContentProvider. The
|
|
286
|
+
// storefront must always render. (Epic S4 / S3.)
|
|
287
|
+
const STORYBLOK_TIMEOUT_MS = 1500;
|
|
288
|
+
|
|
289
|
+
async function fetchStoryblokJson<T>(url: string): Promise<T | null> {
|
|
290
|
+
try {
|
|
291
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(STORYBLOK_TIMEOUT_MS) });
|
|
292
|
+
if (!res.ok) return null;
|
|
293
|
+
return (await res.json()) as T;
|
|
294
|
+
} catch {
|
|
295
|
+
// Network error, non-2xx already handled, or AbortSignal timeout.
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function fetchStory(token: string, slug: string): Promise<StoryblokStory | null> {
|
|
301
|
+
const url =
|
|
302
|
+
`${storyblokBase()}/v2/cdn/stories/${slug}` +
|
|
303
|
+
`?token=${encodeURIComponent(token)}&version=${storyblokVersion()}`;
|
|
304
|
+
const json = await fetchStoryblokJson<{ story?: StoryblokStory }>(url);
|
|
305
|
+
return json?.story ?? null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function fetchStories(token: string, startsWith: string): Promise<StoryblokStory[]> {
|
|
309
|
+
const url =
|
|
310
|
+
`${storyblokBase()}/v2/cdn/stories` +
|
|
311
|
+
`?starts_with=${encodeURIComponent(startsWith)}` +
|
|
312
|
+
`&token=${encodeURIComponent(token)}&version=${storyblokVersion()}&per_page=100`;
|
|
313
|
+
const json = await fetchStoryblokJson<{ stories?: StoryblokStory[] }>(url);
|
|
314
|
+
return json?.stories ?? [];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function mapHome(content: Record<string, any>): HomeContent {
|
|
318
|
+
// Storyblok nests the block list under `body` (array of bloks, each with its
|
|
319
|
+
// own `component`); our HomeBlock union uses the same discriminator.
|
|
320
|
+
const blocks = Array.isArray(content.body)
|
|
321
|
+
? content.body
|
|
322
|
+
: Array.isArray(content.blocks)
|
|
323
|
+
? content.blocks
|
|
324
|
+
: [];
|
|
325
|
+
if (blocks.length === 0) throw new Error("storyblok home: no blocks");
|
|
326
|
+
return {
|
|
327
|
+
seoTitle: content.seoTitle ?? content.seo_title,
|
|
328
|
+
seoDescription: content.seoDescription ?? content.seo_description,
|
|
329
|
+
blocks: blocks as HomeContent["blocks"],
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function mapPage(content: Record<string, any>, slug: string): EditorialPage {
|
|
334
|
+
// Expect a rendered-HTML field (text/markdown component). A raw richtext object
|
|
335
|
+
// would need a resolver — we throw so the local fallback serves it instead.
|
|
336
|
+
const body_html = content.body_html ?? content.body;
|
|
337
|
+
if (typeof body_html !== "string") throw new Error(`storyblok page ${slug}: body not HTML`);
|
|
338
|
+
return {
|
|
339
|
+
slug: slug.replace(/^pages\//, ""),
|
|
340
|
+
title: content.title ?? slug,
|
|
341
|
+
seoTitle: content.seoTitle ?? content.seo_title,
|
|
342
|
+
seoDescription: content.seoDescription ?? content.seo_description,
|
|
343
|
+
body_html,
|
|
344
|
+
eyebrow: content.eyebrow,
|
|
345
|
+
intro: content.intro,
|
|
346
|
+
isPolicy: !!content.isPolicy,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export class StoryblokContentProvider implements ContentProvider {
|
|
351
|
+
constructor(
|
|
352
|
+
private readonly token: string,
|
|
353
|
+
private readonly fallback: ContentProvider = new LocalContentProvider(),
|
|
354
|
+
) {}
|
|
355
|
+
|
|
356
|
+
async getHome(): Promise<HomeContent> {
|
|
357
|
+
try {
|
|
358
|
+
const story = await fetchStory(this.token, "home");
|
|
359
|
+
if (story) return mapHome(story.content);
|
|
360
|
+
} catch {
|
|
361
|
+
/* fall back */
|
|
362
|
+
}
|
|
363
|
+
return this.fallback.getHome();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
getHomeHtml(): Promise<string | null> {
|
|
367
|
+
// Raw home documents live in tenant content files, not Storyblok — delegate.
|
|
368
|
+
return this.fallback.getHomeHtml();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
getPageHtml(slug: string): Promise<string | null> {
|
|
372
|
+
return this.fallback.getPageHtml(slug);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
listPageHtmlSlugs(): Promise<string[]> {
|
|
376
|
+
return this.fallback.listPageHtmlSlugs();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
getChromeHtml(): Promise<string | null> {
|
|
380
|
+
// The chrome wrapper lives in tenant content files, not Storyblok — delegate.
|
|
381
|
+
return this.fallback.getChromeHtml();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async getChrome(): Promise<SiteChrome> {
|
|
385
|
+
// Editorial chrome (nav/footer/tagline) is authored in Storyblok; the
|
|
386
|
+
// announcement + legal disclaimers are MIGRATION-sourced (written to local
|
|
387
|
+
// chrome.json), so overlay those from the fallback regardless.
|
|
388
|
+
const local = await this.fallback.getChrome();
|
|
389
|
+
try {
|
|
390
|
+
const story = await fetchStory(this.token, "chrome");
|
|
391
|
+
const c = story?.content;
|
|
392
|
+
if (c && Array.isArray(c.nav)) {
|
|
393
|
+
return {
|
|
394
|
+
nav: c.nav,
|
|
395
|
+
footer: c.footer ?? local.footer,
|
|
396
|
+
announcement: local.announcement ?? c.announcement,
|
|
397
|
+
disclaimer: local.disclaimer,
|
|
398
|
+
disclaimers: local.disclaimers,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
} catch {
|
|
402
|
+
/* fall back */
|
|
403
|
+
}
|
|
404
|
+
return local;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async getPage(slug: string): Promise<EditorialPage | null> {
|
|
408
|
+
try {
|
|
409
|
+
const story = await fetchStory(this.token, `pages/${slug}`);
|
|
410
|
+
if (story) return mapPage(story.content, story.full_slug ?? slug);
|
|
411
|
+
} catch {
|
|
412
|
+
/* fall back */
|
|
413
|
+
}
|
|
414
|
+
return this.fallback.getPage(slug);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async listPages(): Promise<EditorialPage[]> {
|
|
418
|
+
try {
|
|
419
|
+
const stories = await fetchStories(this.token, "pages/");
|
|
420
|
+
const pages = stories
|
|
421
|
+
.map((s) => {
|
|
422
|
+
try {
|
|
423
|
+
return mapPage(s.content, s.full_slug ?? s.slug);
|
|
424
|
+
} catch {
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
})
|
|
428
|
+
.filter((p): p is EditorialPage => p !== null);
|
|
429
|
+
if (pages.length > 0) return pages;
|
|
430
|
+
} catch {
|
|
431
|
+
/* fall back */
|
|
432
|
+
}
|
|
433
|
+
return this.fallback.listPages();
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
// PublishedContentProvider — overlays published customization content for the
|
|
439
|
+
// resolved environment (store->app wiring). For each content artifact it checks
|
|
440
|
+
// the customization store ({live,test} pointer -> versioned JSON); if present it
|
|
441
|
+
// serves that, otherwise it delegates to the base provider (Storyblok/local).
|
|
442
|
+
// listPages() stays from the base (per-page overrides flow through getPage).
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
export interface ContentCustomization {
|
|
446
|
+
get: KvGet;
|
|
447
|
+
env: CustomizationEnv;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export class PublishedContentProvider implements ContentProvider {
|
|
451
|
+
constructor(
|
|
452
|
+
private readonly base: ContentProvider,
|
|
453
|
+
private readonly tenantId: string,
|
|
454
|
+
private readonly cust: ContentCustomization,
|
|
455
|
+
) {}
|
|
456
|
+
|
|
457
|
+
/** Raw published-artifact string for a tenant file, or null to fall back to base. */
|
|
458
|
+
private publishedRaw(relative: string): Promise<string | null> {
|
|
459
|
+
return readPublishedArtifact(
|
|
460
|
+
this.cust.get,
|
|
461
|
+
this.tenantId,
|
|
462
|
+
this.cust.env,
|
|
463
|
+
contentArtifactPath(this.tenantId, relative),
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private async published<T>(relative: string): Promise<T | null> {
|
|
468
|
+
const raw = await this.publishedRaw(relative);
|
|
469
|
+
if (!raw) return null;
|
|
470
|
+
try {
|
|
471
|
+
return JSON.parse(raw) as T;
|
|
472
|
+
} catch {
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async getHome(): Promise<HomeContent> {
|
|
478
|
+
return (await this.published<HomeContent>("home.json")) ?? this.base.getHome();
|
|
479
|
+
}
|
|
480
|
+
// Raw whole-page documents (T0 marketing tenants) publish via KV too, so a
|
|
481
|
+
// vendor edit previews / goes live with NO Worker rebuild. Artifact path mirrors
|
|
482
|
+
// the source file: content/<tenant>/home.html and .../pages-html/<slug>.html.
|
|
483
|
+
async getHomeHtml(): Promise<string | null> {
|
|
484
|
+
return (await this.publishedRaw("home.html")) ?? this.base.getHomeHtml();
|
|
485
|
+
}
|
|
486
|
+
async getPageHtml(slug: string): Promise<string | null> {
|
|
487
|
+
const rel = `pages-html/${normalizeRawPageSlug(slug)}.html`;
|
|
488
|
+
return (await this.publishedRaw(rel)) ?? this.base.getPageHtml(slug);
|
|
489
|
+
}
|
|
490
|
+
listPageHtmlSlugs(): Promise<string[]> {
|
|
491
|
+
return this.base.listPageHtmlSlugs();
|
|
492
|
+
}
|
|
493
|
+
getChromeHtml(): Promise<string | null> {
|
|
494
|
+
// Raw chrome wrapper is bundled tenant content (like home/page HTML) — delegate.
|
|
495
|
+
return this.base.getChromeHtml();
|
|
496
|
+
}
|
|
497
|
+
async getChrome(): Promise<SiteChrome> {
|
|
498
|
+
return (await this.published<SiteChrome>("chrome.json")) ?? this.base.getChrome();
|
|
499
|
+
}
|
|
500
|
+
async getPage(slug: string): Promise<EditorialPage | null> {
|
|
501
|
+
return (await this.published<EditorialPage>(`pages/${slug}.json`)) ?? this.base.getPage(slug);
|
|
502
|
+
}
|
|
503
|
+
listPages(): Promise<EditorialPage[]> {
|
|
504
|
+
return this.base.listPages();
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const PLACEHOLDER_TOKEN_RE = /PLACEHOLDER|^$/i;
|
|
509
|
+
|
|
510
|
+
export function createContentProvider(
|
|
511
|
+
tenant: TenantConfig,
|
|
512
|
+
customization?: ContentCustomization,
|
|
513
|
+
): ContentProvider {
|
|
514
|
+
// Content dirs are keyed by tenant_id (the adopter writes content/<tenant>/).
|
|
515
|
+
const local = new LocalContentProvider(tenant.tenant_id);
|
|
516
|
+
const token = tenant.storyblok_space_token;
|
|
517
|
+
const usable = token && !PLACEHOLDER_TOKEN_RE.test(token);
|
|
518
|
+
const base: ContentProvider = usable
|
|
519
|
+
? new StoryblokContentProvider(token, local)
|
|
520
|
+
: local;
|
|
521
|
+
return customization
|
|
522
|
+
? new PublishedContentProvider(base, tenant.tenant_id, customization)
|
|
523
|
+
: base;
|
|
524
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant routing resolution: Host first, then a `/<domain>/*` path-prefix
|
|
3
|
+
* fallback for hosts that don't resolve to a tenant on their own.
|
|
4
|
+
*
|
|
5
|
+
* Precedence (host-first — matches the prod contract where a custom hostname
|
|
6
|
+
* authoritatively owns a tenant; the path form is a DNS-free escape hatch for
|
|
7
|
+
* localhost/preview):
|
|
8
|
+
*
|
|
9
|
+
* 1. Host strictly claims a tenant -> use it, no base path.
|
|
10
|
+
* 2. Host is unknown AND the first path
|
|
11
|
+
* segment looks like a hostname that
|
|
12
|
+
* strictly claims a tenant -> use it; the segment becomes the
|
|
13
|
+
* base path and is stripped from
|
|
14
|
+
* the rendered route.
|
|
15
|
+
* 3. Otherwise -> registry default (resolveByHost).
|
|
16
|
+
*
|
|
17
|
+
* Example: on localhost (which claims no tenant), GET
|
|
18
|
+
* /store2.tokenoftrust.com/products/foo
|
|
19
|
+
* resolves tenant "store2", basePath "/store2.tokenoftrust.com", and renders
|
|
20
|
+
* the route "/products/foo". A bare localhost/products falls through to the
|
|
21
|
+
* default tenant because "products" isn't a hostname.
|
|
22
|
+
*/
|
|
23
|
+
import type { TenantConfig, TenantResolver } from "@tot/public-runtime";
|
|
24
|
+
|
|
25
|
+
export interface TenantRouting {
|
|
26
|
+
tenant: TenantConfig | null;
|
|
27
|
+
/** "" when host-resolved; "/<domain>" when path-resolved. */
|
|
28
|
+
basePath: string;
|
|
29
|
+
/** Route to render with the tenant prefix stripped (leading slash, no query). */
|
|
30
|
+
renderPath: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A path segment is treated as a tenant-domain candidate only if it looks like
|
|
35
|
+
* a hostname (has a dot, no "@" or other path-ish junk). Real first segments
|
|
36
|
+
* — products, collections, saved, search — never contain a dot, so there is no
|
|
37
|
+
* collision with genuine routes.
|
|
38
|
+
*/
|
|
39
|
+
export function looksLikeHost(segment: string): boolean {
|
|
40
|
+
return (
|
|
41
|
+
segment.length > 0 &&
|
|
42
|
+
segment.includes(".") &&
|
|
43
|
+
!segment.startsWith(".") &&
|
|
44
|
+
!segment.endsWith(".") &&
|
|
45
|
+
!segment.includes("@") &&
|
|
46
|
+
!segment.includes(" ")
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Normalize a path: always leading slash, never a trailing slash (except "/"). */
|
|
51
|
+
function normalizePath(path: string): string {
|
|
52
|
+
const withSlash = path.startsWith("/") ? path : `/${path}`;
|
|
53
|
+
const trimmed = withSlash.replace(/\/+$/, "");
|
|
54
|
+
return trimmed === "" ? "/" : trimmed;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function resolveTenantRouting(
|
|
58
|
+
host: string,
|
|
59
|
+
pathname: string,
|
|
60
|
+
resolver: TenantResolver,
|
|
61
|
+
): Promise<TenantRouting> {
|
|
62
|
+
// 1. Host wins when it explicitly owns a tenant.
|
|
63
|
+
const byHost = await resolver.resolveStrict(host);
|
|
64
|
+
if (byHost) {
|
|
65
|
+
return { tenant: byHost, basePath: "", renderPath: normalizePath(pathname) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 2. Unknown host -> try a /<domain>/* path prefix.
|
|
69
|
+
const segments = pathname.replace(/^\/+/, "").split("/");
|
|
70
|
+
const firstRaw = segments[0] ?? "";
|
|
71
|
+
let first = firstRaw;
|
|
72
|
+
try {
|
|
73
|
+
first = decodeURIComponent(firstRaw);
|
|
74
|
+
} catch {
|
|
75
|
+
/* leave as-is if it isn't valid percent-encoding */
|
|
76
|
+
}
|
|
77
|
+
if (looksLikeHost(first)) {
|
|
78
|
+
const byPath = await resolver.resolveStrict(first);
|
|
79
|
+
if (byPath) {
|
|
80
|
+
return {
|
|
81
|
+
tenant: byPath,
|
|
82
|
+
basePath: `/${first}`,
|
|
83
|
+
renderPath: normalizePath(segments.slice(1).join("/")),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 3. Fall back to the registry default for this (unknown) host.
|
|
89
|
+
const fallback = await resolver.resolveByHost(host);
|
|
90
|
+
return { tenant: fallback, basePath: "", renderPath: normalizePath(pathname) };
|
|
91
|
+
}
|