mdzilla 0.0.0 → 0.0.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/LICENSE +21 -0
- package/README.md +157 -0
- package/dist/_chunks/exporter.mjs +787 -0
- package/dist/cli/main.d.mts +1 -0
- package/dist/cli/main.mjs +788 -0
- package/dist/index.d.mts +161 -0
- package/dist/index.mjs +2 -0
- package/package.json +52 -1
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, extname, join } from "node:path";
|
|
3
|
+
import { parseMeta, renderToText } from "md4x";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
//#region src/docs/manager.ts
|
|
7
|
+
var DocsManager = class {
|
|
8
|
+
source;
|
|
9
|
+
tree = [];
|
|
10
|
+
flat = [];
|
|
11
|
+
_fileMap = /* @__PURE__ */ new Map();
|
|
12
|
+
_contentCache = /* @__PURE__ */ new Map();
|
|
13
|
+
constructor(source) {
|
|
14
|
+
this.source = source;
|
|
15
|
+
}
|
|
16
|
+
async load() {
|
|
17
|
+
const { tree, fileMap } = await this.source.load();
|
|
18
|
+
this.tree = tree;
|
|
19
|
+
this._fileMap = fileMap;
|
|
20
|
+
this.flat = flattenTree(this.tree, 0, this._fileMap);
|
|
21
|
+
this._contentCache.clear();
|
|
22
|
+
}
|
|
23
|
+
async reload() {
|
|
24
|
+
return this.load();
|
|
25
|
+
}
|
|
26
|
+
/** Get raw file content for a flat entry (cached). */
|
|
27
|
+
async getContent(entry) {
|
|
28
|
+
if (!entry.filePath || entry.entry.page === false) return void 0;
|
|
29
|
+
const cached = this._contentCache.get(entry.filePath);
|
|
30
|
+
if (cached !== void 0) return cached;
|
|
31
|
+
const raw = await this.source.readContent(entry.filePath);
|
|
32
|
+
this._contentCache.set(entry.filePath, raw);
|
|
33
|
+
return raw;
|
|
34
|
+
}
|
|
35
|
+
/** Invalidate cached content for a specific file path. */
|
|
36
|
+
invalidate(filePath) {
|
|
37
|
+
this._contentCache.delete(filePath);
|
|
38
|
+
}
|
|
39
|
+
/** Fuzzy filter flat entries by query string. */
|
|
40
|
+
filter(query) {
|
|
41
|
+
return fuzzyFilter(this.flat, query, ({ entry }) => [entry.title, entry.path]);
|
|
42
|
+
}
|
|
43
|
+
/** Flat entries that are navigable pages (excludes directory stubs). */
|
|
44
|
+
get pages() {
|
|
45
|
+
return this.flat.filter((f) => f.entry.page !== false);
|
|
46
|
+
}
|
|
47
|
+
/** Find a flat entry by path (exact or with trailing slash). */
|
|
48
|
+
findByPath(path) {
|
|
49
|
+
return this.flat.find((f) => f.entry.page !== false && (f.entry.path === path || f.entry.path === path + "/"));
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Resolve a page path to its content, trying:
|
|
53
|
+
* 1. Exact match in the navigation tree
|
|
54
|
+
* 2. Stripped common prefix (e.g., /docs/guide/... → /guide/...)
|
|
55
|
+
* 3. Direct source fetch (for HTTP sources with uncrawled paths)
|
|
56
|
+
*/
|
|
57
|
+
async resolvePage(path) {
|
|
58
|
+
const normalized = path.startsWith("/") ? path : "/" + path;
|
|
59
|
+
const entry = this.findByPath(normalized);
|
|
60
|
+
if (entry) {
|
|
61
|
+
const raw = await this.getContent(entry);
|
|
62
|
+
if (raw) return {
|
|
63
|
+
entry,
|
|
64
|
+
raw
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const prefixed = normalized.match(/^\/[^/]+(\/.+)$/);
|
|
68
|
+
if (prefixed) {
|
|
69
|
+
const stripped = this.findByPath(prefixed[1]);
|
|
70
|
+
if (stripped) {
|
|
71
|
+
const raw = await this.getContent(stripped);
|
|
72
|
+
if (raw) return {
|
|
73
|
+
entry: stripped,
|
|
74
|
+
raw
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const raw = await this.source.readContent(normalized).catch(() => void 0);
|
|
79
|
+
if (raw) return { raw };
|
|
80
|
+
return {};
|
|
81
|
+
}
|
|
82
|
+
/** Return indices of matching flat entries (case-insensitive substring). */
|
|
83
|
+
matchIndices(query) {
|
|
84
|
+
if (!query) return [];
|
|
85
|
+
const lower = query.toLowerCase();
|
|
86
|
+
const matched = /* @__PURE__ */ new Set();
|
|
87
|
+
for (let i = 0; i < this.flat.length; i++) {
|
|
88
|
+
const { entry } = this.flat[i];
|
|
89
|
+
if (entry.title.toLowerCase().includes(lower) || entry.path.toLowerCase().includes(lower)) {
|
|
90
|
+
matched.add(i);
|
|
91
|
+
const parentDepth = this.flat[i].depth;
|
|
92
|
+
for (let j = i + 1; j < this.flat.length; j++) {
|
|
93
|
+
if (this.flat[j].depth <= parentDepth) break;
|
|
94
|
+
matched.add(j);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return [...matched].sort((a, b) => a - b);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
function flattenTree(entries, depth, fileMap) {
|
|
102
|
+
const result = [];
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
result.push({
|
|
105
|
+
entry,
|
|
106
|
+
depth,
|
|
107
|
+
filePath: fileMap.get(entry.path)
|
|
108
|
+
});
|
|
109
|
+
if (entry.children) result.push(...flattenTree(entry.children, depth + 1, fileMap));
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Fuzzy match: checks if all characters of `query` appear in `target` in order.
|
|
115
|
+
* Returns a score (lower is better) or -1 if no match.
|
|
116
|
+
*/
|
|
117
|
+
function fuzzyMatch(query, target) {
|
|
118
|
+
const q = query.toLowerCase();
|
|
119
|
+
const t = target.toLowerCase();
|
|
120
|
+
if (q.length === 0) return 0;
|
|
121
|
+
if (q.length > t.length) return -1;
|
|
122
|
+
let score = 0;
|
|
123
|
+
let qi = 0;
|
|
124
|
+
let prevMatchIdx = -1;
|
|
125
|
+
for (let ti = 0; ti < t.length && qi < q.length; ti++) if (t[ti] === q[qi]) {
|
|
126
|
+
if (prevMatchIdx === ti - 1) score -= 5;
|
|
127
|
+
if (ti === 0 || "/\\-_. ".includes(t[ti - 1])) score -= 10;
|
|
128
|
+
if (prevMatchIdx >= 0) score += ti - prevMatchIdx - 1;
|
|
129
|
+
prevMatchIdx = ti;
|
|
130
|
+
qi++;
|
|
131
|
+
}
|
|
132
|
+
if (qi < q.length) return -1;
|
|
133
|
+
score += t.length * .1;
|
|
134
|
+
return score;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Fuzzy filter + sort a list of items.
|
|
138
|
+
* Returns items that match, sorted by best score (lowest first).
|
|
139
|
+
*/
|
|
140
|
+
function fuzzyFilter(items, query, getText) {
|
|
141
|
+
if (!query) return items;
|
|
142
|
+
const scored = [];
|
|
143
|
+
for (const item of items) {
|
|
144
|
+
let best = Infinity;
|
|
145
|
+
for (const text of getText(item)) {
|
|
146
|
+
const s = fuzzyMatch(query, text);
|
|
147
|
+
if (s >= 0 && s < best) best = s;
|
|
148
|
+
}
|
|
149
|
+
if (best < Infinity) scored.push({
|
|
150
|
+
item,
|
|
151
|
+
score: best
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
scored.sort((a, b) => a.score - b.score);
|
|
155
|
+
return scored.map((s) => s.item);
|
|
156
|
+
}
|
|
157
|
+
//#endregion
|
|
158
|
+
//#region src/docs/sources/_base.ts
|
|
159
|
+
var DocsSource = class {};
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region src/docs/nav.ts
|
|
162
|
+
/**
|
|
163
|
+
* Parse a numbered filename/dirname like "1.guide" or "3.middleware.md"
|
|
164
|
+
* into { order, slug }. Also strips `.draft` suffix.
|
|
165
|
+
*/
|
|
166
|
+
function parseNumberedName(name) {
|
|
167
|
+
let base = name.endsWith(".md") ? name.slice(0, -3) : name;
|
|
168
|
+
const draft = base.endsWith(".draft");
|
|
169
|
+
if (draft) base = base.slice(0, -6);
|
|
170
|
+
const match = base.match(/^(\d+)\.(.+)$/);
|
|
171
|
+
if (match) return {
|
|
172
|
+
order: Number(match[1]),
|
|
173
|
+
slug: match[2],
|
|
174
|
+
draft
|
|
175
|
+
};
|
|
176
|
+
return {
|
|
177
|
+
order: Infinity,
|
|
178
|
+
slug: base,
|
|
179
|
+
draft
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Convert a slug like "getting-started" to "Getting Started".
|
|
184
|
+
*/
|
|
185
|
+
function humanizeSlug(slug) {
|
|
186
|
+
return slug.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Read and parse a .navigation.yml file as simple key: value pairs.
|
|
190
|
+
*/
|
|
191
|
+
async function readNavigation(dirPath) {
|
|
192
|
+
try {
|
|
193
|
+
const { headings: _headings, ...rest } = parseMeta(`---\n${await readFile(join(dirPath, ".navigation.yml"), "utf8")}\n---\n`);
|
|
194
|
+
return rest;
|
|
195
|
+
} catch {
|
|
196
|
+
return {};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const _knownKeys = new Set([
|
|
200
|
+
"slug",
|
|
201
|
+
"path",
|
|
202
|
+
"title",
|
|
203
|
+
"order",
|
|
204
|
+
"icon",
|
|
205
|
+
"description",
|
|
206
|
+
"page",
|
|
207
|
+
"children",
|
|
208
|
+
"headings"
|
|
209
|
+
]);
|
|
210
|
+
/**
|
|
211
|
+
* Extract navigation overrides from frontmatter `navigation` field.
|
|
212
|
+
* Returns merged meta with navigation fields taking priority.
|
|
213
|
+
*/
|
|
214
|
+
function applyNavigationOverride(meta) {
|
|
215
|
+
const nav = meta.navigation;
|
|
216
|
+
if (nav === false) return {
|
|
217
|
+
...meta,
|
|
218
|
+
navigation: false
|
|
219
|
+
};
|
|
220
|
+
if (typeof nav === "object" && nav !== null) {
|
|
221
|
+
const { navigation: _nav, ...rest } = meta;
|
|
222
|
+
return {
|
|
223
|
+
...rest,
|
|
224
|
+
...nav
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
return meta;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Extract extra meta fields (non-known keys) from a record.
|
|
231
|
+
*/
|
|
232
|
+
function extraMeta(record) {
|
|
233
|
+
const extra = {};
|
|
234
|
+
let hasExtra = false;
|
|
235
|
+
for (const [key, value] of Object.entries(record)) if (!_knownKeys.has(key) && key !== "navigation") {
|
|
236
|
+
extra[key] = value;
|
|
237
|
+
hasExtra = true;
|
|
238
|
+
}
|
|
239
|
+
return hasExtra ? extra : void 0;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Scan a docs directory and build a navigation tree
|
|
243
|
+
* using md4x parseMeta for extracting markdown metadata.
|
|
244
|
+
*/
|
|
245
|
+
async function scanNav(dirPath, options) {
|
|
246
|
+
return _scanNav(dirPath, "/", options || {});
|
|
247
|
+
}
|
|
248
|
+
async function _scanNav(dirPath, parentPath, options) {
|
|
249
|
+
const dirEntries = await readdir(dirPath);
|
|
250
|
+
const entries = [];
|
|
251
|
+
for (const entry of dirEntries) {
|
|
252
|
+
if (entry.startsWith(".") || entry.startsWith("_") || entry === "package.json" || entry === "pnpm-lock.yaml" || entry === "pnpm-workspace.yaml") continue;
|
|
253
|
+
const fullPath = join(dirPath, entry);
|
|
254
|
+
let stats;
|
|
255
|
+
try {
|
|
256
|
+
stats = await stat(fullPath);
|
|
257
|
+
} catch {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (stats.isDirectory()) {
|
|
261
|
+
const { order, slug } = parseNumberedName(entry);
|
|
262
|
+
const nav = await readNavigation(fullPath);
|
|
263
|
+
if (nav.navigation === false) continue;
|
|
264
|
+
const children = await _scanNav(fullPath, parentPath === "/" ? `/${slug}` : `${parentPath}/${slug}`, options);
|
|
265
|
+
const hasIndex = children.some((c) => c.slug === "");
|
|
266
|
+
const navEntry = {
|
|
267
|
+
slug,
|
|
268
|
+
path: parentPath === "/" ? `/${slug}` : `${parentPath}/${slug}`,
|
|
269
|
+
title: nav.title || humanizeSlug(slug),
|
|
270
|
+
order,
|
|
271
|
+
...nav.icon ? { icon: nav.icon } : {},
|
|
272
|
+
...nav.description ? { description: nav.description } : {},
|
|
273
|
+
...!hasIndex ? { page: false } : {},
|
|
274
|
+
...children.length > 0 ? { children } : {}
|
|
275
|
+
};
|
|
276
|
+
entries.push(navEntry);
|
|
277
|
+
} else if (extname(entry) === ".md") {
|
|
278
|
+
const { order, slug, draft } = parseNumberedName(basename(entry));
|
|
279
|
+
if (draft && !options.drafts) continue;
|
|
280
|
+
const meta = applyNavigationOverride(parseMeta(await readFile(fullPath, "utf8")));
|
|
281
|
+
if (meta.navigation === false) continue;
|
|
282
|
+
const resolvedSlug = slug === "index" ? "" : slug;
|
|
283
|
+
const title = meta.title || humanizeSlug(slug) || "index";
|
|
284
|
+
const entryPath = resolvedSlug === "" ? parentPath === "/" ? "/" : parentPath : parentPath === "/" ? `/${resolvedSlug}` : `${parentPath}/${resolvedSlug}`;
|
|
285
|
+
const extra = extraMeta(meta);
|
|
286
|
+
const navEntry = {
|
|
287
|
+
slug: resolvedSlug,
|
|
288
|
+
path: entryPath,
|
|
289
|
+
title,
|
|
290
|
+
order,
|
|
291
|
+
...meta.icon ? { icon: meta.icon } : {},
|
|
292
|
+
...meta.description ? { description: meta.description } : {},
|
|
293
|
+
...draft ? { draft: true } : {},
|
|
294
|
+
...extra
|
|
295
|
+
};
|
|
296
|
+
entries.push(navEntry);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
entries.sort((a, b) => a.order - b.order || a.slug.localeCompare(b.slug));
|
|
300
|
+
return entries;
|
|
301
|
+
}
|
|
302
|
+
//#endregion
|
|
303
|
+
//#region src/docs/sources/fs.ts
|
|
304
|
+
var DocsSourceFS = class extends DocsSource {
|
|
305
|
+
dir;
|
|
306
|
+
constructor(dir) {
|
|
307
|
+
super();
|
|
308
|
+
this.dir = dir;
|
|
309
|
+
}
|
|
310
|
+
async load() {
|
|
311
|
+
return {
|
|
312
|
+
tree: await scanNav(this.dir),
|
|
313
|
+
fileMap: await buildFileMap("/", this.dir)
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
async readContent(filePath) {
|
|
317
|
+
return readFile(filePath, "utf8");
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
function parseSlug(name) {
|
|
321
|
+
const base = name.endsWith(".draft") ? name.slice(0, -6) : name;
|
|
322
|
+
const match = base.match(/^(\d+)\.(.+)$/);
|
|
323
|
+
return match ? match[2] : base;
|
|
324
|
+
}
|
|
325
|
+
async function buildFileMap(parentPath, dirPath) {
|
|
326
|
+
const map = /* @__PURE__ */ new Map();
|
|
327
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
328
|
+
for (const entry of entries) {
|
|
329
|
+
if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
|
|
330
|
+
const fullPath = join(dirPath, entry.name);
|
|
331
|
+
if (entry.isDirectory()) {
|
|
332
|
+
const slug = parseSlug(entry.name);
|
|
333
|
+
const childMap = await buildFileMap(parentPath === "/" ? `/${slug}` : `${parentPath}/${slug}`, fullPath);
|
|
334
|
+
for (const [k, v] of childMap) map.set(k, v);
|
|
335
|
+
} else if (extname(entry.name) === ".md") {
|
|
336
|
+
const slug = parseSlug(entry.name.replace(/\.md$/, ""));
|
|
337
|
+
const resolvedSlug = slug === "index" ? "" : slug;
|
|
338
|
+
const entryPath = resolvedSlug === "" ? parentPath === "/" ? "/" : parentPath : parentPath === "/" ? `/${resolvedSlug}` : `${parentPath}/${resolvedSlug}`;
|
|
339
|
+
map.set(entryPath, fullPath);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return map;
|
|
343
|
+
}
|
|
344
|
+
//#endregion
|
|
345
|
+
//#region src/docs/sources/git.ts
|
|
346
|
+
var DocsSourceGit = class extends DocsSource {
|
|
347
|
+
src;
|
|
348
|
+
options;
|
|
349
|
+
_fs;
|
|
350
|
+
constructor(src, options = {}) {
|
|
351
|
+
super();
|
|
352
|
+
this.src = src;
|
|
353
|
+
this.options = options;
|
|
354
|
+
}
|
|
355
|
+
async load() {
|
|
356
|
+
const source = this.options.subdir ? `${this.src}/${this.options.subdir}` : this.src;
|
|
357
|
+
const id = source.replace(/[/#:]/g, "_");
|
|
358
|
+
const dir = join(tmpdir(), "mdzilla", "gh", id);
|
|
359
|
+
const { downloadTemplate } = await import("giget");
|
|
360
|
+
await downloadTemplate(source, {
|
|
361
|
+
dir,
|
|
362
|
+
auth: this.options.auth,
|
|
363
|
+
force: true,
|
|
364
|
+
install: false
|
|
365
|
+
});
|
|
366
|
+
let docsDir = dir;
|
|
367
|
+
for (const sub of ["docs/content", "docs"]) {
|
|
368
|
+
const candidate = join(dir, sub);
|
|
369
|
+
if (existsSync(candidate)) {
|
|
370
|
+
docsDir = candidate;
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
this._fs = new DocsSourceFS(docsDir);
|
|
375
|
+
return this._fs.load();
|
|
376
|
+
}
|
|
377
|
+
async readContent(filePath) {
|
|
378
|
+
if (!this._fs) throw new Error("DocsSourceGit: call load() before readContent()");
|
|
379
|
+
return this._fs.readContent(filePath);
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
//#endregion
|
|
383
|
+
//#region src/docs/sources/_npm.ts
|
|
384
|
+
/**
|
|
385
|
+
* Parse an npm package spec: `[@scope/]name[@version][/subdir]`
|
|
386
|
+
*/
|
|
387
|
+
function parseNpmSpec(input) {
|
|
388
|
+
let rest = input;
|
|
389
|
+
let subdir = "";
|
|
390
|
+
if (rest.startsWith("@")) {
|
|
391
|
+
const secondSlash = rest.indexOf("/", rest.indexOf("/") + 1);
|
|
392
|
+
if (secondSlash > 0) {
|
|
393
|
+
subdir = rest.slice(secondSlash);
|
|
394
|
+
rest = rest.slice(0, secondSlash);
|
|
395
|
+
}
|
|
396
|
+
} else {
|
|
397
|
+
const firstSlash = rest.indexOf("/");
|
|
398
|
+
if (firstSlash > 0) {
|
|
399
|
+
subdir = rest.slice(firstSlash);
|
|
400
|
+
rest = rest.slice(0, firstSlash);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const versionSep = rest.startsWith("@") ? rest.indexOf("@", 1) : rest.indexOf("@");
|
|
404
|
+
const hasVersion = versionSep > 0;
|
|
405
|
+
return {
|
|
406
|
+
name: hasVersion ? rest.slice(0, versionSep) : rest,
|
|
407
|
+
version: hasVersion ? rest.slice(versionSep + 1) : "latest",
|
|
408
|
+
subdir
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Fetch package metadata from the npm registry.
|
|
413
|
+
* When `version` is provided, fetches that specific version.
|
|
414
|
+
* Otherwise fetches the full package document.
|
|
415
|
+
*/
|
|
416
|
+
async function fetchNpmInfo(name, version) {
|
|
417
|
+
const registryURL = version ? `https://registry.npmjs.org/${name}/${version}` : `https://registry.npmjs.org/${name}`;
|
|
418
|
+
const res = await fetch(registryURL);
|
|
419
|
+
if (!res.ok) throw new Error(`Failed to fetch package info for ${name}${version ? `@${version}` : ""}: ${res.status} ${res.statusText}`);
|
|
420
|
+
return res.json();
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Detect npmjs.com URLs and extract the package name.
|
|
424
|
+
* Supports: npmjs.com/\<pkg\>, npmjs.com/package/\<pkg\>, www.npmjs.com/package/\<pkg\>
|
|
425
|
+
* Also handles scoped packages: npmjs.com/package/@scope/name
|
|
426
|
+
*/
|
|
427
|
+
function parseNpmURL(url) {
|
|
428
|
+
let parsed;
|
|
429
|
+
try {
|
|
430
|
+
parsed = new URL(url);
|
|
431
|
+
} catch {
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (parsed.hostname !== "www.npmjs.com" && parsed.hostname !== "npmjs.com") return;
|
|
435
|
+
const pkgMatch = parsed.pathname.match(/^\/package\/((?:@[^/]+\/)?[^/]+)\/?$/);
|
|
436
|
+
if (pkgMatch) return pkgMatch[1];
|
|
437
|
+
const shortMatch = parsed.pathname.match(/^\/((?:@[^/]+\/)?[^/]+)\/?$/);
|
|
438
|
+
if (shortMatch && !/^(package|settings|signup|login|org|search)$/.test(shortMatch[1])) return shortMatch[1];
|
|
439
|
+
}
|
|
440
|
+
//#endregion
|
|
441
|
+
//#region src/docs/sources/http.ts
|
|
442
|
+
var DocsSourceHTTP = class extends DocsSource {
|
|
443
|
+
url;
|
|
444
|
+
options;
|
|
445
|
+
_contentCache = /* @__PURE__ */ new Map();
|
|
446
|
+
_tree = [];
|
|
447
|
+
_fileMap = /* @__PURE__ */ new Map();
|
|
448
|
+
_npmPackage;
|
|
449
|
+
constructor(url, options = {}) {
|
|
450
|
+
super();
|
|
451
|
+
this.url = url.replace(/\/+$/, "");
|
|
452
|
+
this._npmPackage = parseNpmURL(this.url);
|
|
453
|
+
this.options = options;
|
|
454
|
+
}
|
|
455
|
+
async load() {
|
|
456
|
+
if (this._npmPackage) return this._loadNpm(this._npmPackage);
|
|
457
|
+
const llmsTree = await this._tryLlmsTxt();
|
|
458
|
+
if (llmsTree) {
|
|
459
|
+
this._tree = llmsTree;
|
|
460
|
+
return {
|
|
461
|
+
tree: this._tree,
|
|
462
|
+
fileMap: this._fileMap
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
const markdown = await this._fetch(this.url);
|
|
466
|
+
this._contentCache.set("/", markdown);
|
|
467
|
+
const rootEntry = {
|
|
468
|
+
slug: "",
|
|
469
|
+
path: "/",
|
|
470
|
+
title: parseMeta(markdown).title || _titleFromURL(this.url),
|
|
471
|
+
order: 0
|
|
472
|
+
};
|
|
473
|
+
const { entries: children, tocPaths } = _extractLinks(markdown, this.url);
|
|
474
|
+
if (children.length > 0) {
|
|
475
|
+
rootEntry.children = children;
|
|
476
|
+
for (const child of children) this._fileMap.set(child.path, child.path);
|
|
477
|
+
}
|
|
478
|
+
this._tree = [rootEntry];
|
|
479
|
+
this._fileMap.set("/", "/");
|
|
480
|
+
if (tocPaths.size > 0) await this._crawlTocPages(children, tocPaths);
|
|
481
|
+
return {
|
|
482
|
+
tree: this._tree,
|
|
483
|
+
fileMap: this._fileMap
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
/** Try fetching /llms.txt and parse it into a nav tree */
|
|
487
|
+
async _tryLlmsTxt() {
|
|
488
|
+
let origin;
|
|
489
|
+
try {
|
|
490
|
+
origin = new URL(this.url).origin;
|
|
491
|
+
} catch {
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
let text;
|
|
495
|
+
try {
|
|
496
|
+
const res = await fetch(`${origin}/llms.txt`, { headers: {
|
|
497
|
+
accept: "text/plain",
|
|
498
|
+
...this.options.headers
|
|
499
|
+
} });
|
|
500
|
+
if (!res.ok) return void 0;
|
|
501
|
+
text = await res.text();
|
|
502
|
+
} catch {
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (!text.trimStart().startsWith("#")) return void 0;
|
|
506
|
+
return _parseLlmsTxt(text, origin, this._fileMap, this._contentCache);
|
|
507
|
+
}
|
|
508
|
+
async readContent(filePath) {
|
|
509
|
+
const cached = this._contentCache.get(filePath);
|
|
510
|
+
if (cached !== void 0) return cached;
|
|
511
|
+
const origin = new URL(this.url).origin;
|
|
512
|
+
const url = filePath === "/" ? this.url : `${origin}${filePath}`;
|
|
513
|
+
const markdown = await this._fetch(url);
|
|
514
|
+
this._contentCache.set(filePath, markdown);
|
|
515
|
+
return markdown;
|
|
516
|
+
}
|
|
517
|
+
/** Crawl index.md pages and attach their links as children */
|
|
518
|
+
async _crawlTocPages(entries, tocPaths, depth = 0) {
|
|
519
|
+
if (depth > 3) return;
|
|
520
|
+
const origin = new URL(this.url).origin;
|
|
521
|
+
await Promise.all(entries.map(async (entry) => {
|
|
522
|
+
if (!tocPaths.has(entry.path)) return;
|
|
523
|
+
const url = `${origin}${entry.path}`;
|
|
524
|
+
const markdown = await this._fetch(url);
|
|
525
|
+
this._contentCache.set(entry.path, markdown);
|
|
526
|
+
const { entries: children, tocPaths: subTocPaths } = _extractLinks(markdown, url);
|
|
527
|
+
if (children.length > 0) {
|
|
528
|
+
entry.children = children;
|
|
529
|
+
for (const child of children) this._fileMap.set(child.path, child.path);
|
|
530
|
+
if (subTocPaths.size > 0) await this._crawlTocPages(children, subTocPaths, depth + 1);
|
|
531
|
+
}
|
|
532
|
+
}));
|
|
533
|
+
}
|
|
534
|
+
/** Load an npm package README from the registry */
|
|
535
|
+
async _loadNpm(pkg) {
|
|
536
|
+
let markdown;
|
|
537
|
+
try {
|
|
538
|
+
const data = await fetchNpmInfo(pkg);
|
|
539
|
+
markdown = data.readme || `# ${data.name || pkg}\n\n${data.description || "No README available."}`;
|
|
540
|
+
} catch (err) {
|
|
541
|
+
markdown = `# Fetch Error\n\nFailed to fetch package \`${pkg}\`\n\n> ${err instanceof Error ? err.message : String(err)}`;
|
|
542
|
+
}
|
|
543
|
+
this._contentCache.set("/", markdown);
|
|
544
|
+
this._fileMap.set("/", "/");
|
|
545
|
+
this._tree = [{
|
|
546
|
+
slug: "",
|
|
547
|
+
path: "/",
|
|
548
|
+
title: parseMeta(markdown).title || pkg,
|
|
549
|
+
order: 0
|
|
550
|
+
}];
|
|
551
|
+
return {
|
|
552
|
+
tree: this._tree,
|
|
553
|
+
fileMap: this._fileMap
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
async _fetch(url) {
|
|
557
|
+
let res;
|
|
558
|
+
try {
|
|
559
|
+
res = await fetch(url, { headers: {
|
|
560
|
+
accept: "text/markdown, text/plain;q=0.9, text/html;q=0.8",
|
|
561
|
+
...this.options.headers
|
|
562
|
+
} });
|
|
563
|
+
} catch (err) {
|
|
564
|
+
return `# Fetch Error\n\nFailed to fetch \`${url}\`\n\n> ${err instanceof Error ? err.message : String(err)}`;
|
|
565
|
+
}
|
|
566
|
+
if (!res.ok) return `# ${res.status} ${res.statusText}\n\nFailed to fetch \`${url}\``;
|
|
567
|
+
const contentType = res.headers.get("content-type") || "";
|
|
568
|
+
const text = await res.text();
|
|
569
|
+
if (_isHTML(contentType, text)) {
|
|
570
|
+
const { htmlToMarkdown } = await import("mdream");
|
|
571
|
+
return htmlToMarkdown(text, { origin: url });
|
|
572
|
+
}
|
|
573
|
+
return text;
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
/** Check if a response is HTML by content-type or content sniffing */
|
|
577
|
+
function _isHTML(contentType, body) {
|
|
578
|
+
if (contentType.includes("text/html") || contentType.includes("application/xhtml")) return true;
|
|
579
|
+
const trimmed = body.trimStart();
|
|
580
|
+
return trimmed.startsWith("<!") || trimmed.startsWith("<html");
|
|
581
|
+
}
|
|
582
|
+
/** Extract a readable title from a URL */
|
|
583
|
+
function _titleFromURL(url) {
|
|
584
|
+
try {
|
|
585
|
+
return new URL(url).hostname;
|
|
586
|
+
} catch {
|
|
587
|
+
return url;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Parse llms.txt content into a nav tree.
|
|
592
|
+
* Format: `# Title`, `> Description`, `## Section`, `- [Title](url): description`
|
|
593
|
+
*/
|
|
594
|
+
function _parseLlmsTxt(text, origin, fileMap, contentCache) {
|
|
595
|
+
const lines = text.split("\n");
|
|
596
|
+
const tree = [];
|
|
597
|
+
let siteTitle = "";
|
|
598
|
+
let siteDescription = "";
|
|
599
|
+
let currentSection;
|
|
600
|
+
let order = 0;
|
|
601
|
+
let childOrder = 0;
|
|
602
|
+
for (const line of lines) {
|
|
603
|
+
const trimmed = line.trim();
|
|
604
|
+
if (!siteTitle && trimmed.startsWith("# ") && !trimmed.startsWith("## ")) {
|
|
605
|
+
siteTitle = trimmed.slice(2).trim();
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
if (!siteDescription && trimmed.startsWith("> ")) {
|
|
609
|
+
siteDescription = trimmed.slice(2).trim();
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
if (trimmed.startsWith("## ")) {
|
|
613
|
+
currentSection = {
|
|
614
|
+
slug: _slugify(trimmed.slice(3).trim()),
|
|
615
|
+
path: `/_section/${order}`,
|
|
616
|
+
title: trimmed.slice(3).trim(),
|
|
617
|
+
order: order++,
|
|
618
|
+
page: false,
|
|
619
|
+
children: []
|
|
620
|
+
};
|
|
621
|
+
childOrder = 0;
|
|
622
|
+
tree.push(currentSection);
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
const linkMatch = trimmed.match(/^-\s*\[([^\]]+)]\(([^)]+)\)(?::\s*(.+))?$/);
|
|
626
|
+
if (linkMatch) {
|
|
627
|
+
const title = linkMatch[1];
|
|
628
|
+
const href = linkMatch[2];
|
|
629
|
+
const description = linkMatch[3]?.trim();
|
|
630
|
+
let resolved;
|
|
631
|
+
try {
|
|
632
|
+
resolved = new URL(href, origin);
|
|
633
|
+
if (resolved.origin !== origin) continue;
|
|
634
|
+
} catch {
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
const path = resolved.pathname.replace(/\/+$/, "") || "/";
|
|
638
|
+
const entry = {
|
|
639
|
+
slug: path.split("/").pop() || path,
|
|
640
|
+
path,
|
|
641
|
+
title,
|
|
642
|
+
order: childOrder++,
|
|
643
|
+
...description ? { description } : {}
|
|
644
|
+
};
|
|
645
|
+
fileMap.set(path, path);
|
|
646
|
+
if (currentSection) currentSection.children.push(entry);
|
|
647
|
+
else tree.push(entry);
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
if (tree.length === 0) return [];
|
|
652
|
+
const root = {
|
|
653
|
+
slug: "",
|
|
654
|
+
path: "/",
|
|
655
|
+
title: siteTitle || _titleFromURL(origin),
|
|
656
|
+
order: 0,
|
|
657
|
+
...siteDescription ? { description: siteDescription } : {},
|
|
658
|
+
...tree.length > 0 ? { children: tree } : {}
|
|
659
|
+
};
|
|
660
|
+
fileMap.set("/", "/");
|
|
661
|
+
contentCache.set("/", text);
|
|
662
|
+
return [root];
|
|
663
|
+
}
|
|
664
|
+
function _slugify(text) {
|
|
665
|
+
return text.toLowerCase().replace(/[^a-z\d]+/g, "-").replace(/^-|-$/g, "");
|
|
666
|
+
}
|
|
667
|
+
/** Extract internal links from markdown content to build nav children */
|
|
668
|
+
function _extractLinks(markdown, baseURL) {
|
|
669
|
+
const seen = /* @__PURE__ */ new Set();
|
|
670
|
+
const entries = [];
|
|
671
|
+
const tocPaths = /* @__PURE__ */ new Set();
|
|
672
|
+
let order = 0;
|
|
673
|
+
const linkRe = /\[([^\]]+)]\(([^)]+)\)/g;
|
|
674
|
+
let match;
|
|
675
|
+
while ((match = linkRe.exec(markdown)) !== null) {
|
|
676
|
+
const title = match[1];
|
|
677
|
+
const href = match[2];
|
|
678
|
+
const resolved = _resolveHref(href, baseURL);
|
|
679
|
+
if (!resolved) continue;
|
|
680
|
+
let path = resolved.pathname.replace(/\/+$/, "") || "/";
|
|
681
|
+
const isToc = /\/index\.md$/i.test(path) || path.endsWith("/index");
|
|
682
|
+
path = path.replace(/\/index\.md$/i, "").replace(/\/index$/, "").replace(/\.md$/i, "");
|
|
683
|
+
path = path || "/";
|
|
684
|
+
if (path === "/") continue;
|
|
685
|
+
if (seen.has(path)) continue;
|
|
686
|
+
seen.add(path);
|
|
687
|
+
if (isToc) tocPaths.add(path);
|
|
688
|
+
const slug = path.split("/").pop() || path;
|
|
689
|
+
entries.push({
|
|
690
|
+
slug,
|
|
691
|
+
path,
|
|
692
|
+
title,
|
|
693
|
+
order: order++
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
return {
|
|
697
|
+
entries,
|
|
698
|
+
tocPaths
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
/** Resolve an href relative to a base URL, returning null for external links */
|
|
702
|
+
function _resolveHref(href, baseURL) {
|
|
703
|
+
try {
|
|
704
|
+
const base = new URL(baseURL);
|
|
705
|
+
const resolved = new URL(href, baseURL);
|
|
706
|
+
if (resolved.origin !== base.origin) return void 0;
|
|
707
|
+
if (href.startsWith("#")) return void 0;
|
|
708
|
+
if (/\.(png|jpg|jpeg|gif|svg|css|js|ico|woff2?)$/i.test(resolved.pathname)) return void 0;
|
|
709
|
+
return resolved;
|
|
710
|
+
} catch {
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
//#endregion
|
|
715
|
+
//#region src/docs/sources/npm.ts
|
|
716
|
+
var DocsSourceNpm = class extends DocsSource {
|
|
717
|
+
src;
|
|
718
|
+
options;
|
|
719
|
+
_fs;
|
|
720
|
+
constructor(src, options = {}) {
|
|
721
|
+
super();
|
|
722
|
+
this.src = src;
|
|
723
|
+
this.options = options;
|
|
724
|
+
}
|
|
725
|
+
async load() {
|
|
726
|
+
const pkg = this.src.startsWith("npm:") ? this.src : `npm:${this.src}`;
|
|
727
|
+
const source = this.options.subdir ? `${pkg}/${this.options.subdir}` : pkg;
|
|
728
|
+
const id = source.replace(/[/#:@]/g, "_");
|
|
729
|
+
const dir = join(tmpdir(), "mdzilla", "npm", id);
|
|
730
|
+
const { downloadTemplate } = await import("giget");
|
|
731
|
+
await downloadTemplate(source, {
|
|
732
|
+
dir,
|
|
733
|
+
force: true,
|
|
734
|
+
install: false,
|
|
735
|
+
providers: { npm: npmProvider },
|
|
736
|
+
registry: false
|
|
737
|
+
});
|
|
738
|
+
let docsDir = dir;
|
|
739
|
+
for (const sub of ["docs/content", "docs"]) {
|
|
740
|
+
const candidate = join(dir, sub);
|
|
741
|
+
if (existsSync(candidate)) {
|
|
742
|
+
docsDir = candidate;
|
|
743
|
+
break;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
this._fs = new DocsSourceFS(docsDir);
|
|
747
|
+
return this._fs.load();
|
|
748
|
+
}
|
|
749
|
+
async readContent(filePath) {
|
|
750
|
+
if (!this._fs) throw new Error("DocsSourceNpm: call load() before readContent()");
|
|
751
|
+
return this._fs.readContent(filePath);
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
async function npmProvider(input) {
|
|
755
|
+
const { name, version, subdir } = parseNpmSpec(input);
|
|
756
|
+
const info = await fetchNpmInfo(name, version);
|
|
757
|
+
return {
|
|
758
|
+
name: info.name,
|
|
759
|
+
version: info.version,
|
|
760
|
+
subdir,
|
|
761
|
+
tar: info.dist.tarball
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
//#endregion
|
|
765
|
+
//#region src/docs/exporter.ts
|
|
766
|
+
var DocsExporter = class {};
|
|
767
|
+
var DocsExporterFS = class extends DocsExporter {
|
|
768
|
+
dir;
|
|
769
|
+
constructor(dir) {
|
|
770
|
+
super();
|
|
771
|
+
this.dir = dir;
|
|
772
|
+
}
|
|
773
|
+
async export(manager, options = {}) {
|
|
774
|
+
for (const flat of manager.flat) {
|
|
775
|
+
if (!options.includeStubs && flat.entry.page === false) continue;
|
|
776
|
+
let content = await manager.getContent(flat);
|
|
777
|
+
if (content === void 0) continue;
|
|
778
|
+
if (options.plainText) content = renderToText(content);
|
|
779
|
+
const filePath = flat.entry.path === "/" ? "/index.md" : `${flat.entry.path}.md`;
|
|
780
|
+
const dest = join(this.dir, filePath);
|
|
781
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
782
|
+
await writeFile(dest, content, "utf8");
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
//#endregion
|
|
787
|
+
export { DocsSourceGit as a, DocsManager as c, DocsSourceHTTP as i, DocsExporterFS as n, DocsSourceFS as o, DocsSourceNpm as r, DocsSource as s, DocsExporter as t };
|