blume 0.0.0 → 0.1.0

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 (263) hide show
  1. package/bin/blume.mjs +18 -0
  2. package/dist/cli/index.js +11989 -0
  3. package/dist/cli/index.js.map +141 -0
  4. package/docs/01-quickstart.mdx +99 -0
  5. package/docs/02-deployment.mdx +129 -0
  6. package/docs/advanced/api-reference.mdx +114 -0
  7. package/docs/advanced/blog.mdx +121 -0
  8. package/docs/advanced/changelog.mdx +113 -0
  9. package/docs/advanced/custom-pages.mdx +187 -0
  10. package/docs/advanced/meta.ts +7 -0
  11. package/docs/changelog/v0-1-0.mdx +12 -0
  12. package/docs/changelog/v0-2-0.mdx +16 -0
  13. package/docs/configuration/ai.mdx +228 -0
  14. package/docs/configuration/analytics.mdx +98 -0
  15. package/docs/configuration/customization.mdx +91 -0
  16. package/docs/configuration/export.mdx +70 -0
  17. package/docs/configuration/index.mdx +271 -0
  18. package/docs/configuration/meta.ts +15 -0
  19. package/docs/configuration/search.mdx +172 -0
  20. package/docs/configuration/seo.mdx +196 -0
  21. package/docs/configuration/theming.mdx +178 -0
  22. package/docs/content/components.mdx +565 -0
  23. package/docs/content/i18n.mdx +205 -0
  24. package/docs/content/index.mdx +161 -0
  25. package/docs/content/islands.mdx +94 -0
  26. package/docs/content/meta.mdx +119 -0
  27. package/docs/content/meta.ts +15 -0
  28. package/docs/content/navigation.mdx +168 -0
  29. package/docs/content/sources.mdx +216 -0
  30. package/docs/content/syntax.mdx +445 -0
  31. package/docs/index.mdx +112 -0
  32. package/docs/reference/cli.mdx +43 -0
  33. package/docs/reference/frontmatter.mdx +74 -0
  34. package/docs/reference/meta.ts +7 -0
  35. package/package.json +140 -6
  36. package/src/ai/ask.ts +93 -0
  37. package/src/ai/llms.ts +65 -0
  38. package/src/ai/markdown.ts +31 -0
  39. package/src/ai/mcp/data.ts +74 -0
  40. package/src/ai/mcp/discovery.ts +49 -0
  41. package/src/ai/mcp/server.ts +225 -0
  42. package/src/ai/mcp/tools.ts +47 -0
  43. package/src/assets/icon.png +0 -0
  44. package/src/astro/generate.ts +878 -0
  45. package/src/astro/index.ts +4 -0
  46. package/src/astro/integration.ts +74 -0
  47. package/src/astro/islands.ts +131 -0
  48. package/src/astro/markdown-negotiation.ts +68 -0
  49. package/src/astro/pages.ts +28 -0
  50. package/src/astro/templates.ts +1199 -0
  51. package/src/cli/commands/add.ts +81 -0
  52. package/src/cli/commands/build.ts +103 -0
  53. package/src/cli/commands/dev.ts +108 -0
  54. package/src/cli/commands/doctor.ts +74 -0
  55. package/src/cli/commands/eject.ts +57 -0
  56. package/src/cli/commands/init.ts +98 -0
  57. package/src/cli/commands/migrate.ts +39 -0
  58. package/src/cli/commands/preview.ts +39 -0
  59. package/src/cli/commands/sync.ts +52 -0
  60. package/src/cli/commands/validate.ts +60 -0
  61. package/src/cli/index.ts +35 -0
  62. package/src/cli/log.ts +37 -0
  63. package/src/cli/prepare.ts +80 -0
  64. package/src/components/Icon.astro +99 -0
  65. package/src/components/content/Accordion.astro +8 -0
  66. package/src/components/content/AccordionItem.astro +121 -0
  67. package/src/components/content/AutoTypeTable.astro +51 -0
  68. package/src/components/content/Badge.astro +124 -0
  69. package/src/components/content/Callout.astro +73 -0
  70. package/src/components/content/Card.astro +104 -0
  71. package/src/components/content/CardGroup.astro +14 -0
  72. package/src/components/content/CodeGroup.astro +13 -0
  73. package/src/components/content/Color.astro +15 -0
  74. package/src/components/content/ColorItem.astro +87 -0
  75. package/src/components/content/ColorRow.astro +10 -0
  76. package/src/components/content/Column.astro +6 -0
  77. package/src/components/content/Columns.astro +9 -0
  78. package/src/components/content/Expandable.astro +11 -0
  79. package/src/components/content/FileTree.astro +8 -0
  80. package/src/components/content/Frame.astro +70 -0
  81. package/src/components/content/GithubInfo.astro +110 -0
  82. package/src/components/content/Math.astro +24 -0
  83. package/src/components/content/Panel.astro +20 -0
  84. package/src/components/content/Prompt.astro +129 -0
  85. package/src/components/content/Step.astro +34 -0
  86. package/src/components/content/Steps.astro +20 -0
  87. package/src/components/content/Tab.astro +40 -0
  88. package/src/components/content/Tabs.astro +273 -0
  89. package/src/components/content/Tile.astro +42 -0
  90. package/src/components/content/Tooltip.astro +68 -0
  91. package/src/components/content/Tree.astro +300 -0
  92. package/src/components/content/TreeFile.astro +15 -0
  93. package/src/components/content/TreeFolder.astro +62 -0
  94. package/src/components/content/TypeTable.astro +106 -0
  95. package/src/components/content/Update.astro +66 -0
  96. package/src/components/content/Visibility.astro +12 -0
  97. package/src/components/content/Warning.astro +9 -0
  98. package/src/components/content/auto-type-table.ts +141 -0
  99. package/src/components/content/github-info.ts +79 -0
  100. package/src/components/content/mermaid-element.ts +68 -0
  101. package/src/components/github-mark.ts +9 -0
  102. package/src/components/index.ts +14 -0
  103. package/src/components/islands/AskAI.astro +12 -0
  104. package/src/components/islands/ask-ai.tsx +156 -0
  105. package/src/components/layout/Analytics.astro +63 -0
  106. package/src/components/layout/Banner.astro +50 -0
  107. package/src/components/layout/Breadcrumbs.astro +31 -0
  108. package/src/components/layout/Favicon.astro +15 -0
  109. package/src/components/layout/Fonts.astro +14 -0
  110. package/src/components/layout/Header.astro +188 -0
  111. package/src/components/layout/LanguageSwitcher.astro +56 -0
  112. package/src/components/layout/NavTree.astro +462 -0
  113. package/src/components/layout/PageActions.astro +438 -0
  114. package/src/components/layout/PageFeedback.astro +58 -0
  115. package/src/components/layout/Pagination.astro +56 -0
  116. package/src/components/layout/ReferenceLayout.astro +102 -0
  117. package/src/components/layout/RootLayout.astro +533 -0
  118. package/src/components/layout/Search.astro +608 -0
  119. package/src/components/layout/TableOfContents.astro +68 -0
  120. package/src/components/layout/analytics-client.ts +38 -0
  121. package/src/components/layout/nav-utils.ts +87 -0
  122. package/src/components/layout/overrides.ts +32 -0
  123. package/src/components/layout/search/algolia.ts +43 -0
  124. package/src/components/layout/search/endpoint.ts +22 -0
  125. package/src/components/layout/search/flexsearch.ts +52 -0
  126. package/src/components/layout/search/orama-cloud.ts +41 -0
  127. package/src/components/layout/search/orama.ts +26 -0
  128. package/src/components/layout/search/pagefind.ts +43 -0
  129. package/src/components/layout/search/types.ts +163 -0
  130. package/src/components/layout/search/typesense.ts +60 -0
  131. package/src/components/layout/toc-element.ts +108 -0
  132. package/src/core/bridge.ts +92 -0
  133. package/src/core/config.ts +112 -0
  134. package/src/core/content.ts +50 -0
  135. package/src/core/define-components.ts +34 -0
  136. package/src/core/define-meta.ts +20 -0
  137. package/src/core/deployment-env.ts +73 -0
  138. package/src/core/diagnostics.ts +104 -0
  139. package/src/core/graph.ts +128 -0
  140. package/src/core/i18n-ui.ts +171 -0
  141. package/src/core/i18n.ts +169 -0
  142. package/src/core/last-modified.ts +88 -0
  143. package/src/core/links.ts +336 -0
  144. package/src/core/load-module.ts +15 -0
  145. package/src/core/manifest.ts +126 -0
  146. package/src/core/meta.ts +97 -0
  147. package/src/core/navigation.ts +392 -0
  148. package/src/core/package-root.ts +37 -0
  149. package/src/core/project-graph.ts +153 -0
  150. package/src/core/project.ts +56 -0
  151. package/src/core/schema.ts +1057 -0
  152. package/src/core/server-features.ts +23 -0
  153. package/src/core/sources/assets.ts +77 -0
  154. package/src/core/sources/cache.ts +122 -0
  155. package/src/core/sources/filesystem.ts +99 -0
  156. package/src/core/sources/mdx-remote.ts +216 -0
  157. package/src/core/sources/mintlify.ts +161 -0
  158. package/src/core/sources/normalize.ts +227 -0
  159. package/src/core/sources/notion.ts +440 -0
  160. package/src/core/sources/portable-text.ts +143 -0
  161. package/src/core/sources/read.ts +36 -0
  162. package/src/core/sources/resolve.ts +158 -0
  163. package/src/core/sources/sanity.ts +218 -0
  164. package/src/core/sources/types.ts +105 -0
  165. package/src/core/types.ts +261 -0
  166. package/src/core/ui-packs/ar.ts +47 -0
  167. package/src/core/ui-packs/bg.ts +47 -0
  168. package/src/core/ui-packs/bn.ts +47 -0
  169. package/src/core/ui-packs/ca.ts +47 -0
  170. package/src/core/ui-packs/cs.ts +47 -0
  171. package/src/core/ui-packs/da.ts +47 -0
  172. package/src/core/ui-packs/de.ts +47 -0
  173. package/src/core/ui-packs/el.ts +47 -0
  174. package/src/core/ui-packs/es.ts +47 -0
  175. package/src/core/ui-packs/fa.ts +47 -0
  176. package/src/core/ui-packs/fi.ts +47 -0
  177. package/src/core/ui-packs/fr.ts +47 -0
  178. package/src/core/ui-packs/he.ts +47 -0
  179. package/src/core/ui-packs/hi.ts +47 -0
  180. package/src/core/ui-packs/hr.ts +47 -0
  181. package/src/core/ui-packs/hu.ts +47 -0
  182. package/src/core/ui-packs/id.ts +47 -0
  183. package/src/core/ui-packs/index.ts +87 -0
  184. package/src/core/ui-packs/it.ts +47 -0
  185. package/src/core/ui-packs/ja.ts +47 -0
  186. package/src/core/ui-packs/ko.ts +47 -0
  187. package/src/core/ui-packs/nl.ts +47 -0
  188. package/src/core/ui-packs/no.ts +47 -0
  189. package/src/core/ui-packs/pl.ts +47 -0
  190. package/src/core/ui-packs/pt-br.ts +47 -0
  191. package/src/core/ui-packs/pt.ts +47 -0
  192. package/src/core/ui-packs/ro.ts +47 -0
  193. package/src/core/ui-packs/ru.ts +47 -0
  194. package/src/core/ui-packs/sk.ts +47 -0
  195. package/src/core/ui-packs/sr.ts +47 -0
  196. package/src/core/ui-packs/sv.ts +47 -0
  197. package/src/core/ui-packs/th.ts +47 -0
  198. package/src/core/ui-packs/tr.ts +47 -0
  199. package/src/core/ui-packs/uk.ts +47 -0
  200. package/src/core/ui-packs/vi.ts +47 -0
  201. package/src/core/ui-packs/zh-tw.ts +47 -0
  202. package/src/core/ui-packs/zh.ts +47 -0
  203. package/src/core/version.ts +23 -0
  204. package/src/deploy/robots.ts +20 -0
  205. package/src/deploy/rss.ts +128 -0
  206. package/src/deploy/sitemap.ts +28 -0
  207. package/src/index.ts +27 -0
  208. package/src/markdown/code-title.ts +71 -0
  209. package/src/markdown/directives.ts +83 -0
  210. package/src/markdown/heading-anchors.ts +137 -0
  211. package/src/markdown/index.ts +159 -0
  212. package/src/markdown/inline-code.ts +108 -0
  213. package/src/markdown/language-icon.ts +172 -0
  214. package/src/markdown/math.ts +32 -0
  215. package/src/markdown/mdast.ts +48 -0
  216. package/src/markdown/mermaid.ts +37 -0
  217. package/src/markdown/package-commands.ts +159 -0
  218. package/src/markdown/package-install.ts +40 -0
  219. package/src/migrate/fumadocs/config.ts +106 -0
  220. package/src/migrate/fumadocs/content.ts +365 -0
  221. package/src/migrate/fumadocs/frontmatter.ts +18 -0
  222. package/src/migrate/fumadocs/index.ts +252 -0
  223. package/src/migrate/fumadocs/meta.ts +114 -0
  224. package/src/migrate/migrate.ts +53 -0
  225. package/src/migrate/mintlify/config.ts +1040 -0
  226. package/src/migrate/mintlify/content.ts +98 -0
  227. package/src/migrate/mintlify/frontmatter.ts +126 -0
  228. package/src/migrate/mintlify/i18n.ts +51 -0
  229. package/src/migrate/mintlify/icons.ts +128 -0
  230. package/src/migrate/mintlify/index.ts +266 -0
  231. package/src/migrate/mintlify/snippets.ts +305 -0
  232. package/src/migrate/mintlify/transform.ts +81 -0
  233. package/src/migrate/nextra/content.ts +46 -0
  234. package/src/migrate/nextra/frontmatter.ts +40 -0
  235. package/src/migrate/nextra/index.ts +374 -0
  236. package/src/migrate/nextra/meta.ts +266 -0
  237. package/src/migrate/shared.ts +623 -0
  238. package/src/migrate/starlight/config.ts +459 -0
  239. package/src/migrate/starlight/content.ts +78 -0
  240. package/src/migrate/starlight/frontmatter.ts +111 -0
  241. package/src/migrate/starlight/i18n.ts +54 -0
  242. package/src/migrate/starlight/index.ts +131 -0
  243. package/src/og/card.ts +92 -0
  244. package/src/og/index.ts +2 -0
  245. package/src/openapi/scalar.ts +246 -0
  246. package/src/registry/eject.ts +263 -0
  247. package/src/registry/registry.ts +100 -0
  248. package/src/registry/rewrite-imports.ts +39 -0
  249. package/src/runtime/index.ts +14 -0
  250. package/src/search/build.ts +23 -0
  251. package/src/search/documents.ts +165 -0
  252. package/src/search/orama-index.ts +66 -0
  253. package/src/search/providers.ts +91 -0
  254. package/src/search/sync/algolia.ts +30 -0
  255. package/src/search/sync/index.ts +50 -0
  256. package/src/search/sync/orama-cloud.ts +40 -0
  257. package/src/search/sync/typesense.ts +65 -0
  258. package/src/seo/jsonld.ts +113 -0
  259. package/src/theme/entry.ts +608 -0
  260. package/src/theme/fonts.ts +198 -0
  261. package/src/theme/icons.ts +184 -0
  262. package/src/theme/palette.ts +143 -0
  263. package/src/theme/twoslash.ts +81 -0
