@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.
Files changed (36) hide show
  1. package/apps/storefront/src/components/Breadcrumbs.astro +3 -2
  2. package/apps/storefront/src/components/CollectionCard.astro +3 -2
  3. package/apps/storefront/src/components/ProductCard.astro +2 -1
  4. package/apps/storefront/src/components/Seo.astro +6 -4
  5. package/apps/storefront/src/components/blog/BlogIndexView.astro +7 -2
  6. package/apps/storefront/src/components/blog/BlogPostArticle.astro +156 -0
  7. package/apps/storefront/src/components/home/Hero.astro +2 -1
  8. package/apps/storefront/src/components/marketing/Faq.astro +7 -2
  9. package/apps/storefront/src/layouts/Layout.astro +25 -5
  10. package/apps/storefront/src/lib/blog/collectionPaths.ts +23 -2
  11. package/apps/storefront/src/lib/blog/postView.ts +141 -0
  12. package/apps/storefront/src/lib/blog/types.ts +10 -0
  13. package/apps/storefront/src/lib/publication/rootDiscovery.ts +5 -4
  14. package/apps/storefront/src/lib/seo/faqItems.ts +63 -0
  15. package/apps/storefront/src/lib/seo/structuredData.ts +267 -0
  16. package/apps/storefront/src/lib/seo/urlForm.ts +40 -0
  17. package/apps/storefront/src/lib/storyblok/content-model.ts +7 -0
  18. package/apps/storefront/src/lib/storyblok/provider.ts +4 -0
  19. package/apps/storefront/src/pages/[...slug].astro +122 -78
  20. package/apps/storefront/src/pages/blog/[slug].astro +8 -218
  21. package/apps/storefront/src/pages/blog/author/[author].astro +5 -3
  22. package/apps/storefront/src/pages/blog/category/[category].astro +5 -3
  23. package/apps/storefront/src/pages/blog/index.astro +1 -0
  24. package/apps/storefront/src/pages/blog/page/[n].astro +1 -0
  25. package/apps/storefront/src/pages/blog/tag/[tag].astro +5 -3
  26. package/apps/storefront/src/pages/collections/[handle].astro +8 -1
  27. package/apps/storefront/src/pages/collections/index.astro +1 -0
  28. package/apps/storefront/src/pages/index.astro +8 -0
  29. package/apps/storefront/src/pages/llms.txt.ts +5 -4
  30. package/apps/storefront/src/pages/products/[handle].astro +11 -1
  31. package/apps/storefront/src/pages/sitemap.xml.ts +5 -4
  32. package/package.json +1 -1
  33. package/packages/cli/src/declared-config.mjs +115 -1
  34. package/packages/public-runtime/src/declared-tenant-config.d.mts +8 -1
  35. package/packages/public-runtime/src/declared-tenant-config.mjs +109 -1
  36. package/packages/public-runtime/src/tenant.ts +40 -0
@@ -14,6 +14,7 @@
14
14
  * `class` adds a hook for the page's own styles (`breadcrumbs` on the blog templates).
15
15
  */
16
16
  import { withBase } from "@/lib/basePath";
17
+ import { pageUrlForm } from "@/lib/seo/urlForm";
17
18
  import type { Crumb } from "@/lib/breadcrumbs";
18
19
 
