blume 1.1.2 → 1.1.4
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 +47 -0
- package/dist/cli/index.js +284 -109
- package/dist/cli/index.js.map +33 -33
- package/dist/types/ai/component-markdown.d.ts +10 -0
- package/dist/types/core/config-input.d.ts +44 -0
- package/dist/types/core/data.d.ts +2 -0
- package/dist/types/core/i18n-ui.d.ts +24 -24
- package/dist/types/core/schema.d.ts +282 -114
- package/dist/types/core/types.d.ts +14 -0
- package/dist/types/openapi/references.d.ts +5 -0
- package/docs/advanced/api-reference.mdx +20 -0
- package/docs/configuration/index.mdx +27 -0
- package/package.json +1 -1
- package/src/ai/component-markdown.ts +28 -0
- package/src/ai/llms.ts +11 -2
- package/src/ai/markdown.ts +12 -6
- package/src/ai/mcp/server.ts +29 -6
- package/src/astro/examples.ts +13 -0
- package/src/astro/generate.ts +141 -58
- package/src/astro/templates.ts +65 -21
- package/src/audit/checks/duplicates.ts +15 -6
- package/src/audit/checks/indexability.ts +11 -2
- package/src/audit/checks/network.ts +22 -8
- package/src/audit/checks/sitemap.ts +42 -16
- package/src/audit/redirects.ts +12 -1
- package/src/audit/run.ts +13 -3
- package/src/audit/url.ts +21 -2
- package/src/cli/commands/audit.ts +21 -6
- package/src/cli/commands/dev.ts +19 -2
- package/src/components/content/Frame.astro +4 -1
- package/src/components/content/Prompt.astro +4 -1
- package/src/components/content/Tooltip.astro +4 -1
- package/src/components/content/Update.astro +45 -0
- package/src/components/islands/ask-ai.tsx +19 -2
- package/src/components/islands/hooks.ts +38 -11
- package/src/components/layout/Logo.astro +2 -2
- package/src/components/layout/RootLayout.astro +27 -7
- package/src/components/layout/Search.astro +5 -1
- package/src/components/layout/head-scripts.ts +22 -5
- package/src/components/openapi/ApiTagOperations.astro +17 -8
- package/src/core/config-input.ts +45 -0
- package/src/core/data.ts +2 -0
- package/src/core/date-format.ts +17 -0
- package/src/core/deployment-env.ts +7 -2
- package/src/core/graph.ts +7 -1
- package/src/core/i18n.ts +10 -2
- package/src/core/navigation.ts +7 -3
- package/src/core/project-graph.ts +9 -0
- package/src/core/schema.ts +64 -0
- package/src/core/sources/normalize.ts +69 -8
- package/src/core/sources/notion.ts +4 -2
- package/src/core/sources/sanity.ts +5 -3
- package/src/core/types.ts +16 -0
- package/src/markdown/code-title.ts +7 -1
- package/src/openapi/model.ts +31 -2
- package/src/openapi/references.ts +6 -0
- package/src/openapi/render-mdx.ts +12 -7
- package/src/openapi/scalar.ts +4 -0
- package/src/registry/eject.ts +6 -3
- package/src/theme/entry.ts +7 -0
- package/src/theme/twoslash.ts +10 -0
|
@@ -3,7 +3,7 @@ import type { Diagnostic } from "../../core/types.ts";
|
|
|
3
3
|
import { finding } from "../catalog.ts";
|
|
4
4
|
import { pageSite } from "../locate.ts";
|
|
5
5
|
import type { AuditContext, CheckModule } from "../types.ts";
|
|
6
|
-
import { normalizePath, siteOrigin } from "../url.ts";
|
|
6
|
+
import { decodePath, normalizePath, siteOrigin } from "../url.ts";
|
|
7
7
|
|
|
8
8
|
const MAX_SITEMAP_BYTES = 50 * 1024 * 1024;
|
|
9
9
|
const MAX_SITEMAP_URLS = 50_000;
|
|
@@ -18,12 +18,24 @@ const LASTMOD_SLACK_MS = 24 * 60 * 60 * 1000;
|
|
|
18
18
|
/** Error routes are never crawlable destinations, so they belong out of the sitemap. */
|
|
19
19
|
const ERROR_ROUTES = new Set(["/404", "/500"]);
|
|
20
20
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Site paths listed in the sitemap, normalized for comparison against page
|
|
23
|
+
* URLs. `<loc>`s carry the deployment base and are `encodeURI`'d; page URLs
|
|
24
|
+
* (from the file tree) carry neither, so both are undone here.
|
|
25
|
+
*/
|
|
26
|
+
const sitemapPaths = (
|
|
27
|
+
context: AuditContext,
|
|
28
|
+
deployBase: string
|
|
29
|
+
): Map<string, string> => {
|
|
23
30
|
const paths = new Map<string, string>();
|
|
24
31
|
for (const loc of context.sitemap?.urls ?? []) {
|
|
25
32
|
try {
|
|
26
|
-
paths.set(
|
|
33
|
+
paths.set(
|
|
34
|
+
normalizePath(
|
|
35
|
+
stripBasePath(deployBase, decodePath(new URL(loc).pathname))
|
|
36
|
+
),
|
|
37
|
+
loc
|
|
38
|
+
);
|
|
27
39
|
} catch {
|
|
28
40
|
// A malformed <loc> is reported by SITEMAP_INVALID, not here.
|
|
29
41
|
}
|
|
@@ -31,10 +43,20 @@ const sitemapPaths = (context: AuditContext): Map<string, string> => {
|
|
|
31
43
|
return paths;
|
|
32
44
|
};
|
|
33
45
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
46
|
+
/**
|
|
47
|
+
* The path of a canonical URL, or null when it isn't parseable
|
|
48
|
+
* (CANONICAL_BAD_TARGET reports that). Canonicals are emitted as
|
|
49
|
+
* `site + base + route`, so the deployment base is stripped to keep the result
|
|
50
|
+
* comparable against base-less page URLs.
|
|
51
|
+
*/
|
|
52
|
+
const canonicalPath = (
|
|
53
|
+
canonical: string,
|
|
54
|
+
deployBase: string
|
|
55
|
+
): string | null => {
|
|
36
56
|
try {
|
|
37
|
-
return normalizePath(
|
|
57
|
+
return normalizePath(
|
|
58
|
+
stripBasePath(deployBase, decodePath(new URL(canonical).pathname))
|
|
59
|
+
);
|
|
38
60
|
} catch {
|
|
39
61
|
return null;
|
|
40
62
|
}
|
|
@@ -45,7 +67,8 @@ const checkListedUrl = (
|
|
|
45
67
|
context: AuditContext,
|
|
46
68
|
loc: string,
|
|
47
69
|
origin: string | null,
|
|
48
|
-
file: string
|
|
70
|
+
file: string,
|
|
71
|
+
deployBase: string
|
|
49
72
|
): Diagnostic[] => {
|
|
50
73
|
let parsed: URL;
|
|
51
74
|
try {
|
|
@@ -70,12 +93,10 @@ const checkListedUrl = (
|
|
|
70
93
|
];
|
|
71
94
|
}
|
|
72
95
|
|
|
73
|
-
// `<loc>`s carry the deployment base; page URLs (from
|
|
96
|
+
// `<loc>`s carry the deployment base and are `encodeURI`'d; page URLs (from
|
|
97
|
+
// the file tree) are neither.
|
|
74
98
|
const path = normalizePath(
|
|
75
|
-
stripBasePath(
|
|
76
|
-
normalizeBasePath(context.project.config.deployment.base),
|
|
77
|
-
parsed.pathname
|
|
78
|
-
)
|
|
99
|
+
stripBasePath(deployBase, decodePath(parsed.pathname))
|
|
79
100
|
);
|
|
80
101
|
const page = context.byUrl.get(path);
|
|
81
102
|
if (!page) {
|
|
@@ -104,7 +125,7 @@ const checkListedUrl = (
|
|
|
104
125
|
);
|
|
105
126
|
}
|
|
106
127
|
|
|
107
|
-
const canonical = page.canonical && canonicalPath(page.canonical);
|
|
128
|
+
const canonical = page.canonical && canonicalPath(page.canonical, deployBase);
|
|
108
129
|
if (canonical && canonical !== path) {
|
|
109
130
|
found.push(
|
|
110
131
|
finding(
|
|
@@ -198,10 +219,15 @@ export const sitemapChecks: CheckModule = {
|
|
|
198
219
|
}
|
|
199
220
|
|
|
200
221
|
const origin = siteOrigin(site);
|
|
201
|
-
const
|
|
222
|
+
const deployBase = normalizeBasePath(
|
|
223
|
+
context.project.config.deployment.base
|
|
224
|
+
);
|
|
225
|
+
const listed = sitemapPaths(context, deployBase);
|
|
202
226
|
|
|
203
227
|
for (const loc of sitemap.urls) {
|
|
204
|
-
found.push(
|
|
228
|
+
found.push(
|
|
229
|
+
...checkListedUrl(context, loc, origin, sitemap.file, deployBase)
|
|
230
|
+
);
|
|
205
231
|
}
|
|
206
232
|
|
|
207
233
|
// The other direction: a page that was built, is indexable, and should be
|
package/src/audit/redirects.ts
CHANGED
|
@@ -19,6 +19,17 @@ interface ConfiguredRedirect {
|
|
|
19
19
|
* An external destination (`https://…`) is always `ok`: it's outside the site,
|
|
20
20
|
* so there's no local page to check it against.
|
|
21
21
|
*/
|
|
22
|
+
/**
|
|
23
|
+
* A destination's page path: the part before any query string or fragment. A
|
|
24
|
+
* redirect to `/guide#setup` or `/search?q=x` lands on the `/guide` / `/search`
|
|
25
|
+
* page — the suffix belongs to the browser, not the file tree, so keeping it
|
|
26
|
+
* would report a working redirect as broken.
|
|
27
|
+
*/
|
|
28
|
+
const pathOnly = (value: string): string => {
|
|
29
|
+
const cut = value.search(/[?#]/u);
|
|
30
|
+
return cut === -1 ? value : value.slice(0, cut);
|
|
31
|
+
};
|
|
32
|
+
|
|
22
33
|
export const resolveRedirects = (
|
|
23
34
|
redirects: readonly ConfiguredRedirect[],
|
|
24
35
|
pageUrls: ReadonlySet<string>
|
|
@@ -40,7 +51,7 @@ export const resolveRedirects = (
|
|
|
40
51
|
chain.push(current);
|
|
41
52
|
break;
|
|
42
53
|
}
|
|
43
|
-
const next = normalizePath(current);
|
|
54
|
+
const next = normalizePath(pathOnly(current));
|
|
44
55
|
if (seen.has(next)) {
|
|
45
56
|
chain.push(next);
|
|
46
57
|
return {
|
package/src/audit/run.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
|
|
3
|
-
import { normalizeBasePath } from "../core/base-path.ts";
|
|
3
|
+
import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
|
|
4
4
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
5
5
|
import type { Diagnostic } from "../core/types.ts";
|
|
6
6
|
import { deployStaticDir } from "../deploy/adapter-output.ts";
|
|
@@ -123,9 +123,10 @@ const matches = (id: CheckId, terms: string[]): boolean => {
|
|
|
123
123
|
export const runAudit = async (options: AuditOptions): Promise<AuditResult> => {
|
|
124
124
|
const { project } = options;
|
|
125
125
|
const staticDir = deployStaticDir(project.config, project.context);
|
|
126
|
+
const basePath = normalizeBasePath(project.config.basePath);
|
|
126
127
|
|
|
127
128
|
const crawl = await crawlStaticDir({
|
|
128
|
-
basePath
|
|
129
|
+
basePath,
|
|
129
130
|
manifest: project.manifest,
|
|
130
131
|
staticDir,
|
|
131
132
|
});
|
|
@@ -148,7 +149,16 @@ export const runAudit = async (options: AuditOptions): Promise<AuditResult> => {
|
|
|
148
149
|
pages: crawl.pages,
|
|
149
150
|
project,
|
|
150
151
|
redirects: resolveRedirects(
|
|
151
|
-
|
|
152
|
+
// Redirects are authored as if mounted at root; the built page URLs they
|
|
153
|
+
// are checked against carry `basePath` (it's a real directory in the
|
|
154
|
+
// build), so both sides gain it here — mirroring what
|
|
155
|
+
// `applyBaseToAstroRedirects` does at build time. `withBasePath` is
|
|
156
|
+
// idempotent and leaves external `to` URLs untouched.
|
|
157
|
+
project.config.redirects.map((redirect) => ({
|
|
158
|
+
...redirect,
|
|
159
|
+
from: withBasePath(basePath, redirect.from),
|
|
160
|
+
to: withBasePath(basePath, redirect.to),
|
|
161
|
+
})),
|
|
152
162
|
// Pages and static files both: a redirect may legitimately land on a
|
|
153
163
|
// served asset (`/old-whitepaper` -> `/files/whitepaper.pdf`).
|
|
154
164
|
new Set(
|
package/src/audit/url.ts
CHANGED
|
@@ -22,6 +22,21 @@ export const normalizePath = (path: string): string => {
|
|
|
22
22
|
return trimmed === "" ? "/" : trimmed;
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Percent-decode a pathname for comparison against the built file tree. Page
|
|
27
|
+
* URLs and file-index keys come from raw on-disk names, while `URL#pathname`
|
|
28
|
+
* (and the `encodeURI`'d sitemap `<loc>`s) are percent-encoded — a Japanese
|
|
29
|
+
* route would never match its own page without this. Malformed sequences are
|
|
30
|
+
* kept as-is: they can't have come from our own encoder.
|
|
31
|
+
*/
|
|
32
|
+
export const decodePath = (path: string): string => {
|
|
33
|
+
try {
|
|
34
|
+
return decodeURI(path);
|
|
35
|
+
} catch {
|
|
36
|
+
return path;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
25
40
|
/** The origin of `deployment.site`, or null when no site is configured. */
|
|
26
41
|
export const siteOrigin = (site?: string): string | null => {
|
|
27
42
|
if (!site) {
|
|
@@ -73,7 +88,9 @@ export const resolveHref = (
|
|
|
73
88
|
return {
|
|
74
89
|
hash: parsed.hash.slice(1),
|
|
75
90
|
kind: "self-origin",
|
|
76
|
-
path: normalizePath(
|
|
91
|
+
path: normalizePath(
|
|
92
|
+
stripBasePath(deployBase, decodePath(parsed.pathname))
|
|
93
|
+
),
|
|
77
94
|
};
|
|
78
95
|
}
|
|
79
96
|
return { kind: "external", url: parsed.toString() };
|
|
@@ -98,6 +115,8 @@ export const resolveHref = (
|
|
|
98
115
|
return {
|
|
99
116
|
hash: resolved.hash.slice(1),
|
|
100
117
|
kind: "internal",
|
|
101
|
-
path: normalizePath(
|
|
118
|
+
path: normalizePath(
|
|
119
|
+
stripBasePath(deployBase, decodePath(resolved.pathname))
|
|
120
|
+
),
|
|
102
121
|
};
|
|
103
122
|
};
|
|
@@ -40,6 +40,26 @@ export const shouldFail = (
|
|
|
40
40
|
return result.diagnostics.some((d) => failing.has(d.severity));
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Launch the agent CLI, translating a missing executable into the Windows
|
|
45
|
+
* not-found sentinel. Only `ENOENT` means "not installed" — any other spawn
|
|
46
|
+
* failure (`EACCES`, `EMFILE`, …) must surface as itself, not be masked by an
|
|
47
|
+
* irrelevant install hint.
|
|
48
|
+
*/
|
|
49
|
+
const launchAgentCode = async (
|
|
50
|
+
bin: string,
|
|
51
|
+
prompt: string
|
|
52
|
+
): Promise<number> => {
|
|
53
|
+
try {
|
|
54
|
+
return await launchAgent(bin, prompt);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") {
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
return WINDOWS_COMMAND_NOT_FOUND;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
43
63
|
export const auditCommand = defineCommand({
|
|
44
64
|
args: {
|
|
45
65
|
claude: {
|
|
@@ -163,12 +183,7 @@ export const auditCommand = defineCommand({
|
|
|
163
183
|
process.stderr.write(
|
|
164
184
|
` Handing ${count} finding${count === 1 ? "" : "s"} to ${cli.name}…\n\n`
|
|
165
185
|
);
|
|
166
|
-
|
|
167
|
-
try {
|
|
168
|
-
code = await launchAgent(cli.bin, fixPrompt(report));
|
|
169
|
-
} catch {
|
|
170
|
-
code = WINDOWS_COMMAND_NOT_FOUND;
|
|
171
|
-
}
|
|
186
|
+
const code = await launchAgentCode(cli.bin, fixPrompt(report));
|
|
172
187
|
// A POSIX spawn rejects on a missing executable; the Windows shell
|
|
173
188
|
// launch reports it through cmd.exe's 9009 instead. Same diagnosis.
|
|
174
189
|
if (code === WINDOWS_COMMAND_NOT_FOUND) {
|
package/src/cli/commands/dev.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { watch } from "node:fs";
|
|
|
2
2
|
|
|
3
3
|
import { dev } from "astro";
|
|
4
4
|
import { defineCommand } from "citty";
|
|
5
|
+
import { basename, dirname } from "pathe";
|
|
5
6
|
|
|
6
7
|
import { generateRuntime } from "../../astro/generate.ts";
|
|
7
8
|
import { showBlumeErrorOverlay } from "../../astro/integration.ts";
|
|
@@ -202,8 +203,10 @@ export const devCommand = defineCommand({
|
|
|
202
203
|
// Content is watched per source (filesystem uses fs.watch; remote sources
|
|
203
204
|
// are frozen for the session). The remaining project inputs — user pages,
|
|
204
205
|
// config, theme, and component overrides — are watched directly.
|
|
206
|
+
const dirTargets = [project.context.pagesRoot].filter(
|
|
207
|
+
(target) => target !== null
|
|
208
|
+
);
|
|
205
209
|
const fileTargets = [
|
|
206
|
-
project.context.pagesRoot,
|
|
207
210
|
project.context.configFile,
|
|
208
211
|
project.context.themeFile,
|
|
209
212
|
project.context.componentsFile,
|
|
@@ -211,10 +214,24 @@ export const devCommand = defineCommand({
|
|
|
211
214
|
|
|
212
215
|
const disposers = [
|
|
213
216
|
...project.sources.map((source) => source.watch?.(regenerate)),
|
|
214
|
-
...
|
|
217
|
+
...dirTargets.map((target) => {
|
|
215
218
|
const watcher = watch(target, { recursive: true }, regenerate);
|
|
216
219
|
return () => watcher.close();
|
|
217
220
|
}),
|
|
221
|
+
// Single files are watched via their parent directory: fs.watch on the
|
|
222
|
+
// file itself tracks the inode, so a rename-replace save (vim and most
|
|
223
|
+
// "atomic save" editors) orphans the watcher after the first write and
|
|
224
|
+
// every later edit is silently ignored.
|
|
225
|
+
...fileTargets.map((target) => {
|
|
226
|
+
const name = basename(target);
|
|
227
|
+
const watcher = watch(dirname(target), (_event, filename) => {
|
|
228
|
+
// A null filename (some platforms) can't be filtered — regenerate.
|
|
229
|
+
if (!filename || filename === name) {
|
|
230
|
+
regenerate();
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
return () => watcher.close();
|
|
234
|
+
}),
|
|
218
235
|
].filter((dispose) => dispose !== undefined);
|
|
219
236
|
|
|
220
237
|
const shutdown = async () => {
|
|
@@ -13,7 +13,10 @@ const escapeRawHtml = (value: string): string =>
|
|
|
13
13
|
|
|
14
14
|
const unwrapParagraph = (html: string): string => {
|
|
15
15
|
const trimmed = html.trim();
|
|
16
|
-
|
|
16
|
+
// Only a *single* paragraph is unwrapped: the content must not contain its
|
|
17
|
+
// own `</p>`, or `<p>a</p>\n<p>b</p>` would "unwrap" to `a</p>\n<p>b` —
|
|
18
|
+
// unbalanced HTML injected via set:html.
|
|
19
|
+
const match = trimmed.match(/^<p>(?<content>(?:(?!<\/p>)[\s\S])*)<\/p>$/u);
|
|
17
20
|
return match?.groups?.content ?? trimmed;
|
|
18
21
|
};
|
|
19
22
|
|
|
@@ -13,7 +13,10 @@ const escapeRawHtml = (value: string): string =>
|
|
|
13
13
|
|
|
14
14
|
const unwrapParagraph = (html: string): string => {
|
|
15
15
|
const trimmed = html.trim();
|
|
16
|
-
|
|
16
|
+
// Only a *single* paragraph is unwrapped: the content must not contain its
|
|
17
|
+
// own `</p>`, or `<p>a</p>\n<p>b</p>` would "unwrap" to `a</p>\n<p>b` —
|
|
18
|
+
// unbalanced HTML injected via set:html.
|
|
19
|
+
const match = trimmed.match(/^<p>(?<content>(?:(?!<\/p>)[\s\S])*)<\/p>$/u);
|
|
17
20
|
return match?.groups?.content ?? trimmed;
|
|
18
21
|
};
|
|
19
22
|
|
|
@@ -16,7 +16,10 @@ const external = href?.startsWith("http");
|
|
|
16
16
|
|
|
17
17
|
const unwrapParagraph = (html: string): string => {
|
|
18
18
|
const trimmed = html.trim();
|
|
19
|
-
|
|
19
|
+
// Only a *single* paragraph is unwrapped: the content must not contain its
|
|
20
|
+
// own `</p>`, or `<p>a</p>\n<p>b</p>` would "unwrap" to `a</p>\n<p>b` —
|
|
21
|
+
// unbalanced HTML injected via set:html.
|
|
22
|
+
const match = trimmed.match(/^<p>(?<content>(?:(?!<\/p>)[\s\S])*)<\/p>$/u);
|
|
20
23
|
return match?.groups?.content ?? trimmed;
|
|
21
24
|
};
|
|
22
25
|
|
|
@@ -81,3 +81,48 @@ const tagList = Array.isArray(tags) ? tags : tags ? [tags] : [];
|
|
|
81
81
|
<slot />
|
|
82
82
|
</div>
|
|
83
83
|
</article>
|
|
84
|
+
|
|
85
|
+
<script>
|
|
86
|
+
const state = window as Window & { __blumeUpdateIds?: boolean };
|
|
87
|
+
if (!state.__blumeUpdateIds) {
|
|
88
|
+
state.__blumeUpdateIds = true;
|
|
89
|
+
|
|
90
|
+
// Repeated labels slug to the same id (two "Bug fixes" entries on one
|
|
91
|
+
// changelog page); suffix the later ones so ids stay unique and each
|
|
92
|
+
// entry's self-anchor jumps to itself, not the first duplicate. The first
|
|
93
|
+
// keeps the plain slug, so hash deep-links stay stable. Mirrors the
|
|
94
|
+
// accordion id dedupe in `AccordionItem.astro`.
|
|
95
|
+
const dedupeUpdateIds = () => {
|
|
96
|
+
const seen = new Map<string, number>();
|
|
97
|
+
for (const update of document.querySelectorAll<HTMLElement>(
|
|
98
|
+
"[data-blume-update][id]"
|
|
99
|
+
)) {
|
|
100
|
+
const previous = update.id;
|
|
101
|
+
const count = seen.get(previous) ?? 0;
|
|
102
|
+
seen.set(previous, count + 1);
|
|
103
|
+
if (count === 0) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
update.id = `${previous}-${count + 1}`;
|
|
107
|
+
// Re-point the entry's own header anchor when it targets the old id;
|
|
108
|
+
// an explicit `href` prop (an external permalink) is left alone.
|
|
109
|
+
const anchor = update.querySelector<HTMLAnchorElement>("header a[href]");
|
|
110
|
+
const href = anchor?.getAttribute("href");
|
|
111
|
+
if (anchor && href?.endsWith(`#${previous}`)) {
|
|
112
|
+
anchor.setAttribute(
|
|
113
|
+
"href",
|
|
114
|
+
`${href.slice(0, -previous.length)}${update.id}`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
if (document.readyState === "loading") {
|
|
121
|
+
document.addEventListener("DOMContentLoaded", dedupeUpdateIds, {
|
|
122
|
+
once: true,
|
|
123
|
+
});
|
|
124
|
+
} else {
|
|
125
|
+
dedupeUpdateIds();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
</script>
|
|
@@ -180,13 +180,30 @@ const AskAI = ({
|
|
|
180
180
|
return () => window.removeEventListener("blume:open-ask-ai", handler);
|
|
181
181
|
}, []);
|
|
182
182
|
|
|
183
|
-
// ⌘I / Ctrl+I toggles the panel; Escape closes it.
|
|
183
|
+
// ⌘I / Ctrl+I toggles the panel; Escape closes it. Shift/Alt chords are
|
|
184
|
+
// left alone — Ctrl+Shift+I is the browser's DevTools shortcut, and
|
|
185
|
+
// capturing it would flap the panel open alongside them.
|
|
184
186
|
useEffect(() => {
|
|
185
187
|
const onKey = (event: KeyboardEvent) => {
|
|
186
|
-
if (
|
|
188
|
+
if (
|
|
189
|
+
(event.metaKey || event.ctrlKey) &&
|
|
190
|
+
!event.shiftKey &&
|
|
191
|
+
!event.altKey &&
|
|
192
|
+
event.key.toLowerCase() === "i"
|
|
193
|
+
) {
|
|
187
194
|
event.preventDefault();
|
|
188
195
|
setOpen((value) => !value);
|
|
189
196
|
} else if (event.key === "Escape" && open) {
|
|
197
|
+
// An Escape aimed at a modal surface stacked on top (the search
|
|
198
|
+
// dialog traps focus inside itself) dismisses that surface only —
|
|
199
|
+
// this window listener still fires for it, and closing the panel
|
|
200
|
+
// underneath too would eat the user's conversation view. Duck-typed
|
|
201
|
+
// (`closest` presence) rather than `instanceof Element`, which needs
|
|
202
|
+
// a DOM global the test environment doesn't provide.
|
|
203
|
+
const target = event.target as Partial<Element> | null;
|
|
204
|
+
if (typeof target?.closest === "function" && target.closest("dialog")) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
190
207
|
setOpen(false);
|
|
191
208
|
}
|
|
192
209
|
};
|
|
@@ -155,6 +155,13 @@ const currentPath = (): string =>
|
|
|
155
155
|
export const useAskAI = (): UseAskAI => {
|
|
156
156
|
const [messages, setMessages] = useState<AskMessage[]>([]);
|
|
157
157
|
const [loading, setLoading] = useState(false);
|
|
158
|
+
// The stream writes into the conversation via state updates, so `reset()`
|
|
159
|
+
// mid-answer must revoke the in-flight request's right to write — otherwise
|
|
160
|
+
// its next chunk re-appends the assistant bubble onto the emptied list, and
|
|
161
|
+
// its error path resurrects the entire pre-reset history. Mirrors the
|
|
162
|
+
// built-in island's generation/abort guard.
|
|
163
|
+
const generation = useRef(0);
|
|
164
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
158
165
|
|
|
159
166
|
// Retained for the compiler-off opt-out path (`react: { compiler: false }`):
|
|
160
167
|
// preserves a stable `ask` identity for consumers that depend on it. With the
|
|
@@ -166,6 +173,11 @@ export const useAskAI = (): UseAskAI => {
|
|
|
166
173
|
if (!trimmed || loading) {
|
|
167
174
|
return;
|
|
168
175
|
}
|
|
176
|
+
generation.current += 1;
|
|
177
|
+
const { current } = generation;
|
|
178
|
+
const controller = new AbortController();
|
|
179
|
+
abortRef.current = controller;
|
|
180
|
+
const live = () => current === generation.current;
|
|
169
181
|
const history: AskMessage[] = [
|
|
170
182
|
...messages,
|
|
171
183
|
{ content: trimmed, role: "user" },
|
|
@@ -181,30 +193,33 @@ export const useAskAI = (): UseAskAI => {
|
|
|
181
193
|
}),
|
|
182
194
|
headers: { "content-type": "application/json" },
|
|
183
195
|
method: "POST",
|
|
196
|
+
signal: controller.signal,
|
|
184
197
|
});
|
|
185
198
|
if (!response.ok) {
|
|
186
199
|
// An error body (JSON, HTML error page) must not stream in as the
|
|
187
200
|
// assistant's answer.
|
|
188
|
-
|
|
189
|
-
|
|
201
|
+
if (live()) {
|
|
202
|
+
assistant.content = ASK_ERROR;
|
|
203
|
+
setMessages([...history, { ...assistant }]);
|
|
204
|
+
}
|
|
190
205
|
return;
|
|
191
206
|
}
|
|
192
207
|
const reader = response.body?.getReader();
|
|
193
208
|
const decoder = new TextDecoder();
|
|
194
209
|
if (reader) {
|
|
195
210
|
let done = false;
|
|
196
|
-
while (!done) {
|
|
211
|
+
while (!done && live()) {
|
|
197
212
|
// oxlint-disable-next-line no-await-in-loop, react-doctor/async-await-in-loop -- sequential stream consumption; iterations are not independent
|
|
198
213
|
const chunk = await reader.read();
|
|
199
214
|
({ done } = chunk);
|
|
200
|
-
if (chunk.value) {
|
|
215
|
+
if (chunk.value && live()) {
|
|
201
216
|
// Streaming mode: a multi-byte UTF-8 sequence split across
|
|
202
217
|
// chunks must not flush as U+FFFD garbage.
|
|
203
218
|
assistant.content += decoder.decode(chunk.value, {
|
|
204
219
|
stream: true,
|
|
205
220
|
});
|
|
206
|
-
setMessages((
|
|
207
|
-
...
|
|
221
|
+
setMessages((currentMessages) => [
|
|
222
|
+
...currentMessages.slice(0, -1),
|
|
208
223
|
{ ...assistant },
|
|
209
224
|
]);
|
|
210
225
|
}
|
|
@@ -212,11 +227,16 @@ export const useAskAI = (): UseAskAI => {
|
|
|
212
227
|
}
|
|
213
228
|
} catch {
|
|
214
229
|
// A thrown fetch (offline, DNS failure, CORS) must not strand the
|
|
215
|
-
// pre-appended empty assistant message as a stuck placeholder.
|
|
216
|
-
|
|
217
|
-
|
|
230
|
+
// pre-appended empty assistant message as a stuck placeholder. A
|
|
231
|
+
// reset's abort lands here too — the guard keeps it silent.
|
|
232
|
+
if (live()) {
|
|
233
|
+
assistant.content = ASK_ERROR;
|
|
234
|
+
setMessages([...history, { ...assistant }]);
|
|
235
|
+
}
|
|
218
236
|
} finally {
|
|
219
|
-
|
|
237
|
+
if (live()) {
|
|
238
|
+
setLoading(false);
|
|
239
|
+
}
|
|
220
240
|
}
|
|
221
241
|
},
|
|
222
242
|
[loading, messages]
|
|
@@ -225,7 +245,14 @@ export const useAskAI = (): UseAskAI => {
|
|
|
225
245
|
// Retained for the compiler-off opt-out path (`react: { compiler: false }`):
|
|
226
246
|
// keeps a stable `reset` identity. With the compiler on it's redundant but inert.
|
|
227
247
|
// oxlint-disable-next-line react-doctor/react-compiler-no-manual-memoization -- see above
|
|
228
|
-
const reset = useCallback(() =>
|
|
248
|
+
const reset = useCallback(() => {
|
|
249
|
+
generation.current += 1;
|
|
250
|
+
abortRef.current?.abort();
|
|
251
|
+
abortRef.current = null;
|
|
252
|
+
setMessages([]);
|
|
253
|
+
// The in-flight `ask`'s finally is now stale and won't clear this.
|
|
254
|
+
setLoading(false);
|
|
255
|
+
}, []);
|
|
229
256
|
|
|
230
257
|
return { ask, loading, messages, reset };
|
|
231
258
|
};
|
|
@@ -29,7 +29,7 @@ const brandText = logo?.text ?? site.title;
|
|
|
29
29
|
---
|
|
30
30
|
|
|
31
31
|
<a
|
|
32
|
-
class="inline-flex items-center gap-2 font-semibold text-base text-foreground"
|
|
32
|
+
class="inline-flex min-w-0 items-center gap-2 font-semibold text-base text-foreground"
|
|
33
33
|
href={withBase(brandHref)}
|
|
34
34
|
>
|
|
35
35
|
{
|
|
@@ -71,5 +71,5 @@ const brandText = logo?.text ?? site.title;
|
|
|
71
71
|
</>
|
|
72
72
|
))
|
|
73
73
|
}
|
|
74
|
-
{brandText && <span>{brandText}</span>}
|
|
74
|
+
{brandText && <span class="truncate">{brandText}</span>}
|
|
75
75
|
</a>
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { EN_UI } from "../../core/i18n-ui.ts";
|
|
3
3
|
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
4
|
+
import { resolveDateFormatOptions } from "../../core/date-format.ts";
|
|
5
|
+
import type { ResolvedDateFormat } from "../../core/schema.ts";
|
|
4
6
|
import type { BlumeClientData } from "../../core/data.ts";
|
|
5
7
|
import type {
|
|
6
8
|
Heading,
|
|
@@ -23,7 +25,11 @@ import Breadcrumbs from "./Breadcrumbs.astro";
|
|
|
23
25
|
import Empty from "./Empty.astro";
|
|
24
26
|
import Favicon from "./Favicon.astro";
|
|
25
27
|
import Fonts from "./Fonts.astro";
|
|
26
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
BANNER_INIT_SCRIPT,
|
|
30
|
+
SIDEBAR_SCROLL_INIT_SCRIPT,
|
|
31
|
+
THEME_INIT_SCRIPT,
|
|
32
|
+
} from "./head-scripts.ts";
|
|
27
33
|
import Header from "./Header.astro";
|
|
28
34
|
import Icon from "../Icon.astro";
|
|
29
35
|
import {
|
|
@@ -151,6 +157,11 @@ interface Props {
|
|
|
151
157
|
clientData?: BlumeClientData | null;
|
|
152
158
|
/** Table-of-contents settings (`toc` config): visibility + heading range. */
|
|
153
159
|
toc?: { enabled: boolean; maxLevel: number; minLevel: number };
|
|
160
|
+
/**
|
|
161
|
+
* Date-formatting options (`dateFormat` config) for the "last updated" stamp,
|
|
162
|
+
* shared with the changelog timeline. Defaults to the long form when omitted.
|
|
163
|
+
*/
|
|
164
|
+
dateFormat?: ResolvedDateFormat;
|
|
154
165
|
/**
|
|
155
166
|
* Content-column preset. `"bare"` (the generated changelog index) drops both
|
|
156
167
|
* the sidebar and the table of contents and centers a single wide column;
|
|
@@ -202,6 +213,7 @@ const {
|
|
|
202
213
|
layout = {},
|
|
203
214
|
clientData,
|
|
204
215
|
toc = { enabled: true, maxLevel: 3, minLevel: 2 },
|
|
216
|
+
dateFormat,
|
|
205
217
|
contentLayout = "default",
|
|
206
218
|
} = Astro.props;
|
|
207
219
|
|
|
@@ -288,14 +300,15 @@ const twitterCard = ogImage ? "summary_large_image" : "summary";
|
|
|
288
300
|
const xSite = normalizeXHandle(x?.handle);
|
|
289
301
|
const xCreator = normalizeXHandle(x?.creator);
|
|
290
302
|
|
|
291
|
-
// "Last updated on <date>" —
|
|
303
|
+
// "Last updated on <date>" — the configured `dateFormat`, in UTC (unless the
|
|
304
|
+
// config names a zone) so it matches the changelog timeline.
|
|
292
305
|
const lastModifiedDate = lastModified ? new Date(lastModified) : null;
|
|
293
306
|
const formattedLastModified =
|
|
294
307
|
lastModifiedDate && !Number.isNaN(lastModifiedDate.getTime())
|
|
295
|
-
? new Intl.DateTimeFormat(
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
308
|
+
? new Intl.DateTimeFormat(
|
|
309
|
+
locale || "en",
|
|
310
|
+
resolveDateFormatOptions(dateFormat)
|
|
311
|
+
).format(lastModifiedDate)
|
|
299
312
|
: null;
|
|
300
313
|
|
|
301
314
|
// The hosted MCP server's absolute URL, used by the page-actions install menu.
|
|
@@ -549,7 +562,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
549
562
|
</nav>
|
|
550
563
|
)
|
|
551
564
|
}
|
|
552
|
-
<nav>
|
|
565
|
+
<nav data-blume-nav-tree>
|
|
553
566
|
{
|
|
554
567
|
MobileNavSlot ? (
|
|
555
568
|
<>
|
|
@@ -578,6 +591,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
578
591
|
}
|
|
579
592
|
</nav>
|
|
580
593
|
</aside>
|
|
594
|
+
<script is:inline set:html={SIDEBAR_SCROLL_INIT_SCRIPT} />
|
|
581
595
|
<main class="px-6 pt-6 pb-10 lg:px-8 xl:px-10" id="blume-content">
|
|
582
596
|
<BreadcrumbsSlot
|
|
583
597
|
crumbs={crumbs}
|
|
@@ -887,6 +901,12 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
887
901
|
};
|
|
888
902
|
|
|
889
903
|
for (const image of zoomTargets) {
|
|
904
|
+
// An image that is itself a link navigates on click — binding zoom
|
|
905
|
+
// to it would flash a zoom overlay in the instant before navigation
|
|
906
|
+
// and advertise (via the cursor) a zoom that never happens.
|
|
907
|
+
if (image.closest("a")) {
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
890
910
|
image.classList.add("cursor-zoom-in");
|
|
891
911
|
image.addEventListener("click", () => openZoom(image));
|
|
892
912
|
}
|
|
@@ -325,7 +325,11 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
|
|
|
325
325
|
document.addEventListener("keydown", (event) => {
|
|
326
326
|
if (
|
|
327
327
|
(event.key === "k" || event.key === "K") &&
|
|
328
|
-
(event.metaKey || event.ctrlKey)
|
|
328
|
+
(event.metaKey || event.ctrlKey) &&
|
|
329
|
+
// Ctrl+Shift+K is Firefox's web console; a shifted or alted chord
|
|
330
|
+
// belongs to the browser, not the search dialog.
|
|
331
|
+
!event.shiftKey &&
|
|
332
|
+
!event.altKey
|
|
329
333
|
) {
|
|
330
334
|
// ⌘K toggles, mirroring the Ask AI panel's ⌘I: pressing it with
|
|
331
335
|
// the dialog open must close it, not re-showModal an open dialog
|