@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025–2026 Sergei Dmitriev <https://p0nt.dev>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,272 @@
1
+ # @devp0nt/doc0
2
+
3
+ > One navigable, searchable collection from the docs you already write.
4
+
5
+ [![CI](https://github.com/devp0nt/doc0/actions/workflows/ci.yml/badge.svg)](https://github.com/devp0nt/doc0/actions/workflows/ci.yml)
6
+ [![npm](https://img.shields.io/npm/v/@devp0nt/doc0.svg)](https://www.npmjs.com/package/@devp0nt/doc0)
7
+ [![license](https://img.shields.io/npm/l/@devp0nt/doc0.svg)](./LICENSE)
8
+
9
+ <!-- docs:start -->
10
+
11
+ You already write docs — JSDoc, code comments, Markdown. They're write-only:
12
+ scattered across the repo, and invisible to the AI agents that need them most.
13
+ So the agent re-derives your conventions every time, and sometimes guesses
14
+ wrong.
15
+
16
+ doc0 parses every doc carrier into **one normalized, linked collection**, then
17
+ serves it where it's useful: to agents over **MCP**, to you over a **local web
18
+ UI**, and to your build as **generated files**. No new format to learn — it
19
+ reads the docs you already have.
20
+
21
+ ```ts
22
+ // docs/index.ts — point doc0 at the docs you already write, export the engine
23
+ import { Doc0 } from '@devp0nt/doc0'
24
+
25
+ export const doc0 = Doc0.create({
26
+ glob: ['src/**/*.ts', 'docs/**/*.md', '!**/*.test.ts'],
27
+ })
28
+ ```
29
+
30
+ Any JSDoc with a doc0 directive becomes a doc — id, tags, and a `@related`
31
+ graph, all from a comment you'd write anyway:
32
+
33
+ ```ts
34
+ /**
35
+ * Money is stored in minor units (cents). Never do currency math in the UI —
36
+ * format through here.
37
+ *
38
+ * @id money
39
+ * @tags rule, money
40
+ * @related cart-total
41
+ */
42
+ export const formatMoney = (minor: number): string =>
43
+ `$${(minor / 100).toFixed(2)}`
44
+ ```
45
+
46
+ ```sh
47
+ bunx doc0 mcp # serve every doc to your agent: list_docs / search_docs / get_doc
48
+ ```
49
+
50
+ Now the agent asks `list_docs({ tag: "rule" })` and reads the rule **with a
51
+ pointer to the exact file and line** — instead of guessing.
52
+
53
+ ## Install
54
+
55
+ doc0 is a dev tool — add it as a dev dependency:
56
+
57
+ ```sh
58
+ bun add -d @devp0nt/doc0
59
+ # or: npm i -D / pnpm add -D / yarn add -D
60
+ ```
61
+
62
+ MCP, the web UI, keyword search, and export work out of the box — nothing else
63
+ to install. Only **semantic** search is opt-in (it pulls a local ML model,
64
+ hundreds of MB):
65
+
66
+ ```sh
67
+ bun add -d @huggingface/transformers
68
+ ```
69
+
70
+ Bun 1+ or Node.js 20+. ESM only.
71
+
72
+ ## Configure once
73
+
74
+ One config module exports the engine. Runners (the CLI, the MCP/web servers)
75
+ load it — the config itself runs nothing.
76
+
77
+ ```ts
78
+ // docs/index.ts
79
+ import { Doc0 } from '@devp0nt/doc0'
80
+
81
+ export const doc0 = Doc0.create({
82
+ // code files: a JSDoc joins the collection when it has a doc0 directive.
83
+ // markdown files: every match joins, unless its frontmatter says `doc: false`.
84
+ glob: ['src/**/*.ts', 'docs/**/*.md', 'README.md', '!**/*.test.ts'],
85
+ // map a category id to a human title (drives grouping in the web UI and export)
86
+ category: { rule: 'Rules', util: 'Utilities' },
87
+ })
88
+ ```
89
+
90
+ A doc has a small, fixed shape: `id`, `title`, `description`, `tags`,
91
+ `category`, `related`, `content`, and `source` (file + line span). Missing
92
+ fields are filled from the content — `title` from the first heading, `id` from
93
+ the symbol or file name — so a bare `## Heading` markdown file is already a
94
+ valid doc.
95
+
96
+ ## Your agent reads the rules you already wrote
97
+
98
+ This is the point. Replace a folder of static `.cursor/rules` / `CLAUDE.md` rule
99
+ files with one instruction that points at doc0:
100
+
101
+ ```md
102
+ <!-- AGENTS.md -->
103
+
104
+ Before any task: call `list_docs({ tag: "rule" })`, read each rule's
105
+ description, and `get_doc` the ones relevant to your task. Then follow them.
106
+ Search the rest with `search_docs("…")` before guessing.
107
+ ```
108
+
109
+ The MCP server exposes a small, fixed tool set any agent understands — it scales
110
+ to a project with hundreds of docs, where one-tool-per-doc would not:
111
+
112
+ ```sh
113
+ bunx doc0 mcp
114
+ ```
115
+
116
+ | Tool | What it returns |
117
+ | ------------- | ------------------------------------------------------------- |
118
+ | `list_docs` | table of contents — paged, filter by `tag` / `category` |
119
+ | `search_docs` | top matches with snippets |
120
+ | `get_doc` | one doc in full, with `source` and an enriched `related` list |
121
+
122
+ Every tool takes a `fields` list to trim the response, and `source` carries the
123
+ file path and line span — so the agent can jump from a rule straight to the code
124
+ it governs.
125
+
126
+ ## Browse and search them yourself
127
+
128
+ ```sh
129
+ bunx doc0 web # http://localhost:4000 — sidebar grouped by category, search, rendered markdown
130
+ ```
131
+
132
+ A real built-in UI: server-rendered Markdown with highlighted code, a search
133
+ box, and a "Related" section on every doc. Offline, no frontend build step.
134
+
135
+ ## Search by keyword, or by meaning
136
+
137
+ Search works out of the box (orama keyword/BM25). For "find by meaning", install
138
+ the embeddings package and turn it on — the small model downloads once, runs
139
+ locally, no API key:
140
+
141
+ ```ts
142
+ import { Doc0 } from '@devp0nt/doc0'
143
+ import { oramaSearch } from '@devp0nt/doc0/search'
144
+
145
+ export const doc0 = Doc0.create({
146
+ glob: ['src/**/*.ts', 'docs/**/*.md'],
147
+ search: oramaSearch({ embeddings: true }), // hybrid keyword + semantic
148
+ })
149
+ ```
150
+
151
+ ```sh
152
+ bunx doc0 search "how do we format prices" # => money, cart-total, …
153
+ ```
154
+
155
+ ## Export the whole collection
156
+
157
+ Dump everything (optionally filtered) to one file — for a hand-off, a PR, or
158
+ another tool:
159
+
160
+ ```sh
161
+ bunx doc0 export --format md > docs.md # one Markdown bundle: TOC + every doc
162
+ bunx doc0 export --format md --tag rule -o rules.md
163
+ bunx doc0 export --format json --fields id,title,source
164
+ ```
165
+
166
+ ## Keep generated files in sync
167
+
168
+ Need Cursor rule files, a JSON index, or any other artifact? Generate them from
169
+ the collection — writes happen only when content changes:
170
+
171
+ ```ts
172
+ export const doc0 = Doc0.create({
173
+ glob: ['src/**/*.ts', 'docs/**/*.md'],
174
+ // per-doc output
175
+ generate: (doc) =>
176
+ doc.tags.includes('rule')
177
+ ? { path: `.cursor/rules/${doc.id}.mdc`, content: doc.content }
178
+ : undefined,
179
+ // whole-collection output
180
+ finalGenerate: (docs) => ({
181
+ path: 'docs/source.json',
182
+ content: docs.toJSON(),
183
+ }),
184
+ })
185
+ ```
186
+
187
+ ```sh
188
+ bunx doc0 sync # parse → run generators (once)
189
+ bunx doc0 sync --watch # re-run on change
190
+ ```
191
+
192
+ `collect()` is always fresh on its own (it re-scans changed files by mtime), so
193
+ the MCP and web servers never serve stale docs — no watcher needed for reads.
194
+
195
+ ## Reference
196
+
197
+ ### CLI
198
+
199
+ ```sh
200
+ doc0 list [path] # table of contents — --tag --category --fields --limit --offset --json
201
+ doc0 get <id> # one doc in full — --fields --json
202
+ doc0 search <query> # keyword + semantic — --limit --fields --json
203
+ doc0 export [path] # whole collection → file — --format md|json --tag --category -o
204
+ doc0 sync [path] # run generators (diff-only) — --watch
205
+ doc0 mcp [path] # serve over MCP (stdio)
206
+ doc0 web [path] # serve the web UI — --port
207
+ doc0 prune [path] # drop the on-disk cache
208
+ ```
209
+
210
+ `path` is optional — defaults to `docs/index.ts` (or `doc0.config.ts`), or pass
211
+ any path to the config module.
212
+
213
+ ### Directives
214
+
215
+ In a JSDoc comment (any one of these makes the comment a doc), or in Markdown
216
+ frontmatter:
217
+
218
+ | Directive | Meaning |
219
+ | -------------- | ------------------------------------------------------------------- |
220
+ | `@id` | stable id (else the symbol or file name) |
221
+ | `@title` | title (else the first heading, else a humanized id) |
222
+ | `@description` | one-line summary (else the first content line) |
223
+ | `@tags` | comma list; `rule` is just a tag |
224
+ | `@category` | grouping tag(s) — drive folders and the web sidebar |
225
+ | `@related` | linked ids; a trailing `!` (e.g. `@related setup!`) means must-read |
226
+ | `@doc` | force-include a comment that has no other directive |
227
+
228
+ Markdown joins by glob; opt a file out with `doc: false` in its frontmatter.
229
+
230
+ ### Packages
231
+
232
+ | Import | What |
233
+ | ----------------------- | --------------------------------------------------------- |
234
+ | `@devp0nt/doc0` | `Doc0` / `Docs`, parsers, collect / sync / watch, export |
235
+ | `@devp0nt/doc0/parsers` | the built-in parsers + the `Parser` contract for your own |
236
+ | `@devp0nt/doc0/mcp` | `serveMcp(src)` — the MCP server |
237
+ | `@devp0nt/doc0/search` | `oramaSearch()` — keyword by default, semantic opt-in |
238
+ | `@devp0nt/doc0/web` | `serveWeb(src)` — the web server + UI |
239
+
240
+ A runnable end-to-end project lives in [`examples/minimal`](./examples/minimal).
241
+
242
+ ## Requirements
243
+
244
+ - **Bun 1+** or **Node.js 20+** (ESM only)
245
+ - **TypeScript 5+** (optional — works in plain JS too)
246
+
247
+ <!-- docs:end -->
248
+
249
+ ## Community
250
+
251
+ Questions, bugs, or want to hang with other builders? Join the devp0nt community
252
+ — one hub for all our open-source projects, this one included. Get help, share
253
+ what you built, or just say hi: [p0nt.dev/community](https://p0nt.dev/community)
254
+
255
+ ## Contributing
256
+
257
+ Issues and PRs welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) and the
258
+ [Code of Conduct](./CODE_OF_CONDUCT.md). Commits follow
259
+ [Conventional Commits](https://www.conventionalcommits.org/). Security reports:
260
+ [SECURITY.md](./SECURITY.md).
261
+
262
+ ## License
263
+
264
+ [MIT](./LICENSE)
265
+
266
+ ---
267
+
268
+ ```text
269
+ Building open-source software for the glory of the Lord Jesus Christ ☦️
270
+ With love for developers of all backgrounds around the world ❤️
271
+ Sergei Dmitriev, 2026 😎
272
+ ```
@@ -0,0 +1,13 @@
1
+ import { Cache } from "./types.js";
2
+
3
+ //#region src/cache.d.ts
4
+ /** A short, stable content hash used to key the cache. */
5
+ declare const hash: (input: string) => string;
6
+ /** In-memory cache — fast, never persists. */
7
+ declare const memoryCache: () => Cache;
8
+ /** File-backed, content-addressed cache: each key → one JSON file under `dir`. */
9
+ declare const fileCache: (dir?: string) => Cache;
10
+ /** Resolve the `cache` option to a concrete {@link Cache}. */
11
+ declare const resolveCache: (cache?: boolean | string | Cache) => Cache;
12
+ //#endregion
13
+ export { fileCache, hash, memoryCache, resolveCache };
package/dist/cache.js ADDED
@@ -0,0 +1,50 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ //#region src/cache.ts
5
+ /** A short, stable content hash used to key the cache. */
6
+ const hash = (input) => createHash("sha256").update(input).digest("hex").slice(0, 32);
7
+ /** In-memory cache — fast, never persists. */
8
+ const memoryCache = () => {
9
+ const store = /* @__PURE__ */ new Map();
10
+ return {
11
+ get: (key) => Promise.resolve(store.get(key)),
12
+ set: (key, value) => {
13
+ store.set(key, value);
14
+ return Promise.resolve();
15
+ }
16
+ };
17
+ };
18
+ const DEFAULT_DIR = "node_modules/.cache/doc0";
19
+ /** File-backed, content-addressed cache: each key → one JSON file under `dir`. */
20
+ const fileCache = (dir = DEFAULT_DIR) => {
21
+ const fileFor = (key) => join(dir, `${hash(key)}.json`);
22
+ return {
23
+ get: async (key) => {
24
+ try {
25
+ const raw = await readFile(fileFor(key), "utf8");
26
+ return JSON.parse(raw);
27
+ } catch {
28
+ return;
29
+ }
30
+ },
31
+ set: async (key, value) => {
32
+ const file = fileFor(key);
33
+ await mkdir(dirname(file), { recursive: true });
34
+ await writeFile(file, JSON.stringify(value), "utf8");
35
+ }
36
+ };
37
+ };
38
+ const noopCache = () => ({
39
+ get: () => Promise.resolve(void 0),
40
+ set: () => Promise.resolve()
41
+ });
42
+ /** Resolve the `cache` option to a concrete {@link Cache}. */
43
+ const resolveCache = (cache) => {
44
+ if (cache === false) return noopCache();
45
+ if (cache === void 0 || cache === true) return fileCache();
46
+ if (typeof cache === "string") return fileCache(cache);
47
+ return cache;
48
+ };
49
+ //#endregion
50
+ export { fileCache, hash, memoryCache, resolveCache };
package/dist/cli.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { Command } from "commander";
2
+
3
+ //#region src/cli.d.ts
4
+ /** Find the config module: an explicit path, else the first known candidate. */
5
+ declare const findConfig: (explicit?: string) => string;
6
+ /** Build the doc0 CLI program (Commander). Exported so it can be inspected/tested. */
7
+ declare const buildProgram: () => Command;
8
+ //#endregion
9
+ export { buildProgram, findConfig };
package/dist/cli.js ADDED
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env bun
2
+ import { loadDoc0 } from "./load.js";
3
+ import { parseFields } from "./select.js";
4
+ import { rm, writeFile } from "node:fs/promises";
5
+ import { resolve } from "node:path";
6
+ import { existsSync } from "node:fs";
7
+ import { Command } from "commander";
8
+ //#region src/cli.ts
9
+ const CONFIG_CANDIDATES = [
10
+ "docs/index.ts",
11
+ "doc0.config.ts",
12
+ "doc0.config.js",
13
+ "docs/index.js"
14
+ ];
15
+ const CACHE_DIR = "node_modules/.cache/doc0";
16
+ /** Find the config module: an explicit path, else the first known candidate. */
17
+ const findConfig = (explicit) => {
18
+ if (explicit) return explicit;
19
+ for (const candidate of CONFIG_CANDIDATES) if (existsSync(resolve(candidate))) return candidate;
20
+ throw new Error("doc0: no config found. Create docs/index.ts that exports `doc0`, or pass a path.");
21
+ };
22
+ const sourceTag = (doc) => doc.source ? ` ${doc.source.file}:${doc.source.lineStart}` : "";
23
+ const formatRow = (doc) => {
24
+ const tags = doc.tags?.length ? ` [${doc.tags.join(", ")}]` : "";
25
+ const description = doc.description ? `\n ${doc.description}` : "";
26
+ return `${doc.id ?? "?"}${tags}${sourceTag(doc)}${description}`;
27
+ };
28
+ /** Render a listed page as rows, with a footer when it's a window into a larger `total`. */
29
+ const formatList = (page) => {
30
+ if (page.docs.length === 0) return "no docs";
31
+ const rows = page.docs.map(formatRow).join("\n");
32
+ const end = page.offset + page.docs.length;
33
+ if (end >= page.total && page.offset === 0) return rows;
34
+ const more = end < page.total ? ` (next: --offset ${end})` : "";
35
+ return `${rows}\n\nshowing ${page.offset + 1}–${end} of ${page.total}${more}`;
36
+ };
37
+ const formatDoc = (doc) => {
38
+ const lines = [];
39
+ if (doc.title) lines.push(`# ${doc.title}`);
40
+ if (doc.id) lines.push(`id: ${doc.id}`);
41
+ if (doc.source) lines.push(`source: ${doc.source.file}:${doc.source.lineStart}-${doc.source.lineEnd}`);
42
+ if (doc.tags?.length) lines.push(`tags: ${doc.tags.join(", ")}`);
43
+ if (doc.related?.length) {
44
+ lines.push("", "related:");
45
+ for (const link of doc.related) {
46
+ const marks = [link.required ? "must-read" : "", link.reason].filter(Boolean).join("; ");
47
+ lines.push(` - ${link.title} (${link.id})${marks ? ` — ${marks}` : ""}`);
48
+ if (link.description) lines.push(` ${link.description}`);
49
+ }
50
+ }
51
+ if (doc.content) lines.push("", doc.content);
52
+ return lines.join("\n");
53
+ };
54
+ const output = (json, data, pretty) => {
55
+ console.log(json ? JSON.stringify(data, null, 2) : pretty());
56
+ };
57
+ /** Build the doc0 CLI program (Commander). Exported so it can be inspected/tested. */
58
+ const buildProgram = () => {
59
+ const program = new Command();
60
+ program.name("doc0").description("Parse, query, and serve your project docs.").version("0.0.0");
61
+ program.command("sync [path]").description("Parse and run generators (writes only on diff)").option("--watch", "watch and re-sync on change").action(async (path, options) => {
62
+ const doc0 = await loadDoc0(findConfig(path));
63
+ if (options.watch) doc0.watch(() => doc0.sync());
64
+ await doc0.sync();
65
+ });
66
+ program.command("watch [path]").description("Watch the globs and re-sync on change").action(async (path) => {
67
+ const doc0 = await loadDoc0(findConfig(path));
68
+ doc0.watch(() => doc0.sync());
69
+ await doc0.sync();
70
+ });
71
+ program.command("list [path]").description("List docs (table of contents)").option("--tag <tag>", "only docs with this tag").option("--category <category>", "only docs in this category").option("--fields <fields>", "comma list of fields to return (e.g. id,title,source)").option("--limit <n>", "max docs to return (default: all)").option("--offset <n>", "skip this many docs (for paging)").option("--json", "output JSON").action(async (path, options) => {
72
+ const { listDocs } = await import("./mcp/tools.js");
73
+ const page = listDocs(await (await loadDoc0(findConfig(path))).collect(), {
74
+ tag: options.tag,
75
+ category: options.category,
76
+ fields: parseFields(options.fields),
77
+ limit: options.limit ? Number(options.limit) : void 0,
78
+ offset: options.offset ? Number(options.offset) : void 0
79
+ });
80
+ output(options.json, page, () => formatList(page));
81
+ });
82
+ program.command("get <id> [path]").description("Print one doc in full (content + source)").option("--fields <fields>", "comma list of fields to return").option("--json", "output JSON").action(async (id, path, options) => {
83
+ const { getDoc } = await import("./mcp/tools.js");
84
+ const doc = getDoc(await (await loadDoc0(findConfig(path))).collect(), id, parseFields(options.fields));
85
+ if (!doc) throw new Error(`doc0: no doc with id "${id}"`);
86
+ output(options.json, doc, () => formatDoc(doc));
87
+ });
88
+ program.command("search <query> [path]").description("Hybrid keyword + semantic search").option("--limit <n>", "max results").option("--fields <fields>", "comma list of fields to return").option("--json", "output JSON").action(async (query, path, options) => {
89
+ const { searchDocs } = await import("./mcp/tools.js");
90
+ const hits = await searchDocs(await (await loadDoc0(findConfig(path))).collect(), query, {
91
+ limit: options.limit ? Number(options.limit) : void 0,
92
+ fields: parseFields(options.fields)
93
+ });
94
+ output(options.json, hits, () => hits.map(formatRow).join("\n") || "no matches");
95
+ });
96
+ program.command("export [path]").description("Export the whole collection to one file (md or json)").option("--format <format>", "md | json (default: md)").option("--tag <tag>", "only docs with this tag").option("--category <category>", "only docs in this category").option("--fields <fields>", "comma list of fields (json only)").option("--title <title>", "document title (md)").option("-o, --output <file>", "write to this file instead of stdout").action(async (path, options) => {
97
+ const format = options.format ?? "md";
98
+ if (format !== "md" && format !== "json") throw new Error(`doc0: unsupported --format "${format}" (use md or json)`);
99
+ const { exportDocs } = await import("./export.js");
100
+ const text = exportDocs(await (await loadDoc0(findConfig(path))).collect(), {
101
+ format,
102
+ tag: options.tag,
103
+ category: options.category,
104
+ fields: parseFields(options.fields),
105
+ title: options.title
106
+ });
107
+ if (options.output) {
108
+ await writeFile(resolve(options.output), text);
109
+ console.log(`doc0: wrote ${options.output}`);
110
+ } else process.stdout.write(text);
111
+ });
112
+ program.command("mcp [path]").description("Serve docs over MCP (stdio)").action(async (path) => {
113
+ const { serveMcp } = await import("./mcp/index.js");
114
+ await serveMcp(findConfig(path));
115
+ });
116
+ program.command("web [path]").description("Serve a local web UI with search").option("--port <n>", "port (default 4000)").action(async (path, options) => {
117
+ const { serveWeb } = await import("./web/index.js");
118
+ const port = options.port ? Number(options.port) : void 0;
119
+ await serveWeb(findConfig(path), { port });
120
+ console.log(`doc0 web on http://localhost:${port ?? 4e3}`);
121
+ });
122
+ program.command("prune").description("Drop the on-disk cache").option("--all", "remove the whole cache (default: stale entries)").action(async () => {
123
+ await rm(CACHE_DIR, {
124
+ recursive: true,
125
+ force: true
126
+ });
127
+ console.log("doc0: cache pruned");
128
+ });
129
+ return program;
130
+ };
131
+ if (import.meta.main) buildProgram().parseAsync(process.argv).catch((error) => {
132
+ console.error(error instanceof Error ? error.message : error);
133
+ process.exit(1);
134
+ });
135
+ //#endregion
136
+ export { buildProgram, findConfig };
package/dist/docs.d.ts ADDED
@@ -0,0 +1,42 @@
1
+ import { Doc, DocRelated, Docs, RelatedRef, SearchEngine, SearchOptions } from "./types.js";
2
+
3
+ //#region src/docs.d.ts
4
+ /**
5
+ * Resolve stored `@related` links into enriched {@link RelatedRef}s — each link gains the target doc's `title` and
6
+ * `description` (looked up via `docs.get`) so a reader can tell what's behind it. Links whose target is missing are
7
+ * dropped. With `order: true`, must-read (`required`) links surface first; otherwise authored order is kept.
8
+ */
9
+ declare const resolveRelated: (docs: Pick<Docs, "get">, links: readonly DocRelated[], options?: {
10
+ required?: boolean;
11
+ order?: boolean;
12
+ }) => RelatedRef[];
13
+ /** A search engine, or a (possibly async) factory for one — the factory is invoked lazily on the first `search()`. */
14
+ type EngineSource = SearchEngine | (() => SearchEngine | Promise<SearchEngine>);
15
+ /**
16
+ * The queryable collection returned by `doc0.collect()`. Search uses the engine from `Doc0.create({ search })` (or the
17
+ * default orama engine `Doc0` injects), resolved lazily; with no engine at all it falls back to a naive keyword
18
+ * scorer.
19
+ */
20
+ declare class DocsCollection<TExtra = unknown> implements Docs<TExtra> {
21
+ private readonly items;
22
+ private readonly byId;
23
+ private readonly engineSource;
24
+ private engine;
25
+ private enginePromise;
26
+ private indexed;
27
+ constructor(items: Doc<TExtra>[], engineSource?: EngineSource);
28
+ private resolveEngine;
29
+ all(): Doc<TExtra>[];
30
+ get(id: string): Doc<TExtra> | undefined;
31
+ byTag(tag: string): Doc<TExtra>[];
32
+ byCategory(category: string): Doc<TExtra>[];
33
+ related(id: string, options?: {
34
+ required?: boolean;
35
+ }): RelatedRef[];
36
+ search(query: string, options?: SearchOptions): Promise<Doc<TExtra>[]>;
37
+ toJSON(): string;
38
+ [Symbol.iterator](): Iterator<Doc<TExtra>>;
39
+ private naiveSearch;
40
+ }
41
+ //#endregion
42
+ export { DocsCollection, EngineSource, resolveRelated };
package/dist/docs.js ADDED
@@ -0,0 +1,100 @@
1
+ //#region src/docs.ts
2
+ /**
3
+ * Resolve stored `@related` links into enriched {@link RelatedRef}s — each link gains the target doc's `title` and
4
+ * `description` (looked up via `docs.get`) so a reader can tell what's behind it. Links whose target is missing are
5
+ * dropped. With `order: true`, must-read (`required`) links surface first; otherwise authored order is kept.
6
+ */
7
+ const resolveRelated = (docs, links, options) => {
8
+ const filtered = options?.required ? links.filter((link) => link.required) : links;
9
+ return (options?.order ? [...filtered].sort((a, b) => Number(b.required ?? false) - Number(a.required ?? false)) : filtered).flatMap((link) => {
10
+ const target = docs.get(link.id);
11
+ return target ? [{
12
+ ...link,
13
+ title: target.title,
14
+ description: target.description
15
+ }] : [];
16
+ });
17
+ };
18
+ /**
19
+ * The queryable collection returned by `doc0.collect()`. Search uses the engine from `Doc0.create({ search })` (or the
20
+ * default orama engine `Doc0` injects), resolved lazily; with no engine at all it falls back to a naive keyword
21
+ * scorer.
22
+ */
23
+ var DocsCollection = class {
24
+ items;
25
+ byId;
26
+ engineSource;
27
+ engine;
28
+ enginePromise;
29
+ indexed = false;
30
+ constructor(items, engineSource) {
31
+ this.items = items;
32
+ this.byId = new Map(items.map((doc) => [doc.id, doc]));
33
+ this.engineSource = engineSource;
34
+ }
35
+ async resolveEngine() {
36
+ if (this.engine) return this.engine;
37
+ if (this.engineSource === void 0) return;
38
+ this.enginePromise ??= Promise.resolve(typeof this.engineSource === "function" ? this.engineSource() : this.engineSource);
39
+ this.engine = await this.enginePromise;
40
+ return this.engine;
41
+ }
42
+ all() {
43
+ return this.items;
44
+ }
45
+ get(id) {
46
+ return this.byId.get(id);
47
+ }
48
+ byTag(tag) {
49
+ return this.items.filter((doc) => doc.tags.includes(tag));
50
+ }
51
+ byCategory(category) {
52
+ return this.items.filter((doc) => doc.category.includes(category));
53
+ }
54
+ related(id, options) {
55
+ const doc = this.byId.get(id);
56
+ if (!doc) return [];
57
+ return resolveRelated(this, doc.related, {
58
+ required: options?.required,
59
+ order: true
60
+ });
61
+ }
62
+ async search(query, options) {
63
+ const engine = await this.resolveEngine();
64
+ if (engine) {
65
+ if (!this.indexed) {
66
+ await engine.index(this.items);
67
+ this.indexed = true;
68
+ }
69
+ return (await engine.search(query, options)).map((hit) => this.byId.get(hit.id)).filter((doc) => doc !== void 0);
70
+ }
71
+ return this.naiveSearch(query, options);
72
+ }
73
+ toJSON() {
74
+ return JSON.stringify(this.items, null, 2);
75
+ }
76
+ [Symbol.iterator]() {
77
+ return this.items[Symbol.iterator]();
78
+ }
79
+ naiveSearch(query, options) {
80
+ const needle = query.toLowerCase().trim();
81
+ if (!needle) return [];
82
+ const scored = this.items.map((doc) => ({
83
+ doc,
84
+ score: score(doc, needle)
85
+ })).filter((entry) => entry.score > 0).sort((a, b) => b.score - a.score);
86
+ const limit = options?.limit ?? scored.length;
87
+ return scored.slice(0, limit).map((entry) => entry.doc);
88
+ }
89
+ };
90
+ const score = (doc, needle) => {
91
+ let total = 0;
92
+ if (doc.title.toLowerCase().includes(needle)) total += 5;
93
+ if (doc.id.toLowerCase().includes(needle)) total += 4;
94
+ if (doc.description.toLowerCase().includes(needle)) total += 3;
95
+ if (doc.tags.some((tag) => tag.toLowerCase().includes(needle))) total += 2;
96
+ if (doc.content.toLowerCase().includes(needle)) total += 1;
97
+ return total;
98
+ };
99
+ //#endregion
100
+ export { DocsCollection, resolveRelated };
@@ -0,0 +1,29 @@
1
+ import { Doc, Docs } from "./types.js";
2
+ import { DocField } from "./select.js";
3
+
4
+ //#region src/export.d.ts
5
+ /** A built-in export format. Custom formats are supported by passing your own {@link Formatter}. */
6
+ type ExportFormat = 'json' | 'md';
7
+ /** Turns a filtered list of docs into one serialized document. `all` is the full collection (for resolving links). */
8
+ type Formatter = (docs: Doc[], all: Docs, options: ExportOptions) => string;
9
+ type ExportOptions = {
10
+ /** Built-in `json`/`md`, or a custom {@link Formatter}. Default `md`. */format?: ExportFormat | Formatter; /** Only docs with this tag. */
11
+ tag?: string; /** Only docs in this category. */
12
+ category?: string; /** JSON only — project each doc to these fields. */
13
+ fields?: DocField[]; /** Markdown document title (default `Documentation`). */
14
+ title?: string;
15
+ };
16
+ /** The built-in JSON formatter: the raw docs (or a `fields` projection), pretty-printed. */
17
+ declare const formatJson: Formatter;
18
+ /**
19
+ * The built-in Markdown formatter: a title, a table of contents, then each doc with its metadata and body. Docs are
20
+ * grouped under their `category`; when no doc has a category, it degrades to a clean flat list (no empty buckets).
21
+ */
22
+ declare const formatMarkdown: Formatter;
23
+ /**
24
+ * Export a collection to one serialized document — `json` or a rich `md` bundle (or a custom {@link Formatter}),
25
+ * optionally filtered by `tag`/`category`. Powers `doc0 export`, and is reusable from a config's `finalGenerate`.
26
+ */
27
+ declare const exportDocs: (docs: Docs, options?: ExportOptions) => string;
28
+ //#endregion
29
+ export { ExportFormat, ExportOptions, Formatter, exportDocs, formatJson, formatMarkdown };