blume 1.1.3 → 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.
Files changed (37) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/cli/index.js +189 -88
  3. package/dist/cli/index.js.map +22 -22
  4. package/dist/types/core/data.d.ts +2 -0
  5. package/package.json +1 -1
  6. package/src/ai/mcp/server.ts +29 -6
  7. package/src/astro/generate.ts +94 -46
  8. package/src/astro/templates.ts +59 -15
  9. package/src/audit/checks/duplicates.ts +15 -6
  10. package/src/audit/checks/indexability.ts +11 -2
  11. package/src/audit/checks/network.ts +22 -8
  12. package/src/audit/checks/sitemap.ts +42 -16
  13. package/src/audit/redirects.ts +12 -1
  14. package/src/audit/run.ts +13 -3
  15. package/src/audit/url.ts +21 -2
  16. package/src/cli/commands/audit.ts +21 -6
  17. package/src/cli/commands/dev.ts +19 -2
  18. package/src/components/content/Frame.astro +4 -1
  19. package/src/components/content/Prompt.astro +4 -1
  20. package/src/components/content/Tooltip.astro +4 -1
  21. package/src/components/content/Update.astro +45 -0
  22. package/src/components/islands/ask-ai.tsx +19 -2
  23. package/src/components/islands/hooks.ts +38 -11
  24. package/src/components/layout/RootLayout.astro +13 -2
  25. package/src/components/layout/Search.astro +5 -1
  26. package/src/components/layout/head-scripts.ts +22 -5
  27. package/src/core/data.ts +2 -0
  28. package/src/core/deployment-env.ts +7 -2
  29. package/src/core/graph.ts +7 -1
  30. package/src/core/i18n.ts +10 -2
  31. package/src/core/navigation.ts +7 -3
  32. package/src/core/sources/normalize.ts +69 -8
  33. package/src/core/sources/notion.ts +4 -2
  34. package/src/core/sources/sanity.ts +5 -3
  35. package/src/markdown/code-title.ts +7 -1
  36. package/src/openapi/model.ts +31 -2
  37. package/src/openapi/render-mdx.ts +12 -7
