blume 0.5.2 → 0.5.3

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 (57) hide show
  1. package/dist/cli/index.js +2755 -2564
  2. package/dist/cli/index.js.map +33 -31
  3. package/dist/types/core/package-json.d.ts +12 -0
  4. package/dist/types/migrate/shared.d.ts +153 -0
  5. package/docs/advanced/migrate.mdx +1 -0
  6. package/docs/configuration/ai.mdx +1 -1
  7. package/docs/configuration/theming.mdx +1 -1
  8. package/docs/content/i18n.mdx +1 -1
  9. package/docs/content/sources.mdx +1 -1
  10. package/package.json +1 -1
  11. package/src/ai/mcp/discovery.ts +3 -1
  12. package/src/ai/mcp/server.ts +3 -1
  13. package/src/astro/component-slots.ts +10 -2
  14. package/src/astro/static-assets.ts +10 -3
  15. package/src/astro/templates.ts +53 -21
  16. package/src/cli/coalesce.ts +43 -0
  17. package/src/cli/commands/dev.ts +31 -17
  18. package/src/cli/dev-lock.ts +4 -2
  19. package/src/components/content/ColorItem.astro +6 -3
  20. package/src/components/content/Prompt.astro +7 -3
  21. package/src/components/content/Tabs.astro +13 -2
  22. package/src/components/content/mermaid-element.ts +20 -2
  23. package/src/components/islands/ask-ai.tsx +4 -8
  24. package/src/components/islands/base-path.ts +30 -0
  25. package/src/components/islands/hooks.ts +12 -8
  26. package/src/components/layout/PageActions.astro +17 -11
  27. package/src/components/layout/Search.astro +4 -1
  28. package/src/components/layout/search/types.ts +16 -5
  29. package/src/components/openapi/ParametersTable.astro +1 -1
  30. package/src/components/openapi/SchemaProperty.astro +1 -1
  31. package/src/components/openapi/SchemaTable.astro +3 -3
  32. package/src/components/openapi/helpers.ts +17 -8
  33. package/src/components/openapi/snippets.ts +17 -4
  34. package/src/core/config.ts +15 -6
  35. package/src/core/graph.ts +6 -1
  36. package/src/core/navigation.ts +5 -1
  37. package/src/core/sources/filesystem.ts +19 -1
  38. package/src/core/sources/mdx-remote.ts +20 -4
  39. package/src/core/sources/mintlify.ts +14 -28
  40. package/src/core/sources/normalize.ts +28 -6
  41. package/src/core/sources/watch.ts +44 -0
  42. package/src/markdown/code-title.ts +6 -3
  43. package/src/markdown/package-install.ts +3 -1
  44. package/src/migrate/fumadocs/content.ts +3 -5
  45. package/src/migrate/fumadocs/index.ts +24 -9
  46. package/src/migrate/mintlify/config.ts +2 -6
  47. package/src/migrate/mintlify/index.ts +119 -32
  48. package/src/migrate/mintlify/snippets.ts +17 -8
  49. package/src/migrate/nextra/index.ts +16 -1
  50. package/src/migrate/shared.ts +77 -4
  51. package/src/migrate/starlight/content.ts +3 -6
  52. package/src/og/card.ts +16 -4
  53. package/src/openapi/render-mdx.ts +10 -1
  54. package/src/search/sync/orama-cloud.ts +2 -0
  55. package/src/search/sync/typesense.ts +4 -0
  56. package/src/theme/icons.ts +13 -4
  57. package/src/theme/palette.ts +38 -17
@@ -24,6 +24,9 @@ const prefersDark = () => document.documentElement.dataset.theme === "dark";
24
24
  let counter = 0;
25
25
 
