blume 0.1.0 → 0.1.2

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 (106) hide show
  1. package/dist/cli/index.js +1213 -473
  2. package/dist/cli/index.js.map +33 -29
  3. package/dist/types/core/bridge.d.ts +24 -0
  4. package/dist/types/core/config.d.ts +35 -0
  5. package/dist/types/core/data.d.ts +129 -0
  6. package/dist/types/core/define-components.d.ts +27 -0
  7. package/dist/types/core/define-meta.d.ts +16 -0
  8. package/dist/types/core/deployment-env.d.ts +9 -0
  9. package/dist/types/core/diagnostics.d.ts +17 -0
  10. package/dist/types/core/i18n-ui.d.ts +500 -0
  11. package/dist/types/core/load-module.d.ts +7 -0
  12. package/dist/types/core/package-root.d.ts +17 -0
  13. package/dist/types/core/project.d.ts +9 -0
  14. package/dist/types/core/schema.d.ts +3461 -0
  15. package/dist/types/core/sources/types.d.ts +107 -0
  16. package/dist/types/core/types.d.ts +245 -0
  17. package/dist/types/core/ui-packs/ar.d.ts +3 -0
  18. package/dist/types/core/ui-packs/bg.d.ts +3 -0
  19. package/dist/types/core/ui-packs/bn.d.ts +3 -0
  20. package/dist/types/core/ui-packs/ca.d.ts +3 -0
  21. package/dist/types/core/ui-packs/cs.d.ts +3 -0
  22. package/dist/types/core/ui-packs/da.d.ts +3 -0
  23. package/dist/types/core/ui-packs/de.d.ts +3 -0
  24. package/dist/types/core/ui-packs/el.d.ts +3 -0
  25. package/dist/types/core/ui-packs/es.d.ts +3 -0
  26. package/dist/types/core/ui-packs/fa.d.ts +3 -0
  27. package/dist/types/core/ui-packs/fi.d.ts +3 -0
  28. package/dist/types/core/ui-packs/fr.d.ts +3 -0
  29. package/dist/types/core/ui-packs/he.d.ts +3 -0
  30. package/dist/types/core/ui-packs/hi.d.ts +3 -0
  31. package/dist/types/core/ui-packs/hr.d.ts +3 -0
  32. package/dist/types/core/ui-packs/hu.d.ts +3 -0
  33. package/dist/types/core/ui-packs/id.d.ts +3 -0
  34. package/dist/types/core/ui-packs/index.d.ts +13 -0
  35. package/dist/types/core/ui-packs/it.d.ts +3 -0
  36. package/dist/types/core/ui-packs/ja.d.ts +3 -0
  37. package/dist/types/core/ui-packs/ko.d.ts +3 -0
  38. package/dist/types/core/ui-packs/nl.d.ts +3 -0
  39. package/dist/types/core/ui-packs/no.d.ts +3 -0
  40. package/dist/types/core/ui-packs/pl.d.ts +3 -0
  41. package/dist/types/core/ui-packs/pt-br.d.ts +3 -0
  42. package/dist/types/core/ui-packs/pt.d.ts +3 -0
  43. package/dist/types/core/ui-packs/ro.d.ts +3 -0
  44. package/dist/types/core/ui-packs/ru.d.ts +3 -0
  45. package/dist/types/core/ui-packs/sk.d.ts +3 -0
  46. package/dist/types/core/ui-packs/sr.d.ts +3 -0
  47. package/dist/types/core/ui-packs/sv.d.ts +3 -0
  48. package/dist/types/core/ui-packs/th.d.ts +3 -0
  49. package/dist/types/core/ui-packs/tr.d.ts +3 -0
  50. package/dist/types/core/ui-packs/uk.d.ts +3 -0
  51. package/dist/types/core/ui-packs/vi.d.ts +3 -0
  52. package/dist/types/core/ui-packs/zh-tw.d.ts +3 -0
  53. package/dist/types/core/ui-packs/zh.d.ts +3 -0
  54. package/dist/types/core/version.d.ts +8 -0
  55. package/dist/types/index.d.ts +10 -0
  56. package/dist/types/migrate/mintlify/config.d.ts +2 -0
  57. package/dist/types/migrate/mintlify/i18n.d.ts +7 -0
  58. package/dist/types/theme/fonts.d.ts +163 -0
  59. package/docs/advanced/custom-pages.mdx +91 -10
  60. package/docs/configuration/index.mdx +19 -0
  61. package/docs/content/components.mdx +103 -1
  62. package/package.json +13 -3
  63. package/src/ai/llms.ts +1 -2
  64. package/src/astro/examples.ts +106 -0
  65. package/src/astro/generate.ts +300 -75
  66. package/src/astro/index.ts +1 -1
  67. package/src/astro/islands.ts +1 -1
  68. package/src/astro/pages.ts +59 -0
  69. package/src/astro/templates.ts +180 -22
  70. package/src/cli/commands/validate.ts +1 -0
  71. package/src/components/content/CodeBlock.astro +28 -0
  72. package/src/components/content/Component.astro +65 -0
  73. package/src/components/content/Diff.astro +44 -0
  74. package/src/components/content/Tab.astro +8 -2
  75. package/src/components/content/diff.ts +95 -0
  76. package/src/components/layout/Favicon.astro +11 -4
  77. package/src/components/layout/PageLayout.astro +173 -0
  78. package/src/components/layout/ReferenceLayout.astro +6 -1
  79. package/src/components/layout/RootLayout.astro +9 -5
  80. package/src/components/layout/head-scripts.ts +19 -0
  81. package/src/core/data.ts +128 -0
  82. package/src/core/frontmatter.ts +43 -0
  83. package/src/core/links.ts +26 -10
  84. package/src/core/schema.ts +8 -0
  85. package/src/core/sources/filesystem.ts +1 -1
  86. package/src/core/sources/mdx-remote.ts +1 -2
  87. package/src/core/sources/mintlify.ts +1 -1
  88. package/src/core/sources/normalize.ts +19 -3
  89. package/src/core/sources/notion.ts +1 -1
  90. package/src/core/sources/sanity.ts +1 -1
  91. package/src/core/tsconfig-aliases.ts +201 -0
  92. package/src/index.ts +12 -0
  93. package/src/markdown/index.ts +69 -0
  94. package/src/migrate/fumadocs/config.ts +53 -4
  95. package/src/migrate/fumadocs/content.ts +1 -1
  96. package/src/migrate/fumadocs/groups.ts +230 -0
  97. package/src/migrate/fumadocs/index.ts +104 -19
  98. package/src/migrate/fumadocs/meta.ts +140 -10
  99. package/src/migrate/mintlify/snippets.ts +2 -1
  100. package/src/migrate/mintlify/transform.ts +1 -2
  101. package/src/migrate/nextra/index.ts +1 -1
  102. package/src/migrate/shared.ts +98 -1
  103. package/src/migrate/starlight/index.ts +1 -1
  104. package/src/registry/eject.ts +57 -10
  105. package/src/search/documents.ts +1 -2
  106. package/src/theme/entry.ts +29 -0
