@tokenoftrust/storefront-runner 1.4.0 → 1.4.2-rc.0

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 (193) hide show
  1. package/apps/storefront/astro.config.mjs +17 -1
  2. package/apps/storefront/integrations/materialize-guard.mjs +56 -0
  3. package/apps/storefront/migrations-apps/0001_woozy_lyja.sql +22 -0
  4. package/apps/storefront/migrations-apps/0002_good_switch.sql +26 -0
  5. package/apps/storefront/migrations-apps/0003_mute_marauders.sql +12 -0
  6. package/apps/storefront/migrations-apps/meta/0001_snapshot.json +990 -0
  7. package/apps/storefront/migrations-apps/meta/0002_snapshot.json +1160 -0
  8. package/apps/storefront/migrations-apps/meta/0003_snapshot.json +1235 -0
  9. package/apps/storefront/migrations-apps/meta/_journal.json +21 -0
  10. package/apps/storefront/package.json +0 -1
  11. package/apps/storefront/public/js/dashboard-team.js +38 -2
  12. package/apps/storefront/src/components/Seo.astro +65 -1
  13. package/apps/storefront/src/components/admin/AdminBlogDetailRail.astro +878 -0
  14. package/apps/storefront/src/components/admin/AdminBlogTab.astro +1432 -0
  15. package/apps/storefront/src/components/admin/AdminCustomersTab.astro +3 -7
  16. package/apps/storefront/src/components/admin/AdminPublishTab.astro +1756 -1382
  17. package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +1 -3
  18. package/apps/storefront/src/components/blog/BlogPaywall.astro +87 -0
  19. package/apps/storefront/src/components/blog/PostAccessBadge.astro +43 -0
  20. package/apps/storefront/src/components/blog/PostGrid.astro +57 -0
  21. package/apps/storefront/src/components/blog/ReactionBar.astro +214 -0
  22. package/apps/storefront/src/layouts/Layout.astro +48 -3
  23. package/apps/storefront/src/lib/activity/alerts.ts +426 -0
  24. package/apps/storefront/src/lib/activity/changeActorAttribution.ts +85 -0
  25. package/apps/storefront/src/lib/activity/deployVersion.ts +120 -0
  26. package/apps/storefront/src/lib/activity/ingest.ts +188 -0
  27. package/apps/storefront/src/lib/activity/ingestAuth.ts +105 -0
  28. package/apps/storefront/src/lib/activity/killSwitch.ts +80 -0
  29. package/apps/storefront/src/lib/activity/query.ts +403 -0
  30. package/apps/storefront/src/lib/activity/recordActivity.ts +101 -0
  31. package/apps/storefront/src/lib/activity/store.ts +120 -0
  32. package/apps/storefront/src/lib/activity/uiActor.ts +150 -0
  33. package/apps/storefront/src/lib/activity/workerCommit.ts +70 -0
  34. package/apps/storefront/src/lib/admin/adminShell.ts +290 -0
  35. package/apps/storefront/src/lib/admin/ai/approvalMode.ts +2 -3
  36. package/apps/storefront/src/lib/admin/ai/audit.ts +3 -5
  37. package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +7 -7
  38. package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +3 -3
  39. package/apps/storefront/src/lib/admin/orderClient.ts +3 -5
  40. package/apps/storefront/src/lib/admin/services/orders.ts +17 -19
  41. package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +4 -6
  42. package/apps/storefront/src/lib/admin/viewmodels/settings.ts +1 -1
  43. package/apps/storefront/src/lib/apps/adminService.ts +4 -4
  44. package/apps/storefront/src/lib/apps/adminSession.ts +53 -44
  45. package/apps/storefront/src/lib/apps/apiAuth.ts +2 -2
  46. package/apps/storefront/src/lib/apps/apiRoute.ts +2 -2
  47. package/apps/storefront/src/lib/apps/catalogMapper.ts +1 -1
  48. package/apps/storefront/src/lib/apps/credentials.ts +2 -2
  49. package/apps/storefront/src/lib/apps/gatewayKeys.ts +4 -4
  50. package/apps/storefront/src/lib/apps/healthAggregate.ts +5 -5
  51. package/apps/storefront/src/lib/apps/orders/attributionService.ts +2 -2
  52. package/apps/storefront/src/lib/apps/orders/customerHash.ts +1 -1
  53. package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +12 -12
  54. package/apps/storefront/src/lib/apps/orders/idempotency.ts +7 -7
  55. package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +1 -1
  56. package/apps/storefront/src/lib/apps/orders/orderMapper.ts +2 -2
  57. package/apps/storefront/src/lib/apps/orders/ordersStore.ts +6 -6
  58. package/apps/storefront/src/lib/apps/registryService.ts +6 -6
  59. package/apps/storefront/src/lib/apps/scopes.ts +2 -2
  60. package/apps/storefront/src/lib/apps/tokenIssuer.ts +4 -4
  61. package/apps/storefront/src/lib/apps/tokenVerifier.ts +6 -6
  62. package/apps/storefront/src/lib/apps/widgets/eligibility.ts +2 -2
  63. package/apps/storefront/src/lib/apps/widgets/frameProps.ts +3 -3
  64. package/apps/storefront/src/lib/apps/widgets/launchToken.ts +4 -4
  65. package/apps/storefront/src/lib/apps/widgets/placements.ts +2 -2
  66. package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +1 -1
  67. package/apps/storefront/src/lib/auth/adminEntry.ts +9 -9
  68. package/apps/storefront/src/lib/auth/identityToken.ts +6 -14
  69. package/apps/storefront/src/lib/auth/loginGate.ts +75 -13
  70. package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +6 -4
  71. package/apps/storefront/src/lib/auth/operatorSession.ts +113 -0
  72. package/apps/storefront/src/lib/auth/route.ts +44 -4
  73. package/apps/storefront/src/lib/auth/session.ts +7 -8
  74. package/apps/storefront/src/lib/auth/totAccessClient.ts +8 -18
  75. package/apps/storefront/src/lib/basePath.ts +9 -2
  76. package/apps/storefront/src/lib/blog/access.ts +29 -0
  77. package/apps/storefront/src/lib/blog/collection.ts +313 -0
  78. package/apps/storefront/src/lib/blog/markdown.ts +174 -0
  79. package/apps/storefront/src/lib/blog/provider.ts +5 -0
  80. package/apps/storefront/src/lib/blog/reactions.ts +87 -0
  81. package/apps/storefront/src/lib/blog/rss.ts +92 -0
  82. package/apps/storefront/src/lib/blog/teaser.ts +83 -0
  83. package/apps/storefront/src/lib/blog/types.ts +34 -0
  84. package/apps/storefront/src/lib/blog/viewCounts.ts +243 -0
  85. package/apps/storefront/src/lib/d1/catalog.ts +2 -2
  86. package/apps/storefront/src/lib/d1/schema-apps.ts +147 -14
  87. package/apps/storefront/src/lib/dev/activityIngestToken.ts +2 -4
  88. package/apps/storefront/src/lib/dev/activityStore.ts +3 -5
  89. package/apps/storefront/src/lib/dev/apiBase.ts +1 -4
  90. package/apps/storefront/src/lib/dev/cockpitStore.ts +1 -9
  91. package/apps/storefront/src/lib/dev/devDraft.ts +1 -1
  92. package/apps/storefront/src/lib/dev/previewStatus.ts +9 -21
  93. package/apps/storefront/src/lib/dev/rendezvousBroker.ts +9 -24
  94. package/apps/storefront/src/lib/edgeCache.ts +17 -0
  95. package/apps/storefront/src/lib/env.ts +51 -0
  96. package/apps/storefront/src/lib/http/fetchTransport.ts +63 -0
  97. package/apps/storefront/src/lib/i18n.ts +25 -0
  98. package/apps/storefront/src/lib/jsonld.ts +118 -0
  99. package/apps/storefront/src/lib/monitoring/manifest.ts +11 -11
  100. package/apps/storefront/src/lib/newsletter/confirmationEmail.ts +30 -0
  101. package/apps/storefront/src/lib/newsletter/consent.ts +23 -0
  102. package/apps/storefront/src/lib/newsletter/digest.ts +117 -0
  103. package/apps/storefront/src/lib/newsletter/resendClient.ts +79 -0
  104. package/apps/storefront/src/lib/newsletter/send.ts +265 -0
  105. package/apps/storefront/src/lib/newsletter/subscribers.ts +156 -0
  106. package/apps/storefront/src/lib/publish/adminPublishTab.ts +3519 -0
  107. package/apps/storefront/src/lib/publish/apex-readiness.ts +41 -18
  108. package/apps/storefront/src/lib/publish/dispatchHealth.ts +5 -7
  109. package/apps/storefront/src/lib/publish/domainState.ts +22 -13
  110. package/apps/storefront/src/lib/publish/panelPolling.ts +78 -0
  111. package/apps/storefront/src/lib/publish/shipWorkspace.ts +100 -42
  112. package/apps/storefront/src/lib/publish/stageBundleUpload.ts +215 -0
  113. package/apps/storefront/src/lib/rate/kvWindow.ts +53 -0
  114. package/apps/storefront/src/lib/search/index.ts +2 -2
  115. package/apps/storefront/src/lib/seo/alternates.ts +42 -0
  116. package/apps/storefront/src/lib/seo/meta.ts +65 -0
  117. package/apps/storefront/src/lib/social/golive.ts +212 -0
  118. package/apps/storefront/src/lib/social/notify.ts +32 -0
  119. package/apps/storefront/src/lib/social/tokenStore.ts +171 -0
  120. package/apps/storefront/src/lib/the-build/provider.ts +27 -0
  121. package/apps/storefront/src/lib/the-build/readingTime.ts +35 -0
  122. package/apps/storefront/src/lib/the-build/toc.ts +95 -0
  123. package/apps/storefront/src/lib/tot/ToTClient.ts +1 -1
  124. package/apps/storefront/src/lib/tot/d1Client.ts +2 -2
  125. package/apps/storefront/src/lib/tot/totClientInterface.ts +3 -4
  126. package/apps/storefront/src/lib/webhooks/deliveryStore.ts +16 -17
  127. package/apps/storefront/src/middleware/index.ts +130 -2
  128. package/apps/storefront/src/pages/admin/ops-timeline.astro +420 -0
  129. package/apps/storefront/src/pages/admin.astro +52 -1206
  130. package/apps/storefront/src/pages/api/activity.ts +137 -0
  131. package/apps/storefront/src/pages/api/admin/activity-alerts.ts +73 -0
  132. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +27 -1
  133. package/apps/storefront/src/pages/api/apps/admin/install.ts +26 -1
  134. package/apps/storefront/src/pages/api/apps/admin/resume.ts +24 -1
  135. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +24 -1
  136. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +25 -1
  137. package/apps/storefront/src/pages/api/apps/admin/update.ts +24 -1
  138. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +11 -0
  139. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +11 -0
  140. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +20 -0
  141. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +12 -0
  142. package/apps/storefront/src/pages/api/blog/[collection]/[slug]/react.ts +104 -0
  143. package/apps/storefront/src/pages/api/cache-purge.ts +11 -0
  144. package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +30 -0
  145. package/apps/storefront/src/pages/api/newsletter/[collection]/subscribe.ts +146 -0
  146. package/apps/storefront/src/pages/api/newsletter/confirm.ts +75 -0
  147. package/apps/storefront/src/pages/api/newsletter/unsubscribe.ts +90 -0
  148. package/apps/storefront/src/pages/api/publish/stage-bundle.ts +70 -0
  149. package/apps/storefront/src/pages/api/rum/vitals.ts +21 -2
  150. package/apps/storefront/src/pages/auth/login.astro +5 -4
  151. package/apps/storefront/src/pages/blog/[slug].astro +96 -16
  152. package/apps/storefront/src/pages/blog/author/[author].astro +140 -0
  153. package/apps/storefront/src/pages/blog/category/[category].astro +67 -0
  154. package/apps/storefront/src/pages/blog/index.astro +2 -0
  155. package/apps/storefront/src/pages/blog/tag/[tag].astro +65 -0
  156. package/apps/storefront/src/pages/cockpit.astro +74 -12
  157. package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +15 -11
  158. package/apps/storefront/src/pages/search.astro +1 -1
  159. package/apps/storefront/src/pages/sitemap.xml.ts +20 -0
  160. package/apps/storefront/src/pages/the-build/[slug].astro +129 -0
  161. package/apps/storefront/src/pages/the-build/index.astro +146 -0
  162. package/apps/storefront/src/pages/the-build/rss.xml.ts +33 -0
  163. package/apps/storefront/src/styles/admin.css +1080 -0
  164. package/apps/storefront/tsconfig.json +5 -0
  165. package/package.json +1 -1
  166. package/packages/public-runtime/package.json +1 -0
  167. package/packages/public-runtime/src/activity/README.md +146 -0
  168. package/packages/public-runtime/src/activity/catalog.ts +553 -0
  169. package/packages/public-runtime/src/activity/event.ts +168 -0
  170. package/packages/public-runtime/src/activity/index.ts +16 -0
  171. package/packages/public-runtime/src/activity/redaction.ts +263 -0
  172. package/packages/public-runtime/src/candidate-index.ts +13 -0
  173. package/packages/public-runtime/src/catalog-d1.ts +1 -1
  174. package/packages/public-runtime/src/customization-preview.ts +25 -2
  175. package/packages/public-runtime/src/customization-runtime.ts +125 -3
  176. package/packages/public-runtime/src/customization-versioning.ts +10 -0
  177. package/packages/public-runtime/src/index.ts +7 -0
  178. package/packages/public-runtime/src/static-bundle.ts +357 -0
  179. package/packages/public-runtime/src/tenant.ts +36 -8
  180. package/scripts/build/copy-tenant-assets.mjs +14 -4
  181. package/scripts/dev/ai-edit.mjs +1 -1
  182. package/scripts/dev/checkout-watch.mjs +2 -2
  183. package/scripts/dev/file-browser.mjs +2 -4
  184. package/scripts/dev/file-writer.mjs +1 -1
  185. package/scripts/dev/git-status.mjs +2 -2
  186. package/scripts/dev/locate-handler.mjs +3 -3
  187. package/scripts/dev/port-check.mjs +3 -3
  188. package/scripts/dev/publish.mjs +5 -9
  189. package/scripts/dev/shot.mjs +1 -1
  190. package/scripts/dev/unified-diff.mjs +8 -3
  191. package/scripts/tot-dev.mjs +6 -9
  192. package/apps/storefront/src/lib/webhooks/signing.ts +0 -29
  193. package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +0 -146
