@tokenoftrust/storefront-runner 2.4.73 → 2.4.75
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/Breadcrumbs.astro +3 -2
- package/apps/storefront/src/components/CollectionCard.astro +3 -2
- package/apps/storefront/src/components/ProductCard.astro +2 -1
- package/apps/storefront/src/components/Seo.astro +6 -4
- package/apps/storefront/src/components/blog/BlogIndexView.astro +7 -2
- package/apps/storefront/src/components/blog/BlogPostArticle.astro +156 -0
- package/apps/storefront/src/components/home/Hero.astro +2 -1
- package/apps/storefront/src/components/marketing/Faq.astro +7 -2
- package/apps/storefront/src/layouts/Layout.astro +25 -5
- 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/publication/rootDiscovery.ts +5 -4
- package/apps/storefront/src/lib/seo/faqItems.ts +63 -0
- package/apps/storefront/src/lib/seo/structuredData.ts +267 -0
- package/apps/storefront/src/lib/seo/urlForm.ts +40 -0
- package/apps/storefront/src/lib/storyblok/content-model.ts +7 -0
- package/apps/storefront/src/lib/storyblok/provider.ts +4 -0
- package/apps/storefront/src/pages/[...slug].astro +122 -78
- package/apps/storefront/src/pages/blog/[slug].astro +8 -218
- package/apps/storefront/src/pages/blog/author/[author].astro +5 -3
- package/apps/storefront/src/pages/blog/category/[category].astro +5 -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 +5 -3
- package/apps/storefront/src/pages/collections/[handle].astro +8 -1
- package/apps/storefront/src/pages/collections/index.astro +1 -0
- package/apps/storefront/src/pages/index.astro +8 -0
- package/apps/storefront/src/pages/llms.txt.ts +5 -4
- package/apps/storefront/src/pages/products/[handle].astro +11 -1
- package/apps/storefront/src/pages/sitemap.xml.ts +5 -4
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +115 -1
- package/packages/public-runtime/src/declared-tenant-config.d.mts +8 -1
- package/packages/public-runtime/src/declared-tenant-config.mjs +109 -1
- package/packages/public-runtime/src/tenant.ts +40 -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 },
|
|
@@ -69,6 +70,7 @@ const crumbs = breadcrumbLd(trail, canonicalBase);
|
|
|
69
70
|
path={blogPaths.author(slug)}
|
|
70
71
|
fullBleed
|
|
71
72
|
jsonLd={[crumbs, profileLd, itemListLd]}
|
|
73
|
+
structuredData={{ pageType: "collection" }}
|
|
72
74
|
>
|
|
73
75
|
<header class:list={["author-hero", { "author-hero--plain": !presentation.hero }]}>
|
|
74
76
|
<div class="blog-shell">
|
|
@@ -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 = {
|
|
@@ -47,6 +48,7 @@ const crumbs = breadcrumbLd(trail, canonicalBase);
|
|
|
47
48
|
path={path}
|
|
48
49
|
fullBleed
|
|
49
50
|
jsonLd={[crumbs, itemList]}
|
|
51
|
+
structuredData={{ pageType: "collection" }}
|
|
50
52
|
>
|
|
51
53
|
<header class:list={["blog-hero", { "blog-hero--plain": !presentation.hero }]}>
|
|
52
54
|
<div class="blog-shell">
|
|
@@ -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 = {
|
|
@@ -45,6 +46,7 @@ const crumbs = breadcrumbLd(trail, canonicalBase);
|
|
|
45
46
|
path={path}
|
|
46
47
|
fullBleed
|
|
47
48
|
jsonLd={[crumbs, itemList]}
|
|
49
|
+
structuredData={{ pageType: "collection" }}
|
|
48
50
|
>
|
|
49
51
|
<header class:list={["blog-hero", { "blog-hero--plain": !presentation.hero }]}>
|
|
50
52
|
<div class="blog-shell">
|
|
@@ -18,6 +18,7 @@ import Img from "@/components/Img.astro";
|
|
|
18
18
|
import { parseQuery, buildQueryString } from "@/lib/url";
|
|
19
19
|
import { breadcrumbLd, itemListLd } from "@/lib/jsonld";
|
|
20
20
|
import { truncate } from "@/lib/format";
|
|
21
|
+
import { pageUrlForm } from "@/lib/seo/urlForm";
|
|
21
22
|
import { withBase } from "@/lib/basePath";
|
|
22
23
|
import { isBlankDescriptionHtml, sanitizeDescriptionHtml } from "@/lib/content/sanitizeDescriptionHtml";
|
|
23
24
|
|
|
@@ -43,7 +44,7 @@ const { products, page, pageSize, total, facets } = await tot.listProducts(query
|
|
|
43
44
|
// sitemap + directory too. Detect it via the UNFILTERED membership total.
|
|
44
45
|
const { total: memberTotal } = await tot.listProducts({ collection: handle, pageSize: 1 });
|
|
45
46
|
const isEmptyCollection = memberTotal === 0;
|
|
46
|
-
const basePath = `/collections/${handle}
|
|
47
|
+
const basePath = pageUrlForm(`/collections/${handle}`, tenant.seo);
|
|
47
48
|
// Tenant-prefixed page path for facet/sort/pagination, which build links by
|
|
48
49
|
// string-concatenating onto this base (so they stay inside a path-routed
|
|
49
50
|
// tenant). `basePath` itself stays unprefixed for the SEO canonical `path`,
|
|
@@ -97,6 +98,12 @@ if (query.priceMin != null || query.priceMax != null) {
|
|
|
97
98
|
breadcrumbLd(crumbs, canonicalBase),
|
|
98
99
|
itemListLd(products, canonicalBase, (page - 1) * pageSize),
|
|
99
100
|
]}
|
|
101
|
+
structuredData={{
|
|
102
|
+
pageType: "collection",
|
|
103
|
+
description: collection.seo.description || undefined,
|
|
104
|
+
crumbs,
|
|
105
|
+
items: products.map((p) => ({ title: p.title, url: new URL(`/products/${p.handle}`, canonicalBase + "/").toString() })),
|
|
106
|
+
}}
|
|
100
107
|
>
|
|
101
108
|
{/* Collection hero */}
|
|
102
109
|
<header class="bg-[var(--color-primary)] text-[var(--color-primary-contrast)]">
|
|
@@ -34,6 +34,7 @@ const crumbs = siteTrail(
|
|
|
34
34
|
description={`Browse every collection at ${tenant.displayName}.`}
|
|
35
35
|
path="/collections"
|
|
36
36
|
jsonLd={breadcrumbLd(crumbs, canonicalBase)}
|
|
37
|
+
structuredData={{ pageType: "collection" }}
|
|
37
38
|
>
|
|
38
39
|
<div class="container-prose pt-6">
|
|
39
40
|
<Breadcrumbs crumbs={crumbs} />
|
|
@@ -182,6 +182,14 @@ const homeSectionWidgets = marketing
|
|
|
182
182
|
path="/"
|
|
183
183
|
ogType="website"
|
|
184
184
|
fullBleed={marketing}
|
|
185
|
+
structuredData={{
|
|
186
|
+
pageType: "home",
|
|
187
|
+
headline: home.seoTitle ?? tenant.displayName,
|
|
188
|
+
description: home.seoDescription,
|
|
189
|
+
image: home.image,
|
|
190
|
+
author: home.author,
|
|
191
|
+
faq: home.blocks.flatMap((block) => (block.component === "faq" ? block.items : [])),
|
|
192
|
+
}}
|
|
185
193
|
>
|
|
186
194
|
{marketing && <MarketingBlocks blocks={marketingBlocks} basePath={basePath} />}
|
|
187
195
|
{
|
|
@@ -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);
|
|
@@ -45,6 +45,8 @@ import { truncate } from "@/lib/format";
|
|
|
45
45
|
import { productLd, breadcrumbLd, reviewLd } from "@/lib/jsonld";
|
|
46
46
|
import { isUsableImageUrl } from "@/lib/placeholder";
|
|
47
47
|
import { withBase } from "@/lib/basePath";
|
|
48
|
+
import { productPageType, structuredDataProfile } from "@/lib/seo/structuredData";
|
|
49
|
+
import { pageUrlForm } from "@/lib/seo/urlForm";
|
|
48
50
|
import { shippingPolicyPath } from "@/lib/commerce/shippingPolicy";
|
|
49
51
|
import AppWidgetFrame from "@/components/apps/AppWidgetFrame.astro";
|
|
50
52
|
import { getRegistryService } from "@/lib/apps/registryAccess.js";
|
|
@@ -155,7 +157,7 @@ const flavorTags = (product.tags ?? [])
|
|
|
155
157
|
.map((t) => ({
|
|
156
158
|
label: t.slice(3).trim(),
|
|
157
159
|
href: flavorCollection
|
|
158
|
-
? `/collections/${flavorCollection}?tag=${encodeURIComponent(t)}
|
|
160
|
+
? pageUrlForm(`/collections/${flavorCollection}?tag=${encodeURIComponent(t)}`, tenant.seo)
|
|
159
161
|
: undefined,
|
|
160
162
|
}))
|
|
161
163
|
.filter((f) => f.label);
|
|
@@ -299,6 +301,13 @@ const productJsonLd: Record<string, unknown> = {
|
|
|
299
301
|
...reviewLd(reviewAvg, reviewCount, reviews),
|
|
300
302
|
};
|
|
301
303
|
const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
|
|
304
|
+
const structuredData = {
|
|
305
|
+
pageType: productPageType(structuredDataProfile(tenant), product),
|
|
306
|
+
image: ogPrimary && isUsableImageUrl(ogPrimary.url, ogPrimary.rehosted) ? ogPrimary.url : null,
|
|
307
|
+
product,
|
|
308
|
+
reviews: reviewLd(reviewAvg, reviewCount, reviews),
|
|
309
|
+
crumbs,
|
|
310
|
+
};
|
|
302
311
|
---
|
|
303
312
|
|
|
304
313
|
<Layout
|
|
@@ -309,6 +318,7 @@ const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
|
|
|
309
318
|
ogImage={ogImage}
|
|
310
319
|
noindex={isDraft}
|
|
311
320
|
jsonLd={jsonLd}
|
|
321
|
+
structuredData={structuredData}
|
|
312
322
|
>
|
|
313
323
|
<div class="container-prose pt-6">
|
|
314
324
|
<Breadcrumbs crumbs={crumbs} />
|
|
@@ -24,7 +24,8 @@ 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
|
+
import { pageUrlForm } from "@/lib/seo/urlForm.js";
|
|
28
29
|
|
|
29
30
|
interface SitemapUrl {
|
|
30
31
|
loc: string;
|
|
@@ -44,7 +45,8 @@ function escapeXml(value: string): string {
|
|
|
44
45
|
export async function GET(context: APIContext): Promise<Response> {
|
|
45
46
|
const { tot, tenant, canonicalBase, content, features, publicationTimestamp } = context.locals;
|
|
46
47
|
const base = canonicalBase.replace(/\/$/, "");
|
|
47
|
-
const
|
|
48
|
+
const blogSettings = await getBlogSettings(tenant.tenant_id, content);
|
|
49
|
+
const blogPaths = collectionPathsV1(context.locals, "blog", blogPathOptsV1(blogSettings));
|
|
48
50
|
const buildPaths = collectionPathsV1(context.locals, "the-build");
|
|
49
51
|
|
|
50
52
|
const urls: SitemapUrl[] = [{ loc: `${base}/` }];
|
|
@@ -93,7 +95,6 @@ export async function GET(context: APIContext): Promise<Response> {
|
|
|
93
95
|
// straight off this list (withSitemapRoutes in scripts/publish/lib/routes.mjs),
|
|
94
96
|
// so listing them here is what makes /blog/page/N/ exist in BOTH serving
|
|
95
97
|
// planes, not just the SSR Worker.
|
|
96
|
-
const blogSettings = await getBlogSettings(tenant.tenant_id, content);
|
|
97
98
|
const { pageSize } = resolveBlogIndexCopy(blogSettings, tenant.displayName);
|
|
98
99
|
const totalBlogPages = Math.max(1, Math.ceil(blogPosts.length / pageSize));
|
|
99
100
|
for (let n = 2; n <= totalBlogPages; n++) urls.push({ loc: `${base}${blogPaths.page(n)}` });
|
|
@@ -154,7 +155,7 @@ export async function GET(context: APIContext): Promise<Response> {
|
|
|
154
155
|
${urls
|
|
155
156
|
.map(
|
|
156
157
|
(u) =>
|
|
157
|
-
` <url>\n <loc>${escapeXml(u.loc)}</loc>${
|
|
158
|
+
` <url>\n <loc>${escapeXml(pageUrlForm(u.loc, tenant.seo))}</loc>${
|
|
158
159
|
u.lastmod ? `\n <lastmod>${escapeXml(u.lastmod)}</lastmod>` : ""
|
|
159
160
|
}\n </url>`,
|
|
160
161
|
)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.75",
|
|
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",
|