@tokenoftrust/storefront-runner 1.4.1 → 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/0002_good_switch.sql +26 -0
- package/apps/storefront/migrations-apps/0003_mute_marauders.sql +12 -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 +14 -0
- 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 +1105 -2334
- 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 +17 -19
- package/apps/storefront/src/lib/activity/changeActorAttribution.ts +3 -3
- package/apps/storefront/src/lib/activity/deployVersion.ts +19 -26
- package/apps/storefront/src/lib/activity/ingest.ts +8 -8
- package/apps/storefront/src/lib/activity/ingestAuth.ts +1 -1
- package/apps/storefront/src/lib/activity/killSwitch.ts +6 -6
- package/apps/storefront/src/lib/activity/query.ts +9 -9
- package/apps/storefront/src/lib/activity/recordActivity.ts +11 -15
- package/apps/storefront/src/lib/activity/store.ts +4 -6
- package/apps/storefront/src/lib/activity/uiActor.ts +12 -12
- package/apps/storefront/src/lib/activity/workerCommit.ts +6 -6
- 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 +2 -4
- package/apps/storefront/src/lib/auth/operatorSession.ts +113 -0
- 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 +96 -17
- 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/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 -49
- 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 +113 -2
- package/apps/storefront/src/pages/admin.astro +10 -1312
- package/apps/storefront/src/pages/api/blog/[collection]/[slug]/react.ts +104 -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/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 +37 -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/catalog.ts +52 -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 +1 -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/dev/vcBinding.ts +0 -80
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* truth; lifecycle actions require a later subscription API and audit gates.
|
|
5
5
|
*/
|
|
6
6
|
import { getSubscriptionsViewModel } from "@/lib/admin/services/mock";
|
|
7
|
+
import { formatDate } from "@/lib/format";
|
|
7
8
|
import type { AdminTenantEnvelope } from "@/lib/admin/envelope";
|
|
8
9
|
import type {
|
|
9
10
|
SubscriptionAttentionRow,
|
|
@@ -64,9 +65,6 @@ function formatMoney(value: number): string {
|
|
|
64
65
|
return `$${value.toFixed(2)}`;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
function formatDate(iso: string): string {
|
|
68
|
-
return new Date(iso).toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" });
|
|
69
|
-
}
|
|
70
68
|
---
|
|
71
69
|
|
|
72
70
|
<section
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Paywall card shown under the teaser of a gated post the viewer can't read in
|
|
3
|
+
// full. Mirrors the "Coming soon" host gate (`src/lib/auth/gatePage.ts`):
|
|
4
|
+
// badge + headline + one line of value + a single CTA — restyled with the site's
|
|
5
|
+
// design tokens so it reads as native article chrome, and preceded by a fade that
|
|
6
|
+
// dissolves the truncated body into the card.
|
|
7
|
+
import type { BlogAccess } from "@/lib/blog/types.js";
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
access: BlogAccess;
|
|
11
|
+
next: string;
|
|
12
|
+
signedIn: boolean;
|
|
13
|
+
loginHref: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { next, signedIn, loginHref } = Astro.props;
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
<div class="paywall-fade" aria-hidden="true"></div>
|
|
20
|
+
<aside class="paywall" data-next={next} role="note">
|
|
21
|
+
<span class="paywall-badge">Members only</span>
|
|
22
|
+
{signedIn ? (
|
|
23
|
+
<>
|
|
24
|
+
<h2>You're signed in</h2>
|
|
25
|
+
<p>Membership tiers for this story are coming soon.</p>
|
|
26
|
+
{/* Unreachable in v1 (authenticated ⇒ member) — coded per the B4.0 spike so
|
|
27
|
+
a future paid tier only has to point this CTA at a membership route. */}
|
|
28
|
+
<a class="paywall-cta" href={loginHref}>Become a member</a>
|
|
29
|
+
</>
|
|
30
|
+
) : (
|
|
31
|
+
<>
|
|
32
|
+
<h2>This story is for members</h2>
|
|
33
|
+
<p>Sign in with Token of Trust to keep reading — it's free.</p>
|
|
34
|
+
<a class="paywall-cta" href={loginHref}>Sign in to keep reading</a>
|
|
35
|
+
</>
|
|
36
|
+
)}
|
|
37
|
+
</aside>
|
|
38
|
+
|
|
39
|
+
<style>
|
|
40
|
+
.paywall-fade {
|
|
41
|
+
height: 7rem;
|
|
42
|
+
margin-top: -7rem;
|
|
43
|
+
position: relative;
|
|
44
|
+
pointer-events: none;
|
|
45
|
+
background: linear-gradient(to bottom, transparent, var(--color-bg));
|
|
46
|
+
}
|
|
47
|
+
.paywall {
|
|
48
|
+
display: flex;
|
|
49
|
+
flex-direction: column;
|
|
50
|
+
align-items: center;
|
|
51
|
+
text-align: center;
|
|
52
|
+
gap: .9rem;
|
|
53
|
+
max-width: 34rem;
|
|
54
|
+
margin: 0 auto;
|
|
55
|
+
padding: clamp(1.75rem, 4vw, 2.5rem);
|
|
56
|
+
border: 1px solid var(--color-border);
|
|
57
|
+
border-radius: var(--radius-lg);
|
|
58
|
+
background: var(--color-surface);
|
|
59
|
+
box-shadow: var(--shadow-md);
|
|
60
|
+
}
|
|
61
|
+
.paywall-badge {
|
|
62
|
+
display: inline-block;
|
|
63
|
+
letter-spacing: .18em;
|
|
64
|
+
text-transform: uppercase;
|
|
65
|
+
font-size: .72rem;
|
|
66
|
+
font-weight: 700;
|
|
67
|
+
color: var(--color-muted);
|
|
68
|
+
border: 1px solid var(--color-border);
|
|
69
|
+
border-radius: var(--radius-full);
|
|
70
|
+
padding: .35rem .8rem;
|
|
71
|
+
}
|
|
72
|
+
.paywall h2 { margin: 0; font-size: clamp(1.5rem, 3vw, 2rem); line-height: 1.15; }
|
|
73
|
+
.paywall p { margin: 0; max-width: 28rem; color: var(--color-muted); font-size: 1.02rem; line-height: 1.6; }
|
|
74
|
+
.paywall-cta {
|
|
75
|
+
margin-top: .4rem;
|
|
76
|
+
display: inline-block;
|
|
77
|
+
text-decoration: none;
|
|
78
|
+
font-weight: 700;
|
|
79
|
+
font-size: 1rem;
|
|
80
|
+
color: var(--color-primary-contrast);
|
|
81
|
+
background: var(--color-primary);
|
|
82
|
+
border-radius: var(--radius-md);
|
|
83
|
+
padding: .8rem 1.6rem;
|
|
84
|
+
transition: background .15s ease;
|
|
85
|
+
}
|
|
86
|
+
.paywall-cta:hover { background: var(--color-primary-hover); }
|
|
87
|
+
</style>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Visual-only lock badge for a gated post on listing card grids. Reads
|
|
3
|
+
// `post.access` and renders nothing for public posts. No gating logic lives
|
|
4
|
+
// here — entitlement is decided in the route via `viewerHasBlogAccess`; this
|
|
5
|
+
// is purely a "this story is members-only" indicator on listings and archives.
|
|
6
|
+
import type { BlogAccess } from "@/lib/blog/types.js";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
access?: BlogAccess;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { access } = Astro.props;
|
|
13
|
+
const gated = (access ?? "public") !== "public";
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
{gated && (
|
|
17
|
+
<p class="access-badge">
|
|
18
|
+
<svg class="lock" width="12" height="12" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
19
|
+
<rect x="4" y="11" width="16" height="9" rx="2"></rect>
|
|
20
|
+
<path d="M8 11V7a4 4 0 0 1 8 0v4"></path>
|
|
21
|
+
</svg>
|
|
22
|
+
Members
|
|
23
|
+
</p>
|
|
24
|
+
)}
|
|
25
|
+
|
|
26
|
+
<style>
|
|
27
|
+
.access-badge {
|
|
28
|
+
display: inline-flex;
|
|
29
|
+
gap: .3rem;
|
|
30
|
+
align-items: center;
|
|
31
|
+
align-self: flex-start;
|
|
32
|
+
margin: 0 0 .55rem;
|
|
33
|
+
padding: .2rem .55rem;
|
|
34
|
+
border: 1px solid color-mix(in srgb, var(--color-accent) 45%, var(--color-border));
|
|
35
|
+
border-radius: var(--radius-full);
|
|
36
|
+
background: color-mix(in srgb, var(--color-accent) 10%, var(--color-surface));
|
|
37
|
+
color: var(--color-accent-ink);
|
|
38
|
+
font: 700 .68rem/1 var(--font-mono);
|
|
39
|
+
letter-spacing: .06em;
|
|
40
|
+
text-transform: uppercase;
|
|
41
|
+
}
|
|
42
|
+
.lock { flex: 0 0 auto; }
|
|
43
|
+
</style>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Reader-facing card grid for a list of blog posts, shared by the collection's
|
|
3
|
+
// category and tag index pages. `base` is the collection route base (e.g.
|
|
4
|
+
// "/blog"); `basePath` is the tenant/preview path prefix from Astro.locals.
|
|
5
|
+
import type { BlogPostSummary } from "@/lib/blog/types.js";
|
|
6
|
+
import PostAccessBadge from "@/components/blog/PostAccessBadge.astro";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
posts: BlogPostSummary[];
|
|
10
|
+
basePath: string;
|
|
11
|
+
base: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { posts, basePath, base } = Astro.props;
|
|
15
|
+
const href = (path: string) => `${basePath}${path}` || "/";
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
<section class="post-grid" aria-label="Posts">
|
|
19
|
+
{posts.map((post, index) => (
|
|
20
|
+
<article class="post-card">
|
|
21
|
+
<a href={href(`${base}/${post.slug}/`)} aria-label={`Read ${post.title}`}>
|
|
22
|
+
{post.featuredImage ? (
|
|
23
|
+
<img
|
|
24
|
+
src={post.featuredImage.src}
|
|
25
|
+
alt={post.featuredImage.alt}
|
|
26
|
+
width={post.featuredImage.width}
|
|
27
|
+
height={post.featuredImage.height}
|
|
28
|
+
loading={index < 3 ? "eager" : "lazy"}
|
|
29
|
+
decoding="async"
|
|
30
|
+
/>
|
|
31
|
+
) : <div class="image-placeholder" aria-hidden="true"></div>}
|
|
32
|
+
<div class="post-card-copy">
|
|
33
|
+
<p class="post-meta"><time datetime={post.publishedAt}>{new Date(post.publishedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })}</time>{post.tags[0] && <> · {post.tags[0]}</>}</p>
|
|
34
|
+
<PostAccessBadge access={post.access} />
|
|
35
|
+
<h2>{post.title}</h2>
|
|
36
|
+
<p>{post.excerpt}</p>
|
|
37
|
+
<span>Read article <span aria-hidden="true">→</span></span>
|
|
38
|
+
</div>
|
|
39
|
+
</a>
|
|
40
|
+
</article>
|
|
41
|
+
))}
|
|
42
|
+
</section>
|
|
43
|
+
|
|
44
|
+
<style>
|
|
45
|
+
.post-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.35rem; align-items: start; }
|
|
46
|
+
.post-card { height: 100%; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: var(--shadow-sm); transition: transform .2s ease, box-shadow .2s ease; }
|
|
47
|
+
.post-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-md); }
|
|
48
|
+
.post-card a { display: flex; height: 100%; flex-direction: column; }
|
|
49
|
+
.post-card img, .image-placeholder { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 12%, white), color-mix(in srgb, var(--color-accent) 24%, white)); }
|
|
50
|
+
.post-card-copy { display: flex; flex: 1; flex-direction: column; padding: 1.25rem; }
|
|
51
|
+
.post-meta { margin: 0 0 .7rem; color: var(--color-muted); font: 600 .72rem/1.45 var(--font-mono); text-transform: uppercase; letter-spacing: .05em; }
|
|
52
|
+
.post-card h2 { font-size: 1.18rem; line-height: 1.25; }
|
|
53
|
+
.post-card-copy > p:not(.post-meta) { display: -webkit-box; margin: .75rem 0 1rem; overflow: hidden; color: var(--color-muted); font-size: .92rem; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
|
|
54
|
+
.post-card-copy > span { margin-top: auto; color: var(--color-accent-ink); font-weight: 700; font-size: .88rem; }
|
|
55
|
+
@media (max-width: 980px) { .post-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
56
|
+
@media (max-width: 520px) { .post-grid { grid-template-columns: 1fr; } }
|
|
57
|
+
</style>
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* E1.2 — reader reaction bar. Server-renders the post's current reaction tallies
|
|
4
|
+
* (from the E1.1 KV counters) and offers the fixed vocabulary as clickable
|
|
5
|
+
* buttons; a client script POSTs the chosen reaction to the public react endpoint
|
|
6
|
+
* and updates the displayed count optimistically, then reconciles against the
|
|
7
|
+
* authoritative tallies the endpoint returns. Anonymous, no identity, no
|
|
8
|
+
* moderation — an emoji tally, the first reader-sentiment signal (complements
|
|
9
|
+
* view counts). Tenant/collection-generic: mounts on any post route.
|
|
10
|
+
*/
|
|
11
|
+
import { getReactionCounts, REACTIONS, type Reaction } from "@/lib/blog/reactions.js";
|
|
12
|
+
import { readKv } from "@/lib/env";
|
|
13
|
+
import { withBase } from "@/lib/basePath";
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
/** Collection id the post lives in (e.g. "blog", "the-build"). */
|
|
17
|
+
collection: string;
|
|
18
|
+
/** Post slug. */
|
|
19
|
+
slug: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { collection, slug } = Astro.props;
|
|
23
|
+
const { tenant, basePath, cspNonce } = Astro.locals;
|
|
24
|
+
|
|
25
|
+
const META: Record<Reaction, { emoji: string; label: string }> = {
|
|
26
|
+
like: { emoji: "👍", label: "Like" },
|
|
27
|
+
insightful: { emoji: "💡", label: "Insightful" },
|
|
28
|
+
celebrate: { emoji: "🎉", label: "Celebrate" },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const kv = await readKv("TENANT_CACHE");
|
|
32
|
+
const counts = await getReactionCounts(kv, tenant.tenant_id, collection, slug);
|
|
33
|
+
|
|
34
|
+
// The react endpoint is base-path-scoped like every other same-origin POST
|
|
35
|
+
// (mirrors the newsletter form action); host-routed tenants get an empty prefix.
|
|
36
|
+
const endpoint = withBase(
|
|
37
|
+
basePath,
|
|
38
|
+
`/api/blog/${encodeURIComponent(collection)}/${encodeURIComponent(slug)}/react`,
|
|
39
|
+
);
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
<section class="reaction-bar" aria-label="React to this article" data-reaction-bar data-endpoint={endpoint}>
|
|
43
|
+
<span class="reaction-bar__prompt">React</span>
|
|
44
|
+
<div class="reaction-bar__buttons">
|
|
45
|
+
{
|
|
46
|
+
REACTIONS.map((reaction) => (
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
class="reaction-btn"
|
|
50
|
+
data-reaction={reaction}
|
|
51
|
+
aria-pressed="false"
|
|
52
|
+
aria-label={META[reaction].label}
|
|
53
|
+
>
|
|
54
|
+
<span class="reaction-btn__emoji" aria-hidden="true">
|
|
55
|
+
{META[reaction].emoji}
|
|
56
|
+
</span>
|
|
57
|
+
<span class="reaction-btn__label">{META[reaction].label}</span>
|
|
58
|
+
<span class="reaction-btn__count" data-reaction-count>
|
|
59
|
+
{counts[reaction]}
|
|
60
|
+
</span>
|
|
61
|
+
</button>
|
|
62
|
+
))
|
|
63
|
+
}
|
|
64
|
+
</div>
|
|
65
|
+
</section>
|
|
66
|
+
|
|
67
|
+
<style>
|
|
68
|
+
.reaction-bar {
|
|
69
|
+
display: flex;
|
|
70
|
+
flex-wrap: wrap;
|
|
71
|
+
align-items: center;
|
|
72
|
+
gap: 0.75rem 1rem;
|
|
73
|
+
width: min(720px, 100%);
|
|
74
|
+
margin: clamp(2.5rem, 6vw, 4rem) auto 0;
|
|
75
|
+
padding-top: clamp(1.5rem, 4vw, 2.25rem);
|
|
76
|
+
border-top: 1px solid var(--color-border);
|
|
77
|
+
}
|
|
78
|
+
.reaction-bar__prompt {
|
|
79
|
+
color: var(--color-muted);
|
|
80
|
+
font-size: 0.8rem;
|
|
81
|
+
font-weight: 700;
|
|
82
|
+
text-transform: uppercase;
|
|
83
|
+
letter-spacing: 0.04em;
|
|
84
|
+
}
|
|
85
|
+
.reaction-bar__buttons {
|
|
86
|
+
display: flex;
|
|
87
|
+
flex-wrap: wrap;
|
|
88
|
+
gap: 0.6rem;
|
|
89
|
+
}
|
|
90
|
+
.reaction-btn {
|
|
91
|
+
display: inline-flex;
|
|
92
|
+
align-items: center;
|
|
93
|
+
gap: 0.45rem;
|
|
94
|
+
padding: 0.5rem 0.85rem;
|
|
95
|
+
border: 1px solid var(--color-border);
|
|
96
|
+
border-radius: var(--radius-full);
|
|
97
|
+
background: var(--color-surface);
|
|
98
|
+
color: var(--color-ink, inherit);
|
|
99
|
+
font: inherit;
|
|
100
|
+
font-size: 0.9rem;
|
|
101
|
+
line-height: 1;
|
|
102
|
+
cursor: pointer;
|
|
103
|
+
transition: border-color 0.15s ease, background 0.15s ease, transform 0.08s ease;
|
|
104
|
+
}
|
|
105
|
+
.reaction-btn:hover {
|
|
106
|
+
border-color: var(--color-accent);
|
|
107
|
+
}
|
|
108
|
+
.reaction-btn:active {
|
|
109
|
+
transform: translateY(1px);
|
|
110
|
+
}
|
|
111
|
+
.reaction-btn[aria-pressed="true"] {
|
|
112
|
+
border-color: var(--color-accent);
|
|
113
|
+
background: color-mix(in srgb, var(--color-accent) 12%, var(--color-surface));
|
|
114
|
+
}
|
|
115
|
+
.reaction-btn__emoji {
|
|
116
|
+
font-size: 1.1rem;
|
|
117
|
+
}
|
|
118
|
+
.reaction-btn__label {
|
|
119
|
+
font-weight: 700;
|
|
120
|
+
}
|
|
121
|
+
.reaction-btn__count {
|
|
122
|
+
min-width: 1.25ch;
|
|
123
|
+
padding-left: 0.15rem;
|
|
124
|
+
color: var(--color-muted);
|
|
125
|
+
font-variant-numeric: tabular-nums;
|
|
126
|
+
font-weight: 700;
|
|
127
|
+
}
|
|
128
|
+
.reaction-btn[aria-pressed="true"] .reaction-btn__count {
|
|
129
|
+
color: var(--color-accent-ink);
|
|
130
|
+
}
|
|
131
|
+
</style>
|
|
132
|
+
|
|
133
|
+
<script is:inline nonce={cspNonce}>
|
|
134
|
+
(() => {
|
|
135
|
+
// One article (and one reaction bar) per page.
|
|
136
|
+
const root = document.querySelector("[data-reaction-bar]");
|
|
137
|
+
if (!root) return;
|
|
138
|
+
|
|
139
|
+
const endpoint = root.dataset.endpoint;
|
|
140
|
+
if (!endpoint) return;
|
|
141
|
+
|
|
142
|
+
// Remember this browser's reactions so a reload shows the pressed state and
|
|
143
|
+
// the same reader can't trivially re-inflate a tally by re-clicking.
|
|
144
|
+
const storeKey = "blogreact:" + endpoint;
|
|
145
|
+
let reacted = {};
|
|
146
|
+
try {
|
|
147
|
+
reacted = JSON.parse(localStorage.getItem(storeKey) || "{}") || {};
|
|
148
|
+
} catch {
|
|
149
|
+
reacted = {};
|
|
150
|
+
}
|
|
151
|
+
const persist = () => {
|
|
152
|
+
try {
|
|
153
|
+
localStorage.setItem(storeKey, JSON.stringify(reacted));
|
|
154
|
+
} catch {
|
|
155
|
+
/* storage disabled — pressed state is best-effort */
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const buttons = Array.from(root.querySelectorAll("[data-reaction]"));
|
|
160
|
+
const countEl = (btn) => btn.querySelector("[data-reaction-count]");
|
|
161
|
+
const readCount = (btn) => {
|
|
162
|
+
const n = parseInt((countEl(btn)?.textContent || "0").trim(), 10);
|
|
163
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
164
|
+
};
|
|
165
|
+
const setCount = (btn, n) => {
|
|
166
|
+
const el = countEl(btn);
|
|
167
|
+
if (el) el.textContent = String(n);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
for (const btn of buttons) {
|
|
171
|
+
if (reacted[btn.dataset.reaction]) btn.setAttribute("aria-pressed", "true");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const applyServerCounts = (counts) => {
|
|
175
|
+
if (!counts || typeof counts !== "object") return;
|
|
176
|
+
for (const btn of buttons) {
|
|
177
|
+
const v = counts[btn.dataset.reaction];
|
|
178
|
+
if (typeof v === "number" && v >= 0) setCount(btn, v);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
for (const btn of buttons) {
|
|
183
|
+
btn.addEventListener("click", async () => {
|
|
184
|
+
const reaction = btn.dataset.reaction;
|
|
185
|
+
if (!reaction || reacted[reaction]) return;
|
|
186
|
+
|
|
187
|
+
// Optimistic: bump immediately, mark pressed.
|
|
188
|
+
const before = readCount(btn);
|
|
189
|
+
setCount(btn, before + 1);
|
|
190
|
+
btn.setAttribute("aria-pressed", "true");
|
|
191
|
+
reacted[reaction] = true;
|
|
192
|
+
persist();
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const res = await fetch(endpoint, {
|
|
196
|
+
method: "POST",
|
|
197
|
+
headers: { "content-type": "application/json" },
|
|
198
|
+
body: JSON.stringify({ reaction }),
|
|
199
|
+
});
|
|
200
|
+
const data = await res.json().catch(() => null);
|
|
201
|
+
// The endpoint always returns the authoritative tallies (even on
|
|
202
|
+
// rate-limit / invalid) — reconcile the display to them.
|
|
203
|
+
if (data && data.counts) applyServerCounts(data.counts);
|
|
204
|
+
} catch {
|
|
205
|
+
// Network fault: revert the optimistic bump and pressed state.
|
|
206
|
+
setCount(btn, before);
|
|
207
|
+
btn.setAttribute("aria-pressed", "false");
|
|
208
|
+
delete reacted[reaction];
|
|
209
|
+
persist();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
})();
|
|
214
|
+
</script>
|
|
@@ -36,12 +36,46 @@ interface Props {
|
|
|
36
36
|
ogImage?: string;
|
|
37
37
|
noindex?: boolean;
|
|
38
38
|
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
|
|
39
|
+
/** Alternate RSS/Atom feeds this page advertises (threaded to <Seo>). */
|
|
40
|
+
feeds?: { title: string; href: string }[];
|
|
39
41
|
/** Wider pages (PLP/home) drop the prose max-width. */
|
|
40
42
|
fullBleed?: boolean;
|
|
43
|
+
// --- B1.6 article SEO hardening: pass-through to <Seo> (all additive) ---
|
|
44
|
+
article?: import("@/lib/seo/meta.js").ArticleMeta;
|
|
45
|
+
imageAlt?: string;
|
|
46
|
+
imageWidth?: number;
|
|
47
|
+
imageHeight?: number;
|
|
48
|
+
twitterSite?: string;
|
|
49
|
+
twitterCreator?: string;
|
|
50
|
+
canonicalOverride?: string;
|
|
51
|
+
// --- I2 hreflang / <html lang> (additive) ---
|
|
52
|
+
/** Per-variant `hreflang` alternates for a translated article; threaded to
|
|
53
|
+
* <Seo>. Absent/empty ⇒ no alternate links (singleton unchanged). */
|
|
54
|
+
alternates?: import("@/lib/seo/alternates.js").Alternate[];
|
|
55
|
+
/** Overrides `<html lang>` (e.g. a translated article's `inLanguage`). Absent ⇒
|
|
56
|
+
* the tenant-locale default, so non-translated pages are unchanged. */
|
|
57
|
+
lang?: string;
|
|
41
58
|
}
|
|
42
59
|
|
|
43
|
-
const {
|
|
44
|
-
|
|
60
|
+
const {
|
|
61
|
+
title,
|
|
62
|
+
description,
|
|
63
|
+
path,
|
|
64
|
+
ogType,
|
|
65
|
+
ogImage,
|
|
66
|
+
noindex,
|
|
67
|
+
jsonLd,
|
|
68
|
+
feeds,
|
|
69
|
+
article,
|
|
70
|
+
imageAlt,
|
|
71
|
+
imageWidth,
|
|
72
|
+
imageHeight,
|
|
73
|
+
twitterSite,
|
|
74
|
+
twitterCreator,
|
|
75
|
+
canonicalOverride,
|
|
76
|
+
alternates,
|
|
77
|
+
lang,
|
|
78
|
+
} = Astro.props;
|
|
45
79
|
const { tenant, themeStyle, canonicalBase, basePath, cspNonce, features, capabilities } =
|
|
46
80
|
Astro.locals;
|
|
47
81
|
|
|
@@ -156,7 +190,7 @@ const footerWidgets = await renderWidgetSlot(
|
|
|
156
190
|
---
|
|
157
191
|
|
|
158
192
|
<!doctype html>
|
|
159
|
-
<html lang={tenant.locale.split("-")[0] ?? "en"} data-base-path={basePath || undefined}>
|
|
193
|
+
<html lang={lang ?? tenant.locale.split("-")[0] ?? "en"} data-base-path={basePath || undefined}>
|
|
160
194
|
<head>
|
|
161
195
|
<meta charset="utf-8" />
|
|
162
196
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
@@ -185,6 +219,15 @@ const footerWidgets = await renderWidgetSlot(
|
|
|
185
219
|
ogImage={ogImage}
|
|
186
220
|
noindex={noindex}
|
|
187
221
|
jsonLd={allJsonLd}
|
|
222
|
+
feeds={feeds}
|
|
223
|
+
article={article}
|
|
224
|
+
imageAlt={imageAlt}
|
|
225
|
+
imageWidth={imageWidth}
|
|
226
|
+
imageHeight={imageHeight}
|
|
227
|
+
twitterSite={twitterSite}
|
|
228
|
+
twitterCreator={twitterCreator}
|
|
229
|
+
canonicalOverride={canonicalOverride}
|
|
230
|
+
alternates={alternates}
|
|
188
231
|
/>
|
|
189
232
|
|
|
190
233
|
{
|
|
@@ -357,6 +400,8 @@ const footerWidgets = await renderWidgetSlot(
|
|
|
357
400
|
function fix(a) {
|
|
358
401
|
var href = a.getAttribute("href");
|
|
359
402
|
if (!href || href.charAt(0) !== "/" || href.charAt(1) === "/") return;
|
|
403
|
+
// Global asset namespaces are root-served under every tenant.
|
|
404
|
+
if (href.indexOf("/tenants/") === 0 || href.indexOf("/shared/") === 0) return;
|
|
360
405
|
if (href === base || href.indexOf(base + "/") === 0) return;
|
|
361
406
|
a.setAttribute("href", base + href);
|
|
362
407
|
}
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Operational-activity ALERT conditions
|
|
3
|
-
* the
|
|
2
|
+
* Operational-activity ALERT conditions — the detection layer on top of
|
|
3
|
+
* the activity query surface.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* conditions in that stream into ALERTS. It
|
|
7
|
-
* this repo's
|
|
5
|
+
* The query surface makes operational activity queryable; this turns four specific,
|
|
6
|
+
* high-value conditions in that stream into ALERTS. It follows the same shape as
|
|
7
|
+
* this repo's other alert contract, `analytics/rumAlert.ts`: a set of
|
|
8
8
|
* PURE, transport-agnostic detectors that take a window of activity and return a
|
|
9
|
-
* structured `ActivityAlert[]`. WHERE the alerts go (a log line, a
|
|
9
|
+
* structured `ActivityAlert[]`. WHERE the alerts go (a log line, a
|
|
10
10
|
* PagerDuty/Slack sink, a polled endpoint) is the sink's job — there is NO outbound
|
|
11
|
-
* notification transport in this repo
|
|
12
|
-
* so
|
|
11
|
+
* notification transport in this repo (rumAlert is likewise detection-only),
|
|
12
|
+
* so this layer does not invent one. It also does NOT add a cron: the checker is on-demand
|
|
13
13
|
* (computed when `/admin/ops-timeline` loads, or when the read-only alerts endpoint
|
|
14
|
-
* is polled)
|
|
14
|
+
* is polled).
|
|
15
15
|
*
|
|
16
16
|
* THE FOUR CONDITIONS (and how each avoids false-positive noise):
|
|
17
17
|
*
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
* still legitimately in flight (received < grace ago) never fires; a terminal
|
|
38
38
|
* after the received (any status) clears it.
|
|
39
39
|
*
|
|
40
|
-
* 4. deploy-skew — promotes
|
|
40
|
+
* 4. deploy-skew — promotes the visibility-only `deriveDeploySkew()` into an
|
|
41
41
|
* alert. `skewDetected` is true on EVERY normal rollout (two isolates coexist
|
|
42
42
|
* briefly), so alerting on it directly would page on every deploy. Instead this
|
|
43
43
|
* fires ONLY when the build serving this request differs from the newest
|
|
@@ -45,12 +45,10 @@
|
|
|
45
45
|
* period (default 15m) — i.e. THIS isolate failed to roll to a deploy that
|
|
46
46
|
* finished long ago (a persistent stale isolate), not a rollout in progress.
|
|
47
47
|
*
|
|
48
|
-
* DATA DEPENDENCY
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* logs-only; `deployVersion.ts` notes the swap to a timeline-fanning emitter is
|
|
53
|
-
* pending) — NOT D7's job. `preview.serve` stays `analytics`-sink by design (too
|
|
48
|
+
* DATA DEPENDENCY. The detectors read the D1 `activity_events` timeline through the
|
|
49
|
+
* `queryActivity` seam, so they alert on whatever is in that timeline. Getting
|
|
50
|
+
* server-emitted lifecycle events into the timeline is the fan-out wiring's job, not
|
|
51
|
+
* this layer's. `preview.serve` stays `analytics`-sink by design (too
|
|
54
52
|
* high-volume to timeline wholesale); detector 2 is source-agnostic (the rumAlert
|
|
55
53
|
* precedent) and fires against an Analytics-Engine-sourced or seeded feed. See
|
|
56
54
|
* `ALERT_CONDITIONS` for the per-condition source + wiring note.
|
|
@@ -63,10 +61,10 @@ import {
|
|
|
63
61
|
} from "./query.js";
|
|
64
62
|
import type { Queryable } from "../d1/catalog.js";
|
|
65
63
|
|
|
66
|
-
/** Severity, mirroring
|
|
64
|
+
/** Severity, mirroring `rumAlert.AlertSeverity` / `manifest.Severity`. */
|
|
67
65
|
export type AlertSeverity = "page" | "critical" | "warning" | "info";
|
|
68
66
|
|
|
69
|
-
/** The four
|
|
67
|
+
/** The four conditions. Stable ids — a downstream sink/dashboard keys on them. */
|
|
70
68
|
export type ActivityAlertType =
|
|
71
69
|
| "silent-terminal"
|
|
72
70
|
| "recurring-preview-miss"
|
|
@@ -276,7 +274,7 @@ export interface DeploySkewAlertOptions {
|
|
|
276
274
|
}
|
|
277
275
|
|
|
278
276
|
/**
|
|
279
|
-
* Promote
|
|
277
|
+
* Promote the visibility-only `DeploySkewSummary` into an alert, WITHOUT firing on
|
|
280
278
|
* every normal rollout. Fires only when the build serving this request differs from
|
|
281
279
|
* the newest observed build AND that newest build has been latest for longer than
|
|
282
280
|
* `graceMs` — i.e. THIS isolate never rolled to a deploy that finished long ago.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Owner-facing "who did this" attribution for a candidate change or aggregate —
|
|
3
3
|
* DELIBERATELY separate from the anonymized activity spine. `ActivityActor.id`
|
|
4
4
|
* is a salted, non-reversible hash by design (see `recordActivity.ts`'s header:
|
|
5
|
-
* a raw PII id in the core zone would defeat the
|
|
5
|
+
* a raw PII id in the core zone would defeat the redaction contract), so it can never
|
|
6
6
|
* answer "who submitted/accepted/rejected/shipped this" for an owner reviewing
|
|
7
7
|
* `/admin#publish`. This persists the real email (never joined into the general
|
|
8
8
|
* `queryActivity()` surface, never cross-tenant) alongside the existing hashed
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { displayNameFromEmail } from "./recordActivity";
|
|
19
19
|
|
|
20
|
-
export type ChangeActorAction = "submit" | "reject" | "accept" | "ship" | "build";
|
|
20
|
+
export type ChangeActorAction = "submit" | "reject" | "resolve" | "accept" | "ship" | "build";
|
|
21
21
|
|
|
22
22
|
export interface ChangeActorRecord {
|
|
23
23
|
email: string;
|
|
@@ -69,7 +69,7 @@ export async function readChangeActors(
|
|
|
69
69
|
tenantId: string,
|
|
70
70
|
subjectId: string,
|
|
71
71
|
): Promise<Partial<Record<ChangeActorAction, ChangeActorRecord>>> {
|
|
72
|
-
const actions: ChangeActorAction[] = ["submit", "reject", "accept", "ship", "build"];
|
|
72
|
+
const actions: ChangeActorAction[] = ["submit", "reject", "resolve", "accept", "ship", "build"];
|
|
73
73
|
const out: Partial<Record<ChangeActorAction, ChangeActorRecord>> = {};
|
|
74
74
|
await Promise.all(
|
|
75
75
|
actions.map(async (action) => {
|