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
package/src/migrate/shared.ts
DELETED
|
@@ -1,728 +0,0 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
-
|
|
4
|
-
import { basename, isAbsolute, join, relative } from "pathe";
|
|
5
|
-
|
|
6
|
-
import { blumePackageJson, toPackageName } from "../core/package-json.ts";
|
|
7
|
-
import type { BlumeConfig } from "../core/schema.ts";
|
|
8
|
-
import { pageMetaSchema } from "../core/schema.ts";
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Whether `candidate` resolves to a path inside `root` (or is `root` itself).
|
|
12
|
-
* Guards migrators against `../` traversal in author-controlled source paths
|
|
13
|
-
* (`pages` entries, `<include>` targets) that would otherwise read or move
|
|
14
|
-
* files outside the docs tree.
|
|
15
|
-
*/
|
|
16
|
-
export const isInsideRoot = (root: string, candidate: string): boolean => {
|
|
17
|
-
const rel = relative(root, candidate);
|
|
18
|
-
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Framework-agnostic helpers shared by more than one migrator. Each piece here
|
|
23
|
-
* was generalized from a migrator-specific implementation so Mintlify, Nextra,
|
|
24
|
-
* and future migrators converge on a single copy.
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
/** Serialize a `BlumeConfig` to a `blume.config.ts` at the project root. */
|
|
28
|
-
export const writeBlumeConfig = async (
|
|
29
|
-
root: string,
|
|
30
|
-
config: BlumeConfig
|
|
31
|
-
): Promise<void> => {
|
|
32
|
-
const body = `import { defineConfig } from "blume";\n\nexport default defineConfig(${JSON.stringify(config, null, 2)});\n`;
|
|
33
|
-
await writeFile(join(root, "blume.config.ts"), body, "utf-8");
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
// Old-framework teardown
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
|
|
40
|
-
/** The Blume command each standard npm script maps to after a migration. */
|
|
41
|
-
const BLUME_SCRIPTS: Record<string, string> = {
|
|
42
|
-
build: "blume build",
|
|
43
|
-
dev: "blume dev",
|
|
44
|
-
start: "blume preview",
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Rewrite a migrated project's npm scripts off the old framework's CLI. A
|
|
49
|
-
* `dev`/`build`/`start` script whose command invokes `cli` (e.g. `/\bnext\b/`)
|
|
50
|
-
* is repointed at the matching Blume command (`start` -> `blume preview`); a
|
|
51
|
-
* script whose command matches `remove` (e.g. a `fumadocs-mdx` postinstall) is
|
|
52
|
-
* dropped. Scripts that don't match either are left untouched, so custom tasks
|
|
53
|
-
* survive. Returns true when `package.json` changed.
|
|
54
|
-
*/
|
|
55
|
-
export const rewriteFrameworkScripts = async (
|
|
56
|
-
root: string,
|
|
57
|
-
cli: RegExp,
|
|
58
|
-
remove?: RegExp
|
|
59
|
-
): Promise<boolean> => {
|
|
60
|
-
const pkgPath = join(root, "package.json");
|
|
61
|
-
if (!existsSync(pkgPath)) {
|
|
62
|
-
return false;
|
|
63
|
-
}
|
|
64
|
-
let pkg: { scripts?: Record<string, unknown> };
|
|
65
|
-
try {
|
|
66
|
-
pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
|
|
67
|
-
} catch {
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
const { scripts } = pkg;
|
|
71
|
-
if (!scripts || typeof scripts !== "object") {
|
|
72
|
-
return false;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const next: Record<string, unknown> = {};
|
|
76
|
-
let changed = false;
|
|
77
|
-
for (const [name, command] of Object.entries(scripts)) {
|
|
78
|
-
const blume = BLUME_SCRIPTS[name];
|
|
79
|
-
if (typeof command === "string" && remove?.test(command)) {
|
|
80
|
-
changed = true;
|
|
81
|
-
} else if (
|
|
82
|
-
typeof command === "string" &&
|
|
83
|
-
blume &&
|
|
84
|
-
cli.test(command) &&
|
|
85
|
-
command !== blume
|
|
86
|
-
) {
|
|
87
|
-
next[name] = blume;
|
|
88
|
-
changed = true;
|
|
89
|
-
} else {
|
|
90
|
-
next[name] = command;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
if (changed) {
|
|
95
|
-
pkg.scripts = next;
|
|
96
|
-
await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf-8");
|
|
97
|
-
}
|
|
98
|
-
return changed;
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
/** Of the candidate project-relative paths, the ones that still exist — the old
|
|
102
|
-
* framework files a migration leaves behind for the user to remove by hand. */
|
|
103
|
-
export const leftoverFiles = (root: string, candidates: string[]): string[] =>
|
|
104
|
-
candidates.filter((candidate) => existsSync(join(root, candidate)));
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Scaffold a minimal, runnable `package.json` when the migrated project has
|
|
108
|
-
* none. Config-only sources (e.g. a Mintlify `docs.json`) ship no npm manifest,
|
|
109
|
-
* so a fresh migration has nothing to run `blume dev` with; this writes a stub
|
|
110
|
-
* with `blume` as a dependency and `dev`/`build`/`doctor` scripts, making
|
|
111
|
-
* `npm install && npm run dev` work immediately. A pre-existing `package.json`
|
|
112
|
-
* is left untouched — {@link rewriteFrameworkScripts} repoints those instead.
|
|
113
|
-
* Returns true when a file was created.
|
|
114
|
-
*/
|
|
115
|
-
export const ensurePackageJson = async (root: string): Promise<boolean> => {
|
|
116
|
-
const pkgPath = join(root, "package.json");
|
|
117
|
-
if (existsSync(pkgPath)) {
|
|
118
|
-
return false;
|
|
119
|
-
}
|
|
120
|
-
await writeFile(
|
|
121
|
-
pkgPath,
|
|
122
|
-
blumePackageJson(toPackageName(basename(root))),
|
|
123
|
-
"utf-8"
|
|
124
|
-
);
|
|
125
|
-
return true;
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
// ---------------------------------------------------------------------------
|
|
129
|
-
// Callout components -> Blume `:::` directives
|
|
130
|
-
// ---------------------------------------------------------------------------
|
|
131
|
-
|
|
132
|
-
export interface CalloutRewriteOptions {
|
|
133
|
-
/** Directive for a type-bearing tag with no `type` attribute (e.g. bare `<Callout>`). */
|
|
134
|
-
defaultDirective: string;
|
|
135
|
-
/** Tag names whose directive is fixed by the tag itself (e.g. `<Warning>`). */
|
|
136
|
-
tagDirectives: Record<string, string>;
|
|
137
|
-
/** Component tag names to convert. */
|
|
138
|
-
tags: string[];
|
|
139
|
-
/** `type="…"` values mapped to Blume directive names. */
|
|
140
|
-
typeDirectives: Record<string, string>;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** Read a quoted string attribute (`name="…"` or `name='…'`) from a tag. */
|
|
144
|
-
export const attribute = (attrs: string, name: string): string | undefined => {
|
|
145
|
-
const match = attrs.match(
|
|
146
|
-
new RegExp(`\\b${name}=(?:"(?<dq>[^"]*)"|'(?<sq>[^']*)')`, "u")
|
|
147
|
-
);
|
|
148
|
-
return match?.groups?.dq ?? match?.groups?.sq;
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
/** Remove a shared leading indent and surrounding blank lines from a block. */
|
|
152
|
-
const dedent = (value: string): string => {
|
|
153
|
-
const lines = value
|
|
154
|
-
.replace(/^\r?\n/u, "")
|
|
155
|
-
.replace(/\s+$/u, "")
|
|
156
|
-
.split("\n");
|
|
157
|
-
const indents = lines
|
|
158
|
-
.filter((line) => line.trim() !== "")
|
|
159
|
-
.map((line) => line.match(/^\s*/u)?.[0].length ?? 0);
|
|
160
|
-
const common = indents.length > 0 ? Math.min(...indents) : 0;
|
|
161
|
-
return lines.map((line) => line.slice(common)).join("\n");
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
const directiveBlock = (
|
|
165
|
-
directive: string,
|
|
166
|
-
title: string | undefined,
|
|
167
|
-
inner: string
|
|
168
|
-
): string => {
|
|
169
|
-
const head = title ? `:::${directive}[${title}]` : `:::${directive}`;
|
|
170
|
-
const body = dedent(inner);
|
|
171
|
-
return `${head}\n${body}\n:::`;
|
|
172
|
-
};
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Find the `>` that closes an opening JSX tag, honoring quotes and `{…}`
|
|
176
|
-
* expression attributes (so a `>` inside `icon={"<svg…>"}` is not mistaken for
|
|
177
|
-
* the tag end). Returns -1 if unterminated.
|
|
178
|
-
*/
|
|
179
|
-
export const findOpenTagEnd = (source: string, from: number): number => {
|
|
180
|
-
let depth = 0;
|
|
181
|
-
let quote: '"' | "'" | "`" | null = null;
|
|
182
|
-
for (let index = from; index < source.length; index += 1) {
|
|
183
|
-
const char = source[index];
|
|
184
|
-
if (quote) {
|
|
185
|
-
if (char === quote && source[index - 1] !== "\\") {
|
|
186
|
-
quote = null;
|
|
187
|
-
}
|
|
188
|
-
continue;
|
|
189
|
-
}
|
|
190
|
-
if (char === '"' || char === "'" || char === "`") {
|
|
191
|
-
quote = char;
|
|
192
|
-
} else if (char === "{") {
|
|
193
|
-
depth += 1;
|
|
194
|
-
} else if (char === "}") {
|
|
195
|
-
depth -= 1;
|
|
196
|
-
} else if (char === ">" && depth === 0) {
|
|
197
|
-
return index;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
return -1;
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
const directiveFor = (
|
|
204
|
-
tag: string,
|
|
205
|
-
attrs: string,
|
|
206
|
-
options: CalloutRewriteOptions
|
|
207
|
-
): string | undefined => {
|
|
208
|
-
if (tag in options.tagDirectives) {
|
|
209
|
-
return options.tagDirectives[tag];
|
|
210
|
-
}
|
|
211
|
-
const type = attribute(attrs, "type")?.toLowerCase();
|
|
212
|
-
return type ? options.typeDirectives[type] : options.defaultDirective;
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Convert callout-style JSX components into Blume `:::` directives. Uses a
|
|
217
|
-
* quote/brace-aware tag scanner so callouts carrying JSX-expression attributes
|
|
218
|
-
* (e.g. inline-SVG icons) convert cleanly; non-convertible attributes (icons,
|
|
219
|
-
* colors, emoji) are dropped. A tag whose resolved directive is unknown is left
|
|
220
|
-
* untouched.
|
|
221
|
-
*/
|
|
222
|
-
export const rewriteCallouts = (
|
|
223
|
-
source: string,
|
|
224
|
-
options: CalloutRewriteOptions
|
|
225
|
-
): string => {
|
|
226
|
-
const tagPattern = new RegExp(
|
|
227
|
-
`<(?<tag>${options.tags.join("|")})(?=[\\s/>])`,
|
|
228
|
-
"u"
|
|
229
|
-
);
|
|
230
|
-
let output = "";
|
|
231
|
-
let cursor = 0;
|
|
232
|
-
|
|
233
|
-
while (cursor < source.length) {
|
|
234
|
-
const match = tagPattern.exec(source.slice(cursor));
|
|
235
|
-
if (!match?.groups) {
|
|
236
|
-
output += source.slice(cursor);
|
|
237
|
-
break;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const start = cursor + match.index;
|
|
241
|
-
const { tag } = match.groups;
|
|
242
|
-
if (!tag) {
|
|
243
|
-
output += source.slice(cursor);
|
|
244
|
-
break;
|
|
245
|
-
}
|
|
246
|
-
const openEnd = findOpenTagEnd(source, start + tag.length + 1);
|
|
247
|
-
if (openEnd === -1) {
|
|
248
|
-
output += source.slice(cursor, start + 1);
|
|
249
|
-
cursor = start + 1;
|
|
250
|
-
continue;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const attrs = source.slice(start + tag.length + 1, openEnd);
|
|
254
|
-
const directive = directiveFor(tag, attrs, options);
|
|
255
|
-
const closeTag = `</${tag}>`;
|
|
256
|
-
const selfClosing = attrs.trimEnd().endsWith("/");
|
|
257
|
-
const closeIndex = selfClosing
|
|
258
|
-
? openEnd
|
|
259
|
-
: source.indexOf(closeTag, openEnd + 1);
|
|
260
|
-
|
|
261
|
-
if (!directive || (!selfClosing && closeIndex === -1)) {
|
|
262
|
-
output += source.slice(cursor, openEnd + 1);
|
|
263
|
-
cursor = openEnd + 1;
|
|
264
|
-
continue;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
output += source.slice(cursor, start);
|
|
268
|
-
const title = attribute(attrs, "title");
|
|
269
|
-
if (selfClosing) {
|
|
270
|
-
output += title
|
|
271
|
-
? `:::${directive}[${title}]\n:::`
|
|
272
|
-
: `:::${directive}\n:::`;
|
|
273
|
-
cursor = openEnd + 1;
|
|
274
|
-
} else {
|
|
275
|
-
output += directiveBlock(
|
|
276
|
-
directive,
|
|
277
|
-
title,
|
|
278
|
-
source.slice(openEnd + 1, closeIndex)
|
|
279
|
-
);
|
|
280
|
-
cursor = closeIndex + closeTag.length;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
return output;
|
|
285
|
-
};
|
|
286
|
-
|
|
287
|
-
// ---------------------------------------------------------------------------
|
|
288
|
-
// Page frontmatter
|
|
289
|
-
// ---------------------------------------------------------------------------
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* Remove frontmatter keys Blume's strict page schema would reject (e.g. stray
|
|
293
|
-
* `og:*`/`twitter:*` metatags) so the migrated page validates, reporting what
|
|
294
|
-
* was dropped. Validation errors other than stray keys are left for `blume dev`
|
|
295
|
-
* to surface.
|
|
296
|
-
*/
|
|
297
|
-
export const stripUnknownPageMeta = (
|
|
298
|
-
data: Record<string, unknown>
|
|
299
|
-
): { data: Record<string, unknown>; removed: string[] } => {
|
|
300
|
-
const result = pageMetaSchema.safeParse(data);
|
|
301
|
-
if (result.success) {
|
|
302
|
-
return { data, removed: [] };
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
const removed = new Set<string>();
|
|
306
|
-
for (const issue of result.error.issues) {
|
|
307
|
-
if (issue.code === "unrecognized_keys" && issue.path.length === 0) {
|
|
308
|
-
for (const key of issue.keys) {
|
|
309
|
-
removed.add(key);
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
if (removed.size === 0) {
|
|
314
|
-
return { data, removed: [] };
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
const next: Record<string, unknown> = {};
|
|
318
|
-
for (const [key, value] of Object.entries(data)) {
|
|
319
|
-
if (!removed.has(key)) {
|
|
320
|
-
next[key] = value;
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
return { data: next, removed: [...removed] };
|
|
324
|
-
};
|
|
325
|
-
|
|
326
|
-
// ---------------------------------------------------------------------------
|
|
327
|
-
// JSX tag renaming
|
|
328
|
-
// ---------------------------------------------------------------------------
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* Rename a JSX tag (open and close) while preserving its attributes. The
|
|
332
|
-
* trailing lookahead means a longer tag (e.g. `CardGrid`) is never matched by a
|
|
333
|
-
* rule for its shorter prefix (`Card`), so prefix-sharing renames can be chained
|
|
334
|
-
* — run the item-level rename before the container rename.
|
|
335
|
-
*/
|
|
336
|
-
export const renameTag = (source: string, from: string, to: string): string =>
|
|
337
|
-
source.replaceAll(
|
|
338
|
-
new RegExp(`<(?<close>/?)${from}(?=[\\s/>])`, "gu"),
|
|
339
|
-
`<$<close>${to}`
|
|
340
|
-
);
|
|
341
|
-
|
|
342
|
-
// ---------------------------------------------------------------------------
|
|
343
|
-
// JavaScript literal scanning
|
|
344
|
-
// ---------------------------------------------------------------------------
|
|
345
|
-
|
|
346
|
-
/**
|
|
347
|
-
* Static readers for JS/TS config files (Nextra `_meta`, Starlight
|
|
348
|
-
* `astro.config`). Config is parsed by walking the source as text — quote-,
|
|
349
|
-
* comment-, and bracket-aware — rather than executing user code, matching the
|
|
350
|
-
* other migrators (which never eval). Values that aren't pure literals (an
|
|
351
|
-
* identifier, call, JSX, or interpolated template) are reported as `UNPARSEABLE`
|
|
352
|
-
* so the caller can drop the field and warn.
|
|
353
|
-
*/
|
|
354
|
-
|
|
355
|
-
/** Index of a string within a JS source: the close quote matching `s[open]`. */
|
|
356
|
-
export const findStringEnd = (s: string, open: number): number => {
|
|
357
|
-
const quote = s[open];
|
|
358
|
-
for (let index = open + 1; index < s.length; index += 1) {
|
|
359
|
-
if (s[index] === "\\") {
|
|
360
|
-
index += 1;
|
|
361
|
-
continue;
|
|
362
|
-
}
|
|
363
|
-
if (s[index] === quote) {
|
|
364
|
-
return index;
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
return -1;
|
|
368
|
-
};
|
|
369
|
-
|
|
370
|
-
export const unescapeString = (inner: string): string =>
|
|
371
|
-
inner.replaceAll(/\\(?<ch>["'`\\nt])/gu, (_match, ch: string) => {
|
|
372
|
-
if (ch === "n") {
|
|
373
|
-
return "\n";
|
|
374
|
-
}
|
|
375
|
-
if (ch === "t") {
|
|
376
|
-
return "\t";
|
|
377
|
-
}
|
|
378
|
-
return ch;
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
/** Index of the last char of a `//` or block comment at `index`, else `index`. */
|
|
382
|
-
const skipComment = (source: string, index: number): number => {
|
|
383
|
-
if (source[index + 1] === "/") {
|
|
384
|
-
const newline = source.indexOf("\n", index + 2);
|
|
385
|
-
return newline === -1 ? source.length : newline;
|
|
386
|
-
}
|
|
387
|
-
if (source[index + 1] === "*") {
|
|
388
|
-
const close = source.indexOf("*/", index + 2);
|
|
389
|
-
return close === -1 ? source.length : close + 1;
|
|
390
|
-
}
|
|
391
|
-
return index;
|
|
392
|
-
};
|
|
393
|
-
|
|
394
|
-
const pushSlice = (
|
|
395
|
-
parts: string[],
|
|
396
|
-
source: string,
|
|
397
|
-
start: number,
|
|
398
|
-
end: number
|
|
399
|
-
): void => {
|
|
400
|
-
const raw = source.slice(start, end).trim();
|
|
401
|
-
if (raw) {
|
|
402
|
-
parts.push(raw);
|
|
403
|
-
}
|
|
404
|
-
};
|
|
405
|
-
|
|
406
|
-
export interface ObjectScanResult {
|
|
407
|
-
end: number;
|
|
408
|
-
entries: string[];
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
/**
|
|
412
|
-
* Walk a `{…}` object literal starting at `openIndex`, returning the matching
|
|
413
|
-
* close-brace index and the raw `key: value` text of each top-level entry.
|
|
414
|
-
* Quote-, comment-, and bracket-aware so commas/braces nested in strings,
|
|
415
|
-
* arrays, or child objects don't split entries. Returns null if unterminated.
|
|
416
|
-
*/
|
|
417
|
-
export const scanObject = (
|
|
418
|
-
source: string,
|
|
419
|
-
openIndex: number
|
|
420
|
-
): ObjectScanResult | null => {
|
|
421
|
-
let depth = 0;
|
|
422
|
-
const entries: string[] = [];
|
|
423
|
-
let entryStart = openIndex + 1;
|
|
424
|
-
|
|
425
|
-
for (let index = openIndex; index < source.length; index += 1) {
|
|
426
|
-
const char = source[index];
|
|
427
|
-
|
|
428
|
-
if (char === '"' || char === "'" || char === "`") {
|
|
429
|
-
const end = findStringEnd(source, index);
|
|
430
|
-
index = end === -1 ? source.length : end;
|
|
431
|
-
continue;
|
|
432
|
-
}
|
|
433
|
-
if (char === "/") {
|
|
434
|
-
const skipped = skipComment(source, index);
|
|
435
|
-
if (skipped !== index) {
|
|
436
|
-
index = skipped;
|
|
437
|
-
continue;
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
if (char === "{" || char === "[" || char === "(") {
|
|
441
|
-
depth += 1;
|
|
442
|
-
continue;
|
|
443
|
-
}
|
|
444
|
-
if (char === "}" || char === "]" || char === ")") {
|
|
445
|
-
depth -= 1;
|
|
446
|
-
if (char === "}" && depth === 0) {
|
|
447
|
-
pushSlice(entries, source, entryStart, index);
|
|
448
|
-
return { end: index, entries };
|
|
449
|
-
}
|
|
450
|
-
continue;
|
|
451
|
-
}
|
|
452
|
-
if (char === "," && depth === 1) {
|
|
453
|
-
pushSlice(entries, source, entryStart, index);
|
|
454
|
-
entryStart = index + 1;
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
return null;
|
|
459
|
-
};
|
|
460
|
-
|
|
461
|
-
export interface ArrayScanResult {
|
|
462
|
-
elements: string[];
|
|
463
|
-
end: number;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/**
|
|
467
|
-
* Walk a `[…]` array literal starting at `openIndex`, returning the matching
|
|
468
|
-
* close-bracket index and the raw text of each top-level element. The sibling of
|
|
469
|
-
* {@link scanObject}; a trailing comma yields no empty element.
|
|
470
|
-
*/
|
|
471
|
-
export const scanArray = (
|
|
472
|
-
source: string,
|
|
473
|
-
openIndex: number
|
|
474
|
-
): ArrayScanResult | null => {
|
|
475
|
-
let depth = 0;
|
|
476
|
-
const elements: string[] = [];
|
|
477
|
-
let elementStart = openIndex + 1;
|
|
478
|
-
|
|
479
|
-
for (let index = openIndex; index < source.length; index += 1) {
|
|
480
|
-
const char = source[index];
|
|
481
|
-
|
|
482
|
-
if (char === '"' || char === "'" || char === "`") {
|
|
483
|
-
const end = findStringEnd(source, index);
|
|
484
|
-
index = end === -1 ? source.length : end;
|
|
485
|
-
continue;
|
|
486
|
-
}
|
|
487
|
-
if (char === "/") {
|
|
488
|
-
const skipped = skipComment(source, index);
|
|
489
|
-
if (skipped !== index) {
|
|
490
|
-
index = skipped;
|
|
491
|
-
continue;
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
if (char === "{" || char === "[" || char === "(") {
|
|
495
|
-
depth += 1;
|
|
496
|
-
continue;
|
|
497
|
-
}
|
|
498
|
-
if (char === "}" || char === ")") {
|
|
499
|
-
depth -= 1;
|
|
500
|
-
continue;
|
|
501
|
-
}
|
|
502
|
-
if (char === "]") {
|
|
503
|
-
depth -= 1;
|
|
504
|
-
if (depth === 0) {
|
|
505
|
-
pushSlice(elements, source, elementStart, index);
|
|
506
|
-
return { elements, end: index };
|
|
507
|
-
}
|
|
508
|
-
continue;
|
|
509
|
-
}
|
|
510
|
-
if (char === "," && depth === 1) {
|
|
511
|
-
pushSlice(elements, source, elementStart, index);
|
|
512
|
-
elementStart = index + 1;
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
return null;
|
|
517
|
-
};
|
|
518
|
-
|
|
519
|
-
/**
|
|
520
|
-
* Strip `//` and block comments so they don't leak into entry text (the scanner
|
|
521
|
-
* splits on slices, so an inter-entry comment would otherwise attach to the next
|
|
522
|
-
* entry). String literals are preserved verbatim.
|
|
523
|
-
*/
|
|
524
|
-
export const stripJsComments = (source: string): string => {
|
|
525
|
-
let out = "";
|
|
526
|
-
let quote: string | null = null;
|
|
527
|
-
for (let index = 0; index < source.length; index += 1) {
|
|
528
|
-
const char = source[index];
|
|
529
|
-
if (quote) {
|
|
530
|
-
out += char;
|
|
531
|
-
if (char === "\\") {
|
|
532
|
-
out += source[index + 1] ?? "";
|
|
533
|
-
index += 1;
|
|
534
|
-
} else if (char === quote) {
|
|
535
|
-
quote = null;
|
|
536
|
-
}
|
|
537
|
-
continue;
|
|
538
|
-
}
|
|
539
|
-
if (char === '"' || char === "'" || char === "`") {
|
|
540
|
-
quote = char;
|
|
541
|
-
out += char;
|
|
542
|
-
continue;
|
|
543
|
-
}
|
|
544
|
-
if (char === "/" && source[index + 1] === "/") {
|
|
545
|
-
const newline = source.indexOf("\n", index + 2);
|
|
546
|
-
index = newline === -1 ? source.length - 1 : newline - 1;
|
|
547
|
-
continue;
|
|
548
|
-
}
|
|
549
|
-
if (char === "/" && source[index + 1] === "*") {
|
|
550
|
-
const close = source.indexOf("*/", index + 2);
|
|
551
|
-
index = close === -1 ? source.length - 1 : close + 1;
|
|
552
|
-
out += " ";
|
|
553
|
-
continue;
|
|
554
|
-
}
|
|
555
|
-
out += char;
|
|
556
|
-
}
|
|
557
|
-
return out;
|
|
558
|
-
};
|
|
559
|
-
|
|
560
|
-
export interface KeyValue {
|
|
561
|
-
key: string;
|
|
562
|
-
value: string;
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
/** Split a raw `key: value` entry at its top-level colon. */
|
|
566
|
-
export const splitKeyValue = (entry: string): KeyValue | null => {
|
|
567
|
-
let index = 0;
|
|
568
|
-
while (index < entry.length && /\s/u.test(entry[index] ?? "")) {
|
|
569
|
-
index += 1;
|
|
570
|
-
}
|
|
571
|
-
const first = entry[index];
|
|
572
|
-
if (first === "[") {
|
|
573
|
-
// Computed key — not something we can resolve statically.
|
|
574
|
-
return null;
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
let key: string;
|
|
578
|
-
if (first === '"' || first === "'" || first === "`") {
|
|
579
|
-
const close = findStringEnd(entry, index);
|
|
580
|
-
if (close === -1) {
|
|
581
|
-
return null;
|
|
582
|
-
}
|
|
583
|
-
key = entry.slice(index, close + 1);
|
|
584
|
-
index = close + 1;
|
|
585
|
-
} else {
|
|
586
|
-
const start = index;
|
|
587
|
-
while (index < entry.length && !/[\s:]/u.test(entry[index] ?? "")) {
|
|
588
|
-
index += 1;
|
|
589
|
-
}
|
|
590
|
-
key = entry.slice(start, index);
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
while (index < entry.length && /\s/u.test(entry[index] ?? "")) {
|
|
594
|
-
index += 1;
|
|
595
|
-
}
|
|
596
|
-
if (entry[index] !== ":") {
|
|
597
|
-
return { key, value: "" };
|
|
598
|
-
}
|
|
599
|
-
return { key, value: entry.slice(index + 1).trim() };
|
|
600
|
-
};
|
|
601
|
-
|
|
602
|
-
/** Read an object key, unquoting it when it is a string literal. */
|
|
603
|
-
export const parseKey = (key: string): string => {
|
|
604
|
-
const trimmed = key.trim();
|
|
605
|
-
const [quote] = trimmed;
|
|
606
|
-
if (quote === '"' || quote === "'" || quote === "`") {
|
|
607
|
-
const end = findStringEnd(trimmed, 0);
|
|
608
|
-
if (end !== -1) {
|
|
609
|
-
return unescapeString(trimmed.slice(1, end));
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
return trimmed;
|
|
613
|
-
};
|
|
614
|
-
|
|
615
|
-
/** Read a clean string literal value, or null if it's an expression. */
|
|
616
|
-
export const readString = (value: string): string | null => {
|
|
617
|
-
const trimmed = value.trim();
|
|
618
|
-
const [quote] = trimmed;
|
|
619
|
-
if (quote !== '"' && quote !== "'" && quote !== "`") {
|
|
620
|
-
return null;
|
|
621
|
-
}
|
|
622
|
-
if (quote === "`" && trimmed.includes("${")) {
|
|
623
|
-
return null;
|
|
624
|
-
}
|
|
625
|
-
const end = findStringEnd(trimmed, 0);
|
|
626
|
-
if (end === -1 || trimmed.slice(end + 1).trim() !== "") {
|
|
627
|
-
return null;
|
|
628
|
-
}
|
|
629
|
-
return unescapeString(trimmed.slice(1, end));
|
|
630
|
-
};
|
|
631
|
-
|
|
632
|
-
/** A value that isn't a pure literal (identifier, call, JSX, computed, …). */
|
|
633
|
-
export const UNPARSEABLE = Symbol("unparseable");
|
|
634
|
-
|
|
635
|
-
export type LiteralValue =
|
|
636
|
-
| LiteralValue[]
|
|
637
|
-
| boolean
|
|
638
|
-
| null
|
|
639
|
-
| number
|
|
640
|
-
| string
|
|
641
|
-
| typeof UNPARSEABLE
|
|
642
|
-
| { [key: string]: LiteralValue };
|
|
643
|
-
|
|
644
|
-
const NUMERIC = /^-?\d/u;
|
|
645
|
-
|
|
646
|
-
const parseScalarLiteral = (trimmed: string): LiteralValue => {
|
|
647
|
-
if (trimmed === "true") {
|
|
648
|
-
return true;
|
|
649
|
-
}
|
|
650
|
-
if (trimmed === "false") {
|
|
651
|
-
return false;
|
|
652
|
-
}
|
|
653
|
-
if (trimmed === "null") {
|
|
654
|
-
return null;
|
|
655
|
-
}
|
|
656
|
-
if (NUMERIC.test(trimmed)) {
|
|
657
|
-
const num = Number(trimmed);
|
|
658
|
-
if (!Number.isNaN(num)) {
|
|
659
|
-
return num;
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
return UNPARSEABLE;
|
|
663
|
-
};
|
|
664
|
-
|
|
665
|
-
const parseObjectLiteral = (trimmed: string): LiteralValue => {
|
|
666
|
-
const scan = scanObject(trimmed, 0);
|
|
667
|
-
if (!scan || trimmed.slice(scan.end + 1).trim() !== "") {
|
|
668
|
-
return UNPARSEABLE;
|
|
669
|
-
}
|
|
670
|
-
const out: Record<string, LiteralValue> = {};
|
|
671
|
-
for (const entry of scan.entries) {
|
|
672
|
-
const kv = splitKeyValue(entry);
|
|
673
|
-
if (kv?.key && kv.value !== "") {
|
|
674
|
-
// oxlint-disable-next-line no-use-before-define -- mutual recursion
|
|
675
|
-
out[parseKey(kv.key)] = parseLiteral(kv.value);
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
return out;
|
|
679
|
-
};
|
|
680
|
-
|
|
681
|
-
const parseArrayLiteral = (trimmed: string): LiteralValue => {
|
|
682
|
-
const scan = scanArray(trimmed, 0);
|
|
683
|
-
if (!scan || trimmed.slice(scan.end + 1).trim() !== "") {
|
|
684
|
-
return UNPARSEABLE;
|
|
685
|
-
}
|
|
686
|
-
// oxlint-disable-next-line no-use-before-define -- mutual recursion
|
|
687
|
-
return scan.elements.map((element) => parseLiteral(element));
|
|
688
|
-
};
|
|
689
|
-
|
|
690
|
-
/**
|
|
691
|
-
* Evaluate a JS literal expression (string / number / boolean / null / array /
|
|
692
|
-
* object) into its value without executing it. Anything else resolves to
|
|
693
|
-
* {@link UNPARSEABLE}; inside arrays the sentinel keeps the element's position,
|
|
694
|
-
* inside objects it stays as the field's value so the caller can warn and drop.
|
|
695
|
-
*/
|
|
696
|
-
export const parseLiteral = (source: string): LiteralValue => {
|
|
697
|
-
const trimmed = source.trim();
|
|
698
|
-
if (trimmed === "") {
|
|
699
|
-
return UNPARSEABLE;
|
|
700
|
-
}
|
|
701
|
-
const [first] = trimmed;
|
|
702
|
-
if (first === '"' || first === "'" || first === "`") {
|
|
703
|
-
return readString(trimmed) ?? UNPARSEABLE;
|
|
704
|
-
}
|
|
705
|
-
if (first === "{") {
|
|
706
|
-
return parseObjectLiteral(trimmed);
|
|
707
|
-
}
|
|
708
|
-
if (first === "[") {
|
|
709
|
-
return parseArrayLiteral(trimmed);
|
|
710
|
-
}
|
|
711
|
-
return parseScalarLiteral(trimmed);
|
|
712
|
-
};
|
|
713
|
-
|
|
714
|
-
/** Narrow a parsed literal to a string. */
|
|
715
|
-
export const asLiteralString = (
|
|
716
|
-
value: LiteralValue | undefined
|
|
717
|
-
): string | undefined => (typeof value === "string" ? value : undefined);
|
|
718
|
-
|
|
719
|
-
/** Narrow a parsed literal to a plain object (not an array or `UNPARSEABLE`). */
|
|
720
|
-
export const isLiteralObject = (
|
|
721
|
-
value: LiteralValue | undefined
|
|
722
|
-
): value is Record<string, LiteralValue> =>
|
|
723
|
-
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
724
|
-
|
|
725
|
-
/** Narrow a parsed literal to an array. */
|
|
726
|
-
export const asLiteralArray = (
|
|
727
|
-
value: LiteralValue | undefined
|
|
728
|
-
): LiteralValue[] | undefined => (Array.isArray(value) ? value : undefined);
|