@tokenoftrust/storefront-runner 2.0.14 → 2.0.16

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.
@@ -339,6 +339,11 @@ a.article-card__tag:hover { text-decoration: underline; }
339
339
  .footer-warning p { color: color-mix(in srgb, var(--color-primary-contrast) 78%, transparent); font-size: .82rem; }
340
340
  .footer-bottom { margin-top: 28px; padding-top: 18px; border-top: 1px solid color-mix(in srgb, var(--color-primary-contrast) 16%, transparent); display: flex; flex-wrap: wrap; gap: 10px 24px; justify-content: space-between; align-items: center; }
341
341
  .footer-bottom p, .footer-bottom a { color: color-mix(in srgb, var(--color-primary-contrast) 60%, transparent); font-size: .82rem; }
342
+ /* The Legal link row (a "Legal"-titled footer column, moved here — see
343
+ lib/chrome/footerLegal.ts). Wraps under the copyright at narrow widths
344
+ rather than overflowing the bar. */
345
+ .footer-bottom nav ul { display: flex; flex-wrap: wrap; gap: 6px 20px; list-style: none; margin: 0; padding: 0; }
346
+ .footer-bottom nav a:hover { color: var(--color-primary-contrast); text-decoration: underline; }
342
347
 
343
348
  /* --- Reveal-on-scroll (raw-chrome markup uses .reveal/.in) ---------------- */
344
349
  .reveal { opacity: 0; transform: translateY(18px); transition: opacity .5s ease, transform .5s ease; }
@@ -27,7 +27,10 @@ interface Props {
27
27
  compliance?: string;
28
28
  /** Legal bottom-bar line (e.g. a composed "© 2026 Acme · some legal note" string). */
29
29
  legal?: string;
30
- /** Bottom-bar policy links (e.g. Privacy Policy, Terms), shown alongside `legal`. */
30
+ /** Bottom-bar policy links (e.g. Privacy Policy, Terms), shown alongside `legal`.
31
+ * The links of a `Legal`-titled footer column, which the caller MOVES out of
32
+ * `columns` (lib/chrome/footerLegal.ts) — passing them in both places would
33
+ * render each one twice. */
31
34
  bottomLinks?: ChromeLink[];
32
35
  class?: string;
33
36
  }
@@ -25,6 +25,7 @@ import { organizationLd, websiteLd } from "@/lib/jsonld";
25
25
  import { nonceInlineTags, CHROME_BODY_MARKER } from "@/lib/rawMarketingHtml";
26
26
  import { reportTrackingChainHealth } from "@/lib/tracking/interactionHealth";
27
27
  import { resolveHeaderVariant, resolveFooterVariant } from "@/lib/chrome/variant";
28
+ import { splitFooterLegal } from "@/lib/chrome/footerLegal";
28
29
  import type { HeaderVariant, FooterVariant } from "@/lib/chrome/model";
29
30
  import { renderIntegrationPhases, INTERACTION_TRACKING_BOOT_JS } from "@tot/public-runtime";
30
31
  import { readViewerSession } from "@/lib/auth/route";
@@ -208,11 +209,12 @@ const headerLogo = brandLogoValid ?? themeHeaderLogo;
208
209
  const headerWordmark = brand?.label ?? tenant.displayName;
209
210
  const headerBrandHref = brand?.href;
210
211
 
211
- // Footer bottom bar: a "Legal"-titled column's links win; otherwise every
212
- // column's links flatten into the policy-link row (mirrors the retired
213
- // nav/Footer.astro exactly).
214
- const legalCol = chrome.footer.columns.find((c) => /legal/i.test(c.title));
215
- const bottomLinks = legalCol ? legalCol.links : chrome.footer.columns.flatMap((c) => c.links);
212
+ // Footer bottom bar: a "Legal"-titled column MOVES out of the grid and its
213
+ // links render beside the copyright — one shared split with the raw renderer
214
+ // (lib/chrome/footerLegal.ts), so neither surface can duplicate them.
215
+ const { gridColumns: footerColumns, legalLinks: bottomLinks } = splitFooterLegal(
216
+ chrome.footer.columns,
217
+ );
216
218
  const legalBase = chrome.footer.bottom?.copyright ?? `© ${new Date().getFullYear()} ${tenant.displayName}`;
217
219
  const legalLine = `${legalBase}${chrome.footer.bottom?.note ? ` · ${chrome.footer.bottom.note}` : ""}`;
218
220
 
