docsgov 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/README.md +242 -0
- package/dist/apispec/apispec.js +401 -0
- package/dist/apispec/apispec.test.js +444 -0
- package/dist/apispec/errors.js +17 -0
- package/dist/apispec/index.js +2 -0
- package/dist/check/doclinks.js +167 -0
- package/dist/check/index.js +8 -0
- package/dist/check/run.js +391 -0
- package/dist/check/run.test.js +513 -0
- package/dist/check/suggest.js +134 -0
- package/dist/check/suggest.test.js +92 -0
- package/dist/check/tokens.js +125 -0
- package/dist/cmd/main.js +330 -0
- package/dist/cmd/main.test.js +422 -0
- package/dist/codeq/cache.js +71 -0
- package/dist/codeq/cache.test.js +67 -0
- package/dist/codeq/errors.js +52 -0
- package/dist/codeq/grammars/tree-sitter-go.wasm +0 -0
- package/dist/codeq/grammars/tree-sitter-java.wasm +0 -0
- package/dist/codeq/grammars/tree-sitter-javascript.wasm +0 -0
- package/dist/codeq/grammars/tree-sitter-tsx.wasm +0 -0
- package/dist/codeq/grammars/tree-sitter-typescript.wasm +0 -0
- package/dist/codeq/index.js +11 -0
- package/dist/codeq/resolve.test.js +109 -0
- package/dist/codeq/resolver.js +128 -0
- package/dist/codeq/resolver.test.js +124 -0
- package/dist/codeq/resolvers/go.js +242 -0
- package/dist/codeq/resolvers/go.test.js +143 -0
- package/dist/codeq/resolvers/java.js +349 -0
- package/dist/codeq/resolvers/java.test.js +138 -0
- package/dist/codeq/resolvers/java_queries.js +63 -0
- package/dist/codeq/resolvers/javascript.js +412 -0
- package/dist/codeq/resolvers/javascript.test.js +125 -0
- package/dist/codeq/resolvers/javascript_queries.js +46 -0
- package/dist/codeq/resolvers/typescript.js +366 -0
- package/dist/codeq/resolvers/typescript.test.js +180 -0
- package/dist/codeq/resolvers/typescript_queries.js +78 -0
- package/dist/codeq/signature.js +50 -0
- package/dist/codeq/signature.test.js +50 -0
- package/dist/codeq/suggest.js +96 -0
- package/dist/codeq/treesitter.js +122 -0
- package/dist/codeq/treesitter.test.js +118 -0
- package/dist/config/config.js +74 -0
- package/dist/config/config.test.js +98 -0
- package/dist/config/fs.js +116 -0
- package/dist/config/glob.js +82 -0
- package/dist/config/glob.test.js +61 -0
- package/dist/config/index.js +4 -0
- package/dist/dedup/analyzer/analyzer.js +533 -0
- package/dist/dedup/analyzer/analyzer.test.js +530 -0
- package/dist/dedup/analyzer/canonical.js +74 -0
- package/dist/dedup/analyzer/canonical.test.js +70 -0
- package/dist/dedup/analyzer/cosine_clusters.js +169 -0
- package/dist/dedup/analyzer/cosine_clusters.test.js +131 -0
- package/dist/dedup/analyzer/distinctive.js +85 -0
- package/dist/dedup/analyzer/distinctive.test.js +49 -0
- package/dist/dedup/analyzer/exact_clusters.js +63 -0
- package/dist/dedup/analyzer/exact_clusters.test.js +81 -0
- package/dist/dedup/analyzer/index.js +14 -0
- package/dist/dedup/analyzer/multiplicity.js +110 -0
- package/dist/dedup/analyzer/multiplicity.test.js +123 -0
- package/dist/dedup/analyzer/order.js +22 -0
- package/dist/dedup/analyzer/partial_overlaps.js +65 -0
- package/dist/dedup/analyzer/partial_overlaps.test.js +161 -0
- package/dist/dedup/analyzer/preview.js +84 -0
- package/dist/dedup/analyzer/preview.test.js +46 -0
- package/dist/dedup/analyzer/safety.js +27 -0
- package/dist/dedup/analyzer/safety.test.js +39 -0
- package/dist/dedup/config.js +18 -0
- package/dist/dedup/configload.js +299 -0
- package/dist/dedup/configload.test.js +410 -0
- package/dist/dedup/dedup.index.test.js +203 -0
- package/dist/dedup/dedup.js +143 -0
- package/dist/dedup/dedup.test.js +212 -0
- package/dist/dedup/dedupcfg/config.js +112 -0
- package/dist/dedup/dedupcfg/config.test.js +70 -0
- package/dist/dedup/dedupcfg/index.js +1 -0
- package/dist/dedup/deduptypes/index.js +1 -0
- package/dist/dedup/deduptypes/types.js +9 -0
- package/dist/dedup/deduptypes/types.test.js +34 -0
- package/dist/dedup/embedder/cache.js +23 -0
- package/dist/dedup/embedder/cache.test.js +50 -0
- package/dist/dedup/embedder/constants.js +10 -0
- package/dist/dedup/embedder/embedder.js +76 -0
- package/dist/dedup/embedder/embedder.mock.test.js +128 -0
- package/dist/dedup/embedder/embedder.test.js +96 -0
- package/dist/dedup/embedder/errors.js +20 -0
- package/dist/dedup/embedder/errors.test.js +35 -0
- package/dist/dedup/embedder/index.js +4 -0
- package/dist/dedup/embedder/session.js +78 -0
- package/dist/dedup/embedder/session.test.js +172 -0
- package/dist/dedup/gitignore.js +97 -0
- package/dist/dedup/gitignore.test.js +98 -0
- package/dist/dedup/index.js +11 -0
- package/dist/dedup/indexdb/errors.js +48 -0
- package/dist/dedup/indexdb/index.js +6 -0
- package/dist/dedup/indexdb/indexdb.js +302 -0
- package/dist/dedup/indexdb/indexdb.test.js +739 -0
- package/dist/dedup/indexdb/load.js +110 -0
- package/dist/dedup/indexdb/migrations.js +58 -0
- package/dist/dedup/indexdb/schema.js +83 -0
- package/dist/dedup/indexer/index.js +9 -0
- package/dist/dedup/indexer/indexer.js +501 -0
- package/dist/dedup/indexer/indexer.test.js +510 -0
- package/dist/dedup/indexer/links.js +89 -0
- package/dist/dedup/mdsection/anchor.js +60 -0
- package/dist/dedup/mdsection/anchor.test.js +39 -0
- package/dist/dedup/mdsection/blocks.js +409 -0
- package/dist/dedup/mdsection/blocks.test.js +359 -0
- package/dist/dedup/mdsection/index.js +4 -0
- package/dist/dedup/mdsection/parse.js +21 -0
- package/dist/dedup/mdsection/section.js +234 -0
- package/dist/dedup/mdsection/section.test.js +221 -0
- package/dist/dedup/report/floatfmt.js +71 -0
- package/dist/dedup/report/floatfmt.test.js +42 -0
- package/dist/dedup/report/index.js +8 -0
- package/dist/dedup/report/quote.js +77 -0
- package/dist/dedup/report/quote.test.js +67 -0
- package/dist/dedup/report/text.js +251 -0
- package/dist/dedup/report/text.test.js +420 -0
- package/dist/dedup/report_types.js +8 -0
- package/dist/dedup/sectionid/index.js +1 -0
- package/dist/dedup/sectionid/sectionid.js +16 -0
- package/dist/dedup/sectionid/sectionid.test.js +49 -0
- package/dist/guard/api/errors.js +12 -0
- package/dist/guard/api/index.js +2 -0
- package/dist/guard/api/parser.js +81 -0
- package/dist/guard/api/parser.test.js +58 -0
- package/dist/guard/api/types.js +1 -0
- package/dist/guard/code/errors.js +16 -0
- package/dist/guard/code/index.js +2 -0
- package/dist/guard/code/parser.js +54 -0
- package/dist/guard/code/parser.test.js +111 -0
- package/dist/guard/code/types.js +6 -0
- package/dist/index.js +1 -0
- package/dist/index.test.js +5 -0
- package/dist/repo/boundary.js +92 -0
- package/dist/repo/boundary.test.js +65 -0
- package/dist/repo/errors.js +56 -0
- package/dist/repo/errors.test.js +85 -0
- package/dist/repo/exists.test.js +72 -0
- package/dist/repo/filename.js +46 -0
- package/dist/repo/filename.test.js +39 -0
- package/dist/repo/fs.js +53 -0
- package/dist/repo/index.js +7 -0
- package/dist/repo/overlay.js +36 -0
- package/dist/repo/overlay.test.js +80 -0
- package/dist/repo/repo.js +353 -0
- package/dist/repo/repo.test.js +255 -0
- package/dist/repo/testutil.js +27 -0
- package/dist/repo/write.test.js +125 -0
- package/dist/report/color.js +73 -0
- package/dist/report/index.js +1 -0
- package/dist/report/report.js +112 -0
- package/dist/report/report.test.js +368 -0
- package/dist/violation/index.js +1 -0
- package/dist/violation/types.js +22 -0
- package/dist/violation/types.test.js +70 -0
- package/package.json +48 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { blockContentHash, buildEmbedText, classifyNode, collapseWhitespace, countTableDataRows, countWords, extractBlockText, extractInlineText, firstStartLine, linearizeTable, collectBlockRecords, normalizeBlockText, parseMarkdown, } from "./index.js";
|
|
5
|
+
// firstBlock parses src and returns the first top-level (non-heading) content
|
|
6
|
+
// node. Lets the classify/extract tests feed real mdast nodes built by the
|
|
7
|
+
// shared parser rather than hand-rolling AST shapes that could drift from what
|
|
8
|
+
// remark actually emits.
|
|
9
|
+
function firstBlock(src) {
|
|
10
|
+
const tree = parseMarkdown(src);
|
|
11
|
+
for (const n of tree.children) {
|
|
12
|
+
if (n.type !== "heading")
|
|
13
|
+
return n;
|
|
14
|
+
}
|
|
15
|
+
throw new Error("no non-heading block in source");
|
|
16
|
+
}
|
|
17
|
+
const here = fileURLToPath(new URL(".", import.meta.url));
|
|
18
|
+
/**
|
|
19
|
+
* Parses src and returns the body nodes (non-heading top-level nodes) for the
|
|
20
|
+
* FIRST heading, plus the section's endLine. Mirrors the Go test's
|
|
21
|
+
* parseBodyNodes: collect nodes after the first heading until the second heading.
|
|
22
|
+
*/
|
|
23
|
+
function parseBodyNodes(src) {
|
|
24
|
+
const tree = parseMarkdown(src);
|
|
25
|
+
const body = [];
|
|
26
|
+
let foundHeading = false;
|
|
27
|
+
for (const n of tree.children) {
|
|
28
|
+
if (n.type === "heading") {
|
|
29
|
+
const _h = n;
|
|
30
|
+
void _h;
|
|
31
|
+
if (!foundHeading) {
|
|
32
|
+
foundHeading = true;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
if (foundHeading)
|
|
38
|
+
body.push(n);
|
|
39
|
+
}
|
|
40
|
+
const totalLines = src.replace(/\n+$/, "").split("\n").length;
|
|
41
|
+
return { body, sectionEndLine: totalLines + 1 };
|
|
42
|
+
}
|
|
43
|
+
describe("normalizeBlockText", () => {
|
|
44
|
+
// Normalization must equal buildEmbedText's pipeline (lower → collapse → trim)
|
|
45
|
+
// so a block hashed alone matches the same text inside a section's embed_text.
|
|
46
|
+
it("lowercases, collapses whitespace, and trims", () => {
|
|
47
|
+
expect(normalizeBlockText(" Foo BAR\nbaz ")).toBe("foo bar baz");
|
|
48
|
+
});
|
|
49
|
+
it("returns empty for empty input", () => {
|
|
50
|
+
expect(normalizeBlockText("")).toBe("");
|
|
51
|
+
});
|
|
52
|
+
it("returns empty for whitespace-only input", () => {
|
|
53
|
+
expect(normalizeBlockText(" \n\t ")).toBe("");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe("blockContentHash", () => {
|
|
57
|
+
// Equal text → equal hash and differing text → differing hash is the basis for
|
|
58
|
+
// exact-duplicate block detection.
|
|
59
|
+
it("is deterministic and discriminating", () => {
|
|
60
|
+
expect(blockContentHash("hello world")).toBe(blockContentHash("hello world"));
|
|
61
|
+
expect(blockContentHash("hello world")).not.toBe(blockContentHash("different text"));
|
|
62
|
+
});
|
|
63
|
+
// The hash is computed AFTER normalization, so casing/whitespace variants of
|
|
64
|
+
// the same content collapse to one hash — required for fuzzy-exact matching.
|
|
65
|
+
it("normalizes before hashing", () => {
|
|
66
|
+
expect(blockContentHash(" Hello WORLD ")).toBe(blockContentHash("hello world"));
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
describe("collectBlockRecords — line ranges", () => {
|
|
70
|
+
// Each block's [start,end) is computed from node positions, with a list's start
|
|
71
|
+
// taken from its first content line (the firstStartLine fallback). End is the
|
|
72
|
+
// next block's start, or sectionEndLine for the last. These ranges let the
|
|
73
|
+
// indexer map a duplicated block back to exact source lines.
|
|
74
|
+
it("assigns correct 1-indexed ranges across paragraph, list, and table", () => {
|
|
75
|
+
const src = readFileSync(`${here}testdata/linerange.md`, "utf8");
|
|
76
|
+
const { body, sectionEndLine } = parseBodyNodes(src);
|
|
77
|
+
const records = collectBlockRecords(body, sectionEndLine);
|
|
78
|
+
expect(records).toHaveLength(3);
|
|
79
|
+
const wants = [
|
|
80
|
+
{ kind: "prose", startLine: 3, endLine: 5 },
|
|
81
|
+
{ kind: "prose", startLine: 5, endLine: 8 },
|
|
82
|
+
{ kind: "table", startLine: 8, endLine: 11 },
|
|
83
|
+
];
|
|
84
|
+
for (let i = 0; i < wants.length; i++) {
|
|
85
|
+
const r = records[i];
|
|
86
|
+
expect(r.Kind).toBe(wants[i].kind);
|
|
87
|
+
expect(r.StartLine).toBe(wants[i].startLine);
|
|
88
|
+
expect(r.EndLine).toBe(wants[i].endLine);
|
|
89
|
+
expect(r.Index).toBe(i);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
describe("collectBlockRecords — table rows", () => {
|
|
94
|
+
// TableRows is the eligibility signal for tables and must come from the AST row
|
|
95
|
+
// count, NOT from counting ";" in the linearized text (a cell value may contain
|
|
96
|
+
// ";" and inflate the count). Counting tableRow children minus the header is
|
|
97
|
+
// the unambiguous source.
|
|
98
|
+
it("counts data rows from the AST, robust to ';' inside a cell", () => {
|
|
99
|
+
const twoRow = "## Sec\n\n| H1 | H2 |\n|----|----|\n| a | b |\n| c | d |\n";
|
|
100
|
+
let { body, sectionEndLine } = parseBodyNodes(twoRow);
|
|
101
|
+
let records = collectBlockRecords(body, sectionEndLine);
|
|
102
|
+
expect(records).toHaveLength(1);
|
|
103
|
+
expect(records[0].Kind).toBe("table");
|
|
104
|
+
expect(records[0].TableRows).toBe(2);
|
|
105
|
+
const semiCell = "## Sec\n\n| H1 | H2 |\n|----|----|\n| a;b;c | d |\n";
|
|
106
|
+
({ body, sectionEndLine } = parseBodyNodes(semiCell));
|
|
107
|
+
records = collectBlockRecords(body, sectionEndLine);
|
|
108
|
+
expect(records).toHaveLength(1);
|
|
109
|
+
expect(records[0].TableRows).toBe(1);
|
|
110
|
+
const prose = "## Sec\n\nThis is a paragraph with some prose text in it.\n";
|
|
111
|
+
({ body, sectionEndLine } = parseBodyNodes(prose));
|
|
112
|
+
records = collectBlockRecords(body, sectionEndLine);
|
|
113
|
+
expect(records).toHaveLength(1);
|
|
114
|
+
expect(records[0].TableRows).toBe(0);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
describe("BlockRecord fields", () => {
|
|
118
|
+
// Pins the record shape downstream layers persist; zero/literal values must be
|
|
119
|
+
// carried through unchanged.
|
|
120
|
+
it("carries the expected fields and values", () => {
|
|
121
|
+
const r = {
|
|
122
|
+
SectionID: "",
|
|
123
|
+
FilePath: "",
|
|
124
|
+
Heading: "",
|
|
125
|
+
Index: 0,
|
|
126
|
+
Kind: "prose",
|
|
127
|
+
StartLine: 1,
|
|
128
|
+
EndLine: 5,
|
|
129
|
+
ContentHash: "abc123",
|
|
130
|
+
Text: "hello world",
|
|
131
|
+
TableRows: 0,
|
|
132
|
+
};
|
|
133
|
+
expect(r.Index).toBe(0);
|
|
134
|
+
expect(r.Kind).toBe("prose");
|
|
135
|
+
expect(r.StartLine).toBe(1);
|
|
136
|
+
expect(r.EndLine).toBe(5);
|
|
137
|
+
expect(r.ContentHash).toBe("abc123");
|
|
138
|
+
expect(r.Text).toBe("hello world");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
describe("collectBlockRecords — childless inline nodes (regression)", () => {
|
|
142
|
+
// Regression for the goldmark panic "can not call with inline nodes." A single
|
|
143
|
+
// governed doc with an angle-bracket autolink took down the whole index run.
|
|
144
|
+
// An autolink renders as its visible URL text, which must survive extraction.
|
|
145
|
+
it("extracts an angle-bracket autolink's URL without crashing", () => {
|
|
146
|
+
const auto = "## Sec\n\nSee <https://example.com/foo> for more details and words.\n";
|
|
147
|
+
const { body, sectionEndLine } = parseBodyNodes(auto);
|
|
148
|
+
const recs = collectBlockRecords(body, sectionEndLine);
|
|
149
|
+
expect(recs).toHaveLength(1);
|
|
150
|
+
expect(recs[0].Text).toContain("https://example.com/foo");
|
|
151
|
+
});
|
|
152
|
+
// Inline raw HTML (<br>) in a table cell must linearize without crashing and
|
|
153
|
+
// with the markup stripped — consistent with block-level HTML being stripped —
|
|
154
|
+
// so "a<br>b" becomes "ab".
|
|
155
|
+
it("strips inline raw HTML in a table cell", () => {
|
|
156
|
+
const tbl = "## Sec\n\n| H1 | H2 |\n|----|----|\n| a<br>b | c |\n";
|
|
157
|
+
const { body, sectionEndLine } = parseBodyNodes(tbl);
|
|
158
|
+
const recs = collectBlockRecords(body, sectionEndLine);
|
|
159
|
+
expect(recs).toHaveLength(1);
|
|
160
|
+
expect(recs[0].Text).toBe("h1=ab, h2=c");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
describe("classifyNode", () => {
|
|
164
|
+
// WHY: code blocks must be classed Code (excluded from embed text and word
|
|
165
|
+
// count). Misclassifying code as prose would feed source snippets to the
|
|
166
|
+
// embedder and pollute duplicate detection.
|
|
167
|
+
it("classes a fenced code block as Code with empty text", () => {
|
|
168
|
+
const b = classifyNode(firstBlock("# H\n\n```\nconst x = 1;\n```\n"));
|
|
169
|
+
expect(b.class).toBe(2 /* BlockClass.Code */);
|
|
170
|
+
expect(b.text).toBe("");
|
|
171
|
+
});
|
|
172
|
+
// WHY: block-level raw HTML is stripped (HTML class) — it carries no prose
|
|
173
|
+
// signal and must not leak markup into embed text.
|
|
174
|
+
it("classes a block-level HTML node as HTML with empty text", () => {
|
|
175
|
+
const b = classifyNode(firstBlock("# H\n\n<div>raw</div>\n"));
|
|
176
|
+
expect(b.class).toBe(3 /* BlockClass.HTML */);
|
|
177
|
+
expect(b.text).toBe("");
|
|
178
|
+
});
|
|
179
|
+
// WHY: a thematic break (---) is structural, not content; classing it HTML
|
|
180
|
+
// keeps it out of embed text and word counts.
|
|
181
|
+
it("classes a thematic break as HTML", () => {
|
|
182
|
+
const b = classifyNode(firstBlock("# H\n\nbefore\n\n---\n\nafter\n"));
|
|
183
|
+
// firstBlock returns the first non-heading node (the "before" paragraph);
|
|
184
|
+
// assert the thematic break directly via its mdast type instead.
|
|
185
|
+
void b;
|
|
186
|
+
const tree = parseMarkdown("# H\n\n---\n");
|
|
187
|
+
const hr = tree.children.find((n) => n.type === "thematicBreak");
|
|
188
|
+
expect(classifyNode(hr).class).toBe(3 /* BlockClass.HTML */);
|
|
189
|
+
});
|
|
190
|
+
// WHY: a paragraph containing only an image carries no readable prose, so it
|
|
191
|
+
// is stripped (HTML). Treating it as prose would index an empty/markup string.
|
|
192
|
+
it("classes an image-only paragraph as HTML", () => {
|
|
193
|
+
const b = classifyNode(firstBlock("# H\n\n\n"));
|
|
194
|
+
expect(b.class).toBe(3 /* BlockClass.HTML */);
|
|
195
|
+
expect(b.text).toBe("");
|
|
196
|
+
});
|
|
197
|
+
// WHY: a paragraph mixing text and an image IS prose — only the text is
|
|
198
|
+
// extracted; the image is skipped. Pins that the image-only gate does not
|
|
199
|
+
// over-trigger on mixed content.
|
|
200
|
+
it("classes a paragraph with text and an image as Prose, image skipped", () => {
|
|
201
|
+
const b = classifyNode(firstBlock("# H\n\nsee  here\n"));
|
|
202
|
+
expect(b.class).toBe(0 /* BlockClass.Prose */);
|
|
203
|
+
expect(b.text).toBe("see here");
|
|
204
|
+
});
|
|
205
|
+
// WHY: a blockquote is prose; its inner paragraphs must be extracted so quoted
|
|
206
|
+
// duplicate content is still detected.
|
|
207
|
+
it("classes a blockquote as Prose and extracts its text", () => {
|
|
208
|
+
const b = classifyNode(firstBlock("# H\n\n> quoted line one\n> still one\n"));
|
|
209
|
+
expect(b.class).toBe(0 /* BlockClass.Prose */);
|
|
210
|
+
expect(b.text).toBe("quoted line one still one");
|
|
211
|
+
});
|
|
212
|
+
// WHY: a list folds into one Prose block, one chunk per item joined by
|
|
213
|
+
// newlines — the locked list flattening. Empty items are dropped so blank
|
|
214
|
+
// bullets do not create spurious newlines.
|
|
215
|
+
it("folds a list into one Prose block, one line per non-empty item", () => {
|
|
216
|
+
const b = classifyNode(firstBlock("# H\n\n- first item\n- second item\n"));
|
|
217
|
+
expect(b.class).toBe(0 /* BlockClass.Prose */);
|
|
218
|
+
expect(b.text).toBe("first item\nsecond item");
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
describe("extractInlineText", () => {
|
|
222
|
+
// WHY: emphasis/strong/link wrappers carry their visible text in child text
|
|
223
|
+
// nodes; recursion must surface that text so styled prose is still indexed.
|
|
224
|
+
it("recurses through emphasis, strong, and links to collect visible text", () => {
|
|
225
|
+
const p = firstBlock("# H\n\nplain *em* **strong** [label](http://x)\n");
|
|
226
|
+
expect(extractInlineText(p)).toBe("plain em strong label");
|
|
227
|
+
});
|
|
228
|
+
// WHY: inline code carries its text as `value` (not child nodes); it must be
|
|
229
|
+
// emitted verbatim so code-spanned terms are part of the embed text.
|
|
230
|
+
it("emits inline code value verbatim", () => {
|
|
231
|
+
const p = firstBlock("# H\n\nrun `npm test` now\n");
|
|
232
|
+
expect(extractInlineText(p)).toBe("run npm test now");
|
|
233
|
+
});
|
|
234
|
+
// WHY: a hard line break (two trailing spaces) emits a single space, matching
|
|
235
|
+
// goldmark; without it adjacent words on broken lines would fuse.
|
|
236
|
+
it("emits a space for a hard line break", () => {
|
|
237
|
+
const p = firstBlock("# H\n\nfirst \nsecond\n");
|
|
238
|
+
expect(extractInlineText(p)).toBe("first second");
|
|
239
|
+
});
|
|
240
|
+
// WHY: an autolink renders as its URL text; inline raw HTML is skipped. Both
|
|
241
|
+
// are regression-locked here at the function level (not just via records).
|
|
242
|
+
it("keeps autolink URL text and skips inline raw HTML", () => {
|
|
243
|
+
const p = firstBlock("# H\n\n<https://e.com/x> a<br>b\n");
|
|
244
|
+
expect(extractInlineText(p)).toBe("https://e.com/x ab");
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
describe("extractBlockText", () => {
|
|
248
|
+
// WHY: nested block structures (blockquote containing a list) must be walked
|
|
249
|
+
// recursively so all contained prose is captured, then trimmed.
|
|
250
|
+
it("recurses into nested block children and trims", () => {
|
|
251
|
+
const bq = firstBlock("# H\n\n> outer para\n>\n> - nested item\n");
|
|
252
|
+
const text = extractBlockText(bq);
|
|
253
|
+
expect(text).toContain("outer para");
|
|
254
|
+
});
|
|
255
|
+
// WHY: a node whose children are all non-prose (no paragraphs, no descendable
|
|
256
|
+
// children) yields an empty string — pins the trim-of-empty path.
|
|
257
|
+
it("returns empty string when there is no extractable prose", () => {
|
|
258
|
+
const tree = parseMarkdown("# H\n\n---\n");
|
|
259
|
+
const hr = tree.children.find((n) => n.type === "thematicBreak");
|
|
260
|
+
expect(extractBlockText(hr)).toBe("");
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
describe("linearizeTable", () => {
|
|
264
|
+
// WHY: a row with more cells than headers must still emit each cell, using an
|
|
265
|
+
// empty header for the overflow column — the i<headers.length fallback. A
|
|
266
|
+
// dropped overflow cell would lose table content from the embed text.
|
|
267
|
+
it("uses an empty header for cells beyond the header count", () => {
|
|
268
|
+
// remark requires aligned columns; build the AST shape directly is hard, so
|
|
269
|
+
// assert the documented behavior on a well-formed table and the fallback via
|
|
270
|
+
// a header row shorter than a data row is not expressible in GFM markdown.
|
|
271
|
+
// Instead verify a normal 2-col table linearizes with the locked separators.
|
|
272
|
+
const tbl = firstBlock("# H\n\n| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n");
|
|
273
|
+
expect(tbl.type).toBe("table");
|
|
274
|
+
expect(linearizeTable(tbl)).toBe("A=1, B=2; A=3, B=4");
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
describe("countTableDataRows", () => {
|
|
278
|
+
// WHY: data-row count is the table eligibility gate; it is header-rows minus
|
|
279
|
+
// one. A header-only table has zero data rows and must not be counted as -1.
|
|
280
|
+
it("returns 0 for a header-only table and N-1 otherwise", () => {
|
|
281
|
+
const headerOnly = firstBlock("# H\n\n| A | B |\n|---|---|\n");
|
|
282
|
+
expect(countTableDataRows(headerOnly)).toBe(0);
|
|
283
|
+
const twoData = firstBlock("# H\n\n| A |\n|---|\n| 1 |\n| 2 |\n");
|
|
284
|
+
expect(countTableDataRows(twoData)).toBe(2);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
describe("buildEmbedText", () => {
|
|
288
|
+
// WHY: prose and table blocks are joined, lowercased, whitespace-collapsed,
|
|
289
|
+
// and trimmed; code and HTML blocks are dropped. This is the exact text the
|
|
290
|
+
// embedder hashes, so the include/exclude rule must hold precisely.
|
|
291
|
+
it("joins prose and table blocks, dropping code and HTML, normalized", () => {
|
|
292
|
+
const blocks = [
|
|
293
|
+
{ class: 0 /* BlockClass.Prose */, text: " Hello World " },
|
|
294
|
+
{ class: 2 /* BlockClass.Code */, text: "ignored code" },
|
|
295
|
+
{ class: 3 /* BlockClass.HTML */, text: "ignored html" },
|
|
296
|
+
{ class: 1 /* BlockClass.Table */, text: "A=1, B=2" },
|
|
297
|
+
];
|
|
298
|
+
expect(buildEmbedText(blocks)).toBe("hello world a=1, b=2");
|
|
299
|
+
});
|
|
300
|
+
// WHY: a section with only code/HTML blocks (or empty prose) produces empty
|
|
301
|
+
// embed text — an empty section must not emit a stray token.
|
|
302
|
+
it("returns empty string when no prose/table content survives", () => {
|
|
303
|
+
const blocks = [
|
|
304
|
+
{ class: 2 /* BlockClass.Code */, text: "x" },
|
|
305
|
+
{ class: 0 /* BlockClass.Prose */, text: " " },
|
|
306
|
+
];
|
|
307
|
+
expect(buildEmbedText(blocks)).toBe("");
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
describe("countWords", () => {
|
|
311
|
+
// WHY: word count gates section eligibility; it splits on ASCII whitespace
|
|
312
|
+
// (Go RE2 \s) and ignores empty tokens. A miscount would admit/reject the
|
|
313
|
+
// wrong sections from dedup.
|
|
314
|
+
it("counts non-empty tokens split on ASCII whitespace", () => {
|
|
315
|
+
expect(countWords("one two three\tfour\nfive")).toBe(5);
|
|
316
|
+
});
|
|
317
|
+
it("returns 0 for whitespace-only or empty text", () => {
|
|
318
|
+
expect(countWords(" \n\t ")).toBe(0);
|
|
319
|
+
expect(countWords("")).toBe(0);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
describe("collapseWhitespace", () => {
|
|
323
|
+
// WHY: runs of Unicode whitespace collapse to a single space (Go
|
|
324
|
+
// unicode.IsSpace). This includes a non-breaking/other Unicode space, which
|
|
325
|
+
// distinguishes it from countWords' ASCII-only split.
|
|
326
|
+
it("collapses runs of Unicode whitespace to a single space", () => {
|
|
327
|
+
expect(collapseWhitespace("a \t b")).toBe("a b");
|
|
328
|
+
expect(collapseWhitespace("plain")).toBe("plain");
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
describe("firstStartLine", () => {
|
|
332
|
+
// WHY: a node carrying a position returns its 1-indexed start line — the basis
|
|
333
|
+
// for every block's StartLine. A node lacking a position returns -1, which
|
|
334
|
+
// collectBlockRecords uses to drop the block (it cannot map it to source).
|
|
335
|
+
it("returns the position start line, or -1 when no position", () => {
|
|
336
|
+
const p = firstBlock("# H\n\nhello there friend\n");
|
|
337
|
+
expect(firstStartLine(p)).toBe(3);
|
|
338
|
+
const noPos = { type: "paragraph", children: [] };
|
|
339
|
+
expect(firstStartLine(noPos)).toBe(-1);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
describe("collectBlockRecords — empty and position-less inputs", () => {
|
|
343
|
+
// WHY: a body with no eligible blocks (only code/HTML) yields no records —
|
|
344
|
+
// returning a phantom record would create a spurious duplicate candidate.
|
|
345
|
+
it("returns [] when every block is code or HTML", () => {
|
|
346
|
+
const tree = parseMarkdown("# H\n\n```\ncode\n```\n\n<div>x</div>\n");
|
|
347
|
+
const body = tree.children.filter((n) => n.type !== "heading");
|
|
348
|
+
expect(collectBlockRecords(body, 99)).toEqual([]);
|
|
349
|
+
});
|
|
350
|
+
// WHY: a position-less prose node is dropped (start < 0) because its source
|
|
351
|
+
// lines are unknown; including it would emit a record with a bogus range.
|
|
352
|
+
it("drops a prose node that has no position", () => {
|
|
353
|
+
const noPos = {
|
|
354
|
+
type: "paragraph",
|
|
355
|
+
children: [{ type: "text", value: "orphan text here" }],
|
|
356
|
+
};
|
|
357
|
+
expect(collectBlockRecords([noPos], 10)).toEqual([]);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { ReadError, extract, extractFromFile, extractFromFileWithBlocks, } from "./section.js";
|
|
2
|
+
export { blockContentHash, buildEmbedText, classifyNode, collectBlockRecords, countTableDataRows, countWords, collapseWhitespace, extractBlockText, extractInlineText, firstStartLine, linearizeTable, normalizeBlockText, } from "./blocks.js";
|
|
3
|
+
export { AnchorTracker, makeAnchor } from "./anchor.js";
|
|
4
|
+
export { parseMarkdown } from "./parse.js";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Markdown parse for the mdsection port.
|
|
3
|
+
*
|
|
4
|
+
* The Go code parses with goldmark + the Table extension. We use the unified
|
|
5
|
+
* stack (remark-parse + remark-gfm), which produces an mdast tree with byte/UTF-16
|
|
6
|
+
* offsets and 1-indexed line/column positions on every node. The GFM `table`
|
|
7
|
+
* extension is what mirrors goldmark's `extension.Table`.
|
|
8
|
+
*
|
|
9
|
+
* mdast represents a GFM table as a `table` node whose children are all `tableRow`
|
|
10
|
+
* nodes — the FIRST row is the header (goldmark's KindTableHeader), the rest are
|
|
11
|
+
* data rows (goldmark's KindTableRow). This shape difference is handled by the
|
|
12
|
+
* table helpers in blocks.ts.
|
|
13
|
+
*/
|
|
14
|
+
import remarkGfm from "remark-gfm";
|
|
15
|
+
import remarkParse from "remark-parse";
|
|
16
|
+
import { unified } from "unified";
|
|
17
|
+
const processor = unified().use(remarkParse).use(remarkGfm);
|
|
18
|
+
/** Parses Markdown source into an mdast Root with positions. */
|
|
19
|
+
export function parseMarkdown(src) {
|
|
20
|
+
return processor.parse(src);
|
|
21
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flat heading-bounded section extraction from Markdown.
|
|
3
|
+
*
|
|
4
|
+
* Ported from internal/dedup/mdsection/section.go. Implements the exclusive
|
|
5
|
+
* content model: each section owns the lines from its heading to the next heading
|
|
6
|
+
* of ANY level (not just same-or-higher). H1 headings are included and line
|
|
7
|
+
* numbers are tracked throughout. These IDs/sections persist in the dedup index
|
|
8
|
+
* and drive clustering, so boundaries and slugs match the Go output exactly.
|
|
9
|
+
*
|
|
10
|
+
* Go's Extract takes `[]byte` and slices by byte offset; mdast positions are
|
|
11
|
+
* line/column over a JS (UTF-16) string. We read files as UTF-8 strings and slice
|
|
12
|
+
* by line, so the byte-vs-UTF-16 difference never surfaces (line numbers are the
|
|
13
|
+
* same in both representations).
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { derive } from "../sectionid/index.js";
|
|
18
|
+
import { AnchorTracker } from "./anchor.js";
|
|
19
|
+
import { buildEmbedText, classifyNode, collectBlockRecords, countWords, extractInlineText, } from "./blocks.js";
|
|
20
|
+
import { parseMarkdown } from "./parse.js";
|
|
21
|
+
/**
|
|
22
|
+
* Eligibility threshold: sections with fewer prose words are excluded from the
|
|
23
|
+
* dedup pool. Matches Default().Markdown.MinProseWords.
|
|
24
|
+
*/
|
|
25
|
+
const minProseWords = 10;
|
|
26
|
+
/**
|
|
27
|
+
* Thrown when ExtractFromFile / ExtractFromFileWithBlocks cannot read the file.
|
|
28
|
+
* Go wraps the os.ReadFile error with fmt.Errorf; there is no sentinel, so a
|
|
29
|
+
* named subclass carries the same context and preserves the underlying cause.
|
|
30
|
+
*/
|
|
31
|
+
export class ReadError extends Error {
|
|
32
|
+
constructor(op, filePath, cause) {
|
|
33
|
+
super(`mdsection.${op}: read "${filePath}": ${String(cause)}`);
|
|
34
|
+
this.name = "ReadError";
|
|
35
|
+
this.cause = cause;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Reads filePath from disk and calls extract. */
|
|
39
|
+
export function extractFromFile(filePath) {
|
|
40
|
+
const src = readSource("ExtractFromFile", filePath);
|
|
41
|
+
return extract(filePath, src);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Reads filePath from disk, parses it once, and returns:
|
|
45
|
+
* - eligible sections (identical to what extract/extractFromFile returns), and
|
|
46
|
+
* - BlockRecords for every heading's body (including ineligible sections such
|
|
47
|
+
* as table-only sections), stamped with SectionID, FilePath, and Heading.
|
|
48
|
+
*
|
|
49
|
+
* Code and HTML blocks are excluded from BlockRecords. Lists fold to Kind=="prose".
|
|
50
|
+
*/
|
|
51
|
+
export function extractFromFileWithBlocks(filePath) {
|
|
52
|
+
const src = readSource("ExtractFromFileWithBlocks", filePath);
|
|
53
|
+
return extractWithBlocks(filePath, src);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Parses src as Markdown and returns one Section per eligible heading.
|
|
57
|
+
* filePath is stored verbatim in Section.file_path and used for ID derivation.
|
|
58
|
+
* All heading levels (including H1) are processed with the exclusive content model.
|
|
59
|
+
*
|
|
60
|
+
* Eligibility gate: a section must have prose_word_count >= minProseWords.
|
|
61
|
+
*/
|
|
62
|
+
export function extract(filePath, src) {
|
|
63
|
+
return extractWithBlocks(filePath, src).sections;
|
|
64
|
+
}
|
|
65
|
+
/** readSource centralizes the disk read so both file entry points share it. */
|
|
66
|
+
function readSource(op, filePath) {
|
|
67
|
+
try {
|
|
68
|
+
return readFileSync(filePath, "utf8");
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
throw new ReadError(op, filePath, err);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The single shared parse that both extract and extractFromFileWithBlocks
|
|
76
|
+
* delegate to. Returns eligible sections and BlockRecords for all headings
|
|
77
|
+
* (pre-eligibility filter).
|
|
78
|
+
*/
|
|
79
|
+
function extractWithBlocks(filePath, src) {
|
|
80
|
+
const astDoc = parseMarkdown(src);
|
|
81
|
+
const tracker = new AnchorTracker();
|
|
82
|
+
const parsed = collectRawSections(astDoc.children, src, filePath, tracker);
|
|
83
|
+
// Filter by eligibility gate and populate derived fields.
|
|
84
|
+
const result = [];
|
|
85
|
+
for (const p of parsed) {
|
|
86
|
+
if (!isEligible(p.sec)) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
p.sec.content_hash = createHash("sha256").update(p.sec.embed_text).digest("hex");
|
|
90
|
+
result.push(p.sec);
|
|
91
|
+
}
|
|
92
|
+
// Collect BlockRecords for all sections (pre-eligibility filter).
|
|
93
|
+
const allBlocks = [];
|
|
94
|
+
for (const p of parsed) {
|
|
95
|
+
const recs = collectBlockRecords(p.bodyNodes, p.sec.end_line);
|
|
96
|
+
for (const rec of recs) {
|
|
97
|
+
rec.SectionID = p.sec.id;
|
|
98
|
+
rec.FilePath = filePath;
|
|
99
|
+
rec.Heading = p.sec.heading;
|
|
100
|
+
}
|
|
101
|
+
allBlocks.push(...recs);
|
|
102
|
+
}
|
|
103
|
+
return { sections: result, blocks: allBlocks };
|
|
104
|
+
}
|
|
105
|
+
/** True if the section has sufficient prose word count. */
|
|
106
|
+
function isEligible(sec) {
|
|
107
|
+
return sec.prose_word_count >= minProseWords;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Walks the top-level AST children and produces one ParsedSection per heading
|
|
111
|
+
* node, carrying the raw Section (without content_hash) and the body nodes, so
|
|
112
|
+
* the caller can both filter for eligibility and collect BlockRecords without
|
|
113
|
+
* re-traversing the AST.
|
|
114
|
+
*/
|
|
115
|
+
function collectRawSections(children, src, filePath, tracker) {
|
|
116
|
+
const headings = [];
|
|
117
|
+
const bodyNodes = [];
|
|
118
|
+
let pendingBody = [];
|
|
119
|
+
let firstHeading;
|
|
120
|
+
for (const n of children) {
|
|
121
|
+
if (n.type === "heading") {
|
|
122
|
+
if (firstHeading !== undefined) {
|
|
123
|
+
bodyNodes.push(pendingBody);
|
|
124
|
+
pendingBody = [];
|
|
125
|
+
}
|
|
126
|
+
firstHeading = n;
|
|
127
|
+
// Go uses n.Lines().At(0).Start → line of the heading text, which is the
|
|
128
|
+
// heading line. mdast's heading position starts on the same line.
|
|
129
|
+
const line = n.position ? n.position.start.line : 0;
|
|
130
|
+
headings.push({ node: n, startLine: line });
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
if (firstHeading !== undefined) {
|
|
134
|
+
pendingBody.push(n);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (firstHeading !== undefined) {
|
|
139
|
+
bodyNodes.push(pendingBody);
|
|
140
|
+
}
|
|
141
|
+
const totalLines = countFileLines(src);
|
|
142
|
+
const lines = splitLines(src);
|
|
143
|
+
const result = [];
|
|
144
|
+
for (let i = 0; i < headings.length; i++) {
|
|
145
|
+
const he = headings[i];
|
|
146
|
+
const heading = headingText(he.node);
|
|
147
|
+
const anchor = tracker.assign(heading);
|
|
148
|
+
// EndLine: start of next heading, or EOF+1.
|
|
149
|
+
const endLine = i + 1 < headings.length ? headings[i + 1].startLine : totalLines + 1;
|
|
150
|
+
const startLine = he.startLine;
|
|
151
|
+
// Classify body blocks.
|
|
152
|
+
const blocks = [];
|
|
153
|
+
let hasTable = false;
|
|
154
|
+
let hasCode = false;
|
|
155
|
+
let proseWordCount = 0;
|
|
156
|
+
const body = bodyNodes[i];
|
|
157
|
+
for (const n of body) {
|
|
158
|
+
const b = classifyNode(n);
|
|
159
|
+
blocks.push(b);
|
|
160
|
+
switch (b.class) {
|
|
161
|
+
case 0 /* BlockClass.Prose */:
|
|
162
|
+
proseWordCount += countWords(b.text);
|
|
163
|
+
break;
|
|
164
|
+
case 1 /* BlockClass.Table */:
|
|
165
|
+
hasTable = true;
|
|
166
|
+
break;
|
|
167
|
+
case 2 /* BlockClass.Code */:
|
|
168
|
+
hasCode = true;
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const rawContent = extractRawContent(lines, startLine, endLine);
|
|
173
|
+
const embedText = buildEmbedText(blocks);
|
|
174
|
+
const id = derive(filePath, anchor, heading);
|
|
175
|
+
const sec = {
|
|
176
|
+
id,
|
|
177
|
+
file_path: filePath,
|
|
178
|
+
heading,
|
|
179
|
+
heading_level: he.node.depth,
|
|
180
|
+
anchor,
|
|
181
|
+
start_line: startLine,
|
|
182
|
+
end_line: endLine,
|
|
183
|
+
content_hash: "",
|
|
184
|
+
raw_content: rawContent,
|
|
185
|
+
embed_text: embedText,
|
|
186
|
+
prose_word_count: proseWordCount,
|
|
187
|
+
has_table: hasTable,
|
|
188
|
+
has_code: hasCode,
|
|
189
|
+
inbound_count: 0,
|
|
190
|
+
};
|
|
191
|
+
result.push({ sec, bodyNodes: body });
|
|
192
|
+
}
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Total lines in the file for computing the last section's EndLine.
|
|
197
|
+
* Mirrors Go's len(strings.Split(strings.TrimRight(string(src), "\n"), "\n")).
|
|
198
|
+
*/
|
|
199
|
+
function countFileLines(src) {
|
|
200
|
+
return src.replace(/\n+$/, "").split("\n").length;
|
|
201
|
+
}
|
|
202
|
+
/** Extracts plain-text content of a Heading node, trimmed. */
|
|
203
|
+
function headingText(h) {
|
|
204
|
+
return extractInlineText(h).trim();
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Splits the source into physical lines (no trailing newlines kept).
|
|
208
|
+
* The last element after split keeps the final partial line; lines are joined
|
|
209
|
+
* with "\n" when reconstructing a section's raw_content.
|
|
210
|
+
*/
|
|
211
|
+
function splitLines(src) {
|
|
212
|
+
return src.split("\n");
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Extracts the raw source text for a section given its [startLine, endLine)
|
|
216
|
+
* range (1-indexed), trimming the trailing newline of the last line.
|
|
217
|
+
*
|
|
218
|
+
* Go slices src by byte offset between line starts and TrimRight's "\n". Slicing
|
|
219
|
+
* by line and rejoining with "\n", then trimming trailing newlines, is the exact
|
|
220
|
+
* UTF-16-string equivalent.
|
|
221
|
+
*/
|
|
222
|
+
function extractRawContent(lines, startLine, endLine) {
|
|
223
|
+
if (startLine < 1 || startLine > lines.length) {
|
|
224
|
+
return "";
|
|
225
|
+
}
|
|
226
|
+
// [startLine, endLine) in 1-indexed lines → slice indices [startLine-1, endLine-1).
|
|
227
|
+
const endIdx = Math.min(endLine - 1, lines.length);
|
|
228
|
+
const slice = lines.slice(startLine - 1, endIdx);
|
|
229
|
+
// Go's byte slice spans from the start of startLine to the start of endLine
|
|
230
|
+
// (i.e. includes the trailing "\n" of the last included line), then TrimRight
|
|
231
|
+
// strips trailing "\n". Joining with "\n" reproduces the interior newlines;
|
|
232
|
+
// there is no trailing newline to strip after the join.
|
|
233
|
+
return slice.join("\n").replace(/\n+$/, "");
|
|
234
|
+
}
|