@tokenoftrust/storefront-runner 2.4.72 → 2.4.74
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/apps/storefront/src/components/blog/BlogIndexView.astro +6 -2
- package/apps/storefront/src/components/blog/BlogPostArticle.astro +146 -0
- package/apps/storefront/src/lib/blog/collectionPaths.ts +23 -2
- package/apps/storefront/src/lib/blog/postView.ts +141 -0
- package/apps/storefront/src/lib/blog/types.ts +10 -0
- package/apps/storefront/src/lib/chrome/model.ts +5 -0
- package/apps/storefront/src/lib/publication/rootDiscovery.ts +5 -4
- package/apps/storefront/src/pages/[...slug].astro +107 -78
- package/apps/storefront/src/pages/blog/[slug].astro +8 -218
- package/apps/storefront/src/pages/blog/author/[author].astro +4 -3
- package/apps/storefront/src/pages/blog/category/[category].astro +4 -3
- package/apps/storefront/src/pages/blog/index.astro +1 -0
- package/apps/storefront/src/pages/blog/page/[n].astro +1 -0
- package/apps/storefront/src/pages/blog/tag/[tag].astro +4 -3
- package/apps/storefront/src/pages/llms.txt.ts +5 -4
- package/apps/storefront/src/pages/products/[handle].astro +85 -18
- package/apps/storefront/src/pages/sitemap.xml.ts +3 -3
- package/package.json +1 -1
- package/packages/public-runtime/src/chrome.ts +158 -0
- package/packages/public-runtime/src/index.ts +1 -0
- package/packages/public-runtime/src/product-details.ts +160 -0
|
@@ -1,20 +1,6 @@
|
|
|
1
1
|
---
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import BlogPaywall from "@/components/blog/BlogPaywall.astro";
|
|
5
|
-
import ReactionBar from "@/components/blog/ReactionBar.astro";
|
|
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";
|
|
11
|
-
import { viewerHasBlogAccess } from "@/lib/blog/access.js";
|
|
12
|
-
import { articleBodyHtml } from "@/lib/blog/teaser.js";
|
|
13
|
-
import { readViewerSession, safeNext } from "@/lib/auth/route.js";
|
|
14
|
-
import { buildAlternates } from "@/lib/seo/alternates.js";
|
|
15
|
-
import { languageLabel } from "@/lib/i18n.js";
|
|
16
|
-
import { breadcrumbLd, blogPostingLd } from "@/lib/jsonld.js";
|
|
17
|
-
import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
|
|
2
|
+
import BlogPostArticle from "@/components/blog/BlogPostArticle.astro";
|
|
3
|
+
import { loadBlogPostViewV1 } from "@/lib/blog/postView.js";
|
|
18
4
|
|
|
19
5
|
// These redirects also exist in the published artifact: the publication composition lifts this
|
|
20
6
|
// file into the space's `routes.aliases`, which reach the CDN plane through the live pointer. The
|
|
@@ -24,209 +10,13 @@ import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
|
|
|
24
10
|
// a source the composition actually materializes.
|
|
25
11
|
export const MATERIALIZED_REDIRECT_SOURCE = "content/blog/redirects.json";
|
|
26
12
|
|
|
27
|
-
const { tenant, canonicalBase, basePath, content, publicationTimestamp } = Astro.locals;
|
|
28
|
-
const blogPaths = collectionPathsV1(Astro.locals, "blog");
|
|
29
13
|
const slug = Astro.params.slug ?? "";
|
|
30
|
-
const
|
|
31
|
-
if (!post) {
|
|
32
|
-
const redirect = await resolveBlogRedirect(tenant.tenant_id, slug, content);
|
|
33
|
-
// 308, matching the `redirect-308` alias contract the published plane serves, so a renamed slug
|
|
34
|
-
// behaves identically on both substrates.
|
|
35
|
-
if (redirect) return Astro.redirect(`${basePath}${blogPaths.post(redirect)}`, 308);
|
|
36
|
-
return new Response(null, { status: 404 });
|
|
37
|
-
}
|
|
14
|
+
const result = await loadBlogPostViewV1(Astro, slug);
|
|
38
15
|
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
const gated = (post.access ?? "public") !== "public";
|
|
44
|
-
const unlock = viewerHasBlogAccess(viewer, post);
|
|
45
|
-
const bodyHtml = articleBodyHtml(post.contentHtml, { gated, unlock });
|
|
46
|
-
const next = safeNext(`${basePath}${blogPaths.post(post.slug)}`);
|
|
47
|
-
const loginHref = `/auth/login?next=${encodeURIComponent(next)}`;
|
|
48
|
-
|
|
49
|
-
const canonical = new URL(blogPaths.post(post.slug), canonicalBase + "/").toString();
|
|
50
|
-
const presentation = resolveBlogPresentation(await getBlogSettings(tenant.tenant_id, content));
|
|
51
|
-
// A post that names no author has no byline: never attribute it to anyone (the blog's title, the
|
|
52
|
-
// store) its content does not name.
|
|
53
|
-
const author = bylineAuthor(post.author);
|
|
54
|
-
// The author archive (`/blog/author/<slug>`) lists posts from the curated blog
|
|
55
|
-
// index, and 404s for an author with no LISTED post (an unlisted-only or
|
|
56
|
-
// index-omitted byline). Resolve that up front so the byline links ONLY when the
|
|
57
|
-
// archive will actually render — a byline must never point at a missing page
|
|
58
|
-
// (the invariant `blog/author/[author].astro` documents). Same slug + same source
|
|
59
|
-
// the archive itself filters on, so the two can't disagree. Tag links follow the
|
|
60
|
-
// same rule against `blog/tag/[tag].astro`.
|
|
61
|
-
const listed = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp);
|
|
62
|
-
const authorArchiveSlug = author ? authorSlug(author) : "";
|
|
63
|
-
const authorHasArchive = authorArchiveSlug !== "" && listed.some(
|
|
64
|
-
(p) => authorSlug(p.author) === authorArchiveSlug,
|
|
65
|
-
);
|
|
66
|
-
const archivedTags = new Set(listed.flatMap((p) => p.tags.map(categorySlug)));
|
|
67
|
-
const tagLinks = post.tags.map((tag) => {
|
|
68
|
-
const slug = categorySlug(tag);
|
|
69
|
-
return { label: tag, href: slug && archivedTags.has(slug) ? `${basePath}${blogPaths.tag(slug)}` : null };
|
|
70
|
-
});
|
|
71
|
-
const authorUrl = authorHasArchive
|
|
72
|
-
? new URL(blogPaths.author(authorArchiveSlug), canonicalBase + "/").toString()
|
|
73
|
-
: undefined;
|
|
74
|
-
const articleLd = blogPostingLd(post, {
|
|
75
|
-
url: canonical,
|
|
76
|
-
canonicalBase,
|
|
77
|
-
publisherName: tenant.displayName,
|
|
78
|
-
authorUrl,
|
|
79
|
-
});
|
|
80
|
-
const trail = siteTrail(
|
|
81
|
-
homeCrumbLabel(await content.getChrome()),
|
|
82
|
-
{ label: "Blog", href: blogPaths.index },
|
|
83
|
-
{ label: post.title, href: blogPaths.post(post.slug) },
|
|
84
|
-
);
|
|
85
|
-
const crumbs = breadcrumbLd(trail, canonicalBase);
|
|
86
|
-
const blogHref = `${basePath}${blogPaths.index}`;
|
|
87
|
-
const authorHref = authorHasArchive
|
|
88
|
-
? `${basePath}${blogPaths.author(authorArchiveSlug)}`
|
|
89
|
-
: null;
|
|
90
|
-
const share = shareLinks(presentation.share, {
|
|
91
|
-
url: canonical,
|
|
92
|
-
title: post.title,
|
|
93
|
-
image: post.featuredImage ? new URL(post.featuredImage.src, canonicalBase + "/").toString() : undefined,
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
// I2 — hreflang alternates + per-variant <html lang>. Singleton ⇒ [] alternates
|
|
97
|
-
// and no `lang` override, so a non-translated post is byte-identical to today.
|
|
98
|
-
const translations = post.translationGroupId
|
|
99
|
-
? await getTranslations(tenant.tenant_id, "blog", post.slug, { content, now: publicationTimestamp })
|
|
100
|
-
: [];
|
|
101
|
-
const alternates = buildAlternates(translations, (s) =>
|
|
102
|
-
new URL(blogPaths.post(s), canonicalBase + "/").toString(),
|
|
103
|
-
);
|
|
104
|
-
const lang = alternates.length ? (post.inLanguage ?? "en") : undefined;
|
|
16
|
+
if (result.status === "not_found") return new Response(null, { status: 404 });
|
|
17
|
+
// 308, matching the `redirect-308` alias contract the published plane serves, so a renamed slug
|
|
18
|
+
// behaves identically on both substrates.
|
|
19
|
+
if (result.status === "redirect") return Astro.redirect(result.to, 308);
|
|
105
20
|
---
|
|
106
21
|
|
|
107
|
-
<
|
|
108
|
-
title={post.seoTitle || post.title}
|
|
109
|
-
description={post.description}
|
|
110
|
-
path={blogPaths.post(post.slug)}
|
|
111
|
-
ogType="article"
|
|
112
|
-
ogImage={post.featuredImage?.src}
|
|
113
|
-
noindex={post.status === "unlisted"}
|
|
114
|
-
jsonLd={[crumbs, articleLd]}
|
|
115
|
-
article={{
|
|
116
|
-
publishedTime: post.publishedAt,
|
|
117
|
-
modifiedTime: post.modifiedAt,
|
|
118
|
-
author: authorUrl,
|
|
119
|
-
section: post.categories[0],
|
|
120
|
-
tags: post.tags,
|
|
121
|
-
}}
|
|
122
|
-
imageAlt={post.featuredImage?.alt}
|
|
123
|
-
imageWidth={post.featuredImage?.width}
|
|
124
|
-
imageHeight={post.featuredImage?.height}
|
|
125
|
-
canonicalOverride={post.canonicalUrl}
|
|
126
|
-
alternates={alternates}
|
|
127
|
-
lang={lang}
|
|
128
|
-
>
|
|
129
|
-
<article class="article-shell">
|
|
130
|
-
<Breadcrumbs crumbs={trail} class="breadcrumbs" />
|
|
131
|
-
<header>
|
|
132
|
-
{presentation.postEyebrow && post.tags[0] && <p class="eyebrow eyebrow--tick">{post.tags[0]}</p>}
|
|
133
|
-
<h1>{post.title}</h1>
|
|
134
|
-
{presentation.dek && post.excerpt && <p class="dek">{post.excerpt}</p>}
|
|
135
|
-
<div class="byline">
|
|
136
|
-
{author && <span>By {authorHref
|
|
137
|
-
? <a href={authorHref} class="byline-author" rel="author">{author}</a>
|
|
138
|
-
: <span class="byline-author">{author}</span>}</span>}
|
|
139
|
-
{author && <span aria-hidden="true">·</span>}
|
|
140
|
-
<time datetime={post.publishedAt}>{new Date(post.publishedAt).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}</time>
|
|
141
|
-
</div>
|
|
142
|
-
{translations.length > 1 && (
|
|
143
|
-
<nav class="lang-switcher" aria-label="Available languages">
|
|
144
|
-
{translations.map((variant) => {
|
|
145
|
-
const code = variant.inLanguage ?? "en";
|
|
146
|
-
const current = variant.slug === post.slug;
|
|
147
|
-
return (
|
|
148
|
-
<a
|
|
149
|
-
hreflang={code}
|
|
150
|
-
href={`${basePath}${blogPaths.post(variant.slug)}`}
|
|
151
|
-
aria-current={current ? "true" : undefined}
|
|
152
|
-
>{languageLabel(code)}</a>
|
|
153
|
-
);
|
|
154
|
-
})}
|
|
155
|
-
</nav>
|
|
156
|
-
)}
|
|
157
|
-
</header>
|
|
158
|
-
{post.featuredImage && (
|
|
159
|
-
<img
|
|
160
|
-
class="featured"
|
|
161
|
-
src={post.featuredImage.src}
|
|
162
|
-
alt={post.featuredImage.alt}
|
|
163
|
-
width={post.featuredImage.width}
|
|
164
|
-
height={post.featuredImage.height}
|
|
165
|
-
fetchpriority="high"
|
|
166
|
-
decoding="async"
|
|
167
|
-
/>
|
|
168
|
-
)}
|
|
169
|
-
<div class:list={["article-grid", { "article-grid--single": !presentation.aside }]}>
|
|
170
|
-
{presentation.aside && (
|
|
171
|
-
<aside>
|
|
172
|
-
<a href={blogHref}>← All articles</a>
|
|
173
|
-
{tagLinks.length > 0 && <div class="article-tags">{tagLinks.map((tag) => tag.href ? <a href={tag.href}>{tag.label}</a> : <span>{tag.label}</span>)}</div>}
|
|
174
|
-
</aside>
|
|
175
|
-
)}
|
|
176
|
-
<div class="blog-main">
|
|
177
|
-
<div class="blog-body" set:html={bodyHtml}></div>
|
|
178
|
-
{gated && !unlock && (
|
|
179
|
-
<BlogPaywall access={post.access ?? "authenticated"} next={next} signedIn={!!viewer} loginHref={loginHref} />
|
|
180
|
-
)}
|
|
181
|
-
{!presentation.aside && tagLinks.length > 0 && (
|
|
182
|
-
<ul class="post-tags" aria-label="Tags">
|
|
183
|
-
{tagLinks.map((tag) => <li>{tag.href ? <a href={tag.href}>{tag.label}</a> : <span>{tag.label}</span>}</li>)}
|
|
184
|
-
</ul>
|
|
185
|
-
)}
|
|
186
|
-
<ShareRow links={share} />
|
|
187
|
-
</div>
|
|
188
|
-
</div>
|
|
189
|
-
{presentation.reactions && <ReactionBar collection="blog" slug={post.slug} />}
|
|
190
|
-
</article>
|
|
191
|
-
</Layout>
|
|
192
|
-
|
|
193
|
-
<style>
|
|
194
|
-
.article-shell { width: min(1120px, calc(100% - 2rem)); margin-inline: auto; padding-block: clamp(2rem, 5vw, 5rem); }
|
|
195
|
-
.article-shell :global(.breadcrumbs) { font-size: .84rem; }
|
|
196
|
-
.article-shell :global(.breadcrumbs a) { color: var(--color-accent-ink); font-weight: 700; }
|
|
197
|
-
header { max-width: 880px; margin: clamp(2.5rem, 6vw, 5rem) auto clamp(2rem, 5vw, 3.5rem); text-align: center; }
|
|
198
|
-
header .eyebrow { justify-content: center; }
|
|
199
|
-
h1 { margin-top: .9rem; font-size: clamp(2.5rem, 6vw, 5rem); line-height: 1.02; }
|
|
200
|
-
.dek { max-width: 68ch; margin: 1.5rem auto 0; color: var(--color-muted); font-size: clamp(1rem, 2vw, 1.2rem); }
|
|
201
|
-
.byline { display: flex; justify-content: center; gap: .55rem; margin-top: 1.35rem; color: var(--color-muted); font-size: .85rem; }
|
|
202
|
-
.byline-author { color: var(--color-accent-ink); font-weight: 700; text-decoration: none; }
|
|
203
|
-
.byline-author:hover { text-decoration: underline; text-underline-offset: .18em; }
|
|
204
|
-
.lang-switcher { display: flex; flex-wrap: wrap; justify-content: center; gap: .4rem; margin-top: 1.1rem; }
|
|
205
|
-
.lang-switcher a { padding: .28rem .6rem; border: 1px solid var(--color-border); border-radius: var(--radius-full); color: var(--color-muted); font-size: .78rem; font-weight: 600; text-decoration: none; }
|
|
206
|
-
.lang-switcher a:hover { color: var(--color-accent-ink); border-color: var(--color-accent); }
|
|
207
|
-
.lang-switcher a[aria-current="true"] { color: var(--color-accent-ink); border-color: var(--color-accent); background: var(--color-surface); }
|
|
208
|
-
.featured { display: block; width: 100%; max-height: 620px; object-fit: cover; border-radius: var(--radius-lg); box-shadow: var(--shadow-md); }
|
|
209
|
-
.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); }
|
|
210
|
-
aside { align-self: start; position: sticky; top: 7rem; }
|
|
211
|
-
aside > a { color: var(--color-accent-ink); font-size: .88rem; font-weight: 700; }
|
|
212
|
-
.article-grid--single { grid-template-columns: minmax(0, 720px); }
|
|
213
|
-
.article-tags { display: flex; flex-wrap: wrap; gap: .45rem; margin-top: 1.2rem; }
|
|
214
|
-
.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; }
|
|
215
|
-
.article-tags a:hover, .post-tags a:hover { color: var(--color-accent-ink); border-color: var(--color-accent); }
|
|
216
|
-
.post-tags { display: flex; flex-wrap: wrap; gap: .45rem; margin: 2rem 0 0; padding: 0; list-style: none; }
|
|
217
|
-
.blog-main { min-width: 0; }
|
|
218
|
-
.blog-body { min-width: 0; font-size: 1.05rem; line-height: 1.78; }
|
|
219
|
-
.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; }
|
|
220
|
-
.blog-body :global(h2), .blog-body :global(h3), .blog-body :global(h4) { scroll-margin-top: 7rem; margin: 2.8rem 0 1rem; line-height: 1.18; }
|
|
221
|
-
.blog-body :global(h2) { font-size: clamp(1.75rem, 3vw, 2.35rem); }
|
|
222
|
-
.blog-body :global(h3) { font-size: clamp(1.35rem, 2.4vw, 1.75rem); }
|
|
223
|
-
.blog-body :global(a) { color: var(--color-accent-ink); text-decoration: underline; text-underline-offset: .18em; }
|
|
224
|
-
.blog-body :global(ul), .blog-body :global(ol) { padding-left: 1.4rem; }
|
|
225
|
-
.blog-body :global(li) { margin-block: .45rem; }
|
|
226
|
-
.blog-body :global(blockquote) { padding: .8rem 1.25rem; border-left: 4px solid var(--color-accent); background: var(--color-surface); color: var(--color-muted); }
|
|
227
|
-
.blog-body :global(img) { display: block; width: auto; max-width: 100%; margin-inline: auto; border-radius: var(--radius-md); }
|
|
228
|
-
.blog-body :global(figcaption) { margin-top: .5rem; color: var(--color-muted); text-align: center; font-size: .78rem; }
|
|
229
|
-
.blog-body :global(table) { display: block; width: 100%; overflow-x: auto; border-collapse: collapse; }
|
|
230
|
-
.blog-body :global(th), .blog-body :global(td) { padding: .7rem; border: 1px solid var(--color-border); text-align: left; }
|
|
231
|
-
@media (max-width: 760px) { .article-grid { grid-template-columns: 1fr; } aside { position: static; } .article-tags { display: none; } }
|
|
232
|
-
</style>
|
|
22
|
+
<BlogPostArticle {...result.props} />
|
|
@@ -7,18 +7,19 @@ import { resolveBlogPresentation } from "@/lib/blog/presentation.js";
|
|
|
7
7
|
import { homeCrumbLabel, siteTrail } from "@/lib/breadcrumbs.js";
|
|
8
8
|
import { authorSlug, getAuthors } from "@/lib/blog/collection.js";
|
|
9
9
|
import { breadcrumbLd } from "@/lib/jsonld.js";
|
|
10
|
-
import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
|
|
10
|
+
import { collectionPathsV1, blogPathOptsV1 } from "@/lib/blog/collectionPaths.js";
|
|
11
11
|
|
|
12
12
|
// Author archive for the `/blog` collection. Lists exactly the posts whose
|
|
13
13
|
// bylines link here (same source as /blog/index.astro + /blog/[slug].astro),
|
|
14
14
|
// so a byline never points at a page missing its post.
|
|
15
15
|
const { tenant, basePath, canonicalBase, content, publicationTimestamp } = Astro.locals;
|
|
16
|
-
const blogPaths = collectionPathsV1(Astro.locals, "blog");
|
|
17
16
|
const slug = Astro.params.author ?? "";
|
|
18
17
|
|
|
19
18
|
const allPosts = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp);
|
|
20
19
|
const posts = slug ? allPosts.filter((post) => authorSlug(post.author) === slug) : [];
|
|
21
20
|
if (posts.length === 0) return new Response(null, { status: 404 });
|
|
21
|
+
const blogSettings = await getBlogSettings(tenant.tenant_id, content);
|
|
22
|
+
const blogPaths = collectionPathsV1(Astro.locals, "blog", blogPathOptsV1(blogSettings));
|
|
22
23
|
|
|
23
24
|
const authorName = posts[0]!.author;
|
|
24
25
|
const bio = (await getAuthors(tenant.tenant_id, "blog", content))[slug];
|
|
@@ -54,7 +55,7 @@ const itemListLd = {
|
|
|
54
55
|
url: new URL(blogPaths.post(post.slug), canonicalBase + "/").toString(),
|
|
55
56
|
})),
|
|
56
57
|
};
|
|
57
|
-
const presentation = resolveBlogPresentation(
|
|
58
|
+
const presentation = resolveBlogPresentation(blogSettings);
|
|
58
59
|
const trail = siteTrail(
|
|
59
60
|
homeCrumbLabel(await content.getChrome()),
|
|
60
61
|
{ label: "Blog", href: blogPaths.index },
|
|
@@ -7,10 +7,9 @@ import { collectPostsByTerm } from "@/lib/blog/collection.js";
|
|
|
7
7
|
import { resolveBlogPresentation } from "@/lib/blog/presentation.js";
|
|
8
8
|
import { homeCrumbLabel, siteTrail } from "@/lib/breadcrumbs.js";
|
|
9
9
|
import { breadcrumbLd } from "@/lib/jsonld.js";
|
|
10
|
-
import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
|
|
10
|
+
import { collectionPathsV1, blogPathOptsV1 } from "@/lib/blog/collectionPaths.js";
|
|
11
11
|
|
|
12
12
|
const { tenant, basePath, canonicalBase, content, publicationTimestamp } = Astro.locals;
|
|
13
|
-
const blogPaths = collectionPathsV1(Astro.locals, "blog");
|
|
14
13
|
const param = Astro.params.category ?? "";
|
|
15
14
|
|
|
16
15
|
// Same source as the /blog index (the collection's curated list), filtered to
|
|
@@ -19,7 +18,9 @@ const all = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp)
|
|
|
19
18
|
const match = collectPostsByTerm(all, "categories", param);
|
|
20
19
|
if (!match) return new Response(null, { status: 404 });
|
|
21
20
|
const { label, posts } = match;
|
|
22
|
-
const
|
|
21
|
+
const blogSettings = await getBlogSettings(tenant.tenant_id, content);
|
|
22
|
+
const blogPaths = collectionPathsV1(Astro.locals, "blog", blogPathOptsV1(blogSettings));
|
|
23
|
+
const presentation = resolveBlogPresentation(blogSettings);
|
|
23
24
|
|
|
24
25
|
const path = blogPaths.category(param);
|
|
25
26
|
const itemList = {
|
|
@@ -7,17 +7,18 @@ import { collectPostsByTerm } from "@/lib/blog/collection.js";
|
|
|
7
7
|
import { resolveBlogPresentation } from "@/lib/blog/presentation.js";
|
|
8
8
|
import { homeCrumbLabel, siteTrail } from "@/lib/breadcrumbs.js";
|
|
9
9
|
import { breadcrumbLd } from "@/lib/jsonld.js";
|
|
10
|
-
import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
|
|
10
|
+
import { collectionPathsV1, blogPathOptsV1 } from "@/lib/blog/collectionPaths.js";
|
|
11
11
|
|
|
12
12
|
const { tenant, basePath, canonicalBase, content, publicationTimestamp } = Astro.locals;
|
|
13
|
-
const blogPaths = collectionPathsV1(Astro.locals, "blog");
|
|
14
13
|
const param = Astro.params.tag ?? "";
|
|
15
14
|
|
|
16
15
|
const all = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp);
|
|
17
16
|
const match = collectPostsByTerm(all, "tags", param);
|
|
18
17
|
if (!match) return new Response(null, { status: 404 });
|
|
19
18
|
const { label, posts } = match;
|
|
20
|
-
const
|
|
19
|
+
const blogSettings = await getBlogSettings(tenant.tenant_id, content);
|
|
20
|
+
const blogPaths = collectionPathsV1(Astro.locals, "blog", blogPathOptsV1(blogSettings));
|
|
21
|
+
const presentation = resolveBlogPresentation(blogSettings);
|
|
21
22
|
|
|
22
23
|
const path = blogPaths.tag(param);
|
|
23
24
|
const itemList = {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import type { APIContext } from "astro";
|
|
13
13
|
import { nonEmptyCollections } from "@/lib/tot/query.js";
|
|
14
|
-
import { listBlogPosts } from "@/lib/blog/provider.js";
|
|
14
|
+
import { getBlogSettings, listBlogPosts } from "@/lib/blog/provider.js";
|
|
15
15
|
import { compileRouteTierIndexV1 } from "@tot/public-runtime";
|
|
16
16
|
import { readKv } from "@/lib/runtimeEnv.js";
|
|
17
17
|
import {
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
readMountedPublicationDiscoveryV1,
|
|
21
21
|
rootOwnsDiscoveryPathV1,
|
|
22
22
|
} from "@/lib/publication/mountedDiscovery.js";
|
|
23
|
-
import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
|
|
23
|
+
import { collectionPathsV1, blogPathOptsV1 } from "@/lib/blog/collectionPaths.js";
|
|
24
24
|
|
|
25
25
|
/** Collapse HTML/whitespace to a single trimmed line for a link description. */
|
|
26
26
|
function oneLine(s: string | undefined, max = 140): string | undefined {
|
|
@@ -32,12 +32,11 @@ function oneLine(s: string | undefined, max = 140): string | undefined {
|
|
|
32
32
|
|
|
33
33
|
export async function GET(context: APIContext): Promise<Response> {
|
|
34
34
|
const { tot, tenant, canonicalBase, content, publicationTimestamp } = context.locals;
|
|
35
|
-
const blogPaths = collectionPathsV1(context.locals, "blog");
|
|
36
35
|
const base = canonicalBase.replace(/\/$/, "");
|
|
37
36
|
|
|
38
37
|
// Use the request-resolved ContentProvider (locals.content) so any published /
|
|
39
38
|
// dev-draft overlay is reflected, matching sitemap.xml.ts (not a fresh provider).
|
|
40
|
-
const [allCollections, activeProducts, rootPages, featured, home, rootBlogPosts] =
|
|
39
|
+
const [allCollections, activeProducts, rootPages, featured, home, rootBlogPosts, blogSettings] =
|
|
41
40
|
await Promise.all([
|
|
42
41
|
tot.listCollections(),
|
|
43
42
|
tot.getAllActiveProducts(),
|
|
@@ -45,7 +44,9 @@ export async function GET(context: APIContext): Promise<Response> {
|
|
|
45
44
|
tot.getFeatured(12),
|
|
46
45
|
content.getHome().catch(() => null),
|
|
47
46
|
listBlogPosts(tenant.tenant_id, content, publicationTimestamp),
|
|
47
|
+
getBlogSettings(tenant.tenant_id, content),
|
|
48
48
|
]);
|
|
49
|
+
const blogPaths = collectionPathsV1(context.locals, "blog", blogPathOptsV1(blogSettings));
|
|
49
50
|
// Empty synthesized collections (no active members) never make the guide —
|
|
50
51
|
// an LLM shouldn't be pointed at a URL that resolves to zero products.
|
|
51
52
|
const collections = nonEmptyCollections(allCollections, activeProducts);
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
resolveMembershipRule,
|
|
30
30
|
DEFAULT_STACKING_POLICY,
|
|
31
31
|
readReviewAggregate,
|
|
32
|
+
resolveProductDetails,
|
|
32
33
|
} from "@tot/public-runtime";
|
|
33
34
|
import type {
|
|
34
35
|
ProductPurchaseOption,
|
|
@@ -110,7 +111,7 @@ function titleCase(s: string) {
|
|
|
110
111
|
return s.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
111
112
|
}
|
|
112
113
|
|
|
113
|
-
// Metafield reader.
|
|
114
|
+
// Metafield reader (ToT-internal namespaces only — compliance.* below).
|
|
114
115
|
const mf = (ns: string, key: string) =>
|
|
115
116
|
product.metafields.find((m) => m.namespace === ns && m.key === key)?.value;
|
|
116
117
|
|
|
@@ -120,9 +121,16 @@ const mf = (ns: string, key: string) =>
|
|
|
120
121
|
// unless BOTH numbers are real positives — so no fabricated rating can render.
|
|
121
122
|
const { average: reviewAvg, count: reviewCount } = readReviewAggregate(product);
|
|
122
123
|
|
|
124
|
+
// Declared PDP metafield->section mapping (ChromeConfig.productDetails,
|
|
125
|
+
// content/chrome.json). No metafield namespace/key convention is assumed by
|
|
126
|
+
// this template — a tenant's own catalog shape is declared, not guessed, so
|
|
127
|
+
// behavior never branches on which tenant is rendering.
|
|
128
|
+
const chrome = await Astro.locals.content.getChrome();
|
|
129
|
+
const details = resolveProductDetails(product.metafields, chrome.productDetails);
|
|
130
|
+
|
|
123
131
|
// Short description (merchant's concise summary) shown high on the PDP, like the
|
|
124
132
|
// source store. The longer description_html still renders below the buy column.
|
|
125
|
-
const shortDescription =
|
|
133
|
+
const shortDescription = details.shortDescription;
|
|
126
134
|
|
|
127
135
|
// OG image = the real product image (Shopify CDN supports ?width=), sized for
|
|
128
136
|
// social. Falls back to the tenant brand OG only when there's no usable image.
|
|
@@ -133,14 +141,14 @@ const ogImage =
|
|
|
133
141
|
: undefined;
|
|
134
142
|
|
|
135
143
|
// Product attributes shown the way the SOURCE STORE presents them: plain
|
|
136
|
-
// "Label: value" lines below the description
|
|
137
|
-
//
|
|
138
|
-
// are ToT-internal sync data and are NOT
|
|
139
|
-
// show them either (they gate
|
|
144
|
+
// "Label: value" lines below the description, sourced from F:-prefixed tags
|
|
145
|
+
// (flavor) + the tenant's declared attributeLines (e.g. VG % / Bottle Type).
|
|
146
|
+
// The compliance.* metafields are ToT-internal sync data and are NOT
|
|
147
|
+
// displayed — the source store doesn't show them either (they gate
|
|
148
|
+
// shipping/eligibility, not merchandising).
|
|
140
149
|
// Each flavor links to that flavor filtered within the product's collection —
|
|
141
150
|
// mirrors the source store's /collections/<handle>/<tag> pattern using our
|
|
142
|
-
// existing ?tag= PLP filter.
|
|
143
|
-
// "F: " flavor-tag convention is merchant-specific (same as the display above).
|
|
151
|
+
// existing ?tag= PLP filter.
|
|
144
152
|
const flavorCollection = product.collections[0];
|
|
145
153
|
const flavorTags = (product.tags ?? [])
|
|
146
154
|
.filter((t) => t.startsWith("F: "))
|
|
@@ -151,8 +159,7 @@ const flavorTags = (product.tags ?? [])
|
|
|
151
159
|
: undefined,
|
|
152
160
|
}))
|
|
153
161
|
.filter((f) => f.label);
|
|
154
|
-
const
|
|
155
|
-
const bottleType = mf("my_fields", "bottle_type");
|
|
162
|
+
const attributeLines = details.attributeLines;
|
|
156
163
|
|
|
157
164
|
// State sales restrictions (ToT compliance sync). Where this can't ship/sell.
|
|
158
165
|
let restrictedStates: string[] = [];
|
|
@@ -169,7 +176,6 @@ if (restrictedRaw) {
|
|
|
169
176
|
// Legal/safety disclaimer (scraped from the store's own PDP by the migration),
|
|
170
177
|
// per product category — sub-ohm on salts, battery on devices, etc. Selected by
|
|
171
178
|
// product_type (or the compliance.product_category metafield); default fallback.
|
|
172
|
-
const chrome = await Astro.locals.content.getChrome();
|
|
173
179
|
const productCategory = product.product_type || mf("compliance", "product_category");
|
|
174
180
|
const disclaimer =
|
|
175
181
|
(productCategory && chrome.disclaimers?.[productCategory]) || chrome.disclaimer;
|
|
@@ -440,7 +446,7 @@ const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
|
|
|
440
446
|
the longer description, then plain "Label: value" lines (no spec table). */}
|
|
441
447
|
<div class="mt-9 border-t border-[var(--color-border)] pt-7">
|
|
442
448
|
<div class="rte text-[var(--color-text)]" set:html={product.description_html} />
|
|
443
|
-
{(flavorTags.length > 0 ||
|
|
449
|
+
{(flavorTags.length > 0 || attributeLines.length > 0) && (
|
|
444
450
|
<div class="mt-5 space-y-1.5 text-sm leading-relaxed">
|
|
445
451
|
{flavorTags.length > 0 && (
|
|
446
452
|
<p>
|
|
@@ -457,12 +463,9 @@ const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
|
|
|
457
463
|
))}
|
|
458
464
|
</p>
|
|
459
465
|
)}
|
|
460
|
-
{
|
|
461
|
-
<p><span class="text-[var(--color-muted)]">
|
|
462
|
-
)}
|
|
463
|
-
{bottleType && (
|
|
464
|
-
<p><span class="text-[var(--color-muted)]">Bottle Type:</span> {bottleType}</p>
|
|
465
|
-
)}
|
|
466
|
+
{attributeLines.map((line) => (
|
|
467
|
+
<p><span class="text-[var(--color-muted)]">{line.label}:</span> {line.value}</p>
|
|
468
|
+
))}
|
|
466
469
|
</div>
|
|
467
470
|
)}
|
|
468
471
|
</div>
|
|
@@ -506,6 +509,70 @@ const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
|
|
|
506
509
|
</div>
|
|
507
510
|
</div>
|
|
508
511
|
|
|
512
|
+
{/* Declared PDP sections (spec rows, ingredients, FAQ, box contents, age
|
|
513
|
+
notice) — every row/section comes from the tenant's own
|
|
514
|
+
ChromeConfig.productDetails mapping (content/chrome.json), read above
|
|
515
|
+
into `details`. A section is simply absent when its declared metafield
|
|
516
|
+
has no value on this product — never an empty label. */}
|
|
517
|
+
{details.specRows.length > 0 && (
|
|
518
|
+
<Section title={details.specHeading} widget="product-details">
|
|
519
|
+
<dl class="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
520
|
+
{details.specRows.map((row) => (
|
|
521
|
+
<div class="border-b border-[var(--color-border)] pb-3">
|
|
522
|
+
<dt class="text-xs font-medium uppercase tracking-wide text-[var(--color-muted)]">{row.label}</dt>
|
|
523
|
+
<dd class="mt-1 text-sm font-medium text-[var(--color-text)]">{row.value}</dd>
|
|
524
|
+
</div>
|
|
525
|
+
))}
|
|
526
|
+
</dl>
|
|
527
|
+
</Section>
|
|
528
|
+
)}
|
|
529
|
+
|
|
530
|
+
{details.boxContents && (
|
|
531
|
+
<div class="container-prose">
|
|
532
|
+
<p class="text-sm leading-relaxed">
|
|
533
|
+
<span class="text-[var(--color-muted)]">{details.boxContents.label}:</span> {details.boxContents.value}
|
|
534
|
+
</p>
|
|
535
|
+
</div>
|
|
536
|
+
)}
|
|
537
|
+
|
|
538
|
+
{details.ingredients && (
|
|
539
|
+
<Section title={details.ingredients.heading} widget="product-ingredients" tone="surface">
|
|
540
|
+
<p class="text-sm leading-relaxed text-[var(--color-text)]">{details.ingredients.text}</p>
|
|
541
|
+
{details.factCheckedBy && (
|
|
542
|
+
<p class="mt-3 text-xs text-[var(--color-muted)]">{details.factCheckedBy}</p>
|
|
543
|
+
)}
|
|
544
|
+
</Section>
|
|
545
|
+
)}
|
|
546
|
+
|
|
547
|
+
{details.ageNotice && (
|
|
548
|
+
<div class="container-prose">
|
|
549
|
+
<aside
|
|
550
|
+
role="note"
|
|
551
|
+
class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-5 py-4"
|
|
552
|
+
>
|
|
553
|
+
<strong class="block text-sm font-display font-semibold text-[var(--color-text)]">
|
|
554
|
+
{details.ageNotice.title}
|
|
555
|
+
</strong>
|
|
556
|
+
<p class="mt-1 text-sm text-[var(--color-muted)]">{details.ageNotice.text}</p>
|
|
557
|
+
</aside>
|
|
558
|
+
</div>
|
|
559
|
+
)}
|
|
560
|
+
|
|
561
|
+
{details.faq && (
|
|
562
|
+
<Section title={details.faq.heading} widget="product-faq">
|
|
563
|
+
<div class="divide-y divide-[var(--color-border)]">
|
|
564
|
+
{details.faq.items.map((item) => (
|
|
565
|
+
<details class="group py-4">
|
|
566
|
+
<summary class="cursor-pointer list-none font-display text-sm font-medium text-[var(--color-text)]">
|
|
567
|
+
{item.question}
|
|
568
|
+
</summary>
|
|
569
|
+
<p class="mt-2 text-sm leading-relaxed text-[var(--color-muted)]">{item.answer}</p>
|
|
570
|
+
</details>
|
|
571
|
+
))}
|
|
572
|
+
</div>
|
|
573
|
+
</Section>
|
|
574
|
+
)}
|
|
575
|
+
|
|
509
576
|
{/* Customer reviews — real content migrated from the store's reviews app. */}
|
|
510
577
|
{reviews.length > 0 && (
|
|
511
578
|
<Section
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
readMountedPublicationDiscoveryV1,
|
|
25
25
|
rootOwnsDiscoveryPathV1,
|
|
26
26
|
} from "@/lib/publication/mountedDiscovery.js";
|
|
27
|
-
import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
|
|
27
|
+
import { collectionPathsV1, blogPathOptsV1 } from "@/lib/blog/collectionPaths.js";
|
|
28
28
|
|
|
29
29
|
interface SitemapUrl {
|
|
30
30
|
loc: string;
|
|
@@ -44,7 +44,8 @@ function escapeXml(value: string): string {
|
|
|
44
44
|
export async function GET(context: APIContext): Promise<Response> {
|
|
45
45
|
const { tot, tenant, canonicalBase, content, features, publicationTimestamp } = context.locals;
|
|
46
46
|
const base = canonicalBase.replace(/\/$/, "");
|
|
47
|
-
const
|
|
47
|
+
const blogSettings = await getBlogSettings(tenant.tenant_id, content);
|
|
48
|
+
const blogPaths = collectionPathsV1(context.locals, "blog", blogPathOptsV1(blogSettings));
|
|
48
49
|
const buildPaths = collectionPathsV1(context.locals, "the-build");
|
|
49
50
|
|
|
50
51
|
const urls: SitemapUrl[] = [{ loc: `${base}/` }];
|
|
@@ -93,7 +94,6 @@ export async function GET(context: APIContext): Promise<Response> {
|
|
|
93
94
|
// straight off this list (withSitemapRoutes in scripts/publish/lib/routes.mjs),
|
|
94
95
|
// so listing them here is what makes /blog/page/N/ exist in BOTH serving
|
|
95
96
|
// planes, not just the SSR Worker.
|
|
96
|
-
const blogSettings = await getBlogSettings(tenant.tenant_id, content);
|
|
97
97
|
const { pageSize } = resolveBlogIndexCopy(blogSettings, tenant.displayName);
|
|
98
98
|
const totalBlogPages = Math.max(1, Math.ceil(blogPosts.length / pageSize));
|
|
99
99
|
for (let n = 2; n <= totalBlogPages; n++) urls.push({ loc: `${base}${blogPaths.page(n)}` });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.74",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|