blume 0.5.1 → 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 (60) hide show
  1. package/dist/cli/index.js +2996 -2762
  2. package/dist/cli/index.js.map +35 -32
  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/generate.ts +11 -1
  15. package/src/astro/static-assets.ts +10 -3
  16. package/src/astro/templates.ts +87 -29
  17. package/src/cli/coalesce.ts +43 -0
  18. package/src/cli/commands/dev.ts +31 -17
  19. package/src/cli/commands/init.ts +2 -27
  20. package/src/cli/dev-lock.ts +4 -2
  21. package/src/components/content/ColorItem.astro +6 -3
  22. package/src/components/content/Prompt.astro +7 -3
  23. package/src/components/content/Tabs.astro +13 -2
  24. package/src/components/content/mermaid-element.ts +20 -2
  25. package/src/components/islands/ask-ai.tsx +4 -8
  26. package/src/components/islands/base-path.ts +30 -0
  27. package/src/components/islands/hooks.ts +12 -8
  28. package/src/components/layout/PageActions.astro +17 -11
  29. package/src/components/layout/Search.astro +4 -1
  30. package/src/components/layout/search/types.ts +16 -5
  31. package/src/components/openapi/ParametersTable.astro +1 -1
  32. package/src/components/openapi/SchemaProperty.astro +1 -1
  33. package/src/components/openapi/SchemaTable.astro +3 -3
  34. package/src/components/openapi/helpers.ts +17 -8
  35. package/src/components/openapi/snippets.ts +17 -4
  36. package/src/core/config.ts +15 -6
  37. package/src/core/graph.ts +6 -1
  38. package/src/core/navigation.ts +5 -1
  39. package/src/core/package-json.ts +32 -0
  40. package/src/core/sources/filesystem.ts +19 -1
  41. package/src/core/sources/mdx-remote.ts +20 -4
  42. package/src/core/sources/mintlify.ts +30 -1
  43. package/src/core/sources/normalize.ts +28 -6
  44. package/src/core/sources/watch.ts +44 -0
  45. package/src/markdown/code-title.ts +6 -3
  46. package/src/markdown/package-install.ts +3 -1
  47. package/src/migrate/fumadocs/content.ts +3 -5
  48. package/src/migrate/fumadocs/index.ts +24 -9
  49. package/src/migrate/mintlify/config.ts +2 -6
  50. package/src/migrate/mintlify/index.ts +143 -32
  51. package/src/migrate/mintlify/snippets.ts +17 -8
  52. package/src/migrate/nextra/index.ts +16 -1
  53. package/src/migrate/shared.ts +101 -5
  54. package/src/migrate/starlight/content.ts +3 -6
  55. package/src/og/card.ts +16 -4
  56. package/src/openapi/render-mdx.ts +10 -1
  57. package/src/search/sync/orama-cloud.ts +2 -0
  58. package/src/search/sync/typesense.ts +4 -0
  59. package/src/theme/icons.ts +13 -4
  60. package/src/theme/palette.ts +38 -17
@@ -5,35 +5,10 @@ import { defineCommand } from "citty";
5
5
  import { basename, dirname, isAbsolute, join, relative } from "pathe";
6
6
 
7
7
  import { ensureGitignore } from "../../core/gitignore.ts";
8
- import { getBlumeVersion } from "../../core/version.ts";
8
+ import { blumePackageJson, toPackageName } from "../../core/package-json.ts";
9
9
  import { eject } from "../../registry/eject.ts";
10
10
  import { logger } from "../log.ts";
11
11
 
12
- /**
13
- * Derive a valid npm package name from a directory name, falling back to
14
- * `docs` when nothing usable remains.
15
- */
16
- const toPackageName = (raw: string): string =>
17
- raw
18
- .toLowerCase()
19
- .replaceAll(/[^a-z0-9._-]+/gu, "-")
20
- .replaceAll(/^[-_.]+|[-_.]+$/gu, "") || "docs";
21
-
22
- const packageTemplate = (name: string, version: string): string => `{
23
- "name": ${JSON.stringify(name)},
24
- "private": true,
25
- "type": "module",
26
- "scripts": {
27
- "dev": "blume dev",
28
- "build": "blume build",
29
- "doctor": "blume doctor"
30
- },
31
- "dependencies": {
32
- "blume": "^${version}"
33
- }
34
- }
35
- `;
36
-
37
12
  const TEMPLATES = ["docs", "api", "sdk", "changelog"] as const;
38
13
  type Template = (typeof TEMPLATES)[number];
39
14
 
