@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
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Layout from "@/layouts/Layout.astro";
|
|
3
|
-
import
|
|
4
|
-
import
|
|
3
|
+
import BlogPaywall from "@/components/blog/BlogPaywall.astro";
|
|
4
|
+
import ReactionBar from "@/components/blog/ReactionBar.astro";
|
|
5
|
+
import { getBlogPost, listBlogPosts, resolveBlogRedirect } from "@/lib/blog/provider.js";
|
|
6
|
+
import { authorSlug, getTranslations } from "@/lib/blog/collection.js";
|
|
7
|
+
import { viewerHasBlogAccess } from "@/lib/blog/access.js";
|
|
8
|
+
import { articleBodyHtml } from "@/lib/blog/teaser.js";
|
|
9
|
+
import { readViewerSession, safeNext } from "@/lib/auth/route.js";
|
|
10
|
+
import { buildAlternates } from "@/lib/seo/alternates.js";
|
|
11
|
+
import { languageLabel } from "@/lib/i18n.js";
|
|
12
|
+
import { breadcrumbLd, blogPostingLd } from "@/lib/jsonld.js";
|
|
5
13
|
|
|
6
14
|
const { tenant, canonicalBase, basePath } = Astro.locals;
|
|
7
15
|
const slug = Astro.params.slug ?? "";
|
|
@@ -12,26 +20,55 @@ if (!post) {
|
|
|
12
20
|
return new Response(null, { status: 404 });
|
|
13
21
|
}
|
|
14
22
|
|
|
23
|
+
// Entitlement (⟂ visibility): a gated post is still published/listed, but an
|
|
24
|
+
// unentitled reader sees a teaser + paywall rather than the full body. A teaser
|
|
25
|
+
// is a legitimate partial-content 200 — indexable, NOT noindexed (B4.0 spike §4).
|
|
26
|
+
const viewer = await readViewerSession(Astro);
|
|
27
|
+
const gated = (post.access ?? "public") !== "public";
|
|
28
|
+
const unlock = viewerHasBlogAccess(viewer, post);
|
|
29
|
+
const bodyHtml = articleBodyHtml(post.contentHtml, { gated, unlock });
|
|
30
|
+
const next = safeNext(`${basePath}/blog/${post.slug}/`);
|
|
31
|
+
const loginHref = `/auth/login?next=${encodeURIComponent(next)}`;
|
|
32
|
+
|
|
15
33
|
const canonical = new URL(`/blog/${post.slug}/`, canonicalBase + "/").toString();
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
34
|
+
// The author archive (`/blog/author/<slug>`) lists posts from the curated blog
|
|
35
|
+
// index, and 404s for an author with no LISTED post (an unlisted-only or
|
|
36
|
+
// index-omitted byline). Resolve that up front so the byline links ONLY when the
|
|
37
|
+
// archive will actually render — a byline must never point at a missing page
|
|
38
|
+
// (the invariant `blog/author/[author].astro` documents). Same slug + same source
|
|
39
|
+
// the archive itself filters on, so the two can't disagree.
|
|
40
|
+
const authorArchiveSlug = authorSlug(post.author);
|
|
41
|
+
const authorHasArchive = (await listBlogPosts(tenant.tenant_id)).some(
|
|
42
|
+
(p) => authorSlug(p.author) === authorArchiveSlug,
|
|
43
|
+
);
|
|
44
|
+
const authorUrl = authorHasArchive
|
|
45
|
+
? new URL(`/blog/author/${authorArchiveSlug}/`, canonicalBase + "/").toString()
|
|
46
|
+
: undefined;
|
|
47
|
+
const articleLd = blogPostingLd(post, {
|
|
21
48
|
url: canonical,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
publisher: { "@type": "Organization", name: tenant.displayName, url: canonicalBase },
|
|
27
|
-
...(post.featuredImage ? { image: new URL(post.featuredImage.src, canonicalBase + "/").toString() } : {}),
|
|
28
|
-
};
|
|
49
|
+
canonicalBase,
|
|
50
|
+
publisherName: tenant.displayName,
|
|
51
|
+
authorUrl,
|
|
52
|
+
});
|
|
29
53
|
const crumbs = breadcrumbLd([
|
|
30
54
|
{ label: tenant.displayName, href: "/" },
|
|
31
55
|
{ label: "Blog", href: "/blog/" },
|
|
32
56
|
{ label: post.title, href: `/blog/${post.slug}/` },
|
|
33
57
|
], canonicalBase);
|
|
34
58
|
const blogHref = `${basePath}/blog/`;
|
|
59
|
+
const authorHref = authorHasArchive
|
|
60
|
+
? `${basePath}/blog/author/${authorArchiveSlug}/`
|
|
61
|
+
: null;
|
|
62
|
+
|
|
63
|
+
// I2 — hreflang alternates + per-variant <html lang>. Singleton ⇒ [] alternates
|
|
64
|
+
// and no `lang` override, so a non-translated post is byte-identical to today.
|
|
65
|
+
const translations = post.translationGroupId
|
|
66
|
+
? await getTranslations(tenant.tenant_id, "blog", post.slug)
|
|
67
|
+
: [];
|
|
68
|
+
const alternates = buildAlternates(translations, (s) =>
|
|
69
|
+
new URL(`/blog/${s}/`, canonicalBase + "/").toString(),
|
|
70
|
+
);
|
|
71
|
+
const lang = alternates.length ? (post.inLanguage ?? "en") : undefined;
|
|
35
72
|
---
|
|
36
73
|
|
|
37
74
|
<Layout
|
|
@@ -41,6 +78,19 @@ const blogHref = `${basePath}/blog/`;
|
|
|
41
78
|
ogType="article"
|
|
42
79
|
ogImage={post.featuredImage?.src}
|
|
43
80
|
jsonLd={[crumbs, articleLd]}
|
|
81
|
+
article={{
|
|
82
|
+
publishedTime: post.publishedAt,
|
|
83
|
+
modifiedTime: post.modifiedAt,
|
|
84
|
+
author: authorUrl,
|
|
85
|
+
section: post.categories[0],
|
|
86
|
+
tags: post.tags,
|
|
87
|
+
}}
|
|
88
|
+
imageAlt={post.featuredImage?.alt}
|
|
89
|
+
imageWidth={post.featuredImage?.width}
|
|
90
|
+
imageHeight={post.featuredImage?.height}
|
|
91
|
+
canonicalOverride={post.canonicalUrl}
|
|
92
|
+
alternates={alternates}
|
|
93
|
+
lang={lang}
|
|
44
94
|
>
|
|
45
95
|
<article class="article-shell">
|
|
46
96
|
<nav class="breadcrumbs" aria-label="Breadcrumb"><a href={blogHref}>Blog</a><span aria-hidden="true">/</span><span>{post.categories[0] ?? "Article"}</span></nav>
|
|
@@ -49,10 +99,27 @@ const blogHref = `${basePath}/blog/`;
|
|
|
49
99
|
<h1>{post.title}</h1>
|
|
50
100
|
<p class="dek">{post.excerpt}</p>
|
|
51
101
|
<div class="byline">
|
|
52
|
-
<span>By {
|
|
102
|
+
<span>By {authorHref
|
|
103
|
+
? <a href={authorHref} class="byline-author" rel="author">{post.author}</a>
|
|
104
|
+
: <span class="byline-author">{post.author}</span>}</span>
|
|
53
105
|
<span aria-hidden="true">·</span>
|
|
54
106
|
<time datetime={post.publishedAt}>{new Date(post.publishedAt).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}</time>
|
|
55
107
|
</div>
|
|
108
|
+
{translations.length > 1 && (
|
|
109
|
+
<nav class="lang-switcher" aria-label="Available languages">
|
|
110
|
+
{translations.map((variant) => {
|
|
111
|
+
const code = variant.inLanguage ?? "en";
|
|
112
|
+
const current = variant.slug === post.slug;
|
|
113
|
+
return (
|
|
114
|
+
<a
|
|
115
|
+
hreflang={code}
|
|
116
|
+
href={`${basePath}/blog/${variant.slug}/`}
|
|
117
|
+
aria-current={current ? "true" : undefined}
|
|
118
|
+
>{languageLabel(code)}</a>
|
|
119
|
+
);
|
|
120
|
+
})}
|
|
121
|
+
</nav>
|
|
122
|
+
)}
|
|
56
123
|
</header>
|
|
57
124
|
{post.featuredImage && (
|
|
58
125
|
<img
|
|
@@ -70,8 +137,14 @@ const blogHref = `${basePath}/blog/`;
|
|
|
70
137
|
<a href={blogHref}>← All articles</a>
|
|
71
138
|
{post.tags.length > 0 && <div class="article-tags">{post.tags.map((tag) => <span>{tag}</span>)}</div>}
|
|
72
139
|
</aside>
|
|
73
|
-
<div class="blog-
|
|
140
|
+
<div class="blog-main">
|
|
141
|
+
<div class="blog-body" set:html={bodyHtml}></div>
|
|
142
|
+
{gated && !unlock && (
|
|
143
|
+
<BlogPaywall access={post.access ?? "authenticated"} next={next} signedIn={!!viewer} loginHref={loginHref} />
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
74
146
|
</div>
|
|
147
|
+
<ReactionBar collection="blog" slug={post.slug} />
|
|
75
148
|
</article>
|
|
76
149
|
</Layout>
|
|
77
150
|
|
|
@@ -84,12 +157,19 @@ const blogHref = `${basePath}/blog/`;
|
|
|
84
157
|
h1 { margin-top: .9rem; font-size: clamp(2.5rem, 6vw, 5rem); line-height: 1.02; }
|
|
85
158
|
.dek { max-width: 68ch; margin: 1.5rem auto 0; color: var(--color-muted); font-size: clamp(1rem, 2vw, 1.2rem); }
|
|
86
159
|
.byline { display: flex; justify-content: center; gap: .55rem; margin-top: 1.35rem; color: var(--color-muted); font-size: .85rem; }
|
|
160
|
+
.byline-author { color: var(--color-accent-ink); font-weight: 700; text-decoration: none; }
|
|
161
|
+
.byline-author:hover { text-decoration: underline; text-underline-offset: .18em; }
|
|
162
|
+
.lang-switcher { display: flex; flex-wrap: wrap; justify-content: center; gap: .4rem; margin-top: 1.1rem; }
|
|
163
|
+
.lang-switcher a { padding: .28rem .6rem; border: 1px solid var(--color-border); border-radius: var(--radius-full); color: var(--color-muted); font-size: .78rem; font-weight: 600; text-decoration: none; }
|
|
164
|
+
.lang-switcher a:hover { color: var(--color-accent-ink); border-color: var(--color-accent); }
|
|
165
|
+
.lang-switcher a[aria-current="true"] { color: var(--color-accent-ink); border-color: var(--color-accent); background: var(--color-surface); }
|
|
87
166
|
.featured { display: block; width: 100%; max-height: 620px; object-fit: cover; border-radius: var(--radius-lg); box-shadow: var(--shadow-md); }
|
|
88
167
|
.article-grid { display: grid; grid-template-columns: 190px minmax(0, 720px); justify-content: center; gap: clamp(2rem, 6vw, 5rem); margin-top: clamp(3rem, 7vw, 5.5rem); }
|
|
89
168
|
aside { align-self: start; position: sticky; top: 7rem; }
|
|
90
169
|
aside > a { color: var(--color-accent-ink); font-size: .88rem; font-weight: 700; }
|
|
91
170
|
.article-tags { display: flex; flex-wrap: wrap; gap: .45rem; margin-top: 1.2rem; }
|
|
92
171
|
.article-tags span { padding: .3rem .55rem; border: 1px solid var(--color-border); border-radius: var(--radius-full); color: var(--color-muted); font-size: .7rem; }
|
|
172
|
+
.blog-main { min-width: 0; }
|
|
93
173
|
.blog-body { min-width: 0; font-size: 1.05rem; line-height: 1.78; }
|
|
94
174
|
.blog-body :global(p), .blog-body :global(ul), .blog-body :global(ol), .blog-body :global(blockquote), .blog-body :global(figure), .blog-body :global(table), .blog-body :global(pre) { margin-block: 1.25rem; }
|
|
95
175
|
.blog-body :global(h2), .blog-body :global(h3), .blog-body :global(h4) { scroll-margin-top: 7rem; margin: 2.8rem 0 1rem; line-height: 1.18; }
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Layout from "@/layouts/Layout.astro";
|
|
3
|
+
import PostAccessBadge from "@/components/blog/PostAccessBadge.astro";
|
|
4
|
+
import { listBlogPosts } from "@/lib/blog/provider.js";
|
|
5
|
+
import { authorSlug, getAuthors } from "@/lib/blog/collection.js";
|
|
6
|
+
import { breadcrumbLd } from "@/lib/jsonld.js";
|
|
7
|
+
|
|
8
|
+
// Author archive for the `/blog` collection. Lists exactly the posts whose
|
|
9
|
+
// bylines link here (same source as /blog/index.astro + /blog/[slug].astro),
|
|
10
|
+
// so a byline never points at a page missing its post.
|
|
11
|
+
const { tenant, basePath, canonicalBase } = Astro.locals;
|
|
12
|
+
const slug = Astro.params.author ?? "";
|
|
13
|
+
|
|
14
|
+
const allPosts = await listBlogPosts(tenant.tenant_id);
|
|
15
|
+
const posts = allPosts.filter((post) => authorSlug(post.author) === slug);
|
|
16
|
+
if (posts.length === 0) return new Response(null, { status: 404 });
|
|
17
|
+
|
|
18
|
+
const authorName = posts[0]!.author;
|
|
19
|
+
const bio = (await getAuthors(tenant.tenant_id, "blog"))[slug];
|
|
20
|
+
|
|
21
|
+
const href = (path: string) => `${basePath}${path}` || "/";
|
|
22
|
+
const canonical = new URL(`/blog/author/${slug}/`, canonicalBase + "/").toString();
|
|
23
|
+
const avatarUrl = bio?.avatar ? new URL(bio.avatar, canonicalBase + "/").toString() : undefined;
|
|
24
|
+
|
|
25
|
+
const personLd = {
|
|
26
|
+
"@context": "https://schema.org",
|
|
27
|
+
"@type": "Person",
|
|
28
|
+
name: authorName,
|
|
29
|
+
url: canonical,
|
|
30
|
+
...(bio?.title ? { jobTitle: bio.title } : {}),
|
|
31
|
+
...(bio?.bio ? { description: bio.bio } : {}),
|
|
32
|
+
...(avatarUrl ? { image: avatarUrl } : {}),
|
|
33
|
+
...(bio?.url ? { mainEntityOfPage: bio.url } : {}),
|
|
34
|
+
...(bio?.sameAs && bio.sameAs.length > 0 ? { sameAs: bio.sameAs } : {}),
|
|
35
|
+
};
|
|
36
|
+
const profileLd = {
|
|
37
|
+
"@context": "https://schema.org",
|
|
38
|
+
"@type": "ProfilePage",
|
|
39
|
+
mainEntity: personLd,
|
|
40
|
+
};
|
|
41
|
+
const itemListLd = {
|
|
42
|
+
"@context": "https://schema.org",
|
|
43
|
+
"@type": "ItemList",
|
|
44
|
+
numberOfItems: posts.length,
|
|
45
|
+
itemListElement: posts.map((post, index) => ({
|
|
46
|
+
"@type": "ListItem",
|
|
47
|
+
position: index + 1,
|
|
48
|
+
name: post.title,
|
|
49
|
+
url: new URL(`/blog/${post.slug}/`, canonicalBase + "/").toString(),
|
|
50
|
+
})),
|
|
51
|
+
};
|
|
52
|
+
const crumbs = breadcrumbLd([
|
|
53
|
+
{ label: tenant.displayName, href: "/" },
|
|
54
|
+
{ label: "Blog", href: "/blog/" },
|
|
55
|
+
{ label: authorName, href: `/blog/author/${slug}/` },
|
|
56
|
+
], canonicalBase);
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
<Layout
|
|
60
|
+
title={`${authorName} — Blog`}
|
|
61
|
+
description={bio?.bio ?? `Articles by ${authorName}.`}
|
|
62
|
+
path={`/blog/author/${slug}/`}
|
|
63
|
+
fullBleed
|
|
64
|
+
jsonLd={[crumbs, profileLd, itemListLd]}
|
|
65
|
+
>
|
|
66
|
+
<header class="author-hero">
|
|
67
|
+
<div class="blog-shell">
|
|
68
|
+
<nav class="breadcrumbs" aria-label="Breadcrumb"><a href={href("/blog/")}>Blog</a><span aria-hidden="true">/</span><span>Author</span></nav>
|
|
69
|
+
<div class="author-id">
|
|
70
|
+
{avatarUrl && <img class="avatar" src={avatarUrl} alt={authorName} width="96" height="96" decoding="async" />}
|
|
71
|
+
<div>
|
|
72
|
+
<p class="eyebrow eyebrow--tick">Author</p>
|
|
73
|
+
<h1>{authorName}</h1>
|
|
74
|
+
{bio?.title && <p class="author-title">{bio.title}</p>}
|
|
75
|
+
{bio?.bio && <p class="author-bio">{bio.bio}</p>}
|
|
76
|
+
{bio?.sameAs && bio.sameAs.length > 0 && (
|
|
77
|
+
<p class="author-links">
|
|
78
|
+
{bio.sameAs.map((url) => <a href={url} rel="me nofollow" target="_blank">{new URL(url).hostname.replace(/^www\./, "")}</a>)}
|
|
79
|
+
</p>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</header>
|
|
85
|
+
|
|
86
|
+
<div class="blog-shell">
|
|
87
|
+
<section class="post-grid" aria-label={`Posts by ${authorName}`}>
|
|
88
|
+
{posts.map((post, index) => (
|
|
89
|
+
<article class="post-card">
|
|
90
|
+
<a href={href(`/blog/${post.slug}/`)} aria-label={`Read ${post.title}`}>
|
|
91
|
+
{post.featuredImage ? (
|
|
92
|
+
<img
|
|
93
|
+
src={post.featuredImage.src}
|
|
94
|
+
alt={post.featuredImage.alt}
|
|
95
|
+
width={post.featuredImage.width}
|
|
96
|
+
height={post.featuredImage.height}
|
|
97
|
+
loading={index < 3 ? "eager" : "lazy"}
|
|
98
|
+
decoding="async"
|
|
99
|
+
/>
|
|
100
|
+
) : <div class="image-placeholder" aria-hidden="true"></div>}
|
|
101
|
+
<div class="post-card-copy">
|
|
102
|
+
<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>
|
|
103
|
+
<PostAccessBadge access={post.access} />
|
|
104
|
+
<h2>{post.title}</h2>
|
|
105
|
+
<p>{post.excerpt}</p>
|
|
106
|
+
<span>Read article <span aria-hidden="true">→</span></span>
|
|
107
|
+
</div>
|
|
108
|
+
</a>
|
|
109
|
+
</article>
|
|
110
|
+
))}
|
|
111
|
+
</section>
|
|
112
|
+
</div>
|
|
113
|
+
</Layout>
|
|
114
|
+
|
|
115
|
+
<style>
|
|
116
|
+
.blog-shell { width: min(1180px, calc(100% - 2rem)); margin-inline: auto; }
|
|
117
|
+
.author-hero { padding: clamp(3.5rem, 8vw, 6rem) 0 clamp(2rem, 5vw, 3.5rem); background: linear-gradient(145deg, var(--color-primary), color-mix(in srgb, var(--color-primary) 82%, var(--color-accent))); color: var(--color-primary-contrast); }
|
|
118
|
+
.breadcrumbs { display: flex; gap: .55rem; align-items: center; font-size: .84rem; color: color-mix(in srgb, var(--color-primary-contrast) 78%, transparent); }
|
|
119
|
+
.breadcrumbs a { color: inherit; font-weight: 700; }
|
|
120
|
+
.author-id { display: flex; gap: 1.5rem; align-items: center; margin-top: 1.5rem; }
|
|
121
|
+
.avatar { flex: 0 0 auto; width: 96px; height: 96px; border-radius: var(--radius-full); object-fit: cover; box-shadow: var(--shadow-md); }
|
|
122
|
+
.author-hero h1 { margin-top: .5rem; color: inherit; font-size: clamp(2.4rem, 6vw, 4.2rem); }
|
|
123
|
+
.author-hero .eyebrow { color: color-mix(in srgb, var(--color-primary-contrast) 75%, transparent); }
|
|
124
|
+
.author-title { margin-top: .35rem; font-weight: 700; color: color-mix(in srgb, var(--color-primary-contrast) 90%, transparent); }
|
|
125
|
+
.author-bio { max-width: 62ch; margin-top: 1rem; color: color-mix(in srgb, var(--color-primary-contrast) 82%, transparent); font-size: 1.05rem; }
|
|
126
|
+
.author-links { display: flex; flex-wrap: wrap; gap: 1rem; margin-top: 1.1rem; }
|
|
127
|
+
.author-links a { color: inherit; font-weight: 700; font-size: .85rem; text-decoration: underline; text-underline-offset: .18em; }
|
|
128
|
+
.post-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.35rem; align-items: start; padding-block: clamp(2.5rem, 6vw, 4.5rem); }
|
|
129
|
+
.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; }
|
|
130
|
+
.post-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-md); }
|
|
131
|
+
.post-card a { display: flex; height: 100%; flex-direction: column; }
|
|
132
|
+
.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)); }
|
|
133
|
+
.post-card-copy { display: flex; flex: 1; flex-direction: column; padding: 1.25rem; }
|
|
134
|
+
.post-meta { margin: 0 0 .7rem; color: var(--color-muted); font: 600 .72rem/1.45 var(--font-mono); text-transform: uppercase; letter-spacing: .05em; }
|
|
135
|
+
.post-card h2 { font-size: 1.18rem; line-height: 1.25; }
|
|
136
|
+
.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; }
|
|
137
|
+
.post-card-copy > span { margin-top: auto; color: var(--color-accent-ink); font-weight: 700; font-size: .88rem; }
|
|
138
|
+
@media (max-width: 980px) { .post-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
139
|
+
@media (max-width: 640px) { .author-id { flex-direction: column; align-items: flex-start; } .post-grid { grid-template-columns: 1fr; } }
|
|
140
|
+
</style>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Layout from "@/layouts/Layout.astro";
|
|
3
|
+
import PostGrid from "@/components/blog/PostGrid.astro";
|
|
4
|
+
import { listBlogPosts } from "@/lib/blog/provider.js";
|
|
5
|
+
import { collectPostsByTerm } from "@/lib/blog/collection.js";
|
|
6
|
+
import { breadcrumbLd } from "@/lib/jsonld.js";
|
|
7
|
+
|
|
8
|
+
const { tenant, basePath, canonicalBase } = Astro.locals;
|
|
9
|
+
const param = Astro.params.category ?? "";
|
|
10
|
+
|
|
11
|
+
// Same source as the /blog index (the collection's curated list), filtered to
|
|
12
|
+
// one category — so a post can never appear here that isn't already live on /blog.
|
|
13
|
+
const all = await listBlogPosts(tenant.tenant_id);
|
|
14
|
+
const match = collectPostsByTerm(all, "categories", param);
|
|
15
|
+
if (!match) return new Response(null, { status: 404 });
|
|
16
|
+
const { label, posts } = match;
|
|
17
|
+
|
|
18
|
+
const path = `/blog/category/${param}/`;
|
|
19
|
+
const itemList = {
|
|
20
|
+
"@context": "https://schema.org",
|
|
21
|
+
"@type": "ItemList",
|
|
22
|
+
numberOfItems: posts.length,
|
|
23
|
+
itemListElement: posts.map((post, index) => ({
|
|
24
|
+
"@type": "ListItem",
|
|
25
|
+
position: index + 1,
|
|
26
|
+
name: post.title,
|
|
27
|
+
url: new URL(`/blog/${post.slug}/`, canonicalBase + "/").toString(),
|
|
28
|
+
})),
|
|
29
|
+
};
|
|
30
|
+
const crumbs = breadcrumbLd([
|
|
31
|
+
{ label: tenant.displayName, href: "/" },
|
|
32
|
+
{ label: "Blog", href: "/blog/" },
|
|
33
|
+
{ label, href: path },
|
|
34
|
+
], canonicalBase);
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
<Layout
|
|
38
|
+
title={`${label} — Blog`}
|
|
39
|
+
description={`Articles in ${label} from ${tenant.displayName}.`}
|
|
40
|
+
path={path}
|
|
41
|
+
fullBleed
|
|
42
|
+
jsonLd={[crumbs, itemList]}
|
|
43
|
+
>
|
|
44
|
+
<header class="blog-hero">
|
|
45
|
+
<div class="blog-shell">
|
|
46
|
+
<nav class="breadcrumbs" aria-label="Breadcrumb"><a href={`${basePath}/blog/`}>Blog</a><span aria-hidden="true">/</span><span>Category</span></nav>
|
|
47
|
+
<p class="eyebrow eyebrow--tick">Category</p>
|
|
48
|
+
<h1>{label}</h1>
|
|
49
|
+
<p>{posts.length} {posts.length === 1 ? "article" : "articles"} in this category.</p>
|
|
50
|
+
</div>
|
|
51
|
+
</header>
|
|
52
|
+
|
|
53
|
+
<div class="blog-shell blog-layout">
|
|
54
|
+
<PostGrid posts={posts} basePath={basePath} base="/blog" />
|
|
55
|
+
</div>
|
|
56
|
+
</Layout>
|
|
57
|
+
|
|
58
|
+
<style>
|
|
59
|
+
.blog-shell { width: min(1180px, calc(100% - 2rem)); margin-inline: auto; }
|
|
60
|
+
.blog-hero { padding: clamp(4.5rem, 9vw, 8rem) 0 clamp(3rem, 6vw, 5rem); background: linear-gradient(145deg, var(--color-primary), color-mix(in srgb, var(--color-primary) 82%, var(--color-accent))); color: var(--color-primary-contrast); }
|
|
61
|
+
.breadcrumbs { display: flex; gap: .55rem; align-items: center; margin-bottom: 1rem; color: color-mix(in srgb, var(--color-primary-contrast) 78%, transparent); font-size: .84rem; }
|
|
62
|
+
.breadcrumbs a { color: inherit; font-weight: 700; }
|
|
63
|
+
.blog-hero h1 { max-width: 14ch; margin-top: .75rem; color: inherit; font-size: clamp(2.6rem, 6vw, 4.6rem); }
|
|
64
|
+
.blog-hero p:last-child { max-width: 58ch; margin-top: 1rem; color: color-mix(in srgb, var(--color-primary-contrast) 82%, transparent); font-size: 1.05rem; }
|
|
65
|
+
.blog-hero .eyebrow { color: color-mix(in srgb, var(--color-primary-contrast) 75%, transparent); }
|
|
66
|
+
.blog-layout { padding-block: clamp(3rem, 7vw, 6rem); }
|
|
67
|
+
</style>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Layout from "@/layouts/Layout.astro";
|
|
3
|
+
import PostAccessBadge from "@/components/blog/PostAccessBadge.astro";
|
|
3
4
|
import { listBlogPosts } from "@/lib/blog/provider.js";
|
|
4
5
|
|
|
5
6
|
const { tenant, basePath, canonicalBase } = Astro.locals;
|
|
@@ -60,6 +61,7 @@ const itemList = {
|
|
|
60
61
|
) : <div class="image-placeholder" aria-hidden="true"></div>}
|
|
61
62
|
<div class="post-card-copy">
|
|
62
63
|
<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>
|
|
64
|
+
<PostAccessBadge access={post.access} />
|
|
63
65
|
<h2>{post.title}</h2>
|
|
64
66
|
<p>{post.excerpt}</p>
|
|
65
67
|
<span>Read article <span aria-hidden="true">→</span></span>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Layout from "@/layouts/Layout.astro";
|
|
3
|
+
import PostGrid from "@/components/blog/PostGrid.astro";
|
|
4
|
+
import { listBlogPosts } from "@/lib/blog/provider.js";
|
|
5
|
+
import { collectPostsByTerm } from "@/lib/blog/collection.js";
|
|
6
|
+
import { breadcrumbLd } from "@/lib/jsonld.js";
|
|
7
|
+
|
|
8
|
+
const { tenant, basePath, canonicalBase } = Astro.locals;
|
|
9
|
+
const param = Astro.params.tag ?? "";
|
|
10
|
+
|
|
11
|
+
const all = await listBlogPosts(tenant.tenant_id);
|
|
12
|
+
const match = collectPostsByTerm(all, "tags", param);
|
|
13
|
+
if (!match) return new Response(null, { status: 404 });
|
|
14
|
+
const { label, posts } = match;
|
|
15
|
+
|
|
16
|
+
const path = `/blog/tag/${param}/`;
|
|
17
|
+
const itemList = {
|
|
18
|
+
"@context": "https://schema.org",
|
|
19
|
+
"@type": "ItemList",
|
|
20
|
+
numberOfItems: posts.length,
|
|
21
|
+
itemListElement: posts.map((post, index) => ({
|
|
22
|
+
"@type": "ListItem",
|
|
23
|
+
position: index + 1,
|
|
24
|
+
name: post.title,
|
|
25
|
+
url: new URL(`/blog/${post.slug}/`, canonicalBase + "/").toString(),
|
|
26
|
+
})),
|
|
27
|
+
};
|
|
28
|
+
const crumbs = breadcrumbLd([
|
|
29
|
+
{ label: tenant.displayName, href: "/" },
|
|
30
|
+
{ label: "Blog", href: "/blog/" },
|
|
31
|
+
{ label: `#${label}`, href: path },
|
|
32
|
+
], canonicalBase);
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
<Layout
|
|
36
|
+
title={`#${label} — Blog`}
|
|
37
|
+
description={`Articles tagged ${label} from ${tenant.displayName}.`}
|
|
38
|
+
path={path}
|
|
39
|
+
fullBleed
|
|
40
|
+
jsonLd={[crumbs, itemList]}
|
|
41
|
+
>
|
|
42
|
+
<header class="blog-hero">
|
|
43
|
+
<div class="blog-shell">
|
|
44
|
+
<nav class="breadcrumbs" aria-label="Breadcrumb"><a href={`${basePath}/blog/`}>Blog</a><span aria-hidden="true">/</span><span>Tag</span></nav>
|
|
45
|
+
<p class="eyebrow eyebrow--tick">Tag</p>
|
|
46
|
+
<h1>#{label}</h1>
|
|
47
|
+
<p>{posts.length} {posts.length === 1 ? "article" : "articles"} with this tag.</p>
|
|
48
|
+
</div>
|
|
49
|
+
</header>
|
|
50
|
+
|
|
51
|
+
<div class="blog-shell blog-layout">
|
|
52
|
+
<PostGrid posts={posts} basePath={basePath} base="/blog" />
|
|
53
|
+
</div>
|
|
54
|
+
</Layout>
|
|
55
|
+
|
|
56
|
+
<style>
|
|
57
|
+
.blog-shell { width: min(1180px, calc(100% - 2rem)); margin-inline: auto; }
|
|
58
|
+
.blog-hero { padding: clamp(4.5rem, 9vw, 8rem) 0 clamp(3rem, 6vw, 5rem); background: linear-gradient(145deg, var(--color-primary), color-mix(in srgb, var(--color-primary) 82%, var(--color-accent))); color: var(--color-primary-contrast); }
|
|
59
|
+
.breadcrumbs { display: flex; gap: .55rem; align-items: center; margin-bottom: 1rem; color: color-mix(in srgb, var(--color-primary-contrast) 78%, transparent); font-size: .84rem; }
|
|
60
|
+
.breadcrumbs a { color: inherit; font-weight: 700; }
|
|
61
|
+
.blog-hero h1 { max-width: 14ch; margin-top: .75rem; color: inherit; font-size: clamp(2.6rem, 6vw, 4.6rem); }
|
|
62
|
+
.blog-hero p:last-child { max-width: 58ch; margin-top: 1rem; color: color-mix(in srgb, var(--color-primary-contrast) 82%, transparent); font-size: 1.05rem; }
|
|
63
|
+
.blog-hero .eyebrow { color: color-mix(in srgb, var(--color-primary-contrast) 75%, transparent); }
|
|
64
|
+
.blog-layout { padding-block: clamp(3rem, 7vw, 6rem); }
|
|
65
|
+
</style>
|
|
@@ -132,6 +132,40 @@ const storeNotSetUp =
|
|
|
132
132
|
// hasStoreAccess) for anything that mints a command or starts the activity poll.
|
|
133
133
|
const canSetupStore = hasStoreAccess && !noResolvableStore && !storeNotSetUp;
|
|
134
134
|
|
|
135
|
+
// vc-app-binding safety-net (08-18, §5): a BROWSER-FIRST developer reached this page
|
|
136
|
+
// via the storefront's own sign-in (a tot_session cookie) without ever crossing an
|
|
137
|
+
// MCP binding seam (rendezvous approve / native OAuth), so `appForCaller` doesn't
|
|
138
|
+
// resolve them and the `tot start` command we're about to hand them would 403 with
|
|
139
|
+
// "No version-control app is bound." Ensure the binding now — on-behalf of the
|
|
140
|
+
// verified session identity, via the app's private_key_jwt channel. Best-effort +
|
|
141
|
+
// fail-open (never blocks render), throttled to once per browser session so it stays
|
|
142
|
+
// off the hot render path; the authority-seam binding + backfill remain the real
|
|
143
|
+
// guarantee. Gated on canSetupStore (entitled + resolvable + provisioned), the exact
|
|
144
|
+
// condition under which we render a runnable command.
|
|
145
|
+
//
|
|
146
|
+
// The binding itself is control-plane (authors a forge/MCP binding under the app
|
|
147
|
+
// credential), so this renderer page reaches it only over the network — POST
|
|
148
|
+
// /api/dev/ensure-vc-binding — never by static import (renderer-carve moat). The
|
|
149
|
+
// endpoint re-derives the developer from the forwarded session cookie.
|
|
150
|
+
if (canSetupStore && session && !Astro.cookies.has("tot_vc_bound")) {
|
|
151
|
+
const vcBind = await fetch(new URL(`${basePath}/api/dev/ensure-vc-binding`, url.origin), {
|
|
152
|
+
method: "POST",
|
|
153
|
+
headers: { cookie: Astro.request.headers.get("cookie") ?? "" },
|
|
154
|
+
})
|
|
155
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
156
|
+
.catch(() => null);
|
|
157
|
+
if (vcBind?.ok) {
|
|
158
|
+
// Throttle only on a real success — a fail-open miss retries on the next load.
|
|
159
|
+
Astro.cookies.set("tot_vc_bound", "1", {
|
|
160
|
+
path: "/",
|
|
161
|
+
httpOnly: true,
|
|
162
|
+
sameSite: "lax",
|
|
163
|
+
secure: true,
|
|
164
|
+
maxAge: 3600, // re-check hourly so a lost/revoked binding self-heals
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
135
169
|
// Signed in, but either no developer capability on any store OR (staff bypass) no store
|
|
136
170
|
// resolves for this viewer/host — an honest recovery state (not a 401): show what to
|
|
137
171
|
// do, not the fastpath. NOTE: storeNotSetUp is deliberately NOT folded in here — it is
|
|
@@ -871,10 +905,18 @@ const hostedActivityScript = `
|
|
|
871
905
|
}
|
|
872
906
|
|
|
873
907
|
// Item 6 — once the CLI reports the developer's $EDITOR, show the EXACT command to
|
|
874
|
-
// open the first file to edit (e.g. \`code content/home.html\`)
|
|
875
|
-
//
|
|
908
|
+
// open the first file to edit (e.g. \`code content/home.html\`). Without one, guess
|
|
909
|
+
// from the browser's OS (a reasonable proxy for the local dev machine in the common
|
|
910
|
+
// case of viewing the cockpit on the same laptop) and default to VS Code (\`code\`) —
|
|
911
|
+
// the most common case — with an alternate + AI-agent note underneath.
|
|
876
912
|
var editOpenCmd = document.getElementById("editOpenCmd");
|
|
877
913
|
var editOpenHint = document.getElementById("editOpenHint");
|
|
914
|
+
function platformOpenDirCmd(dirArg) {
|
|
915
|
+
var plat = String((navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || navigator.userAgent || "");
|
|
916
|
+
if (/win/i.test(plat)) return "explorer " + dirArg;
|
|
917
|
+
if (/mac/i.test(plat)) return "open " + dirArg;
|
|
918
|
+
return "xdg-open " + dirArg;
|
|
919
|
+
}
|
|
878
920
|
function updateEditHint(rt) {
|
|
879
921
|
if (!editOpenCmd) return;
|
|
880
922
|
var editor = rt && typeof rt.editor === "string" ? rt.editor : "";
|
|
@@ -882,13 +924,18 @@ const hostedActivityScript = `
|
|
|
882
924
|
// Prefer the FULL path (works from any directory) when the CLI reports the
|
|
883
925
|
// project dir; else a project-relative path with a "run it from there" note.
|
|
884
926
|
var file = cwd ? (cwd.replace(/\\/+$/, "") + "/content/home.html") : "content/home.html";
|
|
885
|
-
editOpenCmd.textContent = (editor || "
|
|
927
|
+
editOpenCmd.textContent = (editor || "code") + " " + file;
|
|
886
928
|
if (editOpenHint) {
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
929
|
+
if (editor) {
|
|
930
|
+
editOpenHint.textContent = cwd
|
|
931
|
+
? "Run that anywhere to open the file — then change a line and save."
|
|
932
|
+
: "Run that from your project directory — then change a line and save.";
|
|
933
|
+
} else {
|
|
934
|
+
var altCmd = platformOpenDirCmd(cwd || ".");
|
|
935
|
+
editOpenHint.textContent = "Using VS Code? Run that. Prefer Cursor, another editor, or an AI coding " +
|
|
936
|
+
"agent like Claude? Same idea — open the folder (\`" + altCmd + "\`) or file above and edit there. " +
|
|
937
|
+
"Set $EDITOR and we'll show your exact command instead.";
|
|
938
|
+
}
|
|
892
939
|
}
|
|
893
940
|
}
|
|
894
941
|
|
|
@@ -1570,6 +1617,8 @@ const hostedActivityScript = `
|
|
|
1570
1617
|
|
|
1571
1618
|
/* ---- Step 2 "lay it beside your editor" tip ---- */
|
|
1572
1619
|
.tip-lay { margin: 1rem 0 0; font-size: .9rem; color: var(--muted); line-height: 1.5; }
|
|
1620
|
+
.tip-branch { margin: .5rem 0 0; font-size: .9rem; color: var(--muted); line-height: 1.5; }
|
|
1621
|
+
.tip-branch a { color: inherit; text-decoration: underline; }
|
|
1573
1622
|
|
|
1574
1623
|
/* ---- Guided accordion: a step body animates IN when opened (collapse is instant
|
|
1575
1624
|
+ synchronous so the flow can't get stuck). Shared with isLocalDevLoop —
|
|
@@ -2235,7 +2284,8 @@ const hostedActivityScript = `
|
|
|
2235
2284
|
<span class="cmd" id="editOpenCmd">$EDITOR content/home.html</span></div>
|
|
2236
2285
|
</div>
|
|
2237
2286
|
<p class="cmd-note" id="editOpenHint">Tip: set <code>$EDITOR</code> and we'll show the exact open command here.</p>
|
|
2238
|
-
<p class="tip-lay">Lay this cockpit <em>beside</em> your editor and your running store — hot reload is on, so every save reloads the store and lands in <strong>Your activity</strong> here, live.</p>
|
|
2287
|
+
<p class="tip-lay">Lay this cockpit <em>beside</em> your editor (or an AI coding agent like Claude — same idea) and your running store — hot reload is on, so every save reloads the store and lands in <strong>Your activity</strong> here, live. Keep the terminal running <code>tot start</code>/<code>tot dev</code> open too, watching for build errors — need the command line for something else? Open a new terminal instead of closing that one.</p>
|
|
2288
|
+
<p class="tip-branch">No need to think about git branches for this — just keep editing and running <code>tot preview</code>; it updates the same change every time. <a href="https://github.com/tokenoftrust/storefront/blob/feature/8500-storefront-integration/docs/kb/develop-ship-and-connect-domain.md#branching-is-an-advanced-feature--you-almost-never-need-it" target="_blank" rel="noopener">Working on two things at once? Read this first →</a></p>
|
|
2239
2289
|
</div>
|
|
2240
2290
|
</section>
|
|
2241
2291
|
</div>
|
|
@@ -2651,6 +2701,17 @@ const hostedActivityScript = `
|
|
|
2651
2701
|
var rzvTimer = null, rzvCode = null, rzvDecided = false;
|
|
2652
2702
|
function rzvShow(el) { if (el) el.hidden = false; }
|
|
2653
2703
|
function rzvHide(el) { if (el) el.hidden = true; }
|
|
2704
|
+
// Once the terminal has ATTACHED, the command has plainly been pasted and
|
|
2705
|
+
// run — hide the copy affordances so the approve prompt is the page's
|
|
2706
|
+
// single focus (DZ 2026-08-18). An EXPIRED sign-in restores the generate
|
|
2707
|
+
// button (a fresh mint is needed); the stale command's copy icon stays gone.
|
|
2708
|
+
function rzvHideCopyCtas() {
|
|
2709
|
+
if (btn) btn.hidden = true;
|
|
2710
|
+
if (copyIcon) copyIcon.hidden = true;
|
|
2711
|
+
}
|
|
2712
|
+
function rzvRestoreGenerate() {
|
|
2713
|
+
if (btn) btn.hidden = false;
|
|
2714
|
+
}
|
|
2654
2715
|
function rzvStopPoll() { if (rzvTimer) { clearTimeout(rzvTimer); rzvTimer = null; } }
|
|
2655
2716
|
function rzvFinish(msg) {
|
|
2656
2717
|
rzvStopPoll(); rzvDecided = true;
|
|
@@ -2669,13 +2730,14 @@ const hostedActivityScript = `
|
|
|
2669
2730
|
} catch (e) { rzvTimer = setTimeout(rzvPoll, 2800); return; }
|
|
2670
2731
|
if (!r || !r.ok) { rzvTimer = setTimeout(rzvPoll, 3200); return; }
|
|
2671
2732
|
if (b.state === "attached" && b.fingerprint) {
|
|
2733
|
+
rzvHideCopyCtas();
|
|
2672
2734
|
rzvHide(rzvWait); rzvShow(rzvConfirm);
|
|
2673
2735
|
if (rzvFp) { rzvFp.textContent = b.fingerprint; rzvFp.setAttribute("data-fp", b.fingerprint); }
|
|
2674
2736
|
return; // stop polling — wait for the human decision
|
|
2675
2737
|
}
|
|
2676
|
-
if (b.state === "approved") { rzvFinish("Signed in ✓ — your terminal is good to go."); return; }
|
|
2677
|
-
if (b.state === "denied") { rzvFinish("Rejected — that terminal wasn’t approved. You’re safe; generate a fresh command to try again."); return; }
|
|
2678
|
-
if (b.state === "expired") { rzvFinish("This sign-in expired. Generate a fresh setup command above."); return; }
|
|
2738
|
+
if (b.state === "approved") { rzvHideCopyCtas(); rzvFinish("Signed in ✓ — your terminal is good to go."); return; }
|
|
2739
|
+
if (b.state === "denied") { rzvHideCopyCtas(); rzvFinish("Rejected — that terminal wasn’t approved. You’re safe; generate a fresh command to try again."); return; }
|
|
2740
|
+
if (b.state === "expired") { rzvRestoreGenerate(); rzvFinish("This sign-in expired. Generate a fresh setup command above."); return; }
|
|
2679
2741
|
rzvTimer = setTimeout(rzvPoll, 2500); // pending → keep watching
|
|
2680
2742
|
}
|
|
2681
2743
|
function rzvStart(code) {
|
|
@@ -127,19 +127,23 @@ const backHref = `/dashboard/${encodeURIComponent(appDomain)}`;
|
|
|
127
127
|
<div class="card">
|
|
128
128
|
<label for="email">Developer email</label>
|
|
129
129
|
<input id="email" type="email" placeholder="dev@agency.com" autocomplete="off" />
|
|
130
|
-
{
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
130
|
+
{/* tot20's role vocabulary: developer | appAdmin | appOwner | contributor.
|
|
131
|
+
`contributor` is a strictly-weaker self-downgrade a plain member may
|
|
132
|
+
request directly (tot20 honors it for a non-staff operator — handoff
|
|
133
|
+
2026-08-22-tot20-contributor-role); appAdmin/appOwner still require ToT
|
|
134
|
+
staff (#8453) and tot20 ignores them from a plain member (pins to
|
|
135
|
+
developer), so only staff see those two options. */}
|
|
136
|
+
<label for="role">Role</label>
|
|
137
|
+
<select id="role">
|
|
138
|
+
<option value="developer" selected>Developer</option>
|
|
139
|
+
<option value="contributor">Contributor (blog & content only)</option>
|
|
140
|
+
{!isRealMember && (
|
|
141
|
+
<>
|
|
138
142
|
<option value="appAdmin">Admin</option>
|
|
139
143
|
<option value="appOwner">Owner</option>
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
144
|
+
</>
|
|
145
|
+
)}
|
|
146
|
+
</select>
|
|
143
147
|
<label for="note">Note to the developer (optional)</label>
|
|
144
148
|
<textarea id="note" rows="3" maxlength="1000"
|
|
145
149
|
placeholder="Add a personal message — they'll see it in the invite email."></textarea>
|