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.
- package/dist/cli/index.js +2996 -2762
- package/dist/cli/index.js.map +35 -32
- 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/generate.ts +11 -1
- package/src/astro/static-assets.ts +10 -3
- package/src/astro/templates.ts +87 -29
- package/src/cli/coalesce.ts +43 -0
- package/src/cli/commands/dev.ts +31 -17
- package/src/cli/commands/init.ts +2 -27
- 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/package-json.ts +32 -0
- package/src/core/sources/filesystem.ts +19 -1
- package/src/core/sources/mdx-remote.ts +20 -4
- package/src/core/sources/mintlify.ts +30 -1
- 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 +143 -32
- package/src/migrate/mintlify/snippets.ts +17 -8
- package/src/migrate/nextra/index.ts +16 -1
- package/src/migrate/shared.ts +101 -5
- 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
|
@@ -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
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, watch as fsWatch } from "node:fs";
|
|
2
|
+
import type { WatchListener } from "node:fs";
|
|
2
3
|
import { readFile } from "node:fs/promises";
|
|
3
4
|
|
|
4
5
|
import { isAbsolute, join, relative, resolve } from "pathe";
|
|
@@ -9,6 +10,7 @@ import { BlumeError } from "../diagnostics.ts";
|
|
|
9
10
|
import matter from "../frontmatter.ts";
|
|
10
11
|
import type { Diagnostic } from "../types.ts";
|
|
11
12
|
import type { ContentSource, SourceEntry, SourceLoadResult } from "./types.ts";
|
|
13
|
+
import { BLUME_WATCH_IGNORE_DIRS, ignoringWatchListener } from "./watch.ts";
|
|
12
14
|
|
|
13
15
|
/** Options for the Mintlify bridge content source. */
|
|
14
16
|
export interface MintlifySourceOptions {
|
|
@@ -42,6 +44,26 @@ const MINTLIFY_SOURCE_IGNORES = [
|
|
|
42
44
|
"snippets/**",
|
|
43
45
|
];
|
|
44
46
|
|
|
47
|
+
/**
|
|
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, …).
|
|
52
|
+
*/
|
|
53
|
+
const WATCH_IGNORE_DIRS = [
|
|
54
|
+
...BLUME_WATCH_IGNORE_DIRS,
|
|
55
|
+
...MINTLIFY_SOURCE_IGNORES.map((pattern) => pattern.replace(/\/\*\*$/u, "")),
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
/**
|
|
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.
|
|
62
|
+
*/
|
|
63
|
+
export const mintlifyWatchListener = (
|
|
64
|
+
onChange: () => void
|
|
65
|
+
): WatchListener<string> => ignoringWatchListener(onChange, WATCH_IGNORE_DIRS);
|
|
66
|
+
|
|
45
67
|
/**
|
|
46
68
|
* The Mintlify bridge content source. Reads an unconverted Mintlify project in
|
|
47
69
|
* place and transforms each page to Blume MDX at scan time (callouts → `:::`
|
|
@@ -126,7 +148,14 @@ export const mintlifySource = (
|
|
|
126
148
|
const watch = (onChange: () => void): (() => void) => {
|
|
127
149
|
const disposers: (() => void)[] = [];
|
|
128
150
|
if (existsSync(contentRoot)) {
|
|
129
|
-
|
|
151
|
+
// Recursively watch the content root, but skip Blume's own output and
|
|
152
|
+
// other non-content trees so the dev server's `.blume/` writes don't feed
|
|
153
|
+
// a regeneration loop (`fs.watch` has no ignore option, so filter here).
|
|
154
|
+
const watcher = fsWatch(
|
|
155
|
+
contentRoot,
|
|
156
|
+
{ recursive: true },
|
|
157
|
+
mintlifyWatchListener(onChange)
|
|
158
|
+
);
|
|
130
159
|
disposers.push(() => watcher.close());
|
|
131
160
|
}
|
|
132
161
|
// Watch docs.json directly: it lives at the content root but a non-recursive
|
|
@@ -59,6 +59,11 @@ const mapRoute = (
|
|
|
59
59
|
const groups: string[] = [];
|
|
60
60
|
|
|
61
61
|
for (const part of rawParts) {
|
|
62
|
+
// A leading/trailing/double slash yields an empty part; keeping it would
|
|
63
|
+
// produce a malformed route (`//foo`, `/foo/`) that nothing can link to.
|
|
64
|
+
if (part === "") {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
62
67
|
const group = groupLabel(part);
|
|
63
68
|
if (group !== null) {
|
|
64
69
|
groups.push(group);
|
|
@@ -113,10 +118,11 @@ export const extractHeadings = (body: string): Heading[] => {
|
|
|
113
118
|
};
|
|
114
119
|
|
|
115
120
|
const MD_LINK = /\[[^\]]*\]\((?<target>[^)\s]+)(?:\s+"[^"]*")?\)/gu;
|
|
121
|
+
const INLINE_CODE = /`[^`]*`/gu;
|
|
116
122
|
|
|
117
123
|
/**
|
|
118
124
|
* Extract link targets from a markdown body for later validation, recording the
|
|
119
|
-
* 1-based line/column of each target. Skips fenced code blocks.
|
|
125
|
+
* 1-based line/column of each target. Skips fenced code blocks and inline code.
|
|
120
126
|
*/
|
|
121
127
|
export const extractLinks = (body: string): PageLink[] => {
|
|
122
128
|
const links: PageLink[] = [];
|
|
@@ -132,7 +138,12 @@ export const extractLinks = (body: string): PageLink[] => {
|
|
|
132
138
|
if (inFence) {
|
|
133
139
|
continue;
|
|
134
140
|
}
|
|
135
|
-
|
|
141
|
+
// Blank out inline code spans (`[label](/x)` shown as syntax, not a link)
|
|
142
|
+
// with same-length padding so recorded columns stay accurate.
|
|
143
|
+
const masked = line.replaceAll(INLINE_CODE, (span) =>
|
|
144
|
+
" ".repeat(span.length)
|
|
145
|
+
);
|
|
146
|
+
for (const match of masked.matchAll(MD_LINK)) {
|
|
136
147
|
const target = match.groups?.target;
|
|
137
148
|
if (target === undefined || match.index === undefined) {
|
|
138
149
|
continue;
|
|
@@ -153,7 +164,6 @@ export const extractLinks = (body: string): PageLink[] => {
|
|
|
153
164
|
return links;
|
|
154
165
|
};
|
|
155
166
|
|
|
156
|
-
const INLINE_CODE = /`[^`]*`/gu;
|
|
157
167
|
// Double-quoted strings hold JSX attribute values and JSON in `{...}` props; a
|
|
158
168
|
// `<Tag>` written inside prose there (e.g. an "Astro <Font> integration" note)
|
|
159
169
|
// isn't a real usage. Single quotes are left alone so prose apostrophes don't
|
|
@@ -207,8 +217,14 @@ const deriveTitle = (
|
|
|
207
217
|
return titleCase(stripNumericPrefix(base.replace(extname(base), "")));
|
|
208
218
|
};
|
|
209
219
|
|
|
210
|
-
|
|
211
|
-
|
|
220
|
+
/** Strip habitual leading/trailing slashes (`/getting-started`, `guides/`). */
|
|
221
|
+
const trimSlashes = (value: string): string =>
|
|
222
|
+
value.replaceAll(/^\/+|\/+$/gu, "");
|
|
223
|
+
|
|
224
|
+
const withPrefix = (prefix: string | undefined, path: string): string => {
|
|
225
|
+
const clean = prefix ? trimSlashes(prefix) : "";
|
|
226
|
+
return clean ? `${clean}/${path}` : path;
|
|
227
|
+
};
|
|
212
228
|
|
|
213
229
|
/**
|
|
214
230
|
* Normalize one source entry into per-locale `PageRecord`s. This is the single
|
|
@@ -254,9 +270,15 @@ export const normalizeEntry = (
|
|
|
254
270
|
: { locales: [""], navPath: entry.ref };
|
|
255
271
|
|
|
256
272
|
const navPath = withPrefix(ctx.source.prefix, rawNavPath);
|
|
273
|
+
// Frontmatter `slug` wins, then the adapter-supplied `entry.slug` (the typed
|
|
274
|
+
// SPI's "logical route input; defaults to ref if omitted"), then the ref.
|
|
275
|
+
// The extension is re-appended so mapRoute's extname strip can't eat a
|
|
276
|
+
// dotted slug segment (`v1.2`). A slug that trims to nothing falls back.
|
|
277
|
+
const slugInput = meta.slug ?? entry.slug;
|
|
278
|
+
const slug = slugInput ? trimSlashes(slugInput) : "";
|
|
257
279
|
const routeInput = withPrefix(
|
|
258
280
|
ctx.source.prefix,
|
|
259
|
-
|
|
281
|
+
slug ? `${slug}${ext}` : rawNavPath
|
|
260
282
|
);
|
|
261
283
|
|
|
262
284
|
const { segments, groups, route: logicalRoute } = mapRoute(routeInput);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { WatchListener } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Directory segments a recursive dev watcher must never react to. When a
|
|
5
|
+
* source's content root is the project root — a migrated `.`-rooted project or a
|
|
6
|
+
* Mintlify bridge — a naive recursive `fs.watch` also sees Blume's own `.blume/`
|
|
7
|
+
* output, which the dev server rewrites on every render (e.g.
|
|
8
|
+
* `.blume/.astro/data-store.json`). Left unfiltered, each such write re-triggers
|
|
9
|
+
* a rescan + runtime regeneration whose writes land back under `.blume/` and
|
|
10
|
+
* fire the watcher again: a self-sustaining loop that stalls page renders and
|
|
11
|
+
* floods the console (and, mid-render, corrupts Astro's dev module graph so
|
|
12
|
+
* `astro:server-app.js` fails to load). `.git`/`node_modules` are here for the
|
|
13
|
+
* same reason — churn that is never page content. `fs.watch` has no ignore
|
|
14
|
+
* option, so we filter by the changed path in the callback.
|
|
15
|
+
*/
|
|
16
|
+
export const BLUME_WATCH_IGNORE_DIRS = [".blume", ".git", "node_modules"];
|
|
17
|
+
|
|
18
|
+
/** Extract single-segment ignore dirs (`foo`) from `foo/**`-style excludes. */
|
|
19
|
+
export const excludeDirSegments = (patterns: readonly string[]): string[] =>
|
|
20
|
+
patterns
|
|
21
|
+
.map((pattern) => /^(?<dir>[^*/]+)\/\*\*$/u.exec(pattern)?.groups?.dir)
|
|
22
|
+
.filter((dir): dir is string => dir !== undefined);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build a recursive-watch listener that fires `onChange` for content changes but
|
|
26
|
+
* ignores events whose path crosses an ignored directory segment. A missing
|
|
27
|
+
* `filename` — rare; the platform couldn't name the changed path — falls through
|
|
28
|
+
* to `onChange` rather than silently dropping a real edit. Exported for testing.
|
|
29
|
+
*/
|
|
30
|
+
export const ignoringWatchListener = (
|
|
31
|
+
onChange: () => void,
|
|
32
|
+
ignoreDirs: Iterable<string> = BLUME_WATCH_IGNORE_DIRS
|
|
33
|
+
): WatchListener<string> => {
|
|
34
|
+
const ignore = new Set(ignoreDirs);
|
|
35
|
+
return (_event, filename) => {
|
|
36
|
+
if (
|
|
37
|
+
typeof filename === "string" &&
|
|
38
|
+
filename.split(/[/\\]/u).some((segment) => ignore.has(segment))
|
|
39
|
+
) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
onChange();
|
|
43
|
+
};
|
|
44
|
+
};
|
|
@@ -25,7 +25,9 @@ export interface CodeTitleTransformer {
|
|
|
25
25
|
pre: (this: CodeMetaContext, node: PreNode) => void;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// The body excludes only the delimiting quote, so `title="foo's file.ts"`
|
|
29
|
+
// (an apostrophe inside double quotes) still matches.
|
|
30
|
+
const TITLE_ATTR = /title=(?:"(?<dq>[^"]*)"|'(?<sq>[^']*)')/u;
|
|
29
31
|
const LINE_NUMBERS = /(?:^|\s)lineNumbers(?=\s|$)/u;
|
|
30
32
|
|
|
31
33
|
const parseTitle = (raw: string | undefined): string | undefined => {
|
|
@@ -33,8 +35,9 @@ const parseTitle = (raw: string | undefined): string | undefined => {
|
|
|
33
35
|
return undefined;
|
|
34
36
|
}
|
|
35
37
|
const explicit = raw.match(TITLE_ATTR);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
const attrTitle = explicit?.groups?.dq ?? explicit?.groups?.sq;
|
|
39
|
+
if (attrTitle) {
|
|
40
|
+
return attrTitle;
|
|
38
41
|
}
|
|
39
42
|
// The first bare token is the title (```ts blume.config.ts), skipping Shiki
|
|
40
43
|
// line ranges (`{1,3-5}`), `key=value` attrs, and the reserved `lineNumbers`
|
|
@@ -31,7 +31,9 @@ export const packageInstallPlugin = () => ({
|
|
|
31
31
|
node,
|
|
32
32
|
jsxFlowElement(
|
|
33
33
|
"Tabs",
|
|
34
|
-
|
|
34
|
+
// hash off: clicking "pnpm" in an install block must not rewrite the
|
|
35
|
+
// page hash (clobbering the heading anchor the reader arrived with).
|
|
36
|
+
[jsxAttribute("hash", "false")],
|
|
35
37
|
PACKAGE_MANAGERS.map((manager) => tabNode(manager, commands[manager]))
|
|
36
38
|
)
|
|
37
39
|
);
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
isInsideRoot,
|
|
10
10
|
renameTag,
|
|
11
11
|
rewriteCallouts,
|
|
12
|
+
stripImports,
|
|
12
13
|
} from "../shared.ts";
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -29,11 +30,8 @@ const FUMADOCS_IMPORT =
|
|
|
29
30
|
* injects its components globally, so these imports would fail to resolve once
|
|
30
31
|
* the Fumadocs packages are gone.
|
|
31
32
|
*/
|
|
32
|
-
export const stripFumadocsImports = (source: string): string =>
|
|
33
|
-
|
|
34
|
-
// Collapse the blank gap a removed import block leaves behind.
|
|
35
|
-
return stripped === source ? source : stripped.replaceAll(/\n{3,}/gu, "\n\n");
|
|
36
|
-
};
|
|
33
|
+
export const stripFumadocsImports = (source: string): string =>
|
|
34
|
+
stripImports(source, FUMADOCS_IMPORT);
|
|
37
35
|
|
|
38
36
|
// ---------------------------------------------------------------------------
|
|
39
37
|
// Callouts
|
|
@@ -114,22 +114,33 @@ const movePage = async (
|
|
|
114
114
|
};
|
|
115
115
|
};
|
|
116
116
|
|
|
117
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* Write a `FolderMeta` to `dest` unless it already exists. `handled` tells the
|
|
119
|
+
* caller whether the source `meta.json` is safe to delete — a skipped write
|
|
120
|
+
* (target exists) must keep the source, or its title/ordering is lost with
|
|
121
|
+
* nowhere to recover it from.
|
|
122
|
+
*/
|
|
118
123
|
const writeMeta = async (
|
|
119
124
|
dest: string,
|
|
120
125
|
meta: FolderMeta,
|
|
121
126
|
rel: string,
|
|
122
127
|
warnings: string[]
|
|
123
|
-
): Promise<string[]> => {
|
|
128
|
+
): Promise<{ handled: boolean; warnings: string[] }> => {
|
|
124
129
|
if (Object.keys(meta).length === 0) {
|
|
125
|
-
return warnings;
|
|
130
|
+
return { handled: true, warnings };
|
|
126
131
|
}
|
|
127
132
|
if (existsSync(dest)) {
|
|
128
|
-
return
|
|
133
|
+
return {
|
|
134
|
+
handled: false,
|
|
135
|
+
warnings: [
|
|
136
|
+
...warnings,
|
|
137
|
+
`Skipped ${rel} (target already exists); the source file was kept — merge it into the existing meta.ts by hand.`,
|
|
138
|
+
],
|
|
139
|
+
};
|
|
129
140
|
}
|
|
130
141
|
await mkdir(dirname(dest), { recursive: true });
|
|
131
142
|
await writeFile(dest, renderMetaModule(meta), "utf-8");
|
|
132
|
-
return warnings;
|
|
143
|
+
return { handled: true, warnings };
|
|
133
144
|
};
|
|
134
145
|
|
|
135
146
|
/** Convert one `meta.json` into a typed `meta.ts`, or relocate it if unparseable. */
|
|
@@ -173,14 +184,18 @@ const convertMeta = async (
|
|
|
173
184
|
...self.warnings,
|
|
174
185
|
...reshape.warnings,
|
|
175
186
|
]);
|
|
176
|
-
|
|
177
|
-
|
|
187
|
+
if (result.handled) {
|
|
188
|
+
await rm(abs, { force: true });
|
|
189
|
+
}
|
|
190
|
+
return result.warnings;
|
|
178
191
|
}
|
|
179
192
|
|
|
180
193
|
const { meta, warnings } = translateFumadocsMeta(parsed);
|
|
181
194
|
const result = await writeMeta(dest, meta, rel, warnings);
|
|
182
|
-
|
|
183
|
-
|
|
195
|
+
if (result.handled) {
|
|
196
|
+
await rm(abs, { force: true });
|
|
197
|
+
}
|
|
198
|
+
return result.warnings;
|
|
184
199
|
};
|
|
185
200
|
|
|
186
201
|
interface PageSummary {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
|
|
3
|
-
import { dirname,
|
|
3
|
+
import { dirname, resolve } from "pathe";
|
|
4
4
|
|
|
5
5
|
import { BlumeError } from "../../core/diagnostics.ts";
|
|
6
6
|
import type {
|
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
SidebarItemConfig,
|
|
11
11
|
} from "../../core/schema.ts";
|
|
12
12
|
import { GOOGLE_FONTS } from "../../theme/fonts.ts";
|
|
13
|
+
import { isInsideRoot } from "../shared.ts";
|
|
13
14
|
|
|
14
15
|
type JsonObject = Record<string, unknown>;
|
|
15
16
|
type NavigationSelectors = ResolvedConfig["navigation"]["selectors"];
|
|
@@ -72,11 +73,6 @@ const withoutUndefined = <T extends JsonObject>(value: T): T =>
|
|
|
72
73
|
const hasOwn = (object: JsonObject, key: string): boolean =>
|
|
73
74
|
Object.hasOwn(object, key);
|
|
74
75
|
|
|
75
|
-
const isInsideRoot = (root: string, candidate: string): boolean => {
|
|
76
|
-
const rel = relative(root, candidate);
|
|
77
|
-
return rel === "" || (!rel.startsWith("..") && !rel.startsWith("/"));
|
|
78
|
-
};
|
|
79
|
-
|
|
80
76
|
const readJsonFile = async (file: string): Promise<unknown> => {
|
|
81
77
|
try {
|
|
82
78
|
return JSON.parse(await readFile(file, "utf-8"));
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
|
|
4
|
-
import { dirname, join } from "pathe";
|
|
4
|
+
import { dirname, join, relative } from "pathe";
|
|
5
5
|
import { glob } from "tinyglobby";
|
|
6
6
|
|
|
7
|
+
import { ensureGitignore } from "../../core/gitignore.ts";
|
|
7
8
|
import type { BlumeConfig } from "../../core/schema.ts";
|
|
9
|
+
import { ensurePackageJson } from "../shared.ts";
|
|
8
10
|
import { assetSegments } from "./assets.ts";
|
|
9
11
|
import { loadMintlifyConfig, partitionMintlifyRedirects } from "./config.ts";
|
|
10
12
|
import { mintlifyI18n } from "./i18n.ts";
|
|
@@ -188,6 +190,56 @@ const applyRelocatedAssets = (
|
|
|
188
190
|
}
|
|
189
191
|
};
|
|
190
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Remove the foreign Mintlify config now that it's been translated into
|
|
195
|
+
* `blume.config.ts`. Leaving `docs.json`/`mint.json` on disk keeps the project a
|
|
196
|
+
* bridge-mode candidate: `detectMintlifyBridge` fires on any later run where a
|
|
197
|
+
* `blume.config.*` is absent (e.g. the config is deleted to re-run the
|
|
198
|
+
* migration), silently serving the *un-migrated* Mintlify project instead of the
|
|
199
|
+
* converted one. Removing it makes the conversion permanent — matching the
|
|
200
|
+
* migrator's promise and how it already deletes inlined snippets.
|
|
201
|
+
*/
|
|
202
|
+
const removeForeignConfig = async (
|
|
203
|
+
root: string,
|
|
204
|
+
warnings: string[]
|
|
205
|
+
): Promise<void> => {
|
|
206
|
+
const removed: string[] = [];
|
|
207
|
+
for (const name of ["docs.json", "mint.json"]) {
|
|
208
|
+
const file = join(root, name);
|
|
209
|
+
if (existsSync(file)) {
|
|
210
|
+
// oxlint-disable-next-line no-await-in-loop -- sequential fs removes
|
|
211
|
+
await rm(file, { force: true });
|
|
212
|
+
removed.push(name);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (removed.length > 0) {
|
|
216
|
+
warnings.push(
|
|
217
|
+
`Removed ${removed.join(", ")} (translated to blume.config.ts) so "blume dev" no longer falls back to Mintlify bridge mode.`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Scaffold the project files a config-only Mintlify repo lacks: a runnable
|
|
224
|
+
* `package.json` (it ships no npm manifest) and a `.gitignore` for Blume's
|
|
225
|
+
* generated `.blume/` runtime and `dist/` build output. Both are idempotent —
|
|
226
|
+
* an existing file is extended, not overwritten — and noted in the warnings.
|
|
227
|
+
*/
|
|
228
|
+
const scaffoldProjectFiles = async (
|
|
229
|
+
root: string,
|
|
230
|
+
warnings: string[]
|
|
231
|
+
): Promise<void> => {
|
|
232
|
+
if (await ensurePackageJson(root)) {
|
|
233
|
+
warnings.push(
|
|
234
|
+
"Created a package.json with blume as a dependency; run `npm install`, then `npm run dev`."
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
const ignored = await ensureGitignore(root, [".blume/", "dist/"]);
|
|
238
|
+
if (ignored.length > 0) {
|
|
239
|
+
warnings.push(`Added ${ignored.join(", ")} to .gitignore.`);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
191
243
|
/**
|
|
192
244
|
* Delete the inlined markdown snippets. Component files (e.g. `.jsx`) are kept
|
|
193
245
|
* because their imports were rewritten to resolve against `/snippets`.
|
|
@@ -222,6 +274,84 @@ const cleanupSnippets = async (
|
|
|
222
274
|
}
|
|
223
275
|
};
|
|
224
276
|
|
|
277
|
+
// Root-absolute asset references in page content (``,
|
|
278
|
+
// `src="/img/x.svg"`). Mintlify serves any top-level dir at the site root, so
|
|
279
|
+
// dirs referenced only by content — not the config — must also be mounted.
|
|
280
|
+
const CONTENT_ASSET_REF =
|
|
281
|
+
/(?:\]\(|src=["'])(?<path>\/[^\s"')]+\.(?:avif|bmp|gif|ico|jpe?g|mov|mp4|pdf|png|svg|webm|webp|zip))/giu;
|
|
282
|
+
|
|
283
|
+
const contentAssetSegments = (content: string): string[] =>
|
|
284
|
+
[...content.matchAll(CONTENT_ASSET_REF)].flatMap((match) => {
|
|
285
|
+
const segment = match.groups?.path?.split("/")[1];
|
|
286
|
+
return segment ? [segment] : [];
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Rewrite every page to idiomatic Blume MDX in place, collecting what was
|
|
291
|
+
* dropped or kept for the migration summary. A page whose transform throws
|
|
292
|
+
* (a dangling snippet import, a snippet cycle) must not abort the whole
|
|
293
|
+
* migration mid-rewrite; it is left as-is with a warning.
|
|
294
|
+
*/
|
|
295
|
+
const rewritePagesInPlace = async (
|
|
296
|
+
files: string[],
|
|
297
|
+
options: {
|
|
298
|
+
root: string;
|
|
299
|
+
variables: Record<string, string>;
|
|
300
|
+
warnings: string[];
|
|
301
|
+
}
|
|
302
|
+
): Promise<{
|
|
303
|
+
moved: number;
|
|
304
|
+
removedKeys: Set<string>;
|
|
305
|
+
unsupported: Set<string>;
|
|
306
|
+
keptComponents: Set<string>;
|
|
307
|
+
assetDirs: Set<string>;
|
|
308
|
+
}> => {
|
|
309
|
+
const { root, variables, warnings } = options;
|
|
310
|
+
let moved = 0;
|
|
311
|
+
const removedKeys = new Set<string>();
|
|
312
|
+
const unsupported = new Set<string>();
|
|
313
|
+
const keptComponents = new Set<string>();
|
|
314
|
+
const assetDirs = new Set<string>();
|
|
315
|
+
for (const file of files) {
|
|
316
|
+
// oxlint-disable-next-line no-await-in-loop -- sequential fs writes
|
|
317
|
+
const raw = await readFile(file, "utf-8");
|
|
318
|
+
let result: Awaited<ReturnType<typeof transformMintlifyContent>>;
|
|
319
|
+
try {
|
|
320
|
+
// oxlint-disable-next-line no-await-in-loop -- sequential transforms
|
|
321
|
+
result = await transformMintlifyContent(raw, {
|
|
322
|
+
filePath: file,
|
|
323
|
+
root,
|
|
324
|
+
variables,
|
|
325
|
+
});
|
|
326
|
+
} catch (error) {
|
|
327
|
+
warnings.push(
|
|
328
|
+
`Skipped rewriting ${relative(root, file)}: ${(error as Error).message}. The page was left unconverted — fix it by hand.`
|
|
329
|
+
);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (result.content !== raw) {
|
|
333
|
+
// oxlint-disable-next-line no-await-in-loop -- sequential fs writes
|
|
334
|
+
await mkdir(dirname(file), { recursive: true });
|
|
335
|
+
// oxlint-disable-next-line no-await-in-loop -- sequential fs writes
|
|
336
|
+
await writeFile(file, result.content, "utf-8");
|
|
337
|
+
}
|
|
338
|
+
for (const key of result.removed) {
|
|
339
|
+
removedKeys.add(key);
|
|
340
|
+
}
|
|
341
|
+
for (const name of result.unsupported) {
|
|
342
|
+
unsupported.add(name);
|
|
343
|
+
}
|
|
344
|
+
for (const name of result.components) {
|
|
345
|
+
keptComponents.add(name);
|
|
346
|
+
}
|
|
347
|
+
for (const segment of contentAssetSegments(result.content)) {
|
|
348
|
+
assetDirs.add(segment);
|
|
349
|
+
}
|
|
350
|
+
moved += 1;
|
|
351
|
+
}
|
|
352
|
+
return { assetDirs, keptComponents, moved, removedKeys, unsupported };
|
|
353
|
+
};
|
|
354
|
+
|
|
225
355
|
/**
|
|
226
356
|
* Migrate a Mintlify project to Blume: translate `docs.json`/`mint.json` into
|
|
227
357
|
* `blume.config.ts`, rewrite every page to idiomatic Blume MDX in place, and
|
|
@@ -285,38 +415,15 @@ export const migrateMintlifyProject = async (
|
|
|
285
415
|
],
|
|
286
416
|
});
|
|
287
417
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const unsupported = new Set<string>();
|
|
291
|
-
const keptComponents = new Set<string>();
|
|
292
|
-
for (const file of files) {
|
|
293
|
-
// oxlint-disable-next-line no-await-in-loop -- sequential fs writes
|
|
294
|
-
const raw = await readFile(file, "utf-8");
|
|
295
|
-
// oxlint-disable-next-line no-await-in-loop -- sequential transforms
|
|
296
|
-
const result = await transformMintlifyContent(raw, {
|
|
297
|
-
filePath: file,
|
|
298
|
-
root,
|
|
299
|
-
variables,
|
|
300
|
-
});
|
|
301
|
-
if (result.content !== raw) {
|
|
302
|
-
// oxlint-disable-next-line no-await-in-loop -- sequential fs writes
|
|
303
|
-
await mkdir(dirname(file), { recursive: true });
|
|
304
|
-
// oxlint-disable-next-line no-await-in-loop -- sequential fs writes
|
|
305
|
-
await writeFile(file, result.content, "utf-8");
|
|
306
|
-
}
|
|
307
|
-
for (const key of result.removed) {
|
|
308
|
-
removedKeys.add(key);
|
|
309
|
-
}
|
|
310
|
-
for (const name of result.unsupported) {
|
|
311
|
-
unsupported.add(name);
|
|
312
|
-
}
|
|
313
|
-
for (const name of result.components) {
|
|
314
|
-
keptComponents.add(name);
|
|
315
|
-
}
|
|
316
|
-
moved += 1;
|
|
317
|
-
}
|
|
418
|
+
const { moved, removedKeys, unsupported, keptComponents, assetDirs } =
|
|
419
|
+
await rewritePagesInPlace(files, { root, variables, warnings });
|
|
318
420
|
|
|
319
|
-
|
|
421
|
+
// Config-referenced segments (logo/favicon/backgrounds + /images) plus dirs
|
|
422
|
+
// referenced only by page content — Mintlify serves every top-level dir at
|
|
423
|
+
// the site root, so both kinds must stay resolvable after migration.
|
|
424
|
+
const assets = await relocateAssets(root, [
|
|
425
|
+
...new Set([...assetSegments(config), ...assetDirs]),
|
|
426
|
+
]);
|
|
320
427
|
await cleanupSnippets(root, keptComponents, warnings);
|
|
321
428
|
|
|
322
429
|
if (config.content?.exclude) {
|
|
@@ -324,6 +431,10 @@ export const migrateMintlifyProject = async (
|
|
|
324
431
|
}
|
|
325
432
|
applyRelocatedAssets(config, assets, warnings);
|
|
326
433
|
await writeBlumeConfig(root, config);
|
|
434
|
+
// Drop the source config only after the Blume config is safely on disk, so a
|
|
435
|
+
// mid-migration failure never leaves the project with neither.
|
|
436
|
+
await removeForeignConfig(root, warnings);
|
|
437
|
+
await scaffoldProjectFiles(root, warnings);
|
|
327
438
|
|
|
328
439
|
if (Object.keys(variables).length > 0) {
|
|
329
440
|
warnings.push(
|