blume 0.5.2 → 0.5.4
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 +2235 -5942
- package/dist/cli/index.js.map +36 -65
- package/dist/types/core/config.d.ts +0 -8
- package/dist/types/core/data.d.ts +2 -2
- package/dist/types/core/schema.d.ts +58 -453
- package/dist/types/core/types.d.ts +1 -7
- package/docs/advanced/meta.ts +1 -8
- package/docs/advanced/skills.mdx +28 -0
- package/docs/configuration/ai.mdx +3 -1
- package/docs/configuration/index.mdx +13 -17
- package/docs/configuration/theming.mdx +3 -11
- package/docs/content/components.mdx +2 -53
- package/docs/content/i18n.mdx +1 -5
- package/docs/content/sources.mdx +1 -1
- package/docs/content/syntax.mdx +1 -1
- package/docs/index.mdx +0 -1
- package/docs/reference/cli.mdx +12 -13
- package/package.json +4 -6
- package/skills/blume/SKILL.md +71 -0
- package/skills/blume-update-docs/SKILL.md +52 -0
- package/skills/blume-update-docs/references/audit-checklist.md +46 -0
- 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 +15 -12
- package/src/astro/integration.ts +0 -21
- package/src/astro/templates.ts +62 -37
- package/src/cli/coalesce.ts +43 -0
- package/src/cli/commands/dev.ts +30 -22
- package/src/cli/commands/validate.ts +0 -2
- package/src/cli/dev-lock.ts +4 -2
- package/src/cli/index.ts +0 -2
- package/src/components/BlumePage.astro +0 -6
- package/src/components/Icon.astro +1 -12
- package/src/components/content/AccordionItem.astro +3 -6
- package/src/components/content/Badge.astro +1 -3
- package/src/components/content/Callout.astro +3 -9
- package/src/components/content/Card.astro +2 -3
- package/src/components/content/ColorItem.astro +8 -5
- package/src/components/content/Column.astro +1 -1
- package/src/components/content/Prompt.astro +8 -4
- package/src/components/content/Step.astro +3 -4
- package/src/components/content/Tab.astro +2 -3
- package/src/components/content/Tabs.astro +13 -2
- package/src/components/content/TypeTable.astro +5 -5
- package/src/components/content/Update.astro +1 -1
- 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/Header.astro +26 -3
- package/src/components/layout/Logo.astro +5 -1
- package/src/components/layout/NavSelector.astro +1 -1
- package/src/components/layout/NavTree.astro +14 -14
- package/src/components/layout/PageActions.astro +17 -11
- package/src/components/layout/PageLayout.astro +42 -0
- package/src/components/layout/ReferenceLayout.astro +1 -0
- package/src/components/layout/RootLayout.astro +31 -2
- package/src/components/layout/Search.astro +9 -6
- package/src/components/layout/nav-utils.ts +9 -4
- package/src/components/layout/search/types.ts +16 -5
- package/src/components/openapi/ApiOverview.astro +4 -50
- package/src/components/openapi/ApiTagOperations.astro +42 -0
- 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/builtin-tags.ts +1 -3
- package/src/core/config.ts +19 -33
- package/src/core/data.ts +2 -2
- package/src/core/graph.ts +7 -5
- package/src/core/links.ts +5 -19
- package/src/core/meta.ts +1 -1
- package/src/core/navigation.ts +5 -6
- package/src/core/project-graph.ts +0 -5
- package/src/core/schema.ts +13 -90
- package/src/core/sources/filesystem.ts +19 -1
- package/src/core/sources/mdx-remote.ts +20 -4
- package/src/core/sources/normalize.ts +28 -6
- package/src/core/sources/resolve.ts +0 -13
- package/src/core/sources/watch.ts +45 -0
- package/src/core/types.ts +1 -8
- package/src/markdown/code-title.ts +6 -3
- package/src/markdown/package-install.ts +3 -1
- package/src/og/card.ts +16 -4
- package/src/openapi/parse.ts +197 -14
- package/src/openapi/render-mdx.ts +54 -11
- package/src/openapi/source.ts +19 -2
- package/src/search/sync/orama-cloud.ts +2 -0
- package/src/search/sync/typesense.ts +4 -0
- package/src/theme/entry.ts +45 -17
- package/src/theme/icons.ts +22 -104
- package/src/theme/palette.ts +35 -47
- package/src/theme/twoslash.ts +6 -1
- package/dist/types/core/bridge.d.ts +0 -24
- package/dist/types/migrate/mintlify/assets.d.ts +0 -8
- package/dist/types/migrate/mintlify/config.d.ts +0 -16
- package/dist/types/migrate/mintlify/i18n.d.ts +0 -7
- package/docs/advanced/bridge.mdx +0 -76
- package/docs/advanced/migrate.mdx +0 -123
- package/src/astro/static-assets.ts +0 -117
- package/src/cli/commands/migrate.ts +0 -39
- package/src/components/content/ApiField.astro +0 -75
- package/src/components/content/ParamField.astro +0 -39
- package/src/components/content/RequestField.astro +0 -23
- package/src/components/content/ResponseField.astro +0 -23
- package/src/components/content/Warning.astro +0 -9
- package/src/core/assets.ts +0 -31
- package/src/core/bridge.ts +0 -102
- package/src/core/sources/mintlify.ts +0 -204
- package/src/migrate/fumadocs/config.ts +0 -155
- package/src/migrate/fumadocs/content.ts +0 -378
- package/src/migrate/fumadocs/frontmatter.ts +0 -18
- package/src/migrate/fumadocs/groups.ts +0 -237
- package/src/migrate/fumadocs/index.ts +0 -340
- package/src/migrate/fumadocs/meta.ts +0 -244
- package/src/migrate/migrate.ts +0 -53
- package/src/migrate/mintlify/assets.ts +0 -46
- package/src/migrate/mintlify/config.ts +0 -958
- package/src/migrate/mintlify/content.ts +0 -120
- package/src/migrate/mintlify/frontmatter.ts +0 -126
- package/src/migrate/mintlify/i18n.ts +0 -51
- package/src/migrate/mintlify/icons.ts +0 -128
- package/src/migrate/mintlify/index.ts +0 -372
- package/src/migrate/mintlify/snippets.ts +0 -306
- package/src/migrate/mintlify/transform.ts +0 -82
- package/src/migrate/nextra/content.ts +0 -46
- package/src/migrate/nextra/frontmatter.ts +0 -40
- package/src/migrate/nextra/index.ts +0 -374
- package/src/migrate/nextra/meta.ts +0 -266
- package/src/migrate/shared.ts +0 -728
- package/src/migrate/starlight/config.ts +0 -455
- package/src/migrate/starlight/content.ts +0 -78
- package/src/migrate/starlight/frontmatter.ts +0 -111
- package/src/migrate/starlight/i18n.ts +0 -54
- package/src/migrate/starlight/index.ts +0 -131
|
@@ -1,372 +0,0 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
-
|
|
4
|
-
import { dirname, join } from "pathe";
|
|
5
|
-
import { glob } from "tinyglobby";
|
|
6
|
-
|
|
7
|
-
import { ensureGitignore } from "../../core/gitignore.ts";
|
|
8
|
-
import type { BlumeConfig } from "../../core/schema.ts";
|
|
9
|
-
import { ensurePackageJson } from "../shared.ts";
|
|
10
|
-
import { assetSegments } from "./assets.ts";
|
|
11
|
-
import { loadMintlifyConfig, partitionMintlifyRedirects } from "./config.ts";
|
|
12
|
-
import { mintlifyI18n } from "./i18n.ts";
|
|
13
|
-
import { transformMintlifyContent } from "./transform.ts";
|
|
14
|
-
|
|
15
|
-
export interface MintlifyMigrationResult {
|
|
16
|
-
moved: number;
|
|
17
|
-
warnings: string[];
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
|
|
21
|
-
value && typeof value === "object" && !Array.isArray(value)
|
|
22
|
-
? (value as Record<string, unknown>)
|
|
23
|
-
: undefined;
|
|
24
|
-
|
|
25
|
-
const hasFontFamily = (value: unknown): boolean => {
|
|
26
|
-
const object = asRecord(value);
|
|
27
|
-
if (!object) {
|
|
28
|
-
return false;
|
|
29
|
-
}
|
|
30
|
-
const named = (child: unknown): boolean =>
|
|
31
|
-
typeof asRecord(child)?.family === "string";
|
|
32
|
-
return (
|
|
33
|
-
typeof object.family === "string" ||
|
|
34
|
-
named(object.heading) ||
|
|
35
|
-
named(object.body)
|
|
36
|
-
);
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Warn about Mintlify site chrome that Blume's config doesn't model, so it isn't
|
|
41
|
-
* dropped silently: header links (`navbar.links`/`navbar.primary`), footer
|
|
42
|
-
* socials (`footer.socials`), and fonts outside Blume's curated Google set. The
|
|
43
|
-
* contextual page menu and last-updated timestamp are covered by Blume defaults
|
|
44
|
-
* (page actions, git-derived dates), so they need no warning.
|
|
45
|
-
*/
|
|
46
|
-
const droppedChromeWarnings = (
|
|
47
|
-
spec: Record<string, unknown>,
|
|
48
|
-
config: BlumeConfig
|
|
49
|
-
): string[] => {
|
|
50
|
-
const warnings: string[] = [];
|
|
51
|
-
const navbar = asRecord(spec.navbar);
|
|
52
|
-
if (navbar && (navbar.links || navbar.primary)) {
|
|
53
|
-
warnings.push(
|
|
54
|
-
"Header links (navbar.links/navbar.primary) have no blume.config equivalent and were dropped; re-add them with navigation.tabs or a Header layout override."
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
if (asRecord(spec.footer)?.socials) {
|
|
58
|
-
warnings.push(
|
|
59
|
-
"Footer social links (footer.socials) have no blume.config equivalent and were dropped; add them with a Footer layout override."
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
if (hasFontFamily(spec.fonts ?? spec.font) && !config.theme?.fonts) {
|
|
63
|
-
warnings.push(
|
|
64
|
-
"docs.json font family isn't in Blume's curated Google Fonts set; set theme.fonts to a supported slug or add @font-face rules in theme.css."
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
return warnings;
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Warn about dynamic (wildcard/param) redirects the migrator dropped. Blume
|
|
72
|
-
* redirects are static path-to-path, so a `:slug*`/`:id` source becomes an
|
|
73
|
-
* unroutable Astro destination — kept ones crash the build. Point the user at
|
|
74
|
-
* host-level rules that do support wildcards.
|
|
75
|
-
*/
|
|
76
|
-
const droppedRedirectWarnings = (spec: Record<string, unknown>): string[] => {
|
|
77
|
-
const { dropped } = partitionMintlifyRedirects(spec);
|
|
78
|
-
if (dropped.length === 0) {
|
|
79
|
-
return [];
|
|
80
|
-
}
|
|
81
|
-
return [
|
|
82
|
-
`Dropped ${dropped.length} dynamic redirect(s) Blume can't model as static path-to-path (${dropped.join(", ")}); re-add them as host-level rules (e.g. _redirects or vercel.json).`,
|
|
83
|
-
];
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
/** Recursively drop `undefined`, empty arrays, and empty objects. */
|
|
87
|
-
const prune = (value: unknown): unknown => {
|
|
88
|
-
if (Array.isArray(value)) {
|
|
89
|
-
return value.map(prune);
|
|
90
|
-
}
|
|
91
|
-
if (value && typeof value === "object") {
|
|
92
|
-
const out: Record<string, unknown> = {};
|
|
93
|
-
for (const [key, raw] of Object.entries(value)) {
|
|
94
|
-
const pruned = prune(raw);
|
|
95
|
-
if (pruned === undefined) {
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
if (Array.isArray(pruned) && pruned.length === 0) {
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
101
|
-
if (
|
|
102
|
-
pruned &&
|
|
103
|
-
typeof pruned === "object" &&
|
|
104
|
-
!Array.isArray(pruned) &&
|
|
105
|
-
Object.keys(pruned).length === 0
|
|
106
|
-
) {
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
out[key] = pruned;
|
|
110
|
-
}
|
|
111
|
-
return out;
|
|
112
|
-
}
|
|
113
|
-
return value;
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
const writeBlumeConfig = async (
|
|
117
|
-
root: string,
|
|
118
|
-
config: BlumeConfig
|
|
119
|
-
): Promise<void> => {
|
|
120
|
-
const body = `import { defineConfig } from "blume";\n\nexport default defineConfig(${JSON.stringify(prune(config), null, 2)});\n`;
|
|
121
|
-
await writeFile(join(root, "blume.config.ts"), body, "utf-8");
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
interface RelocatedAssets {
|
|
125
|
-
/** Top-level dirs served in place via `content.assets` (no files moved). */
|
|
126
|
-
served: string[];
|
|
127
|
-
/** Top-level files moved under `public/`. */
|
|
128
|
-
moved: string[];
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Make referenced top-level assets resolvable in Blume. Directories (e.g.
|
|
133
|
-
* Mintlify's `images/`) are left in place and served via `content.assets`, so
|
|
134
|
-
* the migration doesn't churn every file under them; loose top-level files
|
|
135
|
-
* (a root `favicon.png`, `logo.png`) are moved under `public/` since a mount
|
|
136
|
-
* points at a directory.
|
|
137
|
-
*/
|
|
138
|
-
const relocateAssets = async (
|
|
139
|
-
root: string,
|
|
140
|
-
segments: string[]
|
|
141
|
-
): Promise<RelocatedAssets> => {
|
|
142
|
-
const served: string[] = [];
|
|
143
|
-
const moved: string[] = [];
|
|
144
|
-
for (const segment of segments) {
|
|
145
|
-
const source = join(root, segment);
|
|
146
|
-
if (!existsSync(source) || segment === "public") {
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
// oxlint-disable-next-line no-await-in-loop -- sequential fs stats
|
|
150
|
-
const stats = await stat(source);
|
|
151
|
-
if (stats.isDirectory()) {
|
|
152
|
-
served.push(segment);
|
|
153
|
-
continue;
|
|
154
|
-
}
|
|
155
|
-
const dest = join(root, "public", segment);
|
|
156
|
-
if (existsSync(dest)) {
|
|
157
|
-
continue;
|
|
158
|
-
}
|
|
159
|
-
// oxlint-disable-next-line no-await-in-loop -- sequential fs moves
|
|
160
|
-
await mkdir(join(root, "public"), { recursive: true });
|
|
161
|
-
// oxlint-disable-next-line no-await-in-loop -- sequential fs moves
|
|
162
|
-
await rename(source, dest);
|
|
163
|
-
moved.push(segment);
|
|
164
|
-
}
|
|
165
|
-
return { moved, served };
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Fold relocated assets into the config (served dirs become `content.assets`)
|
|
170
|
-
* and record what happened. Served dirs stay in place; only loose files moved.
|
|
171
|
-
*/
|
|
172
|
-
const applyRelocatedAssets = (
|
|
173
|
-
config: BlumeConfig,
|
|
174
|
-
assets: RelocatedAssets,
|
|
175
|
-
warnings: string[]
|
|
176
|
-
): void => {
|
|
177
|
-
if (assets.served.length > 0) {
|
|
178
|
-
config.content = {
|
|
179
|
-
...config.content,
|
|
180
|
-
assets: [
|
|
181
|
-
...new Set([...(config.content?.assets ?? []), ...assets.served]),
|
|
182
|
-
],
|
|
183
|
-
};
|
|
184
|
-
warnings.push(
|
|
185
|
-
`Kept asset dir(s) in place, served via content.assets: ${assets.served.join(", ")}.`
|
|
186
|
-
);
|
|
187
|
-
}
|
|
188
|
-
if (assets.moved.length > 0) {
|
|
189
|
-
warnings.push(`Moved assets into public/: ${assets.moved.join(", ")}.`);
|
|
190
|
-
}
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Scaffold the project files a config-only Mintlify repo lacks: a runnable
|
|
195
|
-
* `package.json` (it ships no npm manifest) and a `.gitignore` for Blume's
|
|
196
|
-
* generated `.blume/` runtime and `dist/` build output. Both are idempotent —
|
|
197
|
-
* an existing file is extended, not overwritten — and noted in the warnings.
|
|
198
|
-
*/
|
|
199
|
-
const scaffoldProjectFiles = async (
|
|
200
|
-
root: string,
|
|
201
|
-
warnings: string[]
|
|
202
|
-
): Promise<void> => {
|
|
203
|
-
if (await ensurePackageJson(root)) {
|
|
204
|
-
warnings.push(
|
|
205
|
-
"Created a package.json with blume as a dependency; run `npm install`, then `npm run dev`."
|
|
206
|
-
);
|
|
207
|
-
}
|
|
208
|
-
const ignored = await ensureGitignore(root, [".blume/", "dist/"]);
|
|
209
|
-
if (ignored.length > 0) {
|
|
210
|
-
warnings.push(`Added ${ignored.join(", ")} to .gitignore.`);
|
|
211
|
-
}
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Delete the inlined markdown snippets. Component files (e.g. `.jsx`) are kept
|
|
216
|
-
* because their imports were rewritten to resolve against `/snippets`.
|
|
217
|
-
*/
|
|
218
|
-
const cleanupSnippets = async (
|
|
219
|
-
root: string,
|
|
220
|
-
kept: Set<string>,
|
|
221
|
-
warnings: string[]
|
|
222
|
-
): Promise<void> => {
|
|
223
|
-
const dir = join(root, "snippets");
|
|
224
|
-
if (!existsSync(dir)) {
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
227
|
-
const markdown = await glob(["**/*.{md,mdx}"], { absolute: true, cwd: dir });
|
|
228
|
-
for (const file of markdown) {
|
|
229
|
-
// oxlint-disable-next-line no-await-in-loop -- sequential fs removes
|
|
230
|
-
await rm(file, { force: true });
|
|
231
|
-
}
|
|
232
|
-
const remaining = await glob(["**/*"], { cwd: dir, dot: true });
|
|
233
|
-
if (remaining.length === 0) {
|
|
234
|
-
await rm(dir, { force: true, recursive: true });
|
|
235
|
-
warnings.push("Inlined and removed the /snippets directory.");
|
|
236
|
-
} else {
|
|
237
|
-
warnings.push(
|
|
238
|
-
`Inlined markdown snippets; kept ${remaining.length} component file(s) under /snippets.`
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
|
-
if (kept.size > 0) {
|
|
242
|
-
warnings.push(
|
|
243
|
-
`Rewrote ${kept.size} component snippet import(s) to relative paths; verify they resolve.`
|
|
244
|
-
);
|
|
245
|
-
}
|
|
246
|
-
};
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* Migrate a Mintlify project to Blume: translate `docs.json`/`mint.json` into
|
|
250
|
-
* `blume.config.ts`, rewrite every page to idiomatic Blume MDX in place, and
|
|
251
|
-
* relocate static assets. Content stays at the project root (`content.root`
|
|
252
|
-
* is `"."`).
|
|
253
|
-
*/
|
|
254
|
-
export const migrateMintlifyProject = async (
|
|
255
|
-
root: string
|
|
256
|
-
): Promise<MintlifyMigrationResult> => {
|
|
257
|
-
const warnings: string[] = [];
|
|
258
|
-
const configFile = existsSync(join(root, "docs.json"))
|
|
259
|
-
? join(root, "docs.json")
|
|
260
|
-
: join(root, "mint.json");
|
|
261
|
-
|
|
262
|
-
let config: BlumeConfig;
|
|
263
|
-
if (existsSync(configFile)) {
|
|
264
|
-
config = await loadMintlifyConfig(root, configFile);
|
|
265
|
-
const spec = JSON.parse(await readFile(configFile, "utf-8")) as Record<
|
|
266
|
-
string,
|
|
267
|
-
unknown
|
|
268
|
-
>;
|
|
269
|
-
const i18n = mintlifyI18n(spec);
|
|
270
|
-
if (i18n) {
|
|
271
|
-
config.i18n = i18n;
|
|
272
|
-
// Language switching is handled by Blume i18n, not a nav selector.
|
|
273
|
-
if (config.navigation?.selectors) {
|
|
274
|
-
config.navigation.selectors = config.navigation.selectors.filter(
|
|
275
|
-
(selector) => selector.kind !== "language"
|
|
276
|
-
);
|
|
277
|
-
}
|
|
278
|
-
warnings.push(
|
|
279
|
-
`Mapped ${i18n.locales.length} languages to i18n.locales (default: ${i18n.defaultLocale}); review the locale labels.`
|
|
280
|
-
);
|
|
281
|
-
}
|
|
282
|
-
const openapiSources = config.openapi?.sources ?? [];
|
|
283
|
-
if (openapiSources.length > 0) {
|
|
284
|
-
warnings.push(
|
|
285
|
-
`Mapped ${openapiSources.length} OpenAPI spec source(s) to openapi.sources (native reference renderer); verify each spec path or URL resolves.`
|
|
286
|
-
);
|
|
287
|
-
}
|
|
288
|
-
warnings.push(...droppedChromeWarnings(spec, config));
|
|
289
|
-
warnings.push(...droppedRedirectWarnings(spec));
|
|
290
|
-
} else {
|
|
291
|
-
warnings.push("No docs.json or mint.json found; writing a default config.");
|
|
292
|
-
config = { content: { root: "." }, title: "Documentation" };
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
const variables = (config.variables as Record<string, string>) ?? {};
|
|
296
|
-
// Globals are inlined into content below; Blume has no runtime substitution.
|
|
297
|
-
config.variables = undefined;
|
|
298
|
-
|
|
299
|
-
const files = await glob(["**/*.{md,mdx}"], {
|
|
300
|
-
absolute: true,
|
|
301
|
-
cwd: root,
|
|
302
|
-
ignore: [
|
|
303
|
-
"node_modules/**",
|
|
304
|
-
".blume/**",
|
|
305
|
-
"dist/**",
|
|
306
|
-
"public/**",
|
|
307
|
-
"snippets/**",
|
|
308
|
-
],
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
let moved = 0;
|
|
312
|
-
const removedKeys = new Set<string>();
|
|
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
|
-
}
|
|
341
|
-
|
|
342
|
-
const assets = await relocateAssets(root, assetSegments(config));
|
|
343
|
-
await cleanupSnippets(root, keptComponents, warnings);
|
|
344
|
-
|
|
345
|
-
if (config.content?.exclude) {
|
|
346
|
-
config.content.exclude = [...new Set(config.content.exclude)];
|
|
347
|
-
}
|
|
348
|
-
applyRelocatedAssets(config, assets, warnings);
|
|
349
|
-
await writeBlumeConfig(root, config);
|
|
350
|
-
await scaffoldProjectFiles(root, warnings);
|
|
351
|
-
|
|
352
|
-
if (Object.keys(variables).length > 0) {
|
|
353
|
-
warnings.push(
|
|
354
|
-
`Inlined ${Object.keys(variables).length} docs.json variable(s) into content; Blume has no runtime variable substitution.`
|
|
355
|
-
);
|
|
356
|
-
}
|
|
357
|
-
if (removedKeys.size > 0) {
|
|
358
|
-
warnings.push(
|
|
359
|
-
`Dropped unsupported page frontmatter keys: ${[...removedKeys].join(", ")}.`
|
|
360
|
-
);
|
|
361
|
-
}
|
|
362
|
-
if (unsupported.size > 0) {
|
|
363
|
-
warnings.push(
|
|
364
|
-
`Components without a Blume equivalent need manual review: ${[...unsupported].join(", ")}.`
|
|
365
|
-
);
|
|
366
|
-
}
|
|
367
|
-
warnings.push(
|
|
368
|
-
"Review blume.config.ts; navigation, theme, and chrome were mapped from docs.json."
|
|
369
|
-
);
|
|
370
|
-
|
|
371
|
-
return { moved, warnings };
|
|
372
|
-
};
|
|
@@ -1,306 +0,0 @@
|
|
|
1
|
-
import { readFile as readFileFromDisk } from "node:fs/promises";
|
|
2
|
-
|
|
3
|
-
import { dirname, relative, resolve } from "pathe";
|
|
4
|
-
|
|
5
|
-
import matter from "../../core/frontmatter.ts";
|
|
6
|
-
|
|
7
|
-
const MARKDOWN_SNIPPET_IMPORT =
|
|
8
|
-
/^import\s+(?<name>[$A-Z_a-z][$\w]*)\s+from\s+["'](?<source>[^"']+\.mdx?)["'];?\s*$/gmu;
|
|
9
|
-
const NAMED_SNIPPET_IMPORT =
|
|
10
|
-
/^import\s+\{(?<names>[^}]+)\}\s+from\s+["'](?<source>[^"']+\.mdx?)["'];?\s*$/gmu;
|
|
11
|
-
const EXPORTED_STRING_CONST =
|
|
12
|
-
/^export\s+const\s+(?<name>[$A-Z_a-z][$\w]*)\s*=\s*(?:"(?<double>(?:\\.|[^"\\])*)"|'(?<single>(?:\\.|[^'\\])*)'|`(?<template>(?:\\.|[^`\\])*)`)\s*;?\s*$/gmu;
|
|
13
|
-
const ATTRIBUTE =
|
|
14
|
-
/\s+(?<name>[$A-Z_a-z][$\w:-]*)(?:=(?:"(?<quoted>[^"]*)"|'(?<single>[^']*)'|\{(?<expression>[^}]*)\}))?/gu;
|
|
15
|
-
const PLACEHOLDER = /\{(?<name>[$A-Z_a-z][$\w]*)\}/gu;
|
|
16
|
-
const GLOBAL_VARIABLE = /\{\{\s*(?<name>[A-Za-z0-9-]+)\s*\}\}/gu;
|
|
17
|
-
const FRONTMATTER_BLOCK = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/u;
|
|
18
|
-
const USER_EXPORT =
|
|
19
|
-
/^(?:export\s+)?(?:const|let|var)\s+user\s*=|^import\s+\{\s*user\s*\}/mu;
|
|
20
|
-
|
|
21
|
-
interface SnippetImport {
|
|
22
|
-
importText: string;
|
|
23
|
-
name: string;
|
|
24
|
-
source: string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
interface SnippetVariableImport {
|
|
28
|
-
names: { imported: string; local: string }[];
|
|
29
|
-
source: string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
interface SnippetTransformOptions {
|
|
33
|
-
filePath: string;
|
|
34
|
-
root: string;
|
|
35
|
-
readFile?: (file: string) => Promise<string>;
|
|
36
|
-
seen?: Set<string>;
|
|
37
|
-
trail?: string[];
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const isInsideRoot = (root: string, candidate: string): boolean => {
|
|
41
|
-
const rel = relative(root, candidate);
|
|
42
|
-
return rel === "" || (!rel.startsWith("..") && !rel.startsWith("/"));
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
const escapeRegExp = (value: string): string =>
|
|
46
|
-
value.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
47
|
-
|
|
48
|
-
const snippetSelfClosingTagPattern = (name: string): RegExp =>
|
|
49
|
-
new RegExp(`<${escapeRegExp(name)}(?<attrs>[^>]*?)\\s*/>`, "gu");
|
|
50
|
-
|
|
51
|
-
const snippetPairedTagPattern = (name: string): RegExp =>
|
|
52
|
-
new RegExp(
|
|
53
|
-
`<${escapeRegExp(name)}(?<attrs>[^>]*?)>[\\s\\S]*?</${escapeRegExp(name)}>`,
|
|
54
|
-
"gu"
|
|
55
|
-
);
|
|
56
|
-
|
|
57
|
-
const rootRelativePath = (root: string, file: string): string => {
|
|
58
|
-
const rel = relative(root, file);
|
|
59
|
-
return rel ? `/${rel}` : "/";
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
const snippetCycleMessage = (
|
|
63
|
-
root: string,
|
|
64
|
-
file: string,
|
|
65
|
-
trail: string[]
|
|
66
|
-
): string => {
|
|
67
|
-
const cycleStart = trail.indexOf(file);
|
|
68
|
-
const cycle = [...(cycleStart === -1 ? trail : trail.slice(cycleStart)), file]
|
|
69
|
-
.map((entry) => rootRelativePath(root, entry))
|
|
70
|
-
.join(" -> ");
|
|
71
|
-
return `Circular Mintlify snippet import detected: ${cycle}`;
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
const resolveSnippetPath = (options: {
|
|
75
|
-
filePath: string;
|
|
76
|
-
root: string;
|
|
77
|
-
source: string;
|
|
78
|
-
}): string | null => {
|
|
79
|
-
const target = options.source.startsWith("/")
|
|
80
|
-
? resolve(options.root, options.source.slice(1))
|
|
81
|
-
: resolve(dirname(options.filePath), options.source);
|
|
82
|
-
return isInsideRoot(options.root, target) ? target : null;
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
const collectImports = (source: string): SnippetImport[] =>
|
|
86
|
-
[...source.matchAll(MARKDOWN_SNIPPET_IMPORT)].flatMap((match) => {
|
|
87
|
-
const name = match.groups?.name;
|
|
88
|
-
const importSource = match.groups?.source;
|
|
89
|
-
if (!(name && importSource)) {
|
|
90
|
-
return [];
|
|
91
|
-
}
|
|
92
|
-
return [{ importText: match[0], name, source: importSource }];
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
const collectVariableImports = (source: string): SnippetVariableImport[] =>
|
|
96
|
-
[...source.matchAll(NAMED_SNIPPET_IMPORT)].flatMap((match) => {
|
|
97
|
-
const names = match.groups?.names;
|
|
98
|
-
const importSource = match.groups?.source;
|
|
99
|
-
if (!(names && importSource)) {
|
|
100
|
-
return [];
|
|
101
|
-
}
|
|
102
|
-
const parsedNames = names.split(",").flatMap((entry) => {
|
|
103
|
-
const [imported, local] = entry.trim().split(/\s+as\s+/u);
|
|
104
|
-
return imported ? [{ imported, local: local ?? imported }] : [];
|
|
105
|
-
});
|
|
106
|
-
return parsedNames.length
|
|
107
|
-
? [{ names: parsedNames, source: importSource }]
|
|
108
|
-
: [];
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
const decodeStringLiteral = (value: string): string =>
|
|
112
|
-
value
|
|
113
|
-
.replaceAll("\\n", "\n")
|
|
114
|
-
.replaceAll("\\r", "\r")
|
|
115
|
-
.replaceAll("\\t", "\t")
|
|
116
|
-
.replaceAll(/\\(?<escaped>[\\'"`])/gu, "$<escaped>");
|
|
117
|
-
|
|
118
|
-
const collectStringExports = (source: string): Map<string, string> => {
|
|
119
|
-
const exports = new Map<string, string>();
|
|
120
|
-
for (const match of source.matchAll(EXPORTED_STRING_CONST)) {
|
|
121
|
-
const name = match.groups?.name;
|
|
122
|
-
const value =
|
|
123
|
-
match.groups?.double ?? match.groups?.single ?? match.groups?.template;
|
|
124
|
-
if (name && value !== undefined) {
|
|
125
|
-
exports.set(name, decodeStringLiteral(value));
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
return exports;
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
const parseAttributes = (source: string): Record<string, string> => {
|
|
132
|
-
const props: Record<string, string> = {};
|
|
133
|
-
for (const match of source.matchAll(ATTRIBUTE)) {
|
|
134
|
-
const name = match.groups?.name;
|
|
135
|
-
if (!name) {
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
props[name] =
|
|
139
|
-
match.groups?.quoted ??
|
|
140
|
-
match.groups?.single ??
|
|
141
|
-
match.groups?.expression?.trim() ??
|
|
142
|
-
"true";
|
|
143
|
-
}
|
|
144
|
-
return props;
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
const interpolateProps = (
|
|
148
|
-
source: string,
|
|
149
|
-
props: Record<string, string>
|
|
150
|
-
): string =>
|
|
151
|
-
source.replaceAll(PLACEHOLDER, (value, name: string) => props[name] ?? value);
|
|
152
|
-
|
|
153
|
-
const stripImport = (source: string, importText: string): string =>
|
|
154
|
-
source.replace(importText, "").replaceAll(/\n{3,}/gu, "\n\n");
|
|
155
|
-
|
|
156
|
-
const replacePlaceholder = (
|
|
157
|
-
source: string,
|
|
158
|
-
name: string,
|
|
159
|
-
value: string
|
|
160
|
-
): string =>
|
|
161
|
-
source.replaceAll(new RegExp(`\\{${escapeRegExp(name)}\\}`, "gu"), value);
|
|
162
|
-
|
|
163
|
-
const inlineSnippetTags = (options: {
|
|
164
|
-
name: string;
|
|
165
|
-
snippet: string;
|
|
166
|
-
source: string;
|
|
167
|
-
}): string => {
|
|
168
|
-
const inline = (_value: string, attrs: string): string =>
|
|
169
|
-
interpolateProps(options.snippet, parseAttributes(attrs));
|
|
170
|
-
return options.source
|
|
171
|
-
.replaceAll(snippetSelfClosingTagPattern(options.name), inline)
|
|
172
|
-
.replaceAll(snippetPairedTagPattern(options.name), inline);
|
|
173
|
-
};
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Mintlify markdown snippets are source-level includes, not standalone pages.
|
|
177
|
-
* Inline markdown snippets before Astro MDX compiles so `.md` imports, nested
|
|
178
|
-
* markdown snippets, and `{prop}` interpolation inside prose or code fences work.
|
|
179
|
-
*/
|
|
180
|
-
export const rewriteMintlifyMarkdownSnippets = async (
|
|
181
|
-
source: string,
|
|
182
|
-
options: SnippetTransformOptions
|
|
183
|
-
): Promise<string> => {
|
|
184
|
-
const loadSnippet = async (file: string): Promise<string> => {
|
|
185
|
-
const seen = options.seen ?? new Set<string>();
|
|
186
|
-
if (seen.has(file)) {
|
|
187
|
-
throw new Error(
|
|
188
|
-
snippetCycleMessage(options.root, file, options.trail ?? [])
|
|
189
|
-
);
|
|
190
|
-
}
|
|
191
|
-
seen.add(file);
|
|
192
|
-
const readFile = options.readFile ?? readFileFromDisk;
|
|
193
|
-
try {
|
|
194
|
-
const raw = await readFile(file);
|
|
195
|
-
const content = matter(raw).content.trim();
|
|
196
|
-
const transformed = await rewriteMintlifyMarkdownSnippets(content, {
|
|
197
|
-
...options,
|
|
198
|
-
filePath: file,
|
|
199
|
-
seen,
|
|
200
|
-
trail: [...(options.trail ?? []), file],
|
|
201
|
-
});
|
|
202
|
-
return transformed.trim();
|
|
203
|
-
} finally {
|
|
204
|
-
seen.delete(file);
|
|
205
|
-
}
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
const inlineImport = async (
|
|
209
|
-
current: string,
|
|
210
|
-
snippetImport: SnippetImport
|
|
211
|
-
): Promise<string> => {
|
|
212
|
-
const file = resolveSnippetPath({
|
|
213
|
-
filePath: options.filePath,
|
|
214
|
-
root: options.root,
|
|
215
|
-
source: snippetImport.source,
|
|
216
|
-
});
|
|
217
|
-
if (!file) {
|
|
218
|
-
return current;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const snippet = await loadSnippet(file);
|
|
222
|
-
const next = inlineSnippetTags({
|
|
223
|
-
name: snippetImport.name,
|
|
224
|
-
snippet,
|
|
225
|
-
source: current,
|
|
226
|
-
});
|
|
227
|
-
return next === current
|
|
228
|
-
? current
|
|
229
|
-
: stripImport(next, snippetImport.importText);
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
const imports = collectImports(source);
|
|
233
|
-
const inlineAt = async (index: number, current: string): Promise<string> => {
|
|
234
|
-
const snippetImport = imports[index];
|
|
235
|
-
if (!snippetImport) {
|
|
236
|
-
return current;
|
|
237
|
-
}
|
|
238
|
-
return inlineAt(index + 1, await inlineImport(current, snippetImport));
|
|
239
|
-
};
|
|
240
|
-
|
|
241
|
-
return await inlineAt(0, source);
|
|
242
|
-
};
|
|
243
|
-
|
|
244
|
-
/** Resolve imported string constants from Mintlify snippets for generated text. */
|
|
245
|
-
export const rewriteMintlifySnippetVariables = async (
|
|
246
|
-
source: string,
|
|
247
|
-
options: SnippetTransformOptions
|
|
248
|
-
): Promise<string> => {
|
|
249
|
-
const readFile = options.readFile ?? readFileFromDisk;
|
|
250
|
-
const inlineImport = async (
|
|
251
|
-
current: string,
|
|
252
|
-
variableImport: SnippetVariableImport
|
|
253
|
-
): Promise<string> => {
|
|
254
|
-
const file = resolveSnippetPath({
|
|
255
|
-
filePath: options.filePath,
|
|
256
|
-
root: options.root,
|
|
257
|
-
source: variableImport.source,
|
|
258
|
-
});
|
|
259
|
-
if (!file) {
|
|
260
|
-
return current;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const exports = collectStringExports(matter(await readFile(file)).content);
|
|
264
|
-
let next = current;
|
|
265
|
-
for (const name of variableImport.names) {
|
|
266
|
-
const value = exports.get(name.imported);
|
|
267
|
-
if (value !== undefined) {
|
|
268
|
-
next = replacePlaceholder(next, name.local, value);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
return next;
|
|
272
|
-
};
|
|
273
|
-
|
|
274
|
-
const imports = collectVariableImports(source);
|
|
275
|
-
const inlineAt = async (index: number, current: string): Promise<string> => {
|
|
276
|
-
const variableImport = imports[index];
|
|
277
|
-
if (!variableImport) {
|
|
278
|
-
return current;
|
|
279
|
-
}
|
|
280
|
-
return inlineAt(index + 1, await inlineImport(current, variableImport));
|
|
281
|
-
};
|
|
282
|
-
|
|
283
|
-
return await inlineAt(0, source);
|
|
284
|
-
};
|
|
285
|
-
|
|
286
|
-
/** Replace docs.json Mintlify globals such as `{{product-name}}`. */
|
|
287
|
-
export const rewriteMintlifyGlobalVariables = (
|
|
288
|
-
source: string,
|
|
289
|
-
variables: Record<string, string>
|
|
290
|
-
): string =>
|
|
291
|
-
source.replaceAll(GLOBAL_VARIABLE, (value, name: string) =>
|
|
292
|
-
Object.hasOwn(variables, name) ? (variables[name] ?? value) : value
|
|
293
|
-
);
|
|
294
|
-
|
|
295
|
-
/** Mintlify exposes a logged-out `user` object to MDX personalization code. */
|
|
296
|
-
export const rewriteMintlifyUserVariable = (source: string): string => {
|
|
297
|
-
if (USER_EXPORT.test(source)) {
|
|
298
|
-
return source;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
const frontmatter = source.match(FRONTMATTER_BLOCK)?.[0] ?? "";
|
|
302
|
-
const body = (
|
|
303
|
-
frontmatter ? source.slice(frontmatter.length) : source
|
|
304
|
-
).replace(/^\r?\n/u, "");
|
|
305
|
-
return `${frontmatter}export const user = {};\n\n${body}`;
|
|
306
|
-
};
|