blume 0.8.0 → 1.0.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/CHANGELOG.md +49 -1
- package/dist/cli/index.js +373 -28
- package/dist/cli/index.js.map +51 -50
- package/dist/types/core/config-input.d.ts +11 -0
- package/dist/types/core/data.d.ts +5 -0
- package/dist/types/core/i18n-ui.d.ts +50 -0
- package/dist/types/core/schema.d.ts +77 -1
- package/dist/types/seo/x-handle.d.ts +12 -0
- package/docs/advanced/api-reference.mdx +1 -1
- package/docs/advanced/changelog.mdx +1 -1
- package/docs/configuration/export.mdx +1 -1
- package/docs/configuration/seo.mdx +33 -3
- package/docs/content/components.mdx +1 -1
- package/docs/content/syntax.mdx +2 -2
- package/docs/index.mdx +1 -1
- package/docs/reference/cli.mdx +1 -1
- package/docs/reference/frontmatter.mdx +1 -1
- package/package.json +9 -1
- package/src/ai/mcp/tools.ts +1 -1
- package/src/astro/generate.ts +1 -0
- package/src/astro/templates.ts +51 -7
- package/src/cli/commands/build.ts +25 -8
- package/src/cli/internal-error.ts +9 -4
- package/src/cli/prepare.ts +3 -2
- package/src/components/Icon.astro +2 -1
- package/src/components/islands/ask-ai.tsx +30 -5
- package/src/components/layout/Breadcrumbs.astro +11 -2
- package/src/components/layout/NavTree.astro +2 -2
- package/src/components/layout/PageLayout.astro +40 -1
- package/src/components/layout/Pagination.astro +10 -4
- package/src/components/layout/ReferenceLayout.astro +12 -1
- package/src/components/layout/RootLayout.astro +75 -3
- package/src/components/layout/Search.astro +23 -5
- package/src/core/config-input.ts +11 -0
- package/src/core/data.ts +2 -0
- package/src/core/frontmatter.ts +7 -0
- package/src/core/i18n-ui.ts +9 -0
- package/src/core/i18n.ts +16 -8
- package/src/core/schema.ts +35 -0
- package/src/core/ui-packs/ar.ts +7 -0
- package/src/core/ui-packs/bg.ts +7 -0
- package/src/core/ui-packs/bn.ts +7 -0
- package/src/core/ui-packs/ca.ts +7 -0
- package/src/core/ui-packs/cs.ts +7 -0
- package/src/core/ui-packs/da.ts +7 -0
- package/src/core/ui-packs/de.ts +7 -0
- package/src/core/ui-packs/el.ts +7 -0
- package/src/core/ui-packs/es.ts +7 -0
- package/src/core/ui-packs/fa.ts +7 -0
- package/src/core/ui-packs/fi.ts +7 -0
- package/src/core/ui-packs/fr.ts +7 -0
- package/src/core/ui-packs/he.ts +7 -0
- package/src/core/ui-packs/hi.ts +7 -0
- package/src/core/ui-packs/hr.ts +7 -0
- package/src/core/ui-packs/hu.ts +7 -0
- package/src/core/ui-packs/id.ts +7 -0
- package/src/core/ui-packs/it.ts +7 -0
- package/src/core/ui-packs/ja.ts +7 -0
- package/src/core/ui-packs/ko.ts +7 -0
- package/src/core/ui-packs/nl.ts +7 -0
- package/src/core/ui-packs/no.ts +7 -0
- package/src/core/ui-packs/pl.ts +7 -0
- package/src/core/ui-packs/pt-br.ts +7 -0
- package/src/core/ui-packs/pt.ts +7 -0
- package/src/core/ui-packs/ro.ts +7 -0
- package/src/core/ui-packs/ru.ts +7 -0
- package/src/core/ui-packs/sk.ts +7 -0
- package/src/core/ui-packs/sr.ts +7 -0
- package/src/core/ui-packs/sv.ts +7 -0
- package/src/core/ui-packs/th.ts +7 -0
- package/src/core/ui-packs/tr.ts +7 -0
- package/src/core/ui-packs/uk.ts +7 -0
- package/src/core/ui-packs/vi.ts +7 -0
- package/src/core/ui-packs/zh-tw.ts +7 -0
- package/src/core/ui-packs/zh.ts +7 -0
- package/src/og/card.ts +4 -2
- package/src/og/dimensions.ts +12 -0
- package/src/openapi/render-mdx.ts +62 -1
- package/src/seo/jsonld.ts +7 -1
- package/src/seo/x-handle.ts +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blume",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Documentation that's fast, AI-ready, and zero-config.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astro",
|
|
@@ -128,6 +128,8 @@
|
|
|
128
128
|
"@ai-sdk/openai-compatible": "^1.0.41",
|
|
129
129
|
"@astrojs/cloudflare": "^14.0.0",
|
|
130
130
|
"@astrojs/netlify": "^8.0.0",
|
|
131
|
+
"@astrojs/svelte": "^9.0.0",
|
|
132
|
+
"@astrojs/vue": "^7.0.0",
|
|
131
133
|
"@mixedbread/sdk": "^0.76.0",
|
|
132
134
|
"@notionhq/client": "^2.2.15",
|
|
133
135
|
"@openrouter/ai-sdk-provider": "^1.5.4",
|
|
@@ -147,6 +149,12 @@
|
|
|
147
149
|
"@astrojs/netlify": {
|
|
148
150
|
"optional": true
|
|
149
151
|
},
|
|
152
|
+
"@astrojs/svelte": {
|
|
153
|
+
"optional": true
|
|
154
|
+
},
|
|
155
|
+
"@astrojs/vue": {
|
|
156
|
+
"optional": true
|
|
157
|
+
},
|
|
150
158
|
"@mixedbread/sdk": {
|
|
151
159
|
"optional": true
|
|
152
160
|
},
|
package/src/ai/mcp/tools.ts
CHANGED
|
@@ -26,7 +26,7 @@ export const MCP_TOOLS: McpToolMeta[] = [
|
|
|
26
26
|
{
|
|
27
27
|
annotations: READ_ONLY,
|
|
28
28
|
description:
|
|
29
|
-
"Fetch a single documentation page as
|
|
29
|
+
"Fetch a single documentation page as agent-optimized Markdown (frontmatter included, components downleveled to plain Markdown). Pass a route from `search_docs` or `list_pages`, e.g. `/guides/install`.",
|
|
30
30
|
name: "get_page",
|
|
31
31
|
title: "Get page Markdown",
|
|
32
32
|
},
|
package/src/astro/generate.ts
CHANGED
package/src/astro/templates.ts
CHANGED
|
@@ -187,18 +187,31 @@ const renderUserAliases = (
|
|
|
187
187
|
const astroOutDir = (context: ProjectContext): string =>
|
|
188
188
|
context.distDir ?? `${context.root}/dist`;
|
|
189
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Excludes Vite's pre-bundled dep cache from @vitejs/plugin-react. Astro's
|
|
192
|
+
* react() replaces the plugin's default `/node_modules/` exclude with just
|
|
193
|
+
* `/\.astro$/`, so without this Babel re-parses every optimized dep chunk
|
|
194
|
+
* served from `.vite/deps` — a 500KB+ vendor bundle per chunk, re-done on each
|
|
195
|
+
* re-optimization. A blanket `/node_modules/` exclude would instead switch the
|
|
196
|
+
* React Compiler off for Blume's own components in published installs (they
|
|
197
|
+
* resolve under `node_modules/blume/src`, and exclude beats include in the
|
|
198
|
+
* plugin's filter), so only the pre-bundle cache is excluded.
|
|
199
|
+
*/
|
|
200
|
+
const REACT_EXCLUDE = String.raw`exclude: [/\/node_modules\/\.vite\//]`;
|
|
201
|
+
|
|
190
202
|
/**
|
|
191
203
|
* The `react()` integration call. When `compilerPath` is set (the resolved
|
|
192
204
|
* absolute path to `babel-plugin-react-compiler`), react() carries the compiler
|
|
193
205
|
* as the first babel plugin — an absolute path, because @vitejs/plugin-react
|
|
194
206
|
* resolves babel plugins from the *project* root, not `.blume/`, so a bare
|
|
195
207
|
* specifier wouldn't resolve in a user project. `target: "19"` matches Blume's
|
|
196
|
-
* React pin. `null`/`undefined` (compiler off or unresolvable)
|
|
208
|
+
* React pin. `null`/`undefined` (compiler off or unresolvable) omits the babel
|
|
209
|
+
* block. Both variants carry the pre-bundle exclude above.
|
|
197
210
|
*/
|
|
198
211
|
const reactIntegration = (compilerPath: string | null | undefined): string =>
|
|
199
212
|
compilerPath
|
|
200
|
-
? `react({ babel: { plugins: [[${JSON.stringify(compilerPath)}, { target: "19" }]] } })`
|
|
201
|
-
:
|
|
213
|
+
? `react({ babel: { plugins: [[${JSON.stringify(compilerPath)}, { target: "19" }]] }, ${REACT_EXCLUDE} })`
|
|
214
|
+
: `react({ ${REACT_EXCLUDE} })`;
|
|
202
215
|
|
|
203
216
|
export const astroConfigTemplate = (options: {
|
|
204
217
|
context: ProjectContext;
|
|
@@ -1023,12 +1036,24 @@ import data from ${JSON.stringify(options.dataImport)};
|
|
|
1023
1036
|
export const prerender = true;
|
|
1024
1037
|
|
|
1025
1038
|
const configuration = ${JSON.stringify(options.configuration, null, 2)};
|
|
1039
|
+
|
|
1040
|
+
// The reference is an unlocalized route, so its chrome renders in the default
|
|
1041
|
+
// locale's language and direction (\`data.ui\` is the default locale's resolved
|
|
1042
|
+
// dictionary), mirroring the changelog index's locale wiring.
|
|
1043
|
+
const i18n = data.config.i18n;
|
|
1044
|
+
const localeMeta = i18n
|
|
1045
|
+
? i18n.locales.find((l) => l.code === i18n.defaultLocale)
|
|
1046
|
+
: null;
|
|
1047
|
+
const dir = localeMeta?.dir ?? "ltr";
|
|
1048
|
+
const htmlLang = i18n ? i18n.defaultLocale : "en";
|
|
1026
1049
|
---
|
|
1027
1050
|
|
|
1028
1051
|
<ReferenceLayout
|
|
1029
1052
|
analytics={data.config.analytics}
|
|
1030
1053
|
banner={data.config.banner}
|
|
1054
|
+
dir={dir}
|
|
1031
1055
|
fontCssVars={data.fontCssVars}
|
|
1056
|
+
locale={htmlLang}
|
|
1032
1057
|
logo={data.config.logo}
|
|
1033
1058
|
favicon={data.config.favicon}
|
|
1034
1059
|
appleIcon={data.config.appleIcon}
|
|
@@ -1202,6 +1227,13 @@ const ogRel = seo.image ?? ogPath;
|
|
|
1202
1227
|
// an external URL, which passes through verbatim (mirrors PageLayout).
|
|
1203
1228
|
const ogImage =
|
|
1204
1229
|
ogRel && base && ogRel.startsWith("/") ? \`\${base}\${withBase(ogRel)}\` : ogRel;
|
|
1230
|
+
// Blume's generated card has known dimensions the layout can declare; a user's
|
|
1231
|
+
// \`seo.image\` could be any size or format, so it gets none.
|
|
1232
|
+
const ogGenerated = !seo.image && Boolean(ogPath);
|
|
1233
|
+
|
|
1234
|
+
// X attribution: the site's account, plus a creator the page can claim for
|
|
1235
|
+
// itself (a guest post crediting its own author) over the configured default.
|
|
1236
|
+
const x = { ...data.config.x, ...(seo.x?.creator ? { creator: seo.x.creator } : {}) };
|
|
1205
1237
|
|
|
1206
1238
|
const basedRoute = withBase(route);
|
|
1207
1239
|
const canonical =
|
|
@@ -1298,6 +1330,8 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
|
1298
1330
|
searchEnabled={data.config.search.enabled}
|
|
1299
1331
|
indexable={indexable}
|
|
1300
1332
|
ogImage={ogImage}
|
|
1333
|
+
ogGenerated={ogGenerated}
|
|
1334
|
+
x={x}
|
|
1301
1335
|
canonical={canonical}
|
|
1302
1336
|
editUrl={editUrl}
|
|
1303
1337
|
feedback={data.config.feedback}
|
|
@@ -1341,7 +1375,7 @@ export const changelogIndexTemplate = (options: {
|
|
|
1341
1375
|
? '\n <AskAI slot="ask" strings={data.ui.ask} suggestions={data.config.ask?.suggestions ?? []} />'
|
|
1342
1376
|
: "";
|
|
1343
1377
|
const clientData = options.needsReact
|
|
1344
|
-
? '\n clientData={{ config: data.config, navigation: data.navigation, page: { route: "/changelog", title:
|
|
1378
|
+
? '\n clientData={{ config: data.config, navigation: data.navigation, page: { route: "/changelog", title: pageTitle } }}'
|
|
1345
1379
|
: "";
|
|
1346
1380
|
// Staged sources (e.g. GitHub Releases) render through a parallel collection,
|
|
1347
1381
|
// so fold them in alongside filesystem entries when one exists.
|
|
@@ -1484,6 +1518,15 @@ const localeMeta = i18n
|
|
|
1484
1518
|
const dir = localeMeta?.dir ?? "ltr";
|
|
1485
1519
|
const htmlLang = i18n ? i18n.defaultLocale : "en";
|
|
1486
1520
|
|
|
1521
|
+
// The page chrome (h1, title, description) comes from the same translatable
|
|
1522
|
+
// \`changelog\` group as the reveal button; optional chaining tolerates a
|
|
1523
|
+
// not-yet-regenerated data snapshot from before these keys existed.
|
|
1524
|
+
const changelogTitle = data.ui.changelog?.title ?? "Changelog";
|
|
1525
|
+
const changelogDescription =
|
|
1526
|
+
data.ui.changelog?.description ??
|
|
1527
|
+
"Product updates, new features, and fixes from every release.";
|
|
1528
|
+
const pageTitle = data.config.title + " " + changelogTitle;
|
|
1529
|
+
|
|
1487
1530
|
const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
1488
1531
|
---
|
|
1489
1532
|
|
|
@@ -1503,8 +1546,8 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
|
1503
1546
|
dir={dir}
|
|
1504
1547
|
ui={data.ui}
|
|
1505
1548
|
page={{
|
|
1506
|
-
title:
|
|
1507
|
-
description:
|
|
1549
|
+
title: pageTitle,
|
|
1550
|
+
description: changelogDescription,
|
|
1508
1551
|
route: "/changelog",
|
|
1509
1552
|
}}
|
|
1510
1553
|
headings={headings}
|
|
@@ -1515,6 +1558,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
|
1515
1558
|
searchEnabled={data.config.search.enabled}
|
|
1516
1559
|
indexable={true}
|
|
1517
1560
|
ogImage={null}
|
|
1561
|
+
x={data.config.x}
|
|
1518
1562
|
canonical={canonical}
|
|
1519
1563
|
askEnabled={${options.askEnabled}}
|
|
1520
1564
|
exportPdf={${options.exportPdf}}
|
|
@@ -1524,7 +1568,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
|
1524
1568
|
noindex={false}
|
|
1525
1569
|
structuredDataEnabled={data.config.structuredData}
|
|
1526
1570
|
>${askSlot}
|
|
1527
|
-
<h1>
|
|
1571
|
+
<h1>{changelogTitle}</h1>
|
|
1528
1572
|
{
|
|
1529
1573
|
items.length === 0 ? (
|
|
1530
1574
|
<p>No changelog entries yet.</p>
|
|
@@ -64,7 +64,10 @@ const validateBudgetFlags = (args: BudgetArgs): void => {
|
|
|
64
64
|
/**
|
|
65
65
|
* Emit platform redirect files for a static build (adapters wire redirects
|
|
66
66
|
* natively). Always writes the manifest; writes `_redirects`/`vercel.json` only
|
|
67
|
-
* when the user hasn't shipped one via public/.
|
|
67
|
+
* when the user hasn't shipped one via public/. Note that Vercel's
|
|
68
|
+
* git-integration builds read `vercel.json` from the repository root only —
|
|
69
|
+
* the copy emitted here takes effect when the dist folder itself is deployed
|
|
70
|
+
* directly via the Vercel CLI.
|
|
68
71
|
*/
|
|
69
72
|
const emitRedirectFiles = async (
|
|
70
73
|
config: ResolvedConfig,
|
|
@@ -209,6 +212,22 @@ export const runClientAssetChecks = async (
|
|
|
209
212
|
}
|
|
210
213
|
};
|
|
211
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Root of an isolated build's output. The runtime-local `dist/`, except for a
|
|
217
|
+
* Vercel server build, whose deploy bundle lands at `<runtime>/.vercel/output`
|
|
218
|
+
* and is never surfaced to the project root.
|
|
219
|
+
*/
|
|
220
|
+
export const isolatedOutputDir = (
|
|
221
|
+
config: ResolvedConfig,
|
|
222
|
+
context: ProjectContext
|
|
223
|
+
): string => {
|
|
224
|
+
const { adapter, output } = config.deployment;
|
|
225
|
+
if (output === "server" && adapter === "vercel") {
|
|
226
|
+
return join(context.outDir, ".vercel", "output");
|
|
227
|
+
}
|
|
228
|
+
return context.distDir ?? join(context.outDir, "dist");
|
|
229
|
+
};
|
|
230
|
+
|
|
212
231
|
/**
|
|
213
232
|
* Directory holding an isolated build's client `_astro/` assets. Mirrors
|
|
214
233
|
* `deployStaticDir`, except that an isolated build never surfaces the adapter
|
|
@@ -221,14 +240,14 @@ export const isolatedStaticDir = (
|
|
|
221
240
|
context: ProjectContext
|
|
222
241
|
): string => {
|
|
223
242
|
const { adapter, output } = config.deployment;
|
|
243
|
+
const outputDir = isolatedOutputDir(config, context);
|
|
224
244
|
if (output === "server" && adapter === "vercel") {
|
|
225
|
-
return join(
|
|
245
|
+
return join(outputDir, "static");
|
|
226
246
|
}
|
|
227
|
-
const dist = context.distDir ?? join(context.outDir, "dist");
|
|
228
247
|
if (output === "server" && adapter === "node") {
|
|
229
|
-
return join(
|
|
248
|
+
return join(outputDir, "client");
|
|
230
249
|
}
|
|
231
|
-
return
|
|
250
|
+
return outputDir;
|
|
232
251
|
};
|
|
233
252
|
|
|
234
253
|
/**
|
|
@@ -442,8 +461,6 @@ export const buildCommand = defineCommand({
|
|
|
442
461
|
root: project.context.outDir,
|
|
443
462
|
});
|
|
444
463
|
|
|
445
|
-
const distDir = project.context.distDir ?? join(root, "dist");
|
|
446
|
-
|
|
447
464
|
// An isolated build is a throwaway verify: it only needs to confirm the site
|
|
448
465
|
// compiles and renders. Skip the network post-steps (search sync) and
|
|
449
466
|
// deploy artifacts (index/llms/sitemap/robots/redirects) that only matter
|
|
@@ -457,7 +474,7 @@ export const buildCommand = defineCommand({
|
|
|
457
474
|
args
|
|
458
475
|
);
|
|
459
476
|
logger.success(
|
|
460
|
-
`Isolated build OK — output at ${
|
|
477
|
+
`Isolated build OK — output at ${isolatedOutputDir(project.config, project.context)} (not published).`
|
|
461
478
|
);
|
|
462
479
|
return;
|
|
463
480
|
}
|
|
@@ -8,9 +8,14 @@ const RESET = `${ESC}[0m`;
|
|
|
8
8
|
|
|
9
9
|
const ISSUES_URL = "https://github.com/haydenbleasel/blume/issues";
|
|
10
10
|
|
|
11
|
-
// Absolute paths into the hidden generated runtime (`…/.blume/…`)
|
|
12
|
-
// trailing `:line:col`,
|
|
13
|
-
|
|
11
|
+
// Absolute paths into the hidden generated runtime — POSIX (`…/.blume/…`) or
|
|
12
|
+
// Windows drive-letter (`C:\…\.blume\…`) — including any trailing `:line:col`,
|
|
13
|
+
// stopping at whitespace or a closing paren.
|
|
14
|
+
const BLUME_FRAME =
|
|
15
|
+
/(?<abs>(?:\/[^\s()]*\/|[A-Za-z]:\\[^\s()]*\\)\.blume[/\\][^\s()]*)/gu;
|
|
16
|
+
|
|
17
|
+
// The separator immediately before `.blume/` (or `.blume\`) in a matched path.
|
|
18
|
+
const BLUME_MARKER = /[/\\]\.blume[/\\]/u;
|
|
14
19
|
|
|
15
20
|
/**
|
|
16
21
|
* Rewrite `.blume/` frames in a stack so the generated runtime reads clearly:
|
|
@@ -22,7 +27,7 @@ const BLUME_FRAME = /(?<abs>\/[^\s()]*\/\.blume\/[^\s()]*)/gu;
|
|
|
22
27
|
*/
|
|
23
28
|
export const remapBlumeStack = (stack: string): string =>
|
|
24
29
|
stack.replaceAll(BLUME_FRAME, (match) => {
|
|
25
|
-
const marker = match.
|
|
30
|
+
const marker = match.search(BLUME_MARKER);
|
|
26
31
|
return `${match.slice(marker + 1)} (generated)`;
|
|
27
32
|
});
|
|
28
33
|
|
package/src/cli/prepare.ts
CHANGED
|
@@ -35,8 +35,9 @@ export interface PrepareOptions {
|
|
|
35
35
|
export const prepareProject = async (
|
|
36
36
|
options: PrepareOptions
|
|
37
37
|
): Promise<BlumeProject> => {
|
|
38
|
-
//
|
|
39
|
-
//
|
|
38
|
+
// Load `.env` files from the project root before the scan: remote sources
|
|
39
|
+
// read their tokens from `process.env` during `scanProject`, and
|
|
40
|
+
// `loadEnvFiles` never overrides variables that are already set.
|
|
40
41
|
loadEnvFiles(options.root);
|
|
41
42
|
|
|
42
43
|
let project: BlumeProject;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { resolveIcon } from "../theme/icons.ts";
|
|
3
|
+
import { withBase } from "./islands/base-path.ts";
|
|
3
4
|
|
|
4
5
|
interface Props {
|
|
5
6
|
name?: unknown;
|
|
@@ -69,7 +70,7 @@ const style = color ? `color:${color}` : undefined;
|
|
|
69
70
|
aria-hidden={label ? undefined : "true"}
|
|
70
71
|
class={resolvedClass}
|
|
71
72
|
height={size}
|
|
72
|
-
src={imageSrc}
|
|
73
|
+
src={withBase(imageSrc)}
|
|
73
74
|
width={size}
|
|
74
75
|
/>
|
|
75
76
|
) : (
|
|
@@ -154,6 +154,13 @@ const AskAI = ({
|
|
|
154
154
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
155
155
|
// Where focus came from when the panel opened, restored on close.
|
|
156
156
|
const returnFocusRef = useRef<HTMLElement | null>(null);
|
|
157
|
+
// The stream writes into the conversation via functional updates, so "Clear
|
|
158
|
+
// conversation" mid-answer must revoke the in-flight stream's right to write
|
|
159
|
+
// — otherwise its next chunk re-appends the assistant bubble onto the
|
|
160
|
+
// emptied list as an orphaned answer. Clearing bumps the generation (stale
|
|
161
|
+
// streams stop writing) and aborts the request (the stream stops arriving).
|
|
162
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
163
|
+
const generationRef = useRef(0);
|
|
157
164
|
|
|
158
165
|
// Portal target (document.body) only exists after mount; guards SSR. The
|
|
159
166
|
// one-time false→true flip is deliberate, so the initial `false` is required.
|
|
@@ -239,6 +246,9 @@ const AskAI = ({
|
|
|
239
246
|
setMessages([...history, assistant]);
|
|
240
247
|
setInput("");
|
|
241
248
|
setBusy(true);
|
|
249
|
+
const generation = generationRef.current;
|
|
250
|
+
const controller = new AbortController();
|
|
251
|
+
abortRef.current = controller;
|
|
242
252
|
|
|
243
253
|
try {
|
|
244
254
|
const response = await fetch(ASK_ENDPOINT, {
|
|
@@ -248,6 +258,7 @@ const AskAI = ({
|
|
|
248
258
|
}),
|
|
249
259
|
headers: { "content-type": "application/json" },
|
|
250
260
|
method: "POST",
|
|
261
|
+
signal: controller.signal,
|
|
251
262
|
});
|
|
252
263
|
// A 4xx/5xx still has a body; without this guard its error text would be
|
|
253
264
|
// decoded and shown as the assistant's answer instead of the error notice.
|
|
@@ -266,18 +277,32 @@ const AskAI = ({
|
|
|
266
277
|
// must not flush as U+FFFD garbage.
|
|
267
278
|
// oxlint-disable-next-line react/react-compiler -- local streaming accumulator, spread into state below
|
|
268
279
|
assistant.content += decoder.decode(chunk.value, { stream: true });
|
|
269
|
-
|
|
280
|
+
if (generationRef.current === generation) {
|
|
281
|
+
setMessages((current) => [
|
|
282
|
+
...current.slice(0, -1),
|
|
283
|
+
{ ...assistant },
|
|
284
|
+
]);
|
|
285
|
+
}
|
|
270
286
|
}
|
|
271
287
|
}
|
|
272
288
|
} catch {
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
289
|
+
// A cleared (aborted) stream must not resurrect its bubble as an error.
|
|
290
|
+
if (generationRef.current === generation) {
|
|
291
|
+
// oxlint-disable-next-line react/react-compiler -- local streaming accumulator, spread into state below
|
|
292
|
+
assistant.content = t.error;
|
|
293
|
+
setMessages((current) => [...current.slice(0, -1), { ...assistant }]);
|
|
294
|
+
}
|
|
276
295
|
} finally {
|
|
277
296
|
setBusy(false);
|
|
278
297
|
}
|
|
279
298
|
};
|
|
280
299
|
|
|
300
|
+
const clearConversation = () => {
|
|
301
|
+
generationRef.current += 1;
|
|
302
|
+
abortRef.current?.abort();
|
|
303
|
+
setMessages([]);
|
|
304
|
+
};
|
|
305
|
+
|
|
281
306
|
const onSubmit = (event: FormEvent) => {
|
|
282
307
|
event.preventDefault();
|
|
283
308
|
void runQuestion(input);
|
|
@@ -332,7 +357,7 @@ const AskAI = ({
|
|
|
332
357
|
aria-label={t.clear}
|
|
333
358
|
className={ICON_BUTTON_CLASS}
|
|
334
359
|
disabled={!hasMessages}
|
|
335
|
-
onClick={
|
|
360
|
+
onClick={clearConversation}
|
|
336
361
|
type="button"
|
|
337
362
|
>
|
|
338
363
|
<Glyph path={icons.clear} />
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { EN_UI } from "../../core/i18n-ui.ts";
|
|
3
|
+
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
2
4
|
import { withBase } from "../islands/base-path.ts";
|
|
3
5
|
import type { Crumb } from "./nav-utils.ts";
|
|
4
6
|
|
|
5
7
|
interface Props {
|
|
6
8
|
/** Full breadcrumb trail from the site root to the current page. */
|
|
7
9
|
crumbs: Crumb[];
|
|
10
|
+
/** Localized nav labels (the landmark's `aria-label`); English when omitted. */
|
|
11
|
+
strings?: UIStrings["nav"];
|
|
8
12
|
/** Left-align full width (for the wide API layout) instead of the prose measure. */
|
|
9
13
|
wide?: boolean;
|
|
10
14
|
}
|
|
11
15
|
|
|
12
|
-
const { crumbs, wide = false } = Astro.props;
|
|
16
|
+
const { crumbs, strings, wide = false } = Astro.props;
|
|
17
|
+
|
|
18
|
+
// Merge over the English defaults so a label missing from a translation (or
|
|
19
|
+
// from a not-yet-regenerated snapshot) still renders instead of coming out
|
|
20
|
+
// blank — the PageActions pattern.
|
|
21
|
+
const n = { ...EN_UI.nav, ...strings };
|
|
13
22
|
|
|
14
23
|
// The built-in shows a single "eyebrow" crumb — the parent group — rather than
|
|
15
24
|
// the whole trail. An override receives the complete list and can render more.
|
|
@@ -19,7 +28,7 @@ const eyebrowCrumb = crumbs.length > 1 ? crumbs[crumbs.length - 2] : null;
|
|
|
19
28
|
{
|
|
20
29
|
eyebrowCrumb && (
|
|
21
30
|
<nav
|
|
22
|
-
aria-label=
|
|
31
|
+
aria-label={n.breadcrumb}
|
|
23
32
|
class:list={[
|
|
24
33
|
"mb-2 text-muted-foreground text-sm",
|
|
25
34
|
wide ? "max-w-none" : "mx-auto max-w-[42rem]",
|
|
@@ -136,7 +136,7 @@ const initialId =
|
|
|
136
136
|
data-nav-back={panel.parentId}
|
|
137
137
|
type="button"
|
|
138
138
|
>
|
|
139
|
-
<Icon name="arrow-left" size={16} />
|
|
139
|
+
<Icon class="rtl:-scale-x-100" name="arrow-left" size={16} />
|
|
140
140
|
</button>
|
|
141
141
|
{panel.route ? (
|
|
142
142
|
<a
|
|
@@ -219,7 +219,7 @@ const initialId =
|
|
|
219
219
|
<span class="flex-1 truncate">{item.label}</span>
|
|
220
220
|
{item.badge && <span class={badgeClassFor(item.badge)}>{item.badge}</span>}
|
|
221
221
|
<Icon
|
|
222
|
-
class="shrink-0 text-muted-foreground"
|
|
222
|
+
class="shrink-0 text-muted-foreground rtl:-scale-x-100"
|
|
223
223
|
name="chevron-right"
|
|
224
224
|
size={14}
|
|
225
225
|
/>
|
|
@@ -25,6 +25,12 @@ import type {
|
|
|
25
25
|
import { EN_UI } from "../../core/i18n-ui.ts";
|
|
26
26
|
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
27
27
|
import type { LocaleSwitchOption, Navigation } from "../../core/types.ts";
|
|
28
|
+
import {
|
|
29
|
+
OG_IMAGE_HEIGHT,
|
|
30
|
+
OG_IMAGE_TYPE,
|
|
31
|
+
OG_IMAGE_WIDTH,
|
|
32
|
+
} from "../../og/dimensions.ts";
|
|
33
|
+
import { normalizeXHandle } from "../../seo/x-handle.ts";
|
|
28
34
|
import { withBase } from "../islands/base-path.ts";
|
|
29
35
|
import "blume:theme";
|
|
30
36
|
import Analytics from "./Analytics.astro";
|
|
@@ -64,6 +70,11 @@ interface Props {
|
|
|
64
70
|
/** SEO overrides; a marketing page often sets its own canonical/og image. */
|
|
65
71
|
ogImage?: string | null;
|
|
66
72
|
canonical?: string | null;
|
|
73
|
+
/**
|
|
74
|
+
* X (Twitter) attribution (`data.config.x`): the site's account and an author
|
|
75
|
+
* handle, emitted as `twitter:site`/`twitter:creator`.
|
|
76
|
+
*/
|
|
77
|
+
x?: { creator?: string; handle?: string };
|
|
67
78
|
noindex?: boolean;
|
|
68
79
|
/** Active locale + direction for `<html lang>`/`<html dir>`. */
|
|
69
80
|
locale?: string;
|
|
@@ -97,6 +108,7 @@ const {
|
|
|
97
108
|
ogEnabled,
|
|
98
109
|
ogImage,
|
|
99
110
|
canonical,
|
|
111
|
+
x,
|
|
100
112
|
noindex,
|
|
101
113
|
locale = "en",
|
|
102
114
|
dir = "ltr",
|
|
@@ -147,6 +159,15 @@ const resolvedOgImage = ogImage
|
|
|
147
159
|
: ogEnabled && siteBase
|
|
148
160
|
? `${siteBase}${withBase(`/og/${ogSlug}.png`)}`
|
|
149
161
|
: null;
|
|
162
|
+
// Only the generated card has a known size and format, so its dimensions are
|
|
163
|
+
// declared for crawlers; a user-supplied `ogImage` could be any image.
|
|
164
|
+
const ogGenerated = !ogImage && Boolean(resolvedOgImage);
|
|
165
|
+
// A page with no image still gets a card on X — the compact `summary` variant
|
|
166
|
+
// — rather than rendering as a bare link.
|
|
167
|
+
const twitterCard = resolvedOgImage ? "summary_large_image" : "summary";
|
|
168
|
+
// A custom page passes these in by hand, so normalize rather than trust the `@`.
|
|
169
|
+
const xSite = normalizeXHandle(x?.handle);
|
|
170
|
+
const xCreator = normalizeXHandle(x?.creator);
|
|
150
171
|
|
|
151
172
|
const initialThemeScript = themeInitScript(themeMode);
|
|
152
173
|
const bannerScript = banner?.dismissible
|
|
@@ -166,17 +187,35 @@ const bannerScript = banner?.dismissible
|
|
|
166
187
|
{noindex && <meta content="noindex" name="robots" />}
|
|
167
188
|
{description && <meta content={description} name="description" />}
|
|
168
189
|
<meta content="website" property="og:type" />
|
|
190
|
+
<meta content={site.title} property="og:site_name" />
|
|
169
191
|
<meta content={pageTitle} property="og:title" />
|
|
170
192
|
{description && <meta content={description} property="og:description" />}
|
|
193
|
+
{resolvedCanonical && <meta content={resolvedCanonical} property="og:url" />}
|
|
171
194
|
{
|
|
172
195
|
resolvedOgImage && (
|
|
173
196
|
<>
|
|
174
197
|
<meta content={resolvedOgImage} property="og:image" />
|
|
198
|
+
{ogGenerated && (
|
|
199
|
+
<>
|
|
200
|
+
<meta content={OG_IMAGE_TYPE} property="og:image:type" />
|
|
201
|
+
<meta content={String(OG_IMAGE_WIDTH)} property="og:image:width" />
|
|
202
|
+
<meta
|
|
203
|
+
content={String(OG_IMAGE_HEIGHT)}
|
|
204
|
+
property="og:image:height"
|
|
205
|
+
/>
|
|
206
|
+
<meta content={pageTitle} property="og:image:alt" />
|
|
207
|
+
</>
|
|
208
|
+
)}
|
|
175
209
|
<meta content={resolvedOgImage} name="twitter:image" />
|
|
176
|
-
<meta content=
|
|
210
|
+
{ogGenerated && <meta content={pageTitle} name="twitter:image:alt" />}
|
|
177
211
|
</>
|
|
178
212
|
)
|
|
179
213
|
}
|
|
214
|
+
<meta content={twitterCard} name="twitter:card" />
|
|
215
|
+
<meta content={pageTitle} name="twitter:title" />
|
|
216
|
+
{description && <meta content={description} name="twitter:description" />}
|
|
217
|
+
{xSite && <meta content={xSite} name="twitter:site" />}
|
|
218
|
+
{xCreator && <meta content={xCreator} name="twitter:creator" />}
|
|
180
219
|
{bannerScript && <script is:inline set:html={bannerScript} />}
|
|
181
220
|
<script is:inline set:html={initialThemeScript} />
|
|
182
221
|
<Analytics analytics={analytics} />
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { withBase } from "../islands/base-path.ts";
|
|
3
|
+
import { EN_UI } from "../../core/i18n-ui.ts";
|
|
3
4
|
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
4
5
|
import type { FlatPage } from "./nav-utils.ts";
|
|
5
6
|
import Icon from "../Icon.astro";
|
|
@@ -9,17 +10,22 @@ interface Props {
|
|
|
9
10
|
prev: FlatPage | null;
|
|
10
11
|
/** Next page in reading order, or `null` at the end. */
|
|
11
12
|
next: FlatPage | null;
|
|
12
|
-
/** Localized page-level strings (`previous`, `next
|
|
13
|
+
/** Localized page-level strings (`previous`, `next`, the landmark label). */
|
|
13
14
|
strings: UIStrings["page"];
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
const { prev, next, strings } = Astro.props;
|
|
18
|
+
|
|
19
|
+
// Merge over the English defaults so a label missing from a translation (or
|
|
20
|
+
// from a not-yet-regenerated snapshot) still renders instead of coming out
|
|
21
|
+
// blank — the PageActions pattern.
|
|
22
|
+
const s = { ...EN_UI.page, ...strings };
|
|
17
23
|
---
|
|
18
24
|
|
|
19
25
|
{
|
|
20
26
|
(prev || next) && (
|
|
21
27
|
<nav
|
|
22
|
-
aria-label=
|
|
28
|
+
aria-label={s.pagination}
|
|
23
29
|
class="mx-auto mt-12 flex max-w-[42rem] justify-between gap-4 border-border border-t pt-6 max-md:flex-col"
|
|
24
30
|
>
|
|
25
31
|
{prev ? (
|
|
@@ -30,7 +36,7 @@ const { prev, next, strings } = Astro.props;
|
|
|
30
36
|
<Icon class="rtl:-scale-x-100" name="arrow-left" size={16} />
|
|
31
37
|
<span>
|
|
32
38
|
<span class="block text-muted-foreground text-xs max-md:hidden">
|
|
33
|
-
{
|
|
39
|
+
{s.previous}
|
|
34
40
|
</span>
|
|
35
41
|
<span class="block font-medium">{prev.label}</span>
|
|
36
42
|
</span>
|
|
@@ -45,7 +51,7 @@ const { prev, next, strings } = Astro.props;
|
|
|
45
51
|
>
|
|
46
52
|
<span>
|
|
47
53
|
<span class="block text-muted-foreground text-xs max-md:hidden">
|
|
48
|
-
{
|
|
54
|
+
{s.next}
|
|
49
55
|
</span>
|
|
50
56
|
<span class="block font-medium">{next.label}</span>
|
|
51
57
|
</span>
|
|
@@ -52,6 +52,10 @@ interface Props {
|
|
|
52
52
|
fontCssVars?: string[];
|
|
53
53
|
searchEnabled: boolean;
|
|
54
54
|
pageTitle: string;
|
|
55
|
+
/** Active locale code for `<html lang>` (defaults to `en`). */
|
|
56
|
+
locale?: string;
|
|
57
|
+
/** Text direction for `<html dir>` (defaults to `ltr`). */
|
|
58
|
+
dir?: "ltr" | "rtl";
|
|
55
59
|
/** Resolved UI dictionary; English baseline when omitted. */
|
|
56
60
|
ui?: UIStrings;
|
|
57
61
|
}
|
|
@@ -69,6 +73,8 @@ const {
|
|
|
69
73
|
fontCssVars,
|
|
70
74
|
searchEnabled,
|
|
71
75
|
pageTitle,
|
|
76
|
+
locale = "en",
|
|
77
|
+
dir = "ltr",
|
|
72
78
|
ui,
|
|
73
79
|
} = Astro.props;
|
|
74
80
|
|
|
@@ -85,7 +91,7 @@ const bannerScript = banner?.dismissible
|
|
|
85
91
|
---
|
|
86
92
|
|
|
87
93
|
<!doctype html>
|
|
88
|
-
<html lang=
|
|
94
|
+
<html dir={dir} lang={locale}>
|
|
89
95
|
<head>
|
|
90
96
|
<meta charset="utf-8" />
|
|
91
97
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
@@ -97,6 +103,10 @@ const bannerScript = banner?.dismissible
|
|
|
97
103
|
<Analytics analytics={analytics} />
|
|
98
104
|
</head>
|
|
99
105
|
<body class="bg-background font-sans text-foreground antialiased">
|
|
106
|
+
<a
|
|
107
|
+
class="absolute start-[-999px] top-0 z-[100] bg-accent px-4 py-2 text-accent-foreground focus:start-0"
|
|
108
|
+
href="#blume-content">{strings.page.skipToContent}</a
|
|
109
|
+
>
|
|
100
110
|
<Banner banner={banner} strings={strings.banner} />
|
|
101
111
|
<Header
|
|
102
112
|
hasDrawer={false}
|
|
@@ -109,6 +119,7 @@ const bannerScript = banner?.dismissible
|
|
|
109
119
|
/>
|
|
110
120
|
<div
|
|
111
121
|
class="h-[calc(100dvh-4rem)] overflow-auto border-border border-t [&>[data-scalar-client]]:h-full"
|
|
122
|
+
id="blume-content"
|
|
112
123
|
>
|
|
113
124
|
<slot />
|
|
114
125
|
</div>
|