@@ -0,0 +1,313 @@
1
+ import type { AuthorBio, BlogPost, BlogPostSummary, PostStatus } from "./types.js";
2
+
3
+ /** A blog collection = a route base + a per-tenant content dir. */
4
+ export interface Collection {
5
+ id: string;
6
+ base: string;
7
+ }
8
+
9
+ // ONE glob covering every tenant + every collection; the tenant id and the
10
+ // collection id are both path segments filtered at call time. The derived index
11
+ // is the set of `posts/*.json` files — there is no hand-maintained index.json.
12
+ const postGlobs = import.meta.glob<BlogPost>(
13
+ "../../../../../tenants/*/content/*/posts/*.json",
14
+ { import: "default" },
15
+ );
16
+ const redirectGlobs = import.meta.glob<Record<string, string>>(
17
+ "../../../../../tenants/*/content/*/redirects.json",
18
+ { import: "default" },
19
+ );
20
+ const authorsGlobs = import.meta.glob<Record<string, AuthorBio>>(
21
+ "../../../../../tenants/*/content/*/authors.json",
22
+ { import: "default" },
23
+ );
24
+
25
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
26
+ const SEGMENT_RE = /^[a-z0-9][a-z0-9._-]*$/;
27
+
28
+ /**
29
+ * A "scheduled" post whose `scheduledAt` instant has passed behaves exactly like
30
+ * "published". Before that instant it is treated like a draft (invisible, unlisted).
31
+ * A scheduled post with a missing/unparseable `scheduledAt` is never due (safe: it
32
+ * stays hidden rather than leaking). Because visibility is computed at build/request
33
+ * time, a post committed ahead of time simply needs a build after its instant.
34
+ */
35
+ function isDueScheduled(p: { status: PostStatus; scheduledAt?: string }, now: string): boolean {
36
+ if (p.status !== "scheduled" || typeof p.scheduledAt !== "string") return false;
37
+ const due = Date.parse(p.scheduledAt);
38
+ return !Number.isNaN(due) && due <= Date.parse(now);
39
+ }
40
+
41
+ /**
42
+ * The single visibility chokepoint: is a post reachable at its URL on the live
43
+ * site? Published, unlisted, and due-scheduled resolve; drafts and not-yet-due
44
+ * scheduled posts 404. Listings (index/RSS/sitemap) apply a stricter filter
45
+ * (published or due-scheduled) — unlisted posts resolve at their URL but never
46
+ * appear in any listing. ("scheduled" branch added in B3.1.)
47
+ */
48
+ export function isVisibleOnLive(
49
+ p: { status: PostStatus; scheduledAt?: string },
50
+ now: string = new Date().toISOString(),
51
+ ): boolean {
52
+ return p.status === "published" || p.status === "unlisted" || isDueScheduled(p, now);
53
+ }
54
+
55
+ /** "Category Insurance" -> "category-insurance" */
56
+ export function categorySlug(name: string): string {
57
+ return name
58
+ .toLowerCase()
59
+ .replace(/[^a-z0-9]+/g, "-")
60
+ .replace(/^-+|-+$/g, "");
61
+ }
62
+
63
+ /**
64
+ * Collect the posts whose `field` (categories or tags) contains a term slugifying
65
+ * to `slug`, preserving input order, plus the human label of the first matching
66
+ * term (for the page heading/breadcrumb). Returns null when nothing matches, which
67
+ * a category/tag route turns into a 404. Terms are matched by `categorySlug` so the
68
+ * URL segment is stable regardless of the term's display casing/spacing.
69
+ */
70
+ export function collectPostsByTerm<T extends BlogPostSummary>(
71
+ posts: T[],
72
+ field: "categories" | "tags",
73
+ slug: string,
74
+ ): { label: string; posts: T[] } | null {
75
+ const matched: T[] = [];
76
+ let label: string | null = null;
77
+ for (const post of posts) {
78
+ const hit = post[field].find((term) => categorySlug(term) === slug);
79
+ if (hit !== undefined) {
80
+ if (label === null) label = hit;
81
+ matched.push(post);
82
+ }
83
+ }
84
+ return label === null ? null : { label, posts: matched };
85
+ }
86
+
87
+ /**
88
+ * "Ada Lovelace" -> "ada-lovelace". Author bylines are people's names, so two
89
+ * spellings of the same person must resolve to ONE archive slug — otherwise a
90
+ * migrated blog splits an author across two pages. Beyond the shared category
91
+ * slugify we first fold the two variant classes that show up in real bylines:
92
+ * - diacritics: "María García" == "Maria Garcia" (NFKD + strip combining marks)
93
+ * - apostrophes/quotes are DROPPED, not turned into a word break, so
94
+ * "Austin O'Brion" == "Austin Obrion" -> "austin-obrion" (WordPress-style),
95
+ * not "austin-o-brion" != "austin-obrion".
96
+ * categorySlug is intentionally left unfolded so existing category/tag URLs
97
+ * (and their redirects/sitemap entries) stay byte-stable.
98
+ */
99
+ export function authorSlug(name: string): string {
100
+ const folded = name
101
+ .normalize("NFKD") // decompose "é" -> "e" + combining acute
102
+ .replace(/[\u0300-\u036f]/g, "") // strip the combining marks
103
+ .replace(/['\u2018\u2019\u02bc\u0060]/g, ""); // drop apostrophes/quotes, do not split
104
+ return categorySlug(folded);
105
+ }
106
+
107
+ function normalizeStatus(raw: unknown): PostStatus {
108
+ return raw === "draft" || raw === "unlisted" || raw === "published" || raw === "scheduled"
109
+ ? raw
110
+ : "published";
111
+ }
112
+
113
+ // Legacy/seeded post JSON may omit `status`; default it to "published" on load so
114
+ // every downstream reader sees a concrete status.
115
+ function normalize(raw: BlogPost): BlogPost {
116
+ return { ...raw, status: normalizeStatus((raw as { status?: unknown }).status) };
117
+ }
118
+
119
+ function postsPrefix(tenantId: string, collection: string): string {
120
+ return `/tenants/${tenantId}/content/${collection}/posts/`;
121
+ }
122
+
123
+ function toSummary(p: BlogPost): BlogPostSummary {
124
+ return {
125
+ source: p.source,
126
+ slug: p.slug,
127
+ title: p.title,
128
+ seoTitle: p.seoTitle,
129
+ description: p.description,
130
+ excerpt: p.excerpt,
131
+ publishedAt: p.publishedAt,
132
+ modifiedAt: p.modifiedAt,
133
+ author: p.author,
134
+ categories: p.categories,
135
+ tags: p.tags,
136
+ status: p.status,
137
+ ...(p.scheduledAt ? { scheduledAt: p.scheduledAt } : {}),
138
+ ...(p.featuredImage ? { featuredImage: p.featuredImage } : {}),
139
+ ...(p.canonicalUrl ? { canonicalUrl: p.canonicalUrl } : {}),
140
+ ...(p.inLanguage ? { inLanguage: p.inLanguage } : {}),
141
+ ...(p.translationGroupId ? { translationGroupId: p.translationGroupId } : {}),
142
+ };
143
+ }
144
+
145
+ /**
146
+ * The collection ids that actually have post content for a tenant, derived from
147
+ * the same glob the index is built from (no per-tenant config needed). Sorted for
148
+ * a stable admin ordering. A tenant with no blog content returns `[]`.
149
+ */
150
+ export function listCollections(tenantId: string): string[] {
151
+ const marker = `/tenants/${tenantId}/content/`;
152
+ const collections = new Set<string>();
153
+ for (const key of Object.keys(postGlobs)) {
154
+ const at = key.indexOf(marker);
155
+ if (at < 0) continue;
156
+ const rest = key.slice(at + marker.length);
157
+ const collection = rest.slice(0, rest.indexOf("/"));
158
+ if (collection && SEGMENT_RE.test(collection)) collections.add(collection);
159
+ }
160
+ return [...collections].sort();
161
+ }
162
+
163
+ /**
164
+ * The derived index for `(tenant, collection)`, newest first. Live surfaces get
165
+ * published posts only; admin/preview pass `includeUnpublished` to also see
166
+ * drafts and unlisted posts.
167
+ */
168
+ export async function listPosts(
169
+ tenantId: string,
170
+ collection: string,
171
+ opts?: { includeUnpublished?: boolean; now?: string },
172
+ ): Promise<BlogPostSummary[]> {
173
+ if (!SEGMENT_RE.test(collection)) return [];
174
+ const prefix = postsPrefix(tenantId, collection);
175
+ const keys = Object.keys(postGlobs).filter((key) => key.includes(prefix));
176
+ const posts = (await Promise.all(keys.map((key) => postGlobs[key]!()))).map(normalize);
177
+ const includeUnpublished = opts?.includeUnpublished ?? false;
178
+ const now = opts?.now ?? new Date().toISOString();
179
+ return posts
180
+ .filter((p) => includeUnpublished || p.status === "published" || isDueScheduled(p, now))
181
+ .sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : a.publishedAt > b.publishedAt ? -1 : 0))
182
+ .map(toSummary);
183
+ }
184
+
185
+ /**
186
+ * Load a single post. On the live path a draft returns null (404) and an
187
+ * unlisted post still resolves; admin/preview pass `includeUnpublished` to load
188
+ * anything.
189
+ */
190
+ export async function getPost(
191
+ tenantId: string,
192
+ collection: string,
193
+ slug: string,
194
+ opts?: { includeUnpublished?: boolean; now?: string },
195
+ ): Promise<BlogPost | null> {
196
+ if (!SEGMENT_RE.test(collection) || !SLUG_RE.test(slug)) return null;
197
+ const key = Object.keys(postGlobs).find(
198
+ (candidate) =>
199
+ candidate.includes(postsPrefix(tenantId, collection)) && candidate.endsWith(`/${slug}.json`),
200
+ );
201
+ if (!key) return null;
202
+ const post = normalize(await postGlobs[key]!());
203
+ if (!(opts?.includeUnpublished ?? false) && !isVisibleOnLive(post, opts?.now)) return null;
204
+ return post;
205
+ }
206
+
207
+ /**
208
+ * Every language variant sharing a `translationGroupId`, newest first. A filter
209
+ * over `listPosts`, so it inherits the visibility chokepoint verbatim: on the live
210
+ * path a draft/not-yet-due translation never appears, and an unlisted variant —
211
+ * which resolves at its own URL but is absent from every listing — is likewise
212
+ * absent here. Pass `includeUnpublished` for the admin/preview view of the group.
213
+ */
214
+ export async function listTranslations(
215
+ tenantId: string,
216
+ collection: string,
217
+ translationGroupId: string,
218
+ opts?: { includeUnpublished?: boolean; now?: string },
219
+ ): Promise<BlogPostSummary[]> {
220
+ if (!translationGroupId) return [];
221
+ const posts = await listPosts(tenantId, collection, opts);
222
+ return posts.filter((p) => p.translationGroupId === translationGroupId);
223
+ }
224
+
225
+ /**
226
+ * Convenience for a route holding a slug: the full variant set for the article at
227
+ * `slug`, including the post itself. A singleton (no `translationGroupId`) returns
228
+ * exactly `[that post]` — the uniform "at least one" contract lets a caller render
229
+ * a switcher purely on `length > 1` without a separate group-id guard. Returns `[]`
230
+ * only when the post itself does not resolve (unknown, or hidden on the live path).
231
+ */
232
+ export async function getTranslations(
233
+ tenantId: string,
234
+ collection: string,
235
+ slug: string,
236
+ opts?: { includeUnpublished?: boolean; now?: string },
237
+ ): Promise<BlogPostSummary[]> {
238
+ const post = await getPost(tenantId, collection, slug, opts);
239
+ if (!post) return [];
240
+ if (!post.translationGroupId) return [toSummary(post)];
241
+ return listTranslations(tenantId, collection, post.translationGroupId, opts);
242
+ }
243
+
244
+ /**
245
+ * Live-visible related posts for an article page: same primary category first
246
+ * (newest first, excluding the current post), then fall back to newest overall
247
+ * to fill up to `n`. Only published posts are candidates; the current post may
248
+ * itself be unlisted (its page still resolves) and is always excluded.
249
+ */
250
+ export async function relatedPosts(
251
+ tenantId: string,
252
+ collection: string,
253
+ slug: string,
254
+ n = 3,
255
+ ): Promise<BlogPostSummary[]> {
256
+ const pool = (await listPosts(tenantId, collection)).filter((p) => p.slug !== slug);
257
+ const current = await getPost(tenantId, collection, slug);
258
+ const category = current?.categories[0];
259
+ const result = category ? pool.filter((p) => p.categories.includes(category)) : [];
260
+ for (const p of pool) {
261
+ if (result.length >= n) break;
262
+ if (!result.some((r) => r.slug === p.slug)) result.push(p);
263
+ }
264
+ return result.slice(0, n);
265
+ }
266
+
267
+ export async function resolveRedirect(
268
+ tenantId: string,
269
+ collection: string,
270
+ slug: string,
271
+ ): Promise<string | null> {
272
+ if (!SEGMENT_RE.test(collection)) return null;
273
+ const redirects = await getRedirects(tenantId, collection);
274
+ return redirects[slug] ?? null;
275
+ }
276
+
277
+ /**
278
+ * The full `slug -> path` redirect map for `(tenant, collection)`, or `{}` when
279
+ * there is no `redirects.json`. The admin fast lane (B2.2) reads this to APPEND a
280
+ * redirect when a published post is deleted, without clobbering existing entries.
281
+ */
282
+ export async function getRedirects(
283
+ tenantId: string,
284
+ collection: string,
285
+ ): Promise<Record<string, string>> {
286
+ if (!SEGMENT_RE.test(collection)) return {};
287
+ const key = Object.keys(redirectGlobs).find(
288
+ (candidate) =>
289
+ candidate.includes(`/tenants/${tenantId}/content/${collection}/`) &&
290
+ candidate.endsWith("/redirects.json"),
291
+ );
292
+ if (!key) return {};
293
+ return (await redirectGlobs[key]!()) ?? {};
294
+ }
295
+
296
+ /**
297
+ * Optional per-tenant × per-collection author bios, keyed by author slug. Missing
298
+ * file returns {} so callers fall back to the byline name. Author pages resolve a
299
+ * bio with `getAuthors(t, c)[authorSlug(post.author)]`.
300
+ */
301
+ export async function getAuthors(
302
+ tenantId: string,
303
+ collection: string,
304
+ ): Promise<Record<string, AuthorBio>> {
305
+ if (!SEGMENT_RE.test(collection)) return {};
306
+ const key = Object.keys(authorsGlobs).find(
307
+ (candidate) =>
308
+ candidate.includes(`/tenants/${tenantId}/content/${collection}/`) &&
309
+ candidate.endsWith("/authors.json"),
310
+ );
311
+ if (!key) return {};
312
+ return (await authorsGlobs[key]!()) ?? {};
313
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Minimal, dependency-free Markdown -> sanitized HTML for the admin blog editor
3
+ * (B3.4 of docs/plans/2026-08-21-blog-platform-workstream.md). The storefront
4
+ * ships no markdown library (see the "No new deps" convention in §1), and the
5
+ * migration package's `sanitize-html` is a node-only devtool — so authoring from
6
+ * the admin gets this small hand-rolled renderer instead.
7
+ *
8
+ * SAFETY: the output is fed to `set:html` in the reader route
9
+ * (`the-build/[slug].astro`), so it must never emit author-supplied HTML
10
+ * verbatim. All text is HTML-escaped BEFORE any tag is emitted, only the tag set
11
+ * this renderer produces can appear, and link hrefs / image `src`s are
12
+ * scheme-checked (`http(s)`/`mailto`/site-relative/anchor only — `javascript:`
13
+ * etc. fall back to plain text). Admin is already gated, but escaping is
14
+ * defense-in-depth.
15
+ *
16
+ * SCOPE (v1): headings, bold, italic, inline code, fenced code, links, images,
17
+ * blockquotes, unordered/ordered lists, horizontal rules, paragraphs. NOT a
18
+ * CommonMark implementation — no nested lists or tables.
19
+ */
20
+
21
+ // A control char that never appears in authored prose and is untouched by
22
+ // escapeHtml — a collision-free placeholder while code spans are set aside.
23
+ const CODE_MARK = String.fromCharCode(0);
24
+ const CODE_RE = new RegExp(`${CODE_MARK}(\\d+)${CODE_MARK}`, "g");
25
+
26
+ function escapeHtml(s: string): string {
27
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
28
+ }
29
+
30
+ // Closes the one gap `escapeHtml` leaves open for attribute interpolation.
31
+ // The url substring reaching this point has ALREADY passed through the single
32
+ // escapeHtml(t) pass over the whole inline text (renderInline escapes before
33
+ // running the link regex), so `&`/`<`/`>` are already entity-encoded — only
34
+ // `"`/`'` remain untouched. Re-running full escapeHtml here would double-encode
35
+ // those already-escaped entities, so this only needs to close the quote gap.
36
+ function escapeAttrQuotes(s: string): string {
37
+ return s.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
38
+ }
39
+
40
+ // Only permit hrefs that can't execute script. Text is already escaped by the
41
+ // time a URL reaches here, so a `javascript:`/`data:` payload arrives intact and
42
+ // is rejected -> the link renders as its (escaped) label text instead. Quote/
43
+ // angle-bracket characters are rejected outright (second line of defense on
44
+ // top of escapeAttrQuotes at the call site) so an attribute-breakout payload
45
+ // never matches the scheme allowlist in the first place.
46
+ function sanitizeUrl(url: string): string | null {
47
+ const u = url.trim();
48
+ if (/["'<>]/.test(u)) return null;
49
+ // `//host/path` is protocol-relative — a browser resolves it to an absolute
50
+ // cross-origin URL using the current scheme, so it must NOT be accepted
51
+ // under the "site-relative" `/` allowance (only a single, non-doubled slash
52
+ // is genuinely site-relative).
53
+ if (/^\/\//.test(u)) return null;
54
+ return /^(https?:\/\/|mailto:|\/|#)/i.test(u) ? u : null;
55
+ }
56
+
57
+ // Inline spans, applied to already-block-split text. Code spans are extracted
58
+ // first (and their contents escaped) so emphasis/link syntax inside `code` is
59
+ // left literal; everything else is escaped, then link/bold/italic tags are woven
60
+ // back in, then the code spans are restored.
61
+ function renderInline(text: string): string {
62
+ const codes: string[] = [];
63
+ let t = text.replace(/`([^`]+)`/g, (_m, code: string) => {
64
+ codes.push(`<code>${escapeHtml(code)}</code>`);
65
+ return `${CODE_MARK}${codes.length - 1}${CODE_MARK}`;
66
+ });
67
+ t = escapeHtml(t);
68
+ // Images before links: `![alt](url)` embeds a link-like `[alt](url)` span, so
69
+ // the link regex would otherwise claim it. Each emitted `<img>` is stashed in
70
+ // `codes` (same placeholder machinery as code spans) so the later link and
71
+ // emphasis passes can't reach into its `src`/`alt` attribute values — an
72
+ // `alt` of `a*b*c` must stay literal inside the attribute, not sprout an
73
+ // `<em>`. `src` is scheme-checked (data:/protocol-relative rejected) and both
74
+ // attribute values are quote-encoded on top of the whole-text escapeHtml pass.
75
+ t = t.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_m, alt: string, url: string) => {
76
+ const safe = sanitizeUrl(url);
77
+ if (!safe) return alt;
78
+ const img = `<img src="${escapeAttrQuotes(safe)}" alt="${escapeAttrQuotes(alt)}" loading="lazy" decoding="async">`;
79
+ codes.push(img);
80
+ return `${CODE_MARK}${codes.length - 1}${CODE_MARK}`;
81
+ });
82
+ t = t.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, label: string, url: string) => {
83
+ const safe = sanitizeUrl(url);
84
+ return safe ? `<a href="${escapeAttrQuotes(safe)}">${label}</a>` : label;
85
+ });
86
+ t = t
87
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
88
+ .replace(/__([^_]+)__/g, "<strong>$1</strong>");
89
+ t = t
90
+ .replace(/\*([^*]+)\*/g, "<em>$1</em>")
91
+ .replace(/(^|[^a-zA-Z0-9])_([^_]+)_(?=[^a-zA-Z0-9]|$)/g, "$1<em>$2</em>");
92
+ return t.replace(CODE_RE, (_m, i: string) => codes[Number(i)]!);
93
+ }
94
+
95
+ const isBlank = (l: string): boolean => l.trim() === "";
96
+ const startsBlock = (l: string): boolean =>
97
+ /^```/.test(l) ||
98
+ /^#{1,6}\s+/.test(l) ||
99
+ /^>\s?/.test(l) ||
100
+ /^\s*[-*+]\s+/.test(l) ||
101
+ /^\s*\d+\.\s+/.test(l) ||
102
+ /^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(l);
103
+
104
+ export function renderMarkdown(md: string): string {
105
+ const lines = md.replace(/\r\n?/g, "\n").split("\n");
106
+ const out: string[] = [];
107
+ let i = 0;
108
+
109
+ while (i < lines.length) {
110
+ const line = lines[i]!;
111
+
112
+ if (isBlank(line)) {
113
+ i++;
114
+ continue;
115
+ }
116
+
117
+ const fence = line.match(/^```\s*([a-zA-Z0-9_-]*)\s*$/);
118
+ if (fence) {
119
+ const buf: string[] = [];
120
+ i++;
121
+ while (i < lines.length && !/^```\s*$/.test(lines[i]!)) buf.push(lines[i++]!);
122
+ i++; // consume closing fence (tolerate an unterminated block at EOF)
123
+ const cls = fence[1] ? ` class="language-${fence[1]}"` : "";
124
+ out.push(`<pre><code${cls}>${escapeHtml(buf.join("\n"))}</code></pre>`);
125
+ continue;
126
+ }
127
+
128
+ const heading = line.match(/^(#{1,6})\s+(.*)$/);
129
+ if (heading) {
130
+ const level = heading[1]!.length;
131
+ out.push(`<h${level}>${renderInline(heading[2]!.trim())}</h${level}>`);
132
+ i++;
133
+ continue;
134
+ }
135
+
136
+ if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
137
+ out.push("<hr>");
138
+ i++;
139
+ continue;
140
+ }
141
+
142
+ if (/^>\s?/.test(line)) {
143
+ const buf: string[] = [];
144
+ while (i < lines.length && /^>\s?/.test(lines[i]!)) buf.push(lines[i++]!.replace(/^>\s?/, ""));
145
+ out.push(`<blockquote>${renderMarkdown(buf.join("\n"))}</blockquote>`);
146
+ continue;
147
+ }
148
+
149
+ if (/^\s*[-*+]\s+/.test(line)) {
150
+ const items: string[] = [];
151
+ while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i]!)) {
152
+ items.push(lines[i++]!.replace(/^\s*[-*+]\s+/, "").trim());
153
+ }
154
+ out.push(`<ul>${items.map((it) => `<li>${renderInline(it)}</li>`).join("")}</ul>`);
155
+ continue;
156
+ }
157
+
158
+ if (/^\s*\d+\.\s+/.test(line)) {
159
+ const items: string[] = [];
160
+ while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i]!)) {
161
+ items.push(lines[i++]!.replace(/^\s*\d+\.\s+/, "").trim());
162
+ }
163
+ out.push(`<ol>${items.map((it) => `<li>${renderInline(it)}</li>`).join("")}</ol>`);
164
+ continue;
165
+ }
166
+
167
+ const buf: string[] = [];
168
+ while (i < lines.length && !isBlank(lines[i]!) && !startsBlock(lines[i]!)) buf.push(lines[i++]!);
169
+ const text = buf.join(" ").trim();
170
+ if (text) out.push(`<p>${renderInline(text)}</p>`);
171
+ }
172
+
173
+ return out.join("\n");
174
+ }
@@ -1,3 +1,8 @@
1
+ // The live WordPress `/blog` deliberately keeps reading its committed, curated
2
+ // index.json (its order is hand-maintained and SEO-load-bearing). The generic
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
5
+ // encoded as data (see B0.1 §"/blog migration caution").
1
6
  import type { BlogPost, BlogPostSummary } from "./types.js";