@@ -386,7 +388,7 @@ const footerWidgets = await renderWidgetSlot(
386
388
  contact: chrome.footer.brand?.text ? [chrome.footer.brand.text] : undefined,
387
389
  }}
388
390
  variant={resolvedFooterVariant}
389
- columns={chrome.footer.columns}
391
+ columns={footerColumns}
390
392
  newsletter={features.newsletter ? { title: "Newsletter", placeholder: "you@example.com" } : undefined}
391
393
  social={chrome.footer.social}
392
394
  legal={legalLine}
@@ -156,6 +156,9 @@ function toSummary(p: BlogPost): BlogPostSummary {
156
156
  tags: p.tags,
157
157
  status: p.status,
158
158
  ...(p.scheduledAt ? { scheduledAt: p.scheduledAt } : {}),
159
+ // Carried, not dropped: a listing renders the entitlement badge off this,
160
+ // so omitting it makes a members-only post read as public in every listing.
161
+ ...(p.access ? { access: p.access } : {}),
159
162
  ...(p.featuredImage ? { featuredImage: p.featuredImage } : {}),
160
163
  ...(p.canonicalUrl ? { canonicalUrl: p.canonicalUrl } : {}),
161
164
  ...(p.inLanguage ? { inLanguage: p.inLanguage } : {}),
@@ -1,12 +1,17 @@
1
1
  // The live WordPress `/blog` deliberately keeps reading its committed, curated
2
2
  // index.json (its order is hand-maintained and SEO-load-bearing). The generic
3
3
  // derived engine lives in ./collection.ts and powers `the-build` + any new
4
- // collection; switching `/blog` onto it is a later migration once order is
4
+ // collection, and backs `/blog` whenever that curated index is unusable;
5
+ // switching `/blog` onto it outright is a later migration once order is
5
6
  // encoded as data (see B0.1 §"/blog migration caution").
6
7
  import { tenantDirSegment } from "@tot/public-runtime";
8
+ import { listPosts } from "./collection.js";
7
9
  import type { BlogPost, BlogPostSummary } from "./types.js";
8
10
  import type { ContentProvider } from "../storyblok/provider.js";
9
11
 
12
+ /** The collection id `/blog` reads — the derived fallback's second argument. */
13
+ const BLOG_COLLECTION = "blog";
14
+
10
15
  type ArtifactSource = Pick<ContentProvider, "readArtifact" | "listArtifactPaths">;
11
16
 
12
17
  function canReadArtifacts(value: ArtifactSource | undefined): value is ArtifactSource {
@@ -43,25 +48,54 @@ function tenantKey(keys: string[], tenantId: string, suffix: string): string | u
43
48
  return keys.find((key) => key.includes(segment) && key.endsWith(suffix));
44
49
  }
45
50
 
46
- export async function listBlogPosts(
51
+ /** A curated index is usable only if it parses AND is the array shape it claims. */
52
+ function parseCuratedIndex(raw: string): BlogPostSummary[] | null {
53
+ try {
54
+ const parsed = JSON.parse(raw) as unknown;
55
+ return Array.isArray(parsed) ? (parsed as BlogPostSummary[]) : null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * The curated `index.json` for this tenant, or null when it is absent, malformed,
63
+ * or not an array. Null is "cannot use the curated order", never "no posts".
64
+ */
65
+ async function readCuratedIndex(
47
66
  tenantId: string,
48
67
  source?: ArtifactSource,
49
- ): Promise<BlogPostSummary[]> {
68
+ ): Promise<BlogPostSummary[] | null> {
50
69
  if (canReadArtifacts(source)) {
51
- const paths = await source.listArtifactPaths("blog/");
52
70
  const raw = await source.readArtifact("blog/index.json");
53
- if (raw) {
54
- try {
55
- return JSON.parse(raw) as BlogPostSummary[];
56
- } catch {
57
- return [];
58
- }
59
- }
60
- if (paths !== null) return [];
71
+ if (raw) return parseCuratedIndex(raw);
61
72
  }
62
73
  const key = tenantKey(Object.keys(indexGlobs), tenantId, "/index.json")
63
74
  ?? Object.keys(indexGlobs).find((candidate) => candidate.includes(`${tenantDirSegment(tenantId)}content/blog/index.json`));
64
- return key ? indexGlobs[key]!() : [];
75
+ return key ? (await indexGlobs[key]!()) ?? null : null;
76
+ }
77
+
78
+ /**
79
+ * The curated index stays the preferred source — its order is hand-maintained
80
+ * and SEO-load-bearing — but it must never be the blog's single point of
81
+ * failure. One malformed byte in `content/blog/index.json` makes the reconcile's
82
+ * JSON validator advisory-skip it, and the whole surface (index, taxonomy pages,
83
+ * RSS, sitemap entries) then reads as an empty collection and hard-404s while
84
+ * every `posts/*.json` sits materialized beside it.
85
+ *
86
+ * So an unusable curated index falls back to the derived index over
87
+ * `blog/posts/*.json` (./collection.ts): newest-first rather than curated order,
88
+ * and every article still reachable and listed. Whether the curated index is
89
+ * authoritative or derived, the runtime/build-time source selection is identical
90
+ * — `listPosts` applies the same "artifact paths, else build-time globs" rule.
91
+ */
92
+ export async function listBlogPosts(
93
+ tenantId: string,
94
+ source?: ArtifactSource,
95
+ ): Promise<BlogPostSummary[]> {
96
+ const curated = await readCuratedIndex(tenantId, source);
97
+ if (curated && curated.length > 0) return curated;
98
+ return listPosts(tenantId, BLOG_COLLECTION, { content: source });
65
99
  }
66
100
 
67
101
  export async function getBlogPost(
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The footer's Legal split — ONE source of truth for both renderers (the
3
+ * raw-string footer in lib/rawChrome.ts and the component footer in
4
+ * components/chrome/SiteFooter.astro), so a tenant's `Legal` column can never
5
+ * land in the grid on one surface and in the bottom bar on the other.
6
+ *
7
+ * Contract, title-based (no extra schema field): a footer column titled
8
+ * `Legal` — case-insensitive, surrounding whitespace ignored — is not a grid
9
+ * column. Its links render in the bottom bar beside the copyright, inside a
10
+ * single `<nav aria-label="Legal">`.
11
+ *
12
+ * The column is MOVED, never copied: it leaves `.footer-grid` entirely, so
13
+ * each legal link appears exactly once in the DOM. Each link keeps its own
14
+ * governed identity (`data-tot-el`) and action (`data-tot-action`); only the
15
+ * placement changes, to `footer-bottom`, which is what that placement means.
16
+ *
17
+ * A tenant with no `Legal` column gets a text-only bottom bar — the other
18
+ * columns stay in the grid rather than being flattened into a second copy of
19
+ * themselves under the copyright.
20
+ */
21
+ import type { ChromeLink, FooterColumn } from "./model.js";
22
+
23
+ /** The bottom-bar nav's accessible name. Exactly one is emitted per footer. */
24
+ export const LEGAL_NAV_LABEL = "Legal";
25
+
26
+ /** The title-based contract: `Legal`, case- and surrounding-whitespace-insensitive. */
27
+ export function isLegalColumnTitle(title: string): boolean {
28
+ return title.trim().toLowerCase() === LEGAL_NAV_LABEL.toLowerCase();
29
+ }
30
+
31
+ export interface FooterLegalSplit {
32
+ /** The columns that stay in `.footer-grid`, in authored order. */
33
+ gridColumns: FooterColumn[];
34
+ /** The extracted bottom-bar links, in authored order. Empty when no Legal column. */
35
+ legalLinks: ChromeLink[];
36
+ }
37
+
38
+ /**
39
+ * Partition footer columns into what the grid renders and what the bottom bar
40
+ * renders. Every `Legal`-titled column is extracted (a config with two is a
41
+ * tenant mistake, not a reason to emit a second nav) and its links concatenated
42
+ * in authored order.
43
+ */
44
+ export function splitFooterLegal(columns: readonly FooterColumn[]): FooterLegalSplit {
45
+ const gridColumns: FooterColumn[] = [];
46
+ const legalLinks: ChromeLink[] = [];
47
+ for (const column of columns) {
48
+ if (isLegalColumnTitle(column.title)) legalLinks.push(...column.links);
49
+ else gridColumns.push(column);
50
+ }
51
+ return { gridColumns, legalLinks };
52
+ }
@@ -24,9 +24,10 @@ export const CHROME_PLACEMENT = {
24
24
  /** Footer link columns. */
25
25
  footer: "footer",
26
26
  /**
27
- * Footer bottom legal/policy link row — component surface only. The raw
28
- * renderer's `ChromeFooter.bottom` carries text only (no links); its footer
29
- * columns cover the same links under `footer` above.
27
+ * Footer bottom legal/policy link row, on either surface — the links of a
28
+ * `Legal`-titled footer column, which both renderers move out of the grid and
29
+ * into the bottom bar (lib/chrome/footerLegal.ts). `ChromeFooter.bottom`
30
+ * itself carries text only.
30
31
  */
31
32
  footerBottom: "footer-bottom",
32
33
  /** The mobile nav drawer (MobileNav island) and its no-JS accordion fallback. */
@@ -28,6 +28,7 @@ import { CHROME_BODY_MARKER, CHROME_HEAD_MARKER } from "./rawMarketingHtml.js";
28
28
  import { isCurrentNavHref } from "./chrome/navCurrent.js";
29
29
  import { ctaIdentityAttr } from "./chrome/ctaIdentity.js";
30
30
  import { CHROME_PLACEMENT } from "./chrome/trackingPlacements.js";
31
+ import { LEGAL_NAV_LABEL, splitFooterLegal } from "./chrome/footerLegal.js";
31
32
  import { trackingAttributesHtml } from "@tot/public-runtime";
32
33
  import type {
33
34
  ChromeAgeGate,
@@ -391,7 +392,11 @@ function renderFooter(footer: ChromeFooter): string {
391
392
  return ` <div class="footer-brand">${logo}${text}${news}</div>\n`;
392
393
  })()
393
394
  : "";
394
- const columns = footer.columns
395
+ // A `Legal`-titled column moves to the bottom bar (lib/chrome/footerLegal.ts)
396
+ // — scope indices count RENDERED grid columns, so they describe where a link
397
+ // actually is rather than where it sat in the authored array.
398
+ const { gridColumns, legalLinks } = splitFooterLegal(footer.columns);
399
+ const columns = gridColumns
395
400
  .map((c, colIndex) => {
396
401
  const links = c.links
397
402
  .map((l, i) => {
@@ -425,11 +430,28 @@ function renderFooter(footer: ChromeFooter): string {
425
430
  const warning = footer.warning
426
431
  ? `\n <div class="footer-warning"><p><strong>WARNING:</strong> ${esc(footer.warning)}</p></div>`
427
432
  : "";
428
- const bottom = footer.bottom
429
- ? `\n <div class="footer-bottom">${footer.bottom.copyright ? `<p>${esc(footer.bottom.copyright)}</p>` : ""}${
433
+ const legalNav = legalLinks.length
434
+ ? `<nav aria-label="${LEGAL_NAV_LABEL}"><ul>${legalLinks
435
+ .map((l, i) => {
436
+ const stamp = trackingAttributesHtml({
437
+ el: l.id,
438
+ action: l.action,
439
+ tracking: l.tracking,
440
+ placement: CHROME_PLACEMENT.footerBottom,
441
+ scope: "footer-bottom",
442
+ index: i,
443
+ });
444
+ return `<li><a href="${esc(l.href)}"${relFor(l)}${stamp}>${esc(l.label)}</a></li>`;
445
+ })
446
+ .join("")}</ul></nav>`
447
+ : "";
448
+ const bottomText = footer.bottom
449
+ ? `${footer.bottom.copyright ? `<p>${esc(footer.bottom.copyright)}</p>` : ""}${
430
450
  footer.bottom.note ? `<p>${esc(footer.bottom.note)}</p>` : ""
431
- }</div>`
451
+ }`
432
452
  : "";
453
+ const bottom =
454
+ bottomText || legalNav ? `\n <div class="footer-bottom">${bottomText}${legalNav}</div>` : "";
433
455
  return (
434
456
  `<footer class="site-footer">\n` +
435
457
  ` <div class="container">\n` +
@@ -5,7 +5,16 @@ import { listBlogPosts } from "@/lib/blog/provider.js";
5
5
 
6
6
  const { tenant, basePath, canonicalBase, content } = Astro.locals;
7
7
  const posts = await listBlogPosts(tenant.tenant_id, content);
8
- if (posts.length === 0) return new Response(null, { status: 404 });
8
+ // A tenant with no blog content has no blog index, so this is still a 404 — but
9
+ // it is a DIFFERENT 404 from "no such route", and telling them apart from the
10
+ // outside used to be impossible (both were bare and byte-identical). The header
11
+ // says which one this is; a nonexistent route never carries it.
12
+ if (posts.length === 0) {
13
+ return new Response(null, {
14
+ status: 404,
15
+ headers: { "x-tot-blog-collection": "empty" },
16
+ });
17
+ }
9
18
 
10
19
  const href = (path: string) => `${basePath}${path}` || "/";
11
20
  const tags = [...new Set(posts.flatMap((post) => post.tags))].sort((a, b) => a.localeCompare(b));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.0.14",
3
+ "version": "2.0.16",
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",