@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.
- package/apps/storefront/astro.config.mjs +17 -1
- package/apps/storefront/integrations/materialize-guard.mjs +56 -0
- package/apps/storefront/migrations-apps/0001_woozy_lyja.sql +22 -0
- package/apps/storefront/migrations-apps/0002_good_switch.sql +26 -0
- package/apps/storefront/migrations-apps/0003_mute_marauders.sql +12 -0
- package/apps/storefront/migrations-apps/meta/0001_snapshot.json +990 -0
- package/apps/storefront/migrations-apps/meta/0002_snapshot.json +1160 -0
- package/apps/storefront/migrations-apps/meta/0003_snapshot.json +1235 -0
- package/apps/storefront/migrations-apps/meta/_journal.json +21 -0
- package/apps/storefront/package.json +0 -1
- package/apps/storefront/public/js/dashboard-team.js +38 -2
- package/apps/storefront/src/components/Seo.astro +65 -1
- package/apps/storefront/src/components/admin/AdminBlogDetailRail.astro +878 -0
- package/apps/storefront/src/components/admin/AdminBlogTab.astro +1432 -0
- package/apps/storefront/src/components/admin/AdminCustomersTab.astro +3 -7
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +1756 -1382
- package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +1 -3
- package/apps/storefront/src/components/blog/BlogPaywall.astro +87 -0
- package/apps/storefront/src/components/blog/PostAccessBadge.astro +43 -0
- package/apps/storefront/src/components/blog/PostGrid.astro +57 -0
- package/apps/storefront/src/components/blog/ReactionBar.astro +214 -0
- package/apps/storefront/src/layouts/Layout.astro +48 -3
- package/apps/storefront/src/lib/activity/alerts.ts +426 -0
- package/apps/storefront/src/lib/activity/changeActorAttribution.ts +85 -0
- package/apps/storefront/src/lib/activity/deployVersion.ts +120 -0
- package/apps/storefront/src/lib/activity/ingest.ts +188 -0
- package/apps/storefront/src/lib/activity/ingestAuth.ts +105 -0
- package/apps/storefront/src/lib/activity/killSwitch.ts +80 -0
- package/apps/storefront/src/lib/activity/query.ts +403 -0
- package/apps/storefront/src/lib/activity/recordActivity.ts +101 -0
- package/apps/storefront/src/lib/activity/store.ts +120 -0
- package/apps/storefront/src/lib/activity/uiActor.ts +150 -0
- package/apps/storefront/src/lib/activity/workerCommit.ts +70 -0
- package/apps/storefront/src/lib/admin/adminShell.ts +290 -0
- package/apps/storefront/src/lib/admin/ai/approvalMode.ts +2 -3
- package/apps/storefront/src/lib/admin/ai/audit.ts +3 -5
- package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +7 -7
- package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +3 -3
- package/apps/storefront/src/lib/admin/orderClient.ts +3 -5
- package/apps/storefront/src/lib/admin/services/orders.ts +17 -19
- package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +4 -6
- package/apps/storefront/src/lib/admin/viewmodels/settings.ts +1 -1
- package/apps/storefront/src/lib/apps/adminService.ts +4 -4
- package/apps/storefront/src/lib/apps/adminSession.ts +53 -44
- package/apps/storefront/src/lib/apps/apiAuth.ts +2 -2
- package/apps/storefront/src/lib/apps/apiRoute.ts +2 -2
- package/apps/storefront/src/lib/apps/catalogMapper.ts +1 -1
- package/apps/storefront/src/lib/apps/credentials.ts +2 -2
- package/apps/storefront/src/lib/apps/gatewayKeys.ts +4 -4
- package/apps/storefront/src/lib/apps/healthAggregate.ts +5 -5
- package/apps/storefront/src/lib/apps/orders/attributionService.ts +2 -2
- package/apps/storefront/src/lib/apps/orders/customerHash.ts +1 -1
- package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +12 -12
- package/apps/storefront/src/lib/apps/orders/idempotency.ts +7 -7
- package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +1 -1
- package/apps/storefront/src/lib/apps/orders/orderMapper.ts +2 -2
- package/apps/storefront/src/lib/apps/orders/ordersStore.ts +6 -6
- package/apps/storefront/src/lib/apps/registryService.ts +6 -6
- package/apps/storefront/src/lib/apps/scopes.ts +2 -2
- package/apps/storefront/src/lib/apps/tokenIssuer.ts +4 -4
- package/apps/storefront/src/lib/apps/tokenVerifier.ts +6 -6
- package/apps/storefront/src/lib/apps/widgets/eligibility.ts +2 -2
- package/apps/storefront/src/lib/apps/widgets/frameProps.ts +3 -3
- package/apps/storefront/src/lib/apps/widgets/launchToken.ts +4 -4
- package/apps/storefront/src/lib/apps/widgets/placements.ts +2 -2
- package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +1 -1
- package/apps/storefront/src/lib/auth/adminEntry.ts +9 -9
- package/apps/storefront/src/lib/auth/identityToken.ts +6 -14
- package/apps/storefront/src/lib/auth/loginGate.ts +75 -13
- package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +6 -4
- package/apps/storefront/src/lib/auth/operatorSession.ts +113 -0
- package/apps/storefront/src/lib/auth/route.ts +44 -4
- package/apps/storefront/src/lib/auth/session.ts +7 -8
- package/apps/storefront/src/lib/auth/totAccessClient.ts +8 -18
- package/apps/storefront/src/lib/basePath.ts +9 -2
- package/apps/storefront/src/lib/blog/access.ts +29 -0
- package/apps/storefront/src/lib/blog/collection.ts +313 -0
- package/apps/storefront/src/lib/blog/markdown.ts +174 -0
- package/apps/storefront/src/lib/blog/provider.ts +5 -0
- package/apps/storefront/src/lib/blog/reactions.ts +87 -0
- package/apps/storefront/src/lib/blog/rss.ts +92 -0
- package/apps/storefront/src/lib/blog/teaser.ts +83 -0
- package/apps/storefront/src/lib/blog/types.ts +34 -0
- package/apps/storefront/src/lib/blog/viewCounts.ts +243 -0
- package/apps/storefront/src/lib/d1/catalog.ts +2 -2
- package/apps/storefront/src/lib/d1/schema-apps.ts +147 -14
- package/apps/storefront/src/lib/dev/activityIngestToken.ts +2 -4
- package/apps/storefront/src/lib/dev/activityStore.ts +3 -5
- package/apps/storefront/src/lib/dev/apiBase.ts +1 -4
- package/apps/storefront/src/lib/dev/cockpitStore.ts +1 -9
- package/apps/storefront/src/lib/dev/devDraft.ts +1 -1
- package/apps/storefront/src/lib/dev/previewStatus.ts +9 -21
- package/apps/storefront/src/lib/dev/rendezvousBroker.ts +9 -24
- package/apps/storefront/src/lib/edgeCache.ts +17 -0
- package/apps/storefront/src/lib/env.ts +51 -0
- package/apps/storefront/src/lib/http/fetchTransport.ts +63 -0
- package/apps/storefront/src/lib/i18n.ts +25 -0
- package/apps/storefront/src/lib/jsonld.ts +118 -0
- package/apps/storefront/src/lib/monitoring/manifest.ts +11 -11
- package/apps/storefront/src/lib/newsletter/confirmationEmail.ts +30 -0
- package/apps/storefront/src/lib/newsletter/consent.ts +23 -0
- package/apps/storefront/src/lib/newsletter/digest.ts +117 -0
- package/apps/storefront/src/lib/newsletter/resendClient.ts +79 -0
- package/apps/storefront/src/lib/newsletter/send.ts +265 -0
- package/apps/storefront/src/lib/newsletter/subscribers.ts +156 -0
- package/apps/storefront/src/lib/publish/adminPublishTab.ts +3519 -0
- package/apps/storefront/src/lib/publish/apex-readiness.ts +41 -18
- package/apps/storefront/src/lib/publish/dispatchHealth.ts +5 -7
- package/apps/storefront/src/lib/publish/domainState.ts +22 -13
- package/apps/storefront/src/lib/publish/panelPolling.ts +78 -0
- package/apps/storefront/src/lib/publish/shipWorkspace.ts +100 -42
- package/apps/storefront/src/lib/publish/stageBundleUpload.ts +215 -0
- package/apps/storefront/src/lib/rate/kvWindow.ts +53 -0
- package/apps/storefront/src/lib/search/index.ts +2 -2
- package/apps/storefront/src/lib/seo/alternates.ts +42 -0
- package/apps/storefront/src/lib/seo/meta.ts +65 -0
- package/apps/storefront/src/lib/social/golive.ts +212 -0
- package/apps/storefront/src/lib/social/notify.ts +32 -0
- package/apps/storefront/src/lib/social/tokenStore.ts +171 -0
- package/apps/storefront/src/lib/the-build/provider.ts +27 -0
- package/apps/storefront/src/lib/the-build/readingTime.ts +35 -0
- package/apps/storefront/src/lib/the-build/toc.ts +95 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +1 -1
- package/apps/storefront/src/lib/tot/d1Client.ts +2 -2
- package/apps/storefront/src/lib/tot/totClientInterface.ts +3 -4
- package/apps/storefront/src/lib/webhooks/deliveryStore.ts +16 -17
- package/apps/storefront/src/middleware/index.ts +130 -2
- package/apps/storefront/src/pages/admin/ops-timeline.astro +420 -0
- package/apps/storefront/src/pages/admin.astro +52 -1206
- package/apps/storefront/src/pages/api/activity.ts +137 -0
- package/apps/storefront/src/pages/api/admin/activity-alerts.ts +73 -0
- package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +27 -1
- package/apps/storefront/src/pages/api/apps/admin/install.ts +26 -1
- package/apps/storefront/src/pages/api/apps/admin/resume.ts +24 -1
- package/apps/storefront/src/pages/api/apps/admin/suspend.ts +24 -1
- package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +25 -1
- package/apps/storefront/src/pages/api/apps/admin/update.ts +24 -1
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +11 -0
- package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +11 -0
- package/apps/storefront/src/pages/api/apps/v1/attribution.ts +20 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +12 -0
- package/apps/storefront/src/pages/api/blog/[collection]/[slug]/react.ts +104 -0
- package/apps/storefront/src/pages/api/cache-purge.ts +11 -0
- package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +30 -0
- package/apps/storefront/src/pages/api/newsletter/[collection]/subscribe.ts +146 -0
- package/apps/storefront/src/pages/api/newsletter/confirm.ts +75 -0
- package/apps/storefront/src/pages/api/newsletter/unsubscribe.ts +90 -0
- package/apps/storefront/src/pages/api/publish/stage-bundle.ts +70 -0
- package/apps/storefront/src/pages/api/rum/vitals.ts +21 -2
- package/apps/storefront/src/pages/auth/login.astro +5 -4
- package/apps/storefront/src/pages/blog/[slug].astro +96 -16
- package/apps/storefront/src/pages/blog/author/[author].astro +140 -0
- package/apps/storefront/src/pages/blog/category/[category].astro +67 -0
- package/apps/storefront/src/pages/blog/index.astro +2 -0
- package/apps/storefront/src/pages/blog/tag/[tag].astro +65 -0
- package/apps/storefront/src/pages/cockpit.astro +74 -12
- package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +15 -11
- package/apps/storefront/src/pages/search.astro +1 -1
- package/apps/storefront/src/pages/sitemap.xml.ts +20 -0
- package/apps/storefront/src/pages/the-build/[slug].astro +129 -0
- package/apps/storefront/src/pages/the-build/index.astro +146 -0
- package/apps/storefront/src/pages/the-build/rss.xml.ts +33 -0
- package/apps/storefront/src/styles/admin.css +1080 -0
- package/apps/storefront/tsconfig.json +5 -0
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/src/activity/README.md +146 -0
- package/packages/public-runtime/src/activity/catalog.ts +553 -0
- package/packages/public-runtime/src/activity/event.ts +168 -0
- package/packages/public-runtime/src/activity/index.ts +16 -0
- package/packages/public-runtime/src/activity/redaction.ts +263 -0
- package/packages/public-runtime/src/candidate-index.ts +13 -0
- package/packages/public-runtime/src/catalog-d1.ts +1 -1
- package/packages/public-runtime/src/customization-preview.ts +25 -2
- package/packages/public-runtime/src/customization-runtime.ts +125 -3
- package/packages/public-runtime/src/customization-versioning.ts +10 -0
- package/packages/public-runtime/src/index.ts +7 -0
- package/packages/public-runtime/src/static-bundle.ts +357 -0
- package/packages/public-runtime/src/tenant.ts +36 -8
- package/scripts/build/copy-tenant-assets.mjs +14 -4
- package/scripts/dev/ai-edit.mjs +1 -1
- package/scripts/dev/checkout-watch.mjs +2 -2
- package/scripts/dev/file-browser.mjs +2 -4
- package/scripts/dev/file-writer.mjs +1 -1
- package/scripts/dev/git-status.mjs +2 -2
- package/scripts/dev/locate-handler.mjs +3 -3
- package/scripts/dev/port-check.mjs +3 -3
- package/scripts/dev/publish.mjs +5 -9
- package/scripts/dev/shot.mjs +1 -1
- package/scripts/dev/unified-diff.mjs +8 -3
- package/scripts/tot-dev.mjs +6 -9
- package/apps/storefront/src/lib/webhooks/signing.ts +0 -29
- 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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, """).replace(/'/g, "'");
|
|
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: `` 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, "&")
|
|
31
|
+
.replace(/</g, "<")
|
|
32
|
+
.replace(/>/g, ">")
|
|
33
|
+
.replace(/"/g, """)
|
|
34
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|