@rodavel/vite-plugin-content-tree 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.
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/index.cjs +537 -0
- package/dist/index.d.cts +277 -0
- package/dist/index.d.ts +277 -0
- package/dist/index.js +484 -0
- package/package.json +71 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rodavel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# @rodavel/vite-plugin-content-tree
|
|
2
|
+
|
|
3
|
+
Vite plugin that scans content directories, extracts frontmatter, and generates a typed navigation tree as a TypeScript file.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @rodavel/vite-plugin-content-tree
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// vite.config.ts
|
|
15
|
+
import { contentTreeGeneratorPlugin } from "@rodavel/vite-plugin-content-tree";
|
|
16
|
+
|
|
17
|
+
export default defineConfig({
|
|
18
|
+
plugins: [
|
|
19
|
+
contentTreeGeneratorPlugin({
|
|
20
|
+
docsDir: "src/content/docs",
|
|
21
|
+
outFile: "src/generated/docs-tree.gen.ts",
|
|
22
|
+
urlPrefix: "/docs",
|
|
23
|
+
treeName: "Documentation",
|
|
24
|
+
}),
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The plugin scans `docsDir` for page files (default `index.mdx`) with YAML frontmatter, builds a nested navigation tree, and writes it to `outFile` as a TypeScript module. During dev it watches for changes and regenerates automatically.
|
|
30
|
+
|
|
31
|
+
## Options
|
|
32
|
+
|
|
33
|
+
### Required
|
|
34
|
+
|
|
35
|
+
| Option | Type | Description |
|
|
36
|
+
| --- | --- | --- |
|
|
37
|
+
| `docsDir` | `string` | Path to the content directory to scan |
|
|
38
|
+
| `outFile` | `string` | Path where the generated TypeScript file is written |
|
|
39
|
+
| `urlPrefix` | `string` | URL prefix prepended to page paths (e.g., `"/docs"`) |
|
|
40
|
+
| `treeName` | `string` | Display name for the root tree object |
|
|
41
|
+
|
|
42
|
+
### Optional
|
|
43
|
+
|
|
44
|
+
| Option | Type | Default | Description |
|
|
45
|
+
| --- | --- | --- | --- |
|
|
46
|
+
| `root` | `{ name, id }` | — | Wraps top-level children in a synthetic root directory node |
|
|
47
|
+
| `treeType` | `{ from, name }` | — | Type to import for the tree variable in the generated file |
|
|
48
|
+
| `pageType` | `{ from, name }` | — | Type to import for the pages Map values in the generated file |
|
|
49
|
+
| `pagesExportName` | `string` | `"pages"` | Export name for the pages `Map` |
|
|
50
|
+
| `treeExportName` | `string` | `"docsTree"` | Export name for the tree object |
|
|
51
|
+
| `acronyms` | `string[] \| Set<string>` | — | Words to fully uppercase when humanizing directory names (e.g., `["api", "sdk"]`) |
|
|
52
|
+
| `metaFile` | `string` | `"_meta.json"` | Filename for directory metadata |
|
|
53
|
+
| `pageFile` | `string` | `"index.mdx"` | Filename to match as a page entry |
|
|
54
|
+
| `mapFrontmatter` | `FrontmatterMapper` | built-in | Custom frontmatter mapper |
|
|
55
|
+
| `enrichPages` | `PageEnricher` | no-op | Post-processes pages after scanning |
|
|
56
|
+
| `mapPageEntry` | `PageEntryMapper` | built-in | Customizes the per-page entry shape in the generated output |
|
|
57
|
+
| `resolveDirectoryLabel` | `DirectoryLabelResolver` | built-in | Resolves the display label for directory nodes |
|
|
58
|
+
|
|
59
|
+
## Compatibility
|
|
60
|
+
|
|
61
|
+
- Vite 5+
|
|
62
|
+
- Node.js 18+
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
[MIT](LICENSE)
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
DEFAULT_META_FILE: () => DEFAULT_META_FILE,
|
|
34
|
+
DEFAULT_ORDER: () => DEFAULT_ORDER,
|
|
35
|
+
DEFAULT_PAGE_FILE: () => DEFAULT_PAGE_FILE,
|
|
36
|
+
INDEX_SUFFIX: () => INDEX_SUFFIX,
|
|
37
|
+
buildChildren: () => buildChildren,
|
|
38
|
+
buildPageIndex: () => buildPageIndex,
|
|
39
|
+
contentTreeGeneratorPlugin: () => contentTreeGeneratorPlugin,
|
|
40
|
+
defaultMapFrontmatter: () => defaultMapFrontmatter,
|
|
41
|
+
defaultResolveDirectoryLabel: () => defaultResolveDirectoryLabel,
|
|
42
|
+
generate: () => generate,
|
|
43
|
+
generateData: () => generateData,
|
|
44
|
+
humanize: () => humanize,
|
|
45
|
+
readDirectoryMeta: () => readDirectoryMeta,
|
|
46
|
+
resolveModules: () => resolveModules,
|
|
47
|
+
scanPages: () => scanPages,
|
|
48
|
+
stripOrder: () => stripOrder,
|
|
49
|
+
writeOutput: () => writeOutput
|
|
50
|
+
});
|
|
51
|
+
module.exports = __toCommonJS(index_exports);
|
|
52
|
+
|
|
53
|
+
// src/config.ts
|
|
54
|
+
var INDEX_SUFFIX = "/index";
|
|
55
|
+
var DEFAULT_META_FILE = "_meta.json";
|
|
56
|
+
var DEFAULT_PAGE_FILE = "index.mdx";
|
|
57
|
+
var DEFAULT_ORDER = 999;
|
|
58
|
+
|
|
59
|
+
// src/generator.ts
|
|
60
|
+
var import_node_crypto = require("crypto");
|
|
61
|
+
var import_promises2 = require("fs/promises");
|
|
62
|
+
var import_node_path2 = require("path");
|
|
63
|
+
|
|
64
|
+
// src/scanner.ts
|
|
65
|
+
var import_promises = require("fs/promises");
|
|
66
|
+
var import_node_path = require("path");
|
|
67
|
+
var import_gray_matter = __toESM(require("gray-matter"), 1);
|
|
68
|
+
var BASE_FIELDS = /* @__PURE__ */ new Set(["title", "description", "order", "nav"]);
|
|
69
|
+
var META_BASE_FIELDS = /* @__PURE__ */ new Set(["name", "order"]);
|
|
70
|
+
var defaultMapFrontmatter = (raw) => {
|
|
71
|
+
const result = {
|
|
72
|
+
title: typeof raw.title === "string" ? raw.title : "",
|
|
73
|
+
description: typeof raw.description === "string" ? raw.description : void 0,
|
|
74
|
+
nav: typeof raw.nav === "string" ? raw.nav : void 0,
|
|
75
|
+
order: typeof raw.order === "number" ? raw.order : void 0
|
|
76
|
+
};
|
|
77
|
+
const extra = {};
|
|
78
|
+
let hasExtra = false;
|
|
79
|
+
for (const key of Object.keys(raw)) {
|
|
80
|
+
if (!BASE_FIELDS.has(key)) {
|
|
81
|
+
extra[key] = raw[key];
|
|
82
|
+
hasExtra = true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (hasExtra) {
|
|
86
|
+
result.extra = extra;
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
};
|
|
90
|
+
async function readDirectoryMeta(docsDir, dirPath, metaFile) {
|
|
91
|
+
const metaPath = (0, import_node_path.join)(docsDir, dirPath, metaFile);
|
|
92
|
+
const resolvedMeta = (0, import_node_path.resolve)(metaPath);
|
|
93
|
+
const resolvedRoot = (0, import_node_path.resolve)(docsDir);
|
|
94
|
+
if (!resolvedMeta.startsWith(`${resolvedRoot}/`) && resolvedMeta !== resolvedRoot) {
|
|
95
|
+
throw new Error(`Metadata path escapes content root: ${metaPath}`);
|
|
96
|
+
}
|
|
97
|
+
let content;
|
|
98
|
+
try {
|
|
99
|
+
content = await (0, import_promises.readFile)(resolvedMeta, "utf-8");
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (err.code === "ENOENT") {
|
|
102
|
+
return {};
|
|
103
|
+
}
|
|
104
|
+
throw new Error(`Failed to read metadata file: ${metaPath}`, { cause: err });
|
|
105
|
+
}
|
|
106
|
+
let parsed;
|
|
107
|
+
try {
|
|
108
|
+
parsed = JSON.parse(content);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
throw new Error(`Failed to parse metadata file: ${metaPath}`, { cause: err });
|
|
111
|
+
}
|
|
112
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
113
|
+
const actual = Array.isArray(parsed) ? "array" : typeof parsed;
|
|
114
|
+
throw new Error(`Metadata file must contain a JSON object, got ${actual}: ${metaPath}`);
|
|
115
|
+
}
|
|
116
|
+
const obj = parsed;
|
|
117
|
+
const meta = {};
|
|
118
|
+
if (typeof obj.name === "string") meta.name = obj.name;
|
|
119
|
+
if (typeof obj.order === "number" && Number.isFinite(obj.order)) meta.order = obj.order;
|
|
120
|
+
const extra = {};
|
|
121
|
+
let hasExtra = false;
|
|
122
|
+
for (const key of Object.keys(obj)) {
|
|
123
|
+
if (!META_BASE_FIELDS.has(key)) {
|
|
124
|
+
extra[key] = obj[key];
|
|
125
|
+
hasExtra = true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (hasExtra) {
|
|
129
|
+
meta.extra = extra;
|
|
130
|
+
}
|
|
131
|
+
return meta;
|
|
132
|
+
}
|
|
133
|
+
async function scanPages(docsDir, pageFile, mapFrontmatter = defaultMapFrontmatter) {
|
|
134
|
+
const pages = [];
|
|
135
|
+
async function walk(dir) {
|
|
136
|
+
const entries = await (0, import_promises.readdir)(dir, { withFileTypes: true });
|
|
137
|
+
const subdirs = [];
|
|
138
|
+
for (const entry of entries) {
|
|
139
|
+
const full = (0, import_node_path.join)(dir, entry.name);
|
|
140
|
+
if (entry.isDirectory()) {
|
|
141
|
+
subdirs.push(full);
|
|
142
|
+
} else if (entry.name === pageFile) {
|
|
143
|
+
const relPath = (0, import_node_path.relative)(docsDir, full).replace(/\\/g, "/");
|
|
144
|
+
const dirRel = (0, import_node_path.dirname)(relPath);
|
|
145
|
+
const key = dirRel === "." ? "" : dirRel;
|
|
146
|
+
const segments = key ? key.split("/") : [];
|
|
147
|
+
let content;
|
|
148
|
+
try {
|
|
149
|
+
content = await (0, import_promises.readFile)(full, "utf-8");
|
|
150
|
+
} catch (err) {
|
|
151
|
+
throw new Error(`Failed to read page file: ${full}`, { cause: err });
|
|
152
|
+
}
|
|
153
|
+
let data;
|
|
154
|
+
try {
|
|
155
|
+
data = (0, import_gray_matter.default)(content).data;
|
|
156
|
+
} catch (err) {
|
|
157
|
+
throw new Error(`Failed to parse frontmatter in: ${full}`, { cause: err });
|
|
158
|
+
}
|
|
159
|
+
const result = mapFrontmatter(data, relPath);
|
|
160
|
+
pages.push({
|
|
161
|
+
key,
|
|
162
|
+
title: result.title,
|
|
163
|
+
description: result.description,
|
|
164
|
+
nav: result.nav,
|
|
165
|
+
order: result.order ?? DEFAULT_ORDER,
|
|
166
|
+
file: relPath,
|
|
167
|
+
segments,
|
|
168
|
+
extra: result.extra
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
await Promise.all(subdirs.map(walk));
|
|
173
|
+
}
|
|
174
|
+
await walk(docsDir);
|
|
175
|
+
return pages;
|
|
176
|
+
}
|
|
177
|
+
function resolveModules(pages, pageByKey) {
|
|
178
|
+
for (const page of pages) {
|
|
179
|
+
if (page.extra?.module) continue;
|
|
180
|
+
if (page.segments.length === 0) continue;
|
|
181
|
+
const moduleRoot = pageByKey.get(page.segments[0]);
|
|
182
|
+
const mod = moduleRoot?.extra?.module;
|
|
183
|
+
if (mod) {
|
|
184
|
+
page.extra = { ...page.extra, module: mod };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// src/tree-builder.ts
|
|
190
|
+
function buildPageIndex(pages) {
|
|
191
|
+
const directChildren = /* @__PURE__ */ new Map();
|
|
192
|
+
const subDirNames = /* @__PURE__ */ new Map();
|
|
193
|
+
for (const page of pages) {
|
|
194
|
+
if (page.key === "") continue;
|
|
195
|
+
const parentPath = page.segments.slice(0, -1).join("/") || "";
|
|
196
|
+
let children = directChildren.get(parentPath);
|
|
197
|
+
if (!children) {
|
|
198
|
+
children = [];
|
|
199
|
+
directChildren.set(parentPath, children);
|
|
200
|
+
}
|
|
201
|
+
children.push(page);
|
|
202
|
+
for (let depth = 0; depth < page.segments.length - 1; depth++) {
|
|
203
|
+
const ancestor = page.segments.slice(0, depth).join("/") || "";
|
|
204
|
+
let dirs = subDirNames.get(ancestor);
|
|
205
|
+
if (!dirs) {
|
|
206
|
+
dirs = /* @__PURE__ */ new Set();
|
|
207
|
+
subDirNames.set(ancestor, dirs);
|
|
208
|
+
}
|
|
209
|
+
dirs.add(page.segments[depth]);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return { directChildren, subDirNames };
|
|
213
|
+
}
|
|
214
|
+
function humanize(slug, acronyms) {
|
|
215
|
+
return slug.split("-").map((w) => acronyms.has(w) ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
216
|
+
}
|
|
217
|
+
var defaultResolveDirectoryLabel = (dirName, meta, indexPage, acronyms) => {
|
|
218
|
+
const mod = indexPage?.extra?.module;
|
|
219
|
+
if (mod) return mod;
|
|
220
|
+
if (meta.name) return meta.name;
|
|
221
|
+
return humanize(dirName, acronyms);
|
|
222
|
+
};
|
|
223
|
+
function buildChildren(parentPath, ctx) {
|
|
224
|
+
const parentSegments = parentPath ? parentPath.split("/") : [];
|
|
225
|
+
const directPages = ctx.pageIndex.directChildren.get(parentPath) ?? [];
|
|
226
|
+
const dirNames = ctx.pageIndex.subDirNames.get(parentPath) ?? /* @__PURE__ */ new Set();
|
|
227
|
+
const pageBySegment = /* @__PURE__ */ new Map();
|
|
228
|
+
for (const page of directPages) {
|
|
229
|
+
pageBySegment.set(page.segments[parentSegments.length], page);
|
|
230
|
+
}
|
|
231
|
+
const results = [];
|
|
232
|
+
for (const page of directPages) {
|
|
233
|
+
const lastSegment = page.segments[page.segments.length - 1];
|
|
234
|
+
if (dirNames.has(lastSegment)) continue;
|
|
235
|
+
const pageNode = {
|
|
236
|
+
type: "page",
|
|
237
|
+
name: page.nav ?? page.title,
|
|
238
|
+
id: page.key,
|
|
239
|
+
url: `${ctx.urlPrefix}/${page.key}`,
|
|
240
|
+
order: page.order
|
|
241
|
+
};
|
|
242
|
+
results.push(pageNode);
|
|
243
|
+
}
|
|
244
|
+
for (const dirName of dirNames) {
|
|
245
|
+
const dirPath = parentPath ? `${parentPath}/${dirName}` : dirName;
|
|
246
|
+
const indexPage = pageBySegment.get(dirName);
|
|
247
|
+
const meta = ctx.metas.get(dirPath) ?? {};
|
|
248
|
+
const children = buildChildren(dirPath, ctx);
|
|
249
|
+
if (children.length === 0 && !indexPage) continue;
|
|
250
|
+
const dirLabel = ctx.resolveDirectoryLabel(dirName, meta, indexPage, ctx.acronyms);
|
|
251
|
+
const directory = {
|
|
252
|
+
type: "directory",
|
|
253
|
+
name: dirLabel,
|
|
254
|
+
id: dirPath,
|
|
255
|
+
children,
|
|
256
|
+
order: indexPage?.order ?? meta.order ?? DEFAULT_ORDER
|
|
257
|
+
};
|
|
258
|
+
if (indexPage) {
|
|
259
|
+
directory.index = {
|
|
260
|
+
type: "page",
|
|
261
|
+
name: indexPage.nav ?? indexPage.title,
|
|
262
|
+
url: `${ctx.urlPrefix}/${indexPage.key}`,
|
|
263
|
+
id: `${indexPage.key}${INDEX_SUFFIX}`
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
results.push(directory);
|
|
267
|
+
}
|
|
268
|
+
results.sort((a, b) => {
|
|
269
|
+
if (a.order !== b.order) return a.order - b.order;
|
|
270
|
+
return a.name.localeCompare(b.name);
|
|
271
|
+
});
|
|
272
|
+
return results;
|
|
273
|
+
}
|
|
274
|
+
function stripOrder(node) {
|
|
275
|
+
if (node.type === "page") {
|
|
276
|
+
const { order: _2, ...rest2 } = node;
|
|
277
|
+
return rest2;
|
|
278
|
+
}
|
|
279
|
+
const { order: _, children, index, ...rest } = node;
|
|
280
|
+
return {
|
|
281
|
+
...rest,
|
|
282
|
+
children: children.map(stripOrder),
|
|
283
|
+
...index && { index }
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// src/generator.ts
|
|
288
|
+
var SAFE_IDENTIFIER = /^[a-zA-Z_$][\w$]*$/;
|
|
289
|
+
var SAFE_IMPORT_PATH = /^(?:\.\.?\/[\w@._$/-]+|@[\w-]+\/[\w._-]+(?:\/[\w._-]+)*|[a-zA-Z][\w._-]*(?:\/[\w._-]+)*)$/;
|
|
290
|
+
function normalizeAcronyms(acronyms) {
|
|
291
|
+
if (acronyms instanceof Set) return acronyms;
|
|
292
|
+
return new Set(acronyms ?? []);
|
|
293
|
+
}
|
|
294
|
+
var defaultMapPageEntry = (page) => {
|
|
295
|
+
const entry = { ...page.extra };
|
|
296
|
+
entry.title = page.title;
|
|
297
|
+
if (page.description) entry.description = page.description;
|
|
298
|
+
entry.file = page.file;
|
|
299
|
+
return entry;
|
|
300
|
+
};
|
|
301
|
+
async function preloadMetas(docsDir, metaFile, subDirNames) {
|
|
302
|
+
const dirPaths = /* @__PURE__ */ new Set();
|
|
303
|
+
for (const [parentPath, dirs] of subDirNames) {
|
|
304
|
+
for (const dir of dirs) {
|
|
305
|
+
dirPaths.add(parentPath ? `${parentPath}/${dir}` : dir);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const metas = /* @__PURE__ */ new Map();
|
|
309
|
+
await Promise.all(
|
|
310
|
+
[...dirPaths].map(async (dirPath) => {
|
|
311
|
+
const meta = await readDirectoryMeta(docsDir, dirPath, metaFile);
|
|
312
|
+
if (meta.name !== void 0 || meta.order !== void 0 || meta.extra !== void 0) {
|
|
313
|
+
metas.set(dirPath, meta);
|
|
314
|
+
}
|
|
315
|
+
})
|
|
316
|
+
);
|
|
317
|
+
return metas;
|
|
318
|
+
}
|
|
319
|
+
async function generateData(opts) {
|
|
320
|
+
const metaFile = opts.metaFile ?? DEFAULT_META_FILE;
|
|
321
|
+
const pageFile = opts.pageFile ?? DEFAULT_PAGE_FILE;
|
|
322
|
+
const acronyms = normalizeAcronyms(opts.acronyms);
|
|
323
|
+
const pages = await scanPages(opts.docsDir, pageFile, opts.mapFrontmatter);
|
|
324
|
+
const pageByKey = new Map(pages.map((p) => [p.key, p]));
|
|
325
|
+
const enrich = opts.enrichPages ?? (() => {
|
|
326
|
+
});
|
|
327
|
+
enrich(pages, pageByKey);
|
|
328
|
+
const resolveLabel = opts.resolveDirectoryLabel ?? defaultResolveDirectoryLabel;
|
|
329
|
+
const pageIndex = buildPageIndex(pages);
|
|
330
|
+
const metas = await preloadMetas(opts.docsDir, metaFile, pageIndex.subDirNames);
|
|
331
|
+
const ctx = {
|
|
332
|
+
pageIndex,
|
|
333
|
+
metas,
|
|
334
|
+
urlPrefix: opts.urlPrefix,
|
|
335
|
+
acronyms,
|
|
336
|
+
resolveDirectoryLabel: resolveLabel
|
|
337
|
+
};
|
|
338
|
+
const topChildren = buildChildren("", ctx);
|
|
339
|
+
let tree;
|
|
340
|
+
const rootPage = pageByKey.get("");
|
|
341
|
+
if (opts.root) {
|
|
342
|
+
const rootDirectory = {
|
|
343
|
+
type: "directory",
|
|
344
|
+
name: opts.root.name,
|
|
345
|
+
id: opts.root.id,
|
|
346
|
+
children: [],
|
|
347
|
+
order: DEFAULT_ORDER
|
|
348
|
+
};
|
|
349
|
+
if (rootPage) {
|
|
350
|
+
rootDirectory.index = {
|
|
351
|
+
type: "page",
|
|
352
|
+
name: rootPage.nav ?? rootPage.title,
|
|
353
|
+
url: opts.urlPrefix,
|
|
354
|
+
id: `${opts.root.id}${INDEX_SUFFIX}`
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
const treeChildren = [stripOrder(rootDirectory), ...topChildren.map(stripOrder)];
|
|
358
|
+
tree = { type: "directory", name: opts.treeName, id: opts.treeName, children: treeChildren };
|
|
359
|
+
} else {
|
|
360
|
+
tree = {
|
|
361
|
+
type: "directory",
|
|
362
|
+
name: opts.treeName,
|
|
363
|
+
id: opts.treeName,
|
|
364
|
+
children: topChildren.map(stripOrder)
|
|
365
|
+
};
|
|
366
|
+
if (rootPage) {
|
|
367
|
+
tree.index = {
|
|
368
|
+
type: "page",
|
|
369
|
+
name: rootPage.nav ?? rootPage.title,
|
|
370
|
+
url: opts.urlPrefix,
|
|
371
|
+
id: `${opts.treeName}${INDEX_SUFFIX}`
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return { pages, tree };
|
|
376
|
+
}
|
|
377
|
+
async function writeOutput(opts, data) {
|
|
378
|
+
const { treeType, pageType } = opts;
|
|
379
|
+
const pagesExport = opts.pagesExportName ?? "pages";
|
|
380
|
+
const treeExport = opts.treeExportName ?? "docsTree";
|
|
381
|
+
const mapEntry = opts.mapPageEntry ?? defaultMapPageEntry;
|
|
382
|
+
if (!SAFE_IDENTIFIER.test(pagesExport)) {
|
|
383
|
+
throw new Error(`Invalid pagesExportName: "${pagesExport}" \u2014 must be a valid JS identifier`);
|
|
384
|
+
}
|
|
385
|
+
if (!SAFE_IDENTIFIER.test(treeExport)) {
|
|
386
|
+
throw new Error(`Invalid treeExportName: "${treeExport}" \u2014 must be a valid JS identifier`);
|
|
387
|
+
}
|
|
388
|
+
if (treeType) {
|
|
389
|
+
if (!SAFE_IDENTIFIER.test(treeType.name)) {
|
|
390
|
+
throw new Error(`Invalid treeType.name: "${treeType.name}" \u2014 must be a valid JS identifier`);
|
|
391
|
+
}
|
|
392
|
+
if (!SAFE_IMPORT_PATH.test(treeType.from)) {
|
|
393
|
+
throw new Error(`Invalid treeType.from: "${treeType.from}" \u2014 must be a valid import path`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (pageType) {
|
|
397
|
+
if (!SAFE_IDENTIFIER.test(pageType.name)) {
|
|
398
|
+
throw new Error(`Invalid pageType.name: "${pageType.name}" \u2014 must be a valid JS identifier`);
|
|
399
|
+
}
|
|
400
|
+
if (!SAFE_IMPORT_PATH.test(pageType.from)) {
|
|
401
|
+
throw new Error(`Invalid pageType.from: "${pageType.from}" \u2014 must be a valid import path`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const pagesEntries = data.pages.map((p) => [
|
|
405
|
+
p.key,
|
|
406
|
+
mapEntry(p)
|
|
407
|
+
]);
|
|
408
|
+
const pagesJson = JSON.stringify(pagesEntries, null, " ");
|
|
409
|
+
const treeJson = JSON.stringify(data.tree, null, " ");
|
|
410
|
+
const lines = [
|
|
411
|
+
"// This file is auto-generated by the content-tree plugin. Do not edit."
|
|
412
|
+
];
|
|
413
|
+
if (treeType && pageType && treeType.from === pageType.from) {
|
|
414
|
+
lines.push(`import type { ${pageType.name}, ${treeType.name} } from "${treeType.from}";`);
|
|
415
|
+
} else {
|
|
416
|
+
if (pageType) {
|
|
417
|
+
lines.push(`import type { ${pageType.name} } from "${pageType.from}";`);
|
|
418
|
+
}
|
|
419
|
+
if (treeType) {
|
|
420
|
+
lines.push(`import type { ${treeType.name} } from "${treeType.from}";`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const pagesValueType = pageType ? pageType.name : "Record<string, unknown>";
|
|
424
|
+
lines.push("");
|
|
425
|
+
lines.push(`export const ${pagesExport} = new Map<string, ${pagesValueType}>(${pagesJson});`);
|
|
426
|
+
lines.push("");
|
|
427
|
+
if (treeType) {
|
|
428
|
+
lines.push(`export const ${treeExport}: ${treeType.name} = ${treeJson};`);
|
|
429
|
+
} else {
|
|
430
|
+
lines.push(`export const ${treeExport} = ${treeJson};`);
|
|
431
|
+
}
|
|
432
|
+
const outDir = (0, import_node_path2.dirname)(opts.outFile);
|
|
433
|
+
await (0, import_promises2.mkdir)(outDir, { recursive: true });
|
|
434
|
+
const tmpFile = (0, import_node_path2.join)(outDir, `.${(0, import_node_crypto.randomUUID)()}.content-tree.tmp`);
|
|
435
|
+
try {
|
|
436
|
+
await (0, import_promises2.writeFile)(tmpFile, `${lines.join("\n")}
|
|
437
|
+
`);
|
|
438
|
+
await (0, import_promises2.rename)(tmpFile, opts.outFile);
|
|
439
|
+
} catch (err) {
|
|
440
|
+
try {
|
|
441
|
+
await (0, import_promises2.unlink)(tmpFile);
|
|
442
|
+
} catch {
|
|
443
|
+
}
|
|
444
|
+
throw err;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
async function generate(opts) {
|
|
448
|
+
const data = await generateData(opts);
|
|
449
|
+
await writeOutput(opts, data);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// src/plugin.ts
|
|
453
|
+
var import_node_path3 = require("path");
|
|
454
|
+
var import_vite = require("vite");
|
|
455
|
+
function contentTreeGeneratorPlugin(opts) {
|
|
456
|
+
let resolved;
|
|
457
|
+
const metaFile = opts.metaFile ?? DEFAULT_META_FILE;
|
|
458
|
+
const pageFile = opts.pageFile ?? DEFAULT_PAGE_FILE;
|
|
459
|
+
return {
|
|
460
|
+
name: "content-tree",
|
|
461
|
+
enforce: "pre",
|
|
462
|
+
configResolved(config) {
|
|
463
|
+
resolved = {
|
|
464
|
+
...opts,
|
|
465
|
+
docsDir: (0, import_vite.normalizePath)((0, import_node_path3.resolve)(config.root, opts.docsDir)),
|
|
466
|
+
outFile: (0, import_vite.normalizePath)((0, import_node_path3.resolve)(config.root, opts.outFile))
|
|
467
|
+
};
|
|
468
|
+
},
|
|
469
|
+
async buildStart() {
|
|
470
|
+
await generate(resolved);
|
|
471
|
+
},
|
|
472
|
+
configureServer(server) {
|
|
473
|
+
let debounceTimer;
|
|
474
|
+
let generating = false;
|
|
475
|
+
let pendingRegeneration = false;
|
|
476
|
+
async function regenerate() {
|
|
477
|
+
generating = true;
|
|
478
|
+
try {
|
|
479
|
+
await generate(resolved);
|
|
480
|
+
const mod = server.moduleGraph.getModuleById(resolved.outFile);
|
|
481
|
+
if (mod) {
|
|
482
|
+
server.moduleGraph.invalidateModule(mod);
|
|
483
|
+
}
|
|
484
|
+
server.hot.send({ type: "full-reload" });
|
|
485
|
+
} catch (err) {
|
|
486
|
+
server.config.logger.error(
|
|
487
|
+
`[content-tree] Generation failed: ${err instanceof Error ? err.message : err}`
|
|
488
|
+
);
|
|
489
|
+
} finally {
|
|
490
|
+
generating = false;
|
|
491
|
+
if (pendingRegeneration) {
|
|
492
|
+
pendingRegeneration = false;
|
|
493
|
+
debounceTimer = setTimeout(regenerate, 150);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const handler = (file) => {
|
|
498
|
+
const normalized = (0, import_vite.normalizePath)(file);
|
|
499
|
+
if (normalized === resolved.outFile) return;
|
|
500
|
+
if (normalized.startsWith(`${resolved.docsDir}/`) && (normalized.endsWith(`/${pageFile}`) || normalized.endsWith(`/${metaFile}`))) {
|
|
501
|
+
if (generating) {
|
|
502
|
+
pendingRegeneration = true;
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
clearTimeout(debounceTimer);
|
|
506
|
+
debounceTimer = setTimeout(regenerate, 150);
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
server.watcher.on("change", handler);
|
|
510
|
+
server.watcher.on("add", handler);
|
|
511
|
+
server.watcher.on("unlink", handler);
|
|
512
|
+
server.httpServer?.on("close", () => {
|
|
513
|
+
clearTimeout(debounceTimer);
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
519
|
+
0 && (module.exports = {
|
|
520
|
+
DEFAULT_META_FILE,
|
|
521
|
+
DEFAULT_ORDER,
|
|
522
|
+
DEFAULT_PAGE_FILE,
|
|
523
|
+
INDEX_SUFFIX,
|
|
524
|
+
buildChildren,
|
|
525
|
+
buildPageIndex,
|
|
526
|
+
contentTreeGeneratorPlugin,
|
|
527
|
+
defaultMapFrontmatter,
|
|
528
|
+
defaultResolveDirectoryLabel,
|
|
529
|
+
generate,
|
|
530
|
+
generateData,
|
|
531
|
+
humanize,
|
|
532
|
+
readDirectoryMeta,
|
|
533
|
+
resolveModules,
|
|
534
|
+
scanPages,
|
|
535
|
+
stripOrder,
|
|
536
|
+
writeOutput
|
|
537
|
+
});
|