@tokenoftrust/storefront-runner 2.2.126 → 2.3.1

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 (109) hide show
  1. package/apps/storefront/package.json +3 -1
  2. package/apps/storefront/public/shared/commerce-chrome.css +57 -15
  3. package/apps/storefront/public/shared/commerce-marketing.css +7 -7
  4. package/apps/storefront/src/components/Badge.astro +1 -1
  5. package/apps/storefront/src/components/Breadcrumbs.astro +8 -3
  6. package/apps/storefront/src/components/Section.astro +4 -1
  7. package/apps/storefront/src/components/Seo.astro +7 -5
  8. package/apps/storefront/src/components/blog/BlogIndexView.astro +22 -13
  9. package/apps/storefront/src/components/blog/ShareRow.astro +43 -0
  10. package/apps/storefront/src/components/chrome/AnnouncementBar.astro +1 -1
  11. package/apps/storefront/src/components/chrome/NavDropdown.astro +7 -7
  12. package/apps/storefront/src/components/chrome/SiteFooter.astro +17 -0
  13. package/apps/storefront/src/components/chrome/SiteHeader.astro +5 -5
  14. package/apps/storefront/src/components/commerce/TagFilterCloud.astro +1 -1
  15. package/apps/storefront/src/components/compliance/NicotineWarning.astro +12 -22
  16. package/apps/storefront/src/components/compliance/PactActNotice.astro +9 -10
  17. package/apps/storefront/src/components/content/ProseSections.astro +2 -2
  18. package/apps/storefront/src/components/home/Hero.astro +34 -12
  19. package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +39 -5
  20. package/apps/storefront/src/components/islands/VariantSelector.tsx +3 -3
  21. package/apps/storefront/src/components/marketing/CardGrid.astro +1 -1
  22. package/apps/storefront/src/components/marketing/CodeSample.astro +1 -1
  23. package/apps/storefront/src/components/marketing/CtaBand.astro +1 -1
  24. package/apps/storefront/src/components/marketing/Faq.astro +1 -1
  25. package/apps/storefront/src/components/marketing/Integrations.astro +1 -1
  26. package/apps/storefront/src/components/marketing/MarketingCtas.astro +1 -1
  27. package/apps/storefront/src/components/marketing/MarketingHero.astro +1 -1
  28. package/apps/storefront/src/components/marketing/ProofStrip.astro +1 -1
  29. package/apps/storefront/src/components/marketing/SplitCompare.astro +1 -1
  30. package/apps/storefront/src/components/marketing/Steps.astro +1 -1
  31. package/apps/storefront/src/components/marketing/Testimonials.astro +2 -2
  32. package/apps/storefront/src/components/marketing/TrustBar.astro +1 -1
  33. package/apps/storefront/src/components/membership/TierComparisonTable.astro +1 -1
  34. package/apps/storefront/src/components/plp/ActiveFilters.astro +1 -1
  35. package/apps/storefront/src/components/plp/FacetSidebar.astro +4 -4
  36. package/apps/storefront/src/components/plp/Pagination.astro +1 -1
  37. package/apps/storefront/src/components/plp/SortSelect.astro +1 -1
  38. package/apps/storefront/src/components/style-guide/StyleGuideNav.astro +18 -3
  39. package/apps/storefront/src/components/the-build/TheBuildIndexView.astro +9 -5
  40. package/apps/storefront/src/config/devTenantSeed.ts +23 -34
  41. package/apps/storefront/src/config/resolver.ts +0 -38
  42. package/apps/storefront/src/layouts/Layout.astro +8 -1
  43. package/apps/storefront/src/lib/assets/preview-version-cookie.ts +52 -5
  44. package/apps/storefront/src/lib/blog/pagination.ts +20 -10
  45. package/apps/storefront/src/lib/blog/presentation.ts +94 -0
  46. package/apps/storefront/src/lib/blog/rss.ts +1 -1
  47. package/apps/storefront/src/lib/blog/types.ts +24 -7
  48. package/apps/storefront/src/lib/breadcrumbs.ts +25 -0
  49. package/apps/storefront/src/lib/checkoutCommerce.ts +26 -3
  50. package/apps/storefront/src/lib/chrome/model.ts +4 -0
  51. package/apps/storefront/src/lib/cloudflare-workers.d.ts +53 -0
  52. package/apps/storefront/src/lib/compliance/rawComplianceNotices.ts +38 -18
  53. package/apps/storefront/src/lib/dashboard/delegateGate.ts +28 -0
  54. package/apps/storefront/src/lib/homeVisualParity.ts +8 -5
  55. package/apps/storefront/src/lib/jsonld.ts +5 -5
  56. package/apps/storefront/src/lib/pinnedRouteRewriteBoundary.ts +20 -2
  57. package/apps/storefront/src/lib/rawChrome.ts +74 -12
  58. package/apps/storefront/src/lib/runtimeEnv.ts +1 -1
  59. package/apps/storefront/src/lib/seo/documentTitle.ts +19 -0
  60. package/apps/storefront/src/lib/shared/addToRelease.ts +32 -0
  61. package/apps/storefront/src/lib/storyblok/content-model.ts +3 -0
  62. package/apps/storefront/src/lib/styleGuideThemes.ts +20 -22
  63. package/apps/storefront/src/pages/[...slug].astro +13 -8
  64. package/apps/storefront/src/pages/blog/[slug].astro +56 -24
  65. package/apps/storefront/src/pages/blog/author/[author].astro +17 -9
  66. package/apps/storefront/src/pages/blog/category/[category].astro +18 -10
  67. package/apps/storefront/src/pages/blog/index.astro +2 -0
  68. package/apps/storefront/src/pages/blog/page/[n].astro +2 -0
  69. package/apps/storefront/src/pages/blog/tag/[tag].astro +18 -10
  70. package/apps/storefront/src/pages/collections/[handle].astro +5 -4
  71. package/apps/storefront/src/pages/collections/index.astro +4 -3
  72. package/apps/storefront/src/pages/index.astro +34 -27
  73. package/apps/storefront/src/pages/products/[handle].astro +5 -4
  74. package/apps/storefront/src/pages/saved.astro +4 -3
  75. package/apps/storefront/src/pages/search.astro +4 -3
  76. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +129 -118
  77. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +19 -15
  78. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +23 -27
  79. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -14
  80. package/apps/storefront/src/pages/style-guide/index.astro +6 -5
  81. package/apps/storefront/src/pages/tenants/[id]/[...path].ts +32 -7
  82. package/apps/storefront/src/pages/the-build/[slug].astro +44 -20
  83. package/apps/storefront/src/pages/the-build/index.astro +2 -0
  84. package/apps/storefront/src/pages/the-build/page/[n].astro +2 -0
  85. package/apps/storefront/src/styles/admin.css +47 -0
  86. package/apps/storefront/src/styles/global.css +104 -56
  87. package/apps/storefront/src/themes/derivedTokens.ts +34 -0
  88. package/apps/storefront/src/themes/schema.ts +6 -0
  89. package/apps/storefront/test/pipeline/harness.ts +164 -0
  90. package/apps/storefront/test/pipeline/intake-worker.mjs +14 -0
  91. package/apps/storefront/test/pipeline/pipeline-host.ts +77 -0
  92. package/apps/storefront/test/pipeline/synthetic-job.ts +156 -0
  93. package/apps/storefront/vitest.workers.config.cts +67 -0
  94. package/docs/widget-library-guide.md +1496 -0
  95. package/package.json +1 -1
  96. package/packages/cli/src/declared-config.mjs +400 -5
  97. package/packages/public-runtime/package.json +1 -0
  98. package/packages/public-runtime/src/checkout.ts +13 -0
  99. package/packages/public-runtime/src/chrome.ts +93 -2
  100. package/packages/public-runtime/src/compliance-coherence.d.mts +17 -0
  101. package/packages/public-runtime/src/compliance-coherence.mjs +84 -0
  102. package/packages/public-runtime/src/declared-tenant-config.d.mts +1 -1
  103. package/packages/public-runtime/src/declared-tenant-config.mjs +6 -2
  104. package/packages/public-runtime/src/extension-contract-values.d.mts +4 -1
  105. package/packages/public-runtime/src/extension-contract-values.mjs +14 -1
  106. package/packages/public-runtime/src/extension-contract.ts +2 -0
  107. package/packages/public-runtime/src/tenant.ts +48 -4
  108. package/pnpm-lock.runner.yaml +469 -0
  109. package/scripts/tenant/validate.mjs +27 -1
