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.
- package/dist/cli/index.js +1213 -473
- package/dist/cli/index.js.map +33 -29
- package/dist/types/core/bridge.d.ts +24 -0
- package/dist/types/core/config.d.ts +35 -0
- package/dist/types/core/data.d.ts +129 -0
- package/dist/types/core/define-components.d.ts +27 -0
- package/dist/types/core/define-meta.d.ts +16 -0
- package/dist/types/core/deployment-env.d.ts +9 -0
- package/dist/types/core/diagnostics.d.ts +17 -0
- package/dist/types/core/i18n-ui.d.ts +500 -0
- package/dist/types/core/load-module.d.ts +7 -0
- package/dist/types/core/package-root.d.ts +17 -0
- package/dist/types/core/project.d.ts +9 -0
- package/dist/types/core/schema.d.ts +3461 -0
- package/dist/types/core/sources/types.d.ts +107 -0
- package/dist/types/core/types.d.ts +245 -0
- package/dist/types/core/ui-packs/ar.d.ts +3 -0
- package/dist/types/core/ui-packs/bg.d.ts +3 -0
- package/dist/types/core/ui-packs/bn.d.ts +3 -0
- package/dist/types/core/ui-packs/ca.d.ts +3 -0
- package/dist/types/core/ui-packs/cs.d.ts +3 -0
- package/dist/types/core/ui-packs/da.d.ts +3 -0
- package/dist/types/core/ui-packs/de.d.ts +3 -0
- package/dist/types/core/ui-packs/el.d.ts +3 -0
- package/dist/types/core/ui-packs/es.d.ts +3 -0
- package/dist/types/core/ui-packs/fa.d.ts +3 -0
- package/dist/types/core/ui-packs/fi.d.ts +3 -0
- package/dist/types/core/ui-packs/fr.d.ts +3 -0
- package/dist/types/core/ui-packs/he.d.ts +3 -0
- package/dist/types/core/ui-packs/hi.d.ts +3 -0
- package/dist/types/core/ui-packs/hr.d.ts +3 -0
- package/dist/types/core/ui-packs/hu.d.ts +3 -0
- package/dist/types/core/ui-packs/id.d.ts +3 -0
- package/dist/types/core/ui-packs/index.d.ts +13 -0
- package/dist/types/core/ui-packs/it.d.ts +3 -0
- package/dist/types/core/ui-packs/ja.d.ts +3 -0
- package/dist/types/core/ui-packs/ko.d.ts +3 -0
- package/dist/types/core/ui-packs/nl.d.ts +3 -0
- package/dist/types/core/ui-packs/no.d.ts +3 -0
- package/dist/types/core/ui-packs/pl.d.ts +3 -0
- package/dist/types/core/ui-packs/pt-br.d.ts +3 -0
- package/dist/types/core/ui-packs/pt.d.ts +3 -0
- package/dist/types/core/ui-packs/ro.d.ts +3 -0
- package/dist/types/core/ui-packs/ru.d.ts +3 -0
- package/dist/types/core/ui-packs/sk.d.ts +3 -0
- package/dist/types/core/ui-packs/sr.d.ts +3 -0
- package/dist/types/core/ui-packs/sv.d.ts +3 -0
- package/dist/types/core/ui-packs/th.d.ts +3 -0
- package/dist/types/core/ui-packs/tr.d.ts +3 -0
- package/dist/types/core/ui-packs/uk.d.ts +3 -0
- package/dist/types/core/ui-packs/vi.d.ts +3 -0
- package/dist/types/core/ui-packs/zh-tw.d.ts +3 -0
- package/dist/types/core/ui-packs/zh.d.ts +3 -0
- package/dist/types/core/version.d.ts +8 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/migrate/mintlify/config.d.ts +2 -0
- package/dist/types/migrate/mintlify/i18n.d.ts +7 -0
- package/dist/types/theme/fonts.d.ts +163 -0
- package/docs/advanced/custom-pages.mdx +91 -10
- package/docs/configuration/index.mdx +19 -0
- package/docs/content/components.mdx +103 -1
- package/package.json +13 -3
- package/src/ai/llms.ts +1 -2
- package/src/astro/examples.ts +106 -0
- package/src/astro/generate.ts +300 -75
- package/src/astro/index.ts +1 -1
- package/src/astro/islands.ts +1 -1
- package/src/astro/pages.ts +59 -0
- package/src/astro/templates.ts +180 -22
- package/src/cli/commands/validate.ts +1 -0
- package/src/components/content/CodeBlock.astro +28 -0
- package/src/components/content/Component.astro +65 -0
- package/src/components/content/Diff.astro +44 -0
- package/src/components/content/Tab.astro +8 -2
- package/src/components/content/diff.ts +95 -0
- package/src/components/layout/Favicon.astro +11 -4
- package/src/components/layout/PageLayout.astro +173 -0
- package/src/components/layout/ReferenceLayout.astro +6 -1
- package/src/components/layout/RootLayout.astro +9 -5
- package/src/components/layout/head-scripts.ts +19 -0
- package/src/core/data.ts +128 -0
- package/src/core/frontmatter.ts +43 -0
- package/src/core/links.ts +26 -10
- package/src/core/schema.ts +8 -0
- package/src/core/sources/filesystem.ts +1 -1
- package/src/core/sources/mdx-remote.ts +1 -2
- package/src/core/sources/mintlify.ts +1 -1
- package/src/core/sources/normalize.ts +19 -3
- package/src/core/sources/notion.ts +1 -1
- package/src/core/sources/sanity.ts +1 -1
- package/src/core/tsconfig-aliases.ts +201 -0
- package/src/index.ts +12 -0
- package/src/markdown/index.ts +69 -0
- package/src/migrate/fumadocs/config.ts +53 -4
- package/src/migrate/fumadocs/content.ts +1 -1
- package/src/migrate/fumadocs/groups.ts +230 -0
- package/src/migrate/fumadocs/index.ts +104 -19
- package/src/migrate/fumadocs/meta.ts +140 -10
- package/src/migrate/mintlify/snippets.ts +2 -1
- package/src/migrate/mintlify/transform.ts +1 -2
- package/src/migrate/nextra/index.ts +1 -1
- package/src/migrate/shared.ts +98 -1
- package/src/migrate/starlight/index.ts +1 -1
- package/src/registry/eject.ts +57 -10
- package/src/search/documents.ts +1 -2
- 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,
|
package/src/markdown/index.ts
CHANGED
|
@@ -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("&", "&")
|
|
111
|
+
.replaceAll("<", "<")
|
|
112
|
+
.replaceAll(">", ">");
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
+
};
|