@@ -0,0 +1,201 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ import { dirname, isAbsolute, join, resolve } from "pathe";
6
+
7
+ /**
8
+ * Read the project's TypeScript path aliases (`compilerOptions.paths`) and turn
9
+ * them into Vite `resolve.alias` entries, so `@/`-style imports in custom pages,
10
+ * islands, and components resolve in the generated Astro build exactly as they
11
+ * do in the user's own tooling.
12
+ *
13
+ * The generated `.blume/` runtime is its own Astro project with its own tsconfig
14
+ * and never inherits the project's, so without this every shadcn-style `@/…`
15
+ * import would have to be rewritten to a relative path. Reading the aliases here
16
+ * lets those components port over unchanged.
17
+ *
18
+ * Best-effort and non-fatal: tsconfig is parsed leniently (it is JSONC —
19
+ * comments and trailing commas), a single `extends` chain is followed to the
20
+ * file that actually declares `paths`, and anything unparseable yields no
21
+ * aliases (the prior behavior).
22
+ */
23
+
24
+ /** Strip `//` line and `/* *\/` block comments that sit outside strings. */
25
+ const stripJsonComments = (text: string): string => {
26
+ let out = "";
27
+ let inString = false;
28
+ for (let index = 0; index < text.length; index += 1) {
29
+ const char = text[index];
30
+ if (inString) {
31
+ out += char;
32
+ if (char === "\\") {
33
+ out += text[index + 1] ?? "";
34
+ index += 1;
35
+ } else if (char === '"') {
36
+ inString = false;
37
+ }
38
+ continue;
39
+ }
40
+ if (char === '"') {
41
+ inString = true;
42
+ out += char;
43
+ continue;
44
+ }
45
+ if (char === "/" && text[index + 1] === "/") {
46
+ const newline = text.indexOf("\n", index + 2);
47
+ index = newline === -1 ? text.length : newline - 1;
48
+ continue;
49
+ }
50
+ if (char === "/" && text[index + 1] === "*") {
51
+ const end = text.indexOf("*/", index + 2);
52
+ index = end === -1 ? text.length : end + 1;
53
+ continue;
54
+ }
55
+ out += char;
56
+ }
57
+ return out;
58
+ };
59
+
60
+ const TRAILING_COMMA = /,(?<rest>\s*[}\]])/gu;
61
+
62
+ /** Parse JSONC (tsconfig) into a plain object, or null if it can't be read. */
63
+ const parseJsonc = (text: string): Record<string, unknown> | null => {
64
+ try {
65
+ const cleaned = stripJsonComments(text).replaceAll(
66
+ TRAILING_COMMA,
67
+ "$<rest>"
68
+ );
69
+ const value: unknown = JSON.parse(cleaned);
70
+ return value && typeof value === "object" && !Array.isArray(value)
71
+ ? (value as Record<string, unknown>)
72
+ : null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ };
77
+
78
+ const isFile = (path: string): boolean => {
79
+ try {
80
+ return statSync(path).isFile();
81
+ } catch {
82
+ return false;
83
+ }
84
+ };
85
+
86
+ /** Resolve a tsconfig `extends` target (relative path, directory, or package). */
87
+ const resolveExtends = (spec: string, fromDir: string): string | null => {
88
+ if (spec.startsWith(".") || isAbsolute(spec)) {
89
+ const candidates = spec.endsWith(".json")
90
+ ? [resolve(fromDir, spec)]
91
+ : [
92
+ resolve(fromDir, `${spec}.json`),
93
+ resolve(fromDir, spec, "tsconfig.json"),
94
+ resolve(fromDir, spec),
95
+ ];
96
+ return candidates.find(isFile) ?? null;
97
+ }
98
+ // A bare specifier points at a package's shared config (e.g. `@tsconfig/*`).
99
+ try {
100
+ const require_ = createRequire(pathToFileURL(join(fromDir, "_.js")).href);
101
+ for (const sub of [`${spec}/tsconfig.json`, spec]) {
102
+ try {
103
+ return require_.resolve(sub);
104
+ } catch {
105
+ // try the next candidate
106
+ }
107
+ }
108
+ } catch {
109
+ // createRequire failed; fall through
110
+ }
111
+ return null;
112
+ };
113
+
114
+ interface LoadedPaths {
115
+ /** Directory `paths` entries resolve against (`dirname(file)` + `baseUrl`). */
116
+ baseDir: string;
117
+ paths: Record<string, unknown>;
118
+ }
119
+
120
+ /** Find the nearest tsconfig in an `extends` chain that declares `paths`. */
121
+ const loadPaths = (file: string, seen: Set<string>): LoadedPaths | null => {
122
+ if (seen.has(file) || !existsSync(file)) {
123
+ return null;
124
+ }
125
+ seen.add(file);
126
+ const json = parseJsonc(readFileSync(file, "utf-8"));
127
+ if (!json) {
128
+ return null;
129
+ }
130
+ const options = (json.compilerOptions ?? {}) as Record<string, unknown>;
131
+ if (options.paths && typeof options.paths === "object") {
132
+ const baseUrl = typeof options.baseUrl === "string" ? options.baseUrl : ".";
133
+ return {
134
+ baseDir: resolve(dirname(file), baseUrl),
135
+ paths: options.paths as Record<string, unknown>,
136
+ };
137
+ }
138
+ // `extends` is a string or, since TS 5.0, an array searched first-to-last.
139
+ const bases = Array.isArray(json.extends)
140
+ ? json.extends
141
+ : [json.extends].filter(Boolean);
142
+ for (const base of bases) {
143
+ if (typeof base !== "string") {
144
+ continue;
145
+ }
146
+ const resolved = resolveExtends(base, dirname(file));
147
+ const found = resolved ? loadPaths(resolved, seen) : null;
148
+ if (found) {
149
+ return found;
150
+ }
151
+ }
152
+ return null;
153
+ };
154
+
155
+ /** Convert one tsconfig `paths` mapping to a Vite alias, or null to skip. */
156
+ const toAlias = (
157
+ key: string,
158
+ value: unknown,
159
+ baseDir: string
160
+ ): { find: string; replacement: string } | null => {
161
+ // tsconfig allows a fallback array; Vite aliases are 1:1, so take the first.
162
+ const first = Array.isArray(value) ? value[0] : value;
163
+ if (typeof first !== "string") {
164
+ return null;
165
+ }
166
+ const find = key.endsWith("/*") ? key.slice(0, -2) : key;
167
+ const target = first.endsWith("/*") ? first.slice(0, -2) : first;
168
+ // A bare `*`/`/*` catch-all would alias every import — never wire that.
169
+ if (find === "" || find === "*") {
170
+ return null;
171
+ }
172
+ return { find, replacement: resolve(baseDir, target) };
173
+ };
174
+
175
+ /**
176
+ * Resolve the project's tsconfig/jsconfig path aliases to absolute Vite
177
+ * `resolve.alias` entries (`find` → absolute replacement). Returns `{}` when no
178
+ * config or no usable `paths` is found.
179
+ */
180
+ export const resolveTsconfigAliases = (
181
+ root: string
182
+ ): Record<string, string> => {
183
+ const entry = ["tsconfig.json", "jsconfig.json"]
184
+ .map((name) => join(root, name))
185
+ .find((file) => existsSync(file));
186
+ if (!entry) {
187
+ return {};
188
+ }
189
+ const loaded = loadPaths(entry, new Set());
190
+ if (!loaded) {
191
+ return {};
192
+ }
193
+ const aliases: Record<string, string> = {};
194
+ for (const [key, value] of Object.entries(loaded.paths)) {
195
+ const alias = toAlias(key, value, loaded.baseDir);
196
+ if (alias) {
197
+ aliases[alias.find] = alias.replacement;
198
+ }
199
+ }
200
+ return aliases;
201
+ };
package/src/index.ts CHANGED
@@ -1,4 +1,15 @@
1
1
  export { defineConfig } from "./core/config.ts";
2
+ export type {
3
+ BlumeBanner,
4
+ BlumeData,
5
+ BlumeDataConfig,
6
+ BlumeDataI18n,
7
+ BlumeDataLocale,
8
+ BlumeFavicon,
9
+ BlumeFeed,
10
+ BlumeLogo,
11
+ BlumeRoute,
12
+ } from "./core/data.ts";
2
13
  export { defineComponents } from "./core/define-components.ts";
3
14
  export type {
4
15
  ComponentOverride,
@@ -10,6 +21,7 @@ export type {
10
21
  FolderMetaDefinition,
11
22
  FolderMetaFactory,
12
23
  } from "./core/define-meta.ts";
24
+ export type { UIStrings } from "./core/i18n-ui.ts";
13
25
  export type {
14
26
  BlumeConfig,
15
27
  FolderMeta,
@@ -6,6 +6,7 @@ import {
6
6
  transformerNotationHighlight,
7
7
  transformerNotationWordHighlight,
8
8
  } from "@shikijs/transformers";
9
+ import { codeToHtml } from "shiki";
9
10
 
10
11
  import { codeTitleTransformer } from "./code-title.ts";
11
12
  import { directiveToCalloutPlugin } from "./directives.ts";
@@ -97,6 +98,74 @@ export const blumeShikiTransformers = (
97
98
  return transformers;
98
99
  };
99
100
 
101
+ /**
102
+ * The light/dark Shiki themes Blume highlights with. Kept in lockstep with the
103
+ * generated Astro config's `shikiConfig.themes` so code highlighted outside the
104
+ * Markdown pipeline (via {@link highlightCode}) matches fenced code exactly.
105
+ */
106
+ const CODE_THEMES = { dark: "github-dark", light: "github-light" } as const;
107
+
108
+ const escapeHtml = (value: string): string =>
109
+ value
110
+ .replaceAll("&", "&amp;")
111
+ .replaceAll("<", "&lt;")
112
+ .replaceAll(">", "&gt;");
113
+
114
+ /**
115
+ * Tag the highlighted `<pre>` with `astro-code` (plus any extra classes) so the
116
+ * theme's code-block styles apply — `codeToHtml`'s bare output is `pre.shiki`,
117
+ * which the theme doesn't style.
118
+ */
119
+ const astroCodeClassTransformer = (extra?: string): ShikiTransformer =>
120
+ ({
121
+ name: "blume:astro-code-class",
122
+ pre(node: { properties: Record<string, unknown> }) {
123
+ const existing =
124
+ typeof node.properties.class === "string" ? node.properties.class : "";
125
+ node.properties.class = `astro-code ${extra ?? ""} ${existing}`
126
+ .replaceAll(/\s+/gu, " ")
127
+ .trim();
128
+ },
129
+ }) as unknown as ShikiTransformer;
130
+
131
+ export interface HighlightCodeOptions extends BlumeShikiOptions {
132
+ /** Extra `<pre>` class names, e.g. `blume-source` for a height-capped pane. */
133
+ className?: string;
134
+ }
135
+
136
+ /**
137
+ * Highlight a code string with the same Shiki themes and transformers as Blume's
138
+ * Markdown code fences, returning ready-to-render HTML. Use it to show themed
139
+ * code *outside* the Markdown pipeline (custom pages, components): the output
140
+ * carries the `astro-code` class and dual (light/dark) color variables, so the
141
+ * theme styles it — including the light/dark swap — with no extra CSS. The
142
+ * theme's code-block rules are scoped to `.prose`, so render the result inside a
143
+ * `.prose` container (the shipped `<CodeBlock>` does this). An unknown language
144
+ * falls back to an escaped plain block.
145
+ */
146
+ export const highlightCode = async (
147
+ code: string,
148
+ lang: string,
149
+ options: HighlightCodeOptions = {}
150
+ ): Promise<string> => {
151
+ try {
152
+ return await codeToHtml(code, {
153
+ defaultColor: false,
154
+ lang,
155
+ themes: CODE_THEMES,
156
+ transformers: [
157
+ ...blumeShikiTransformers({ icons: options.icons }),
158
+ astroCodeClassTransformer(options.className),
159
+ ],
160
+ });
161
+ } catch {
162
+ const className = `astro-code ${options.className ?? ""}`
163
+ .replaceAll(/\s+/gu, " ")
164
+ .trim();
165
+ return `<pre class="${className}"><code>${escapeHtml(code)}</code></pre>`;
166
+ }
167
+ };
168
+
100
169
  /**
101
170
  * Sätteri Markdown features Blume enables beyond Astro's defaults. GFM,
102
171
  * frontmatter, and smart punctuation are already on; this adds superscript
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { readFile } from "node:fs/promises";
3
3
 
4
- import { join } from "pathe";
4
+ import { basename, dirname, join } from "pathe";
5
5
 
6
6
  import type { BlumeConfig } from "../../core/schema.ts";
7
7
 
@@ -39,6 +39,41 @@ const prettifyTitle = (name: string): string => {
39
39
  .join(" ");
40
40
  };
41
41
 
42
+ /**
43
+ * Generic monorepo app-shell package names. When the migrated project is named
44
+ * one of these, its name makes a poor doc title ("Web"), so we fall back to the
45
+ * repo name — but only in a monorepo, where a better name is actually available.
46
+ */
47
+ const GENERIC_NAMES = new Set([
48
+ "api",
49
+ "app",
50
+ "client",
51
+ "frontend",
52
+ "server",
53
+ "site",
54
+ "web",
55
+ "www",
56
+ ]);
57
+
58
+ /** The nearest ancestor that is a git repository root, or null. */
59
+ const gitRepoRoot = (start: string): string | null => {
60
+ let dir = start;
61
+ for (;;) {
62
+ if (existsSync(join(dir, ".git"))) {
63
+ return dir;
64
+ }
65
+ const parent = dirname(dir);
66
+ if (parent === dir) {
67
+ return null;
68
+ }
69
+ dir = parent;
70
+ }
71
+ };
72
+
73
+ /** The unscoped package name (`@acme/web` -> `web`). */
74
+ const bareName = (name: string): string =>
75
+ name.includes("/") ? name.slice(name.lastIndexOf("/") + 1) : name;
76
+
42
77
  const readTitle = async (root: string): Promise<string> => {
43
78
  const packageJson = join(root, "package.json");
44
79
  if (!existsSync(packageJson)) {
@@ -48,9 +83,23 @@ const readTitle = async (root: string): Promise<string> => {
48
83
  const parsed = JSON.parse(await readFile(packageJson, "utf-8")) as {
49
84
  name?: unknown;
50
85
  };
51
- return typeof parsed.name === "string" && parsed.name.trim()
52
- ? prettifyTitle(parsed.name)
53
- : "Documentation";
86
+ const { name } = parsed;
87
+ if (typeof name !== "string" || !name.trim()) {
88
+ return "Documentation";
89
+ }
90
+ // A generic name (`apps/web` -> "Web") is a weak title. In a monorepo the
91
+ // repo's own directory name is usually better, so prefer it when this isn't
92
+ // already the repo root.
93
+ if (GENERIC_NAMES.has(bareName(name).toLowerCase())) {
94
+ const repoRoot = gitRepoRoot(root);
95
+ if (repoRoot && repoRoot !== root) {
96
+ const repoTitle = prettifyTitle(basename(repoRoot));
97
+ if (repoTitle !== "Documentation") {
98
+ return repoTitle;
99
+ }
100
+ }
101
+ }
102
+ return prettifyTitle(name);
54
103
  } catch {
55
104
  return "Documentation";
56
105
  }
@@ -1,9 +1,9 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { readFile as readFileFromDisk } from "node:fs/promises";
3
3
 
4
- import matter from "gray-matter";
5
4
  import { dirname, resolve } from "pathe";
6
5
 
6
+ import matter from "../../core/frontmatter.ts";
7
7
  import { findOpenTagEnd, renameTag, rewriteCallouts } from "../shared.ts";
8
8
 
9
9
  /**
@@ -0,0 +1,230 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { mkdir, rename, writeFile } from "node:fs/promises";
3
+
4
+ import { basename, join } from "pathe";
5
+
6
+ import { renderMetaModule } from "./meta.ts";
7
+ import type {
8
+ FumadocsPageItem,
9
+ FumadocsPagesStructure,
10
+ FumadocsSection,
11
+ } from "./meta.ts";
12
+
13
+ /**
14
+ * Rebuild Fumadocs `---Section---` separators as Blume group folders.
15
+ *
16
+ * Fumadocs draws a non-collapsible section heading from a `"---Label---"` entry
17
+ * in a folder's `pages` array — exactly what a Blume `(Label)/` group folder
18
+ * renders by default (`display: "flat"`). Blume has no flat-file "separator"
19
+ * primitive, so the only faithful, non-overriding home for a section is a group
20
+ * folder: it is route-transparent (the `(Label)` segment is stripped from URLs,
21
+ * see `core/sources/normalize.ts`) and composes with per-folder `meta.ts`,
22
+ * unlike a global `navigation.sidebar` override (which would disable every other
23
+ * folder's meta site-wide).
24
+ *
25
+ * The reshape runs against the already-migrated `docs/` tree, so each `pages`
26
+ * entry is resolved to a real page file or folder before it is moved. A section
27
+ * that is a single folder is left in place (wrapping it would double the
28
+ * heading); links have no file to move and are dropped with a warning.
29
+ */
30
+
31
+ const PAGE_EXTS = [".mdx", ".md"];
32
+ const WORD_SPLIT = /[-_]/u;
33
+ const PATH_SEP = /[/\\]/u;
34
+
35
+ interface ResolvedEntry {
36
+ kind: "file" | "folder";
37
+ path: string;
38
+ }
39
+
40
+ const isDirectory = (path: string): boolean => {
41
+ try {
42
+ return statSync(path).isDirectory();
43
+ } catch {
44
+ return false;
45
+ }
46
+ };
47
+
48
+ /** Resolve a `pages` name to its on-disk page file or folder under `docsDir`. */
49
+ const resolveEntry = (docsDir: string, name: string): ResolvedEntry | null => {
50
+ for (const ext of PAGE_EXTS) {
51
+ const file = join(docsDir, `${name}${ext}`);
52
+ if (existsSync(file)) {
53
+ return { kind: "file", path: file };
54
+ }
55
+ }
56
+ const folder = join(docsDir, name);
57
+ return isDirectory(folder) ? { kind: "folder", path: folder } : null;
58
+ };
59
+
60
+ const humanize = (name: string): string =>
61
+ name
62
+ .split(WORD_SPLIT)
63
+ .filter(Boolean)
64
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
65
+ .join(" ");
66
+
67
+ const linkWarning = (item: { href: string; text: string }): string =>
68
+ `Dropped sidebar link "${item.text}" (${item.href}) — add it to navbar.links manually.`;
69
+
70
+ const extractWarning = (name: string): string =>
71
+ `Sidebar extract "...${name}" became a normal "${name}" group (its pages are not flattened into the parent).`;
72
+
73
+ export interface GroupReshapeResult {
74
+ /** Ordering keys for the parent folder's `meta.ts` `pages`. */
75
+ order: string[];
76
+ warnings: string[];
77
+ }
78
+
79
+ /** Record an in-place item's ordering key (lead items, or an ungroupable run). */
80
+ const addUngroupedItem = (
81
+ item: FumadocsPageItem,
82
+ order: string[],
83
+ warnings: string[]
84
+ ): void => {
85
+ if (item.kind === "link") {
86
+ warnings.push(linkWarning(item));
87
+ return;
88
+ }
89
+ if (item.kind === "extract") {
90
+ warnings.push(extractWarning(item.name));
91
+ }
92
+ order.push(item.name);
93
+ };
94
+
95
+ /** A section of one folder keeps its place without a wrapping group folder. */
96
+ const reshapeSingleFolderSection = (
97
+ section: FumadocsSection,
98
+ only: FumadocsPageItem & { kind: "extract" | "ref" },
99
+ order: string[],
100
+ warnings: string[]
101
+ ): void => {
102
+ order.push(only.name);
103
+ if (humanize(only.name).toLowerCase() !== section.label.toLowerCase()) {
104
+ warnings.push(
105
+ `Section "${section.label}" wraps folder "${only.name}"; set that folder's meta title to "${section.label}" to make the heading match.`
106
+ );
107
+ }
108
+ if (only.kind === "extract") {
109
+ warnings.push(extractWarning(only.name));
110
+ }
111
+ };
112
+
113
+ /** Move one section item into the group folder; return its ordering key. */
114
+ const moveItemIntoGroup = async (
115
+ item: FumadocsPageItem,
116
+ docsDir: string,
117
+ groupDir: string,
118
+ label: string,
119
+ warnings: string[]
120
+ ): Promise<string | null> => {
121
+ if (item.kind === "link") {
122
+ warnings.push(linkWarning(item));
123
+ return null;
124
+ }
125
+ const resolved = resolveEntry(docsDir, item.name);
126
+ if (!resolved) {
127
+ warnings.push(
128
+ `Sidebar entry "${item.name}" in section "${label}" matched no page or folder; skipped.`
129
+ );
130
+ return null;
131
+ }
132
+ const dest = join(groupDir, basename(resolved.path));
133
+ if (existsSync(dest)) {
134
+ warnings.push(
135
+ `Skipped moving "${item.name}" into section "${label}" (target already exists).`
136
+ );
137
+ return null;
138
+ }
139
+ await mkdir(groupDir, { recursive: true });
140
+ await rename(resolved.path, dest);
141
+ if (item.kind === "extract") {
142
+ warnings.push(extractWarning(item.name));
143
+ }
144
+ return item.name;
145
+ };
146
+
147
+ const reshapeSection = async (
148
+ section: FumadocsSection,
149
+ docsDir: string,
150
+ order: string[],
151
+ warnings: string[]
152
+ ): Promise<void> => {
153
+ const movable = section.items.filter((item) => item.kind !== "link");
154
+
155
+ // A lone folder already renders as its own group; wrapping it in a `(Label)/`
156
+ // folder would stack two headings, so leave it in place.
157
+ if (movable.length === 1) {
158
+ const [only] = movable as [FumadocsPageItem & { kind: "extract" | "ref" }];
159
+ if (resolveEntry(docsDir, only.name)?.kind === "folder") {
160
+ reshapeSingleFolderSection(section, only, order, warnings);
161
+ return;
162
+ }
163
+ }
164
+
165
+ if (PATH_SEP.test(section.label)) {
166
+ warnings.push(
167
+ `Section "${section.label}" has a slash in its name and can't become a group folder; its pages stay ungrouped.`
168
+ );
169
+ for (const item of section.items) {
170
+ addUngroupedItem(item, order, warnings);
171
+ }
172
+ return;
173
+ }
174
+
175
+ const groupDir = join(docsDir, `(${section.label})`);
176
+ const sectionKeys: string[] = [];
177
+ for (const item of section.items) {
178
+ // oxlint-disable-next-line no-await-in-loop -- sequential moves into one group
179
+ const key = await moveItemIntoGroup(
180
+ item,
181
+ docsDir,
182
+ groupDir,
183
+ section.label,
184
+ warnings
185
+ );
186
+ if (key) {
187
+ sectionKeys.push(key);
188
+ }
189
+ }
190
+
191
+ // Nothing movable (e.g. a link-only section) — drop the empty heading.
192
+ if (sectionKeys.length === 0) {
193
+ return;
194
+ }
195
+
196
+ // The group folder's key in the nav is its label, so order it by label.
197
+ order.push(section.label);
198
+ // Preserve the authored order of the section's pages.
199
+ if (sectionKeys.length > 1) {
200
+ await writeFile(
201
+ join(groupDir, "meta.ts"),
202
+ renderMetaModule({ pages: sectionKeys }),
203
+ "utf-8"
204
+ );
205
+ }
206
+ };
207
+
208
+ /**
209
+ * Reshape a parsed `pages` structure into group folders under `docsDir`,
210
+ * returning the parent folder's `meta.ts` page ordering and any warnings. Items
211
+ * are moved within the already-migrated `docs/` tree, so this must run after the
212
+ * pages have been moved out of `content/docs`.
213
+ */
214
+ export const reshapeFumadocsGroups = async (
215
+ structure: FumadocsPagesStructure,
216
+ docsDir: string
217
+ ): Promise<GroupReshapeResult> => {
218
+ const order: string[] = [];
219
+ const warnings: string[] = [];
220
+
221
+ for (const item of structure.lead) {
222
+ addUngroupedItem(item, order, warnings);
223
+ }
224
+ for (const section of structure.sections) {
225
+ // oxlint-disable-next-line no-await-in-loop -- sections share `docsDir` state
226
+ await reshapeSection(section, docsDir, order, warnings);
227
+ }
228
+
229
+ return { order, warnings };
230
+ };