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
|
@@ -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,7 +1,7 @@
|
|
|
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
7
|
import { ensureGitignore } from "../../core/gitignore.ts";
|
|
@@ -190,6 +190,35 @@ const applyRelocatedAssets = (
|
|
|
190
190
|
}
|
|
191
191
|
};
|
|
192
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
|
+
|
|
193
222
|
/**
|
|
194
223
|
* Scaffold the project files a config-only Mintlify repo lacks: a runnable
|
|
195
224
|
* `package.json` (it ships no npm manifest) and a `.gitignore` for Blume's
|
|
@@ -245,6 +274,84 @@ const cleanupSnippets = async (
|
|
|
245
274
|
}
|
|
246
275
|
};
|
|
247
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
|
+
|
|
248
355
|
/**
|
|
249
356
|
* Migrate a Mintlify project to Blume: translate `docs.json`/`mint.json` into
|
|
250
357
|
* `blume.config.ts`, rewrite every page to idiomatic Blume MDX in place, and
|
|
@@ -308,38 +415,15 @@ export const migrateMintlifyProject = async (
|
|
|
308
415
|
],
|
|
309
416
|
});
|
|
310
417
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
const unsupported = new Set<string>();
|
|
314
|
-
const keptComponents = 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
|
-
// oxlint-disable-next-line no-await-in-loop -- sequential transforms
|
|
319
|
-
const result = await transformMintlifyContent(raw, {
|
|
320
|
-
filePath: file,
|
|
321
|
-
root,
|
|
322
|
-
variables,
|
|
323
|
-
});
|
|
324
|
-
if (result.content !== raw) {
|
|
325
|
-
// oxlint-disable-next-line no-await-in-loop -- sequential fs writes
|
|
326
|
-
await mkdir(dirname(file), { recursive: true });
|
|
327
|
-
// oxlint-disable-next-line no-await-in-loop -- sequential fs writes
|
|
328
|
-
await writeFile(file, result.content, "utf-8");
|
|
329
|
-
}
|
|
330
|
-
for (const key of result.removed) {
|
|
331
|
-
removedKeys.add(key);
|
|
332
|
-
}
|
|
333
|
-
for (const name of result.unsupported) {
|
|
334
|
-
unsupported.add(name);
|
|
335
|
-
}
|
|
336
|
-
for (const name of result.components) {
|
|
337
|
-
keptComponents.add(name);
|
|
338
|
-
}
|
|
339
|
-
moved += 1;
|
|
340
|
-
}
|
|
418
|
+
const { moved, removedKeys, unsupported, keptComponents, assetDirs } =
|
|
419
|
+
await rewritePagesInPlace(files, { root, variables, warnings });
|
|
341
420
|
|
|
342
|
-
|
|
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
|
+
]);
|
|
343
427
|
await cleanupSnippets(root, keptComponents, warnings);
|
|
344
428
|
|
|
345
429
|
if (config.content?.exclude) {
|
|
@@ -347,6 +431,9 @@ export const migrateMintlifyProject = async (
|
|
|
347
431
|
}
|
|
348
432
|
applyRelocatedAssets(config, assets, warnings);
|
|
349
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);
|
|
350
437
|
await scaffoldProjectFiles(root, warnings);
|
|
351
438
|
|
|
352
439
|
if (Object.keys(variables).length > 0) {
|
|
@@ -3,6 +3,7 @@ import { readFile as readFileFromDisk } from "node:fs/promises";
|
|
|
3
3
|
import { dirname, relative, resolve } from "pathe";
|
|
4
4
|
|
|
5
5
|
import matter from "../../core/frontmatter.ts";
|
|
6
|
+
import { isInsideRoot, stripImports } from "../shared.ts";
|
|
6
7
|
|
|
7
8
|
const MARKDOWN_SNIPPET_IMPORT =
|
|
8
9
|
/^import\s+(?<name>[$A-Z_a-z][$\w]*)\s+from\s+["'](?<source>[^"']+\.mdx?)["'];?\s*$/gmu;
|
|
@@ -37,11 +38,6 @@ interface SnippetTransformOptions {
|
|
|
37
38
|
trail?: string[];
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
const isInsideRoot = (root: string, candidate: string): boolean => {
|
|
41
|
-
const rel = relative(root, candidate);
|
|
42
|
-
return rel === "" || (!rel.startsWith("..") && !rel.startsWith("/"));
|
|
43
|
-
};
|
|
44
|
-
|
|
45
41
|
const escapeRegExp = (value: string): string =>
|
|
46
42
|
value.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
47
43
|
|
|
@@ -151,7 +147,9 @@ const interpolateProps = (
|
|
|
151
147
|
source.replaceAll(PLACEHOLDER, (value, name: string) => props[name] ?? value);
|
|
152
148
|
|
|
153
149
|
const stripImport = (source: string, importText: string): string =>
|
|
154
|
-
|
|
150
|
+
// Seam-targeted removal: a whole-document `\n{3,}` collapse would rewrite
|
|
151
|
+
// real double blank lines inside the page's code fences.
|
|
152
|
+
stripImports(source, new RegExp(escapeRegExp(importText), "gu"));
|
|
155
153
|
|
|
156
154
|
const replacePlaceholder = (
|
|
157
155
|
source: string,
|
|
@@ -189,9 +187,20 @@ export const rewriteMintlifyMarkdownSnippets = async (
|
|
|
189
187
|
);
|
|
190
188
|
}
|
|
191
189
|
seen.add(file);
|
|
192
|
-
const readFile =
|
|
190
|
+
const readFile =
|
|
191
|
+
options.readFile ??
|
|
192
|
+
((path: string): Promise<string> => readFileFromDisk(path, "utf-8"));
|
|
193
193
|
try {
|
|
194
|
-
|
|
194
|
+
let raw: string;
|
|
195
|
+
try {
|
|
196
|
+
raw = await readFile(file);
|
|
197
|
+
} catch {
|
|
198
|
+
// A dangling import must fail with a message that names both ends —
|
|
199
|
+
// the caller downgrades it to a per-page warning and moves on.
|
|
200
|
+
throw new Error(
|
|
201
|
+
`snippet ${rootRelativePath(options.root, file)} (imported by ${rootRelativePath(options.root, options.filePath)}) does not exist`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
195
204
|
const content = matter(raw).content.trim();
|
|
196
205
|
const transformed = await rewriteMintlifyMarkdownSnippets(content, {
|
|
197
206
|
...options,
|
|
@@ -5,8 +5,9 @@ import { basename, dirname, extname, join, relative } from "pathe";
|
|
|
5
5
|
import { glob } from "tinyglobby";
|
|
6
6
|
|
|
7
7
|
import matter from "../../core/frontmatter.ts";
|
|
8
|
+
import { ensureGitignore } from "../../core/gitignore.ts";
|
|
8
9
|
import type { BlumeConfig, FolderMeta } from "../../core/schema.ts";
|
|
9
|
-
import { writeBlumeConfig } from "../shared.ts";
|
|
10
|
+
import { rewriteFrameworkScripts, writeBlumeConfig } from "../shared.ts";
|
|
10
11
|
import {
|
|
11
12
|
rewriteNextraCallouts,
|
|
12
13
|
stripNextraImports,
|
|
@@ -353,11 +354,25 @@ export const migrateNextraProject = async (
|
|
|
353
354
|
);
|
|
354
355
|
await writeBlumeConfig(root, buildConfig(plan.tabs));
|
|
355
356
|
|
|
357
|
+
// Tear down the Next scaffolding like the Fumadocs migrator does — without
|
|
358
|
+
// this, `npm run dev` still launches Next against the gutted content tree.
|
|
359
|
+
const scriptsRewritten = await rewriteFrameworkScripts(
|
|
360
|
+
root,
|
|
361
|
+
/\bnext\b/u,
|
|
362
|
+
/\bnextra\b/u
|
|
363
|
+
);
|
|
364
|
+
await ensureGitignore(root, [".blume/", "dist/"]);
|
|
365
|
+
|
|
356
366
|
const warnings = [
|
|
357
367
|
...plan.warnings,
|
|
358
368
|
...pages.skipped.map((rel) => `Skipped ${rel} (target already exists)`),
|
|
359
369
|
...relocateWarnings,
|
|
360
370
|
];
|
|
371
|
+
if (scriptsRewritten) {
|
|
372
|
+
warnings.push(
|
|
373
|
+
"Repointed package.json scripts at Blume (dev/build/preview); remove the leftover Next/Nextra dependencies and next.config/theme.config by hand."
|
|
374
|
+
);
|
|
375
|
+
}
|
|
361
376
|
if (pages.removedKeys.length > 0) {
|
|
362
377
|
warnings.push(
|
|
363
378
|
`Dropped unsupported page frontmatter keys: ${pages.removedKeys.join(", ")}.`
|
package/src/migrate/shared.ts
CHANGED
|
@@ -125,6 +125,27 @@ export const ensurePackageJson = async (root: string): Promise<boolean> => {
|
|
|
125
125
|
return true;
|
|
126
126
|
};
|
|
127
127
|
|
|
128
|
+
// A marker no markdown document contains, planted where an import was removed
|
|
129
|
+
// so the blank-gap collapse can target the seams alone.
|
|
130
|
+
const IMPORT_SEAM = "\u0000blume-import-seam\u0000";
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Remove every match of an import pattern, collapsing the blank gap each
|
|
134
|
+
* removal leaves behind — without touching blank runs elsewhere in the
|
|
135
|
+
* document (double blank lines inside code fences are real content that a
|
|
136
|
+
* whole-document `\n{3,}` collapse used to corrupt).
|
|
137
|
+
*/
|
|
138
|
+
export const stripImports = (source: string, pattern: RegExp): string => {
|
|
139
|
+
const marked = source.replaceAll(pattern, IMPORT_SEAM);
|
|
140
|
+
if (marked === source) {
|
|
141
|
+
return source;
|
|
142
|
+
}
|
|
143
|
+
return marked
|
|
144
|
+
.replaceAll(new RegExp(String.raw`\n*(?:${IMPORT_SEAM})+\n*`, "gu"), "\n\n")
|
|
145
|
+
.replace(/^\n+/u, "")
|
|
146
|
+
.replace(/\n+$/u, "\n");
|
|
147
|
+
};
|
|
148
|
+
|
|
128
149
|
// ---------------------------------------------------------------------------
|
|
129
150
|
// Callout components -> Blume `:::` directives
|
|
130
151
|
// ---------------------------------------------------------------------------
|
|
@@ -161,14 +182,30 @@ const dedent = (value: string): string => {
|
|
|
161
182
|
return lines.map((line) => line.slice(common)).join("\n");
|
|
162
183
|
};
|
|
163
184
|
|
|
185
|
+
/**
|
|
186
|
+
* A colon fence one longer than any directive fence in the body — nested
|
|
187
|
+
* container directives require the outer fence to be longer than the inner
|
|
188
|
+
* ones, or the inner `:::` closes the outer block.
|
|
189
|
+
*/
|
|
190
|
+
const fenceOver = (body: string): string => {
|
|
191
|
+
let max = 2;
|
|
192
|
+
for (const match of body.matchAll(/^ {0,3}(?<colons>:{3,})/gmu)) {
|
|
193
|
+
max = Math.max(max, match.groups?.colons?.length ?? 0);
|
|
194
|
+
}
|
|
195
|
+
return ":".repeat(Math.max(3, max + 1));
|
|
196
|
+
};
|
|
197
|
+
|
|
164
198
|
const directiveBlock = (
|
|
165
199
|
directive: string,
|
|
166
200
|
title: string | undefined,
|
|
167
201
|
inner: string
|
|
168
202
|
): string => {
|
|
169
|
-
const head = title ? `:::${directive}[${title}]` : `:::${directive}`;
|
|
170
203
|
const body = dedent(inner);
|
|
171
|
-
|
|
204
|
+
const fence = fenceOver(body);
|
|
205
|
+
const head = title
|
|
206
|
+
? `${fence}${directive}[${title}]`
|
|
207
|
+
: `${fence}${directive}`;
|
|
208
|
+
return `${head}\n${body}\n${fence}`;
|
|
172
209
|
};
|
|
173
210
|
|
|
174
211
|
/**
|
|
@@ -200,6 +237,40 @@ export const findOpenTagEnd = (source: string, from: number): number => {
|
|
|
200
237
|
return -1;
|
|
201
238
|
};
|
|
202
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Find the close tag matching an open tag, honoring same-tag nesting
|
|
242
|
+
* (`<Note>a<Note>b</Note>c</Note>` must close at the *outer* `</Note>`).
|
|
243
|
+
* Returns the index of the matching `</Tag>`, or -1 when unterminated.
|
|
244
|
+
*/
|
|
245
|
+
const findMatchingClose = (
|
|
246
|
+
source: string,
|
|
247
|
+
tag: string,
|
|
248
|
+
from: number
|
|
249
|
+
): number => {
|
|
250
|
+
const scanner = new RegExp(`<(?<closing>/)?${tag}(?=[\\s/>])`, "gu");
|
|
251
|
+
scanner.lastIndex = from;
|
|
252
|
+
let depth = 0;
|
|
253
|
+
for (let match = scanner.exec(source); match; match = scanner.exec(source)) {
|
|
254
|
+
if (match.groups?.closing) {
|
|
255
|
+
if (depth === 0) {
|
|
256
|
+
return match.index;
|
|
257
|
+
}
|
|
258
|
+
depth -= 1;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
// A nested open tag: skip its attributes; self-closing ones don't nest.
|
|
262
|
+
const end = findOpenTagEnd(source, match.index + tag.length + 1);
|
|
263
|
+
if (end === -1) {
|
|
264
|
+
return -1;
|
|
265
|
+
}
|
|
266
|
+
if (source[end - 1] !== "/") {
|
|
267
|
+
depth += 1;
|
|
268
|
+
}
|
|
269
|
+
scanner.lastIndex = end + 1;
|
|
270
|
+
}
|
|
271
|
+
return -1;
|
|
272
|
+
};
|
|
273
|
+
|
|
203
274
|
const directiveFor = (
|
|
204
275
|
tag: string,
|
|
205
276
|
attrs: string,
|
|
@@ -256,7 +327,7 @@ export const rewriteCallouts = (
|
|
|
256
327
|
const selfClosing = attrs.trimEnd().endsWith("/");
|
|
257
328
|
const closeIndex = selfClosing
|
|
258
329
|
? openEnd
|
|
259
|
-
: source
|
|
330
|
+
: findMatchingClose(source, tag, openEnd + 1);
|
|
260
331
|
|
|
261
332
|
if (!directive || (!selfClosing && closeIndex === -1)) {
|
|
262
333
|
output += source.slice(cursor, openEnd + 1);
|
|
@@ -272,10 +343,12 @@ export const rewriteCallouts = (
|
|
|
272
343
|
: `:::${directive}\n:::`;
|
|
273
344
|
cursor = openEnd + 1;
|
|
274
345
|
} else {
|
|
346
|
+
// Recurse so nested callouts (of any tag) convert too — they'd
|
|
347
|
+
// otherwise survive as components Blume doesn't ship.
|
|
275
348
|
output += directiveBlock(
|
|
276
349
|
directive,
|
|
277
350
|
title,
|
|
278
|
-
source.slice(openEnd + 1, closeIndex)
|
|
351
|
+
rewriteCallouts(source.slice(openEnd + 1, closeIndex), options)
|
|
279
352
|
);
|
|
280
353
|
cursor = closeIndex + closeTag.length;
|
|
281
354
|
}
|