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.
- package/dist/cli/index.js +2755 -2564
- package/dist/cli/index.js.map +33 -31
- package/dist/types/core/package-json.d.ts +12 -0
- package/dist/types/migrate/shared.d.ts +153 -0
- package/docs/advanced/migrate.mdx +1 -0
- package/docs/configuration/ai.mdx +1 -1
- package/docs/configuration/theming.mdx +1 -1
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/sources.mdx +1 -1
- package/package.json +1 -1
- package/src/ai/mcp/discovery.ts +3 -1
- package/src/ai/mcp/server.ts +3 -1
- package/src/astro/component-slots.ts +10 -2
- package/src/astro/static-assets.ts +10 -3
- package/src/astro/templates.ts +53 -21
- package/src/cli/coalesce.ts +43 -0
- package/src/cli/commands/dev.ts +31 -17
- package/src/cli/dev-lock.ts +4 -2
- package/src/components/content/ColorItem.astro +6 -3
- package/src/components/content/Prompt.astro +7 -3
- package/src/components/content/Tabs.astro +13 -2
- package/src/components/content/mermaid-element.ts +20 -2
- package/src/components/islands/ask-ai.tsx +4 -8
- package/src/components/islands/base-path.ts +30 -0
- package/src/components/islands/hooks.ts +12 -8
- package/src/components/layout/PageActions.astro +17 -11
- package/src/components/layout/Search.astro +4 -1
- package/src/components/layout/search/types.ts +16 -5
- package/src/components/openapi/ParametersTable.astro +1 -1
- package/src/components/openapi/SchemaProperty.astro +1 -1
- package/src/components/openapi/SchemaTable.astro +3 -3
- package/src/components/openapi/helpers.ts +17 -8
- package/src/components/openapi/snippets.ts +17 -4
- package/src/core/config.ts +15 -6
- package/src/core/graph.ts +6 -1
- package/src/core/navigation.ts +5 -1
- package/src/core/sources/filesystem.ts +19 -1
- package/src/core/sources/mdx-remote.ts +20 -4
- package/src/core/sources/mintlify.ts +14 -28
- package/src/core/sources/normalize.ts +28 -6
- package/src/core/sources/watch.ts +44 -0
- package/src/markdown/code-title.ts +6 -3
- package/src/markdown/package-install.ts +3 -1
- package/src/migrate/fumadocs/content.ts +3 -5
- package/src/migrate/fumadocs/index.ts +24 -9
- package/src/migrate/mintlify/config.ts +2 -6
- package/src/migrate/mintlify/index.ts +119 -32
- package/src/migrate/mintlify/snippets.ts +17 -8
- package/src/migrate/nextra/index.ts +16 -1
- package/src/migrate/shared.ts +77 -4
- package/src/migrate/starlight/content.ts +3 -6
- package/src/og/card.ts +16 -4
- package/src/openapi/render-mdx.ts +10 -1
- package/src/search/sync/orama-cloud.ts +2 -0
- package/src/search/sync/typesense.ts +4 -0
- package/src/theme/icons.ts +13 -4
- 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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
329
|
-
if (el) {
|
|
330
|
-
|
|
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
|
|
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
|
-
/**
|
|
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 (`&` 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
|
|
86
|
+
return escapeHtml(text);
|
|
83
87
|
}
|
|
84
|
-
const pattern = new RegExp(`(
|
|
85
|
-
return
|
|
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
|
|
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
|
|
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 ?? {}
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
144
|
-
.
|
|
145
|
-
|
|
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}"`];
|
package/src/core/config.ts
CHANGED
|
@@ -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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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,
|
package/src/core/navigation.ts
CHANGED
|
@@ -255,7 +255,11 @@ const normalizeRef = (ref: string): string => {
|
|
|
255
255
|
return "/";
|
|
256
256
|
}
|
|
257
257
|
const withSlash = ref.startsWith("/") ? ref : `/${ref}`;
|
|
258
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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 =
|
|
53
|
+
const WATCH_IGNORE_DIRS = [
|
|
54
|
+
...BLUME_WATCH_IGNORE_DIRS,
|
|
58
55
|
...MINTLIFY_SOURCE_IGNORES.map((pattern) => pattern.replace(/\/\*\*$/u, "")),
|
|
59
|
-
|
|
60
|
-
]);
|
|
56
|
+
];
|
|
61
57
|
|
|
62
58
|
/**
|
|
63
|
-
* Build the recursive-watch listener
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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
|
-
|
|
71
|
-
|
|
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
|