@@ -1,9 +1,13 @@
1
1
  ---
2
2
  import Layout from "@/layouts/Layout.astro";
3
+ import Breadcrumbs from "@/components/Breadcrumbs.astro";
3
4
  import BlogPaywall from "@/components/blog/BlogPaywall.astro";
4
5
  import ReactionBar from "@/components/blog/ReactionBar.astro";
5
- import { getBlogPost, listBlogPosts, resolveBlogRedirect } from "@/lib/blog/provider.js";
6
- import { authorSlug, getTranslations } from "@/lib/blog/collection.js";
6
+ import ShareRow from "@/components/blog/ShareRow.astro";
7
+ import { getBlogPost, getBlogSettings, listBlogPosts, resolveBlogRedirect } from "@/lib/blog/provider.js";
8
+ import { authorSlug, categorySlug, getTranslations } from "@/lib/blog/collection.js";
9
+ import { bylineAuthor, resolveBlogPresentation, shareLinks } from "@/lib/blog/presentation.js";
10
+ import { homeCrumbLabel, siteTrail } from "@/lib/breadcrumbs.js";
7
11
  import { viewerHasBlogAccess } from "@/lib/blog/access.js";
8
12
  import { articleBodyHtml } from "@/lib/blog/teaser.js";
9
13
  import { readViewerSession, safeNext } from "@/lib/auth/route.js";
@@ -41,16 +45,27 @@ const next = safeNext(`${basePath}/blog/${post.slug}/`);
41
45
  const loginHref = `/auth/login?next=${encodeURIComponent(next)}`;
42
46
 
43
47
  const canonical = new URL(`/blog/${post.slug}/`, canonicalBase + "/").toString();
48
+ const presentation = resolveBlogPresentation(await getBlogSettings(tenant.tenant_id, content));
49
+ // A post that names no author has no byline: never attribute it to anyone (the blog's title, the
50
+ // store) its content does not name.
51
+ const author = bylineAuthor(post.author);
44
52
  // The author archive (`/blog/author/<slug>`) lists posts from the curated blog
45
53
  // index, and 404s for an author with no LISTED post (an unlisted-only or
46
54
  // index-omitted byline). Resolve that up front so the byline links ONLY when the
47
55
  // archive will actually render — a byline must never point at a missing page
48
56
  // (the invariant `blog/author/[author].astro` documents). Same slug + same source
49
- // the archive itself filters on, so the two can't disagree.
50
- const authorArchiveSlug = authorSlug(post.author);
51
- const authorHasArchive = (await listBlogPosts(tenant.tenant_id, content, publicationTimestamp)).some(
57
+ // the archive itself filters on, so the two can't disagree. Tag links follow the
58
+ // same rule against `blog/tag/[tag].astro`.
59
+ const listed = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp);
60
+ const authorArchiveSlug = author ? authorSlug(author) : "";
61
+ const authorHasArchive = authorArchiveSlug !== "" && listed.some(
52
62
  (p) => authorSlug(p.author) === authorArchiveSlug,
53
63
  );
64
+ const archivedTags = new Set(listed.flatMap((p) => p.tags.map(categorySlug)));
65
+ const tagLinks = post.tags.map((tag) => {
66
+ const slug = categorySlug(tag);
67
+ return { label: tag, href: slug && archivedTags.has(slug) ? `${basePath}/blog/tag/${slug}/` : null };
68
+ });
54
69
  const authorUrl = authorHasArchive
