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.
Files changed (137) hide show
  1. package/dist/cli/index.js +2235 -5942
  2. package/dist/cli/index.js.map +36 -65
  3. package/dist/types/core/config.d.ts +0 -8
  4. package/dist/types/core/data.d.ts +2 -2
  5. package/dist/types/core/schema.d.ts +58 -453
  6. package/dist/types/core/types.d.ts +1 -7
  7. package/docs/advanced/meta.ts +1 -8
  8. package/docs/advanced/skills.mdx +28 -0
  9. package/docs/configuration/ai.mdx +3 -1
  10. package/docs/configuration/index.mdx +13 -17
  11. package/docs/configuration/theming.mdx +3 -11
  12. package/docs/content/components.mdx +2 -53
  13. package/docs/content/i18n.mdx +1 -5
  14. package/docs/content/sources.mdx +1 -1
  15. package/docs/content/syntax.mdx +1 -1
  16. package/docs/index.mdx +0 -1
  17. package/docs/reference/cli.mdx +12 -13
  18. package/package.json +4 -6
  19. package/skills/blume/SKILL.md +71 -0
  20. package/skills/blume-update-docs/SKILL.md +52 -0
  21. package/skills/blume-update-docs/references/audit-checklist.md +46 -0
  22. package/src/ai/mcp/discovery.ts +3 -1
  23. package/src/ai/mcp/server.ts +3 -1
  24. package/src/astro/component-slots.ts +10 -2
  25. package/src/astro/generate.ts +15 -12
  26. package/src/astro/integration.ts +0 -21
  27. package/src/astro/templates.ts +62 -37
  28. package/src/cli/coalesce.ts +43 -0
  29. package/src/cli/commands/dev.ts +30 -22
  30. package/src/cli/commands/validate.ts +0 -2
  31. package/src/cli/dev-lock.ts +4 -2
  32. package/src/cli/index.ts +0 -2
  33. package/src/components/BlumePage.astro +0 -6
  34. package/src/components/Icon.astro +1 -12
  35. package/src/components/content/AccordionItem.astro +3 -6
  36. package/src/components/content/Badge.astro +1 -3
  37. package/src/components/content/Callout.astro +3 -9
  38. package/src/components/content/Card.astro +2 -3
  39. package/src/components/content/ColorItem.astro +8 -5
  40. package/src/components/content/Column.astro +1 -1
  41. package/src/components/content/Prompt.astro +8 -4
  42. package/src/components/content/Step.astro +3 -4
  43. package/src/components/content/Tab.astro +2 -3
  44. package/src/components/content/Tabs.astro +13 -2
  45. package/src/components/content/TypeTable.astro +5 -5
  46. package/src/components/content/Update.astro +1 -1
  47. package/src/components/content/mermaid-element.ts +20 -2
  48. package/src/components/islands/ask-ai.tsx +4 -8
  49. package/src/components/islands/base-path.ts +30 -0
  50. package/src/components/islands/hooks.ts +12 -8
  51. package/src/components/layout/Header.astro +26 -3
  52. package/src/components/layout/Logo.astro +5 -1
  53. package/src/components/layout/NavSelector.astro +1 -1
  54. package/src/components/layout/NavTree.astro +14 -14
  55. package/src/components/layout/PageActions.astro +17 -11
  56. package/src/components/layout/PageLayout.astro +42 -0
  57. package/src/components/layout/ReferenceLayout.astro +1 -0
  58. package/src/components/layout/RootLayout.astro +31 -2
  59. package/src/components/layout/Search.astro +9 -6
  60. package/src/components/layout/nav-utils.ts +9 -4
  61. package/src/components/layout/search/types.ts +16 -5
  62. package/src/components/openapi/ApiOverview.astro +4 -50
  63. package/src/components/openapi/ApiTagOperations.astro +42 -0
  64. package/src/components/openapi/ParametersTable.astro +1 -1
  65. package/src/components/openapi/SchemaProperty.astro +1 -1
  66. package/src/components/openapi/SchemaTable.astro +3 -3
  67. package/src/components/openapi/helpers.ts +17 -8
  68. package/src/components/openapi/snippets.ts +17 -4
  69. package/src/core/builtin-tags.ts +1 -3
  70. package/src/core/config.ts +19 -33
  71. package/src/core/data.ts +2 -2
  72. package/src/core/graph.ts +7 -5
  73. package/src/core/links.ts +5 -19
  74. package/src/core/meta.ts +1 -1
  75. package/src/core/navigation.ts +5 -6
  76. package/src/core/project-graph.ts +0 -5
  77. package/src/core/schema.ts +13 -90
  78. package/src/core/sources/filesystem.ts +19 -1
  79. package/src/core/sources/mdx-remote.ts +20 -4
  80. package/src/core/sources/normalize.ts +28 -6
  81. package/src/core/sources/resolve.ts +0 -13
  82. package/src/core/sources/watch.ts +45 -0
  83. package/src/core/types.ts +1 -8
  84. package/src/markdown/code-title.ts +6 -3
  85. package/src/markdown/package-install.ts +3 -1
  86. package/src/og/card.ts +16 -4
  87. package/src/openapi/parse.ts +197 -14
  88. package/src/openapi/render-mdx.ts +54 -11
  89. package/src/openapi/source.ts +19 -2
  90. package/src/search/sync/orama-cloud.ts +2 -0
  91. package/src/search/sync/typesense.ts +4 -0
  92. package/src/theme/entry.ts +45 -17
  93. package/src/theme/icons.ts +22 -104
  94. package/src/theme/palette.ts +35 -47
  95. package/src/theme/twoslash.ts +6 -1
  96. package/dist/types/core/bridge.d.ts +0 -24
  97. package/dist/types/migrate/mintlify/assets.d.ts +0 -8
  98. package/dist/types/migrate/mintlify/config.d.ts +0 -16
  99. package/dist/types/migrate/mintlify/i18n.d.ts +0 -7
  100. package/docs/advanced/bridge.mdx +0 -76
  101. package/docs/advanced/migrate.mdx +0 -123
  102. package/src/astro/static-assets.ts +0 -117
  103. package/src/cli/commands/migrate.ts +0 -39
  104. package/src/components/content/ApiField.astro +0 -75
  105. package/src/components/content/ParamField.astro +0 -39
  106. package/src/components/content/RequestField.astro +0 -23
  107. package/src/components/content/ResponseField.astro +0 -23
  108. package/src/components/content/Warning.astro +0 -9
  109. package/src/core/assets.ts +0 -31
  110. package/src/core/bridge.ts +0 -102
  111. package/src/core/sources/mintlify.ts +0 -204
  112. package/src/migrate/fumadocs/config.ts +0 -155
  113. package/src/migrate/fumadocs/content.ts +0 -378
  114. package/src/migrate/fumadocs/frontmatter.ts +0 -18
  115. package/src/migrate/fumadocs/groups.ts +0 -237
  116. package/src/migrate/fumadocs/index.ts +0 -340
  117. package/src/migrate/fumadocs/meta.ts +0 -244
  118. package/src/migrate/migrate.ts +0 -53
  119. package/src/migrate/mintlify/assets.ts +0 -46
  120. package/src/migrate/mintlify/config.ts +0 -958
  121. package/src/migrate/mintlify/content.ts +0 -120
  122. package/src/migrate/mintlify/frontmatter.ts +0 -126
  123. package/src/migrate/mintlify/i18n.ts +0 -51
  124. package/src/migrate/mintlify/icons.ts +0 -128
  125. package/src/migrate/mintlify/index.ts +0 -372
  126. package/src/migrate/mintlify/snippets.ts +0 -306
  127. package/src/migrate/mintlify/transform.ts +0 -82
  128. package/src/migrate/nextra/content.ts +0 -46
  129. package/src/migrate/nextra/frontmatter.ts +0 -40
  130. package/src/migrate/nextra/index.ts +0 -374
  131. package/src/migrate/nextra/meta.ts +0 -266
  132. package/src/migrate/shared.ts +0 -728
  133. package/src/migrate/starlight/config.ts +0 -455
  134. package/src/migrate/starlight/content.ts +0 -78
  135. package/src/migrate/starlight/frontmatter.ts +0 -111
  136. package/src/migrate/starlight/i18n.ts +0 -54
  137. package/src/migrate/starlight/index.ts +0 -131
@@ -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);