2
7
 
3
8
  const indexGlobs = import.meta.glob<BlogPostSummary[]>(
@@ -0,0 +1,87 @@
1
+ /**
2
+ * E1.1 — per-post reader reactions: an anonymous, additive emoji tally, the
3
+ * first reader-sentiment signal for the blog platform (complements view counts,
4
+ * which measure reads). A near-clone of `viewCounts.ts`: KV counters keyed by
5
+ * (tenant, collection, slug, reaction), best-effort read-modify-write, no PII,
6
+ * no per-visitor identity, no moderation (an emoji count carries no user text —
7
+ * worst case is an inflated tally, self-limiting and low-stakes).
8
+ *
9
+ * Storage is the TENANT_CACHE KV namespace (same binding view-counts uses).
10
+ * Under concurrency some increments are lost to KV's last-write-wins semantics:
11
+ * acceptable for a demand signal, not a billing counter.
12
+ *
13
+ * Tenant/collection-generic by construction — nothing here is bound to a
14
+ * specific tenant or collection.
15
+ */
16
+ import { getPost } from "./collection.js";
17
+
18
+ /** The fixed reaction vocabulary. Not free-form — a closed set like the pillar vocabulary. */
19
+ export const REACTIONS = ["like", "insightful", "celebrate"] as const;
20
+ export type Reaction = (typeof REACTIONS)[number];
21
+
22
+ /** Narrow an untrusted string to a known reaction, or null. */
23
+ export function isReaction(value: unknown): value is Reaction {
24
+ return typeof value === "string" && (REACTIONS as readonly string[]).includes(value);
25
+ }
26
+
27
+ /** KV key for one (post, reaction) rolling tally. */
28
+ export function reactionsKey(
29
+ tenantId: string,
30
+ collection: string,
31
+ slug: string,
32
+ reaction: Reaction,
33
+ ): string {
34
+ return `blogreact:${tenantId}:${collection}:${slug}:${reaction}`;
35
+ }
36
+
37
+ async function readCount(kv: KVNamespace, key: string): Promise<number> {
38
+ const raw = await kv.get(key);
39
+ const n = raw == null ? 0 : Number.parseInt(raw, 10);
40
+ return Number.isFinite(n) && n >= 0 ? n : 0;
41
+ }
42
+
43
+ /**
44
+ * Increment the tally for `reaction` on a post IF the reaction is in the vocab
45
+ * AND the post is live (published/unlisted/due-scheduled) for this tenant —
46
+ * `getPost` returns null on the live path for a draft/404/non-post. Best-effort:
47
+ * never throws (a reaction must never fail the reader's request).
48
+ */
49
+ export async function recordReaction(
50
+ kv: KVNamespace,
51
+ tenantId: string,
52
+ collection: string,
53
+ slug: string,
54
+ reaction: string,
55
+ ): Promise<void> {
56
+ try {
57
+ if (!isReaction(reaction)) return;
58
+ const post = await getPost(tenantId, collection, slug);
59
+ if (!post) return; // not a live post (draft / 404 / non-blog) — do not count
60
+ const key = reactionsKey(tenantId, collection, slug, reaction);
61
+ const next = (await readCount(kv, key)) + 1;
62
+ await kv.put(key, String(next));
63
+ } catch {
64
+ /* swallow — reactions are best-effort */
65
+ }
66
+ }
67
+
68
+ /**
69
+ * The full tally map for a post: every vocab reaction to its count (0 if unset).
70
+ * A null KV (local dev, no binding) yields all-zero counts. Read by the reader
71
+ * reaction bar and the admin engagement panel (E1.2).
72
+ */
73
+ export async function getReactionCounts(
74
+ kv: KVNamespace | undefined,
75
+ tenantId: string,
76
+ collection: string,
77
+ slug: string,
78
+ ): Promise<Record<Reaction, number>> {
79
+ const out = Object.fromEntries(REACTIONS.map((r) => [r, 0])) as Record<Reaction, number>;
80
+ if (!kv) return out;
81
+ await Promise.all(
82
+ REACTIONS.map(async (reaction) => {
83
+ out[reaction] = await readCount(kv, reactionsKey(tenantId, collection, slug, reaction));
84
+ }),
85
+ );
86
+ return out;
87
+ }
@@ -0,0 +1,92 @@
1
+ import type { BlogPostSummary } from "./types.js";
2
+
3
+ /**
4
+ * Hand-rolled RSS 2.0 renderer (no `@astrojs/rss` dependency), the syndication
5
+ * mirror of `sitemap.xml.ts`. Tenant- and collection-agnostic: the caller passes
6
+ * the resolved canonical base + the collection's route base, so the SAME renderer
7
+ * serves `/the-build/rss.xml`, `/blog/rss.xml`, and any tenant. Feeding it the
8
+ * engine's live-visible list (`listPosts` default) inherits the visibility
9
+ * chokepoint — drafts/unlisted never reach a feed.
10
+ */
11
+ export interface RssFeedInput {
12
+ channelTitle: string;
13
+ channelDescription: string;
14
+ /** Resolved canonical base, e.g. `https://x.com` or a path-prefixed preview host. */
15
+ canonicalBase: string;
16
+ /** The collection's route base, e.g. `/the-build`. */
17
+ collectionBase: string;
18
+ /**
19
+ * Feed items. `BlogPost` is assignable here (it extends `BlogPostSummary`),
20
+ * so an item may carry a full `contentHtml` body — which is emitted as
21
+ * `<content:encoded>` for public posts only. Gated posts stay in the feed but
22
+ * never syndicate their body (see the per-item guard below).
23
+ */
24
+ posts: Array<BlogPostSummary & { contentHtml?: string }>;
25
+ }
26
+
27
+ /** XML-escape text inside a tag (mirrors sitemap.xml.ts). */
28
+ export function escapeXml(value: string): string {
29
+ return value
30
+ .replace(/&/g, "&amp;")
31
+ .replace(/</g, "&lt;")
32
+ .replace(/>/g, "&gt;")
33
+ .replace(/"/g, "&quot;")
34
+ .replace(/'/g, "&apos;");
35
+ }
36
+
37
+ /** RFC-822 date (`Sun, 01 Mar 2026 00:00:00 GMT`), the RSS `pubDate` format. */
38
+ function rfc822(iso: string): string {
39
+ const d = new Date(iso);
40
+ return Number.isNaN(d.getTime()) ? "" : d.toUTCString();
41
+ }
42
+
43
+ export function renderRssFeed(input: RssFeedInput): string {
44
+ const { channelTitle, channelDescription, posts } = input;
45
+ const base = input.canonicalBase.replace(/\/$/, "");
46
+ const collectionBase = `/${input.collectionBase.replace(/^\/+|\/+$/g, "")}`;
47
+ const channelLink = `${base}${collectionBase}/`;
48
+ const feedLink = `${base}${collectionBase}/rss.xml`;
49
+
50
+ const items = posts
51
+ .map((post) => {
52
+ const link = `${base}${collectionBase}/${post.slug}/`;
53
+ const pubDate = rfc822(post.publishedAt);
54
+ const categories = post.categories
55
+ .map((c) => ` <category>${escapeXml(c)}</category>`)
56
+ .join("\n");
57
+ // Entitlement guard: a gated post (access !== "public") stays discoverable
58
+ // in the feed but NEVER syndicates its body — otherwise a feed reader would
59
+ // unlock paywalled content. Only public posts emit <content:encoded>.
60
+ const gated = (post.access ?? "public") !== "public";
61
+ const contentEncoded =
62
+ !gated && post.contentHtml
63
+ ? ` <content:encoded><![CDATA[${post.contentHtml}]]></content:encoded>`
64
+ : "";
65
+ return [
66
+ " <item>",
67
+ ` <title>${escapeXml(post.title)}</title>`,
68
+ ` <link>${escapeXml(link)}</link>`,
69
+ ` <guid isPermaLink="true">${escapeXml(link)}</guid>`,
70
+ pubDate ? ` <pubDate>${pubDate}</pubDate>` : "",
71
+ ` <dc:creator>${escapeXml(post.author)}</dc:creator>`,
72
+ ` <description>${escapeXml(post.excerpt)}</description>`,
73
+ contentEncoded,
74
+ categories,
75
+ ]
76
+ .filter(Boolean)
77
+ .join("\n");
78
+ })
79
+ .join("\n");
80
+
81
+ return `<?xml version="1.0" encoding="UTF-8"?>
82
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
83
+ <channel>
84
+ <title>${escapeXml(channelTitle)}</title>
85
+ <link>${escapeXml(channelLink)}</link>
86
+ <atom:link href="${escapeXml(feedLink)}" rel="self" type="application/rss+xml" />
87
+ <description>${escapeXml(channelDescription)}</description>
88
+ ${items}
89
+ </channel>
90
+ </rss>
91
+ `;
92
+ }