55
70
  ? new URL(`/blog/author/${authorArchiveSlug}/`, canonicalBase + "/").toString()
56
71
  : undefined;
@@ -60,15 +75,21 @@ const articleLd = blogPostingLd(post, {
60
75
  publisherName: tenant.displayName,
61
76
  authorUrl,
62
77
  });
63
- const crumbs = breadcrumbLd([
64
- { label: tenant.displayName, href: "/" },
78
+ const trail = siteTrail(
79
+ homeCrumbLabel(await content.getChrome()),
65
80
  { label: "Blog", href: "/blog/" },
66
81
  { label: post.title, href: `/blog/${post.slug}/` },
67
- ], canonicalBase);
82
+ );
83
+ const crumbs = breadcrumbLd(trail, canonicalBase);
68
84
  const blogHref = `${basePath}/blog/`;
69
85
  const authorHref = authorHasArchive
70
86
  ? `${basePath}/blog/author/${authorArchiveSlug}/`
71
87
  : null;
88
+ const share = shareLinks(presentation.share, {
89
+ url: canonical,
90
+ title: post.title,
91
+ image: post.featuredImage ? new URL(post.featuredImage.src, canonicalBase + "/").toString() : undefined,
92
+ });
72
93
 
73
94
  // I2 — hreflang alternates + per-variant <html lang>. Singleton ⇒ [] alternates
74
95
  // and no `lang` override, so a non-translated post is byte-identical to today.
@@ -104,16 +125,16 @@ const lang = alternates.length ? (post.inLanguage ?? "en") : undefined;
104
125
  lang={lang}
105
126
  >
106
127
  <article class="article-shell">
107
- <nav class="breadcrumbs" aria-label="Breadcrumb"><a href={blogHref}>Blog</a><span aria-hidden="true">/</span><span>{post.categories[0] ?? "Article"}</span></nav>
128
+ <Breadcrumbs crumbs={trail} class="breadcrumbs" />
108
129
  <header>
109
- {post.tags[0] && <p class="eyebrow eyebrow--tick">{post.tags[0]}</p>}
130
+ {presentation.postEyebrow && post.tags[0] && <p class="eyebrow eyebrow--tick">{post.tags[0]}</p>}
110
131
  <h1>{post.title}</h1>
111
- <p class="dek">{post.excerpt}</p>
132
+ {presentation.dek && post.excerpt && <p class="dek">{post.excerpt}</p>}
112
133
  <div class="byline">
113
- <span>By {authorHref
114
- ? <a href={authorHref} class="byline-author" rel="author">{post.author}</a>
115
- : <span class="byline-author">{post.author}</span>}</span>
116
- <span aria-hidden="true">·</span>
134
+ {author && <span>By {authorHref
135
+ ? <a href={authorHref} class="byline-author" rel="author">{author}</a>
136
+ : <span class="byline-author">{author}</span>}</span>}
137
+ {author && <span aria-hidden="true">·</span>}
117
138
  <time datetime={post.publishedAt}>{new Date(post.publishedAt).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}</time>
118
139
  </div>
119
140
  {translations.length > 1 && (
@@ -143,26 +164,34 @@ const lang = alternates.length ? (post.inLanguage ?? "en") : undefined;
143
164
  decoding="async"
144
165
  />
145
166
  )}
146
- <div class="article-grid">
147
- <aside>
148
- <a href={blogHref}>← All articles</a>
149
- {post.tags.length > 0 && <div class="article-tags">{post.tags.map((tag) => <span>{tag}</span>)}</div>}
150
- </aside>
167
+ <div class:list={["article-grid", { "article-grid--single": !presentation.aside }]}>
168
+ {presentation.aside && (
169
+ <aside>
170
+ <a href={blogHref}>← All articles</a>
171
+ {tagLinks.length > 0 && <div class="article-tags">{tagLinks.map((tag) => tag.href ? <a href={tag.href}>{tag.label}</a> : <span>{tag.label}</span>)}</div>}
172
+ </aside>
173
+ )}
151
174
  <div class="blog-main">
152
175
  <div class="blog-body" set:html={bodyHtml}></div>
153
176
  {gated && !unlock && (
154
177
  <BlogPaywall access={post.access ?? "authenticated"} next={next} signedIn={!!viewer} loginHref={loginHref} />
155
178
  )}
179
+ {!presentation.aside && tagLinks.length > 0 && (
180
+ <ul class="post-tags" aria-label="Tags">
181
+ {tagLinks.map((tag) => <li>{tag.href ? <a href={tag.href}>{tag.label}</a> : <span>{tag.label}</span>}</li>)}
182
+ </ul>
183
+ )}
184
+ <ShareRow links={share} />
156
185
  </div>
157
186
  </div>
158
- <ReactionBar collection="blog" slug={post.slug} />
187
+ {presentation.reactions && <ReactionBar collection="blog" slug={post.slug} />}
159
188
  </article>
160
189
  </Layout>
161
190
 
162
191
  <style>
163
192
  .article-shell { width: min(1120px, calc(100% - 2rem)); margin-inline: auto; padding-block: clamp(2rem, 5vw, 5rem); }
164
- .breadcrumbs { display: flex; gap: .55rem; align-items: center; color: var(--color-muted); font-size: .84rem; }
165
- .breadcrumbs a { color: var(--color-accent-ink); font-weight: 700; }
193
+ .article-shell :global(.breadcrumbs) { font-size: .84rem; }
194
+ .article-shell :global(.breadcrumbs a) { color: var(--color-accent-ink); font-weight: 700; }
166
195
  header { max-width: 880px; margin: clamp(2.5rem, 6vw, 5rem) auto clamp(2rem, 5vw, 3.5rem); text-align: center; }
167
196
  header .eyebrow { justify-content: center; }
168
197
  h1 { margin-top: .9rem; font-size: clamp(2.5rem, 6vw, 5rem); line-height: 1.02; }