26
26
  class BlumeMermaid extends HTMLElement {
27
+ #observer: MutationObserver | null = null;
28
+ #renderToken = 0;
29
+
27
30
  connectedCallback() {
28
31
  const source = this.dataset.source ?? "";
29
32
  if (!source.trim()) {
@@ -35,6 +38,8 @@ class BlumeMermaid extends HTMLElement {
35
38
  this.replaceChildren(output);
36
39
 
37
40
  const render = async () => {
41
+ this.#renderToken += 1;
42
+ const token = this.#renderToken;
38
43
  const mermaid = await loadMermaid();
39
44
  mermaid.initialize({
40
45
  securityLevel: "strict",
@@ -47,7 +52,11 @@ class BlumeMermaid extends HTMLElement {
47
52
  `blume-mermaid-${counter}`,
48
53
  source
49
54
  );
50
- output.innerHTML = svg;
55
+ // A newer render (rapid theme toggles) superseded this one — dropping
56
+ // the stale result keeps the diagram in the latest theme.
57
+ if (token === this.#renderToken) {
58
+ output.innerHTML = svg;
59
+ }
51
60
  } catch {
52
61
  output.textContent = "Could not render this diagram.";
53
62
  }
@@ -57,10 +66,19 @@ class BlumeMermaid extends HTMLElement {
57
66
  render();
58
67
 
59
68
  // Re-render on color-theme changes so the diagram tracks light and dark.
60
- new MutationObserver(() => render()).observe(document.documentElement, {
69
+ // One observer per connection, disconnected on removal — otherwise every
70
+ // DOM move stacks another observer that renders into detached DOM forever.
71
+ this.#observer?.disconnect();
72
+ this.#observer = new MutationObserver(() => render());
73
+ this.#observer.observe(document.documentElement, {
61
74
  attributeFilter: ["data-theme"],
62
75
  });
63
76
  }
77
+
78
+ disconnectedCallback() {
79
+ this.#observer?.disconnect();
80
+ this.#observer = null;
81
+ }
64
82
  }
65
83
 
66
84
  if (!customElements.get("blume-mermaid")) {
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
2
2
  import type { FormEvent } from "react";
3
3
 
4
4
  import type { UIStrings } from "../../core/i18n-ui.ts";
5
+ import { joinBase, stripBase } from "./base-path.ts";
5
6
 
6
7
  interface ChatMessage {
7
8
  id: number;
@@ -27,16 +28,11 @@ const nextId = (): number => {
27
28
 
28
29
  // The endpoint and page path both honor the deployment `base` so grounding works
29
30
  // under a non-root base path (the server matches base-less document routes).
30
- const ASK_ENDPOINT = `${import.meta.env.BASE_URL}api/ask`.replace("//", "/");
31
+ const ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
31
32
 
32
33
  /** The current route with the deployment base stripped, for page-context lookup. */
33
- const currentPath = (): string => {
34
- const base = import.meta.env.BASE_URL;
35
- const path = window.location.pathname;
36
- return base.length > 1 && path.startsWith(base)
37
- ? `/${path.slice(base.length)}`
38
- : path;
39
- };
34
+ const currentPath = (): string =>
35
+ stripBase(import.meta.env.BASE_URL, window.location.pathname);
40
36
 
41
37
  const BUTTON_CLASS =
42
38
  "inline-flex h-9 cursor-pointer items-center gap-2 rounded-blume border border-border bg-muted px-2.5 text-muted-foreground text-sm hover:border-accent disabled:opacity-50";
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Base-path helpers for client islands. Astro's default `trailingSlash:
3
+ * "ignore"` passes `deployment.base` through as-is, so `BASE_URL` may arrive
4
+ * with or without a trailing slash (`/docs` or `/docs/`); every consumer must
5
+ * treat both forms the same or endpoints/grounding break under a base path.
6
+ */
7
+
8
+ /** The base with a guaranteed trailing slash (`/docs` -> `/docs/`). */
9
+ export const withTrailingSlash = (base: string): string =>
10
+ base.endsWith("/") ? base : `${base}/`;
11
+
12
+ /** Join a base-relative path (`api/ask`) onto the deployment base. */
13
+ export const joinBase = (base: string, path: string): string =>
14
+ `${withTrailingSlash(base)}${path}`;
15
+
16
+ /**
17
+ * A pathname with the deployment base stripped (`/docs/guide` -> `/guide`),
18
+ * for page-context lookups against base-less document routes.
19
+ */
20
+ export const stripBase = (base: string, pathname: string): string => {
21
+ const slashed = withTrailingSlash(base);
22
+ if (slashed === "/") {
23
+ return pathname;
24
+ }
25
+ if (pathname.startsWith(slashed)) {
26
+ return `/${pathname.slice(slashed.length)}`;
27
+ }
28
+ // The bare base itself ("/docs") is the base-less root.
29
+ return `${pathname}/` === slashed ? "/" : pathname;
30
+ };
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
2
2
 
3
3
  import type { BlumeClientData } from "../../core/data.ts";
4
4
  import type { SearchFn, SearchResult } from "../layout/search/types.ts";
5
+ import { joinBase, stripBase } from "./base-path.ts";
5
6
 
6
7
  /**
7
8
  * React hooks for Blume islands.
@@ -117,16 +118,11 @@ export interface UseAskAI {
117
118
  reset: () => void;
118
119
  }
119
120
 
120
- const ASK_ENDPOINT = `${import.meta.env.BASE_URL}api/ask`.replace("//", "/");
121
+ const ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
121
122
 
122
123
  /** The current route with the deployment base stripped, for page grounding. */
123
- const currentPath = (): string => {
124
- const base = import.meta.env.BASE_URL;
125
- const path = window.location.pathname;
126
- return base.length > 1 && path.startsWith(base)
127
- ? `/${path.slice(base.length)}`
128
- : path;
129
- };
124
+ const currentPath = (): string =>
125
+ stripBase(import.meta.env.BASE_URL, window.location.pathname);
130
126
 
131
127
  /**
132
128
  * Stream answers from the Ask AI endpoint. Mirrors the built-in Ask AI island so
@@ -158,6 +154,14 @@ export const useAskAI = (): UseAskAI => {
158
154
  headers: { "content-type": "application/json" },
159
155
  method: "POST",
160
156
  });
157
+ if (!response.ok) {
158
+ // An error body (JSON, HTML error page) must not stream in as the
159
+ // assistant's answer.
160
+ assistant.content =
161
+ "Something went wrong answering that. Please try again.";
162
+ setMessages([...history, { ...assistant }]);
163
+ return;
164
+ }
161
165
  const reader = response.body?.getReader();
162
166
  const decoder = new TextDecoder();
163
167
  if (reader) {
@@ -311,7 +311,9 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
311
311
  .replace(/[^a-z0-9]+/gu, "-")
312
312
  .replace(/^-+|-+$/gu, "") || "docs";
313
313
 
314
- const cursorConfig = btoa(JSON.stringify({ url: mcpUrl }));
314
+ // Base64 output can contain `+`/`=`, which a query string mangles
315
+ // (`+` decodes as a space) — it must be URL-encoded like any other value.
316
+ const cursorConfig = encodeURIComponent(btoa(JSON.stringify({ url: mcpUrl })));
315
317
  root
316
318
  .querySelector("[data-mcp-cursor]")
317
319
  ?.setAttribute(
@@ -325,20 +327,22 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
325
327
  `vscode:mcp/install?${encodeURIComponent(JSON.stringify({ name: id, type: "http", url: mcpUrl }))}`
326
328
  );
327
329
 
328
- const flash = (el: Element | null, text: string, reset: string) => {
329
- if (el) {
330
- el.textContent = text;
331
- setTimeout(() => {
332
- el.textContent = reset;
333
- }, 1500);
330
+ const flash = (el: Element | null, text: string) => {
331
+ if (!(el instanceof HTMLElement)) {
332
+ return;
334
333
  }
334
+ // Remember the element's own (localized) label once — capturing it at
335
+ // click time would capture "Copied!" on a double-click and stick.
336
+ el.dataset.blumeLabel ??= el.textContent ?? "";
337
+ el.textContent = text;
338
+ setTimeout(() => {
339
+ el.textContent = el.dataset.blumeLabel ?? "";
340
+ }, 1500);
335
341
  };
336
342
  const copy = async (value: string, el: Element | null) => {
337
- // Restore the element's own label, so localized text round-trips.
338
- const reset = el?.textContent ?? "";
339
343
  try {
340
344
  await navigator.clipboard.writeText(value);
341
- flash(el, copiedLabel, reset);
345
+ flash(el, copiedLabel);
342
346
  } catch {
343
347
  // Clipboard unavailable; nothing to do.
344
348
  }
@@ -359,10 +363,12 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
359
363
  }
360
364
 
361
365
  const label = root.querySelector("[data-blume-copy-label]");
366
+ // Captured once — inside the handler a double-click would capture and
367
+ // permanently restore "Copied!".
368
+ const original = label?.textContent ?? "";
362
369
  root
363
370
  .querySelector("[data-blume-copy-page]")
364
371
  ?.addEventListener("click", async () => {
365
- const original = label?.textContent ?? "";
366
372
  try {
367
373
  const response = await fetch(md);
368
374
  await navigator.clipboard.writeText(await response.text());
@@ -296,10 +296,13 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
296
296
  this.input.focus();
297
297
  this.input.select();
298
298
  if (!this.loaded) {
299
- this.loaded = true;
300
299
  try {
301
300
  const { createSearch } = await import("blume:search-client");
302
301
  this.searchFn = await createSearch();
302
+ // Only latch on success — a transient failure (flaky network
303
+ // fetching the index) must retry on the next open, not disable
304
+ // search until a full page reload.
305
+ this.loaded = true;
303
306
  } catch {
304
307
  this.searchFn = null;
305
308
  }
@@ -74,15 +74,26 @@ const queryTokens = (query: string): string[] =>
74
74
  .filter(Boolean)
75
75
  .map((token) => token.replaceAll(REGEXP_SPECIAL, String.raw`\$&`));
76
76
 
77
- /** Wrap query matches in `<mark>`, after HTML-escaping the source text. */
77
+ /**
78
+ * Wrap query matches in `<mark>`, HTML-escaping the source text. Matching runs
79
+ * on the *raw* text and escaping on each segment — matching after escaping
80
+ * would let a query like "amp" or "lt" mark the inside of an entity produced
81
+ * from the source (`&amp;` in "a & b"), corrupting the rendered excerpt.
82
+ */
78
83
  export const highlight = (text: string, query: string): string => {
79
- const escaped = escapeHtml(text);
80
84
  const tokens = queryTokens(query);
81
85
  if (tokens.length === 0) {
82
- return escaped;
86
+ return escapeHtml(text);
83
87
  }
84
- const pattern = new RegExp(`(?<match>${tokens.join("|")})`, "giu");
85
- return escaped.replaceAll(pattern, "<mark>$<match></mark>");
88
+ const pattern = new RegExp(`(${tokens.join("|")})`, "giu");
89
+ return text
90
+ .split(pattern)
91
+ .map((segment, index) =>
92
+ index % 2 === 1
93
+ ? `<mark>${escapeHtml(segment)}</mark>`
94
+ : escapeHtml(segment)
95
+ )
96
+ .join("");
86
97
  };
87
98
 
88
99
  /** First index in `text` where any query token matches (case-insensitive). */
@@ -48,7 +48,7 @@ const groups = SECTIONS.map((section) => ({
48
48
  <div class="not-prose rounded-blume border border-border px-4">
49
49
  {group.items.map((param) => {
50
50
  const resolved = resolveSchema(schemas, param.schema ?? {});
51
- const type = param.schema ? typeLabel(param.schema, schemas) : "string";
51
+ const type = param.schema ? typeLabel(param.schema) : "string";
52
52
  const limits = constraints(resolved);
53
53
  const enumValues = Array.isArray(resolved.enum) ? resolved.enum : null;
54
54
  return (
@@ -31,7 +31,7 @@ const refLabel = typeof schema.$ref === "string" ? refName(schema.$ref) : null;
31
31
  const circular = refLabel !== null && seen.includes(refLabel);
32
32
  const resolved = resolveSchema(schemas, schema);
33
33
 
34
- const type = typeLabel(schema, schemas);
34
+ const type = typeLabel(schema);
35
35
  const description = resolved.description ?? schema.description ?? "";
36
36
  const deprecated = resolved.deprecated === true;
37
37
  const nullable = isNullable(resolved);
@@ -45,7 +45,7 @@ const { properties, required } = circular
45
45
  ) : isArray ? (
46
46
  <div>
47
47
  <div class="mb-2 text-muted-foreground text-xs">
48
- Array of <code class="text-foreground">{typeLabel(items ?? {}, schemas)}</code>
48
+ Array of <code class="text-foreground">{typeLabel(items ?? {})}</code>
49
49
  </div>
50
50
  {items && (
51
51
  <Astro.self schema={items} schemas={schemas} seen={nextSeen} expandAll={expandAll} />
@@ -59,7 +59,7 @@ const { properties, required } = circular
59
59
  {branches.map((branch, index) => (
60
60
  <div class="rounded-blume border border-border p-3">
61
61
  <div class="mb-2 font-medium text-foreground text-xs">
62
- {typeLabel(branch, schemas) || `Option ${index + 1}`}
62
+ {typeLabel(branch) || `Option ${index + 1}`}
63
63
  </div>
64
64
  <Astro.self schema={branch} schemas={schemas} seen={nextSeen} expandAll={expandAll} />
65
65
  </div>
@@ -80,7 +80,7 @@ const { properties, required } = circular
80
80
  </div>
81
81
  ) : (
82
82
  <div class="text-muted-foreground text-sm">
83
- <code class="text-foreground">{typeLabel(resolved, schemas)}</code>
83
+ <code class="text-foreground">{typeLabel(resolved)}</code>
84
84
  </div>
85
85
  )
86
86
  }
@@ -69,17 +69,18 @@ const nonNullTypes = (type: string | string[] | undefined): string[] => {
69
69
  return (Array.isArray(type) ? type : [type]).filter((t) => t !== "null");
70
70
  };
71
71
 
72
- /** A short, human-readable type label for a schema row. */
73
- export const typeLabel = (
74
- schema: SchemaLike,
75
- schemas: Record<string, SchemaLike>
76
- ): string => {
72
+ /**
73
+ * A short, human-readable type label for a schema row. `$ref`s label by name
74
+ * (`Pet`, `Pet[]`) without resolving — which also means circular refs through
75
+ * array items can't recurse forever.
76
+ */
77
+ export const typeLabel = (schema: SchemaLike): string => {
77
78
  if (typeof schema.$ref === "string") {
78
79
  return refName(schema.$ref);
79
80
  }
80
81
  if (schema.oneOf || schema.anyOf) {
81
82
  const branches = schema.oneOf ?? schema.anyOf ?? [];
82
- const labels = branches.map((branch) => typeLabel(branch, schemas));
83
+ const labels = branches.map((branch) => typeLabel(branch));
83
84
  return [...new Set(labels)].join(" | ") || "any";
84
85
  }
85
86
  if (schema.allOf) {
@@ -87,8 +88,7 @@ export const typeLabel = (
87
88
  }
88
89
  const types = nonNullTypes(schema.type);
89
90
  if (types.includes("array")) {
90
- const item = resolveSchema(schemas, schema.items);
91
- return `${typeLabel(item, schemas)}[]`;
91
+ return `${typeLabel(schema.items ?? {})}[]`;
92
92
  }
93
93
  const base = types[0] ?? (schema.properties ? "object" : "any");
94
94
  return schema.format ? `${base}<${schema.format}>` : base;
@@ -136,8 +136,17 @@ export const objectProperties = (
136
136
  ): { properties: [string, SchemaLike][]; required: Set<string> } => {
137
137
  const properties = new Map<string, SchemaLike>();
138
138
  const required = new Set<string>();
139
+ // Cycles can only enter through `$ref`s (inline JSON can't self-nest), so
140
+ // tracking visited refs is enough to stop circular allOf chains recursing.
141
+ const seen = new Set<string>();
139
142
 
140
143
  const collect = (node: SchemaLike): void => {
144
+ if (typeof node.$ref === "string") {
145
+ if (seen.has(node.$ref)) {
146
+ return;
147
+ }
148
+ seen.add(node.$ref);
149
+ }
141
150
  const resolved = resolveSchema(schemas, node);
142
151
  for (const name of resolved.required ?? []) {
143
152
  required.add(name);
@@ -115,7 +115,9 @@ const curlSnippet = (sample: RequestSample): string => {
115
115
  ...headerLines(sample.headers, (key, value) => ` -H "${key}: ${value}"`),
116
116
  ];
117
117
  if (sample.body) {
118
- lines.push(` -d '${sample.body}'`);
118
+ // Close-quote/escaped-quote/reopen: the POSIX way to put a literal ' in a
119
+ // single-quoted string, so an example like "it's" doesn't break the shell.
120
+ lines.push(` -d '${sample.body.replaceAll("'", String.raw`'\''`)}'`);
119
121
  }
120
122
  return lines.join(" \\\n");
121
123
  };
@@ -137,12 +139,23 @@ const fetchSnippet = (sample: RequestSample): string => {
137
139
  )}\n});`;
138
140
  };
139
141
 
142
+ // Split-with-capture: odd segments are JSON string literals, kept verbatim so
143
+ // a string *value* containing the words true/false/null isn't rewritten.
144
+ const JSON_STRING = /(?<literal>"(?:\\.|[^"\\])*")/gu;
145
+
140
146
  /** Turn a JSON literal into an equivalent Python literal (`true` -> `True`). */
141
147
  const toPython = (json: string): string =>
142
148
  json
143
- .replaceAll(/\btrue\b/gu, "True")
144
- .replaceAll(/\bfalse\b/gu, "False")
145
- .replaceAll(/\bnull\b/gu, "None");
149
+ .split(JSON_STRING)
150
+ .map((part, index) =>
151
+ index % 2 === 1
152
+ ? part
153
+ : part
154
+ .replaceAll(/\btrue\b/gu, "True")
155
+ .replaceAll(/\bfalse\b/gu, "False")
156
+ .replaceAll(/\bnull\b/gu, "None")
157
+ )
158
+ .join("");
146
159
 
147
160
  const pythonSnippet = (sample: RequestSample): string => {
148
161
  const args = [` "${sample.url}"`];
@@ -85,13 +85,22 @@ export const loadConfig = async (
85
85
  file: sourceFile ?? undefined,
86
86
  source,
87
87
  });
88
+ const [first, ...rest] = diagnostics;
89
+ const primary = first ?? {
90
+ code: "BLUME_CONFIG_INVALID",
91
+ file: sourceFile ?? undefined,
92
+ message: "Invalid Blume config.",
93
+ severity: "error" as const,
94
+ };
95
+ // Surface every issue in one failing run — reporting only the first turns
96
+ // a three-mistake config into three fix-rerun-fail loops.
88
97
  throw new BlumeError(
89
- diagnostics[0] ?? {
90
- code: "BLUME_CONFIG_INVALID",
91
- file: sourceFile ?? undefined,
92
- message: "Invalid Blume config.",
93
- severity: "error",
94
- }
98
+ rest.length > 0
99
+ ? {
100
+ ...primary,
101
+ message: `${primary.message}\n${rest.length} more config issue(s):\n${rest.map((d) => ` - ${d.message}`).join("\n")}`,
102
+ }
103
+ : primary
95
104
  );
96
105
  }
97
106
 
package/src/core/graph.ts CHANGED
@@ -91,7 +91,12 @@ export const buildContentGraph = (
91
91
  navigationByLocale[code] = buildNavigation(localePages, {
92
92
  chromeVariants: options.navigation.chromeVariants,
93
93
  folderMeta: options.folderMeta,
94
- metaPrefix: code === i18n.defaultLocale ? "" : code,
94
+ // Meta files live in locale directories only under the `dir` parser
95
+ // (`fr/guides/meta.ts` -> key `fr/guides`). Under `dot`, translations
96
+ // sit next to the originals and `guides/meta.ts` applies to every
97
+ // locale — prefixing would look up keys that can never exist.
98
+ metaPrefix:
99
+ i18n.parser === "dir" && code !== i18n.defaultLocale ? code : "",
95
100
  refByLogical: true,
96
101
  selectors: options.navigation.selectors,
97
102
  sharedFolderMeta: options.sharedFolderMeta,
@@ -255,7 +255,11 @@ const normalizeRef = (ref: string): string => {
255
255
  return "/";
256
256
  }
257
257
  const withSlash = ref.startsWith("/") ? ref : `/${ref}`;
258
- return withSlash.endsWith("/index") ? withSlash.slice(0, -6) : withSlash;
258
+ const trimmed = withSlash.endsWith("/index")
259
+ ? withSlash.slice(0, -"/index".length)
260
+ : withSlash;
261
+ // "/index" trims to "" — that's the root, not an empty route.
262
+ return trimmed === "" ? "/" : trimmed;
259
263
  };
260
264
 
261
265
  const routeForRef = (
@@ -7,6 +7,11 @@ import { glob } from "tinyglobby";
7
7
  import { BlumeError } from "../diagnostics.ts";
8
8
  import matter from "../frontmatter.ts";
9
9
  import type { ContentSource, SourceEntry, SourceLoadResult } from "./types.ts";
10
+ import {
11
+ BLUME_WATCH_IGNORE_DIRS,
12
+ excludeDirSegments,
13
+ ignoringWatchListener,
14
+ } from "./watch.ts";
10
15
 
11
16
  /** Options for the built-in filesystem source. */
12
17
  export interface FilesystemSourceOptions {
@@ -75,13 +80,26 @@ export const filesystemSource = (
75
80
  }
76
81
  };
77
82
 
83
+ // When `content.root` is the project root (a migrated `.`-rooted project),
84
+ // the recursive dev watcher would otherwise see Blume's own `.blume/` output
85
+ // and loop; skip it, VCS/dependency trees, and every excluded dir so the
86
+ // watcher stays in sync with what `load()` globs. See {@link ignoringWatchListener}.
87
+ const watchIgnoreDirs = new Set([
88
+ ...BLUME_WATCH_IGNORE_DIRS,
89
+ ...excludeDirSegments(options.exclude),
90
+ ]);
91
+
78
92
  const watch = (onChange: () => void): (() => void) => {
79
93
  if (!existsSync(contentRoot)) {
80
94
  return () => {
81
95
  // Nothing to dispose when the root doesn't exist yet.
82
96
  };
83
97
  }
84
- const watcher = fsWatch(contentRoot, { recursive: true }, onChange);
98
+ const watcher = fsWatch(
99
+ contentRoot,
100
+ { recursive: true },
101
+ ignoringWatchListener(onChange, watchIgnoreDirs)
102
+ );
85
103
  return () => watcher.close();
86
104
  };
87
105
 
@@ -91,9 +91,23 @@ interface RemoteRef {
91
91
  editUrl?: string;
92
92
  }
93
93
 
94
- const githubHeaders = (): Record<string, string> => {
94
+ // Hosts the GITHUB_TOKEN may be sent to. A configured `url` base can point at
95
+ // any server, and leaking the token there would hand a repo credential to an
96
+ // arbitrary third party.
97
+ const GITHUB_HOSTS = new Set(["api.github.com", "raw.githubusercontent.com"]);
98
+
99
+ const githubHeaders = (url: string): Record<string, string> => {
95
100
  const token = process.env.GITHUB_TOKEN;
96
- return token ? { authorization: `Bearer ${token}` } : {};
101
+ if (!token) {
102
+ return {};
103
+ }
104
+ let host = "";
105
+ try {
106
+ host = new URL(url).hostname;
107
+ } catch {
108
+ return {};
109
+ }
110
+ return GITHUB_HOSTS.has(host) ? { authorization: `Bearer ${token}` } : {};
97
111
  };
98
112
 
99
113
  interface GithubTreeEntry {
@@ -110,7 +124,7 @@ const enumerateGithub = async (
110
124
  const { owner, repo, ref } = github;
111
125
  const base = github.path.replaceAll(/^\/|\/$/gu, "");
112
126
  const treeUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${ref}?recursive=1`;
113
- const res = await doFetch(treeUrl, { headers: githubHeaders() });
127
+ const res = await doFetch(treeUrl, { headers: githubHeaders(treeUrl) });
114
128
  if (!res.ok) {
115
129
  throw new Error(`${treeUrl} -> ${res.status}`);
116
130
  }
@@ -172,7 +186,9 @@ export const mdxRemoteSource = (
172
186
  };
173
187
 
174
188
  const fetchEntry = async (item: RemoteRef): Promise<SourceEntry> => {
175
- const res = await doFetch(item.fetchUrl, { headers: githubHeaders() });
189
+ const res = await doFetch(item.fetchUrl, {
190
+ headers: githubHeaders(item.fetchUrl),
191
+ });
176
192
  if (!res.ok) {
177
193
  throw new Error(`${item.fetchUrl} -> ${res.status}`);
178
194
  }
@@ -10,6 +10,7 @@ import { BlumeError } from "../diagnostics.ts";
10
10
  import matter from "../frontmatter.ts";
11
11
  import type { Diagnostic } from "../types.ts";
12
12
  import type { ContentSource, SourceEntry, SourceLoadResult } from "./types.ts";
13
+ import { BLUME_WATCH_IGNORE_DIRS, ignoringWatchListener } from "./watch.ts";
13
14
 
14
15
  /** Options for the Mintlify bridge content source. */
15
16
  export interface MintlifySourceOptions {
@@ -44,39 +45,24 @@ const MINTLIFY_SOURCE_IGNORES = [
44
45
  ];
45
46
 
46
47
  /**
47
- * Directory names the recursive dev watcher must ignore. In bridge mode the
48
- * content root is the project root, so a naive recursive `fs.watch` also sees
49
- * Blume's own `.blume/` output which the dev server rewrites on every request
50
- * (`.blume/.astro/data-store.json`). Left unfiltered, each such write re-triggers
51
- * a full rescan + runtime regeneration, whose writes land back under `.blume/`
52
- * and fire the watcher again: a self-sustaining storm that stalls page renders
53
- * and floods the console. `fs.watch` has no ignore option, so we filter by the
54
- * changed path in the callback. Derived from {@link MINTLIFY_SOURCE_IGNORES}
55
- * (dir prefixes) plus VCS metadata.
48
+ * Directory names the recursive dev watcher must ignore, on top of the shared
49
+ * {@link BLUME_WATCH_IGNORE_DIRS} (Blume's own `.blume/` output, VCS,
50
+ * dependencies). Derived from {@link MINTLIFY_SOURCE_IGNORES} so bridge mode's
51
+ * watcher stays in sync with what its scan skips (snippets, build output, …).
56
52
  */
57
- const WATCH_IGNORE_DIRS = new Set([
53
+ const WATCH_IGNORE_DIRS = [
54
+ ...BLUME_WATCH_IGNORE_DIRS,
58
55
  ...MINTLIFY_SOURCE_IGNORES.map((pattern) => pattern.replace(/\/\*\*$/u, "")),
59
- ".git",
60
- ]);
56
+ ];
61
57
 
62
58
  /**
63
- * Build the recursive-watch listener: fire `onChange` for content changes but
64
- * ignore events whose path crosses a {@link WATCH_IGNORE_DIRS} segment (Blume's
65
- * own `.blume/` output, `node_modules`, VCS metadata, …). A missing `filename`
66
- * — rare; the platform couldn't name the changed path — falls through to
67
- * regenerate rather than silently dropping a real edit. Exported for testing.
59
+ * Build the recursive-watch listener for the bridge source: ignore events under
60
+ * {@link WATCH_IGNORE_DIRS} so the dev server's `.blume/` writes don't feed a
61
+ * regeneration loop. Exported for testing.
68
62
  */
69
- export const mintlifyWatchListener =
70
- (onChange: () => void): WatchListener<string> =>
71
- (_event, filename) => {
72
- if (
73
- typeof filename === "string" &&
74
- filename.split(/[/\\]/u).some((segment) => WATCH_IGNORE_DIRS.has(segment))
75
- ) {
76
- return;
77
- }
78
- onChange();
79
- };
63
+ export const mintlifyWatchListener = (
64
+ onChange: () => void
65
+ ): WatchListener<string> => ignoringWatchListener(onChange, WATCH_IGNORE_DIRS);
80
66
 
81
67
  /**
82
68
  * The Mintlify bridge content source. Reads an unconverted Mintlify project in