@@ -0,0 +1,623 @@
1
+ import { writeFile } from "node:fs/promises";
2
+
3
+ import { join } from "pathe";
4
+
5
+ import type { BlumeConfig } from "../core/schema.ts";
6
+ import { pageMetaSchema } from "../core/schema.ts";
7
+
8
+ /**
9
+ * Framework-agnostic helpers shared by more than one migrator. Each piece here
10
+ * was generalized from a migrator-specific implementation so Mintlify, Nextra,
11
+ * and future migrators converge on a single copy.
12
+ */
13
+
14
+ /** Serialize a `BlumeConfig` to a `blume.config.ts` at the project root. */
15
+ export const writeBlumeConfig = async (
16
+ root: string,
17
+ config: BlumeConfig
18
+ ): Promise<void> => {
19
+ const body = `import { defineConfig } from "blume";\n\nexport default defineConfig(${JSON.stringify(config, null, 2)});\n`;
20
+ await writeFile(join(root, "blume.config.ts"), body, "utf-8");
21
+ };
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Callout components -> Blume `:::` directives
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export interface CalloutRewriteOptions {
28
+ /** Directive for a type-bearing tag with no `type` attribute (e.g. bare `<Callout>`). */
29
+ defaultDirective: string;
30
+ /** Tag names whose directive is fixed by the tag itself (e.g. `<Warning>`). */
31
+ tagDirectives: Record<string, string>;
32
+ /** Component tag names to convert. */
33
+ tags: string[];
34
+ /** `type="…"` values mapped to Blume directive names. */
35
+ typeDirectives: Record<string, string>;
36
+ }
37
+
38
+ /** Read a quoted string attribute (`name="…"` or `name='…'`) from a tag. */
39
+ export const attribute = (attrs: string, name: string): string | undefined => {
40
+ const match = attrs.match(
41
+ new RegExp(`\\b${name}=(?:"(?<dq>[^"]*)"|'(?<sq>[^']*)')`, "u")
42
+ );
43
+ return match?.groups?.dq ?? match?.groups?.sq;
44
+ };
45
+
46
+ /** Remove a shared leading indent and surrounding blank lines from a block. */
47
+ const dedent = (value: string): string => {
48
+ const lines = value
49
+ .replace(/^\r?\n/u, "")
50
+ .replace(/\s+$/u, "")
51
+ .split("\n");
52
+ const indents = lines
53
+ .filter((line) => line.trim() !== "")
54
+ .map((line) => line.match(/^\s*/u)?.[0].length ?? 0);
55
+ const common = indents.length > 0 ? Math.min(...indents) : 0;
56
+ return lines.map((line) => line.slice(common)).join("\n");
57
+ };
58
+
59
+ const directiveBlock = (
60
+ directive: string,
61
+ title: string | undefined,
62
+ inner: string
63
+ ): string => {
64
+ const head = title ? `:::${directive}[${title}]` : `:::${directive}`;
65
+ const body = dedent(inner);
66
+ return `${head}\n${body}\n:::`;
67
+ };
68
+
69
+ /**
70
+ * Find the `>` that closes an opening JSX tag, honoring quotes and `{…}`
71
+ * expression attributes (so a `>` inside `icon={"<svg…>"}` is not mistaken for
72
+ * the tag end). Returns -1 if unterminated.
73
+ */
74
+ export const findOpenTagEnd = (source: string, from: number): number => {
75
+ let depth = 0;
76
+ let quote: '"' | "'" | "`" | null = null;
77
+ for (let index = from; index < source.length; index += 1) {
78
+ const char = source[index];
79
+ if (quote) {
80
+ if (char === quote && source[index - 1] !== "\\") {
81
+ quote = null;
82
+ }
83
+ continue;
84
+ }
85
+ if (char === '"' || char === "'" || char === "`") {
86
+ quote = char;
87
+ } else if (char === "{") {
88
+ depth += 1;
89
+ } else if (char === "}") {
90
+ depth -= 1;
91
+ } else if (char === ">" && depth === 0) {
92
+ return index;
93
+ }
94
+ }
95
+ return -1;
96
+ };
97
+
98
+ const directiveFor = (
99
+ tag: string,
100
+ attrs: string,
101
+ options: CalloutRewriteOptions
102
+ ): string | undefined => {
103
+ if (tag in options.tagDirectives) {
104
+ return options.tagDirectives[tag];
105
+ }
106
+ const type = attribute(attrs, "type")?.toLowerCase();
107
+ return type ? options.typeDirectives[type] : options.defaultDirective;
108
+ };
109
+
110
+ /**
111
+ * Convert callout-style JSX components into Blume `:::` directives. Uses a
112
+ * quote/brace-aware tag scanner so callouts carrying JSX-expression attributes
113
+ * (e.g. inline-SVG icons) convert cleanly; non-convertible attributes (icons,
114
+ * colors, emoji) are dropped. A tag whose resolved directive is unknown is left
115
+ * untouched.
116
+ */
117
+ export const rewriteCallouts = (
118
+ source: string,
119
+ options: CalloutRewriteOptions
120
+ ): string => {
121
+ const tagPattern = new RegExp(
122
+ `<(?<tag>${options.tags.join("|")})(?=[\\s/>])`,
123
+ "u"
124
+ );
125
+ let output = "";
126
+ let cursor = 0;
127
+
128
+ while (cursor < source.length) {
129
+ const match = tagPattern.exec(source.slice(cursor));
130
+ if (!match?.groups) {
131
+ output += source.slice(cursor);
132
+ break;
133
+ }
134
+
135
+ const start = cursor + match.index;
136
+ const { tag } = match.groups;
137
+ if (!tag) {
138
+ output += source.slice(cursor);
139
+ break;
140
+ }
141
+ const openEnd = findOpenTagEnd(source, start + tag.length + 1);
142
+ if (openEnd === -1) {
143
+ output += source.slice(cursor, start + 1);
144
+ cursor = start + 1;
145
+ continue;
146
+ }
147
+
148
+ const attrs = source.slice(start + tag.length + 1, openEnd);
149
+ const directive = directiveFor(tag, attrs, options);
150
+ const closeTag = `</${tag}>`;
151
+ const selfClosing = attrs.trimEnd().endsWith("/");
152
+ const closeIndex = selfClosing
153
+ ? openEnd
154
+ : source.indexOf(closeTag, openEnd + 1);
155
+
156
+ if (!directive || (!selfClosing && closeIndex === -1)) {
157
+ output += source.slice(cursor, openEnd + 1);
158
+ cursor = openEnd + 1;
159
+ continue;
160
+ }
161
+
162
+ output += source.slice(cursor, start);
163
+ const title = attribute(attrs, "title");
164
+ if (selfClosing) {
165
+ output += title
166
+ ? `:::${directive}[${title}]\n:::`
167
+ : `:::${directive}\n:::`;
168
+ cursor = openEnd + 1;
169
+ } else {
170
+ output += directiveBlock(
171
+ directive,
172
+ title,
173
+ source.slice(openEnd + 1, closeIndex)
174
+ );
175
+ cursor = closeIndex + closeTag.length;
176
+ }
177
+ }
178
+
179
+ return output;
180
+ };
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // Page frontmatter
184
+ // ---------------------------------------------------------------------------
185
+
186
+ /**
187
+ * Remove frontmatter keys Blume's strict page schema would reject (e.g. stray
188
+ * `og:*`/`twitter:*` metatags) so the migrated page validates, reporting what
189
+ * was dropped. Validation errors other than stray keys are left for `blume dev`
190
+ * to surface.
191
+ */
192
+ export const stripUnknownPageMeta = (
193
+ data: Record<string, unknown>
194
+ ): { data: Record<string, unknown>; removed: string[] } => {
195
+ const result = pageMetaSchema.safeParse(data);
196
+ if (result.success) {
197
+ return { data, removed: [] };
198
+ }
199
+
200
+ const removed = new Set<string>();
201
+ for (const issue of result.error.issues) {
202
+ if (issue.code === "unrecognized_keys" && issue.path.length === 0) {
203
+ for (const key of issue.keys) {
204
+ removed.add(key);
205
+ }
206
+ }
207
+ }
208
+ if (removed.size === 0) {
209
+ return { data, removed: [] };
210
+ }
211
+
212
+ const next: Record<string, unknown> = {};
213
+ for (const [key, value] of Object.entries(data)) {
214
+ if (!removed.has(key)) {
215
+ next[key] = value;
216
+ }
217
+ }
218
+ return { data: next, removed: [...removed] };
219
+ };
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // JSX tag renaming
223
+ // ---------------------------------------------------------------------------
224
+
225
+ /**
226
+ * Rename a JSX tag (open and close) while preserving its attributes. The
227
+ * trailing lookahead means a longer tag (e.g. `CardGrid`) is never matched by a
228
+ * rule for its shorter prefix (`Card`), so prefix-sharing renames can be chained
229
+ * — run the item-level rename before the container rename.
230
+ */
231
+ export const renameTag = (source: string, from: string, to: string): string =>
232
+ source.replaceAll(
233
+ new RegExp(`<(?<close>/?)${from}(?=[\\s/>])`, "gu"),
234
+ `<$<close>${to}`
235
+ );
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // JavaScript literal scanning
239
+ // ---------------------------------------------------------------------------
240
+
241
+ /**
242
+ * Static readers for JS/TS config files (Nextra `_meta`, Starlight
243
+ * `astro.config`). Config is parsed by walking the source as text — quote-,
244
+ * comment-, and bracket-aware — rather than executing user code, matching the
245
+ * other migrators (which never eval). Values that aren't pure literals (an
246
+ * identifier, call, JSX, or interpolated template) are reported as `UNPARSEABLE`
247
+ * so the caller can drop the field and warn.
248
+ */
249
+
250
+ /** Index of a string within a JS source: the close quote matching `s[open]`. */
251
+ export const findStringEnd = (s: string, open: number): number => {
252
+ const quote = s[open];
253
+ for (let index = open + 1; index < s.length; index += 1) {
254
+ if (s[index] === "\\") {
255
+ index += 1;
256
+ continue;
257
+ }
258
+ if (s[index] === quote) {
259
+ return index;
260
+ }
261
+ }
262
+ return -1;
263
+ };
264
+
265
+ export const unescapeString = (inner: string): string =>
266
+ inner.replaceAll(/\\(?<ch>["'`\\nt])/gu, (_match, ch: string) => {
267
+ if (ch === "n") {
268
+ return "\n";
269
+ }
270
+ if (ch === "t") {
271
+ return "\t";
272
+ }
273
+ return ch;
274
+ });
275
+
276
+ /** Index of the last char of a `//` or block comment at `index`, else `index`. */
277
+ const skipComment = (source: string, index: number): number => {
278
+ if (source[index + 1] === "/") {
279
+ const newline = source.indexOf("\n", index + 2);
280
+ return newline === -1 ? source.length : newline;
281
+ }
282
+ if (source[index + 1] === "*") {
283
+ const close = source.indexOf("*/", index + 2);
284
+ return close === -1 ? source.length : close + 1;
285
+ }
286
+ return index;
287
+ };
288
+
289
+ const pushSlice = (
290
+ parts: string[],
291
+ source: string,
292
+ start: number,
293
+ end: number
294
+ ): void => {
295
+ const raw = source.slice(start, end).trim();
296
+ if (raw) {
297
+ parts.push(raw);
298
+ }
299
+ };
300
+
301
+ export interface ObjectScanResult {
302
+ end: number;
303
+ entries: string[];
304
+ }
305
+
306
+ /**
307
+ * Walk a `{…}` object literal starting at `openIndex`, returning the matching
308
+ * close-brace index and the raw `key: value` text of each top-level entry.
309
+ * Quote-, comment-, and bracket-aware so commas/braces nested in strings,
310
+ * arrays, or child objects don't split entries. Returns null if unterminated.
311
+ */
312
+ export const scanObject = (
313
+ source: string,
314
+ openIndex: number
315
+ ): ObjectScanResult | null => {
316
+ let depth = 0;
317
+ const entries: string[] = [];
318
+ let entryStart = openIndex + 1;
319
+
320
+ for (let index = openIndex; index < source.length; index += 1) {
321
+ const char = source[index];
322
+
323
+ if (char === '"' || char === "'" || char === "`") {
324
+ const end = findStringEnd(source, index);
325
+ index = end === -1 ? source.length : end;
326
+ continue;
327
+ }
328
+ if (char === "/") {
329
+ const skipped = skipComment(source, index);
330
+ if (skipped !== index) {
331
+ index = skipped;
332
+ continue;
333
+ }
334
+ }
335
+ if (char === "{" || char === "[" || char === "(") {
336
+ depth += 1;
337
+ continue;
338
+ }
339
+ if (char === "}" || char === "]" || char === ")") {
340
+ depth -= 1;
341
+ if (char === "}" && depth === 0) {
342
+ pushSlice(entries, source, entryStart, index);
343
+ return { end: index, entries };
344
+ }
345
+ continue;
346
+ }
347
+ if (char === "," && depth === 1) {
348
+ pushSlice(entries, source, entryStart, index);
349
+ entryStart = index + 1;
350
+ }
351
+ }
352
+
353
+ return null;
354
+ };
355
+
356
+ export interface ArrayScanResult {
357
+ elements: string[];
358
+ end: number;
359
+ }
360
+
361
+ /**
362
+ * Walk a `[…]` array literal starting at `openIndex`, returning the matching
363
+ * close-bracket index and the raw text of each top-level element. The sibling of
364
+ * {@link scanObject}; a trailing comma yields no empty element.
365
+ */
366
+ export const scanArray = (
367
+ source: string,
368
+ openIndex: number
369
+ ): ArrayScanResult | null => {
370
+ let depth = 0;
371
+ const elements: string[] = [];
372
+ let elementStart = openIndex + 1;
373
+
374
+ for (let index = openIndex; index < source.length; index += 1) {
375
+ const char = source[index];
376
+
377
+ if (char === '"' || char === "'" || char === "`") {
378
+ const end = findStringEnd(source, index);
379
+ index = end === -1 ? source.length : end;
380
+ continue;
381
+ }
382
+ if (char === "/") {
383
+ const skipped = skipComment(source, index);
384
+ if (skipped !== index) {
385
+ index = skipped;
386
+ continue;
387
+ }
388
+ }
389
+ if (char === "{" || char === "[" || char === "(") {
390
+ depth += 1;
391
+ continue;
392
+ }
393
+ if (char === "}" || char === ")") {
394
+ depth -= 1;
395
+ continue;
396
+ }
397
+ if (char === "]") {
398
+ depth -= 1;
399
+ if (depth === 0) {
400
+ pushSlice(elements, source, elementStart, index);
401
+ return { elements, end: index };
402
+ }
403
+ continue;
404
+ }
405
+ if (char === "," && depth === 1) {
406
+ pushSlice(elements, source, elementStart, index);
407
+ elementStart = index + 1;
408
+ }
409
+ }
410
+
411
+ return null;
412
+ };
413
+
414
+ /**
415
+ * Strip `//` and block comments so they don't leak into entry text (the scanner
416
+ * splits on slices, so an inter-entry comment would otherwise attach to the next
417
+ * entry). String literals are preserved verbatim.
418
+ */
419
+ export const stripJsComments = (source: string): string => {
420
+ let out = "";
421
+ let quote: string | null = null;
422
+ for (let index = 0; index < source.length; index += 1) {
423
+ const char = source[index];
424
+ if (quote) {
425
+ out += char;
426
+ if (char === "\\") {
427
+ out += source[index + 1] ?? "";
428
+ index += 1;
429
+ } else if (char === quote) {
430
+ quote = null;
431
+ }
432
+ continue;
433
+ }
434
+ if (char === '"' || char === "'" || char === "`") {
435
+ quote = char;
436
+ out += char;
437
+ continue;
438
+ }
439
+ if (char === "/" && source[index + 1] === "/") {
440
+ const newline = source.indexOf("\n", index + 2);
441
+ index = newline === -1 ? source.length - 1 : newline - 1;
442
+ continue;
443
+ }
444
+ if (char === "/" && source[index + 1] === "*") {
445
+ const close = source.indexOf("*/", index + 2);
446
+ index = close === -1 ? source.length - 1 : close + 1;
447
+ out += " ";
448
+ continue;
449
+ }
450
+ out += char;
451
+ }
452
+ return out;
453
+ };
454
+
455
+ export interface KeyValue {
456
+ key: string;
457
+ value: string;
458
+ }
459
+
460
+ /** Split a raw `key: value` entry at its top-level colon. */
461
+ export const splitKeyValue = (entry: string): KeyValue | null => {
462
+ let index = 0;
463
+ while (index < entry.length && /\s/u.test(entry[index] ?? "")) {
464
+ index += 1;
465
+ }
466
+ const first = entry[index];
467
+ if (first === "[") {
468
+ // Computed key — not something we can resolve statically.
469
+ return null;
470
+ }
471
+
472
+ let key: string;
473
+ if (first === '"' || first === "'" || first === "`") {
474
+ const close = findStringEnd(entry, index);
475
+ if (close === -1) {
476
+ return null;
477
+ }
478
+ key = entry.slice(index, close + 1);
479
+ index = close + 1;
480
+ } else {
481
+ const start = index;
482
+ while (index < entry.length && !/[\s:]/u.test(entry[index] ?? "")) {
483
+ index += 1;
484
+ }
485
+ key = entry.slice(start, index);
486
+ }
487
+
488
+ while (index < entry.length && /\s/u.test(entry[index] ?? "")) {
489
+ index += 1;
490
+ }
491
+ if (entry[index] !== ":") {
492
+ return { key, value: "" };
493
+ }
494
+ return { key, value: entry.slice(index + 1).trim() };
495
+ };
496
+
497
+ /** Read an object key, unquoting it when it is a string literal. */
498
+ export const parseKey = (key: string): string => {
499
+ const trimmed = key.trim();
500
+ const [quote] = trimmed;
501
+ if (quote === '"' || quote === "'" || quote === "`") {
502
+ const end = findStringEnd(trimmed, 0);
503
+ if (end !== -1) {
504
+ return unescapeString(trimmed.slice(1, end));
505
+ }
506
+ }
507
+ return trimmed;
508
+ };
509
+
510
+ /** Read a clean string literal value, or null if it's an expression. */
511
+ export const readString = (value: string): string | null => {
512
+ const trimmed = value.trim();
513
+ const [quote] = trimmed;
514
+ if (quote !== '"' && quote !== "'" && quote !== "`") {
515
+ return null;
516
+ }
517
+ if (quote === "`" && trimmed.includes("${")) {
518
+ return null;
519
+ }
520
+ const end = findStringEnd(trimmed, 0);
521
+ if (end === -1 || trimmed.slice(end + 1).trim() !== "") {
522
+ return null;
523
+ }
524
+ return unescapeString(trimmed.slice(1, end));
525
+ };
526
+
527
+ /** A value that isn't a pure literal (identifier, call, JSX, computed, …). */
528
+ export const UNPARSEABLE = Symbol("unparseable");
529
+
530
+ export type LiteralValue =
531
+ | LiteralValue[]
532
+ | boolean
533
+ | null
534
+ | number
535
+ | string
536
+ | typeof UNPARSEABLE
537
+ | { [key: string]: LiteralValue };
538
+
539
+ const NUMERIC = /^-?\d/u;
540
+
541
+ const parseScalarLiteral = (trimmed: string): LiteralValue => {
542
+ if (trimmed === "true") {
543
+ return true;
544
+ }
545
+ if (trimmed === "false") {
546
+ return false;
547
+ }
548
+ if (trimmed === "null") {
549
+ return null;
550
+ }
551
+ if (NUMERIC.test(trimmed)) {
552
+ const num = Number(trimmed);
553
+ if (!Number.isNaN(num)) {
554
+ return num;
555
+ }
556
+ }
557
+ return UNPARSEABLE;
558
+ };
559
+
560
+ const parseObjectLiteral = (trimmed: string): LiteralValue => {
561
+ const scan = scanObject(trimmed, 0);
562
+ if (!scan || trimmed.slice(scan.end + 1).trim() !== "") {
563
+ return UNPARSEABLE;
564
+ }
565
+ const out: Record<string, LiteralValue> = {};
566
+ for (const entry of scan.entries) {
567
+ const kv = splitKeyValue(entry);
568
+ if (kv?.key && kv.value !== "") {
569
+ // oxlint-disable-next-line no-use-before-define -- mutual recursion
570
+ out[parseKey(kv.key)] = parseLiteral(kv.value);
571
+ }
572
+ }
573
+ return out;
574
+ };
575
+
576
+ const parseArrayLiteral = (trimmed: string): LiteralValue => {
577
+ const scan = scanArray(trimmed, 0);
578
+ if (!scan || trimmed.slice(scan.end + 1).trim() !== "") {
579
+ return UNPARSEABLE;
580
+ }
581
+ // oxlint-disable-next-line no-use-before-define -- mutual recursion
582
+ return scan.elements.map((element) => parseLiteral(element));
583
+ };
584
+
585
+ /**
586
+ * Evaluate a JS literal expression (string / number / boolean / null / array /
587
+ * object) into its value without executing it. Anything else resolves to
588
+ * {@link UNPARSEABLE}; inside arrays the sentinel keeps the element's position,
589
+ * inside objects it stays as the field's value so the caller can warn and drop.
590
+ */
591
+ export const parseLiteral = (source: string): LiteralValue => {
592
+ const trimmed = source.trim();
593
+ if (trimmed === "") {
594
+ return UNPARSEABLE;
595
+ }
596
+ const [first] = trimmed;
597
+ if (first === '"' || first === "'" || first === "`") {
598
+ return readString(trimmed) ?? UNPARSEABLE;
599
+ }
600
+ if (first === "{") {
601
+ return parseObjectLiteral(trimmed);
602
+ }
603
+ if (first === "[") {
604
+ return parseArrayLiteral(trimmed);
605
+ }
606
+ return parseScalarLiteral(trimmed);
607
+ };
608
+
609
+ /** Narrow a parsed literal to a string. */
610
+ export const asLiteralString = (
611
+ value: LiteralValue | undefined
612
+ ): string | undefined => (typeof value === "string" ? value : undefined);
613
+
614
+ /** Narrow a parsed literal to a plain object (not an array or `UNPARSEABLE`). */
615
+ export const isLiteralObject = (
616
+ value: LiteralValue | undefined
617
+ ): value is Record<string, LiteralValue> =>
618
+ typeof value === "object" && value !== null && !Array.isArray(value);
619
+
620
+ /** Narrow a parsed literal to an array. */
621
+ export const asLiteralArray = (
622
+ value: LiteralValue | undefined
623
+ ): LiteralValue[] | undefined => (Array.isArray(value) ? value : undefined);