@@ -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
- /** Site paths listed in the sitemap, normalized for comparison against page URLs. */
22
- const sitemapPaths = (context: AuditContext): Map<string, string> => {
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(normalizePath(new URL(loc).pathname), loc);
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
- /** The path of a canonical URL, or null when it isn't parseable (CANONICAL_BAD_TARGET reports that). */
35
- const canonicalPath = (canonical: string): string | null => {
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(new URL(canonical).pathname);
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 the file tree) don't.
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 listed = sitemapPaths(context);
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(...checkListedUrl(context, loc, origin, sitemap.file));
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
@@ -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: normalizeBasePath(project.config.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
- project.config.redirects,
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(stripBasePath(deployBase, parsed.pathname)),
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(stripBasePath(deployBase, resolved.pathname)),
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
- let code: number;
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) {
@@ -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
- ...fileTargets.map((target) => {
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
- const match = trimmed.match(/^<p>(?<content>[\s\S]*)<\/p>$/u);
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
- const match = trimmed.match(/^<p>(?<content>[\s\S]*)<\/p>$/u);
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
- const match = trimmed.match(/^<p>(?<content>[\s\S]*)<\/p>$/u);
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 ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "i") {
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
- assistant.content = ASK_ERROR;
189
- setMessages([...history, { ...assistant }]);
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((current) => [
207
- ...current.slice(0, -1),
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
- assistant.content = ASK_ERROR;
217
- setMessages([...history, { ...assistant }]);
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
- setLoading(false);
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(() => setMessages([]), []);
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
  };
@@ -25,7 +25,11 @@ import Breadcrumbs from "./Breadcrumbs.astro";
25
25
  import Empty from "./Empty.astro";
26
26
  import Favicon from "./Favicon.astro";
27
27
  import Fonts from "./Fonts.astro";
28
- import { BANNER_INIT_SCRIPT, THEME_INIT_SCRIPT } from "./head-scripts.ts";
28
+ import {
29
+ BANNER_INIT_SCRIPT,
30
+ SIDEBAR_SCROLL_INIT_SCRIPT,
31
+ THEME_INIT_SCRIPT,
32
+ } from "./head-scripts.ts";
29
33
  import Header from "./Header.astro";
30
34
  import Icon from "../Icon.astro";
31
35
  import {
@@ -558,7 +562,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
558
562
  </nav>
559
563
  )
560
564
  }
561
- <nav>
565
+ <nav data-blume-nav-tree>
562
566
  {
563
567
  MobileNavSlot ? (
564
568
  <>
@@ -587,6 +591,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
587
591
  }
588
592
  </nav>
589
593
  </aside>
594
+ <script is:inline set:html={SIDEBAR_SCROLL_INIT_SCRIPT} />
590
595
  <main class="px-6 pt-6 pb-10 lg:px-8 xl:px-10" id="blume-content">
591
596
  <BreadcrumbsSlot
592
597
  crumbs={crumbs}
@@ -896,6 +901,12 @@ const bannerKey = banner?.dismissible ? banner.key : null;
896
901
  };
897
902
 
898
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
+ }
899
910
  image.classList.add("cursor-zoom-in");
900
911
  image.addEventListener("click", () => openZoom(image));
901
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
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * Pre-paint inline scripts shared by the document layouts (`RootLayout`,
3
- * `PageLayout`, `ReferenceLayout`). They run synchronously in `<head>`, before
4
- * first paint, so the page never flashes the wrong theme or a since-dismissed
5
- * banner. Kept in one place so the layouts can't drift on this timing-critical
6
- * logic.
3
+ * `PageLayout`, `ReferenceLayout`). They run synchronously — in `<head>`, or
4
+ * immediately after the markup they act on — before that content paints, so the
5
+ * page never flashes the wrong theme, a since-dismissed banner, or a sidebar
6
+ * scrolled away from the current page. Kept in one place so the layouts can't
7
+ * drift on this timing-critical logic.
7
8
  *
8
- * Both are constants, never built by interpolating config into source text: the
9
+ * All are constants, never built by interpolating config into source text: any
9
10
  * values they need ride in as `data-*` attributes on the script tag and are read
10
11
  * back through `document.currentScript`. Baking a config string into JS — even
11
12
  * via `JSON.stringify` — is code construction, and JSON escaping does not cover
@@ -27,3 +28,19 @@ export const THEME_INIT_SCRIPT = `(()=>{const m=document.currentScript?.dataset.
27
28
  * Reads `data-key` — the banner's dismissal key.
28
29
  */
29
30
  export const BANNER_INIT_SCRIPT = `(()=>{const k=document.currentScript?.dataset.key;if(k&&localStorage.getItem("blume-banner:"+k))document.documentElement.setAttribute("data-blume-banner-hidden","");})();`;
31
+
32
+ /**
33
+ * Center the current page's sidebar link before the sidebar paints. Every
34
+ * navigation is a full page load, and the sidebar is its own scroll container,
35
+ * so without this it is reborn scrolled to the top on every click — on a long
36
+ * sidebar the viewport visibly jumps away from the link you just clicked.
37
+ *
38
+ * Runs inline immediately after the sidebar `<aside>` (not in `<head>`: it
39
+ * needs that markup parsed). The lookup is scoped to the page tree
40
+ * (`data-blume-nav-tree`) because the drawer also holds the mobile tabs list,
41
+ * whose active tab is `aria-current` too. `getClientRects()` skips links that
42
+ * aren't rendered — `hidden` drill-in panels and breakpoint-hidden duplicates —
43
+ * and the script no-ops when the active link is already inside the visible
44
+ * scroll area, so a short sidebar never moves.
45
+ */
46
+ export const SIDEBAR_SCROLL_INIT_SCRIPT = `(()=>{const n=document.querySelector("[data-blume-nav-drawer]");const s=n&&(n.querySelector("[data-blume-nav-tree]")||n);if(!s)return;let l=null;for(const a of s.querySelectorAll('a[aria-current="page"]')){if(a.getClientRects().length){l=a;break;}}if(!l)return;const r=n.getBoundingClientRect();const t=l.getBoundingClientRect();if(t.top>=r.top&&t.bottom<=r.bottom)return;n.scrollTop+=t.top-r.top-(n.clientHeight-t.height)/2;})();`;
package/src/core/data.ts CHANGED
@@ -104,6 +104,8 @@ export interface BlumeDataConfig {
104
104
  codeThemes: ResolvedConfig["markdown"]["codeBlocks"]["theme"];
105
105
  /** `markdown.code.wrap`: wrap long code lines instead of scrolling. */
106
106
  codeWrap: boolean;
107
+ /** `dateFormat`: `Intl.DateTimeFormat` options for the date stamps. */
108
+ dateFormat: ResolvedConfig["dateFormat"];
107
109
  description: string | undefined;
108
110
  favicon: BlumeFavicon;
109
111
  feedback: boolean;
@@ -30,12 +30,17 @@ const PLATFORMS: Platform[] = [
30
30
  {
31
31
  adapter: "vercel",
32
32
  detect: (env) => Boolean(env.VERCEL),
33
- site: (env) => toUrl(env.VERCEL_PROJECT_PRODUCTION_URL ?? env.VERCEL_URL),
33
+ // Fall through per *resolved* value, not per variable — a platform can set
34
+ // a var to the empty string, which `??` on the raw values treats as
35
+ // present, dead-ending the chain and silently losing the site URL.
36
+ site: (env) =>
37
+ toUrl(env.VERCEL_PROJECT_PRODUCTION_URL) ?? toUrl(env.VERCEL_URL),
34
38
  },
35
39
  {
36
40
  adapter: "netlify",
37
41
  detect: (env) => Boolean(env.NETLIFY),
38
- site: (env) => toUrl(env.URL ?? env.DEPLOY_PRIME_URL ?? env.DEPLOY_URL),
42
+ site: (env) =>
43
+ toUrl(env.URL) ?? toUrl(env.DEPLOY_PRIME_URL) ?? toUrl(env.DEPLOY_URL),
39
44
  },
40
45
  {
41
46
  adapter: "cloudflare",