@marincervinschi/docs 0.0.0-stage → 0.1.1

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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -2
  3. package/dist/bin.js +831 -0
  4. package/package.json +61 -5
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marin Cervinschi
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 CHANGED
@@ -1,3 +1,51 @@
1
- # Temporary Holding Version
1
+ # docs
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `docs` reads a project's [Fumadocs](https://fumadocs.dev) content from the terminal, one piece at a time. It is meant for coding agents: an agent asks for the map of the docs, the headings of a page, one section, the best matches for a query or the links of a page, and gets back only that, without opening whole files.
4
+
5
+ It reads the Fumadocs content format: a `docs/` folder of `.mdx` and `.md` pages, ordered by `meta.json` files. Page order, slugs and heading anchors come from `fumadocs-core` itself, so `docs` agrees with the site.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g @marincervinschi/docs
11
+ ```
12
+
13
+ Node 22 or later.
14
+
15
+ ## Commands
16
+
17
+ Run them anywhere inside a git repo that has a `docs/` folder at its root, or point `--dir` at another repo or docs folder. A page is its path in `docs/` without the extension (`guides/install`); its URL, its file path and `page#anchor` work too.
18
+
19
+ | Command | Prints |
20
+ |---|---|
21
+ | `docs outline` | the map of the docs in `meta.json` order, one line per page with its title and description |
22
+ | `docs outline <page>` | the page's headings, each with its anchor and an estimated size in tokens |
23
+ | `docs page <page> [--part frontmatter\|body\|summary\|section:<heading>]` | the page as written, or one part of it |
24
+ | `docs search <query> [--limit <n>]` | the best pages, 5 by default, each with its best section and a snippet |
25
+ | `docs links <page>` | links out of the page, external links, the pages that link to it, broken links |
26
+ | `docs lint` | missing titles, MDX that would not compile, broken links and anchors, `meta.json` against the files; exits with 1 on findings |
27
+ | `docs help [<command>]` | the list above in under 300 tokens, or one command's options |
28
+
29
+ Every command takes `--json` for the same data as JSON.
30
+
31
+ ```
32
+ $ docs outline guides/install
33
+ guides/install Install ~115
34
+ ## Requirements #requirements ~16
35
+ ## First run #first ~74
36
+ ### When the port is taken #when-the-port-is-taken ~36
37
+
38
+ $ docs search port in use --limit 2
39
+ guides/install#when-the-port-is-taken Install > When the port is taken The dev server stops when port 3000 is already in use. Pass `--port 3001`, or read deploy.
40
+ guides/deploy#ports Deploy > Ports The preview listens on port 4000. The install guide explains the first run.
41
+
42
+ $ docs page guides/install#first
43
+ ```
44
+
45
+ ## What it does not do
46
+
47
+ It only reads. It does not render MDX components, does not keep an index, and has no settings file. The decisions behind this are in [SPEC.md](SPEC.md).
48
+
49
+ ## License
50
+
51
+ MIT
package/dist/bin.js ADDED
@@ -0,0 +1,831 @@
1
+ #!/usr/bin/env node
2
+ import { frontmatter } from "fumadocs-core/content/md/frontmatter";
3
+ import { loader } from "fumadocs-core/source";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { remarkGfm } from "fumadocs-core/mdx-plugins/remark-gfm";
7
+ import { remarkHeading } from "fumadocs-core/mdx-plugins/remark-heading";
8
+ import { remarkStructure } from "fumadocs-core/mdx-plugins/remark-structure";
9
+ import { remark } from "remark";
10
+ import remarkMdx from "remark-mdx";
11
+ import { VFile } from "vfile";
12
+ // src/core/errors.ts
13
+ var DocsError = class extends Error {
14
+ name = "DocsError";
15
+ };
16
+ // src/core/parse.ts
17
+ const tokens = (text) => Math.ceil(text.length / 4);
18
+ function processor(mdx) {
19
+ const p = remark().use(remarkGfm);
20
+ if (mdx) p.use(remarkMdx);
21
+ return p.use(remarkHeading).use(remarkStructure);
22
+ }
23
+ function parsePage(page) {
24
+ const mdx = page.path.endsWith(".mdx");
25
+ try {
26
+ return run(processor(mdx), page);
27
+ } catch (err) {
28
+ if (!mdx) throw err;
29
+ const e = err;
30
+ const message = e.reason ?? e.message;
31
+ const at = /\((\d+):\d+(?:-\d+:\d+)?\)$/.exec(message);
32
+ const line = e.line ?? e.place?.line ?? e.place?.start?.line ?? Number(at?.[1] ?? 1);
33
+ return {
34
+ ...run(processor(false), page),
35
+ error: {
36
+ line: line + page.bodyLine,
37
+ message: message.replace(/\s*\(\d+:\d+(?:-\d+:\d+)?\)$/, "")
38
+ }
39
+ };
40
+ }
41
+ }
42
+ function run(p, page) {
43
+ const file = new VFile({
44
+ value: page.body,
45
+ path: page.path
46
+ });
47
+ const tree = p.runSync(p.parse(file), file);
48
+ const nodes = [];
49
+ walk(tree, (n) => {
50
+ if (n.type === "heading") nodes.push(n);
51
+ });
52
+ const toc = file.data.toc ?? [];
53
+ return {
54
+ tree,
55
+ headings: nodes.map((n, i) => {
56
+ const start = n.position?.start.offset ?? 0;
57
+ const end = nodes.slice(i + 1).find((m) => m.depth <= n.depth)?.position?.start.offset ?? page.body.length;
58
+ return {
59
+ depth: n.depth,
60
+ text: typeof toc[i]?.title === "string" ? toc[i].title : toText(n),
61
+ anchor: String(n.data?.hProperties?.id ?? ""),
62
+ line: (n.position?.start.line ?? 1) + page.bodyLine,
63
+ start,
64
+ end,
65
+ tokens: tokens(page.body.slice(start, end))
66
+ };
67
+ }),
68
+ structured: file.data.structuredData ?? {
69
+ headings: [],
70
+ contents: []
71
+ }
72
+ };
73
+ }
74
+ function walk(node, visit) {
75
+ visit(node);
76
+ if ("children" in node) for (const child of node.children) walk(child, visit);
77
+ }
78
+ function toText(node) {
79
+ if ("value" in node && typeof node.value === "string") return node.value;
80
+ if ("children" in node) return node.children.map((c) => toText(c)).join("");
81
+ return "";
82
+ }
83
+ // src/core/docs.ts
84
+ const BASE_URL = "/docs";
85
+ const buildSource = (files) => loader({
86
+ source: { files },
87
+ baseUrl: BASE_URL
88
+ });
89
+ const PAGE = /\.mdx?$/;
90
+ const posix = (p) => p.split(path.sep).join("/");
91
+ function loadDocs(dir) {
92
+ const top = path.basename(dir);
93
+ const pages = [];
94
+ const metas = [];
95
+ const entries = fs.readdirSync(dir, { recursive: true }).map(posix).filter((p) => !p.split("/").some((s) => s.startsWith(".") || s === "node_modules")).sort();
96
+ for (const p of entries) {
97
+ const full = path.join(dir, p);
98
+ if (PAGE.test(p)) pages.push(readPage(full, p, `${top}/${p}`));
99
+ else if (path.posix.basename(p) === "meta.json") metas.push(readMeta(full, p, `${top}/${p}`));
100
+ }
101
+ if (!pages.length) throw new DocsError(`No .mdx or .md pages in ${dir}.`);
102
+ const files = [...pages.map((p) => ({
103
+ type: "page",
104
+ path: p.path,
105
+ data: {
106
+ title: p.title,
107
+ description: p.description
108
+ }
109
+ })), ...metas.filter((m) => m.data).map((m) => ({
110
+ type: "meta",
111
+ path: m.path,
112
+ data: m.data
113
+ }))];
114
+ const source = buildSource(files);
115
+ const byPath = new Map(pages.map((p) => [p.path, p]));
116
+ for (const lp of source.getPages()) {
117
+ const page = byPath.get(lp.path);
118
+ if (page) page.url = lp.url;
119
+ }
120
+ const cache = /* @__PURE__ */ new Map();
121
+ return {
122
+ dir,
123
+ pages,
124
+ metas,
125
+ source,
126
+ byPath,
127
+ parsed(page) {
128
+ let parsed = cache.get(page);
129
+ if (!parsed) cache.set(page, parsed = parsePage(page));
130
+ return parsed;
131
+ }
132
+ };
133
+ }
134
+ function readPage(full, p, file) {
135
+ const raw = fs.readFileSync(full, "utf8");
136
+ const page = {
137
+ id: p.replace(PAGE, ""),
138
+ path: p,
139
+ file,
140
+ url: "",
141
+ raw,
142
+ matter: "",
143
+ body: raw,
144
+ bodyLine: 0
145
+ };
146
+ try {
147
+ const fm = frontmatter(raw);
148
+ const data = fm.data ?? {};
149
+ page.body = fm.content;
150
+ page.bodyLine = fm.matter.split("\n").length - 1;
151
+ page.matter = fm.matter.replace(/^---\r?\n/, "").replace(/\r?\n---\r?\n?$/, "");
152
+ if (typeof data.title === "string") page.title = data.title;
153
+ if (typeof data.description === "string") page.description = data.description;
154
+ } catch (err) {
155
+ page.frontmatterError = err.message.split("\n")[0];
156
+ }
157
+ return page;
158
+ }
159
+ function readMeta(full, p, file) {
160
+ const raw = fs.readFileSync(full, "utf8");
161
+ try {
162
+ return {
163
+ path: p,
164
+ file,
165
+ raw,
166
+ data: JSON.parse(raw)
167
+ };
168
+ } catch (err) {
169
+ const message = err.message;
170
+ const at = /position (\d+)/.exec(message);
171
+ return {
172
+ path: p,
173
+ file,
174
+ raw,
175
+ error: {
176
+ line: at ? raw.slice(0, Number(at[1])).split("\n").length : 1,
177
+ message
178
+ }
179
+ };
180
+ }
181
+ }
182
+ function pageRef(page) {
183
+ return {
184
+ page: page.id,
185
+ title: page.title ?? page.id,
186
+ ...page.description ? { description: page.description } : {},
187
+ url: page.url,
188
+ file: page.file
189
+ };
190
+ }
191
+ /**
192
+ * The page a name points to: its path without the extension (`guides/install`), its
193
+ * slugs, its URL (`/docs/guides/install` or `/guides/install`) or its file path. A
194
+ * `#anchor` suffix is returned apart.
195
+ */
196
+ function findPage(docs, name) {
197
+ const hash = name.indexOf("#");
198
+ const anchor = hash < 0 ? void 0 : name.slice(hash + 1) || void 0;
199
+ const key = (hash < 0 ? name : name.slice(0, hash)).trim().replace(/^\.?\//, "");
200
+ const top = path.basename(docs.dir);
201
+ const bare = key.replace(new RegExp(`^${top}/`), "").replace(PAGE, "").replace(/\/$/, "");
202
+ const page = docs.pages.find((p) => p.id === bare) ?? docs.pages.find((p) => p.id === `${bare}/index`) ?? pageFromLoader(docs, docs.source.getPage(bare ? bare.split("/") : [])) ?? pageFromLoader(docs, docs.source.getPageByUrl(`/${key}`)) ?? pageFromLoader(docs, docs.source.getPageByUrl(`/docs/${key}`));
203
+ if (!page) throw new DocsError(`No page ${name}. Name a page by its path in the docs folder without .mdx; docs outline lists them.`);
204
+ return {
205
+ page,
206
+ anchor
207
+ };
208
+ }
209
+ function pageFromLoader(docs, lp) {
210
+ return lp ? docs.byPath.get(lp.path) : void 0;
211
+ }
212
+ // src/core/find.ts
213
+ const isDir = (p) => fs.statSync(p, { throwIfNoEntry: false })?.isDirectory() ?? false;
214
+ /** The docs folder: `--dir` (a repo root or the folder itself), else `docs/` at the git root above `cwd`. */
215
+ function findDocsDir(cwd, dir) {
216
+ if (dir !== void 0) {
217
+ const target = path.resolve(cwd, dir);
218
+ if (!isDir(target)) throw new DocsError(`--dir ${dir} is not a folder.`);
219
+ const inner = path.join(target, "docs");
220
+ return isDir(inner) ? inner : target;
221
+ }
222
+ const root = gitRoot(cwd);
223
+ const docs = path.join(root ?? cwd, "docs");
224
+ if (isDir(docs)) return docs;
225
+ throw new DocsError(root ? `No docs/ folder at the root of this repo (${root}). Pass --dir with the repo or the folder that holds the pages.` : `Not inside a git repo and no docs/ folder here. Pass --dir with the repo or the folder that holds the pages.`);
226
+ }
227
+ function gitRoot(from) {
228
+ for (let dir = path.resolve(from);; dir = path.dirname(dir)) {
229
+ if (fs.existsSync(path.join(dir, ".git"))) return dir;
230
+ if (path.dirname(dir) === dir) return void 0;
231
+ }
232
+ }
233
+ // src/cli/args.ts
234
+ function parseArgs(spec, argv) {
235
+ const options = {};
236
+ const words = [];
237
+ for (let i = 0; i < argv.length; i++) {
238
+ const arg = argv[i];
239
+ if (!arg.startsWith("--")) {
240
+ words.push(arg);
241
+ continue;
242
+ }
243
+ const eq = arg.indexOf("=");
244
+ const name = arg.slice(2, eq < 0 ? void 0 : eq);
245
+ const option = spec.options.find((o) => o.name === name);
246
+ if (!option) throw new DocsError(`docs ${spec.name} has no option --${name}. Options: ${spec.options.map((o) => `--${o.name}`).join(", ")}.`);
247
+ if (option.flag) {
248
+ if (eq >= 0) throw new DocsError(`--${name} takes no value.`);
249
+ options[name] = "true";
250
+ continue;
251
+ }
252
+ const value = eq < 0 ? argv[++i] : arg.slice(eq + 1);
253
+ if (value === void 0) throw new DocsError(`--${name} needs a value: ${option.hint}.`);
254
+ if (option.number && !/^[1-9]\d*$/.test(value)) throw new DocsError(`--${name} must be a positive whole number, not ${value}.`);
255
+ options[name] = value;
256
+ }
257
+ const pos = spec.positional;
258
+ if (words.length && !pos) throw new DocsError(`docs ${spec.name} takes no argument: ${words[0]}.`);
259
+ if (words.length > 1 && !pos?.rest) throw new DocsError(`docs ${spec.name} takes one ${pos.name}; got ${words.length} words. Quote it.`);
260
+ const positional = words.length ? words.join(" ") : void 0;
261
+ if (pos?.required && positional === void 0) throw new DocsError(`docs ${spec.name} needs <${pos.name}>: ${pos.hint}.`);
262
+ return {
263
+ positional,
264
+ options
265
+ };
266
+ }
267
+ // src/core/links.ts
268
+ const SCHEME = /^[a-z][a-z\d+.-]*:/i;
269
+ const ASSET = /\.(?!mdx?$)[a-z\d]+$/i;
270
+ /** Every link of a page: Markdown links and definitions, and `href` on MDX elements. */
271
+ function pageLinkRefs(docs, page) {
272
+ const refs = [];
273
+ walk(docs.parsed(page).tree, (node) => {
274
+ const line = (node.position?.start.line ?? 1) + page.bodyLine;
275
+ if (node.type === "link") refs.push({
276
+ href: node.url,
277
+ text: toText(node),
278
+ line
279
+ });
280
+ else if (node.type === "definition") refs.push({
281
+ href: node.url,
282
+ text: node.label ?? node.identifier,
283
+ line
284
+ });
285
+ else if (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") {
286
+ for (const attr of node.attributes) if (attr.type === "mdxJsxAttribute" && attr.name === "href" && typeof attr.value === "string") refs.push({
287
+ href: attr.value,
288
+ text: toText(node),
289
+ line
290
+ });
291
+ }
292
+ });
293
+ return refs;
294
+ }
295
+ /**
296
+ * Where a link goes. Internal links may be relative file paths (`./install.mdx`), site
297
+ * paths with the `/docs` base or without it, relative URLs, or `#anchor` on the same page.
298
+ */
299
+ function resolveLink(docs, from, href) {
300
+ if (SCHEME.test(href) || href.startsWith("//")) return {
301
+ kind: "external",
302
+ url: href
303
+ };
304
+ const hash = href.indexOf("#");
305
+ const target = hash < 0 ? href : href.slice(0, hash);
306
+ const anchor = hash < 0 ? void 0 : decodeURIComponent(href.slice(hash + 1));
307
+ if (ASSET.test(target)) return {
308
+ kind: "external",
309
+ url: href
310
+ };
311
+ let page;
312
+ if (!target) page = from;
313
+ else {
314
+ const dir = path.posix.dirname(from.path);
315
+ page = pageFromLoader(docs, (docs.source.getPageByHref(target, { dir: dir === "." ? "" : dir }) ?? (target.startsWith("/") ? docs.source.getPageByHref(target === "/" ? "/docs" : "/docs" + target) : void 0))?.page);
316
+ if (!page && !target.startsWith("/")) {
317
+ const url = new URL(target, `http://docs.invalid${from.url}`).pathname;
318
+ page = pageFromLoader(docs, docs.source.getPageByUrl(url.replace(/\/$/, "")));
319
+ }
320
+ }
321
+ if (!page) return {
322
+ kind: "broken",
323
+ reason: `no page at ${target}`
324
+ };
325
+ if (anchor && !docs.parsed(page).headings.some((h) => h.anchor === anchor)) return {
326
+ kind: "broken",
327
+ reason: `no heading #${anchor} in ${page.id}`
328
+ };
329
+ return {
330
+ kind: "internal",
331
+ page,
332
+ ...anchor ? { anchor } : {}
333
+ };
334
+ }
335
+ function pageLinks(docs, page) {
336
+ const out = {
337
+ internal: [],
338
+ external: [],
339
+ backlinks: [],
340
+ broken: []
341
+ };
342
+ for (const ref of pageLinkRefs(docs, page)) {
343
+ const r = resolveLink(docs, page, ref.href);
344
+ if (r.kind === "internal") out.internal.push({
345
+ page: r.page.id,
346
+ ...r.anchor ? { anchor: r.anchor } : {},
347
+ text: ref.text,
348
+ line: ref.line
349
+ });
350
+ else if (r.kind === "external") out.external.push({
351
+ url: r.url,
352
+ text: ref.text,
353
+ line: ref.line
354
+ });
355
+ else out.broken.push({
356
+ target: ref.href,
357
+ line: ref.line,
358
+ reason: r.reason
359
+ });
360
+ }
361
+ for (const other of docs.pages) {
362
+ if (other === page) continue;
363
+ for (const ref of pageLinkRefs(docs, other)) {
364
+ const r = resolveLink(docs, other, ref.href);
365
+ if (r.kind === "internal" && r.page === page) out.backlinks.push({
366
+ page: other.id,
367
+ line: ref.line
368
+ });
369
+ }
370
+ }
371
+ return out;
372
+ }
373
+ // src/core/lint.ts
374
+ /** What would break a Fumadocs site, or hide a page from its navigation. */
375
+ function lint(docs) {
376
+ const findings = [];
377
+ const add = (file, line, rule, message) => findings.push({
378
+ file,
379
+ line,
380
+ rule,
381
+ message
382
+ });
383
+ for (const page of docs.pages) {
384
+ if (page.frontmatterError) add(page.file, 1, "frontmatter", `the frontmatter is not valid YAML: ${page.frontmatterError}`);
385
+ else if (!page.title) add(page.file, 1, "title", "no title in the frontmatter");
386
+ const { error } = docs.parsed(page);
387
+ if (error) add(page.file, error.line, "mdx", `MDX does not compile: ${error.message}`);
388
+ for (const ref of pageLinkRefs(docs, page)) {
389
+ const r = resolveLink(docs, page, ref.href);
390
+ if (r.kind === "broken") add(page.file, ref.line, "link", `${ref.href}: ${r.reason}`);
391
+ }
392
+ }
393
+ const excluded = /* @__PURE__ */ new Set();
394
+ for (const meta of docs.metas) {
395
+ if (meta.error) {
396
+ add(meta.file, meta.error.line, "meta", `not valid JSON: ${meta.error.message}`);
397
+ continue;
398
+ }
399
+ checkMeta(docs, meta, excluded, add);
400
+ }
401
+ const shown = navPages(docs);
402
+ for (const page of docs.pages) if (!shown.has(page) && !excluded.has(page.path.replace(/\.mdx?$/, ""))) add(page.file, 1, "meta", `not in the navigation: ${missingHint(docs, page)}`);
403
+ return findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
404
+ }
405
+ const NOT_A_NAME = /^(\.\.\.|z\.\.\.a|---.*---|(external:)?\[.*\]\(.*\))$/;
406
+ function checkMeta(docs, meta, excluded, add) {
407
+ const pages = meta.data?.pages;
408
+ if (pages === void 0) return;
409
+ const folder = path.posix.dirname(meta.path);
410
+ if (!Array.isArray(pages) || pages.some((p) => typeof p !== "string")) {
411
+ add(meta.file, 1, "meta", "pages must be a list of strings");
412
+ return;
413
+ }
414
+ for (const entry of pages) {
415
+ if (NOT_A_NAME.test(entry)) continue;
416
+ const name = entry.replace(/^!/, "").replace(/^\.\.\./, "");
417
+ const rel = folder === "." ? name : `${folder}/${name}`;
418
+ if (entry.startsWith("!")) excluded.add(rel);
419
+ const full = path.join(docs.dir, rel);
420
+ if ([
421
+ ".mdx",
422
+ ".md",
423
+ ""
424
+ ].some((ext) => fs.existsSync(full + ext))) continue;
425
+ const line = meta.raw.slice(0, meta.raw.indexOf(JSON.stringify(entry))).split("\n").length;
426
+ add(meta.file, line, "meta", `lists "${entry}", but there is no ${name}.mdx or ${name}/ in ${folder === "." ? "the docs folder" : `${folder}/`}`);
427
+ }
428
+ }
429
+ /** The pages the site's navigation reaches. */
430
+ function navPages(docs) {
431
+ const shown = /* @__PURE__ */ new Set();
432
+ const add = (item) => {
433
+ const page = pageFromLoader(docs, docs.source.getNodePage(item));
434
+ if (page) shown.add(page);
435
+ };
436
+ const visit = (nodes) => {
437
+ for (const node of nodes) if (node.type === "page") add(node);
438
+ else if (node.type === "folder") {
439
+ if (node.index) add(node.index);
440
+ visit(node.children);
441
+ }
442
+ };
443
+ visit(docs.source.pageTree.children);
444
+ return shown;
445
+ }
446
+ function missingHint(docs, page) {
447
+ const folder = path.posix.dirname(page.path);
448
+ const meta = folder === "." ? "meta.json" : `${folder}/meta.json`;
449
+ const name = path.posix.basename(page.path).replace(/\.mdx?$/, "");
450
+ return docs.metas.some((m) => m.path === meta) ? `add "${name}" (or "...") to ${meta}` : `a meta.json above this folder leaves it out`;
451
+ }
452
+ // src/core/outline.ts
453
+ /** The navigation as the site shows it: `meta.json` order, folders, separators and links. */
454
+ function docsOutline(docs) {
455
+ const out = [];
456
+ const visit = (nodes, depth) => {
457
+ for (const node of nodes) {
458
+ const title = typeof node.name === "string" ? node.name : "";
459
+ if (node.type === "separator") out.push({
460
+ kind: "separator",
461
+ depth,
462
+ title
463
+ });
464
+ else if (node.type === "folder") {
465
+ out.push({
466
+ kind: "folder",
467
+ depth,
468
+ title
469
+ });
470
+ if (node.index) pushPage(node.index, depth + 1);
471
+ visit(node.children, depth + 1);
472
+ } else pushPage(node, depth);
473
+ }
474
+ };
475
+ const pushPage = (item, depth) => {
476
+ const page = pageFromLoader(docs, docs.source.getNodePage(item));
477
+ if (page) out.push({
478
+ kind: "page",
479
+ depth,
480
+ title: page.title ?? page.id,
481
+ page: pageRef(page)
482
+ });
483
+ else out.push({
484
+ kind: "link",
485
+ depth,
486
+ title: String(item.name),
487
+ url: item.url
488
+ });
489
+ };
490
+ visit(docs.source.pageTree.children, 0);
491
+ return out;
492
+ }
493
+ function pageOutline(docs, page) {
494
+ return {
495
+ page: pageRef(page),
496
+ tokens: tokens(page.raw),
497
+ headings: docs.parsed(page).headings.map((h) => ({
498
+ depth: h.depth,
499
+ text: h.text,
500
+ anchor: h.anchor,
501
+ tokens: h.tokens
502
+ }))
503
+ };
504
+ }
505
+ // src/core/search.ts
506
+ const SNIPPET = 140;
507
+ const terms = (query) => [...new Set(query.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean))];
508
+ const matcher = (term) => new RegExp(`(?<![\\p{L}\\p{N}])${term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "giu");
509
+ /**
510
+ * The best pages for a query, each with its best section. Every call scores the sections
511
+ * Fumadocs extracts for its own search: the title and description weigh 3, a heading 2,
512
+ * a paragraph or table row 1, each scaled by how rare the term is and how many of the
513
+ * query's terms the block holds.
514
+ */
515
+ function search(docs, query, limit = 5) {
516
+ const words = terms(query);
517
+ if (!words.length) throw new DocsError("search needs at least one word to look for.");
518
+ const blocks = docs.pages.flatMap((page) => pageBlocks(docs, page));
519
+ const patterns = words.map(matcher);
520
+ const counts = blocks.map((b) => patterns.map((p) => b.text.match(p)?.length ?? 0));
521
+ const idf = words.map((_, t) => {
522
+ const df = counts.filter((c) => c[t] > 0).length;
523
+ return Math.log(1 + blocks.length / (1 + df));
524
+ });
525
+ const pages = /* @__PURE__ */ new Map();
526
+ blocks.forEach((block, i) => {
527
+ const c = counts[i];
528
+ const matched = c.filter((n) => n > 0).length;
529
+ if (!matched) return;
530
+ const raw = c.reduce((sum, n, t) => sum + (n ? idf[t] * (1 + Math.log(n)) : 0), 0);
531
+ const score = block.weight * raw * (matched / words.length) ** 2;
532
+ let p = pages.get(block.ref.page);
533
+ if (!p) pages.set(block.ref.page, p = {
534
+ ref: block.ref,
535
+ top: 0,
536
+ total: 0
537
+ });
538
+ p.top = Math.max(p.top, score);
539
+ p.total += score;
540
+ if (block.isTitle) p.title = block;
541
+ else if (!p.section || score > p.section.score) p.section = {
542
+ block,
543
+ score
544
+ };
545
+ });
546
+ return [...pages.values()].map((p) => ({
547
+ block: p.section?.block ?? p.title,
548
+ score: Math.round((p.top + .1 * (p.total - p.top)) * 100) / 100
549
+ })).sort((a, b) => b.score - a.score || a.block.ref.page.localeCompare(b.block.ref.page)).slice(0, limit).map(({ block, score }) => ({
550
+ page: block.ref.page,
551
+ ...block.anchor ? { anchor: block.anchor } : {},
552
+ ...block.heading ? { heading: block.heading } : {},
553
+ title: block.ref.title,
554
+ snippet: snippet(block.preview ?? block.text, patterns),
555
+ score
556
+ }));
557
+ }
558
+ function pageBlocks(docs, page) {
559
+ const ref = pageRef(page);
560
+ const { structured } = docs.parsed(page);
561
+ const headingText = new Map(structured.headings.map((h) => [h.id, h.content]));
562
+ return [
563
+ {
564
+ ref,
565
+ text: [ref.title, ref.description].filter(Boolean).join(". "),
566
+ weight: 3,
567
+ isTitle: true
568
+ },
569
+ ...structured.headings.map((h) => ({
570
+ ref,
571
+ anchor: h.id,
572
+ heading: h.content,
573
+ text: h.content,
574
+ weight: 2,
575
+ preview: structured.contents.find((c) => c.heading === h.id)?.content
576
+ })),
577
+ ...structured.contents.map((c) => ({
578
+ ref,
579
+ anchor: c.heading,
580
+ heading: c.heading ? headingText.get(c.heading) : void 0,
581
+ text: c.content,
582
+ weight: 1
583
+ }))
584
+ ];
585
+ }
586
+ function snippet(text, patterns) {
587
+ const flat = text.split("\n").filter((line) => !/^[\s|:-]*-{3}[\s|:-]*$/.test(line)).join(" ").replace(/\s+/g, " ").trim();
588
+ const first = Math.min(...patterns.map((p) => {
589
+ p.lastIndex = 0;
590
+ const m = p.exec(flat);
591
+ return m ? m.index : Infinity;
592
+ }));
593
+ const start = Number.isFinite(first) ? Math.max(0, first - 40) : 0;
594
+ const piece = flat.slice(start, start + SNIPPET);
595
+ return `${start > 0 ? "…" : ""}${piece}${start + SNIPPET < flat.length ? "…" : ""}`;
596
+ }
597
+ // src/core/sections.ts
598
+ const PARTS = [
599
+ "frontmatter",
600
+ "body",
601
+ "summary",
602
+ "section:<heading>"
603
+ ];
604
+ /** The page as written, or one part: frontmatter, body, summary or `section:<heading>`. */
605
+ function pagePart(docs, page, part) {
606
+ if (part === void 0) return page.raw;
607
+ if (part === "frontmatter") return page.matter;
608
+ if (part === "body") return page.body.replace(/^\s*\n/, "").trimEnd();
609
+ if (part === "summary") return [page.title ?? page.id, page.description].filter(Boolean).join("\n");
610
+ if (part.startsWith("section:")) {
611
+ const heading = findHeading(docs, page, part.slice(8));
612
+ return page.body.slice(heading.start, heading.end).trimEnd();
613
+ }
614
+ throw new DocsError(`No part ${part}. Parts: ${PARTS.join(", ")}.`);
615
+ }
616
+ /** A heading by its anchor, its exact text, or a piece of its text that only one heading has. */
617
+ function findHeading(docs, page, key) {
618
+ const headings = docs.parsed(page).headings;
619
+ const k = key.trim().replace(/^#/, "");
620
+ const lower = k.toLowerCase();
621
+ const exact = headings.find((h) => h.anchor === k) ?? headings.find((h) => h.text.toLowerCase() === lower);
622
+ if (exact) return exact;
623
+ const partial = headings.filter((h) => h.text.toLowerCase().includes(lower));
624
+ if (partial.length === 1) return partial[0];
625
+ const list = (partial.length ? partial : headings).map((h) => `#${h.anchor}`).join(", ");
626
+ throw new DocsError(partial.length ? `${partial.length} headings in ${page.id} match "${k}": ${list}. Pass the anchor.` : `No heading "${k}" in ${page.id}.${list ? ` Anchors: ${list}.` : " The page has no headings."}`);
627
+ }
628
+ // src/cli/commands.ts
629
+ const indent = (depth) => " ".repeat(depth);
630
+ const fields = (...parts) => parts.filter((p) => p !== void 0 && p !== "").join(" ");
631
+ const orNone = (lines, none) => lines.length ? lines.join("\n") : none;
632
+ const COMMANDS = [
633
+ {
634
+ name: "outline",
635
+ summary: "the map of the docs; with a page, its headings with anchors and sizes",
636
+ positional: {
637
+ name: "page",
638
+ hint: "a page, e.g. guides/install"
639
+ },
640
+ options: [],
641
+ example: "docs outline guides/install",
642
+ run(docs, args) {
643
+ if (args.positional === void 0) {
644
+ const entries = docsOutline(docs);
645
+ return {
646
+ text: entries.map((e) => {
647
+ const pad = indent(e.depth);
648
+ if (e.kind === "folder") return `${pad}${e.title}/`;
649
+ if (e.kind === "separator") return `${pad}--- ${e.title} ---`;
650
+ if (e.kind === "link") return `${pad}${fields("link", e.title, e.url)}`;
651
+ return `${pad}${fields(e.page.page, e.title, e.page.description)}`;
652
+ }).join("\n"),
653
+ json: entries
654
+ };
655
+ }
656
+ const { page } = findPage(docs, args.positional);
657
+ const outline = pageOutline(docs, page);
658
+ return {
659
+ text: [fields(outline.page.page, outline.page.title, `~${outline.tokens}`), ...outline.headings.map((h) => fields(`${"#".repeat(h.depth)} ${h.text}`, `#${h.anchor}`, `~${h.tokens}`))].join("\n"),
660
+ json: outline
661
+ };
662
+ }
663
+ },
664
+ {
665
+ name: "page",
666
+ summary: "a page as written, or one part of it",
667
+ positional: {
668
+ name: "page",
669
+ hint: "a page; page#anchor gives that section",
670
+ required: true
671
+ },
672
+ options: [{
673
+ name: "part",
674
+ hint: PARTS.join(" | ")
675
+ }],
676
+ example: "docs page guides/install --part section:requirements",
677
+ run(docs, args) {
678
+ const { page, anchor } = findPage(docs, args.positional);
679
+ const part = args.options.part ?? (anchor ? `section:${anchor}` : void 0);
680
+ const content = pagePart(docs, page, part);
681
+ return {
682
+ text: content,
683
+ json: {
684
+ page: pageRef(page),
685
+ ...part ? { part } : {},
686
+ content
687
+ }
688
+ };
689
+ }
690
+ },
691
+ {
692
+ name: "search",
693
+ summary: "best pages, each with its best section and a snippet",
694
+ positional: {
695
+ name: "query",
696
+ hint: "words to look for",
697
+ required: true,
698
+ rest: true
699
+ },
700
+ options: [{
701
+ name: "limit",
702
+ hint: "how many pages, default 5",
703
+ number: true
704
+ }],
705
+ example: "docs search port already in use",
706
+ run(docs, args) {
707
+ const limit = args.options.limit ? Number(args.options.limit) : void 0;
708
+ const hits = search(docs, args.positional, limit);
709
+ const text = hits.map((h) => fields(h.anchor ? `${h.page}#${h.anchor}` : h.page, h.heading ? `${h.title} > ${h.heading}` : h.title, h.snippet));
710
+ return {
711
+ text: orNone(text, "no match"),
712
+ json: hits
713
+ };
714
+ }
715
+ },
716
+ {
717
+ name: "links",
718
+ summary: "links out, external links, backlinks and broken links of a page",
719
+ positional: {
720
+ name: "page",
721
+ hint: "a page, e.g. guides/install",
722
+ required: true
723
+ },
724
+ options: [],
725
+ example: "docs links guides/install",
726
+ run(docs, args) {
727
+ const { page } = findPage(docs, args.positional);
728
+ const links = pageLinks(docs, page);
729
+ const text = [
730
+ ...links.internal.map((l) => fields("out", l.anchor ? `${l.page}#${l.anchor}` : l.page, `line ${l.line}`)),
731
+ ...links.external.map((l) => fields("external", l.url, `line ${l.line}`)),
732
+ ...links.backlinks.map((l) => fields("in", l.page, `line ${l.line}`)),
733
+ ...links.broken.map((l) => fields("broken", l.target, `line ${l.line}`, l.reason))
734
+ ];
735
+ return {
736
+ text: orNone(text, "no links"),
737
+ json: {
738
+ page: pageRef(page),
739
+ ...links
740
+ }
741
+ };
742
+ }
743
+ },
744
+ {
745
+ name: "lint",
746
+ summary: "missing titles, MDX that breaks the build, broken links, meta.json against the files",
747
+ options: [],
748
+ example: "docs lint",
749
+ run(docs) {
750
+ const findings = lint(docs);
751
+ const text = findings.map((f) => fields(`${f.file}:${f.line}`, f.rule, f.message));
752
+ return {
753
+ text: orNone(text, `no problems in ${docs.pages.length} pages`),
754
+ json: findings,
755
+ exitCode: findings.length ? 1 : 0
756
+ };
757
+ }
758
+ }
759
+ ];
760
+ // src/cli/help.ts
761
+ function signature(c) {
762
+ const pos = c.positional ? c.positional.required ? ` <${c.positional.name}>` : ` [<${c.positional.name}>]` : "";
763
+ return `${c.name}${pos}${c.options.map((o) => ` [--${o.name}]`).join("")}`;
764
+ }
765
+ /** What `docs help` prints: one line per command. Agents read it, so it stays short. */
766
+ function usage(commands) {
767
+ return [
768
+ "docs <command>: reads this repo's Fumadocs docs (docs/, .mdx, meta.json). A page is its path in docs/ without .mdx (guides/install); its URL and page#anchor work too.",
769
+ ...commands.map((c) => `${signature(c)}: ${c.summary}`),
770
+ "Every command: --dir <repo or docs folder>, --json.",
771
+ "docs help <command>: its options and an example."
772
+ ].join("\n");
773
+ }
774
+ function commandHelp(c, globals) {
775
+ const lines = [`docs ${signature(c)}`, c.summary];
776
+ if (c.positional) lines.push(` <${c.positional.name}>: ${c.positional.hint}`);
777
+ for (const o of [...c.options, ...globals]) lines.push(` --${o.name}: ${o.hint}`);
778
+ if (c.example) lines.push(`example: ${c.example}`);
779
+ return lines.join("\n");
780
+ }
781
+ // src/cli/main.ts
782
+ const GLOBALS = [{
783
+ name: "dir",
784
+ hint: "the repo, or the folder that holds the pages"
785
+ }, {
786
+ name: "json",
787
+ hint: "print the same data as JSON",
788
+ flag: true
789
+ }];
790
+ function main(argv, cwd) {
791
+ const [name, ...rest] = argv;
792
+ if (!name || [
793
+ "help",
794
+ "--help",
795
+ "-h"
796
+ ].includes(name)) {
797
+ if (!rest[0]) return {
798
+ stdout: usage(COMMANDS),
799
+ exitCode: 0
800
+ };
801
+ const command = COMMANDS.find((c) => c.name === rest[0]);
802
+ if (!command) throw new DocsError(`No command ${rest[0]}. docs help lists them.`);
803
+ return {
804
+ stdout: commandHelp(command, GLOBALS),
805
+ exitCode: 0
806
+ };
807
+ }
808
+ const command = COMMANDS.find((c) => c.name === name);
809
+ if (!command) throw new DocsError(`No command ${name}. docs help lists them.`);
810
+ const args = parseArgs({
811
+ ...command,
812
+ options: [...command.options, ...GLOBALS]
813
+ }, rest);
814
+ const docs = loadDocs(findDocsDir(cwd, args.options.dir));
815
+ const result = command.run(docs, args);
816
+ return {
817
+ stdout: args.options.json ? JSON.stringify(result.json, null, 2) : result.text,
818
+ exitCode: result.exitCode ?? 0
819
+ };
820
+ }
821
+ // src/bin.ts
822
+ try {
823
+ const { stdout, exitCode } = main(process.argv.slice(2), process.cwd());
824
+ process.stdout.write(stdout.endsWith("\n") ? stdout : `${stdout}\n`);
825
+ process.exitCode = exitCode;
826
+ } catch (err) {
827
+ const hint = err instanceof DocsError ? "" : " (unexpected: this is a bug in docs)";
828
+ process.stderr.write(`error: ${err.message}${hint}\n`);
829
+ process.exitCode = 1;
830
+ }
831
+ export {};
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
- "name": "@marincervinschi/docs",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@marincervinschi/docs",
3
+ "version": "0.1.1",
4
+ "description": "Read a project's Fumadocs docs from the terminal, one piece at a time, for coding agents",
5
+ "license": "MIT",
6
+ "author": "Marin Cervinschi",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/MarinCervinschi/docs-cli.git"
10
+ },
11
+ "keywords": [
12
+ "fumadocs",
13
+ "docs",
14
+ "cli",
15
+ "mdx",
16
+ "agents"
17
+ ],
18
+ "type": "module",
19
+ "bin": {
20
+ "docs": "dist/bin.js"
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "engines": {
26
+ "node": ">=22"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "packageManager": "pnpm@10.30.1",
32
+ "scripts": {
33
+ "build": "tsdown",
34
+ "typecheck": "tsc --noEmit",
35
+ "test": "vitest run",
36
+ "format": "prettier --write .",
37
+ "docs": "tsx src/bin.ts",
38
+ "prepublishOnly": "pnpm typecheck && pnpm test && pnpm build"
39
+ },
40
+ "dependencies": {
41
+ "fumadocs-core": "^16.17.10",
42
+ "react": "^19.3.1",
43
+ "remark": "^15.0.1",
44
+ "remark-mdx": "^3.1.1",
45
+ "vfile": "^6.0.3"
46
+ },
47
+ "devDependencies": {
48
+ "@trivago/prettier-plugin-sort-imports": "^6.0.2",
49
+ "@types/mdast": "^4.0.4",
50
+ "@types/node": "^26.6.6",
51
+ "prettier": "^3.9.10",
52
+ "tsdown": "^0.23.4",
53
+ "tsx": "^4.23.15",
54
+ "typescript": "^7.0.2",
55
+ "vitest": "^5.1.0"
56
+ },
57
+ "pnpm": {
58
+ "onlyBuiltDependencies": [
59
+ "esbuild"
60
+ ]
61
+ }
62
+ }