@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 +21 -0
- package/README.md +272 -0
- package/dist/cache.d.ts +13 -0
- package/dist/cache.js +50 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +136 -0
- package/dist/docs.d.ts +42 -0
- package/dist/docs.js +100 -0
- package/dist/export.d.ts +29 -0
- package/dist/export.js +76 -0
- package/dist/generate.d.ts +7 -0
- package/dist/generate.js +12 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +160 -0
- package/dist/load.d.ts +10 -0
- package/dist/load.js +17 -0
- package/dist/mcp/index.d.ts +13 -0
- package/dist/mcp/index.js +82 -0
- package/dist/mcp/tools.d.ts +39 -0
- package/dist/mcp/tools.js +72 -0
- package/dist/normalize.d.ts +21 -0
- package/dist/normalize.js +25 -0
- package/dist/parsers/code.d.ts +7 -0
- package/dist/parsers/code.js +73 -0
- package/dist/parsers/index.d.ts +11 -0
- package/dist/parsers/index.js +15 -0
- package/dist/parsers/markdown.d.ts +7 -0
- package/dist/parsers/markdown.js +36 -0
- package/dist/search/embedder.d.ts +12 -0
- package/dist/search/embedder.js +30 -0
- package/dist/search/index.d.ts +23 -0
- package/dist/search/index.js +80 -0
- package/dist/select.d.ts +19 -0
- package/dist/select.js +25 -0
- package/dist/types.d.ts +147 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +27 -0
- package/dist/utils.js +74 -0
- package/dist/web/index.d.ts +145 -0
- package/dist/web/index.js +43 -0
- package/dist/web/render.d.ts +12 -0
- package/dist/web/render.js +129 -0
- package/package.json +164 -3
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
|
+
[](https://github.com/devp0nt/doc0/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.npmjs.com/package/@devp0nt/doc0)
|
|
7
|
+
[](./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
|
+
```
|
package/dist/cache.d.ts
ADDED
|
@@ -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 };
|
package/dist/export.d.ts
ADDED
|
@@ -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 };
|