@@ -221,7 +196,7 @@ export const initCommand = defineCommand({
221
196
  const starter = STARTERS[template];
222
197
  const createdPackage = await writeFileSafe(
223
198
  join(root, "package.json"),
224
- packageTemplate(toPackageName(basename(root)), getBlumeVersion())
199
+ blumePackageJson(toPackageName(basename(root)))
225
200
  );
226
201
  await writeFileSafe(join(root, "blume.config.ts"), starter.config);
227
202
  await Promise.all(
@@ -38,8 +38,10 @@ export const isDevLocked = (outDir: string): boolean => {
38
38
  // Signal 0 probes liveness without actually signaling the process.
39
39
  process.kill(pid, 0);
40
40
  return true;
41
- } catch {
42
- return false;
41
+ } catch (error) {
42
+ // EPERM means the process exists but belongs to another user — still
43
+ // live, so the lock must hold (only ESRCH proves it's gone).
44
+ return (error as NodeJS.ErrnoException).code === "EPERM";
43
45
  }
44
46
  };
45
47
 
@@ -46,7 +46,8 @@ const displayValue =
46
46
  </button>
47
47
 
48
48
  <style>
49
- :global(.dark) [data-blume-color-swatch] {
49
+ /* Dark mode is data-theme="dark" on <html> (see theme/entry.ts), not a class. */
50
+ :global([data-theme="dark"]) [data-blume-color-swatch] {
50
51
  background: var(--blume-color-dark, var(--blume-color-light));
51
52
  }
52
53
  </style>
@@ -77,10 +78,12 @@ const displayValue =
77
78
  return;
78
79
  }
79
80
 
80
- const original = status.textContent;
81
+ // Remember the real value once — capturing at click time would capture
82
+ // "Copied" on a double-click and stick until reload.
83
+ status.dataset.blumeLabel ??= status.textContent ?? "";
81
84
  status.textContent = "Copied";
82
85
  window.setTimeout(() => {
83
- status.textContent = original;
86
+ status.textContent = status.dataset.blumeLabel ?? "";
84
87
  }, 1500);
85
88
  });
86
89
  }
@@ -110,14 +110,18 @@ const secondaryButton =
110
110
  return;
111
111
  }
112
112
 
113
- const label = copy.querySelector("[data-blume-prompt-copy-label]");
113
+ const label = copy.querySelector<HTMLElement>(
114
+ "[data-blume-prompt-copy-label]"
115
+ );
114
116
  if (!label) {
115
117
  return;
116
118
  }
117
- const previous = label.textContent ?? "Copy prompt";
119
+ // Remember the real label once capturing at click time would
120
+ // capture "Copied" on a double-click and stick until reload.
121
+ label.dataset.blumeLabel ??= label.textContent ?? "Copy prompt";
118
122
  label.textContent = "Copied";
119
123
  setTimeout(() => {
120
- label.textContent = previous;
124
+ label.textContent = label.dataset.blumeLabel ?? "Copy prompt";
121
125
  }, 1500);
122
126
  });
123
127
  }
@@ -3,7 +3,7 @@ interface Props {
3
3
  borderBottom?: boolean;
4
4
  defaultTabIndex?: number;
5
5
  dropdown?: boolean;
6
- hash?: boolean;
6
+ hash?: boolean | "false" | "true";
7
7
  sync?: boolean;
8
8
  }
9
9
 
@@ -14,6 +14,10 @@ const {
14
14
  hash = true,
15
15
  sync = true,
16
16
  } = Astro.props;
17
+
18
+ // MDX string attributes (`hash="false"` from generated markup) must read as
19
+ // their boolean meaning — the string "false" is truthy.
20
+ const hashEnabled = String(hash) !== "false";
17
21
  ---
18
22
 
19
23
  <blume-tabs
@@ -21,7 +25,7 @@ const {
21
25
  data-border-bottom={borderBottom ? "true" : "false"}
22
26
  data-default-tab-index={defaultTabIndex}
23
27
  data-dropdown={dropdown ? "true" : "false"}
24
- data-hash={hash ? "true" : "false"}
28
+ data-hash={hashEnabled ? "true" : "false"}
25
29
  data-sync={sync ? "true" : "false"}
26
30
  >
27
31
  <div class:list={[dropdown ? "" : "overflow-x-auto"]}>
@@ -100,6 +104,13 @@ const {
100
104
  return;
101
105
  }
102
106
  if (this.dataset.blumeTabsReady === "true") {
107
+ // The built DOM survives a move; the listeners removed by
108
+ // disconnectedCallback don't — re-attach them (addEventListener with
109
+ // the same reference is idempotent).
110
+ if (this.#syncEnabled()) {
111
+ document.addEventListener(SYNC_EVENT, this.#sync);
112
+ }
113
+ window.addEventListener("hashchange", this.#hashChange);
103
114
  return;
104
115
  }
105
116
  this.dataset.blumeTabsReady = "true";
@@ -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 = (
@@ -0,0 +1,32 @@
1
+ import { getBlumeVersion } from "./version.ts";
2
+
3
+ /**
4
+ * Derive a valid npm package name from a directory name, falling back to
5
+ * `docs` when nothing usable remains.
6
+ */
7
+ export const toPackageName = (raw: string): string =>
8
+ raw
9
+ .toLowerCase()
10
+ .replaceAll(/[^a-z0-9._-]+/gu, "-")
11
+ .replaceAll(/^[-_.]+|[-_.]+$/gu, "") || "docs";
12
+
13
+ /**
14
+ * A minimal, runnable `package.json` body for a Blume project: the `blume`
15
+ * dependency pinned to the installed version plus `dev`/`build`/`doctor`
16
+ * scripts, so `npm install && npm run dev` works immediately. Shared by
17
+ * `blume init` and the migrators, which scaffold one when a project has none.
18
+ */
19
+ export const blumePackageJson = (name: string): string => `{
20
+ "name": ${JSON.stringify(name)},
21
+ "private": true,
22
+ "type": "module",
23
+ "scripts": {
24
+ "dev": "blume dev",
25
+ "build": "blume build",
26
+ "doctor": "blume doctor"
27
+ },
28
+ "dependencies": {
29
+ "blume": "^${getBlumeVersion()}"
30
+ }
31
+ }
32
+ `;