@@ -178,8 +207,11 @@ const lang = alternates.length ? (post.inLanguage ?? "en") : undefined;
178
207
  .article-grid { display: grid; grid-template-columns: 190px minmax(0, 720px); justify-content: center; gap: clamp(2rem, 6vw, 5rem); margin-top: clamp(3rem, 7vw, 5.5rem); }
179
208
  aside { align-self: start; position: sticky; top: 7rem; }
180
209
  aside > a { color: var(--color-accent-ink); font-size: .88rem; font-weight: 700; }
210
+ .article-grid--single { grid-template-columns: minmax(0, 720px); }
181
211
  .article-tags { display: flex; flex-wrap: wrap; gap: .45rem; margin-top: 1.2rem; }
182
- .article-tags span { padding: .3rem .55rem; border: 1px solid var(--color-border); border-radius: var(--radius-full); color: var(--color-muted); font-size: .7rem; }
212
+ .article-tags > *, .post-tags :is(a, span) { padding: .3rem .55rem; border: 1px solid var(--color-border); border-radius: var(--radius-full); color: var(--color-muted); font-size: .7rem; text-decoration: none; }
213
+ .article-tags a:hover, .post-tags a:hover { color: var(--color-accent-ink); border-color: var(--color-accent); }
214
+ .post-tags { display: flex; flex-wrap: wrap; gap: .45rem; margin: 2rem 0 0; padding: 0; list-style: none; }
183
215
  .blog-main { min-width: 0; }
184
216
  .blog-body { min-width: 0; font-size: 1.05rem; line-height: 1.78; }
185
217
  .blog-body :global(p), .blog-body :global(ul), .blog-body :global(ol), .blog-body :global(blockquote), .blog-body :global(figure), .blog-body :global(table), .blog-body :global(pre) { margin-block: 1.25rem; }
@@ -1,7 +1,10 @@
1
1
  ---
2
2
  import Layout from "@/layouts/Layout.astro";
3
+ import Breadcrumbs from "@/components/Breadcrumbs.astro";
3
4
  import PostAccessBadge from "@/components/blog/PostAccessBadge.astro";
4
- import { listBlogPosts } from "@/lib/blog/provider.js";
5
+ import { getBlogSettings, listBlogPosts } from "@/lib/blog/provider.js";
6
+ import { resolveBlogPresentation } from "@/lib/blog/presentation.js";
7
+ import { homeCrumbLabel, siteTrail } from "@/lib/breadcrumbs.js";
5
8
  import { authorSlug, getAuthors } from "@/lib/blog/collection.js";
6
9
  import { breadcrumbLd } from "@/lib/jsonld.js";
7
10
 
@@ -12,7 +15,7 @@ const { tenant, basePath, canonicalBase, content, publicationTimestamp } = Astro
12
15
  const slug = Astro.params.author ?? "";
13
16
 
14
17
  const allPosts = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp);
15
- const posts = allPosts.filter((post) => authorSlug(post.author) === slug);
18
+ const posts = slug ? allPosts.filter((post) => authorSlug(post.author) === slug) : [];
16
19
  if (posts.length === 0) return new Response(null, { status: 404 });
17
20
 
18
21
  const authorName = posts[0]!.author;
@@ -49,11 +52,13 @@ const itemListLd = {
49
52
  url: new URL(`/blog/${post.slug}/`, canonicalBase + "/").toString(),
50
53
  })),
51
54
  };
52
- const crumbs = breadcrumbLd([
53
- { label: tenant.displayName, href: "/" },
55
+ const presentation = resolveBlogPresentation(await getBlogSettings(tenant.tenant_id, content));
56
+ const trail = siteTrail(
57
+ homeCrumbLabel(await content.getChrome()),
54
58
  { label: "Blog", href: "/blog/" },
55
59
  { label: authorName, href: `/blog/author/${slug}/` },
56
- ], canonicalBase);
60
+ );
61
+ const crumbs = breadcrumbLd(trail, canonicalBase);
57
62
  ---
58
63
 
59
64
  <Layout
