@docsworth/cli 0.2.0 → 0.3.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 +60 -8
- package/dist/cli.js +52 -3
- package/dist/core/acl.d.ts +9 -0
- package/dist/core/acl.js +40 -6
- package/dist/core/adopt.d.ts +68 -0
- package/dist/core/adopt.js +665 -0
- package/dist/core/config.d.ts +6 -1
- package/dist/core/config.js +46 -2
- package/dist/core/edits.d.ts +13 -0
- package/dist/core/edits.js +20 -0
- package/dist/core/frontmatter.d.ts +12 -0
- package/dist/core/frontmatter.js +16 -7
- package/dist/core/gfm.d.ts +13 -0
- package/dist/core/gfm.js +244 -0
- package/dist/core/links.d.ts +82 -0
- package/dist/core/links.js +345 -0
- package/dist/core/lint.d.ts +1 -2
- package/dist/core/lint.js +40 -6
- package/dist/core/mcp/server.js +11 -2
- package/dist/core/printable.d.ts +4 -0
- package/dist/core/printable.js +16 -0
- package/dist/core/references.d.ts +38 -3
- package/dist/core/references.js +543 -41
- package/dist/core/repo.d.ts +4 -0
- package/dist/core/repo.js +3 -1
- package/dist/core/schema.d.ts +4 -0
- package/dist/core/schema.js +8 -6
- package/dist/core/search.d.ts +8 -0
- package/dist/core/search.js +34 -0
- package/dist/core/serve/server.d.ts +1 -2
- package/dist/core/serve/sessions.d.ts +5 -4
- package/dist/core/serve/sessions.js +102 -29
- package/dist/core/service.d.ts +118 -20
- package/dist/core/service.js +249 -91
- package/dist/core/stale.js +2 -1
- package/dist/core/tool-registry.js +6 -3
- package/dist/core/verify-pages.d.ts +19 -0
- package/dist/core/verify-pages.js +110 -0
- package/dist/core/version.d.ts +2 -2
- package/dist/core/version.js +2 -2
- package/dist/web/assets/index-8J-FUbb0.js +29 -0
- package/dist/web/assets/index-BGpAZzeM.css +1 -0
- package/dist/web/index.html +2 -2
- package/docs/format-spec.md +40 -2
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/bin/docsworth-mcp.mjs +1 -1
- package/dist/web/assets/index-BglLMIyN.css +0 -1
- package/dist/web/assets/index-DugFPoRu.js +0 -25
package/dist/core/config.d.ts
CHANGED
|
@@ -19,7 +19,9 @@ declare const MirrorSourceSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
19
19
|
}, z.core.$strip>], "kind">;
|
|
20
20
|
export declare const ConfigSchema: z.ZodObject<{
|
|
21
21
|
format: z.ZodDefault<z.ZodLiteral<1>>;
|
|
22
|
-
knowledge_root: z.ZodDefault<z.ZodString
|
|
22
|
+
knowledge_root: z.ZodDefault<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
|
|
23
|
+
static_dirs: z.ZodDefault<z.ZodArray<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>>;
|
|
24
|
+
site_url: z.ZodOptional<z.ZodString>;
|
|
23
25
|
github: z.ZodObject<{
|
|
24
26
|
org: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
|
|
25
27
|
repo: z.ZodOptional<z.ZodString>;
|
|
@@ -40,6 +42,9 @@ export declare const ConfigSchema: z.ZodObject<{
|
|
|
40
42
|
skills: z.ZodDefault<z.ZodObject<{
|
|
41
43
|
out_dir: z.ZodDefault<z.ZodString>;
|
|
42
44
|
}, z.core.$strip>>;
|
|
45
|
+
frontmatter: z.ZodDefault<z.ZodObject<{
|
|
46
|
+
extra_fields: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
47
|
+
}, z.core.$strict>>;
|
|
43
48
|
mirror: z.ZodDefault<z.ZodObject<{
|
|
44
49
|
out_dir: z.ZodDefault<z.ZodString>;
|
|
45
50
|
sources: z.ZodDefault<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
package/dist/core/config.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { join, posix } from "node:path";
|
|
3
3
|
import { parse as parseYaml } from "yaml";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { UserError } from "./errors.js";
|
|
@@ -26,9 +26,47 @@ const MirrorSourceSchema = z.discriminatedUnion("kind", [
|
|
|
26
26
|
acl: z.string().default("internal"),
|
|
27
27
|
}),
|
|
28
28
|
]);
|
|
29
|
+
/**
|
|
30
|
+
* A directory in the repo, in one spelling: `knowledge/`, `./knowledge` and `knowledge//` are
|
|
31
|
+
* `knowledge`, since paths join to it and a trailing slash would double. It's named relative to
|
|
32
|
+
* the repo, which redaction and CODEOWNERS paths are built on: the repo itself (`.`, or `/`,
|
|
33
|
+
* which a join makes the same), a directory above it, an absolute path and a Windows separator
|
|
34
|
+
* are refused. So are git's own directory (a ref named `x.md` is a file adopt would write) and
|
|
35
|
+
* Docsworth's state.
|
|
36
|
+
*/
|
|
37
|
+
function repoDir(what) {
|
|
38
|
+
return z
|
|
39
|
+
.string()
|
|
40
|
+
.transform((s) => posix.normalize(s).replace(/(.)\/+$/, "$1"))
|
|
41
|
+
.refine((s) => s !== "." && s !== ".." && !s.startsWith("../") && !s.startsWith("/") && !/^[A-Za-z]:/.test(s) && !s.includes("\\"), `${what} is a directory in the repo, named relative to it: not the repo itself (.), not above it (..), not absolute; such as \`knowledge\``)
|
|
42
|
+
.refine((s) => !s.split("/").some((seg) => seg.toLowerCase() === ".git") && !/^\.knowledge(\/|$)/i.test(s), `${what} can't be in git's own directory (.git) or Docsworth's (.knowledge)`);
|
|
43
|
+
}
|
|
29
44
|
export const ConfigSchema = z.object({
|
|
30
45
|
format: z.literal(1).default(1),
|
|
31
|
-
knowledge_root:
|
|
46
|
+
knowledge_root: repoDir("the knowledge root").default("knowledge"),
|
|
47
|
+
/**
|
|
48
|
+
* Directories a docs site serves at its root (Docusaurus's `static/`): lint reads a
|
|
49
|
+
* root-relative attachment link (`/img/x.png`) from them too.
|
|
50
|
+
*/
|
|
51
|
+
static_dirs: z.array(repoDir("a static directory")).default([]),
|
|
52
|
+
/**
|
|
53
|
+
* Where the team's docs site serves the pages, as `https://docs.example.com/docs/`.
|
|
54
|
+
* Redaction reads a link to a URL under it as the route it names.
|
|
55
|
+
*/
|
|
56
|
+
site_url: z
|
|
57
|
+
.string()
|
|
58
|
+
.url()
|
|
59
|
+
.superRefine((u, ctx) => {
|
|
60
|
+
// `.url()` has reported a value that isn't one; this runs all the same.
|
|
61
|
+
const at = URL.canParse(u) ? new URL(u) : undefined;
|
|
62
|
+
if (!at)
|
|
63
|
+
return;
|
|
64
|
+
if (at.protocol !== "https:" && at.protocol !== "http:")
|
|
65
|
+
ctx.addIssue({ code: "custom", message: "site_url must be an http or https URL" });
|
|
66
|
+
else if (at.username || at.password || at.search || at.hash)
|
|
67
|
+
ctx.addIssue({ code: "custom", message: "site_url is an origin and a path, with no credentials, query or fragment" });
|
|
68
|
+
})
|
|
69
|
+
.optional(),
|
|
32
70
|
github: z.object({
|
|
33
71
|
/** Team owners resolve to @<org>/<team>. Required even for local-only repos. */
|
|
34
72
|
org: z.string().min(1).transform((s) => s.toLowerCase()),
|
|
@@ -69,6 +107,12 @@ export const ConfigSchema = z.object({
|
|
|
69
107
|
.refine((a) => !a.required || a.enabled, { message: "audit_log.required needs audit_log.enabled: true (otherwise nothing is audited)" })
|
|
70
108
|
.default({ enabled: false, include_queries: false, required: false, retention_days: 365 }),
|
|
71
109
|
skills: z.object({ out_dir: z.string().default("skills") }).default({ out_dir: "skills" }),
|
|
110
|
+
/** Keys a site generator keeps in frontmatter (Docusaurus's `sidebar_position`, say): lint doesn't warn on them. */
|
|
111
|
+
frontmatter: z
|
|
112
|
+
.object({ extra_fields: z.array(z.string().min(1)).default([]) })
|
|
113
|
+
// `extra_field:` must fail, not quietly leave every key warned on.
|
|
114
|
+
.strict()
|
|
115
|
+
.default({ extra_fields: [] }),
|
|
72
116
|
mirror: z
|
|
73
117
|
.object({
|
|
74
118
|
out_dir: z.string().default(".knowledge/mirror"),
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface Edit {
|
|
2
|
+
/** Start offset in the original text. */
|
|
3
|
+
start: number;
|
|
4
|
+
/** End offset (exclusive) in the original text. */
|
|
5
|
+
end: number;
|
|
6
|
+
insert: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* `text` with each edit applied at its original offsets, rebuilt in one pass after sorting the
|
|
10
|
+
* edits (O(E log E) plus the text, not once per edit). Edits must not overlap; ones at the same
|
|
11
|
+
* offset apply in the order given.
|
|
12
|
+
*/
|
|
13
|
+
export declare function applyEdits(text: string, edits: readonly Edit[]): string;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Replacing ranges of a text by their original offsets: every rewrite `adopt` and `verify`
|
|
2
|
+
// make goes through here, so one edit never shifts another's place.
|
|
3
|
+
/**
|
|
4
|
+
* `text` with each edit applied at its original offsets, rebuilt in one pass after sorting the
|
|
5
|
+
* edits (O(E log E) plus the text, not once per edit). Edits must not overlap; ones at the same
|
|
6
|
+
* offset apply in the order given.
|
|
7
|
+
*/
|
|
8
|
+
export function applyEdits(text, edits) {
|
|
9
|
+
const sorted = edits.map((e, i) => ({ e, i })).sort((a, b) => a.e.start - b.e.start || a.e.end - b.e.end || a.i - b.i);
|
|
10
|
+
const parts = [];
|
|
11
|
+
let at = 0;
|
|
12
|
+
for (const { e } of sorted) {
|
|
13
|
+
if (e.start < at)
|
|
14
|
+
throw new Error(`edits overlap at offset ${e.start}`);
|
|
15
|
+
parts.push(text.slice(at, e.start), e.insert);
|
|
16
|
+
at = e.end;
|
|
17
|
+
}
|
|
18
|
+
parts.push(text.slice(at));
|
|
19
|
+
return parts.join("");
|
|
20
|
+
}
|
|
@@ -13,5 +13,17 @@ export declare const FRONTMATTER_MAX: number;
|
|
|
13
13
|
* isn't a plain number/bool/null stays a string, so dates round-trip exactly.
|
|
14
14
|
*/
|
|
15
15
|
export declare function splitFrontmatter(text: string): SplitDoc;
|
|
16
|
+
export interface FrontmatterSpan {
|
|
17
|
+
/** Where the YAML starts: just after the opening `---` line. */
|
|
18
|
+
start: number;
|
|
19
|
+
/** Where it ends, before the closing `---` line's own line break. */
|
|
20
|
+
end: number;
|
|
21
|
+
/** Where the body starts, after the closing `---` line. */
|
|
22
|
+
bodyStart: number;
|
|
23
|
+
/** The opening line's line ending. */
|
|
24
|
+
eol: "\n" | "\r\n";
|
|
25
|
+
}
|
|
26
|
+
/** Where a page's frontmatter sits in `text`, by offset; null without a terminated block. */
|
|
27
|
+
export declare function frontmatterSpan(text: string): FrontmatterSpan | null;
|
|
16
28
|
/** Serialize frontmatter + body back to a markdown file (used by writes and promote). */
|
|
17
29
|
export declare function joinFrontmatter(yaml: string, body: string): string;
|
package/dist/core/frontmatter.js
CHANGED
|
@@ -10,15 +10,13 @@ const CLOSE = /\r?\n---[ \t]*(\r?\n|$)/;
|
|
|
10
10
|
* isn't a plain number/bool/null stays a string, so dates round-trip exactly.
|
|
11
11
|
*/
|
|
12
12
|
export function splitFrontmatter(text) {
|
|
13
|
-
|
|
14
|
-
if (!open)
|
|
13
|
+
if (!OPEN.test(text))
|
|
15
14
|
return { data: null, body: text, error: "missing frontmatter block" };
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
if (!close)
|
|
15
|
+
const span = frontmatterSpan(text);
|
|
16
|
+
if (!span)
|
|
19
17
|
return { data: null, body: text, error: "unterminated frontmatter block" };
|
|
20
|
-
const yamlSrc =
|
|
21
|
-
const body =
|
|
18
|
+
const yamlSrc = text.slice(span.start, span.end);
|
|
19
|
+
const body = text.slice(span.bodyStart);
|
|
22
20
|
// A page's frontmatter is a few hundred bytes; YAML parsing slows past linear on
|
|
23
21
|
// hostile input (64 KB of `}&`: 650 ms, paid on every reload).
|
|
24
22
|
if (Buffer.byteLength(yamlSrc, "utf8") > FRONTMATTER_MAX)
|
|
@@ -34,6 +32,17 @@ export function splitFrontmatter(text) {
|
|
|
34
32
|
return { data: null, body, error: `invalid YAML: ${err.message.split("\n")[0]}` };
|
|
35
33
|
}
|
|
36
34
|
}
|
|
35
|
+
/** Where a page's frontmatter sits in `text`, by offset; null without a terminated block. */
|
|
36
|
+
export function frontmatterSpan(text) {
|
|
37
|
+
const open = OPEN.exec(text);
|
|
38
|
+
if (!open)
|
|
39
|
+
return null;
|
|
40
|
+
const start = open[0].length;
|
|
41
|
+
const close = CLOSE.exec(text.slice(start));
|
|
42
|
+
if (!close)
|
|
43
|
+
return null;
|
|
44
|
+
return { start, end: start + close.index, bodyStart: start + close.index + close[0].length, eol: open[0].length === 5 ? "\r\n" : "\n" };
|
|
45
|
+
}
|
|
37
46
|
/** Serialize frontmatter + body back to a markdown file (used by writes and promote). */
|
|
38
47
|
export function joinFrontmatter(yaml, body) {
|
|
39
48
|
return `---\n${yaml.trimEnd()}\n---\n${body.startsWith("\n") ? body : `\n${body}`}`;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Node } from "commonmark";
|
|
2
|
+
/**
|
|
3
|
+
* commonmark.js's tree for `markdown`, with GFM tables as tables (see above). The placeholder
|
|
4
|
+
* is never a link or an image, whatever a page writes around it: a definition for it, a
|
|
5
|
+
* `(target)` or `[label]` right after the link redaction replaced, or a link around it. A
|
|
6
|
+
* link or image labelled "restricted page" becomes the placeholder's text, and one whose
|
|
7
|
+
* label holds the placeholder becomes its label's text.
|
|
8
|
+
*/
|
|
9
|
+
export declare function parseGfm(markdown: string): Node;
|
|
10
|
+
/** The text and code inside `node`: a link's or image's label, as the reader sees it. */
|
|
11
|
+
export declare function textIn(node: Node): string;
|
|
12
|
+
/** A table cell (`th` or `td`) in a tree from `parseGfm`; its children are the cell's content. */
|
|
13
|
+
export declare function isTableCell(node: Node): boolean;
|
package/dist/core/gfm.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// GFM tables on commonmark.js: the parse the web app renders pages from
|
|
2
|
+
// (packages/ui/src/markdown.ts), and search's text snippets read (`plainText` in
|
|
3
|
+
// search.ts), so a snippet holds exactly the words the page shows.
|
|
4
|
+
import { Node, Parser } from "commonmark";
|
|
5
|
+
/**
|
|
6
|
+
* Whether a line, indented as it was, starts a block of its own: CommonMark's own grammar,
|
|
7
|
+
* by parsing the line alone. A table row is a paragraph; a list item, indented code or an
|
|
8
|
+
* HTML tag isn't, and in GFM it ends the table.
|
|
9
|
+
*/
|
|
10
|
+
function startsBlock(line, indent) {
|
|
11
|
+
const first = new Parser().parse(" ".repeat(indent) + line).firstChild;
|
|
12
|
+
return first !== null && first.type !== "paragraph";
|
|
13
|
+
}
|
|
14
|
+
/** A row's cells: outer pipes optional, `\|` is a pipe inside a cell (in code too, as GFM). */
|
|
15
|
+
function cellsOf(line) {
|
|
16
|
+
let s = line.trim();
|
|
17
|
+
if (s.startsWith("|"))
|
|
18
|
+
s = s.slice(1);
|
|
19
|
+
if (s.endsWith("|") && !s.endsWith("\\|"))
|
|
20
|
+
s = s.slice(0, -1);
|
|
21
|
+
return s.split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, "|"));
|
|
22
|
+
}
|
|
23
|
+
/** The alignments a delimiter row (`| :--- | ---: |`) sets, or null if the line isn't one. */
|
|
24
|
+
function delimiterRow(line) {
|
|
25
|
+
if (!line.includes("|"))
|
|
26
|
+
return null;
|
|
27
|
+
const cells = cellsOf(line);
|
|
28
|
+
if (!cells.every((c) => /^:?-+:?$/.test(c)))
|
|
29
|
+
return null;
|
|
30
|
+
return cells.map((c) => (c.startsWith(":") ? (c.endsWith(":") ? "center" : "left") : c.endsWith(":") ? "right" : null));
|
|
31
|
+
}
|
|
32
|
+
/** A node that renders as the given fixed tags around its children. Blocks get their own line. */
|
|
33
|
+
function custom(type, onEnter, onExit) {
|
|
34
|
+
const n = new Node(type);
|
|
35
|
+
n.onEnter = onEnter;
|
|
36
|
+
n.onExit = onExit;
|
|
37
|
+
return n;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Replace `block` (a paragraph, or a setext heading with its `underline`) with the table its
|
|
41
|
+
* text holds, if it holds one. `info[i]` is what line i was.
|
|
42
|
+
*/
|
|
43
|
+
function rebuildTable(block, text, info, underline, budget, inlines, blocks) {
|
|
44
|
+
const lines = text.replace(/\n$/, "").split("\n");
|
|
45
|
+
const endsTable = (i) => info[i]?.lazy || startsBlock(lines[i], info[i]?.indent ?? 0);
|
|
46
|
+
for (let h = 0; h + 1 < lines.length; h++) {
|
|
47
|
+
if (info[h]?.lazy || info[h + 1]?.lazy)
|
|
48
|
+
continue;
|
|
49
|
+
const align = delimiterRow(lines[h + 1]);
|
|
50
|
+
if (!align || cellsOf(lines[h]).length !== align.length)
|
|
51
|
+
continue;
|
|
52
|
+
let end = h + 2;
|
|
53
|
+
while (end < lines.length && !endsTable(end))
|
|
54
|
+
end++;
|
|
55
|
+
const setext = block.type === "heading";
|
|
56
|
+
const rule = setext && /^-{3,}$/.test(underline);
|
|
57
|
+
const early = end < lines.length;
|
|
58
|
+
const bodyLines = [...lines.slice(h + 2, end), ...(setext && !rule && !early ? [underline] : [])];
|
|
59
|
+
// Padding a short row costs no input, so a table's cells could outgrow the page by far
|
|
60
|
+
// (800 header cells and 800 one-cell rows). A table past the page's budget stays text.
|
|
61
|
+
const cells = align.length * (1 + bodyLines.length);
|
|
62
|
+
if (cells > budget.cells)
|
|
63
|
+
return;
|
|
64
|
+
budget.cells -= cells;
|
|
65
|
+
const row = (line, tag) => {
|
|
66
|
+
const tr = custom("custom_block", "<tr>", "</tr>");
|
|
67
|
+
const cells = cellsOf(line);
|
|
68
|
+
align.forEach((a, i) => {
|
|
69
|
+
const cell = custom("custom_inline", a ? `<${tag} class="dw-align-${a}">` : `<${tag}>`, `</${tag}>`);
|
|
70
|
+
const content = inlines(cells[i] ?? "");
|
|
71
|
+
while (content.firstChild)
|
|
72
|
+
cell.appendChild(content.firstChild);
|
|
73
|
+
tr.appendChild(cell);
|
|
74
|
+
});
|
|
75
|
+
return tr;
|
|
76
|
+
};
|
|
77
|
+
const table = custom("custom_block", '<div class="dw-table-scroll"><table>', "</table></div>");
|
|
78
|
+
const head = custom("custom_block", "<thead>", "</thead>");
|
|
79
|
+
head.appendChild(row(lines[h], "th"));
|
|
80
|
+
table.appendChild(head);
|
|
81
|
+
if (bodyLines.length > 0) {
|
|
82
|
+
const body = custom("custom_block", "<tbody>", "</tbody>");
|
|
83
|
+
for (const line of bodyLines)
|
|
84
|
+
body.appendChild(row(line, "td"));
|
|
85
|
+
table.appendChild(body);
|
|
86
|
+
}
|
|
87
|
+
if (h > 0)
|
|
88
|
+
block.insertBefore(inlines(lines.slice(0, h).join("\n")));
|
|
89
|
+
block.insertBefore(table);
|
|
90
|
+
if (early) {
|
|
91
|
+
// The lines after the table, indented as they were, read as blocks again.
|
|
92
|
+
const rest = blocks(lines.slice(end).map((line, i) => " ".repeat(info[end + i]?.indent ?? 0) + line).join("\n"));
|
|
93
|
+
while (rest.firstChild)
|
|
94
|
+
block.insertBefore(rest.firstChild);
|
|
95
|
+
}
|
|
96
|
+
if (rule)
|
|
97
|
+
block.insertBefore(new Node("thematic_break"));
|
|
98
|
+
else if (setext && early)
|
|
99
|
+
block.insertBefore(inlines(underline));
|
|
100
|
+
block.unlink();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* commonmark.js's tree, with GFM tables as tables. `refmap`: link definitions from the page
|
|
106
|
+
* this text was cut from (the blocks after a table), which its own may add to. `budget.left`:
|
|
107
|
+
* how many more characters may be parsed again after tables, for the whole page. Tables chained
|
|
108
|
+
* in one paragraph (each ended by a line that can't interrupt it) re-parse ever shorter tails,
|
|
109
|
+
* which is quadratic, and nests a parse per table; page text is untrusted, so past eight
|
|
110
|
+
* times the page's length, or 16 tables deep, the tail is read as plain CommonMark instead
|
|
111
|
+
* (its tables as text). Real pages chain a table or two.
|
|
112
|
+
*/
|
|
113
|
+
function parse(markdown, refmap, budget = { left: 8 * markdown.length, cells: markdown.length }, depth = 0) {
|
|
114
|
+
const parser = new Parser();
|
|
115
|
+
const internals = parser;
|
|
116
|
+
const inline = internals.inlineParser;
|
|
117
|
+
const { addLine, processInlines } = internals;
|
|
118
|
+
if (typeof inline?.parse !== "function" || typeof addLine !== "function" || typeof processInlines !== "function")
|
|
119
|
+
return parser.parse(markdown);
|
|
120
|
+
internals.processInlines = function (block) {
|
|
121
|
+
// The tail's own definitions win: GitHub links a label defined again after a table to
|
|
122
|
+
// that later definition.
|
|
123
|
+
this.refmap = { ...refmap, ...this.refmap };
|
|
124
|
+
processInlines.call(this, block);
|
|
125
|
+
};
|
|
126
|
+
// Each line a paragraph is given: whether it came lazily (its containers unmatched), and its
|
|
127
|
+
// indent. Keyed by the paragraph's sourcepos array: commonmark.js updates it in place, and a
|
|
128
|
+
// setext heading made from the paragraph is given the same array.
|
|
129
|
+
const lineInfo = new WeakMap();
|
|
130
|
+
internals.addLine = function () {
|
|
131
|
+
const key = this.tip.sourcepos;
|
|
132
|
+
if (this.tip.type === "paragraph" && key) {
|
|
133
|
+
let lines = lineInfo.get(key);
|
|
134
|
+
if (!lines)
|
|
135
|
+
lineInfo.set(key, (lines = []));
|
|
136
|
+
lines.push({ lazy: !this.allClosed, indent: this.indent });
|
|
137
|
+
}
|
|
138
|
+
addLine.call(this);
|
|
139
|
+
};
|
|
140
|
+
// The placeholder is never a link definition: redacted, `[term](page.md): word` reads
|
|
141
|
+
// `[restricted page]: word`, which CommonMark would take as one and drop from the page.
|
|
142
|
+
const { parseReference } = inline;
|
|
143
|
+
if (typeof parseReference === "function")
|
|
144
|
+
inline.parseReference = (s, refmap) => (PLACEHOLDER_DEFINITION.test(s) ? 0 : parseReference.call(inline, s, refmap));
|
|
145
|
+
const parseInlines = inline.parse.bind(inline);
|
|
146
|
+
const raw = new Map();
|
|
147
|
+
inline.parse = (block) => {
|
|
148
|
+
const text = block._string_content;
|
|
149
|
+
if ((block.type === "paragraph" || block.type === "heading") && text?.includes("|"))
|
|
150
|
+
raw.set(block, text);
|
|
151
|
+
parseInlines(block);
|
|
152
|
+
};
|
|
153
|
+
const doc = parser.parse(markdown);
|
|
154
|
+
const inlines = (text) => {
|
|
155
|
+
const p = new Node("paragraph");
|
|
156
|
+
p._string_content = text;
|
|
157
|
+
parseInlines(p);
|
|
158
|
+
return p;
|
|
159
|
+
};
|
|
160
|
+
const source = markdown.split(/\r\n|\n|\r/);
|
|
161
|
+
for (const [block, text] of raw) {
|
|
162
|
+
// Reference definitions are taken from the front of a paragraph: its last lines are the text's.
|
|
163
|
+
const lines = text.replace(/\n$/, "").split("\n").length;
|
|
164
|
+
// A setext heading ends on its underline's line, at the underline's last character.
|
|
165
|
+
const [endLine = 0, endCol = 0] = block.sourcepos?.[1] ?? [];
|
|
166
|
+
const underline = block.type === "heading" ? (/([=-]+)\s*$/.exec(source[endLine - 1]?.slice(0, endCol) ?? "")?.[1] ?? "") : "";
|
|
167
|
+
const blocks = (rest) => {
|
|
168
|
+
if (rest.length > budget.left || depth >= 16)
|
|
169
|
+
return new Parser().parse(rest);
|
|
170
|
+
budget.left -= rest.length;
|
|
171
|
+
return parse(rest, internals.refmap, budget, depth + 1);
|
|
172
|
+
};
|
|
173
|
+
rebuildTable(block, text, ((block.sourcepos && lineInfo.get(block.sourcepos)) || []).slice(-lines), underline, budget, inlines, blocks);
|
|
174
|
+
}
|
|
175
|
+
return doc;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* `[restricted page]` (`RESTRICTED`, src/core/acl.ts): the placeholder redaction leaves for a
|
|
179
|
+
* link to a page the caller can't see. Not imported: acl.ts would bring its dependencies into
|
|
180
|
+
* the web app.
|
|
181
|
+
*/
|
|
182
|
+
const PLACEHOLDER = "[restricted page]";
|
|
183
|
+
const MAX_NESTING = 32;
|
|
184
|
+
const PLACEHOLDER_DEFINITION = /^\[\s*restricted\s+page\s*\]:/i;
|
|
185
|
+
/**
|
|
186
|
+
* commonmark.js's tree for `markdown`, with GFM tables as tables (see above). The placeholder
|
|
187
|
+
* is never a link or an image, whatever a page writes around it: a definition for it, a
|
|
188
|
+
* `(target)` or `[label]` right after the link redaction replaced, or a link around it. A
|
|
189
|
+
* link or image labelled "restricted page" becomes the placeholder's text, and one whose
|
|
190
|
+
* label holds the placeholder becomes its label's text.
|
|
191
|
+
*/
|
|
192
|
+
export function parseGfm(markdown) {
|
|
193
|
+
const doc = parse(markdown);
|
|
194
|
+
// Links and images nested deeper than MAX_NESTING are read as their text. Each check below
|
|
195
|
+
// walks what's inside a link, so unbounded nesting would make it quadratic (page text is
|
|
196
|
+
// untrusted); 32 levels is far past anything a page means.
|
|
197
|
+
const deep = [];
|
|
198
|
+
let nesting = 0;
|
|
199
|
+
const outer = doc.walker();
|
|
200
|
+
for (let e = outer.next(); e; e = outer.next()) {
|
|
201
|
+
if (e.node.type !== "link" && e.node.type !== "image")
|
|
202
|
+
continue;
|
|
203
|
+
if (!e.entering)
|
|
204
|
+
nesting--;
|
|
205
|
+
else if (++nesting > MAX_NESTING)
|
|
206
|
+
deep.push(e.node);
|
|
207
|
+
}
|
|
208
|
+
for (const n of deep) {
|
|
209
|
+
while (n.firstChild)
|
|
210
|
+
n.insertBefore(n.firstChild);
|
|
211
|
+
n.unlink();
|
|
212
|
+
}
|
|
213
|
+
// Innermost first: a link is judged by its label once what's inside it is replaced, so an
|
|
214
|
+
// image inside a link that becomes the placeholder takes the link's target away too.
|
|
215
|
+
const links = [];
|
|
216
|
+
const walker = doc.walker();
|
|
217
|
+
for (let e = walker.next(); e; e = walker.next())
|
|
218
|
+
if (!e.entering && (e.node.type === "link" || e.node.type === "image"))
|
|
219
|
+
links.push(e.node);
|
|
220
|
+
for (const n of links) {
|
|
221
|
+
const label = textIn(n).replace(/\s+/g, " ").trim();
|
|
222
|
+
const literal = `[${label.toLowerCase()}]` === PLACEHOLDER ? PLACEHOLDER : label.toLowerCase().includes(PLACEHOLDER) ? label : null;
|
|
223
|
+
if (literal === null)
|
|
224
|
+
continue;
|
|
225
|
+
const text = new Node("text");
|
|
226
|
+
text.literal = literal;
|
|
227
|
+
n.insertBefore(text);
|
|
228
|
+
n.unlink();
|
|
229
|
+
}
|
|
230
|
+
return doc;
|
|
231
|
+
}
|
|
232
|
+
/** The text and code inside `node`: a link's or image's label, as the reader sees it. */
|
|
233
|
+
export function textIn(node) {
|
|
234
|
+
let out = "";
|
|
235
|
+
const walker = node.walker();
|
|
236
|
+
for (let e = walker.next(); e; e = walker.next())
|
|
237
|
+
if (e.entering && (e.node.type === "text" || e.node.type === "code"))
|
|
238
|
+
out += e.node.literal ?? "";
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
/** A table cell (`th` or `td`) in a tree from `parseGfm`; its children are the cell's content. */
|
|
242
|
+
export function isTableCell(node) {
|
|
243
|
+
return node.type === "custom_inline" && /^<t[hd][\s>]/.test(node.onEnter ?? "");
|
|
244
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Every link and image destination in `body`, in order, as commonmark.js parses it. */
|
|
2
|
+
export declare function pageLinks(body: string): string[];
|
|
3
|
+
export interface LinkSpan {
|
|
4
|
+
/** The destination as CommonMark reads it (escapes and entities applied, percent-encoded). */
|
|
5
|
+
dest: string;
|
|
6
|
+
/** The destination as written, `<` `>` aside: `start`/`end` delimit it in the body. */
|
|
7
|
+
raw: string;
|
|
8
|
+
start: number;
|
|
9
|
+
end: number;
|
|
10
|
+
/** Written in angle brackets (`<…>`), which stay outside start/end. */
|
|
11
|
+
angle: boolean;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Where each link's destination is written in `body`, for rewriting it in place. A liberal
|
|
15
|
+
* scanner proposes candidates (after every `](`, and after a definition's `]:`); then
|
|
16
|
+
* commonmark.js confirms them, all at once. Each candidate gets a token of its own written
|
|
17
|
+
* just before it, the body is parsed once, and a candidate counts only if some link's or
|
|
18
|
+
* image's destination is its token followed by what CommonMark reads the candidate as.
|
|
19
|
+
* Text is only added, never taken away, so the page parses as it did: whatever CommonMark
|
|
20
|
+
* doesn't link (code, HTML, a definition no link uses) is left out by its own rules, and
|
|
21
|
+
* candidates may overlap.
|
|
22
|
+
*/
|
|
23
|
+
export declare function linkSpans(body: string, nonce?: () => string): LinkSpan[];
|
|
24
|
+
/** A destination as written, read by CommonMark: escapes and entities applied, encoded. */
|
|
25
|
+
export declare function readDestination(raw: string, angle: boolean): string;
|
|
26
|
+
/** A destination that leaves the page's files: a scheme, `//`, or an in-page `#`. */
|
|
27
|
+
export declare const EXTERNAL: RegExp;
|
|
28
|
+
/** A destination's path: no query or fragment, still percent-encoded (`resolveLink` decodes it once). */
|
|
29
|
+
export declare function linkPath(dest: string): string;
|
|
30
|
+
/** A path from `linkPath`, decoded: to show, and to match against file names. */
|
|
31
|
+
export declare function shownPath(path: string): string;
|
|
32
|
+
export type LinkFault = "missing" | "directory" | "extensionless" | "route";
|
|
33
|
+
/**
|
|
34
|
+
* How lint judges a link's path (from `linkPath`) on the page at `fromPath`: `missing` when it
|
|
35
|
+
* resolves to nothing in the repo; under the knowledge root, `directory` or `extensionless`
|
|
36
|
+
* when it names no page or attachment (R8b), since redaction couldn't tell which page it
|
|
37
|
+
* means; otherwise null.
|
|
38
|
+
*/
|
|
39
|
+
export declare function linkFault(repoRoot: string, knowledgeRoot: string, fromPath: string, path: string, opts?: {
|
|
40
|
+
/** The pages a slug or an id puts at a route (`siteRoute`), by route. */
|
|
41
|
+
aliases?: (route: string) => string[];
|
|
42
|
+
/** Directories a docs site serves at its root (config `static_dirs`). */
|
|
43
|
+
staticDirs?: readonly string[];
|
|
44
|
+
}): {
|
|
45
|
+
target: string;
|
|
46
|
+
fault: LinkFault | null;
|
|
47
|
+
underRoot: boolean;
|
|
48
|
+
pages?: string[];
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* The page files under the knowledge root a root-relative route names on a docs site, with or
|
|
52
|
+
* without a leading `docs/`: each segment a directory or, last, a page, by its name or without
|
|
53
|
+
* its number prefix (`02-ops` is `ops`); a directory serves its category index. Lint fails a
|
|
54
|
+
* route this finds that also names a file of the repo's, and adopt lists one only a number prefix
|
|
55
|
+
* reaches. Dot-directories are skipped, as the loader skips them.
|
|
56
|
+
*/
|
|
57
|
+
export declare function routePages(repoRoot: string, rootDir: string, route: string): string[];
|
|
58
|
+
/** A file or directory name as Docusaurus routes it: its number prefix removed. */
|
|
59
|
+
export declare function unprefixed(name: string): string;
|
|
60
|
+
/** Docusaurus's category index: `index`, `README`, or a file named after its directory. */
|
|
61
|
+
export declare function isCategoryIndex(stem: string, dir: string | undefined): boolean;
|
|
62
|
+
/**
|
|
63
|
+
* Where a page's file puts it on a Docusaurus site, as a route without a leading slash: its
|
|
64
|
+
* path under the knowledge root without number prefixes, a category index at its directory.
|
|
65
|
+
*/
|
|
66
|
+
export declare function fileRoute(under: string): string;
|
|
67
|
+
/** The one route a docs site serves a page at: its `siteRoute`, else its `fileRoute`. */
|
|
68
|
+
export declare function pageRoute(under: string, slug: unknown, id: unknown): string;
|
|
69
|
+
/**
|
|
70
|
+
* Where a page's own `slug`, or an `id` other than its file name, puts it on a Docusaurus site,
|
|
71
|
+
* as a route without a leading slash; null when that's where its file is. `under` is its path
|
|
72
|
+
* under the knowledge root. (A category index routes by its directory whatever its id.)
|
|
73
|
+
*/
|
|
74
|
+
export declare function siteRoute(under: string, slug: unknown, id: unknown): string | null;
|
|
75
|
+
/**
|
|
76
|
+
* Whether `target` is a file inside static directory `dir`, by real path: a path that climbs out
|
|
77
|
+
* (`/../../package.json`), a link in it that leads out, or a directory that is itself a link out
|
|
78
|
+
* of the repo, is nothing the site serves there.
|
|
79
|
+
*/
|
|
80
|
+
export declare function servedFrom(repoRoot: string, dir: string, target: string): boolean;
|
|
81
|
+
/** A path a static directory can serve: a file with an extension, never a page (`.md`), which lives under the knowledge root. */
|
|
82
|
+
export declare function isAttachment(path: string): boolean;
|