19
20
  interface Props {
@@ -22,7 +23,7 @@ interface Props {
22
23
  }
23
24
 
24
25
  const { crumbs, class: className } = Astro.props;
25
- const { basePath } = Astro.locals;
26
+ const { basePath, tenant } = Astro.locals;
26
27
  ---
27
28
 
28
29
  <nav aria-label="Breadcrumb" class:list={["text-sm text-muted", className]}>
@@ -43,7 +44,7 @@ const { basePath } = Astro.locals;
43
44
  </span>
44
45
  ) : (
45
46
  <a
46
- href={withBase(basePath, crumb.href)}
47
+ href={withBase(basePath, pageUrlForm(crumb.href, tenant.seo))}
47
48
  class="inline-flex min-h-11 items-center transition-colors hover:text-primary"
48
49
  >
49
50
  {crumb.label}
@@ -7,6 +7,7 @@
7
7
  import type { CatalogCollection } from "@tot/public-runtime";
8
8
  import Img from "@/components/Img.astro";
9
9
  import { withBase } from "@/lib/basePath";
10
+ import { pageUrlForm } from "@/lib/seo/urlForm";
10
11
 
11
12
  interface Props {
12
13
  collection: CatalogCollection;
@@ -14,12 +15,12 @@ interface Props {
14
15
  }
15
16
 
16
17
  const { collection, productCount } = Astro.props;
17
- const { basePath } = Astro.locals;
18
+ const { basePath, tenant } = Astro.locals;
18
19
  const image = collection.image;
19
20
  ---
20
21
 
21
22
  <a
22
- href={withBase(basePath, `/collections/${collection.handle}`)}
23
+ href={withBase(basePath, pageUrlForm(`/collections/${collection.handle}`, tenant.seo))}
23
24
  data-astro-prefetch
24
25
  class="group relative block aspect-[3/2] overflow-hidden rounded-md bg-border"
25
26
  data-reveal
@@ -15,6 +15,7 @@ import Badge from "@/components/Badge.astro";
15
15
  import PriceDisplay from "@/components/commerce/PriceDisplay.astro";
16
16
  import RatingStars from "@/components/commerce/RatingStars.astro";
17
17
  import { withBase } from "@/lib/basePath";
18
+ import { pageUrlForm } from "@/lib/seo/urlForm";
18
19
 
19
20
  interface Props {
20
21
  product: CatalogProduct;
@@ -189,7 +190,7 @@ const quickview = {
189
190
  {/* `py-3` on an inline box raises the tap target to the 44px minimum
190
191
  without changing the line box, so the card layout is untouched. */}
191
192
  <a
192
- href={withBase(basePath, `/products/${product.handle}`)}
193
+ href={withBase(basePath, pageUrlForm(`/products/${product.handle}`, tenant.seo))}
193
194
  data-astro-prefetch
194
195
  class="py-3 after:absolute after:inset-0 after:z-0 transition-colors motion-safe:group-hover:text-primary"
195
196
  >
@@ -7,6 +7,7 @@ import { articleSeoMetaTags, resolveOgImage, type ArticleMeta } from "@/lib/seo/
7
7
  import { withXDefault, type Alternate } from "@/lib/seo/alternates.js";
8
8
  import { safeJsonScriptContent } from "@/lib/jsonScript";
9
9
  import { documentTitle } from "@/lib/seo/documentTitle";
10
+ import { canonicalUrl } from "@/lib/seo/urlForm";
10
11
 
11
12
  interface JsonLd {
12
13
  [key: string]: unknown;
@@ -19,7 +20,8 @@ interface Props {
19
20
  * Absent ⇒ `title`, suffixed with the store's display name. */
20
21
  seoTitle?: string;
21
22
  description?: string;
22
- /** Path-only canonical, e.g. /products/foo. Joined to tenant canonical base. */
23
+ /** Path-only canonical, e.g. /products/foo. Joined to tenant canonical base, in the
24
+ * tenant's declared URL form (`seo.trailingSlash`). */
23
25
  path: string;
24
26
  ogType?: "website" | "product" | "article";
25
27
  ogImage?: string;
@@ -73,7 +75,7 @@ const {
73
75
 
74
76
  const { tenant, canonicalBase } = Astro.locals;
75
77
  const canonical =
76
- canonicalOverride ?? new URL(path, canonicalBase + "/").toString();
78
+ canonicalOverride ?? canonicalUrl(path, canonicalBase, tenant.seo);
77
79
  const fullTitle = documentTitle(title, seoTitle, tenant.displayName);
78
80
  const image = resolveOgImage({
79
81
  ogImage, themeOgImage: tenant.theme_tokens?.brand?.ogImage ?? null, canonicalBase,
@@ -91,8 +93,8 @@ const articleMeta = articleSeoMetaTags({
91
93
  const graphs = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
92
94
  const alternateLinks = withXDefault(alternates);
93
95
  const paginationLinks = [
94
- ...(pagination?.prev ? [{ rel: "prev", href: new URL(pagination.prev, canonicalBase + "/").toString() }] : []),
95
- ...(pagination?.next ? [{ rel: "next", href: new URL(pagination.next, canonicalBase + "/").toString() }] : []),
96
+ ...(pagination?.prev ? [{ rel: "prev", href: canonicalUrl(pagination.prev, canonicalBase, tenant.seo) }] : []),
97
+ ...(pagination?.next ? [{ rel: "next", href: canonicalUrl(pagination.next, canonicalBase, tenant.seo) }] : []),
96
98
  ];
97
99
  ---
98
100
 
@@ -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 { basePath, canonicalBase, copy, presentation, tags, pagePosts, page, totalPages } = Astro.props;
26
- const blogPaths = collectionPathsV1(Astro.locals, "blog");
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}` || "/";
@@ -56,6 +60,7 @@ const itemList = {
56
60
  path={path}
57
61
  fullBleed
58
62
  jsonLd={itemList}
63
+ structuredData={{ pageType: "collection" }}
59
64
  pagination={pagination}
60
65
  >
61
66
  <header class:list={["blog-hero", { "blog-hero--plain": !presentation.hero }]}>
@@ -0,0 +1,156 @@
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
+ structuredData={{
36
+ pageType: "post",
37
+ headline: post.title,
38
+ description: post.description,
39
+ datePublished: post.publishedAt,
40
+ dateModified: post.modifiedAt,
41
+ image: post.featuredImage?.src,
42
+ author: author ? { name: author, url: authorUrl } : null,
43
+ crumbs: trail,
44
+ }}
45
+ article={{
46
+ publishedTime: post.publishedAt,
47
+ modifiedTime: post.modifiedAt,
48
+ author: authorUrl,
49
+ section: post.categories[0],
50
+ tags: post.tags,
51
+ }}
52
+ imageAlt={post.featuredImage?.alt}
53
+ imageWidth={post.featuredImage?.width}
54
+ imageHeight={post.featuredImage?.height}
55
+ canonicalOverride={post.canonicalUrl}
56
+ alternates={alternates}
57
+ lang={lang}
58
+ >
59
+ <article class="article-shell">
60
+ <Breadcrumbs crumbs={trail} class="breadcrumbs" />
61
+ <header>
62
+ {presentation.postEyebrow && post.tags[0] && <p class="eyebrow eyebrow--tick">{post.tags[0]}</p>}
63
+ <h1>{post.title}</h1>
64
+ {presentation.dek && post.excerpt && <p class="dek">{post.excerpt}</p>}
65
+ <div class="byline">
66
+ {author && <span>By {authorHref
67
+ ? <a href={authorHref} class="byline-author" rel="author">{author}</a>
68
+ : <span class="byline-author">{author}</span>}</span>}
69
+ {author && <span aria-hidden="true">·</span>}
70
+ <time datetime={post.publishedAt}>{new Date(post.publishedAt).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}</time>
71
+ </div>
72
+ {translationLinks.length > 1 && (
73
+ <nav class="lang-switcher" aria-label="Available languages">
74
+ {translationLinks.map(({ code, href, current }) => (
75
+ <a hreflang={code} href={href} aria-current={current ? "true" : undefined}>
76
+ {languageLabel(code)}
77
+ </a>
78
+ ))}
79
+ </nav>
80
+ )}
81
+ </header>
82
+ {post.featuredImage && (
83
+ <img
84
+ class="featured"
85
+ src={post.featuredImage.src}
86
+ alt={post.featuredImage.alt}
87
+ width={post.featuredImage.width}
88
+ height={post.featuredImage.height}
89
+ fetchpriority="high"
90
+ decoding="async"
91
+ />
92
+ )}
93
+ <div class:list={["article-grid", { "article-grid--single": !presentation.aside }]}>
94
+ {presentation.aside && (
95
+ <aside>
96
+ <a href={blogHref}>← All articles</a>
97
+ {tagLinks.length > 0 && <div class="article-tags">{tagLinks.map((tag) => tag.href ? <a href={tag.href}>{tag.label}</a> : <span>{tag.label}</span>)}</div>}
98
+ </aside>
99
+ )}
100
+ <div class="blog-main">
101
+ <div class="blog-body" set:html={bodyHtml}></div>
102
+ {gated && !unlock && (
103
+ <BlogPaywall access={post.access ?? "authenticated"} next={next} signedIn={!!viewer} loginHref={loginHref} />
104
+ )}
105
+ {!presentation.aside && tagLinks.length > 0 && (
106
+ <ul class="post-tags" aria-label="Tags">
107
+ {tagLinks.map((tag) => <li>{tag.href ? <a href={tag.href}>{tag.label}</a> : <span>{tag.label}</span>}</li>)}
108
+ </ul>
109
+ )}
110
+ <ShareRow links={share} />
111
+ </div>
112
+ </div>
113
+ {presentation.reactions && <ReactionBar collection="blog" slug={post.slug} />}
114
+ </article>
115
+ </Layout>
116
+
117
+ <style>
118
+ .article-shell { width: min(1120px, calc(100% - 2rem)); margin-inline: auto; padding-block: clamp(2rem, 5vw, 5rem); }
119
+ .article-shell :global(.breadcrumbs) { font-size: .84rem; }
120
+ .article-shell :global(.breadcrumbs a) { color: var(--color-accent-ink); font-weight: 700; }
121
+ header { max-width: 880px; margin: clamp(2.5rem, 6vw, 5rem) auto clamp(2rem, 5vw, 3.5rem); text-align: center; }
122
+ header .eyebrow { justify-content: center; }
123
+ h1 { margin-top: .9rem; font-size: clamp(2.5rem, 6vw, 5rem); line-height: 1.02; }
124
+ .dek { max-width: 68ch; margin: 1.5rem auto 0; color: var(--color-muted); font-size: clamp(1rem, 2vw, 1.2rem); }
125
+ .byline { display: flex; justify-content: center; gap: .55rem; margin-top: 1.35rem; color: var(--color-muted); font-size: .85rem; }
126
+ .byline-author { color: var(--color-accent-ink); font-weight: 700; text-decoration: none; }
127
+ .byline-author:hover { text-decoration: underline; text-underline-offset: .18em; }
128
+ .lang-switcher { display: flex; flex-wrap: wrap; justify-content: center; gap: .4rem; margin-top: 1.1rem; }
129
+ .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; }
130
+ .lang-switcher a:hover { color: var(--color-accent-ink); border-color: var(--color-accent); }
131
+ .lang-switcher a[aria-current="true"] { color: var(--color-accent-ink); border-color: var(--color-accent); background: var(--color-surface); }
132
+ .featured { display: block; width: 100%; max-height: 620px; object-fit: cover; border-radius: var(--radius-lg); box-shadow: var(--shadow-md); }
133
+ .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); }
134
+ aside { align-self: start; position: sticky; top: 7rem; }
135
+ aside > a { color: var(--color-accent-ink); font-size: .88rem; font-weight: 700; }
136
+ .article-grid--single { grid-template-columns: minmax(0, 720px); }
137
+ .article-tags { display: flex; flex-wrap: wrap; gap: .45rem; margin-top: 1.2rem; }
138
+ .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; }
139
+ .article-tags a:hover, .post-tags a:hover { color: var(--color-accent-ink); border-color: var(--color-accent); }
140
+ .post-tags { display: flex; flex-wrap: wrap; gap: .45rem; margin: 2rem 0 0; padding: 0; list-style: none; }
141
+ .blog-main { min-width: 0; }
142
+ .blog-body { min-width: 0; font-size: 1.05rem; line-height: 1.78; }
143
+ .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; }
144
+ .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; }
145
+ .blog-body :global(h2) { font-size: clamp(1.75rem, 3vw, 2.35rem); }
146
+ .blog-body :global(h3) { font-size: clamp(1.35rem, 2.4vw, 1.75rem); }
147
+ .blog-body :global(a) { color: var(--color-accent-ink); text-decoration: underline; text-underline-offset: .18em; }
148
+ .blog-body :global(ul), .blog-body :global(ol) { padding-left: 1.4rem; }
149
+ .blog-body :global(li) { margin-block: .45rem; }
150
+ .blog-body :global(blockquote) { padding: .8rem 1.25rem; border-left: 4px solid var(--color-accent); background: var(--color-surface); color: var(--color-muted); }
151
+ .blog-body :global(img) { display: block; width: auto; max-width: 100%; margin-inline: auto; border-radius: var(--radius-md); }
152
+ .blog-body :global(figcaption) { margin-top: .5rem; color: var(--color-muted); text-align: center; font-size: .78rem; }
153
+ .blog-body :global(table) { display: block; width: 100%; overflow-x: auto; border-collapse: collapse; }
154
+ .blog-body :global(th), .blog-body :global(td) { padding: .7rem; border: 1px solid var(--color-border); text-align: left; }
155
+ @media (max-width: 760px) { .article-grid { grid-template-columns: 1fr; } aside { position: static; } .article-tags { display: none; } }
156
+ </style>
@@ -23,6 +23,7 @@ import type { HeroBlock } from "@/lib/storyblok/content-model";
23
23
  import type { CatalogProduct } from "@tot/public-runtime";
24
24
  import Img from "@/components/Img.astro";
25
25
  import { withBase } from "@/lib/basePath";
26
+ import { pageUrlForm } from "@/lib/seo/urlForm";
26
27
  import { heroSlideshowView } from "@/lib/heroSlideshow";
27
28
 
28
29
  interface Props {
@@ -261,7 +262,7 @@ const notice = block.notice?.trim();
261
262
  />
262
263
  </div>
263
264
  <a
264
- href={withBase(basePath, `/products/${featureProduct.handle}`)}
265
+ href={withBase(basePath, pageUrlForm(`/products/${featureProduct.handle}`, Astro.locals.tenant?.seo))}
265
266
  class="absolute bottom-4 left-4 right-4 flex items-center justify-between rounded-[var(--radius-md)] bg-[var(--color-surface)]/95 px-4 py-3 text-[var(--color-text)] backdrop-blur"
266
267
  >
267
268
  <span class="font-display text-sm font-medium">{featureProduct.title}</span>
@@ -2,11 +2,14 @@
2
2
  /**
3
3
  * <Faq> — a no-JS accordion of question/answer pairs using native
4
4
  * <details>/<summary>. Emits FAQPage JSON-LD so answers are eligible for rich
5
- * results. Answers are plain text (auto-escaped).
5
+ * results — unless the tenant declares a structured-data profile, which owns every
6
+ * JSON-LD node on the page (lib/seo/structuredData.ts). Answers are plain text
7
+ * (auto-escaped).
6
8
  */
7
9
  import Section from "@/components/Section.astro";
8
10
  import type { FaqBlock } from "@/lib/storyblok/content-model";
9
11
  import { safeJsonScriptContent } from "@/lib/jsonScript";
12
+ import { structuredDataProfile } from "@/lib/seo/structuredData";
10
13
  interface Props {
11
14
  block: FaqBlock;
12
15
  }
@@ -22,6 +25,8 @@ const faqLd = {
22
25
  })),
23
26
  };
24
27
  const faqJsonLd = safeJsonScriptContent(faqLd);
28
+ // A declared structured-data profile owns every JSON-LD node on the page.
29
+ const emitsJsonLd = !structuredDataProfile(Astro.locals.tenant);
25
30
  ---
26
31
  <Section eyebrow={block.eyebrow} title={block.title} tone="surface" widget="marketing-faq">
27
32
  {block.lede && (
@@ -41,4 +46,4 @@ const faqJsonLd = safeJsonScriptContent(faqLd);
41
46
  ))}
42
47
  </div>
43
48
  </Section>
44
- <script type="application/ld+json" set:html={faqJsonLd} is:inline />
49
+ {emitsJsonLd && <script type="application/ld+json" set:html={faqJsonLd} is:inline />}
@@ -25,6 +25,9 @@ import AuthBadge from "@/components/auth/AuthBadge.astro";
25
25
  import { referenceTheme } from "@/themes/reference";
26
26
  import { mergeTheme } from "@/themes/schema";
27
27
  import { organizationLd, websiteLd } from "@/lib/jsonld";
28
+ import { composeStructuredData, structuredDataProfile, type StructuredDataPage } from "@/lib/seo/structuredData";
29
+ import { canonicalUrl } from "@/lib/seo/urlForm";
30
+ import { documentTitle } from "@/lib/seo/documentTitle";
28
31
  import { nonceInlineTags, prefixApiFormActions, CHROME_BODY_MARKER } from "@/lib/rawMarketingHtml";
29
32
  import { CHROME_BEHAVIOR_JS, CHROME_HEAD_JS, sanitizeSharedChromeFragment } from "@/lib/rawChrome";
30
33
  import { ASYNC_CSS_SWAP_JS } from "@/lib/asyncCss";
@@ -49,6 +52,12 @@ interface Props {
49
52
  ogImage?: string;
50
53
  noindex?: boolean;
51
54
  jsonLd?: Record<string, unknown> | Record<string, unknown>[];
55
+ /**
56
+ * What the page knows about itself for the tenant's declared structured-data profile
57
+ * (lib/seo/structuredData.ts). When the profile declares a graph for `pageType`, that graph
58
+ * is the page's ONLY JSON-LD and `jsonLd` is not emitted.
59
+ */
60
+ structuredData?: StructuredDataPage;
52
61
  /** Alternate RSS/Atom feeds this page advertises (threaded to <Seo>). */
53
62
  feeds?: { title: string; href: string }[];
54
63
  /** A paginated collection's prev/next siblings (threaded to <Seo>). */
@@ -92,6 +101,7 @@ const {
92
101
  ogImage,
93
102
  noindex,
94
103
  jsonLd,
104
+ structuredData,
95
105
  feeds,
96
106
  pagination,
97
107
  article,
@@ -123,12 +133,22 @@ const integrationPhases = renderIntegrationPhases(
123
133
 
124
134
  // Organization JSON-LD is emitted site-wide (brief §8); pages add their own
125
135
  // graphs (Product, BreadcrumbList…) via the jsonLd prop.
136
+ // A tenant that declares a graph for this page type gets exactly that graph instead.
126
137
  const pageGraphs = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
127
- const allJsonLd = [
128
- organizationLd(tenant, canonicalBase),
129
- websiteLd(tenant, canonicalBase),
130
- ...pageGraphs,
131
- ];
138
+ const profile = structuredDataProfile(tenant);
139
+ const declaredGraph = profile && structuredData
140
+ ? composeStructuredData({
141
+ profile,
142
+ page: structuredData,
143
+ tenant,
144
+ canonicalBase,
145
+ canonical: canonicalOverride ?? canonicalUrl(path, canonicalBase, tenant.seo),
146
+ documentTitle: documentTitle(title, seoTitle, tenant.displayName),
147
+ })
148
+ : null;
149
+ const allJsonLd = declaredGraph
150
+ ? [declaredGraph]
151
+ : [organizationLd(tenant, canonicalBase), websiteLd(tenant, canonicalBase), ...pageGraphs];
132
152
 
133
153
  const theme = mergeTheme(referenceTheme, tenant.theme_tokens);
134
154
 
@@ -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
- /** The collection paths for the space the request resolved to (the root space otherwise). */
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) => path({ kind: "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
  /**
@@ -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 })),