@devp0nt/doc0 0.0.0 → 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/dist/export.js ADDED
@@ -0,0 +1,76 @@
1
+ import { selectFields } from "./select.js";
2
+ import { resolveRelated } from "./docs.js";
3
+ import { humanize, stripLeadingHeading } from "./utils.js";
4
+ //#region src/export.ts
5
+ const anchor = (id) => `doc-${id}`;
6
+ const filterDocs = (docs, tag, category) => {
7
+ const items = tag ? docs.byTag(tag) : docs.all();
8
+ return category ? items.filter((doc) => doc.category.includes(category)) : items;
9
+ };
10
+ /** Group docs by their first `category`, preserving encounter order; uncategorized docs fall into one bucket. */
11
+ const groupByCategory = (items) => {
12
+ const groups = /* @__PURE__ */ new Map();
13
+ for (const doc of items) {
14
+ const key = doc.category[0] ?? "uncategorized";
15
+ const list = groups.get(key);
16
+ if (list) list.push(doc);
17
+ else groups.set(key, [doc]);
18
+ }
19
+ return groups;
20
+ };
21
+ const metaLine = (doc, all) => {
22
+ const parts = [`\`${doc.id}\``];
23
+ if (doc.tags.length > 0) parts.push(doc.tags.join(", "));
24
+ parts.push(`\`${doc.source.file}:${doc.source.lineStart}-${doc.source.lineEnd}\``);
25
+ let line = parts.join(" · ");
26
+ const refs = resolveRelated(all, doc.related);
27
+ if (refs.length > 0) {
28
+ const links = refs.map((ref) => `[${ref.title}](#${anchor(ref.id)})${ref.required ? " (must-read)" : ""}`);
29
+ line += `\n\n**Related:** ${links.join(", ")}`;
30
+ }
31
+ return line;
32
+ };
33
+ /** The built-in JSON formatter: the raw docs (or a `fields` projection), pretty-printed. */
34
+ const formatJson = (docs, _all, options) => JSON.stringify(options.fields ? docs.map((doc) => selectFields(doc, options.fields ?? [])) : docs, null, 2);
35
+ /**
36
+ * The built-in Markdown formatter: a title, a table of contents, then each doc with its metadata and body. Docs are
37
+ * grouped under their `category`; when no doc has a category, it degrades to a clean flat list (no empty buckets).
38
+ */
39
+ const formatMarkdown = (docs, all, options) => {
40
+ const groups = groupByCategory(docs);
41
+ const grouped = !(groups.size === 1 && groups.has("uncategorized"));
42
+ const docHeading = grouped ? "###" : "##";
43
+ const out = [
44
+ `# ${options.title ?? "Documentation"}`,
45
+ "",
46
+ `> ${docs.length} ${docs.length === 1 ? "doc" : "docs"}`,
47
+ "",
48
+ "## Contents",
49
+ ""
50
+ ];
51
+ for (const [category, inCat] of groups) {
52
+ if (grouped) out.push(`- **${humanize(category)}**`);
53
+ for (const doc of inCat) out.push(`${grouped ? " " : ""}- [${doc.title}](#${anchor(doc.id)})`);
54
+ }
55
+ for (const [category, inCat] of groups) {
56
+ out.push("", "---");
57
+ if (grouped) out.push("", `## ${humanize(category)}`);
58
+ for (const doc of inCat) out.push("", `<a id="${anchor(doc.id)}"></a>`, "", `${docHeading} ${doc.title}`, "", metaLine(doc, all), "", stripLeadingHeading(doc.content).trim());
59
+ }
60
+ return `${out.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n`;
61
+ };
62
+ const BUILTIN = {
63
+ json: formatJson,
64
+ md: formatMarkdown
65
+ };
66
+ /**
67
+ * Export a collection to one serialized document — `json` or a rich `md` bundle (or a custom {@link Formatter}),
68
+ * optionally filtered by `tag`/`category`. Powers `doc0 export`, and is reusable from a config's `finalGenerate`.
69
+ */
70
+ const exportDocs = (docs, options = {}) => {
71
+ const { format = "md", tag, category } = options;
72
+ const items = filterDocs(docs, tag, category);
73
+ return (typeof format === "function" ? format : BUILTIN[format])(items, docs, options);
74
+ };
75
+ //#endregion
76
+ export { exportDocs, formatJson, formatMarkdown };
@@ -0,0 +1,7 @@
1
+ import { Output } from "./types.js";
2
+
3
+ //#region src/generate.d.ts
4
+ /** Write the file only when its content differs from what's on disk. Returns true if written. */
5
+ declare const writeIfChanged: (output: Output) => Promise<boolean>;
6
+ //#endregion
7
+ export { writeIfChanged };
@@ -0,0 +1,12 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ //#region src/generate.ts
4
+ /** Write the file only when its content differs from what's on disk. Returns true if written. */
5
+ const writeIfChanged = async (output) => {
6
+ if (await readFile(output.path, "utf8").catch(() => void 0) === output.content) return false;
7
+ await mkdir(dirname(output.path), { recursive: true });
8
+ await writeFile(output.path, output.content, "utf8");
9
+ return true;
10
+ };
11
+ //#endregion
12
+ export { writeIfChanged };
@@ -0,0 +1,46 @@
1
+ import { Cache, Doc, Doc0Instance, Doc0Options, Doc0Source, DocRelated, DocSource, DocSourceType, Docs, OneOrMany, Output, Parser, ParserInput, RelatedRef, SearchEngine, SearchOptions, ServeWebOptions } from "./types.js";
2
+ import { fileCache, hash, memoryCache, resolveCache } from "./cache.js";
3
+ import { DocsCollection, EngineSource, resolveRelated } from "./docs.js";
4
+ import { ALL_FIELDS, DocField, DocView, parseFields, selectFields } from "./select.js";
5
+ import { ExportFormat, ExportOptions, Formatter, exportDocs, formatJson, formatMarkdown } from "./export.js";
6
+ import { writeIfChanged } from "./generate.js";
7
+ import { codeParser } from "./parsers/code.js";
8
+ import { markdownParser } from "./parsers/markdown.js";
9
+ import { defaultParsers, parseFile } from "./parsers/index.js";
10
+ import { buildDoc } from "./normalize.js";
11
+ import { loadDoc0 } from "./load.js";
12
+ import { asString, asStringArray, defaultIdFromFile, firstHeading, firstLine, humanize, parseRelated, stripLeadingHeading, toArray } from "./utils.js";
13
+
14
+ //#region src/index.d.ts
15
+ /**
16
+ * The engine — parse config plus actions. Create one with `Doc0.create(options)` and export it from a config module
17
+ * (`docs/index.ts`); runners (the CLI, `serveMcp`, …) load it and decide what to run.
18
+ */
19
+ declare class Doc0<TExtra = unknown> implements Doc0Instance<TExtra> {
20
+ readonly options: Doc0Options<TExtra>;
21
+ readonly cache: Cache;
22
+ private readonly fingerprint;
23
+ private collected;
24
+ private signature;
25
+ private watcher;
26
+ private readonly listeners;
27
+ private constructor();
28
+ static create<TExtra = unknown>(options: Doc0Options<TExtra>): Doc0<TExtra>;
29
+ private get engineSource();
30
+ collect(): Promise<Docs<TExtra>>;
31
+ sync(): Promise<void>;
32
+ /**
33
+ * Start watching the globs and call `onChange` (debounced) when a file changes — for push-based reactions like
34
+ * regenerating outputs. Idempotent: at most one underlying watcher exists per `Doc0`, no matter how many times
35
+ * `watch()` is called; each call's stop function removes its own listener and closes the watcher once the last one
36
+ * leaves. Freshness does NOT depend on this — `collect()` revalidates itself.
37
+ */
38
+ watch(onChange: () => void | Promise<void>): () => void;
39
+ private startWatcher;
40
+ private globFiles;
41
+ private signatureOf;
42
+ private scan;
43
+ private validate;
44
+ }
45
+ //#endregion
46
+ export { ALL_FIELDS, type Cache, type Doc, Doc0, type Doc0Instance, type Doc0Options, type Doc0Source, type DocField, type DocRelated, type DocSource, type DocSourceType, type DocView, type Docs, DocsCollection, type EngineSource, type ExportFormat, type ExportOptions, type Formatter, type OneOrMany, type Output, type Parser, type ParserInput, type RelatedRef, type SearchEngine, type SearchOptions, type ServeWebOptions, asString, asStringArray, buildDoc, codeParser, defaultIdFromFile, defaultParsers, exportDocs, fileCache, firstHeading, firstLine, formatJson, formatMarkdown, hash, humanize, loadDoc0, markdownParser, memoryCache, parseFields, parseFile, parseRelated, resolveCache, resolveRelated, selectFields, stripLeadingHeading, toArray, writeIfChanged };
package/dist/index.js ADDED
@@ -0,0 +1,160 @@
1
+ import { fileCache, hash, memoryCache, resolveCache } from "./cache.js";
2
+ import { loadDoc0 } from "./load.js";
3
+ import { ALL_FIELDS, parseFields, selectFields } from "./select.js";
4
+ import { DocsCollection, resolveRelated } from "./docs.js";
5
+ import { asString, asStringArray, defaultIdFromFile, firstHeading, firstLine, humanize, parseRelated, stripLeadingHeading, toArray } from "./utils.js";
6
+ import { exportDocs, formatJson, formatMarkdown } from "./export.js";
7
+ import { writeIfChanged } from "./generate.js";
8
+ import { buildDoc } from "./normalize.js";
9
+ import { codeParser } from "./parsers/code.js";
10
+ import { markdownParser } from "./parsers/markdown.js";
11
+ import { defaultParsers, parseFile } from "./parsers/index.js";
12
+ import { readFile, stat } from "node:fs/promises";
13
+ import { watch } from "chokidar";
14
+ import { glob } from "tinyglobby";
15
+ //#region src/index.ts
16
+ /**
17
+ * Bump when the shape of a parsed/cached `Doc` changes (e.g. `related` normalization), so an upgrade invalidates stale
18
+ * on-disk parse entries instead of serving the old shape. Part of the cache fingerprint.
19
+ */
20
+ const CACHE_VERSION = 2;
21
+ /**
22
+ * Lazily build the default search engine (orama keyword/BM25). Imported only on the first `search()`, never on
23
+ * `collect()`/`export`.
24
+ */
25
+ const defaultEngine = () => import("./search/index.js").then((module) => module.oramaSearch());
26
+ const isNegative = (pattern) => pattern.startsWith("!");
27
+ /** The non-glob prefix of a pattern — used as a watch root (chokidar 4+ has no glob support). */
28
+ const globBase = (pattern) => {
29
+ const base = [];
30
+ for (const segment of pattern.split("/")) {
31
+ if (/[*?{}[\]!]/.test(segment)) break;
32
+ base.push(segment);
33
+ }
34
+ return base.join("/") || ".";
35
+ };
36
+ /**
37
+ * The engine — parse config plus actions. Create one with `Doc0.create(options)` and export it from a config module
38
+ * (`docs/index.ts`); runners (the CLI, `serveMcp`, …) load it and decide what to run.
39
+ */
40
+ var Doc0 = class Doc0 {
41
+ options;
42
+ cache;
43
+ fingerprint;
44
+ collected;
45
+ signature;
46
+ watcher;
47
+ listeners = /* @__PURE__ */ new Set();
48
+ constructor(options) {
49
+ this.options = options;
50
+ this.cache = resolveCache(options.cache);
51
+ this.fingerprint = hash(JSON.stringify({
52
+ version: CACHE_VERSION,
53
+ transform: options.transform?.toString() ?? null,
54
+ category: options.category ?? null,
55
+ parsers: (options.parsers ?? defaultParsers).map((parser) => parser.name)
56
+ }));
57
+ }
58
+ static create(options) {
59
+ return new Doc0(options);
60
+ }
61
+ get engineSource() {
62
+ return this.options.search ?? defaultEngine;
63
+ }
64
+ async collect() {
65
+ if (this.options.data) {
66
+ this.collected ??= new DocsCollection([...this.options.data], this.engineSource);
67
+ return this.collected;
68
+ }
69
+ const files = await this.globFiles();
70
+ const signature = await this.signatureOf(files);
71
+ if (this.collected && signature === this.signature) return this.collected;
72
+ this.collected = new DocsCollection(await this.scan(files), this.engineSource);
73
+ this.signature = signature;
74
+ return this.collected;
75
+ }
76
+ async sync() {
77
+ const docs = await this.collect();
78
+ const { callback, generate, finalGenerate, finalCallback } = this.options;
79
+ if (callback) for (const cb of toArray(callback)) for (const doc of docs.all()) await cb(doc);
80
+ const outputs = [];
81
+ if (generate) for (const doc of docs.all()) {
82
+ const output = generate(doc);
83
+ if (output) outputs.push(output);
84
+ }
85
+ if (finalGenerate) outputs.push(...toArray(finalGenerate(docs)));
86
+ for (const output of outputs) await writeIfChanged(output);
87
+ if (finalCallback) await finalCallback(docs);
88
+ }
89
+ /**
90
+ * Start watching the globs and call `onChange` (debounced) when a file changes — for push-based reactions like
91
+ * regenerating outputs. Idempotent: at most one underlying watcher exists per `Doc0`, no matter how many times
92
+ * `watch()` is called; each call's stop function removes its own listener and closes the watcher once the last one
93
+ * leaves. Freshness does NOT depend on this — `collect()` revalidates itself.
94
+ */
95
+ watch(onChange) {
96
+ this.listeners.add(onChange);
97
+ this.watcher ??= this.startWatcher();
98
+ return () => {
99
+ this.listeners.delete(onChange);
100
+ if (this.listeners.size === 0) {
101
+ this.watcher?.close();
102
+ this.watcher = void 0;
103
+ }
104
+ };
105
+ }
106
+ startWatcher() {
107
+ const watcher = watch([...new Set(this.options.glob.filter((pattern) => !isNegative(pattern)).map(globBase))], { ignoreInitial: true });
108
+ let timer;
109
+ const fire = () => {
110
+ clearTimeout(timer);
111
+ timer = setTimeout(() => {
112
+ for (const listener of this.listeners) listener();
113
+ }, 100);
114
+ };
115
+ watcher.on("add", fire).on("change", fire).on("unlink", fire);
116
+ return watcher;
117
+ }
118
+ async globFiles() {
119
+ return (await glob(this.options.glob.filter((pattern) => !isNegative(pattern)), {
120
+ ignore: this.options.glob.filter(isNegative).map((pattern) => pattern.slice(1)),
121
+ dot: false
122
+ })).sort();
123
+ }
124
+ async signatureOf(files) {
125
+ return hash((await Promise.all(files.map(async (file) => {
126
+ const info = await stat(file);
127
+ return `${file}:${info.mtimeMs}:${info.size}`;
128
+ }))).join("\n"));
129
+ }
130
+ async scan(files) {
131
+ const parsers = this.options.parsers ?? defaultParsers;
132
+ const all = [];
133
+ for (const file of files) {
134
+ const content = await readFile(file, "utf8");
135
+ const key = `parse:${this.fingerprint}:${hash(content)}:${file}`;
136
+ let parsed = await this.cache.get(key);
137
+ if (!parsed) {
138
+ parsed = await parseFile({
139
+ file,
140
+ content
141
+ }, parsers);
142
+ await this.cache.set(key, parsed);
143
+ }
144
+ for (const base of parsed) {
145
+ const produced = this.options.transform ? toArray(this.options.transform(base)) : [base];
146
+ for (const doc of produced) all.push(await this.validate(doc));
147
+ }
148
+ }
149
+ return all;
150
+ }
151
+ async validate(doc) {
152
+ const schema = this.options.schema;
153
+ if (!schema) return doc;
154
+ const result = await schema["~standard"].validate(doc);
155
+ if (result.issues) throw new Error(`doc0: schema validation failed for "${doc.id}"`);
156
+ return result.value;
157
+ }
158
+ };
159
+ //#endregion
160
+ export { ALL_FIELDS, Doc0, DocsCollection, asString, asStringArray, buildDoc, codeParser, defaultIdFromFile, defaultParsers, exportDocs, fileCache, firstHeading, firstLine, formatJson, formatMarkdown, hash, humanize, loadDoc0, markdownParser, memoryCache, parseFields, parseFile, parseRelated, resolveCache, resolveRelated, selectFields, stripLeadingHeading, toArray, writeIfChanged };
package/dist/load.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { Doc0Instance, Doc0Source } from "./types.js";
2
+
3
+ //#region src/load.d.ts
4
+ /**
5
+ * Resolve a {@link Doc0Source} to an engine: pass an instance through, or import a module by path and grab its `doc0`
6
+ * (named) or `default` export.
7
+ */
8
+ declare const loadDoc0: (src: Doc0Source) => Promise<Doc0Instance>;
9
+ //#endregion
10
+ export { loadDoc0 };
package/dist/load.js ADDED
@@ -0,0 +1,17 @@
1
+ import { resolve } from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+ //#region src/load.ts
4
+ const isDoc0 = (value) => typeof value === "object" && value !== null && typeof value.collect === "function";
5
+ /**
6
+ * Resolve a {@link Doc0Source} to an engine: pass an instance through, or import a module by path and grab its `doc0`
7
+ * (named) or `default` export.
8
+ */
9
+ const loadDoc0 = async (src) => {
10
+ if (typeof src !== "string") return src;
11
+ const mod = await import(pathToFileURL(resolve(src)).href);
12
+ const candidate = mod.doc0 ?? mod.default;
13
+ if (!isDoc0(candidate)) throw new Error(`doc0: no \`doc0\` (or default) export found in "${src}"`);
14
+ return candidate;
15
+ };
16
+ //#endregion
17
+ export { loadDoc0 };
@@ -0,0 +1,13 @@
1
+ import { Doc0Source } from "../types.js";
2
+ import { DocsPage, ListOptions, SearchToolOptions, getDoc, listDocs, searchDocs } from "./tools.js";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+
5
+ //#region src/mcp/index.d.ts
6
+ /**
7
+ * Serve a doc0 collection over MCP with three universal tools (`list_docs` [paged], `search_docs`, `get_doc`). Pass a
8
+ * `Doc0` instance or a path to a config module. Tool handlers call `collect()`, which self-revalidates — so the served
9
+ * data is always fresh without a watcher.
10
+ */
11
+ declare const serveMcp: (src: Doc0Source) => Promise<McpServer>;
12
+ //#endregion
13
+ export { type DocsPage, type ListOptions, type SearchToolOptions, getDoc, listDocs, searchDocs, serveMcp };
@@ -0,0 +1,82 @@
1
+ import { loadDoc0 } from "../load.js";
2
+ import { ALL_FIELDS, parseFields } from "../select.js";
3
+ import { getDoc, listDocs, searchDocs } from "./tools.js";
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { z } from "zod";
7
+ //#region src/mcp/index.ts
8
+ /** Default page size for `list_docs` — keeps the agent's context lean; page further with `offset`. */
9
+ const LIST_LIMIT = 50;
10
+ /** Default number of `search_docs` hits when the caller doesn't say. */
11
+ const SEARCH_LIMIT = 10;
12
+ const FIELDS_DESC = `Optional subset of fields to return. Any of: ${ALL_FIELDS.join(", ")}. "source" gives the file path and line span so you can open the implementation.`;
13
+ const json = (value) => ({
14
+ content: [{
15
+ type: "text",
16
+ text: JSON.stringify(value, null, 2)
17
+ }],
18
+ structuredContent: value
19
+ });
20
+ /**
21
+ * Serve a doc0 collection over MCP with three universal tools (`list_docs` [paged], `search_docs`, `get_doc`). Pass a
22
+ * `Doc0` instance or a path to a config module. Tool handlers call `collect()`, which self-revalidates — so the served
23
+ * data is always fresh without a watcher.
24
+ */
25
+ const serveMcp = async (src) => {
26
+ const doc0 = await loadDoc0(src);
27
+ const server = new McpServer({
28
+ name: "doc0",
29
+ version: "0.0.0"
30
+ }, { capabilities: { tools: {} } });
31
+ server.registerTool("list_docs", {
32
+ title: "List docs",
33
+ description: "List documentation entries (paged). Call this first; for project rules, filter by tag \"rule\" and follow any whose description fits your task. The response includes `total` — if it exceeds the page, fetch more with `offset`.",
34
+ inputSchema: {
35
+ tag: z.string().optional(),
36
+ category: z.string().optional(),
37
+ fields: z.array(z.string()).optional().describe(FIELDS_DESC),
38
+ limit: z.number().int().positive().optional().describe(`Max docs to return (default ${LIST_LIMIT}).`),
39
+ offset: z.number().int().nonnegative().optional().describe("Skip this many docs — page through `total`.")
40
+ }
41
+ }, async ({ tag, category, fields, limit, offset }) => json(listDocs(await doc0.collect(), {
42
+ tag,
43
+ category,
44
+ fields: parseFields(fields),
45
+ limit: limit ?? LIST_LIMIT,
46
+ offset
47
+ })));
48
+ server.registerTool("search_docs", {
49
+ title: "Search docs",
50
+ description: "Hybrid keyword + semantic search across all docs. Returns the top matching docs with snippets.",
51
+ inputSchema: {
52
+ query: z.string(),
53
+ limit: z.number().int().positive().optional().describe(`Max hits (default ${SEARCH_LIMIT}).`),
54
+ fields: z.array(z.string()).optional().describe(FIELDS_DESC)
55
+ }
56
+ }, async ({ query, limit, fields }) => json({ hits: await searchDocs(await doc0.collect(), query, {
57
+ limit: limit ?? SEARCH_LIMIT,
58
+ fields: parseFields(fields)
59
+ }) }));
60
+ server.registerTool("get_doc", {
61
+ title: "Get doc",
62
+ description: "Get one doc by id (full content and source by default). Its `related` field lists linked docs with their title and description — open those by id with get_doc, no separate call needed.",
63
+ inputSchema: {
64
+ id: z.string(),
65
+ fields: z.array(z.string()).optional().describe(FIELDS_DESC)
66
+ }
67
+ }, async ({ id, fields }) => {
68
+ const doc = getDoc(await doc0.collect(), id, parseFields(fields));
69
+ if (!doc) return {
70
+ content: [{
71
+ type: "text",
72
+ text: `No doc with id "${id}".`
73
+ }],
74
+ isError: true
75
+ };
76
+ return json(doc);
77
+ });
78
+ await server.connect(new StdioServerTransport());
79
+ return server;
80
+ };
81
+ //#endregion
82
+ export { getDoc, listDocs, searchDocs, serveMcp };
@@ -0,0 +1,39 @@
1
+ import { Docs } from "../types.js";
2
+ import { DocField, DocView } from "../select.js";
3
+
4
+ //#region src/mcp/tools.d.ts
5
+ type ListOptions = {
6
+ tag?: string;
7
+ category?: string;
8
+ fields?: DocField[]; /** Max docs to return; omit for all. Pair with `offset` to page a large project. */
9
+ limit?: number; /** Skip this many docs before the page (default 0). */
10
+ offset?: number;
11
+ };
12
+ type SearchToolOptions = {
13
+ limit?: number;
14
+ fields?: DocField[];
15
+ };
16
+ /** A page of listed docs plus the unpaged `total`, so a caller knows whether to fetch more. */
17
+ type DocsPage = {
18
+ docs: DocView[];
19
+ total: number;
20
+ offset: number;
21
+ limit?: number;
22
+ };
23
+ /**
24
+ * Table of contents — optionally filtered by tag/category, with selectable fields and `limit`/`offset` paging. Returns
25
+ * the page plus `total` (the full filtered count) so callers can page through a large project without flooding
26
+ * context.
27
+ */
28
+ declare const listDocs: (docs: Docs, options?: ListOptions) => DocsPage;
29
+ /** Hybrid search; each hit carries the selected fields plus a content snippet. */
30
+ declare const searchDocs: (docs: Docs, query: string, options?: SearchToolOptions) => Promise<(DocView & {
31
+ snippet: string;
32
+ })[]>;
33
+ /**
34
+ * One doc by id (all fields by default — `content` and `source` included), or undefined. The `related` field comes back
35
+ * enriched (each link carries the target's `title`/`description`), so a separate "get related" call isn't needed.
36
+ */
37
+ declare const getDoc: (docs: Docs, id: string, fields?: DocField[]) => DocView | undefined;
38
+ //#endregion
39
+ export { DocsPage, ListOptions, SearchToolOptions, getDoc, listDocs, searchDocs };
@@ -0,0 +1,72 @@
1
+ import { ALL_FIELDS, selectFields } from "../select.js";
2
+ import { resolveRelated } from "../docs.js";
3
+ //#region src/mcp/tools.ts
4
+ /** Fields returned by default from `list_docs` — a compact summary plus `source`. */
5
+ const SUMMARY_FIELDS = [
6
+ "id",
7
+ "title",
8
+ "description",
9
+ "tags",
10
+ "category",
11
+ "source"
12
+ ];
13
+ /** Fields returned by default per `search_docs` hit (the snippet is added on top). */
14
+ const HIT_FIELDS = [
15
+ "id",
16
+ "title",
17
+ "description",
18
+ "source"
19
+ ];
20
+ const snippet = (content, max = 280) => {
21
+ const text = content.trim().replace(/\s+/g, " ");
22
+ return text.length > max ? `${text.slice(0, max).trimEnd()}…` : text;
23
+ };
24
+ /** Project a doc to the selected fields, upgrading `related` (when present) to enriched {@link RelatedRef}s. */
25
+ const project = (docs, doc, fields) => {
26
+ const { related, ...rest } = selectFields(doc, fields);
27
+ return related ? {
28
+ ...rest,
29
+ related: resolveRelated(docs, related)
30
+ } : rest;
31
+ };
32
+ /**
33
+ * Table of contents — optionally filtered by tag/category, with selectable fields and `limit`/`offset` paging. Returns
34
+ * the page plus `total` (the full filtered count) so callers can page through a large project without flooding
35
+ * context.
36
+ */
37
+ const listDocs = (docs, options = {}) => {
38
+ const { tag, category, fields = SUMMARY_FIELDS, limit, offset = 0 } = options;
39
+ let items = tag ? docs.byTag(tag) : docs.all();
40
+ if (category) items = items.filter((doc) => doc.category.includes(category));
41
+ const total = items.length;
42
+ const start = Math.max(0, offset);
43
+ const projected = (limit === void 0 ? items.slice(start) : items.slice(start, start + Math.max(0, limit))).map((doc) => project(docs, doc, fields));
44
+ return limit === void 0 ? {
45
+ docs: projected,
46
+ total,
47
+ offset: start
48
+ } : {
49
+ docs: projected,
50
+ total,
51
+ offset: start,
52
+ limit
53
+ };
54
+ };
55
+ /** Hybrid search; each hit carries the selected fields plus a content snippet. */
56
+ const searchDocs = async (docs, query, options = {}) => {
57
+ const { limit, fields = HIT_FIELDS } = options;
58
+ return (await docs.search(query, { limit })).map((doc) => ({
59
+ ...project(docs, doc, fields),
60
+ snippet: snippet(doc.content)
61
+ }));
62
+ };
63
+ /**
64
+ * One doc by id (all fields by default — `content` and `source` included), or undefined. The `related` field comes back
65
+ * enriched (each link carries the target's `title`/`description`), so a separate "get related" call isn't needed.
66
+ */
67
+ const getDoc = (docs, id, fields = [...ALL_FIELDS]) => {
68
+ const doc = docs.get(id);
69
+ return doc ? project(docs, doc, fields) : void 0;
70
+ };
71
+ //#endregion
72
+ export { getDoc, listDocs, searchDocs };
@@ -0,0 +1,21 @@
1
+ import { Doc, DocRelated, DocSource } from "./types.js";
2
+
3
+ //#region src/normalize.d.ts
4
+ /** A doc as a parser produces it, before defaults are filled in. */
5
+ type RawDoc = {
6
+ source: DocSource;
7
+ id?: string;
8
+ title?: string;
9
+ description?: string;
10
+ tags?: string[];
11
+ category?: string[];
12
+ related?: DocRelated[];
13
+ content: string;
14
+ };
15
+ /**
16
+ * Fill the conventional defaults: `id` from the file, `title` from the first heading, `description` from the first
17
+ * line, and merge `category` into `tags` (tags is always a superset of category).
18
+ */
19
+ declare const buildDoc: (raw: RawDoc) => Doc;
20
+ //#endregion
21
+ export { RawDoc, buildDoc };
@@ -0,0 +1,25 @@
1
+ import { defaultIdFromFile, firstHeading, firstLine, humanize } from "./utils.js";
2
+ //#region src/normalize.ts
3
+ /**
4
+ * Fill the conventional defaults: `id` from the file, `title` from the first heading, `description` from the first
5
+ * line, and merge `category` into `tags` (tags is always a superset of category).
6
+ */
7
+ const buildDoc = (raw) => {
8
+ const id = raw.id ?? defaultIdFromFile(raw.source.file);
9
+ const title = raw.title ?? firstHeading(raw.content) ?? humanize(id);
10
+ const description = raw.description ?? firstLine(raw.content) ?? "";
11
+ const category = raw.category ?? [];
12
+ const tags = [...new Set([...raw.tags ?? [], ...category])];
13
+ return {
14
+ source: raw.source,
15
+ id,
16
+ title,
17
+ description,
18
+ tags,
19
+ category,
20
+ related: raw.related ?? [],
21
+ content: raw.content
22
+ };
23
+ };
24
+ //#endregion
25
+ export { buildDoc };
@@ -0,0 +1,7 @@
1
+ import { Parser } from "../types.js";
2
+
3
+ //#region src/parsers/code.d.ts
4
+ /** Parses JSDoc in code (`.ts`, `.tsx`, `.js`, …); a block is a doc iff it has a doc0 directive. */
5
+ declare const codeParser: Parser;
6
+ //#endregion
7
+ export { codeParser };