@kontourai/corpus 0.0.0-bootstrap.0 → 0.8.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 +201 -0
- package/README.md +497 -0
- package/bin/corpus.mjs +4 -0
- package/bin/docs-kit.mjs +4 -0
- package/package.json +58 -3
- package/schemas/decision-record.schema.json +85 -0
- package/src/assets.mjs +67 -0
- package/src/build.mjs +535 -0
- package/src/built-on.mjs +238 -0
- package/src/bundle.mjs +174 -0
- package/src/check.mjs +520 -0
- package/src/cli.mjs +226 -0
- package/src/content-freshness.mjs +37 -0
- package/src/content.mjs +222 -0
- package/src/decisions.mjs +317 -0
- package/src/errors.mjs +24 -0
- package/src/git.mjs +171 -0
- package/src/hub.mjs +198 -0
- package/src/index.mjs +12 -0
- package/src/init.mjs +63 -0
- package/src/locate.mjs +341 -0
- package/src/manifest.mjs +589 -0
- package/src/markdown.mjs +547 -0
- package/src/mcp.mjs +583 -0
- package/src/paths.mjs +122 -0
- package/src/search.mjs +508 -0
- package/src/site-client.js +622 -0
- package/src/site-template.mjs +191 -0
package/src/hub.mjs
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { UsageError } from './errors.mjs';
|
|
4
|
+
import { validateProducts } from './manifest.mjs';
|
|
5
|
+
import { BASE_CSS, escapeHtml } from './site-template.mjs';
|
|
6
|
+
|
|
7
|
+
const HUB_CSS = `${BASE_CSS}
|
|
8
|
+
main { max-width: 1100px; margin: 0 auto; padding: 2rem 1.5rem 3rem; }
|
|
9
|
+
h1 { font-size: 2.1rem; line-height: 1.2; margin: 0 0 .5rem; }
|
|
10
|
+
.lede { color: var(--muted); font-size: 1.1rem; max-width: 70ch; margin: 0 0 1.5rem; }
|
|
11
|
+
.legend { display: flex; flex-wrap: wrap; gap: .5rem 1.25rem; padding: 0; margin: 0 0 1.5rem; list-style: none; color: var(--muted); font-size: .9rem; }
|
|
12
|
+
.products { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); padding: 0; margin: 0; list-style: none; }
|
|
13
|
+
.product { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); padding: 1rem 1.1rem; display: flex; flex-direction: column; gap: .4rem; }
|
|
14
|
+
.product h2 { font-size: 1.15rem; margin: 0; display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
|
|
15
|
+
.product h2 a { color: var(--fg); text-decoration: none; }
|
|
16
|
+
.product h2 a:hover { text-decoration: underline; }
|
|
17
|
+
.product .tagline { margin: 0; color: var(--muted); font-size: .95rem; }
|
|
18
|
+
.product .meta { margin: 0; color: var(--muted); font-size: .85rem; }
|
|
19
|
+
.product .links { margin: auto 0 0; font-size: .9rem; display: flex; gap: 1rem; }
|
|
20
|
+
.state { display: inline-block; font-size: .75rem; font-weight: 700; letter-spacing: .03em; text-transform: uppercase; padding: .1rem .5rem; border-radius: 999px; border: 1px solid currentColor; }
|
|
21
|
+
.state-current { color: var(--ok); }
|
|
22
|
+
.state-behind { color: var(--warn); }
|
|
23
|
+
.state-thin { color: var(--info); }
|
|
24
|
+
.state-unpublished { color: var(--bad); }
|
|
25
|
+
.state-unknown, .state-checking { color: var(--muted); }
|
|
26
|
+
section.install { margin-top: 2.5rem; max-width: 80ch; }
|
|
27
|
+
section.install h2 { margin-bottom: .5rem; }
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Client-side status: the bundle comes from the same origin (the hub must be
|
|
32
|
+
* served at the origin root, e.g. https://kontourai.github.io/), and the
|
|
33
|
+
* default-branch HEAD from the GitHub API, cached in localStorage for 10
|
|
34
|
+
* minutes to stay under the anonymous rate limit. Precedence: unpublished, behind, thin, current; "unknown" when the
|
|
35
|
+
* main commit could not be read and nothing else applies.
|
|
36
|
+
*/
|
|
37
|
+
const HUB_SCRIPT = `
|
|
38
|
+
(function () {
|
|
39
|
+
var products = JSON.parse(document.getElementById('products-data').textContent);
|
|
40
|
+
var TTL = 10 * 60 * 1000;
|
|
41
|
+
function ago(iso) {
|
|
42
|
+
var then = Date.parse(iso);
|
|
43
|
+
if (!isFinite(then)) return 'at an unknown time';
|
|
44
|
+
var s = Math.max(0, (Date.now() - then) / 1000);
|
|
45
|
+
var units = [['year', 31536000], ['month', 2592000], ['day', 86400], ['hour', 3600], ['minute', 60]];
|
|
46
|
+
for (var i = 0; i < units.length; i++) {
|
|
47
|
+
var n = Math.floor(s / units[i][1]);
|
|
48
|
+
if (n >= 1) return n + ' ' + units[i][0] + (n === 1 ? '' : 's') + ' ago';
|
|
49
|
+
}
|
|
50
|
+
return 'just now';
|
|
51
|
+
}
|
|
52
|
+
function getJson(url, init) {
|
|
53
|
+
return fetch(url, init).then(function (r) {
|
|
54
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
55
|
+
return r.json();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function mainCommit(repo) {
|
|
59
|
+
var key = 'docs-kit-hub:head:' + repo;
|
|
60
|
+
try {
|
|
61
|
+
var hit = JSON.parse(localStorage.getItem(key) || 'null');
|
|
62
|
+
if (hit && Date.now() - hit.at < TTL) return Promise.resolve(hit.value);
|
|
63
|
+
} catch (e) {}
|
|
64
|
+
return getJson('https://api.github.com/repos/' + repo + '/commits/HEAD', { headers: { Accept: 'application/vnd.github+json' } }).then(function (c) {
|
|
65
|
+
var value = { sha: c.sha, date: c.commit && c.commit.committer ? c.commit.committer.date : null };
|
|
66
|
+
try { localStorage.setItem(key, JSON.stringify({ at: Date.now(), value: value })); } catch (e) {}
|
|
67
|
+
return value;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function settle(p) {
|
|
71
|
+
return p.then(function (v) { return { ok: true, value: v }; }, function (e) { return { ok: false, error: e }; });
|
|
72
|
+
}
|
|
73
|
+
function text(el, value) { el.textContent = value; }
|
|
74
|
+
products.forEach(function (p) {
|
|
75
|
+
var card = document.getElementById('product-' + p.product);
|
|
76
|
+
var badge = card.querySelector('.state');
|
|
77
|
+
var tagline = card.querySelector('.tagline');
|
|
78
|
+
var meta = card.querySelector('.meta');
|
|
79
|
+
Promise.all([
|
|
80
|
+
settle(getJson('/' + p.product + '/docs-bundle.json', { cache: 'no-cache' })),
|
|
81
|
+
settle(mainCommit(p.repo))
|
|
82
|
+
]).then(function (r) {
|
|
83
|
+
var bundle = r[0].ok && r[0].value && [1, 2].includes(r[0].value.schemaVersion) ? r[0].value : null;
|
|
84
|
+
var main = r[1].ok ? r[1].value : null;
|
|
85
|
+
var state, detail;
|
|
86
|
+
if (!bundle) {
|
|
87
|
+
state = 'unpublished';
|
|
88
|
+
detail = 'No docs bundle is published at /' + p.product + '/docs-bundle.json.';
|
|
89
|
+
} else {
|
|
90
|
+
var docs = (bundle.documents || []).filter(function (d) { return !d.landing; }).length, topics = (bundle.topics || []).length;
|
|
91
|
+
var counts = docs + ' doc' + (docs === 1 ? '' : 's') + ', ' + topics + ' topic' + (topics === 1 ? '' : 's');
|
|
92
|
+
var built = 'built ' + ago(bundle.builtAt) + ' from ' + String(bundle.sha || '').slice(0, 7);
|
|
93
|
+
if (bundle.tagline) text(tagline, bundle.tagline);
|
|
94
|
+
if (main && main.sha && bundle.sha !== main.sha) {
|
|
95
|
+
state = 'behind';
|
|
96
|
+
detail = counts + '; ' + built + '; HEAD is ' + main.sha.slice(0, 7) + (main.date ? ', committed ' + ago(main.date) : '') + '.';
|
|
97
|
+
} else if (docs < 3) {
|
|
98
|
+
state = 'thin';
|
|
99
|
+
detail = counts + '; ' + built + '. Fewer than 3 documents.';
|
|
100
|
+
} else if (main && main.sha) {
|
|
101
|
+
state = 'current';
|
|
102
|
+
detail = counts + '; ' + built + ', matching HEAD.';
|
|
103
|
+
} else {
|
|
104
|
+
state = 'unknown';
|
|
105
|
+
detail = counts + '; ' + built + '. Could not read the default branch from GitHub to compare.';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
badge.className = 'state state-' + state;
|
|
109
|
+
text(badge, state);
|
|
110
|
+
text(meta, detail);
|
|
111
|
+
card.setAttribute('data-state', state);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
})();
|
|
115
|
+
`;
|
|
116
|
+
|
|
117
|
+
function productCard({ product, repo }) {
|
|
118
|
+
return `<li class="product" id="product-${escapeHtml(product)}" data-state="checking">
|
|
119
|
+
<h2><a href="/${escapeHtml(product)}/">${escapeHtml(product)}</a> <span class="state state-checking">checking</span></h2>
|
|
120
|
+
<p class="tagline"></p>
|
|
121
|
+
<p class="meta" aria-live="polite">Loading the published docs bundle…</p>
|
|
122
|
+
<p class="links"><a href="/${escapeHtml(product)}/">Docs site</a><a href="https://github.com/${escapeHtml(repo)}">${escapeHtml(repo)}</a></p>
|
|
123
|
+
</li>`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function renderHub(products) {
|
|
127
|
+
// `<` is escaped so product data can never close the script element.
|
|
128
|
+
const data = JSON.stringify(products).replace(/</g, '\\u003c');
|
|
129
|
+
return `<!doctype html>
|
|
130
|
+
<html lang="en">
|
|
131
|
+
<head>
|
|
132
|
+
<meta charset="utf-8">
|
|
133
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
134
|
+
<title>Kontour documentation</title>
|
|
135
|
+
<meta name="description" content="Documentation for every Kontour building block, and one MCP server that serves all of it to agents.">
|
|
136
|
+
<meta name="generator" content="@kontourai/corpus">
|
|
137
|
+
<style>${HUB_CSS}</style>
|
|
138
|
+
</head>
|
|
139
|
+
<body>
|
|
140
|
+
<a class="skip-link" href="#main">Skip to content</a>
|
|
141
|
+
<header class="site-header">
|
|
142
|
+
<a class="brand" href="./">Kontour docs</a>
|
|
143
|
+
<nav aria-label="Site"><a href="https://github.com/kontourai">GitHub</a><a href="#install">MCP server</a></nav>
|
|
144
|
+
</header>
|
|
145
|
+
<main id="main">
|
|
146
|
+
<h1>Kontour documentation</h1>
|
|
147
|
+
<p class="lede">Every Kontour building block publishes its docs here from its own repository. Status compares each published bundle with the HEAD of the repository's default branch.</p>
|
|
148
|
+
<ul class="legend" aria-label="Status legend">
|
|
149
|
+
<li><span class="state state-current">current</span> built from the default branch HEAD</li>
|
|
150
|
+
<li><span class="state state-behind">behind</span> the default branch has moved since the build</li>
|
|
151
|
+
<li><span class="state state-thin">thin</span> fewer than 3 documents</li>
|
|
152
|
+
<li><span class="state state-unpublished">unpublished</span> no bundle found</li>
|
|
153
|
+
<li><span class="state state-unknown">unknown</span> GitHub could not be reached to compare</li>
|
|
154
|
+
</ul>
|
|
155
|
+
<ul class="products">
|
|
156
|
+
${products.map(productCard).join('\n')}
|
|
157
|
+
</ul>
|
|
158
|
+
<section class="install" id="install" aria-labelledby="install-heading">
|
|
159
|
+
<h2 id="install-heading">Ask an agent about any building block</h2>
|
|
160
|
+
<p>The Corpus MCP server reads every published bundle listed here and answers <code>list_products</code>, <code>list_topics</code>, <code>get_topic</code>, <code>get_document</code>, and <code>search</code>. Replace <code><sha></code> with a commit of <a href="https://github.com/kontourai/corpus">kontourai/corpus</a>.</p>
|
|
161
|
+
<pre><code>npx -y github:kontourai/corpus#<sha> corpus mcp</code></pre>
|
|
162
|
+
<p>Claude Code:</p>
|
|
163
|
+
<pre><code>claude mcp add kontour-docs -- npx -y github:kontourai/corpus#<sha> corpus mcp</code></pre>
|
|
164
|
+
<p>Codex (<code>~/.codex/config.toml</code>):</p>
|
|
165
|
+
<pre><code>[mcp_servers.kontour-docs]
|
|
166
|
+
command = "npx"
|
|
167
|
+
args = ["-y", "github:kontourai/corpus#<sha>", "corpus", "mcp"]</code></pre>
|
|
168
|
+
</section>
|
|
169
|
+
</main>
|
|
170
|
+
<footer class="site-footer">Generated by <a href="https://github.com/kontourai/corpus">Corpus</a> from <a href="products.json">products.json</a>.</footer>
|
|
171
|
+
<script type="application/json" id="products-data">${data}</script>
|
|
172
|
+
<script>${HUB_SCRIPT}</script>
|
|
173
|
+
</body>
|
|
174
|
+
</html>
|
|
175
|
+
`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** `docs-kit hub`: validate products.json strictly, write index.html and a copy of products.json. */
|
|
179
|
+
export function runHub({ products: productsFile, out }) {
|
|
180
|
+
let text;
|
|
181
|
+
try {
|
|
182
|
+
text = readFileSync(productsFile, 'utf8');
|
|
183
|
+
} catch (error) {
|
|
184
|
+
throw new UsageError(`cannot read ${productsFile}: ${error.code === 'ENOENT' ? 'file not found' : error.message}`);
|
|
185
|
+
}
|
|
186
|
+
let raw;
|
|
187
|
+
try {
|
|
188
|
+
raw = JSON.parse(text);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
throw new UsageError(`${productsFile} is not valid JSON: ${error.message}`);
|
|
191
|
+
}
|
|
192
|
+
const { products, errors } = validateProducts(raw);
|
|
193
|
+
if (errors.length > 0) throw new UsageError(`${productsFile} is invalid:\n${errors.map((error) => ` - ${error}`).join('\n')}`);
|
|
194
|
+
mkdirSync(out, { recursive: true });
|
|
195
|
+
writeFileSync(path.join(out, 'index.html'), renderHub(products));
|
|
196
|
+
writeFileSync(path.join(out, 'products.json'), text);
|
|
197
|
+
return `docs-kit hub: ${products.length} products -> ${out}`;
|
|
198
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Library entry. The CLI (bin/docs-kit.mjs) is the supported interface; these
|
|
2
|
+
// exports exist for tests and for tools that embed docs-kit.
|
|
3
|
+
export { runChecks, runDecisionChecks, formatCheckReport } from './check.mjs';
|
|
4
|
+
export { parseDecisionRecord, validateDecisionRecord, validateDecisions, renderDecisionIndex, DECISION_STATUSES, EVIDENCE_KINDS } from './decisions.mjs';
|
|
5
|
+
export { runBuild, checkGeneratedSite } from './build.mjs';
|
|
6
|
+
export { assembleBundle, bundleDigest, bundleFromCheckout } from './bundle.mjs';
|
|
7
|
+
export { validateManifest, validateProducts, loadManifest } from './manifest.mjs';
|
|
8
|
+
export { analyzeMarkdown, githubSlug, extractSection } from './markdown.mjs';
|
|
9
|
+
export { createDocsServer, hubSource, repoSource, workspaceSource, staticSource, bundleFileSource, bundleShapeError, searchBundles } from './mcp.mjs';
|
|
10
|
+
export { renderHub } from './hub.mjs';
|
|
11
|
+
export { EXIT_OK, EXIT_GATE, EXIT_USAGE } from './errors.mjs';
|
|
12
|
+
export { readContentManifest, contentImpact, checkContentReviews } from './content.mjs';
|
package/src/init.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { UsageError } from './errors.mjs';
|
|
4
|
+
import { originRepo } from './git.mjs';
|
|
5
|
+
import { DEFAULT_LANDING, INIT_PURPOSE_PLACEHOLDER, manifestFile } from './manifest.mjs';
|
|
6
|
+
import { isRepoFile, listMarkdownFiles } from './paths.mjs';
|
|
7
|
+
|
|
8
|
+
function titleCase(value) {
|
|
9
|
+
return value
|
|
10
|
+
.replace(/[-_]+/g, ' ')
|
|
11
|
+
.replace(/\s+/g, ' ')
|
|
12
|
+
.trim()
|
|
13
|
+
.replace(/\b\p{L}/gu, (letter) => letter.toUpperCase());
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Section guess: the first directory under docs/, or "Documentation" for docs/*.md. */
|
|
17
|
+
export function guessSection(source) {
|
|
18
|
+
const parts = source.split('/');
|
|
19
|
+
return parts.length > 2 ? titleCase(parts[1]) : 'Documentation';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function slugify(value) {
|
|
23
|
+
return value.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[-._]+|[-._]+$/g, '') || 'product';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `corpus init`: write a starter v2 corpus.json. Never overwrites one, and
|
|
28
|
+
* refuses while a retired docs-kit.json is present (manifestFile throws).
|
|
29
|
+
*/
|
|
30
|
+
export function runInit({ root }) {
|
|
31
|
+
const target = path.join(root, manifestFile(root));
|
|
32
|
+
if (existsSync(target)) throw new UsageError(`${target} already exists; refusing to overwrite it`);
|
|
33
|
+
|
|
34
|
+
const repo = originRepo(root) ?? `kontourai/${slugify(path.basename(path.resolve(root)))}`;
|
|
35
|
+
const product = repo.split('/')[1];
|
|
36
|
+
let tagline = '';
|
|
37
|
+
try {
|
|
38
|
+
const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
39
|
+
if (typeof pkg.description === 'string') tagline = pkg.description.trim();
|
|
40
|
+
} catch {
|
|
41
|
+
// No package.json, or not JSON: leave the tagline for the author.
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const manifest = {
|
|
45
|
+
schemaVersion: 2,
|
|
46
|
+
product,
|
|
47
|
+
title: titleCase(product),
|
|
48
|
+
tagline,
|
|
49
|
+
repo,
|
|
50
|
+
purpose: INIT_PURPOSE_PLACEHOLDER,
|
|
51
|
+
builtOn: [],
|
|
52
|
+
landing: DEFAULT_LANDING,
|
|
53
|
+
documents: listMarkdownFiles(root, 'docs').map((source) => ({ source, section: guessSection(source) })),
|
|
54
|
+
topics: [],
|
|
55
|
+
exclude: [],
|
|
56
|
+
};
|
|
57
|
+
writeFileSync(target, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
58
|
+
const notes = [`corpus init: wrote ${target} with ${manifest.documents.length} documents`];
|
|
59
|
+
if (!isRepoFile(root, DEFAULT_LANDING)) notes.push(` note: ${DEFAULT_LANDING} does not exist; create it or set "landing"`);
|
|
60
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(product)) notes.push(` note: product "${product}" is not a lowercase slug; adjust product and repo`);
|
|
61
|
+
notes.push(' next: replace the "purpose" placeholder, list the Kontour products you build on in "builtOn" (or leave it empty), review sections and order, move unpublished docs to "exclude", add "topics" and "covers", then run corpus check');
|
|
62
|
+
return notes.join('\n');
|
|
63
|
+
}
|
package/src/locate.mjs
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { Lexer } from 'marked';
|
|
2
|
+
import { analyzeMarkdown, blockLines, decodeEntities, plainText } from './markdown.mjs';
|
|
3
|
+
import { encodePath } from './paths.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Find a quoted passage, typically text selected on a rendered docs page, in
|
|
7
|
+
* a bundle's Markdown and report where it lives in the source file.
|
|
8
|
+
*
|
|
9
|
+
* Both sides are reduced to a comparable form: letters and digits, lowercased
|
|
10
|
+
* and NFKD-decomposed with combining marks dropped, and every run of
|
|
11
|
+
* whitespace or punctuation collapsed to one space. Markdown that renders as
|
|
12
|
+
* nothing is dropped from the document side first: link and image
|
|
13
|
+
* destinations, HTML tags and comments, fence lines, list and task markers,
|
|
14
|
+
* and reference definitions. Entities are decoded. Emphasis and code marks
|
|
15
|
+
* (`*`, `_`, `~`, backticks) and backslashes are dropped on both sides
|
|
16
|
+
* without leaving a gap, so `**foo**bar` matches the rendered `foobar`. Code
|
|
17
|
+
* (fenced, indented, and code spans) renders literally, so nothing inside it
|
|
18
|
+
* is dropped except those marks. Each kept character remembers its offset in
|
|
19
|
+
* the original Markdown, so a match maps back to exact lines.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const DROPPED = /[*_~`\\]/;
|
|
23
|
+
const WORD = /[\p{L}\p{N}]/u;
|
|
24
|
+
const MARK = /\p{M}/u;
|
|
25
|
+
const MIN_QUOTE = 3;
|
|
26
|
+
const ANCHOR_WORDS = 4;
|
|
27
|
+
/** Alternatives returned for an ambiguous quote; `matchCount` reports the total. */
|
|
28
|
+
export const MAX_ALTERNATIVES = 10;
|
|
29
|
+
|
|
30
|
+
/** Line-start offsets, counting CRLF, a lone CR, and LF as one break each, as the Markdown lexer does. */
|
|
31
|
+
function lineIndex(text) {
|
|
32
|
+
const starts = [0];
|
|
33
|
+
for (let i = 0; i < text.length; i++) {
|
|
34
|
+
const code = text.charCodeAt(i);
|
|
35
|
+
if (code === 10 || (code === 13 && text.charCodeAt(i + 1) !== 10)) starts.push(i + 1);
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
starts,
|
|
39
|
+
/** 1-based line of a character offset. */
|
|
40
|
+
lineOf(offset) {
|
|
41
|
+
let low = 0;
|
|
42
|
+
let high = starts.length - 1;
|
|
43
|
+
while (low < high) {
|
|
44
|
+
const mid = (low + high + 1) >> 1;
|
|
45
|
+
if (starts[mid] <= offset) low = mid;
|
|
46
|
+
else high = mid - 1;
|
|
47
|
+
}
|
|
48
|
+
return low + 1;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const LIST_MARKER = /^(?:[-+*]|\d{1,9}[.)])(?:[ \t]|$)/;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Offsets that are code content (rendered literally): fenced and indented
|
|
57
|
+
* code lines, and code spans. Fences are found by a line scanner, so ones
|
|
58
|
+
* nested in lists and blockquotes count too. An opener may follow `>` and
|
|
59
|
+
* list markers; it may be indented four or more columns only inside a list
|
|
60
|
+
* (elsewhere that is an indented code line, not a fence). A fence opened in
|
|
61
|
+
* a blockquote ends with the blockquote.
|
|
62
|
+
*/
|
|
63
|
+
function codeRanges(markdown, index) {
|
|
64
|
+
const literal = new Uint8Array(markdown.length);
|
|
65
|
+
const lineSpan = (first, last) => {
|
|
66
|
+
if (last < first) return;
|
|
67
|
+
const from = index.starts[first - 1];
|
|
68
|
+
const to = last < index.starts.length ? index.starts[last] : markdown.length;
|
|
69
|
+
literal.fill(1, from, to);
|
|
70
|
+
};
|
|
71
|
+
const lines = markdown.split(/\r\n|\r|\n/);
|
|
72
|
+
let open = null;
|
|
73
|
+
let inList = false;
|
|
74
|
+
lines.forEach((line, i) => {
|
|
75
|
+
const quoted = /^[ \t]*>/.test(line);
|
|
76
|
+
let rest = line.replace(/^(?:[ \t]*>)+/, '');
|
|
77
|
+
const indent = /^[ \t]*/.exec(rest)[0].replace(/\t/g, ' ').length;
|
|
78
|
+
rest = rest.trimStart();
|
|
79
|
+
let marker = false;
|
|
80
|
+
for (let m = LIST_MARKER.exec(rest); m; m = LIST_MARKER.exec(rest)) {
|
|
81
|
+
marker = true;
|
|
82
|
+
rest = rest.slice(m[0].length).trimStart();
|
|
83
|
+
}
|
|
84
|
+
if (marker) inList = true;
|
|
85
|
+
else if (rest !== '' && indent === 0 && !quoted && open === null) inList = false;
|
|
86
|
+
const fence = /^(`{3,}|~{3,})(.*)$/.exec(rest);
|
|
87
|
+
if (open !== null) {
|
|
88
|
+
if (open.quoted && !quoted) {
|
|
89
|
+
lineSpan(open.first, i); // the blockquote ended, and the fence with it
|
|
90
|
+
open = null;
|
|
91
|
+
} else {
|
|
92
|
+
if (fence && fence[1][0] === open.char && fence[1].length >= open.length && fence[2].trim() === '') {
|
|
93
|
+
lineSpan(open.first, i);
|
|
94
|
+
open = null;
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!fence || (indent >= 4 && !marker && !inList)) return;
|
|
100
|
+
// A backtick fence's info string cannot contain a backtick.
|
|
101
|
+
if (fence[1][0] === '`' && fence[2].includes('`')) return;
|
|
102
|
+
open = { first: i + 2, char: fence[1][0], length: fence[1].length, quoted };
|
|
103
|
+
});
|
|
104
|
+
if (open !== null) lineSpan(open.first, lines.length);
|
|
105
|
+
// Indented code blocks are top-level code tokens without a fence.
|
|
106
|
+
for (const { token, start, end } of blockLines(Lexer.lex(markdown, { gfm: true })))
|
|
107
|
+
if (token.type === 'code' && !/^[ \t]*(?:`{3,}|~{3,})/.test(token.raw)) lineSpan(start, end);
|
|
108
|
+
// Code spans, per paragraph: a backtick run closes at the next run of the
|
|
109
|
+
// same length; an unmatched run is literal. Runs are found once, so time
|
|
110
|
+
// stays linear in the paragraph, whatever its backticks.
|
|
111
|
+
for (const paragraph of markdown.matchAll(/[^\n]+(?:\n(?![ \t]*\r?$)[^\n]*)*/gm)) {
|
|
112
|
+
const runs = [...paragraph[0].matchAll(/`+/g)].map((run) => ({ at: paragraph.index + run.index, length: run[0].length }));
|
|
113
|
+
const next = new Map(); // length -> indexes of runs with that length, in order
|
|
114
|
+
runs.forEach((run, k) => {
|
|
115
|
+
if (!next.has(run.length)) next.set(run.length, []);
|
|
116
|
+
next.get(run.length).push(k);
|
|
117
|
+
});
|
|
118
|
+
const cursor = new Map();
|
|
119
|
+
for (let k = 0; k < runs.length; ) {
|
|
120
|
+
const { at, length } = runs[k];
|
|
121
|
+
const same = next.get(length);
|
|
122
|
+
let c = cursor.get(length) ?? 0;
|
|
123
|
+
while (c < same.length && same[c] <= k) c++;
|
|
124
|
+
cursor.set(length, c);
|
|
125
|
+
if (c >= same.length || literal[at]) {
|
|
126
|
+
k++;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const close = same[c];
|
|
130
|
+
literal.fill(1, at + length, runs[close].at);
|
|
131
|
+
k = close + 1;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return literal;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const ENTITY = /&(?:#x[\da-f]+|#\d+|[a-z][a-z\d]*);/gi;
|
|
138
|
+
|
|
139
|
+
/** Character ranges of Markdown that render as nothing, and entities to decode, outside code. */
|
|
140
|
+
function markdownMask(markdown, index) {
|
|
141
|
+
const literal = codeRanges(markdown, index);
|
|
142
|
+
const hidden = new Uint8Array(markdown.length);
|
|
143
|
+
const patterns = [
|
|
144
|
+
/\]\((?:[^()\s]|\([^()]*\))*(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/g, // ](destination "title")
|
|
145
|
+
/\]\[[^\]\n]*\]/g, // ][reference]
|
|
146
|
+
/^ {0,3}\[[^\]\n]+\]:.*$/gm, // [reference]: definition
|
|
147
|
+
/<!--[\s\S]*?-->/g,
|
|
148
|
+
/<\/?[A-Za-z][A-Za-z0-9-]*(?:\s[^<>]*)?\/?>/g, // tags, but not autolinks such as <https://x>
|
|
149
|
+
/^[ \t]*(?:`{3,}|~{3,}).*$/gm, // fence lines and their info strings
|
|
150
|
+
/^[ \t>]*(?:[-+*]|\d{1,9}[.)])[ \t]+(?:\[[ xX]\][ \t]+)?/gm, // list and task markers
|
|
151
|
+
];
|
|
152
|
+
for (const pattern of patterns)
|
|
153
|
+
for (const match of markdown.matchAll(pattern)) {
|
|
154
|
+
if (literal[match.index]) continue;
|
|
155
|
+
// A `](...)` or `][...]` match keeps its `]`; only the destination is hidden.
|
|
156
|
+
const from = match[0].startsWith('](') || match[0].startsWith('][') ? match.index + 1 : match.index;
|
|
157
|
+
for (let i = from; i < match.index + match[0].length; i++) if (!literal[i]) hidden[i] = 1;
|
|
158
|
+
}
|
|
159
|
+
const entities = new Map();
|
|
160
|
+
for (const match of markdown.matchAll(ENTITY)) {
|
|
161
|
+
if (literal[match.index] || hidden[match.index]) continue;
|
|
162
|
+
const decoded = decodeEntities(match[0]);
|
|
163
|
+
if (decoded === match[0]) hidden.fill(1, match.index, match.index + match[0].length);
|
|
164
|
+
else entities.set(match.index, { end: match.index + match[0].length, text: decoded });
|
|
165
|
+
}
|
|
166
|
+
return { hidden, entities };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The comparable form of `text`, plus each output character's offset in `text`. */
|
|
170
|
+
export function normalizeForMatch(text, { markdown = false, index = null } = {}) {
|
|
171
|
+
const { hidden, entities } = markdown ? markdownMask(text, index ?? lineIndex(text)) : { hidden: null, entities: new Map() };
|
|
172
|
+
let normalized = '';
|
|
173
|
+
const offsets = [];
|
|
174
|
+
// Tracked here: endsWith on a string built with += flattens it on every call.
|
|
175
|
+
let open = false;
|
|
176
|
+
const separator = (offset) => {
|
|
177
|
+
if (open) {
|
|
178
|
+
normalized += ' ';
|
|
179
|
+
offsets.push(offset);
|
|
180
|
+
open = false;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
const emit = (value, offset) => {
|
|
184
|
+
const code = value.charCodeAt(0);
|
|
185
|
+
// ASCII fast path; everything else goes through NFKD.
|
|
186
|
+
if (value.length === 1 && code < 128) {
|
|
187
|
+
if ((code >= 48 && code <= 57) || (code >= 97 && code <= 122)) {
|
|
188
|
+
normalized += value;
|
|
189
|
+
offsets.push(offset);
|
|
190
|
+
open = true;
|
|
191
|
+
} else if (code >= 65 && code <= 90) {
|
|
192
|
+
normalized += String.fromCharCode(code + 32);
|
|
193
|
+
offsets.push(offset);
|
|
194
|
+
open = true;
|
|
195
|
+
} else if (!DROPPED.test(value)) separator(offset);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
for (const part of value.normalize('NFKD').toLowerCase()) {
|
|
199
|
+
if (MARK.test(part) || DROPPED.test(part)) continue;
|
|
200
|
+
if (WORD.test(part)) {
|
|
201
|
+
normalized += part;
|
|
202
|
+
for (let k = 0; k < part.length; k++) offsets.push(offset);
|
|
203
|
+
open = true;
|
|
204
|
+
} else separator(offset);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
for (let i = 0; i < text.length; ) {
|
|
208
|
+
const entity = entities.get(i);
|
|
209
|
+
if (entity) {
|
|
210
|
+
emit(entity.text, i);
|
|
211
|
+
i = entity.end;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const char = String.fromCodePoint(text.codePointAt(i));
|
|
215
|
+
if (!hidden?.[i]) emit(char, i);
|
|
216
|
+
i += char.length;
|
|
217
|
+
}
|
|
218
|
+
return { normalized, offsets };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Every start index of `needle` in `haystack`. */
|
|
222
|
+
function occurrences(haystack, needle) {
|
|
223
|
+
const found = [];
|
|
224
|
+
for (let at = haystack.indexOf(needle); at !== -1; at = haystack.indexOf(needle, at + 1)) found.push(at);
|
|
225
|
+
return found;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Top-level blocks with body lines, and the top-level headings with the id
|
|
230
|
+
* the page gives them (analyzeMarkdown is what the site build slugs with).
|
|
231
|
+
*/
|
|
232
|
+
function documentOutline(markdown) {
|
|
233
|
+
const blocks = blockLines(analyzeMarkdown(markdown).tokens);
|
|
234
|
+
const headings = blocks
|
|
235
|
+
.filter(({ token }) => token.type === 'heading')
|
|
236
|
+
.map(({ token, start }) => ({ line: start, text: plainText(token.tokens).trim(), anchor: token.slugId }));
|
|
237
|
+
return { blocks, headings };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const collapse = (text) => text.replace(/\s+/g, ' ');
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Locate `quote` in the documents of `bundle` (optionally only `source`).
|
|
244
|
+
* Returns { best, alternatives, matchCount, confidence, documentsSearched }
|
|
245
|
+
* (`best` is null when nothing matches), or { error }. Matching counts every
|
|
246
|
+
* candidate cheaply; only the best and up to MAX_ALTERNATIVES others are
|
|
247
|
+
* described in full: the source, the 1-based file line range, the enclosing
|
|
248
|
+
* heading and anchor, the surrounding Markdown blocks, the GitHub blob URL
|
|
249
|
+
* with #Lstart-Lend, and the page URL.
|
|
250
|
+
*
|
|
251
|
+
* Ranking and confidence: verbatim matches (the quote, whitespace collapsed,
|
|
252
|
+
* appears as written) come first, then normalized ones, each group in
|
|
253
|
+
* document order. Confidence is 1 for a verbatim best match, 0.9 for a
|
|
254
|
+
* normalized one, and 0.5 for the head-and-tail fallback (the first and last
|
|
255
|
+
* four words in order within a nearby window, used only when nothing matches
|
|
256
|
+
* whole), divided by the number of candidates.
|
|
257
|
+
*/
|
|
258
|
+
export function locateInBundle(bundle, quote, { source } = {}) {
|
|
259
|
+
const wanted = normalizeForMatch(quote).normalized.trim();
|
|
260
|
+
if (wanted.replace(/ /g, '').length < MIN_QUOTE) return { error: `quote is too short to locate; it needs at least ${MIN_QUOTE} letters or digits` };
|
|
261
|
+
const documents = source === undefined ? bundle.documents : bundle.documents.filter((document) => document.source === source);
|
|
262
|
+
if (documents.length === 0) return { error: `unknown document "${source}"`, availableDocuments: bundle.documents.map((document) => document.source) };
|
|
263
|
+
|
|
264
|
+
// The quote as written, split around its first and last letter or digit, for the verbatim check.
|
|
265
|
+
const written = collapse(quote).trim();
|
|
266
|
+
const core = written.replace(/^[^\p{L}\p{N}]+/u, '').replace(/[^\p{L}\p{N}]+$/u, '');
|
|
267
|
+
const lead = written.slice(0, written.indexOf(core));
|
|
268
|
+
const trail = written.slice(written.indexOf(core) + core.length);
|
|
269
|
+
|
|
270
|
+
const spansIn = (normalized) => {
|
|
271
|
+
const exact = occurrences(normalized, wanted).map((at) => ({ from: at, to: at + wanted.length, exact: true }));
|
|
272
|
+
if (exact.length > 0) return exact;
|
|
273
|
+
const words = wanted.split(' ');
|
|
274
|
+
if (words.length < ANCHOR_WORDS * 2) return [];
|
|
275
|
+
const head = words.slice(0, ANCHOR_WORDS).join(' ');
|
|
276
|
+
const tail = words.slice(-ANCHOR_WORDS).join(' ');
|
|
277
|
+
const spans = [];
|
|
278
|
+
for (const at of occurrences(normalized, head)) {
|
|
279
|
+
const end = normalized.indexOf(tail, at + head.length);
|
|
280
|
+
if (end !== -1 && end + tail.length - at <= wanted.length * 1.5 + 200) spans.push({ from: at, to: end + tail.length, exact: false });
|
|
281
|
+
}
|
|
282
|
+
return spans;
|
|
283
|
+
};
|
|
284
|
+
let candidates = documents.flatMap((document) => {
|
|
285
|
+
const index = lineIndex(document.markdown);
|
|
286
|
+
const { normalized, offsets } = normalizeForMatch(document.markdown, { markdown: true, index });
|
|
287
|
+
const { markdown } = document;
|
|
288
|
+
return spansIn(normalized).map(({ from, to, exact }) => {
|
|
289
|
+
const start = offsets[from];
|
|
290
|
+
const last = offsets[to - 1];
|
|
291
|
+
const end = last + (markdown.codePointAt(last) > 0xffff ? 2 : 1);
|
|
292
|
+
const verbatim =
|
|
293
|
+
exact &&
|
|
294
|
+
collapse(markdown.slice(start, end)) === core &&
|
|
295
|
+
collapse(markdown.slice(Math.max(0, start - lead.length), start)) === lead &&
|
|
296
|
+
collapse(markdown.slice(end, end + trail.length)) === trail;
|
|
297
|
+
return { document, index, start, end, exact, verbatim };
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
if (candidates.some((candidate) => candidate.exact)) candidates = candidates.filter((candidate) => candidate.exact);
|
|
301
|
+
if (candidates.length === 0) return { best: null, documentsSearched: documents.length };
|
|
302
|
+
const ordered = [...candidates.filter((candidate) => candidate.verbatim), ...candidates.filter((candidate) => !candidate.verbatim)];
|
|
303
|
+
const best = ordered[0];
|
|
304
|
+
const base = best.verbatim ? 1 : best.exact ? 0.9 : 0.5;
|
|
305
|
+
|
|
306
|
+
const outlines = new Map();
|
|
307
|
+
const describe = ({ document, index, start, end, exact, verbatim }) => {
|
|
308
|
+
if (!outlines.has(document)) outlines.set(document, { ...documentOutline(document.markdown), lines: document.markdown.split(/\r\n|\r|\n/) });
|
|
309
|
+
const { blocks, headings, lines } = outlines.get(document);
|
|
310
|
+
const lineOffset = document.lineOffset ?? 0;
|
|
311
|
+
const repoPath = document.repoPath ?? document.source;
|
|
312
|
+
const bodyStart = index.lineOf(start);
|
|
313
|
+
const bodyEnd = index.lineOf(end - 1);
|
|
314
|
+
const heading = headings.filter((entry) => entry.line <= bodyStart).at(-1) ?? null;
|
|
315
|
+
const around = blocks.filter((block) => block.end >= bodyStart && block.start <= bodyEnd);
|
|
316
|
+
const contextStart = Math.min(bodyStart, ...around.map((block) => block.start));
|
|
317
|
+
const contextEnd = Math.max(bodyEnd, ...around.map((block) => block.end));
|
|
318
|
+
const startLine = bodyStart + lineOffset;
|
|
319
|
+
const endLine = bodyEnd + lineOffset;
|
|
320
|
+
return {
|
|
321
|
+
source: document.source,
|
|
322
|
+
repoPath,
|
|
323
|
+
startLine,
|
|
324
|
+
endLine,
|
|
325
|
+
heading: heading?.text ?? null,
|
|
326
|
+
anchor: heading?.anchor ?? null,
|
|
327
|
+
url: heading ? `${document.url}#${encodeURIComponent(heading.anchor)}` : document.url,
|
|
328
|
+
blobUrl: `https://github.com/${bundle.repo}/blob/${bundle.sha ?? 'HEAD'}/${encodePath(repoPath)}#L${startLine}-L${endLine}`,
|
|
329
|
+
markdown: `${lines.slice(contextStart - 1, contextEnd).join('\n')}\n`,
|
|
330
|
+
match: verbatim ? 'verbatim' : exact ? 'normalized' : 'head-and-tail',
|
|
331
|
+
...(document.lineOffset === undefined ? { note: 'this bundle predates docs-kit 0.6.0, so line numbers assume the file has no front matter' } : {}),
|
|
332
|
+
};
|
|
333
|
+
};
|
|
334
|
+
return {
|
|
335
|
+
best: describe(best),
|
|
336
|
+
alternatives: ordered.slice(1, 1 + MAX_ALTERNATIVES).map(describe),
|
|
337
|
+
matchCount: ordered.length,
|
|
338
|
+
confidence: Math.max(0.01, Math.round((base / ordered.length) * 100) / 100),
|
|
339
|
+
documentsSearched: documents.length,
|
|
340
|
+
};
|
|
341
|
+
}
|