@tokenoftrust/storefront-runner 2.0.15 → 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.
@@ -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(
@@ -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.15",
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",