@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
|
@@ -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>
|
|
@@ -27,7 +27,6 @@ import { readViewerSession } from "@/lib/auth/route";
|
|
|
27
27
|
import { staticTenants } from "@/config/tenants";
|
|
28
28
|
import { resolvePreviewTenant } from "@/config/previewTenantLookup";
|
|
29
29
|
import { readTenantPreviewStatus, isStoreNotSetUp } from "@/lib/dev/previewStatus";
|
|
30
|
-
import { ensureCockpitVcBinding } from "@/lib/dev/vcBinding";
|
|
31
30
|
import { resolveDevApiBase } from "@/lib/dev/apiBase";
|
|
32
31
|
import { resolveCockpitStoreResource } from "@/lib/dev/cockpitStore";
|
|
33
32
|
import { normalizeEmailHint, redactEmailForHint } from "@/lib/privacy/emailHint";
|
|
@@ -143,9 +142,19 @@ const canSetupStore = hasStoreAccess && !noResolvableStore && !storeNotSetUp;
|
|
|
143
142
|
// off the hot render path; the authority-seam binding + backfill remain the real
|
|
144
143
|
// guarantee. Gated on canSetupStore (entitled + resolvable + provisioned), the exact
|
|
145
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.
|
|
146
150
|
if (canSetupStore && session && !Astro.cookies.has("tot_vc_bound")) {
|
|
147
|
-
const vcBind = await
|
|
148
|
-
|
|
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) {
|
|
149
158
|
// Throttle only on a real success — a fail-open miss retries on the next load.
|
|
150
159
|
Astro.cookies.set("tot_vc_bound", "1", {
|
|
151
160
|
path: "/",
|
|
@@ -896,10 +905,18 @@ const hostedActivityScript = `
|
|
|
896
905
|
}
|
|
897
906
|
|
|
898
907
|
// Item 6 — once the CLI reports the developer's $EDITOR, show the EXACT command to
|
|
899
|
-
// open the first file to edit (e.g. \`code content/home.html\`)
|
|
900
|
-
//
|
|
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.
|
|
901
912
|
var editOpenCmd = document.getElementById("editOpenCmd");
|
|
902
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
|
+
}
|
|
903
920
|
function updateEditHint(rt) {
|
|
904
921
|
if (!editOpenCmd) return;
|
|
905
922
|
var editor = rt && typeof rt.editor === "string" ? rt.editor : "";
|
|
@@ -907,13 +924,18 @@ const hostedActivityScript = `
|
|
|
907
924
|
// Prefer the FULL path (works from any directory) when the CLI reports the
|
|
908
925
|
// project dir; else a project-relative path with a "run it from there" note.
|
|
909
926
|
var file = cwd ? (cwd.replace(/\\/+$/, "") + "/content/home.html") : "content/home.html";
|
|
910
|
-
editOpenCmd.textContent = (editor || "
|
|
927
|
+
editOpenCmd.textContent = (editor || "code") + " " + file;
|
|
911
928
|
if (editOpenHint) {
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|
+
}
|
|
917
939
|
}
|
|
918
940
|
}
|
|
919
941
|
|
|
@@ -1595,6 +1617,8 @@ const hostedActivityScript = `
|
|
|
1595
1617
|
|
|
1596
1618
|
/* ---- Step 2 "lay it beside your editor" tip ---- */
|
|
1597
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; }
|
|
1598
1622
|
|
|
1599
1623
|
/* ---- Guided accordion: a step body animates IN when opened (collapse is instant
|
|
1600
1624
|
+ synchronous so the flow can't get stuck). Shared with isLocalDevLoop —
|
|
@@ -2260,7 +2284,8 @@ const hostedActivityScript = `
|
|
|
2260
2284
|
<span class="cmd" id="editOpenCmd">$EDITOR content/home.html</span></div>
|
|
2261
2285
|
</div>
|
|
2262
2286
|
<p class="cmd-note" id="editOpenHint">Tip: set <code>$EDITOR</code> and we'll show the exact open command here.</p>
|
|
2263
|
-
<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>
|
|
2264
2289
|
</div>
|
|
2265
2290
|
</section>
|
|
2266
2291
|
</div>
|
|
@@ -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>
|
|
@@ -28,7 +28,7 @@ const isJson = url.searchParams.get("format") === "json";
|
|
|
28
28
|
// Search goes through the ToTClient: D1 FTS5 when a D1 binding is wired, else a
|
|
29
29
|
// Fuse index over the bundled snapshot (FixtureToTClient). It returns full
|
|
30
30
|
// products, so the HTML grid renders directly and the typeahead just projects to
|
|
31
|
-
// the compact shape.
|
|
31
|
+
// the compact shape.
|
|
32
32
|
|
|
33
33
|
// --- JSON path: powers the header typeahead island. Must return BEFORE render.
|
|
34
34
|
if (isJson) {
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
import type { APIContext } from "astro";
|
|
12
12
|
import { nonEmptyCollections } from "@/lib/tot/query.js";
|
|
13
13
|
import { listBlogPosts } from "@/lib/blog/provider.js";
|
|
14
|
+
import { authorSlug } from "@/lib/blog/collection.js";
|
|
15
|
+
import { listBuildPosts } from "@/lib/the-build/provider.js";
|
|
14
16
|
|
|
15
17
|
interface SitemapUrl {
|
|
16
18
|
loc: string;
|
|
@@ -71,6 +73,24 @@ export async function GET(context: APIContext): Promise<Response> {
|
|
|
71
73
|
for (const post of blogPosts) {
|
|
72
74
|
urls.push({ loc: `${base}/blog/${post.slug}/`, lastmod: post.modifiedAt || post.publishedAt });
|
|
73
75
|
}
|
|
76
|
+
// Author archives (blog/author/[author].astro): one per distinct byline,
|
|
77
|
+
// same source as the pages themselves so the sitemap never lists an
|
|
78
|
+
// archive that would 404 (and vice versa — the static publish's T3 gate
|
|
79
|
+
// requires every rendered route to appear here).
|
|
80
|
+
const authorSlugs = [
|
|
81
|
+
...new Set(blogPosts.map((post) => authorSlug(post.author)).filter(Boolean)),
|
|
82
|
+
].sort();
|
|
83
|
+
for (const slug of authorSlugs) {
|
|
84
|
+
urls.push({ loc: `${base}/blog/author/${slug}/` });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const buildPosts = await listBuildPosts(tenant.tenant_id);
|
|
89
|
+
if (buildPosts.length > 0) {
|
|
90
|
+
urls.push({ loc: `${base}/the-build/` });
|
|
91
|
+
for (const post of buildPosts) {
|
|
92
|
+
urls.push({ loc: `${base}/the-build/${post.slug}/`, lastmod: post.modifiedAt || post.publishedAt });
|
|
93
|
+
}
|
|
74
94
|
}
|
|
75
95
|
|
|
76
96
|
const body = `<?xml version="1.0" encoding="UTF-8"?>
|