@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.
- package/apps/storefront/public/shared/commerce-chrome.css +5 -0
- package/apps/storefront/src/components/chrome/SiteFooter.astro +4 -1
- package/apps/storefront/src/layouts/Layout.astro +8 -6
- package/apps/storefront/src/lib/blog/collection.ts +3 -0
- package/apps/storefront/src/lib/blog/provider.ts +47 -13
- package/apps/storefront/src/lib/chrome/footerLegal.ts +52 -0
- package/apps/storefront/src/lib/chrome/trackingPlacements.ts +4 -3
- package/apps/storefront/src/lib/rawChrome.ts +26 -4
- package/apps/storefront/src/pages/blog/index.astro +10 -1
- package/package.json +1 -1
|
@@ -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
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
const
|
|
215
|
-
|
|
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={
|
|
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
|
|
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
|
-
|
|
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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
-
|
|
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
|
|
429
|
-
?
|
|
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
|
-
}
|
|
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
|
-
|
|
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.
|
|
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",
|