@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
|
@@ -20,10 +20,14 @@ interface Props {
|
|
|
20
20
|
pagePosts: BlogPostSummary[];
|
|
21
21
|
page: number;
|
|
22
22
|
totalPages: number;
|
|
23
|
+
/** `BlogSettings.postsAtRoot` — see `collectionPaths.js`'s `blogPathOptsV1`. */
|
|
24
|
+
postsAtRoot?: boolean;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
const {
|
|
26
|
-
|
|
27
|
+
const {
|
|
28
|
+
basePath, canonicalBase, copy, presentation, tags, pagePosts, page, totalPages, postsAtRoot,
|
|
29
|
+
} = Astro.props;
|
|
30
|
+
const blogPaths = collectionPathsV1(Astro.locals, "blog", postsAtRoot ? { postBase: "" } : {});
|
|
27
31
|
const showTopics = presentation.topics && tags.length > 0;
|
|
28
32
|
|
|
29
33
|
const href = (path: string) => `${basePath}${path}` || "/";
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The post article view — rendered by `pages/blog/[slug].astro` at its usual
|
|
4
|
+
* `/blog/<slug>` URL, and by `pages/[...slug].astro` at the site root when the
|
|
5
|
+
* tenant declares `BlogSettings.postsAtRoot`. Both callers resolve their props
|
|
6
|
+
* through `@/lib/blog/postView.js`'s `loadBlogPostViewV1`, so the markup, byline,
|
|
7
|
+
* paywall gate and structured data are identical regardless of which URL served
|
|
8
|
+
* the request.
|
|
9
|
+
*/
|
|
10
|
+
import Layout from "@/layouts/Layout.astro";
|
|
11
|
+
import Breadcrumbs from "@/components/Breadcrumbs.astro";
|
|
12
|
+
import BlogPaywall from "@/components/blog/BlogPaywall.astro";
|
|
13
|
+
import ReactionBar from "@/components/blog/ReactionBar.astro";
|
|
14
|
+
import ShareRow from "@/components/blog/ShareRow.astro";
|
|
15
|
+
import { languageLabel } from "@/lib/i18n.js";
|
|
16
|
+
import type { BlogPostViewPropsV1 } from "@/lib/blog/postView.js";
|
|
17
|
+
|
|
18
|
+
type Props = BlogPostViewPropsV1;
|
|
19
|
+
|
|
20
|
+
const {
|
|
21
|
+
post, path, bodyHtml, gated, unlock, next, loginHref, viewer, presentation, author,
|
|
22
|
+
authorHref, authorUrl, tagLinks, articleLd, trail, crumbs, blogHref, share, translationLinks,
|
|
23
|
+
alternates, lang,
|
|
24
|
+
} = Astro.props;
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
<Layout
|
|
28
|
+
title={post.seoTitle || post.title}
|
|
29
|
+
description={post.description}
|
|
30
|
+
path={path}
|
|
31
|
+
ogType="article"
|
|
32
|
+
ogImage={post.featuredImage?.src}
|
|
33
|
+
noindex={post.status === "unlisted"}
|
|
34
|
+
jsonLd={[crumbs, articleLd]}
|
|
35
|
+
article={{
|
|
36
|
+
publishedTime: post.publishedAt,
|
|
37
|
+
modifiedTime: post.modifiedAt,
|
|
38
|
+
author: authorUrl,
|
|
39
|
+
section: post.categories[0],
|
|
40
|
+
tags: post.tags,
|
|
41
|
+
}}
|
|
42
|
+
imageAlt={post.featuredImage?.alt}
|
|
43
|
+
imageWidth={post.featuredImage?.width}
|
|
44
|
+
imageHeight={post.featuredImage?.height}
|
|
45
|
+
canonicalOverride={post.canonicalUrl}
|
|
46
|
+
alternates={alternates}
|
|
47
|
+
lang={lang}
|
|
48
|
+
>
|
|
49
|
+
<article class="article-shell">
|
|
50
|
+
<Breadcrumbs crumbs={trail} class="breadcrumbs" />
|
|
51
|
+
<header>
|
|
52
|
+
{presentation.postEyebrow && post.tags[0] && <p class="eyebrow eyebrow--tick">{post.tags[0]}</p>}
|
|
53
|
+
<h1>{post.title}</h1>
|
|
54
|
+
{presentation.dek && post.excerpt && <p class="dek">{post.excerpt}</p>}
|
|
55
|
+
<div class="byline">
|
|
56
|
+
{author && <span>By {authorHref
|
|
57
|
+
? <a href={authorHref} class="byline-author" rel="author">{author}</a>
|
|
58
|
+
: <span class="byline-author">{author}</span>}</span>}
|
|
59
|
+
{author && <span aria-hidden="true">·</span>}
|
|
60
|
+
<time datetime={post.publishedAt}>{new Date(post.publishedAt).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}</time>
|
|
61
|
+
</div>
|
|
62
|
+
{translationLinks.length > 1 && (
|
|
63
|
+
<nav class="lang-switcher" aria-label="Available languages">
|
|
64
|
+
{translationLinks.map(({ code, href, current }) => (
|
|
65
|
+
<a hreflang={code} href={href} aria-current={current ? "true" : undefined}>
|
|
66
|
+
{languageLabel(code)}
|
|
67
|
+
</a>
|
|
68
|
+
))}
|
|
69
|
+
</nav>
|
|
70
|
+
)}
|
|
71
|
+
</header>
|
|
72
|
+
{post.featuredImage && (
|
|
73
|
+
<img
|
|
74
|
+
class="featured"
|
|
75
|
+
src={post.featuredImage.src}
|
|
76
|
+
alt={post.featuredImage.alt}
|
|
77
|
+
width={post.featuredImage.width}
|
|
78
|
+
height={post.featuredImage.height}
|
|
79
|
+
fetchpriority="high"
|
|
80
|
+
decoding="async"
|
|
81
|
+
/>
|
|
82
|
+
)}
|
|
83
|
+
<div class:list={["article-grid", { "article-grid--single": !presentation.aside }]}>
|
|
84
|
+
{presentation.aside && (
|
|
85
|
+
<aside>
|
|
86
|
+
<a href={blogHref}>← All articles</a>
|
|
87
|
+
{tagLinks.length > 0 && <div class="article-tags">{tagLinks.map((tag) => tag.href ? <a href={tag.href}>{tag.label}</a> : <span>{tag.label}</span>)}</div>}
|
|
88
|
+
</aside>
|
|
89
|
+
)}
|
|
90
|
+
<div class="blog-main">
|
|
91
|
+
<div class="blog-body" set:html={bodyHtml}></div>
|
|
92
|
+
{gated && !unlock && (
|
|
93
|
+
<BlogPaywall access={post.access ?? "authenticated"} next={next} signedIn={!!viewer} loginHref={loginHref} />
|
|
94
|
+
)}
|
|
95
|
+
{!presentation.aside && tagLinks.length > 0 && (
|
|
96
|
+
<ul class="post-tags" aria-label="Tags">
|
|
97
|
+
{tagLinks.map((tag) => <li>{tag.href ? <a href={tag.href}>{tag.label}</a> : <span>{tag.label}</span>}</li>)}
|
|
98
|
+
</ul>
|
|
99
|
+
)}
|
|
100
|
+
<ShareRow links={share} />
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
{presentation.reactions && <ReactionBar collection="blog" slug={post.slug} />}
|
|
104
|
+
</article>
|
|
105
|
+
</Layout>
|
|
106
|
+
|
|
107
|
+
<style>
|
|
108
|
+
.article-shell { width: min(1120px, calc(100% - 2rem)); margin-inline: auto; padding-block: clamp(2rem, 5vw, 5rem); }
|
|
109
|
+
.article-shell :global(.breadcrumbs) { font-size: .84rem; }
|
|
110
|
+
.article-shell :global(.breadcrumbs a) { color: var(--color-accent-ink); font-weight: 700; }
|
|
111
|
+
header { max-width: 880px; margin: clamp(2.5rem, 6vw, 5rem) auto clamp(2rem, 5vw, 3.5rem); text-align: center; }
|
|
112
|
+
header .eyebrow { justify-content: center; }
|
|
113
|
+
h1 { margin-top: .9rem; font-size: clamp(2.5rem, 6vw, 5rem); line-height: 1.02; }
|
|
114
|
+
.dek { max-width: 68ch; margin: 1.5rem auto 0; color: var(--color-muted); font-size: clamp(1rem, 2vw, 1.2rem); }
|
|
115
|
+
.byline { display: flex; justify-content: center; gap: .55rem; margin-top: 1.35rem; color: var(--color-muted); font-size: .85rem; }
|
|
116
|
+
.byline-author { color: var(--color-accent-ink); font-weight: 700; text-decoration: none; }
|
|
117
|
+
.byline-author:hover { text-decoration: underline; text-underline-offset: .18em; }
|
|
118
|
+
.lang-switcher { display: flex; flex-wrap: wrap; justify-content: center; gap: .4rem; margin-top: 1.1rem; }
|
|
119
|
+
.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; }
|
|
120
|
+
.lang-switcher a:hover { color: var(--color-accent-ink); border-color: var(--color-accent); }
|
|
121
|
+
.lang-switcher a[aria-current="true"] { color: var(--color-accent-ink); border-color: var(--color-accent); background: var(--color-surface); }
|
|
122
|
+
.featured { display: block; width: 100%; max-height: 620px; object-fit: cover; border-radius: var(--radius-lg); box-shadow: var(--shadow-md); }
|
|
123
|
+
.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); }
|
|
124
|
+
aside { align-self: start; position: sticky; top: 7rem; }
|
|
125
|
+
aside > a { color: var(--color-accent-ink); font-size: .88rem; font-weight: 700; }
|
|
126
|
+
.article-grid--single { grid-template-columns: minmax(0, 720px); }
|
|
127
|
+
.article-tags { display: flex; flex-wrap: wrap; gap: .45rem; margin-top: 1.2rem; }
|
|
128
|
+
.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; }
|
|
129
|
+
.article-tags a:hover, .post-tags a:hover { color: var(--color-accent-ink); border-color: var(--color-accent); }
|
|
130
|
+
.post-tags { display: flex; flex-wrap: wrap; gap: .45rem; margin: 2rem 0 0; padding: 0; list-style: none; }
|
|
131
|
+
.blog-main { min-width: 0; }
|
|
132
|
+
.blog-body { min-width: 0; font-size: 1.05rem; line-height: 1.78; }
|
|
133
|
+
.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; }
|
|
134
|
+
.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; }
|
|
135
|
+
.blog-body :global(h2) { font-size: clamp(1.75rem, 3vw, 2.35rem); }
|
|
136
|
+
.blog-body :global(h3) { font-size: clamp(1.35rem, 2.4vw, 1.75rem); }
|
|
137
|
+
.blog-body :global(a) { color: var(--color-accent-ink); text-decoration: underline; text-underline-offset: .18em; }
|
|
138
|
+
.blog-body :global(ul), .blog-body :global(ol) { padding-left: 1.4rem; }
|
|
139
|
+
.blog-body :global(li) { margin-block: .45rem; }
|
|
140
|
+
.blog-body :global(blockquote) { padding: .8rem 1.25rem; border-left: 4px solid var(--color-accent); background: var(--color-surface); color: var(--color-muted); }
|
|
141
|
+
.blog-body :global(img) { display: block; width: auto; max-width: 100%; margin-inline: auto; border-radius: var(--radius-md); }
|
|
142
|
+
.blog-body :global(figcaption) { margin-top: .5rem; color: var(--color-muted); text-align: center; font-size: .78rem; }
|
|
143
|
+
.blog-body :global(table) { display: block; width: 100%; overflow-x: auto; border-collapse: collapse; }
|
|
144
|
+
.blog-body :global(th), .blog-body :global(td) { padding: .7rem; border: 1px solid var(--color-border); text-align: left; }
|
|
145
|
+
@media (max-width: 760px) { .article-grid { grid-template-columns: 1fr; } aside { position: static; } .article-tags { display: none; } }
|
|
146
|
+
</style>
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type PublicationCollectionV1,
|
|
12
12
|
type PublicationRoutePlanV1,
|
|
13
13
|
} from "@tot/public-runtime";
|
|
14
|
+
import type { BlogSettings } from "./types.js";
|
|
14
15
|
|
|
15
16
|
export interface CollectionPathsV1 {
|
|
16
17
|
/** The collection index, page 1. */
|
|
@@ -23,10 +24,21 @@ export interface CollectionPathsV1 {
|
|
|
23
24
|
rss: string;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* The collection paths for the space the request resolved to (the root space otherwise).
|
|
29
|
+
*
|
|
30
|
+
* `opts.postBase`, when given, replaces the collection's declared base for the `post` route
|
|
31
|
+
* only — every other route (index, page, author, category, tag, rss) keeps resolving from the
|
|
32
|
+
* manifest as before. This is the one constructor a tenant's declared
|
|
33
|
+
* `BlogSettings.postsAtRoot` goes through (see `./types.ts`): callers pass `postBase: ""` to
|
|
34
|
+
* serve posts at the mount root while the archive stays at its usual base. Still a single
|
|
35
|
+
* spelling of the path shape — never a second one — because every call still ends in
|
|
36
|
+
* `publicationCollectionPathV1`.
|
|
37
|
+
*/
|
|
27
38
|
export function collectionPathsV1(
|
|
28
39
|
locals: { publicationRoute?: PublicationRoutePlanV1 },
|
|
29
40
|
collection: PublicationCollectionV1,
|
|
41
|
+
opts?: { postBase?: string },
|
|
30
42
|
): CollectionPathsV1 {
|
|
31
43
|
const plan = locals.publicationRoute;
|
|
32
44
|
const manifest = plan && plan.action === "render" ? plan.manifest : null;
|
|
@@ -34,13 +46,22 @@ export function collectionPathsV1(
|
|
|
34
46
|
const base = publicationCollectionBaseV1(manifest, collection);
|
|
35
47
|
const path = (route: Parameters<typeof publicationCollectionPathV1>[2]) =>
|
|
36
48
|
publicationCollectionPathV1(mount, base, route);
|
|
49
|
+
const postBase = opts?.postBase;
|
|
37
50
|
return {
|
|
38
51
|
index: path({ kind: "index" }),
|
|
39
52
|
page: (page) => path({ kind: "page", page }),
|
|
40
|
-
post: (slug) =>
|
|
53
|
+
post: (slug) =>
|
|
54
|
+
postBase !== undefined
|
|
55
|
+
? publicationCollectionPathV1(mount, postBase, { kind: "post", slug })
|
|
56
|
+
: path({ kind: "post", slug }),
|
|
41
57
|
author: (slug) => path({ kind: "author", slug }),
|
|
42
58
|
category: (slug) => path({ kind: "category", slug }),
|
|
43
59
|
tag: (slug) => path({ kind: "tag", slug }),
|
|
44
60
|
rss: path({ kind: "rss" }),
|
|
45
61
|
};
|
|
46
62
|
}
|
|
63
|
+
|
|
64
|
+
/** `collectionPathsV1`'s `postBase` override for `/blog`, from the tenant's own declared settings. */
|
|
65
|
+
export function blogPathOptsV1(settings: BlogSettings | null | undefined): { postBase?: string } {
|
|
66
|
+
return settings?.postsAtRoot ? { postBase: "" } : {};
|
|
67
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The resolved view for one blog post — the frontmatter `pages/blog/[slug].astro` used to compute
|
|
3
|
+
* inline, factored out so a SECOND route can serve the same post at a different URL shape.
|
|
4
|
+
* `pages/[...slug].astro` calls this when the tenant declares `BlogSettings.postsAtRoot` (see
|
|
5
|
+
* `./types.js`), so a root-level request renders the identical article the `/blog/<slug>` route
|
|
6
|
+
* would, through the one resolver rather than a second re-implementation.
|
|
7
|
+
*/
|
|
8
|
+
import type { AstroGlobal } from "astro";
|
|
9
|
+
import { getBlogPost, getBlogSettings, listBlogPosts, resolveBlogRedirect } from "./provider.js";
|
|
10
|
+
import { authorSlug, categorySlug, getTranslations } from "./collection.js";
|
|
11
|
+
import {
|
|
12
|
+
bylineAuthor,
|
|
13
|
+
resolveBlogPresentation,
|
|
14
|
+
shareLinks,
|
|
15
|
+
type BlogPresentation,
|
|
16
|
+
type ShareLink,
|
|
17
|
+
} from "./presentation.js";
|
|
18
|
+
import { homeCrumbLabel, siteTrail, type Crumb } from "@/lib/breadcrumbs.js";
|
|
19
|
+
import { viewerHasBlogAccess } from "./access.js";
|
|
20
|
+
import { articleBodyHtml } from "./teaser.js";
|
|
21
|
+
import { readViewerSession, safeNext } from "@/lib/auth/route.js";
|
|
22
|
+
import { buildAlternates, type Alternate } from "@/lib/seo/alternates.js";
|
|
23
|
+
import { breadcrumbLd, blogPostingLd } from "@/lib/jsonld.js";
|
|
24
|
+
import { collectionPathsV1, blogPathOptsV1 } from "./collectionPaths.js";
|
|
25
|
+
import type { BlogPost } from "./types.js";
|
|
26
|
+
import type { SessionRecord } from "@/lib/auth/session.js";
|
|
27
|
+
|
|
28
|
+
export interface BlogPostViewPropsV1 {
|
|
29
|
+
post: BlogPost;
|
|
30
|
+
/** Tenant-relative path this post actually resolved at (root or `/blog/<slug>`). */
|
|
31
|
+
path: string;
|
|
32
|
+
bodyHtml: string;
|
|
33
|
+
gated: boolean;
|
|
34
|
+
unlock: boolean;
|
|
35
|
+
next: string;
|
|
36
|
+
loginHref: string;
|
|
37
|
+
viewer: SessionRecord | null;
|
|
38
|
+
canonical: string;
|
|
39
|
+
presentation: BlogPresentation;
|
|
40
|
+
author: string | null;
|
|
41
|
+
authorHref: string | null;
|
|
42
|
+
authorUrl: string | undefined;
|
|
43
|
+
tagLinks: { label: string; href: string | null }[];
|
|
44
|
+
articleLd: ReturnType<typeof blogPostingLd>;
|
|
45
|
+
trail: Crumb[];
|
|
46
|
+
crumbs: ReturnType<typeof breadcrumbLd>;
|
|
47
|
+
blogHref: string;
|
|
48
|
+
share: ShareLink[];
|
|
49
|
+
translationLinks: { code: string; href: string; current: boolean }[];
|
|
50
|
+
alternates: Alternate[];
|
|
51
|
+
lang: string | undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type BlogPostViewResultV1 =
|
|
55
|
+
| { status: "not_found" }
|
|
56
|
+
| { status: "redirect"; to: string }
|
|
57
|
+
| { status: "ok"; props: BlogPostViewPropsV1 };
|
|
58
|
+
|
|
59
|
+
/** Resolve one post, by slug, into everything `BlogPostArticle.astro` renders. */
|
|
60
|
+
export async function loadBlogPostViewV1(Astro: AstroGlobal, slug: string): Promise<BlogPostViewResultV1> {
|
|
61
|
+
const { tenant, canonicalBase, basePath, content, publicationTimestamp } = Astro.locals;
|
|
62
|
+
const blogSettings = await getBlogSettings(tenant.tenant_id, content);
|
|
63
|
+
const blogPaths = collectionPathsV1(Astro.locals, "blog", blogPathOptsV1(blogSettings));
|
|
64
|
+
const post = await getBlogPost(tenant.tenant_id, slug, content, publicationTimestamp);
|
|
65
|
+
if (!post) {
|
|
66
|
+
const redirect = await resolveBlogRedirect(tenant.tenant_id, slug, content);
|
|
67
|
+
if (redirect) return { status: "redirect", to: `${basePath}${blogPaths.post(redirect)}` };
|
|
68
|
+
return { status: "not_found" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const path = blogPaths.post(post.slug);
|
|
72
|
+
const viewer = await readViewerSession(Astro);
|
|
73
|
+
const gated = (post.access ?? "public") !== "public";
|
|
74
|
+
const unlock = viewerHasBlogAccess(viewer, post);
|
|
75
|
+
const bodyHtml = articleBodyHtml(post.contentHtml, { gated, unlock });
|
|
76
|
+
const next = safeNext(`${basePath}${blogPaths.post(post.slug)}`);
|
|
77
|
+
const loginHref = `/auth/login?next=${encodeURIComponent(next)}`;
|
|
78
|
+
|
|
79
|
+
const canonical = new URL(blogPaths.post(post.slug), canonicalBase + "/").toString();
|
|
80
|
+
const presentation = resolveBlogPresentation(blogSettings);
|
|
81
|
+
const author = bylineAuthor(post.author);
|
|
82
|
+
|
|
83
|
+
const listed = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp);
|
|
84
|
+
const authorArchiveSlug = author ? authorSlug(author) : "";
|
|
85
|
+
const authorHasArchive = authorArchiveSlug !== "" && listed.some(
|
|
86
|
+
(p) => authorSlug(p.author) === authorArchiveSlug,
|
|
87
|
+
);
|
|
88
|
+
const archivedTags = new Set(listed.flatMap((p) => p.tags.map(categorySlug)));
|
|
89
|
+
const tagLinks = post.tags.map((tag) => {
|
|
90
|
+
const tagSlug = categorySlug(tag);
|
|
91
|
+
return { label: tag, href: tagSlug && archivedTags.has(tagSlug) ? `${basePath}${blogPaths.tag(tagSlug)}` : null };
|
|
92
|
+
});
|
|
93
|
+
const authorUrl = authorHasArchive
|
|
94
|
+
? new URL(blogPaths.author(authorArchiveSlug), canonicalBase + "/").toString()
|
|
95
|
+
: undefined;
|
|
96
|
+
const articleLd = blogPostingLd(post, {
|
|
97
|
+
url: canonical,
|
|
98
|
+
canonicalBase,
|
|
99
|
+
publisherName: tenant.displayName,
|
|
100
|
+
authorUrl,
|
|
101
|
+
});
|
|
102
|
+
const trail = siteTrail(
|
|
103
|
+
homeCrumbLabel(await content.getChrome()),
|
|
104
|
+
{ label: "Blog", href: blogPaths.index },
|
|
105
|
+
{ label: post.title, href: blogPaths.post(post.slug) },
|
|
106
|
+
);
|
|
107
|
+
const crumbs = breadcrumbLd(trail, canonicalBase);
|
|
108
|
+
const blogHref = `${basePath}${blogPaths.index}`;
|
|
109
|
+
const authorHref = authorHasArchive ? `${basePath}${blogPaths.author(authorArchiveSlug)}` : null;
|
|
110
|
+
const share = shareLinks(presentation.share, {
|
|
111
|
+
url: canonical,
|
|
112
|
+
title: post.title,
|
|
113
|
+
image: post.featuredImage ? new URL(post.featuredImage.src, canonicalBase + "/").toString() : undefined,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const translations = post.translationGroupId
|
|
117
|
+
? await getTranslations(tenant.tenant_id, "blog", post.slug, { content, now: publicationTimestamp })
|
|
118
|
+
: [];
|
|
119
|
+
const alternates = buildAlternates(translations, (s) =>
|
|
120
|
+
new URL(blogPaths.post(s), canonicalBase + "/").toString());
|
|
121
|
+
const lang = alternates.length ? (post.inLanguage ?? "en") : undefined;
|
|
122
|
+
// The on-page language switcher stays same-origin (preview-host-relative), unlike `alternates`
|
|
123
|
+
// (absolute canonical URLs for the <link rel="alternate"> head tags) — a path-prefixed preview
|
|
124
|
+
// host must never send a reader off to the production domain.
|
|
125
|
+
const translationLinks = translations.length > 1
|
|
126
|
+
? translations.map((variant) => ({
|
|
127
|
+
code: variant.inLanguage ?? "en",
|
|
128
|
+
href: `${basePath}${blogPaths.post(variant.slug)}`,
|
|
129
|
+
current: variant.slug === post.slug,
|
|
130
|
+
}))
|
|
131
|
+
: [];
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
status: "ok",
|
|
135
|
+
props: {
|
|
136
|
+
post, path, bodyHtml, gated, unlock, next, loginHref, viewer, canonical, presentation, author,
|
|
137
|
+
authorHref, authorUrl, tagLinks, articleLd, trail, crumbs, blogHref, share, translationLinks,
|
|
138
|
+
alternates, lang,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -77,6 +77,16 @@ export interface BlogSettings {
|
|
|
77
77
|
reactions?: boolean;
|
|
78
78
|
/** Share row after the post's tags, in this order. `[]` renders none; absent uses `DEFAULT_SHARE_TARGETS`. */
|
|
79
79
|
share?: string[];
|
|
80
|
+
/**
|
|
81
|
+
* Declared permalink shape: `true` serves individual posts at the site root
|
|
82
|
+
* (`/<slug>/`, no `/blog` prefix), matching a migrated source whose archive stays
|
|
83
|
+
* at `/blog/` but whose singular posts never carried that prefix (a common
|
|
84
|
+
* WordPress permalink split: a static "posts page" plus `/%postname%/`).
|
|
85
|
+
* Default false/absent keeps every post under `/blog/<slug>/`. The index, author,
|
|
86
|
+
* category and tag archives are unaffected either way — only the `post` route
|
|
87
|
+
* moves. Declared per tenant; never branched on tenant id.
|
|
88
|
+
*/
|
|
89
|
+
postsAtRoot?: boolean;
|
|
80
90
|
}
|
|
81
91
|
|
|
82
92
|
/**
|
|
@@ -48,6 +48,11 @@ export {
|
|
|
48
48
|
type ChromeHeader,
|
|
49
49
|
type ChromeFooter,
|
|
50
50
|
type ChromeConfig,
|
|
51
|
+
type ProductDetailsConfig,
|
|
52
|
+
type ProductDetailSpecRow,
|
|
53
|
+
type ProductAttributeLine,
|
|
54
|
+
type ProductMetafieldRef,
|
|
55
|
+
type ProductDetailFaqConfig,
|
|
51
56
|
type ChromeAssignmentEntry,
|
|
52
57
|
type ChromeAssignments,
|
|
53
58
|
type ChromeAssignmentsValidationResult,
|
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
* Paths are spelled exactly as the root sitemap lists them.
|
|
6
6
|
*/
|
|
7
7
|
import type { APIContext } from "astro";
|
|
8
|
-
import { listBlogPosts } from "@/lib/blog/provider.js";
|
|
8
|
+
import { getBlogSettings, listBlogPosts } from "@/lib/blog/provider.js";
|
|
9
9
|
import { listBuildPosts } from "@/lib/the-build/provider.js";
|
|
10
10
|
import { isIndexableHtml, rawPageCanonicalUrl } from "@/lib/rawMarketingHtml.js";
|
|
11
|
-
import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
|
|
11
|
+
import { collectionPathsV1, blogPathOptsV1 } from "@/lib/blog/collectionPaths.js";
|
|
12
12
|
|
|
13
13
|
export interface RootDiscoveryDocumentV1 {
|
|
14
14
|
kind: "home" | "page" | "post";
|
|
@@ -33,11 +33,12 @@ function htmlTitle(html: string): string {
|
|
|
33
33
|
/** Root listable posts (blog and the-build), newest first as the collections list them. */
|
|
34
34
|
export async function rootPostDocumentsV1(locals: APIContext["locals"]): Promise<RootDiscoveryDocumentV1[]> {
|
|
35
35
|
const { tenant, content, publicationTimestamp } = locals;
|
|
36
|
-
const [blogPosts, buildPosts] = await Promise.all([
|
|
36
|
+
const [blogPosts, buildPosts, blogSettings] = await Promise.all([
|
|
37
37
|
listBlogPosts(tenant.tenant_id, content, publicationTimestamp),
|
|
38
38
|
listBuildPosts(tenant.tenant_id, { content, now: publicationTimestamp }),
|
|
39
|
+
getBlogSettings(tenant.tenant_id, content),
|
|
39
40
|
]);
|
|
40
|
-
const blogPaths = collectionPathsV1(locals, "blog");
|
|
41
|
+
const blogPaths = collectionPathsV1(locals, "blog", blogPathOptsV1(blogSettings));
|
|
41
42
|
const buildPaths = collectionPathsV1(locals, "the-build");
|
|
42
43
|
return [
|
|
43
44
|
...blogPosts.map((post) => ({ post, paths: blogPaths })),
|
|
@@ -15,6 +15,9 @@ import { truncate } from "@/lib/format";
|
|
|
15
15
|
import { rawHtmlPolicy, rawHtmlResponse, rawPageCanonicalUrl } from "@/lib/rawMarketingHtml";
|
|
16
16
|
import { renderRawComplianceTop, renderRawComplianceBottom } from "@/lib/compliance/rawComplianceNotices";
|
|
17
17
|
import { styleGuideAliasRewrite } from "@/lib/styleGuidePaths";
|
|
18
|
+
import { getBlogSettings } from "@/lib/blog/provider.js";
|
|
19
|
+
import { loadBlogPostViewV1, type BlogPostViewPropsV1 } from "@/lib/blog/postView.js";
|
|
20
|
+
import BlogPostArticle from "@/components/blog/BlogPostArticle.astro";
|
|
18
21
|
|
|
19
22
|
const { tenant, canonicalBase, content, basePath, apiBasePath, capabilities, features } =
|
|
20
23
|
Astro.locals;
|
|
@@ -41,51 +44,73 @@ if (styleGuideTarget !== undefined) {
|
|
|
41
44
|
return Astro.rewrite(styleGuideTarget);
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
pactAct: !!tenant.compliance?.pactAct,
|
|
57
|
-
minAge: tenant.compliance?.minAge,
|
|
58
|
-
shippingRestriction: tenant.compliance?.shippingRestriction,
|
|
59
|
-
exciseTaxEnabled: capabilities.exciseTax.enabled && features.catalog,
|
|
60
|
-
};
|
|
61
|
-
return rawHtmlResponse(rawHtml, {
|
|
62
|
-
nonce: Astro.locals.cspNonce,
|
|
63
|
-
basePath,
|
|
64
|
-
apiBasePath,
|
|
65
|
-
chromeWrapper,
|
|
66
|
-
plan: Astro.locals.integrationPlan,
|
|
67
|
-
consentSnapshot: Astro.locals.consentSnapshot,
|
|
68
|
-
canonicalUrl: rawPageCanonicalUrl(canonicalBase, slug),
|
|
69
|
-
complianceTop: rawPolicy.regulated ? renderRawComplianceTop(notices) : undefined,
|
|
70
|
-
complianceBottom: rawPolicy.regulated ? renderRawComplianceBottom(notices) : undefined,
|
|
71
|
-
});
|
|
47
|
+
// A tenant that declares `BlogSettings.postsAtRoot` (see lib/blog/types.ts) serves its
|
|
48
|
+
// individual posts at the site root — a single-segment slug tries the blog collection
|
|
49
|
+
// before falling through to a raw marketing page or a CMS page. Multi-segment slugs are
|
|
50
|
+
// never posts, so this never shadows a nested marketing/CMS path.
|
|
51
|
+
let blogPostProps: BlogPostViewPropsV1 | null = null;
|
|
52
|
+
if (!slug.includes("/")) {
|
|
53
|
+
const blogSettings = await getBlogSettings(tenant.tenant_id, content);
|
|
54
|
+
if (blogSettings?.postsAtRoot) {
|
|
55
|
+
const result = await loadBlogPostViewV1(Astro, slug);
|
|
56
|
+
if (result.status === "redirect") return Astro.redirect(result.to, 308);
|
|
57
|
+
if (result.status === "ok") blogPostProps = result.props;
|
|
58
|
+
}
|
|
72
59
|
}
|
|
73
60
|
|
|
74
|
-
|
|
75
|
-
if (!
|
|
61
|
+
let page: Awaited<ReturnType<typeof content.getPage>> | null = null;
|
|
62
|
+
if (!blogPostProps) {
|
|
63
|
+
// Raw tenant-authored marketing subpage (vendor-injected HTML) — serve the
|
|
64
|
+
// self-contained document VERBATIM only when rawHtmlPolicy says this tenant may
|
|
65
|
+
// bypass Layout. Regulated commerce tenants fall through to platform-composed
|
|
66
|
+
// pages so compliance UI cannot be removed by raw HTML.
|
|
67
|
+
const rawHtml = await content.getPageHtml(slug);
|
|
68
|
+
const rawPolicy = rawHtmlPolicy(tenant);
|
|
69
|
+
if (rawHtml && rawPolicy.allowed) {
|
|
70
|
+
// Shared chrome: a body-only fragment gets wrapped in the tenant's chrome.html;
|
|
71
|
+
// a full document serves verbatim (backward-compatible). See rawMarketingHtml.ts.
|
|
72
|
+
const chromeWrapper = await content.getChromeHtml(`/${slug}`);
|
|
73
|
+
const notices = {
|
|
74
|
+
nicotineWarning: tenant.compliance?.nicotineWarning,
|
|
75
|
+
pactAct: !!tenant.compliance?.pactAct,
|
|
76
|
+
minAge: tenant.compliance?.minAge,
|
|
77
|
+
shippingRestriction: tenant.compliance?.shippingRestriction,
|
|
78
|
+
exciseTaxEnabled: capabilities.exciseTax.enabled && features.catalog,
|
|
79
|
+
};
|
|
80
|
+
return rawHtmlResponse(rawHtml, {
|
|
81
|
+
nonce: Astro.locals.cspNonce,
|
|
82
|
+
basePath,
|
|
83
|
+
apiBasePath,
|
|
84
|
+
chromeWrapper,
|
|
85
|
+
plan: Astro.locals.integrationPlan,
|
|
86
|
+
consentSnapshot: Astro.locals.consentSnapshot,
|
|
87
|
+
canonicalUrl: rawPageCanonicalUrl(canonicalBase, slug),
|
|
88
|
+
complianceTop: rawPolicy.regulated ? renderRawComplianceTop(notices) : undefined,
|
|
89
|
+
complianceBottom: rawPolicy.regulated ? renderRawComplianceBottom(notices) : undefined,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
76
92
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
93
|
+
page = await content.getPage(slug);
|
|
94
|
+
if (!page) return new Response(null, { status: 404 });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Only reached when no root-level blog post claimed this slug. `page` is always truthy by
|
|
98
|
+
// the time this renders (the block above already 404s otherwise) — `[]` here is only to
|
|
99
|
+
// keep `crumbs` non-nullable for the breadcrumbLd/<Breadcrumbs> calls below.
|
|
100
|
+
const crumbs = page
|
|
101
|
+
? siteTrail(
|
|
102
|
+
homeCrumbLabel(await Astro.locals.content.getChrome()),
|
|
103
|
+
{ label: page.title, href: `/${page.slug}` },
|
|
104
|
+
)
|
|
105
|
+
: [];
|
|
81
106
|
|
|
82
|
-
const description =
|
|
83
|
-
page.seoDescription ||
|
|
84
|
-
|
|
107
|
+
const description = page
|
|
108
|
+
? page.seoDescription || (page.intro ? truncate(page.intro, 160) : undefined)
|
|
109
|
+
: undefined;
|
|
85
110
|
|
|
86
111
|
// Structured pages drive the body from `sections`; legacy pages keep the
|
|
87
112
|
// sanitized `body_html`. Prefer structured whenever a page ships sections.
|
|
88
|
-
const structured = (page
|
|
113
|
+
const structured = (page?.sections?.length ?? 0) > 0;
|
|
89
114
|
|
|
90
115
|
// Reviewer-only needs-review flags render in preview/dev only — mirrors the
|
|
91
116
|
// middleware's "production read" definition (no preview host / token / cookie),
|
|
@@ -96,7 +121,7 @@ const isPreview =
|
|
|
96
121
|
Astro.url.searchParams.has("__preview") ||
|
|
97
122
|
Astro.cookies.has("tot_preview");
|
|
98
123
|
|
|
99
|
-
const updatedLabel = page
|
|
124
|
+
const updatedLabel = page?.updated
|
|
100
125
|
? new Date(page.updated).toLocaleDateString("en-US", {
|
|
101
126
|
year: "numeric",
|
|
102
127
|
month: "long",
|
|
@@ -105,44 +130,48 @@ const updatedLabel = page.updated
|
|
|
105
130
|
: null;
|
|
106
131
|
---
|
|
107
132
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
133
|
+
{blogPostProps ? (
|
|
134
|
+
<BlogPostArticle {...blogPostProps} />
|
|
135
|
+
) : (
|
|
136
|
+
<Layout
|
|
137
|
+
title={page!.title}
|
|
138
|
+
seoTitle={page!.seoTitle}
|
|
139
|
+
description={description}
|
|
140
|
+
path={`/${page!.slug}`}
|
|
141
|
+
ogType="article"
|
|
142
|
+
jsonLd={breadcrumbLd(crumbs, canonicalBase)}
|
|
143
|
+
>
|
|
144
|
+
<div class="container-prose pt-6">
|
|
145
|
+
<Breadcrumbs crumbs={crumbs} />
|
|
146
|
+
</div>
|
|
119
147
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
148
|
+
{/* Page hero */}
|
|
149
|
+
<header class="container-prose pb-2 pt-8">
|
|
150
|
+
{page!.eyebrow && <p class="eyebrow eyebrow--tick mb-3">{page!.eyebrow}</p>}
|
|
151
|
+
<h1 class="display-xl max-w-[20ch]">
|
|
152
|
+
{page!.title}
|
|
153
|
+
</h1>
|
|
154
|
+
{page!.intro && (
|
|
155
|
+
<p class="mt-5 max-w-[60ch] text-[1.1rem] text-[var(--color-muted)]">
|
|
156
|
+
{page!.intro}
|
|
157
|
+
</p>
|
|
158
|
+
)}
|
|
159
|
+
{updatedLabel && (
|
|
160
|
+
<p class="mt-3 text-sm text-[var(--color-muted)]">Last updated {updatedLabel}</p>
|
|
161
|
+
)}
|
|
162
|
+
{structured && <ProseToc sections={page!.sections!} />}
|
|
163
|
+
</header>
|
|
136
164
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
</Layout>
|
|
165
|
+
{/* Body — constrained to a readable measure. Structured pages render
|
|
166
|
+
framework widgets from `sections`; legacy pages keep the sanitized HTML. */}
|
|
167
|
+
<div class="container-prose pb-[clamp(3rem,7vw,6rem)] pt-8">
|
|
168
|
+
{structured ? (
|
|
169
|
+
<div class="mx-auto max-w-[68ch]">
|
|
170
|
+
<ProseSections sections={page!.sections!} includeNeedsReview={isPreview} />
|
|
171
|
+
</div>
|
|
172
|
+
) : (
|
|
173
|
+
<div class="rte mx-auto max-w-[68ch] text-[var(--color-text)]" set:html={page!.body_html} />
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
</Layout>
|
|
177
|
+
)}
|