lilmd 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/BENCHMARK.md +106 -0
- package/README.md +113 -0
- package/dist/index.cjs +364 -0
- package/dist/index.d.cts +112 -0
- package/dist/index.d.ts +112 -0
- package/dist/index.js +331 -0
- package/dist/index.js.map +13 -0
- package/dist/mdq.js +735 -0
- package/dist/mdq.js.map +16 -0
- package/package.json +42 -0
package/BENCHMARK.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Benchmark summary
|
|
2
|
+
|
|
3
|
+
Why `mdq` uses a hand-rolled scanner instead of an off-the-shelf markdown
|
|
4
|
+
parser. Numbers captured on Bun 1.3.11 against MDN content (`mdn/content`,
|
|
5
|
+
sparse-checkout of `files/en-us/web/{javascript,css,html,api}`, concatenated
|
|
6
|
+
into fixtures of ~100 KB, ~1 MB, and ~10 MB).
|
|
7
|
+
|
|
8
|
+
## Parse speed (large, 10 MB)
|
|
9
|
+
|
|
10
|
+
| library | positions? | throughput |
|
|
11
|
+
|---|:---:|---:|
|
|
12
|
+
| **scanner (in `src/scan.ts`)** | ✅ | **~180 MB/s** |
|
|
13
|
+
| markdown-it | ✅ | ~26 MB/s |
|
|
14
|
+
| mdast-util-from-markdown | ✅ | ~1 MB/s (skipped — too slow) |
|
|
15
|
+
| marked lexer | ❌ | >90 s on a 1 MB input (unusable) |
|
|
16
|
+
| md4w (WASM) | ❌ | ~42 MB/s, errors on 10 MB JSON output |
|
|
17
|
+
|
|
18
|
+
## End-to-end `mdq read` (10 MB, find a section + slice its body)
|
|
19
|
+
|
|
20
|
+
| strategy | time |
|
|
21
|
+
|---|---:|
|
|
22
|
+
| **scanner** | **55 ms** (IO-bound) |
|
|
23
|
+
| markdown-it | 422 ms (~7.6× slower) |
|
|
24
|
+
| mdast-util-from-markdown | ~60 s (~1000× slower) |
|
|
25
|
+
|
|
26
|
+
All three strategies agree on the matched section and exact body bytes —
|
|
27
|
+
the scanner is correct, not just fast.
|
|
28
|
+
|
|
29
|
+
## CLI cold start
|
|
30
|
+
|
|
31
|
+
| framework | cold start |
|
|
32
|
+
|---|---:|
|
|
33
|
+
| `node:util.parseArgs` (built-in) | ~16 ms |
|
|
34
|
+
| cac | ~16 ms |
|
|
35
|
+
| citty | ~23 ms |
|
|
36
|
+
|
|
37
|
+
## Why the scanner wins
|
|
38
|
+
|
|
39
|
+
`mdq`'s read-path commands (`toc`, `read`, `ls`, `grep`) only need two facts
|
|
40
|
+
from the markdown:
|
|
41
|
+
|
|
42
|
+
1. ATX headings — level, text, line number
|
|
43
|
+
2. Fenced code block boundaries (so `#` inside code doesn't become a heading)
|
|
44
|
+
|
|
45
|
+
Everything else — links, emphasis, tables, footnotes, nested lists, HTML
|
|
46
|
+
blocks — is irrelevant to "list the headings" and "slice the body between
|
|
47
|
+
line N and line M". A full CommonMark parser spends 95% of its budget on
|
|
48
|
+
grammar `mdq` immediately throws away. The scanner skips all of that, runs
|
|
49
|
+
in a single pass over character codes, and is IO-bound on 10 MB of prose.
|
|
50
|
+
|
|
51
|
+
## The final stack
|
|
52
|
+
|
|
53
|
+
- **Parsing**: hand-rolled scanner in `src/scan.ts`. Zero dependencies.
|
|
54
|
+
- **CLI**: `node:util.parseArgs` + a ~20-line subcommand switch. Zero
|
|
55
|
+
dependencies.
|
|
56
|
+
- **Future write-path commands** (`set`, `insert`, `mv`, `links`, `code`)
|
|
57
|
+
may add `markdown-it` as the only runtime dep when they land — it's the
|
|
58
|
+
only position-preserving parser that scales.
|
|
59
|
+
- **Rejected**: `mdast-util-from-markdown` (25× slower than markdown-it
|
|
60
|
+
despite wrapping the same micromark tokenizer), `marked` (catastrophic
|
|
61
|
+
regex backtracking on prose), `md4w` (no source positions + JSON
|
|
62
|
+
marshaller bug at 10 MB), `citty` (~45% slower cold start than the
|
|
63
|
+
built-in for no meaningful feature we need), `cac` (same cold-start
|
|
64
|
+
class as built-in but adds a dep).
|
|
65
|
+
|
|
66
|
+
## Reproducing
|
|
67
|
+
|
|
68
|
+
The raw benchmark scripts were removed to keep the repo minimal. To rerun
|
|
69
|
+
them, check out an earlier commit on this branch (look for `dev/bench/` in
|
|
70
|
+
git history) or rewrite them against the methodology above:
|
|
71
|
+
|
|
72
|
+
- Small/medium/large fixtures built by concatenating MDN markdown files.
|
|
73
|
+
- Per-(library, fixture) wall budgets of 4–8 s with hard iteration caps, so
|
|
74
|
+
a pathological parser (we're looking at you, marked) can't hang the run.
|
|
75
|
+
- Trimmed mean of the fastest 50% of iterations per combo.
|
|
76
|
+
- Full-throughput results written incrementally so a timeout still yields
|
|
77
|
+
partial data.
|
|
78
|
+
|
|
79
|
+
## Integration-test fixture
|
|
80
|
+
|
|
81
|
+
`src/__fixtures__/mdn-array.md` is a tiny (~42 KB, 1,298 lines, 112
|
|
82
|
+
headings) fixture committed into the repo and exercised by
|
|
83
|
+
`src/integration.test.ts`. It's a concatenation of 8 MDN
|
|
84
|
+
`Array.prototype.*` reference pages hoisted under synthetic H1 wrappers.
|
|
85
|
+
Small enough to commit, big enough to catch regressions the synthetic unit
|
|
86
|
+
fixtures can miss (Kuma macros, JSX-flavored HTML, tables, real fenced
|
|
87
|
+
code, nested lists).
|
|
88
|
+
|
|
89
|
+
Licensed CC BY-SA 2.5, © Mozilla Contributors. Regenerate with:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# 1. Sparse-clone the MDN Array docs (no blobs, no tree outside array/)
|
|
93
|
+
git clone --depth 1 --filter=blob:none --sparse \
|
|
94
|
+
https://github.com/mdn/content.git /tmp/mdn
|
|
95
|
+
cd /tmp/mdn
|
|
96
|
+
git sparse-checkout set \
|
|
97
|
+
files/en-us/web/javascript/reference/global_objects/array
|
|
98
|
+
|
|
99
|
+
# 2. Concatenate 8 method pages under synthetic H1s
|
|
100
|
+
# (see the fixture's own header comment for the exact list)
|
|
101
|
+
# 3. Prepend the attribution block from the existing fixture header
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
If you change the file list or the synthetic wrappers, update the relevant
|
|
105
|
+
assertions in `src/integration.test.ts` — a couple of them pin exact counts
|
|
106
|
+
("8 matches, showing first 3") that are tied to the 8-page choice.
|
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
## `mdq` - Markdown as a Database for Agents
|
|
2
|
+
`mdq` is a CLI for working with large MD files designed for agents.
|
|
3
|
+
|
|
4
|
+
*Wait, but why?* Agent knowledge, docs, memory keeps growing.
|
|
5
|
+
MDQ allows you to dump it all in one file and effeciently read/write/navigate its contents.
|
|
6
|
+
|
|
7
|
+
Features:
|
|
8
|
+
- fast navigation, complex read selectors, link extraction
|
|
9
|
+
- complex section selectors
|
|
10
|
+
- designed to save as much context as possible
|
|
11
|
+
- can write, append, remove entire sections
|
|
12
|
+
- can run in Node/Bun
|
|
13
|
+
- optimized for speed
|
|
14
|
+
- can be used by humans and **agents**
|
|
15
|
+
- uses Bun as tooling: to test, control deps etc.
|
|
16
|
+
|
|
17
|
+
### Help
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# start here!
|
|
21
|
+
# both commands print short documentation for the agent
|
|
22
|
+
> mdq
|
|
23
|
+
> mdq --help
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Overview & table of contents
|
|
27
|
+
|
|
28
|
+
First, the agent gets file overview and table of contents.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# renders toc + stats; line ranges are inclusive, 1-indexed
|
|
32
|
+
# --depth=N to limit nesting, --flat for a flat list
|
|
33
|
+
> mdq file.md
|
|
34
|
+
|
|
35
|
+
file.md L1-450 12 headings
|
|
36
|
+
# MDQ L1-450
|
|
37
|
+
## Getting Started L5-80
|
|
38
|
+
### Installation L31-80
|
|
39
|
+
## Community L301-450
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Reading sections
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
> mdq read file.md "# MDQ"
|
|
46
|
+
> mdq file.md "# MDQ" # alias!
|
|
47
|
+
# prints the contents of the MDQ section
|
|
48
|
+
|
|
49
|
+
# descendant selector (any depth under the parent)
|
|
50
|
+
> mdq file.md "MDQ > Installation"
|
|
51
|
+
|
|
52
|
+
# direct child only
|
|
53
|
+
> mdq file.md "MDQ >> Installation"
|
|
54
|
+
|
|
55
|
+
# level filter (H2 only)
|
|
56
|
+
> mdq file.md "##Installation"
|
|
57
|
+
|
|
58
|
+
# exact match (default is fuzzy, case-insensitive)
|
|
59
|
+
> mdq file.md "=Installation"
|
|
60
|
+
|
|
61
|
+
# regex
|
|
62
|
+
> mdq file.md "/install(ation)?/"
|
|
63
|
+
|
|
64
|
+
# by default no more than 25 matches are printed; if more, mdq prints a hint
|
|
65
|
+
# about --max-results=N
|
|
66
|
+
# --max-lines=N truncates long bodies (shows "… N more lines")
|
|
67
|
+
# --body-only skips subsections, --no-body prints headings only
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### For humans only
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# --pretty renders the section body as syntax-highlighted terminal markdown
|
|
74
|
+
# (for humans; piped output stays plain unless FORCE_COLOR is set)
|
|
75
|
+
> mdq file.md --pretty "Installation"
|
|
76
|
+
|
|
77
|
+
# nicely formatted markdown
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Searching & extracting
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
> mdq ls file.md "Getting Started" # direct children of a section
|
|
84
|
+
> mdq grep file.md "pattern" # regex search, grouped by section
|
|
85
|
+
> mdq links file.md ["selector"] # extract links with section path
|
|
86
|
+
> mdq code file.md "Install" [--lang=ts] # extract code blocks
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Writing
|
|
90
|
+
|
|
91
|
+
`mdq` treats sections as addressable records: you can replace, append,
|
|
92
|
+
insert, move, or rename them without rewriting the whole file. Every write
|
|
93
|
+
supports `--dry-run`, which prints a unified diff instead of touching disk —
|
|
94
|
+
perfect for agent-authored edits that a human (or another agent) reviews
|
|
95
|
+
before applying.
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
> mdq set file.md "Install" < body.md # replace section body
|
|
99
|
+
> mdq append file.md "Install" < body.md
|
|
100
|
+
> mdq insert file.md --after "Install" < new.md
|
|
101
|
+
> mdq rm file.md "Old"
|
|
102
|
+
> mdq mv file.md "From" "To" # re-parent, fixes heading levels
|
|
103
|
+
> mdq rename file.md "Old" "New"
|
|
104
|
+
> mdq promote|demote file.md "Section" # shift heading level ±1
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Output
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
# human-readable by default; --json for machine output
|
|
111
|
+
# use - as filename to read from stdin
|
|
112
|
+
> cat big.md | mdq - "Install"
|
|
113
|
+
```
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
var import_node_module = require("node:module");
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __moduleCache = /* @__PURE__ */ new WeakMap;
|
|
7
|
+
var __toCommonJS = (from) => {
|
|
8
|
+
var entry = __moduleCache.get(from), desc;
|
|
9
|
+
if (entry)
|
|
10
|
+
return entry;
|
|
11
|
+
entry = __defProp({}, "__esModule", { value: true });
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function")
|
|
13
|
+
__getOwnPropNames(from).map((key) => !__hasOwnProp.call(entry, key) && __defProp(entry, key, {
|
|
14
|
+
get: () => from[key],
|
|
15
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
16
|
+
}));
|
|
17
|
+
__moduleCache.set(from, entry);
|
|
18
|
+
return entry;
|
|
19
|
+
};
|
|
20
|
+
var __export = (target, all) => {
|
|
21
|
+
for (var name in all)
|
|
22
|
+
__defProp(target, name, {
|
|
23
|
+
get: all[name],
|
|
24
|
+
enumerable: true,
|
|
25
|
+
configurable: true,
|
|
26
|
+
set: (newValue) => all[name] = () => newValue
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var exports_src = {};
|
|
32
|
+
__export(exports_src, {
|
|
33
|
+
truncateBody: () => truncateBody,
|
|
34
|
+
scan: () => scan,
|
|
35
|
+
renderToc: () => renderToc,
|
|
36
|
+
renderSection: () => renderSection,
|
|
37
|
+
pathOf: () => pathOf,
|
|
38
|
+
parseSelector: () => parseSelector,
|
|
39
|
+
match: () => match,
|
|
40
|
+
countLines: () => countLines,
|
|
41
|
+
buildSections: () => buildSections
|
|
42
|
+
});
|
|
43
|
+
module.exports = __toCommonJS(exports_src);
|
|
44
|
+
|
|
45
|
+
// src/scan.ts
|
|
46
|
+
function scan(src) {
|
|
47
|
+
const out = [];
|
|
48
|
+
const len = src.length;
|
|
49
|
+
let i = 0;
|
|
50
|
+
let lineNo = 0;
|
|
51
|
+
let inFence = false;
|
|
52
|
+
let fenceChar = 0;
|
|
53
|
+
let fenceLen = 0;
|
|
54
|
+
while (i <= len) {
|
|
55
|
+
const start = i;
|
|
56
|
+
while (i < len && src.charCodeAt(i) !== 10)
|
|
57
|
+
i++;
|
|
58
|
+
let line = src.slice(start, i);
|
|
59
|
+
if (line.length > 0 && line.charCodeAt(line.length - 1) === 13) {
|
|
60
|
+
line = line.slice(0, line.length - 1);
|
|
61
|
+
}
|
|
62
|
+
lineNo++;
|
|
63
|
+
const fence = matchFence(line);
|
|
64
|
+
if (fence) {
|
|
65
|
+
if (!inFence) {
|
|
66
|
+
inFence = true;
|
|
67
|
+
fenceChar = fence.char;
|
|
68
|
+
fenceLen = fence.len;
|
|
69
|
+
} else if (fence.char === fenceChar && fence.len >= fenceLen) {
|
|
70
|
+
inFence = false;
|
|
71
|
+
}
|
|
72
|
+
} else if (!inFence) {
|
|
73
|
+
const h = matchHeading(line, lineNo);
|
|
74
|
+
if (h)
|
|
75
|
+
out.push(h);
|
|
76
|
+
}
|
|
77
|
+
if (i >= len)
|
|
78
|
+
break;
|
|
79
|
+
i++;
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
function matchFence(line) {
|
|
84
|
+
let p = 0;
|
|
85
|
+
while (p < 3 && line.charCodeAt(p) === 32)
|
|
86
|
+
p++;
|
|
87
|
+
const ch = line.charCodeAt(p);
|
|
88
|
+
if (ch !== 96 && ch !== 126)
|
|
89
|
+
return null;
|
|
90
|
+
let run = 0;
|
|
91
|
+
while (line.charCodeAt(p + run) === ch)
|
|
92
|
+
run++;
|
|
93
|
+
if (run < 3)
|
|
94
|
+
return null;
|
|
95
|
+
return { char: ch, len: run };
|
|
96
|
+
}
|
|
97
|
+
function matchHeading(line, lineNo) {
|
|
98
|
+
let p = 0;
|
|
99
|
+
while (p < 3 && line.charCodeAt(p) === 32)
|
|
100
|
+
p++;
|
|
101
|
+
if (line.charCodeAt(p) !== 35)
|
|
102
|
+
return null;
|
|
103
|
+
let hashes = 0;
|
|
104
|
+
while (line.charCodeAt(p + hashes) === 35)
|
|
105
|
+
hashes++;
|
|
106
|
+
if (hashes < 1 || hashes > 6)
|
|
107
|
+
return null;
|
|
108
|
+
const after = p + hashes;
|
|
109
|
+
const afterCh = line.charCodeAt(after);
|
|
110
|
+
if (after < line.length && afterCh !== 32 && afterCh !== 9) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
let contentStart = after;
|
|
114
|
+
while (contentStart < line.length && (line.charCodeAt(contentStart) === 32 || line.charCodeAt(contentStart) === 9)) {
|
|
115
|
+
contentStart++;
|
|
116
|
+
}
|
|
117
|
+
let end = line.length;
|
|
118
|
+
while (end > contentStart && (line.charCodeAt(end - 1) === 32 || line.charCodeAt(end - 1) === 9)) {
|
|
119
|
+
end--;
|
|
120
|
+
}
|
|
121
|
+
let closing = end;
|
|
122
|
+
while (closing > contentStart && line.charCodeAt(closing - 1) === 35)
|
|
123
|
+
closing--;
|
|
124
|
+
if (closing < end && (closing === contentStart || line.charCodeAt(closing - 1) === 32 || line.charCodeAt(closing - 1) === 9)) {
|
|
125
|
+
end = closing;
|
|
126
|
+
while (end > contentStart && (line.charCodeAt(end - 1) === 32 || line.charCodeAt(end - 1) === 9)) {
|
|
127
|
+
end--;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const title = line.slice(contentStart, end);
|
|
131
|
+
return { level: hashes, title, line: lineNo };
|
|
132
|
+
}
|
|
133
|
+
// src/sections.ts
|
|
134
|
+
function buildSections(headings, totalLines) {
|
|
135
|
+
const out = [];
|
|
136
|
+
const stack = [];
|
|
137
|
+
for (const h of headings) {
|
|
138
|
+
while (stack.length > 0 && stack[stack.length - 1].level >= h.level) {
|
|
139
|
+
const closing = stack.pop();
|
|
140
|
+
closing.line_end = h.line - 1;
|
|
141
|
+
}
|
|
142
|
+
const parent = stack.length > 0 ? stack[stack.length - 1] : null;
|
|
143
|
+
const sec = {
|
|
144
|
+
level: h.level,
|
|
145
|
+
title: h.title,
|
|
146
|
+
line_start: h.line,
|
|
147
|
+
line_end: totalLines,
|
|
148
|
+
parent
|
|
149
|
+
};
|
|
150
|
+
out.push(sec);
|
|
151
|
+
stack.push(sec);
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
function pathOf(sec) {
|
|
156
|
+
const path = [];
|
|
157
|
+
let cur = sec.parent;
|
|
158
|
+
while (cur) {
|
|
159
|
+
path.push(cur.title);
|
|
160
|
+
cur = cur.parent;
|
|
161
|
+
}
|
|
162
|
+
return path.reverse();
|
|
163
|
+
}
|
|
164
|
+
function countLines(src) {
|
|
165
|
+
if (src.length === 0)
|
|
166
|
+
return 0;
|
|
167
|
+
let n = 1;
|
|
168
|
+
for (let i = 0;i < src.length; i++) {
|
|
169
|
+
if (src.charCodeAt(i) === 10)
|
|
170
|
+
n++;
|
|
171
|
+
}
|
|
172
|
+
if (src.charCodeAt(src.length - 1) === 10)
|
|
173
|
+
n--;
|
|
174
|
+
return n;
|
|
175
|
+
}
|
|
176
|
+
// src/select.ts
|
|
177
|
+
function parseSelector(input) {
|
|
178
|
+
const trimmed = input.trim();
|
|
179
|
+
if (trimmed.length === 0)
|
|
180
|
+
return [];
|
|
181
|
+
const rawSegments = [];
|
|
182
|
+
const ops = ["descendant"];
|
|
183
|
+
let cur = "";
|
|
184
|
+
let i = 0;
|
|
185
|
+
let inRegex = false;
|
|
186
|
+
let atSegmentStart = true;
|
|
187
|
+
while (i < trimmed.length) {
|
|
188
|
+
const ch = trimmed[i];
|
|
189
|
+
if (ch === "/" && (atSegmentStart || inRegex)) {
|
|
190
|
+
inRegex = !inRegex;
|
|
191
|
+
cur += ch;
|
|
192
|
+
atSegmentStart = false;
|
|
193
|
+
i++;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (!inRegex && ch === ">") {
|
|
197
|
+
rawSegments.push(cur.trim());
|
|
198
|
+
cur = "";
|
|
199
|
+
atSegmentStart = true;
|
|
200
|
+
if (trimmed[i + 1] === ">") {
|
|
201
|
+
ops.push("child");
|
|
202
|
+
i += 2;
|
|
203
|
+
} else {
|
|
204
|
+
ops.push("descendant");
|
|
205
|
+
i += 1;
|
|
206
|
+
}
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
cur += ch;
|
|
210
|
+
if (ch !== " " && ch !== "\t")
|
|
211
|
+
atSegmentStart = false;
|
|
212
|
+
i++;
|
|
213
|
+
}
|
|
214
|
+
rawSegments.push(cur.trim());
|
|
215
|
+
return rawSegments.map((s, idx) => parseSegment(s, ops[idx] ?? "descendant"));
|
|
216
|
+
}
|
|
217
|
+
function parseSegment(raw, op) {
|
|
218
|
+
let s = raw;
|
|
219
|
+
let level = null;
|
|
220
|
+
const levelMatch = /^(#{1,6})(?!#)\s*(.*)$/.exec(s);
|
|
221
|
+
if (levelMatch) {
|
|
222
|
+
level = levelMatch[1].length;
|
|
223
|
+
s = levelMatch[2] ?? "";
|
|
224
|
+
}
|
|
225
|
+
const regexMatch = /^\/(.+)\/([gimsuy]*)$/.exec(s);
|
|
226
|
+
if (regexMatch) {
|
|
227
|
+
const pattern = regexMatch[1];
|
|
228
|
+
const flags = regexMatch[2] || "i";
|
|
229
|
+
return {
|
|
230
|
+
op,
|
|
231
|
+
level,
|
|
232
|
+
kind: "regex",
|
|
233
|
+
value: pattern,
|
|
234
|
+
regex: new RegExp(pattern, flags)
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
if (s.startsWith("=")) {
|
|
238
|
+
return { op, level, kind: "exact", value: s.slice(1).trim() };
|
|
239
|
+
}
|
|
240
|
+
return { op, level, kind: "fuzzy", value: s.trim() };
|
|
241
|
+
}
|
|
242
|
+
function match(sections, selector) {
|
|
243
|
+
if (selector.length === 0)
|
|
244
|
+
return [];
|
|
245
|
+
const out = [];
|
|
246
|
+
for (const sec of sections) {
|
|
247
|
+
if (matches(sec, selector))
|
|
248
|
+
out.push(sec);
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
function matches(sec, segs) {
|
|
253
|
+
const last = segs[segs.length - 1];
|
|
254
|
+
if (!last || !segmentMatchesSection(last, sec))
|
|
255
|
+
return false;
|
|
256
|
+
let cursor = sec.parent;
|
|
257
|
+
for (let i = segs.length - 2;i >= 0; i--) {
|
|
258
|
+
const op = segs[i + 1].op;
|
|
259
|
+
const seg = segs[i];
|
|
260
|
+
if (op === "child") {
|
|
261
|
+
if (!cursor || !segmentMatchesSection(seg, cursor))
|
|
262
|
+
return false;
|
|
263
|
+
cursor = cursor.parent;
|
|
264
|
+
} else {
|
|
265
|
+
let found = null;
|
|
266
|
+
while (cursor) {
|
|
267
|
+
if (segmentMatchesSection(seg, cursor)) {
|
|
268
|
+
found = cursor;
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
cursor = cursor.parent;
|
|
272
|
+
}
|
|
273
|
+
if (!found)
|
|
274
|
+
return false;
|
|
275
|
+
cursor = found.parent;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
function segmentMatchesSection(seg, sec) {
|
|
281
|
+
if (seg.level !== null && seg.level !== sec.level)
|
|
282
|
+
return false;
|
|
283
|
+
const title = sec.title;
|
|
284
|
+
switch (seg.kind) {
|
|
285
|
+
case "exact":
|
|
286
|
+
return title.toLowerCase() === seg.value.toLowerCase();
|
|
287
|
+
case "regex":
|
|
288
|
+
return seg.regex.test(title);
|
|
289
|
+
case "fuzzy":
|
|
290
|
+
return title.toLowerCase().includes(seg.value.toLowerCase());
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// src/render.ts
|
|
294
|
+
function renderToc(file, src, sections, opts) {
|
|
295
|
+
const totalLines = countLines(src);
|
|
296
|
+
const headerCount = sections.length;
|
|
297
|
+
const headerRange = totalLines === 0 ? "L0" : `L1-${totalLines}`;
|
|
298
|
+
const plural = headerCount === 1 ? "heading" : "headings";
|
|
299
|
+
const out = [];
|
|
300
|
+
out.push(`${file} ${headerRange} ${headerCount} ${plural}`);
|
|
301
|
+
for (const sec of sections) {
|
|
302
|
+
if (opts.depth != null && sec.level > opts.depth)
|
|
303
|
+
continue;
|
|
304
|
+
const indent = opts.flat ? "" : " ".repeat(Math.max(0, sec.level - 1));
|
|
305
|
+
const hashes = "#".repeat(sec.level);
|
|
306
|
+
const range = `L${sec.line_start}-${sec.line_end}`;
|
|
307
|
+
out.push(`${indent}${hashes} ${sec.title} ${range}`);
|
|
308
|
+
}
|
|
309
|
+
return out.join(`
|
|
310
|
+
`);
|
|
311
|
+
}
|
|
312
|
+
function renderSection(file, srcLines, sec, opts) {
|
|
313
|
+
const start = sec.line_start;
|
|
314
|
+
let end = sec.line_end;
|
|
315
|
+
if (opts.bodyOnly && opts.allSections) {
|
|
316
|
+
const firstChild = findFirstChild(sec, opts.allSections);
|
|
317
|
+
if (firstChild)
|
|
318
|
+
end = firstChild.line_start - 1;
|
|
319
|
+
}
|
|
320
|
+
if (opts.noBody) {
|
|
321
|
+
end = start;
|
|
322
|
+
}
|
|
323
|
+
const clampedEnd = Math.min(end, srcLines.length);
|
|
324
|
+
let body = srcLines.slice(start - 1, clampedEnd).join(`
|
|
325
|
+
`);
|
|
326
|
+
if (opts.maxLines != null && opts.maxLines > 0) {
|
|
327
|
+
body = truncateBody(body, opts.maxLines);
|
|
328
|
+
}
|
|
329
|
+
if (opts.pretty) {
|
|
330
|
+
body = opts.pretty(body);
|
|
331
|
+
}
|
|
332
|
+
if (opts.raw)
|
|
333
|
+
return body;
|
|
334
|
+
const hashes = "#".repeat(sec.level);
|
|
335
|
+
const header = `── ${file} L${start}-${end} ${hashes} ${sec.title} ${"─".repeat(8)}`;
|
|
336
|
+
const footer = `── end ${"─".repeat(40)}`;
|
|
337
|
+
return `${header}
|
|
338
|
+
${body}
|
|
339
|
+
${footer}`;
|
|
340
|
+
}
|
|
341
|
+
function truncateBody(body, maxLines) {
|
|
342
|
+
if (maxLines <= 0)
|
|
343
|
+
return body;
|
|
344
|
+
const lines = body.split(`
|
|
345
|
+
`);
|
|
346
|
+
if (lines.length <= maxLines)
|
|
347
|
+
return body;
|
|
348
|
+
const kept = lines.slice(0, maxLines).join(`
|
|
349
|
+
`);
|
|
350
|
+
const remaining = lines.length - maxLines;
|
|
351
|
+
return `${kept}
|
|
352
|
+
|
|
353
|
+
… ${remaining} more lines (use --max-lines=0 for full)`;
|
|
354
|
+
}
|
|
355
|
+
function findFirstChild(sec, all) {
|
|
356
|
+
for (const candidate of all) {
|
|
357
|
+
if (candidate.parent === sec)
|
|
358
|
+
return candidate;
|
|
359
|
+
}
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
//# debugId=F78549B744E4995264756E2164756E21
|
|
364
|
+
//# sourceMappingURL=index.js.map
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown heading scanner — the engine behind every read-path command.
|
|
3
|
+
*
|
|
4
|
+
* Instead of building a full CommonMark AST we walk the source line by line
|
|
5
|
+
* and recognize only what `mdq` actually needs: ATX headings and fenced code
|
|
6
|
+
* blocks (so `#` inside code doesn't count as a heading).
|
|
7
|
+
*
|
|
8
|
+
* Numbers on MDN content (see BENCHMARK.md): ~180 MB/s end-to-end on a
|
|
9
|
+
* 10 MB fixture, roughly 7x faster than markdown-it and ~1000x faster than
|
|
10
|
+
* mdast-util-from-markdown while returning the exact same section.
|
|
11
|
+
*
|
|
12
|
+
* Deliberate limitations:
|
|
13
|
+
* - Setext headings (`===` / `---` underlines) are NOT recognized. mdq is
|
|
14
|
+
* aimed at agent-authored markdown where ATX is ubiquitous.
|
|
15
|
+
* - HTML blocks are not detected. A `<pre>` containing an ATX-looking line
|
|
16
|
+
* would be misread as a heading. That's an acceptable tradeoff for 100x
|
|
17
|
+
* speed; a future `--strict` flag could hand off to markdown-it.
|
|
18
|
+
* - Fenced code blocks *inside a list item* that are indented 4+ spaces are
|
|
19
|
+
* not recognized as fences — we only look at the first 3 columns for the
|
|
20
|
+
* fence opener. A `# fake` line inside such a block would be scanned as a
|
|
21
|
+
* heading. Rare in practice; document-your-way-out rather than fix.
|
|
22
|
+
* - An unclosed fence at EOF leaves the scanner in "still in fence" state
|
|
23
|
+
* to the end of the file, so any `#`-looking lines after it are ignored.
|
|
24
|
+
* That's the conservative choice — prefer under-counting to over-counting.
|
|
25
|
+
*/
|
|
26
|
+
type Heading = {
|
|
27
|
+
/** 1..6 */
|
|
28
|
+
level: number;
|
|
29
|
+
/** Heading text with trailing closing hashes stripped. */
|
|
30
|
+
title: string;
|
|
31
|
+
/** 1-indexed line number. */
|
|
32
|
+
line: number;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Return every ATX heading in `src`, in document order.
|
|
36
|
+
* Runs in a single pass; O(n) in source length, O(headings) in space.
|
|
37
|
+
*/
|
|
38
|
+
declare function scan(src: string): Heading[];
|
|
39
|
+
type Section = {
|
|
40
|
+
level: number;
|
|
41
|
+
title: string;
|
|
42
|
+
/** 1-indexed line of the heading itself. */
|
|
43
|
+
line_start: number;
|
|
44
|
+
/** 1-indexed inclusive end of the subtree. */
|
|
45
|
+
line_end: number;
|
|
46
|
+
/** Nearest enclosing section, or null for top-level. */
|
|
47
|
+
parent: Section | null;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Build the section tree in a single pass. Preserves document order.
|
|
51
|
+
*
|
|
52
|
+
* Runs in O(n): every section is pushed once and popped once, and we set
|
|
53
|
+
* its `line_end` at pop time. Sections still on the stack when we run out
|
|
54
|
+
* of headings keep their provisional `line_end = totalLines`.
|
|
55
|
+
*/
|
|
56
|
+
declare function buildSections(headings: Heading[], totalLines: number): Section[];
|
|
57
|
+
/**
|
|
58
|
+
* Walk `sec` up to the root, collecting ancestor titles in top-down order.
|
|
59
|
+
* Returns [] for a root section.
|
|
60
|
+
*/
|
|
61
|
+
declare function pathOf(sec: Section): string[];
|
|
62
|
+
/**
|
|
63
|
+
* Count lines in a source string. Empty string is 0; otherwise every line
|
|
64
|
+
* (including the last one, whether or not it ends with a newline) is 1.
|
|
65
|
+
* A trailing newline does NOT add a phantom line.
|
|
66
|
+
*/
|
|
67
|
+
declare function countLines(src: string): number;
|
|
68
|
+
type Op = "descendant" | "child";
|
|
69
|
+
type Kind = "fuzzy" | "exact" | "regex";
|
|
70
|
+
type Segment = {
|
|
71
|
+
/** Operator that connects this segment to the *previous* one.
|
|
72
|
+
* For the first segment this is always "descendant" (unused). */
|
|
73
|
+
op: Op;
|
|
74
|
+
/** Optional 1..6 level filter. */
|
|
75
|
+
level: number | null;
|
|
76
|
+
kind: Kind;
|
|
77
|
+
/** The raw value (without level/kind prefix). */
|
|
78
|
+
value: string;
|
|
79
|
+
/** Present only for kind === "regex". */
|
|
80
|
+
regex?: RegExp;
|
|
81
|
+
};
|
|
82
|
+
declare function parseSelector(input: string): Segment[];
|
|
83
|
+
declare function match(sections: Section[], selector: Segment[]): Section[];
|
|
84
|
+
/**
|
|
85
|
+
* Pretty printing for `mdq read --pretty`. Lazy-loads marked +
|
|
86
|
+
* marked-terminal on first use so the default (plain-text) path keeps its
|
|
87
|
+
* ~16ms cold start.
|
|
88
|
+
*/
|
|
89
|
+
type PrettyFormatter = (markdown: string) => string;
|
|
90
|
+
type TocOptions = {
|
|
91
|
+
depth?: number;
|
|
92
|
+
flat?: boolean;
|
|
93
|
+
};
|
|
94
|
+
declare function renderToc(file: string, src: string, sections: Section[], opts: TocOptions): string;
|
|
95
|
+
type SectionOptions = {
|
|
96
|
+
bodyOnly?: boolean;
|
|
97
|
+
noBody?: boolean;
|
|
98
|
+
raw?: boolean;
|
|
99
|
+
maxLines?: number;
|
|
100
|
+
/** Required when bodyOnly is true so we can find the first child. */
|
|
101
|
+
allSections?: Section[];
|
|
102
|
+
/** Optional markdown→ANSI formatter applied to the body before delimiters. */
|
|
103
|
+
pretty?: PrettyFormatter;
|
|
104
|
+
};
|
|
105
|
+
declare function renderSection(file: string, srcLines: string[], sec: Section, opts: SectionOptions): string;
|
|
106
|
+
/**
|
|
107
|
+
* Cut `body` to the first `maxLines` lines. If anything was dropped, append
|
|
108
|
+
* a marker line telling the agent how to get the rest. `maxLines <= 0`
|
|
109
|
+
* disables truncation.
|
|
110
|
+
*/
|
|
111
|
+
declare function truncateBody(body: string, maxLines: number): string;
|
|
112
|
+
export { truncateBody, scan, renderToc, renderSection, pathOf, parseSelector, match, countLines, buildSections, TocOptions, Segment, SectionOptions, Section, Op, Kind, Heading };
|