@@ -63,9 +68,9 @@ const crumbs = breadcrumbLd([
63
68
  fullBleed
64
69
  jsonLd={[crumbs, profileLd, itemListLd]}
65
70
  >
66
- <header class="author-hero">
71
+ <header class:list={["author-hero", { "author-hero--plain": !presentation.hero }]}>
67
72
  <div class="blog-shell">
68
- <nav class="breadcrumbs" aria-label="Breadcrumb"><a href={href("/blog/")}>Blog</a><span aria-hidden="true">/</span><span>Author</span></nav>
73
+ <Breadcrumbs crumbs={trail} class="breadcrumbs" />
69
74
  <div class="author-id">
70
75
  {avatarUrl && <img class="avatar" src={avatarUrl} alt={authorName} width="96" height="96" decoding="async" />}
71
76
  <div>
@@ -115,8 +120,11 @@ const crumbs = breadcrumbLd([
115
120
  <style>
116
121
  .blog-shell { width: min(1180px, calc(100% - 2rem)); margin-inline: auto; }
117
122
  .author-hero { padding: clamp(3.5rem, 8vw, 6rem) 0 clamp(2rem, 5vw, 3.5rem); background: linear-gradient(145deg, var(--color-primary), color-mix(in srgb, var(--color-primary) 82%, var(--color-accent))); color: var(--color-primary-contrast); }
118
- .breadcrumbs { display: flex; gap: .55rem; align-items: center; font-size: .84rem; color: color-mix(in srgb, var(--color-primary-contrast) 78%, transparent); }
119
- .breadcrumbs a { color: inherit; font-weight: 700; }
123
+ .author-hero :global(.breadcrumbs) { font-size: .84rem; }
124
+ .author-hero:not(.author-hero--plain) :global(.breadcrumbs), .author-hero:not(.author-hero--plain) :global(.breadcrumbs *) { color: color-mix(in srgb, var(--color-primary-contrast) 78%, transparent); }
125
+ .author-hero :global(.breadcrumbs a) { font-weight: 700; }
126
+ .author-hero--plain { padding: clamp(2rem, 5vw, 3.5rem) 0 0; background: none; color: inherit; }
127
+ .author-hero--plain :is(.eyebrow, .author-title, .author-bio) { color: var(--color-muted); }
120
128
  .author-id { display: flex; gap: 1.5rem; align-items: center; margin-top: 1.5rem; }
121
129
  .avatar { flex: 0 0 auto; width: 96px; height: 96px; border-radius: var(--radius-full); object-fit: cover; box-shadow: var(--shadow-md); }
122
130
  .author-hero h1 { margin-top: .5rem; color: inherit; font-size: clamp(2.4rem, 6vw, 4.2rem); }
@@ -1,8 +1,11 @@
1
1
  ---
2
2
  import Layout from "@/layouts/Layout.astro";
3
+ import Breadcrumbs from "@/components/Breadcrumbs.astro";
3
4
  import PostGrid from "@/components/blog/PostGrid.astro";
4
- import { listBlogPosts } from "@/lib/blog/provider.js";
5
+ import { getBlogSettings, listBlogPosts } from "@/lib/blog/provider.js";
5
6
  import { collectPostsByTerm } from "@/lib/blog/collection.js";
7
+ import { resolveBlogPresentation } from "@/lib/blog/presentation.js";
8
+ import { homeCrumbLabel, siteTrail } from "@/lib/breadcrumbs.js";
6
9
  import { breadcrumbLd } from "@/lib/jsonld.js";
7
10
 
8
11
  const { tenant, basePath, canonicalBase, content, publicationTimestamp } = Astro.locals;
@@ -14,6 +17,7 @@ const all = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp)
14
17
  const match = collectPostsByTerm(all, "categories", param);
15
18
  if (!match) return new Response(null, { status: 404 });
16
19
  const { label, posts } = match;
20
+ const presentation = resolveBlogPresentation(await getBlogSettings(tenant.tenant_id, content));
17
21
 
18
22
  const path = `/blog/category/${param}/`;
19
23
  const itemList = {
@@ -27,11 +31,12 @@ const itemList = {
27
31
  url: new URL(`/blog/${post.slug}/`, canonicalBase + "/").toString(),
28
32
  })),
29
33
  };
30
- const crumbs = breadcrumbLd([
31
- { label: tenant.displayName, href: "/" },
34
+ const trail = siteTrail(
35
+ homeCrumbLabel(await content.getChrome()),
32
36
  { label: "Blog", href: "/blog/" },
33
37
  { label, href: path },
34
- ], canonicalBase);
38
+ );
39
+ const crumbs = breadcrumbLd(trail, canonicalBase);
35
40
  ---
36
41
 
37
42
  <Layout
@@ -41,12 +46,12 @@ const crumbs = breadcrumbLd([
41
46
  fullBleed
42
47
  jsonLd={[crumbs, itemList]}
43
48
  >
44
- <header class="blog-hero">
49
+ <header class:list={["blog-hero", { "blog-hero--plain": !presentation.hero }]}>
45
50
  <div class="blog-shell">
46
- <nav class="breadcrumbs" aria-label="Breadcrumb"><a href={`${basePath}/blog/`}>Blog</a><span aria-hidden="true">/</span><span>Category</span></nav>
51
+ <Breadcrumbs crumbs={trail} class="breadcrumbs" />
47
52
  <p class="eyebrow eyebrow--tick">Category</p>
48
53
  <h1>{label}</h1>
49
- <p>{posts.length} {posts.length === 1 ? "article" : "articles"} in this category.</p>
54
+ <p class="blog-hero__tagline">{posts.length} {posts.length === 1 ? "article" : "articles"} in this category.</p>
50
55
  </div>
51
56
  </header>
52
57
 
@@ -58,10 +63,13 @@ const crumbs = breadcrumbLd([
58
63
  <style>
59
64
  .blog-shell { width: min(1180px, calc(100% - 2rem)); margin-inline: auto; }
60
65
  .blog-hero { padding: clamp(4.5rem, 9vw, 8rem) 0 clamp(3rem, 6vw, 5rem); background: linear-gradient(145deg, var(--color-primary), color-mix(in srgb, var(--color-primary) 82%, var(--color-accent))); color: var(--color-primary-contrast); }
61
- .breadcrumbs { display: flex; gap: .55rem; align-items: center; margin-bottom: 1rem; color: color-mix(in srgb, var(--color-primary-contrast) 78%, transparent); font-size: .84rem; }
62
- .breadcrumbs a { color: inherit; font-weight: 700; }
66
+ .blog-hero :global(.breadcrumbs) { margin-bottom: 1rem; font-size: .84rem; }
67
+ .blog-hero:not(.blog-hero--plain) :global(.breadcrumbs), .blog-hero:not(.blog-hero--plain) :global(.breadcrumbs *) { color: color-mix(in srgb, var(--color-primary-contrast) 78%, transparent); }
68
+ .blog-hero :global(.breadcrumbs a) { font-weight: 700; }
63
69
  .blog-hero h1 { max-width: 14ch; margin-top: .75rem; color: inherit; font-size: clamp(2.6rem, 6vw, 4.6rem); }
64
- .blog-hero p:last-child { max-width: 58ch; margin-top: 1rem; color: color-mix(in srgb, var(--color-primary-contrast) 82%, transparent); font-size: 1.05rem; }
70
+ .blog-hero__tagline { max-width: 58ch; margin-top: 1rem; color: color-mix(in srgb, var(--color-primary-contrast) 82%, transparent); font-size: 1.05rem; }
65
71
  .blog-hero .eyebrow { color: color-mix(in srgb, var(--color-primary-contrast) 75%, transparent); }
72
+ .blog-hero--plain { padding: clamp(2rem, 5vw, 3.5rem) 0 0; background: none; color: inherit; }
73
+ .blog-hero--plain .blog-hero__tagline, .blog-hero--plain .eyebrow { color: var(--color-muted); }
66
74
  .blog-layout { padding-block: clamp(3rem, 7vw, 6rem); }
67
75
  </style>
@@ -2,6 +2,7 @@
2
2
  import BlogIndexView from "@/components/blog/BlogIndexView.astro";
3
3
  import { getBlogSettings, listBlogPosts } from "@/lib/blog/provider.js";
4
4
  import { paginateBlogPosts, resolveBlogIndexCopy } from "@/lib/blog/pagination.js";
5
+ import { resolveBlogPresentation } from "@/lib/blog/presentation.js";
5
6
 
6
7
  const { tenant, basePath, canonicalBase, content, publicationTimestamp } = Astro.locals;
7
8
  const posts = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp);
@@ -28,6 +29,7 @@ const tags = [...new Set(posts.flatMap((post) => post.tags))].sort((a, b) => a.l
28
29
  basePath={basePath}
29
30
  canonicalBase={canonicalBase}
30
31
  copy={copy}
32
+ presentation={resolveBlogPresentation(settings)}
31
33
  tags={tags}
32
34
  pagePosts={pagePosts}
33
35
  page={1}
@@ -5,6 +5,7 @@
5
5
  import BlogIndexView from "@/components/blog/BlogIndexView.astro";
6
6
  import { getBlogSettings, listBlogPosts } from "@/lib/blog/provider.js";
7
7
  import { paginateBlogPosts, parseBlogPageParam, resolveBlogIndexCopy } from "@/lib/blog/pagination.js";
8
+ import { resolveBlogPresentation } from "@/lib/blog/presentation.js";
8
9
 
9
10
  const page = parseBlogPageParam(Astro.params.n);
10
11
  if (page === null) return new Response(null, { status: 404 });
@@ -32,6 +33,7 @@ const tags = [...new Set(posts.flatMap((post) => post.tags))].sort((a, b) => a.l
32
33
  basePath={basePath}
33
34
  canonicalBase={canonicalBase}
34
35
  copy={copy}
36
+ presentation={resolveBlogPresentation(settings)}
35
37
  tags={tags}
36
38
  pagePosts={sliced.posts}
37
39
  page={sliced.page}
@@ -1,8 +1,11 @@
1
1
  ---
2
2
  import Layout from "@/layouts/Layout.astro";
3
+ import Breadcrumbs from "@/components/Breadcrumbs.astro";
3
4
  import PostGrid from "@/components/blog/PostGrid.astro";
4
- import { listBlogPosts } from "@/lib/blog/provider.js";
5
+ import { getBlogSettings, listBlogPosts } from "@/lib/blog/provider.js";
5
6
  import { collectPostsByTerm } from "@/lib/blog/collection.js";
7
+ import { resolveBlogPresentation } from "@/lib/blog/presentation.js";
8
+ import { homeCrumbLabel, siteTrail } from "@/lib/breadcrumbs.js";
6
9
  import { breadcrumbLd } from "@/lib/jsonld.js";
7
10
 
8
11
  const { tenant, basePath, canonicalBase, content, publicationTimestamp } = Astro.locals;
@@ -12,6 +15,7 @@ const all = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp)
12
15
  const match = collectPostsByTerm(all, "tags", param);
13
16
  if (!match) return new Response(null, { status: 404 });
14
17
  const { label, posts } = match;
18
+ const presentation = resolveBlogPresentation(await getBlogSettings(tenant.tenant_id, content));
15
19
 
16
20
  const path = `/blog/tag/${param}/`;
17
21
  const itemList = {
@@ -25,11 +29,12 @@ const itemList = {
25
29
  url: new URL(`/blog/${post.slug}/`, canonicalBase + "/").toString(),
26
30
  })),
27
31
  };
28
- const crumbs = breadcrumbLd([
29
- { label: tenant.displayName, href: "/" },
32
+ const trail = siteTrail(
33
+ homeCrumbLabel(await content.getChrome()),
30
34
  { label: "Blog", href: "/blog/" },
31
35
  { label: `#${label}`, href: path },
32
- ], canonicalBase);
36
+ );
37
+ const crumbs = breadcrumbLd(trail, canonicalBase);
33
38
  ---
34
39
 
35
40
  <Layout
@@ -39,12 +44,12 @@ const crumbs = breadcrumbLd([
39
44
  fullBleed
40
45
  jsonLd={[crumbs, itemList]}
41
46
  >
42
- <header class="blog-hero">
47
+ <header class:list={["blog-hero", { "blog-hero--plain": !presentation.hero }]}>
43
48
  <div class="blog-shell">
44
- <nav class="breadcrumbs" aria-label="Breadcrumb"><a href={`${basePath}/blog/`}>Blog</a><span aria-hidden="true">/</span><span>Tag</span></nav>
49
+ <Breadcrumbs crumbs={trail} class="breadcrumbs" />
45
50
  <p class="eyebrow eyebrow--tick">Tag</p>
46
51
  <h1>#{label}</h1>
47
- <p>{posts.length} {posts.length === 1 ? "article" : "articles"} with this tag.</p>
52
+ <p class="blog-hero__tagline">{posts.length} {posts.length === 1 ? "article" : "articles"} with this tag.</p>
48
53
  </div>
49
54
  </header>
50
55
 
@@ -56,10 +61,13 @@ const crumbs = breadcrumbLd([
56
61
  <style>
57
62
  .blog-shell { width: min(1180px, calc(100% - 2rem)); margin-inline: auto; }
58
63
  .blog-hero { padding: clamp(4.5rem, 9vw, 8rem) 0 clamp(3rem, 6vw, 5rem); background: linear-gradient(145deg, var(--color-primary), color-mix(in srgb, var(--color-primary) 82%, var(--color-accent))); color: var(--color-primary-contrast); }
59
- .breadcrumbs { display: flex; gap: .55rem; align-items: center; margin-bottom: 1rem; color: color-mix(in srgb, var(--color-primary-contrast) 78%, transparent); font-size: .84rem; }
60
- .breadcrumbs a { color: inherit; font-weight: 700; }
64
+ .blog-hero :global(.breadcrumbs) { margin-bottom: 1rem; font-size: .84rem; }
65
+ .blog-hero:not(.blog-hero--plain) :global(.breadcrumbs), .blog-hero:not(.blog-hero--plain) :global(.breadcrumbs *) { color: color-mix(in srgb, var(--color-primary-contrast) 78%, transparent); }
66
+ .blog-hero :global(.breadcrumbs a) { font-weight: 700; }
61
67
  .blog-hero h1 { max-width: 14ch; margin-top: .75rem; color: inherit; font-size: clamp(2.6rem, 6vw, 4.6rem); }
62
- .blog-hero p:last-child { max-width: 58ch; margin-top: 1rem; color: color-mix(in srgb, var(--color-primary-contrast) 82%, transparent); font-size: 1.05rem; }
68
+ .blog-hero__tagline { max-width: 58ch; margin-top: 1rem; color: color-mix(in srgb, var(--color-primary-contrast) 82%, transparent); font-size: 1.05rem; }
63
69
  .blog-hero .eyebrow { color: color-mix(in srgb, var(--color-primary-contrast) 75%, transparent); }
70
+ .blog-hero--plain { padding: clamp(2rem, 5vw, 3.5rem) 0 0; background: none; color: inherit; }
71
+ .blog-hero--plain .blog-hero__tagline, .blog-hero--plain .eyebrow { color: var(--color-muted); }
64
72
  .blog-layout { padding-block: clamp(3rem, 7vw, 6rem); }
65
73
  </style>
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import Layout from "@/layouts/Layout.astro";
9
9
  import Breadcrumbs from "@/components/Breadcrumbs.astro";
10
+ import { homeCrumbLabel, siteTrail } from "@/lib/breadcrumbs";
10
11
  import ProductCard from "@/components/ProductCard.astro";
11
12
  import EmptyState from "@/components/EmptyState.astro";
12
13
  import FacetSidebar from "@/components/plp/FacetSidebar.astro";
@@ -48,10 +49,10 @@ const basePath = `/collections/${handle}`;
48
49
  // breadcrumbs, and EmptyState — those prefix on their own via withBase.
49
50
  const linkBase = withBase(Astro.locals.basePath, basePath);
50
51
 
51
- const crumbs = [
52
- { label: tenant.displayName, href: "/" },
52
+ const crumbs = siteTrail(
53
+ homeCrumbLabel(await Astro.locals.content.getChrome()),
53
54
  { label: collection.title, href: basePath },
54
- ];
55
+ );
55
56
 
56
57
  const heroImg = collection.image;
57
58
  const desc = collection.description_text || collection.description_html?.replace(/<[^>]+>/g, "");
@@ -93,7 +94,7 @@ if (query.priceMin != null || query.priceMax != null) {
93
94
  <header class="bg-[var(--color-primary)] text-[var(--color-primary-contrast)]">
94
95
  <div class="container-prose grid gap-6 py-[clamp(2.5rem,6vw,4.5rem)] md:grid-cols-[1fr_auto] md:items-end">
95
96
  <div data-reveal>
96
- <p class="eyebrow mb-3 !text-[color-mix(in_srgb,var(--color-primary-contrast)_70%,transparent)]">
97
+ <p class="eyebrow mb-3 text-[color-mix(in_srgb,var(--color-primary-contrast)_70%,transparent)]">
97
98
  Collection
98
99
  </p>
99
100
  <h1 class="display-xl">
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import Layout from "@/layouts/Layout.astro";
7
7
  import Breadcrumbs from "@/components/Breadcrumbs.astro";
8
+ import { homeCrumbLabel, siteTrail } from "@/lib/breadcrumbs";
8
9
  import CollectionCard from "@/components/CollectionCard.astro";
9
10
  import EmptyState from "@/components/EmptyState.astro";
10
11
  import { breadcrumbLd } from "@/lib/jsonld";
@@ -22,10 +23,10 @@ const [allCollections, activeProducts] = await Promise.all([
22
23
  ]);
23
24
  const collections = nonEmptyCollections(allCollections, activeProducts);
24
25
 
25
- const crumbs = [
26
- { label: tenant.displayName, href: "/" },
26
+ const crumbs = siteTrail(
27
+ homeCrumbLabel(await Astro.locals.content.getChrome()),
27
28
  { label: "Collections", href: "/collections" },
28
- ];
29
+ );
29
30
  ---
30
31
 
31
32
  <Layout
@@ -67,7 +67,9 @@ if (rawHtml && rawPolicy.allowed) {
67
67
  // verbatim (backward-compatible).
68
68
  const chromeWrapper = await content.getChromeHtml("/");
69
69
  const notices = {
70
- nicotineWarning: !!tenant.compliance?.nicotineWarning,
70
+ nicotineWarning: tenant.compliance?.nicotineWarning,
71
+ pactAct: !!tenant.compliance?.pactAct,
72
+ minAge: tenant.compliance?.minAge,
71
73
  shippingRestriction: tenant.compliance?.shippingRestriction,
72
74
  exciseTaxEnabled: capabilities.exciseTax.enabled && features.catalog,
73
75
  };
@@ -173,7 +175,8 @@ const homeSectionWidgets = marketing
173
175
  ---
174
176
 
175
177
  <Layout
176
- title={home.seoTitle ?? tenant.displayName}
178
+ title={tenant.displayName}
179
+ seoTitle={home.seoTitle}
177
180
  description={home.seoDescription}
178
181
  path="/"
179
182
  ogType="website"
@@ -226,13 +229,15 @@ const homeSectionWidgets = marketing
226
229
  const b = r.block;
227
230
  const media = editorialMediaView(b, tenant.tenant_id);
228
231
  const right = b.align !== "left";
232
+ const textOnly = media.kind === "none";
229
233
  return (
230
234
  <Section tone="bg">
231
235
  <div
232
236
  class:list={[
233
- "grid items-center gap-10 md:grid-cols-2",
234
- right ? "" : "md:[&>*:first-child]:order-2",
237
+ textOnly ? "max-w-[68ch]" : "grid items-center gap-10 md:grid-cols-2",
238
+ textOnly || right ? "" : "md:[&>*:first-child]:order-2",
235
239
  ]}
240
+ data-editorial-layout={textOnly ? "text" : "media"}
236
241
  >
237
242
  <div data-reveal>
238
243
  {b.eyebrow && <p class="eyebrow eyebrow--tick mb-4">{b.eyebrow}</p>}
@@ -251,30 +256,32 @@ const homeSectionWidgets = marketing
251
256
  </div>
252
257
  )}
253
258
  </div>
254
- <div data-reveal>
255
- <div class="overflow-hidden rounded-[var(--radius-lg)] shadow-[var(--shadow-md)]">
256
- {media.kind === "image" ? (
257
- <img
258
- src={media.src}
259
- alt={media.alt}
260
- width="1000"
261
- height="750"
262
- loading="lazy"
263
- class="h-full w-full object-cover"
264
- />
265
- ) : (
266
- <Img
267
- seed={media.seed}
268
- label={media.label}
269
- alt=""
270
- width={1000}
271
- height={750}
272
- sizes="(min-width:768px) 48vw, 100vw"
273
- class="h-full w-full object-cover"
274
- />
275
- )}
259
+ {media.kind !== "none" && (
260
+ <div data-reveal>
261
+ <div class="overflow-hidden rounded-[var(--radius-lg)] shadow-[var(--shadow-md)]">
262
+ {media.kind === "image" ? (
263
+ <img
264
+ src={media.src}
265
+ alt={media.alt}
266
+ width="1000"
267
+ height="750"
268
+ loading="lazy"
269
+ class="h-full w-full object-cover"
270
+ />
271
+ ) : (
272
+ <Img
273
+ seed={media.seed}
274
+ label={media.label}
275
+ alt=""
276
+ width={1000}
277
+ height={750}
278
+ sizes="(min-width:768px) 48vw, 100vw"
279
+ class="h-full w-full object-cover"
280
+ />
281
+ )}
282
+ </div>
276
283
  </div>
277
- </div>
284
+ )}
278
285
  </div>
279
286
  </Section>
280
287
  );
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import Layout from "@/layouts/Layout.astro";
9
9
  import Breadcrumbs from "@/components/Breadcrumbs.astro";
10
+ import { homeCrumbLabel, siteTrail } from "@/lib/breadcrumbs";
10
11
  import Badge from "@/components/Badge.astro";
11
12
  import ProductCard from "@/components/ProductCard.astro";
12
13
  import Section from "@/components/Section.astro";
@@ -95,13 +96,13 @@ const related = await (async () => {
95
96
  return [...out.values()].slice(0, 4);
96
97
  })();
97
98
 
98
- const crumbs = [
99
- { label: tenant.displayName, href: "/" },
99
+ const crumbs = siteTrail(
100
+ homeCrumbLabel(await Astro.locals.content.getChrome()),
100
101
  ...(product.collections[0]
101
102
  ? [{ label: titleCase(product.collections[0]), href: `/collections/${product.collections[0]}` }]
102
103
  : []),
103
104
  { label: product.title, href: `/products/${product.handle}` },
104
- ];
105
+ );
105
106
 
106
107
  function titleCase(s: string) {
107
108
  return s.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
@@ -343,7 +344,7 @@ const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
343
344
 
344
345
  {compliance?.nicotineWarning && (
345
346
  <div class="mt-6">
346
- <NicotineWarning variant="block" />
347
+ <NicotineWarning variant="block" declaration={compliance.nicotineWarning} />
347
348
  </div>
348
349
  )}
349
350
 
@@ -5,15 +5,16 @@
5
5
  */
6
6
  import Layout from "@/layouts/Layout.astro";
7
7
  import Breadcrumbs from "@/components/Breadcrumbs.astro";
8
+ import { homeCrumbLabel, siteTrail } from "@/lib/breadcrumbs";
8
9
  import SavedList from "@/components/islands/SavedList.tsx";
9
10
 
10
11
  const { tenant } = Astro.locals;
11
12
  // Commerce-only route: marketing tenants have no saved-items surface.
12
13
  if (!Astro.locals.features.savedItems) return new Response(null, { status: 404 });
13
- const crumbs = [
14
- { label: tenant.displayName, href: "/" },
14
+ const crumbs = siteTrail(
15
+ homeCrumbLabel(await Astro.locals.content.getChrome()),
15
16
  { label: "Saved", href: "/saved" },
16
- ];
17
+ );
17
18
  ---
18
19
 
19
20
  <Layout
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import Layout from "@/layouts/Layout.astro";
13
13
  import Breadcrumbs from "@/components/Breadcrumbs.astro";
14
+ import { homeCrumbLabel, siteTrail } from "@/lib/breadcrumbs";
14
15
  import ProductCard from "@/components/ProductCard.astro";
15
16
  import EmptyState from "@/components/EmptyState.astro";
16
17
  import Section from "@/components/Section.astro";
@@ -42,10 +43,10 @@ if (isJson) {
42
43
  const results = q ? await tot.search(q, 24) : [];
43
44
  const featured = q ? [] : await tot.getFeatured(8);
44
45
 
45
- const crumbs = [
46
- { label: tenant.displayName, href: "/" },
46
+ const crumbs = siteTrail(
47
+ homeCrumbLabel(await Astro.locals.content.getChrome()),
47
48
  { label: "Search", href: "/search" },
48
- ];
49
+ );
49
50
  ---
50
51
 
51
52
  <Layout