@fro.bot/wiki-write-core 0.1.3 → 0.2.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 +35 -4
- package/dist/code-regions.js +34 -15
- package/dist/corrections-survival.d.ts +7 -1
- package/dist/corrections-survival.js +48 -41
- package/dist/frontmatter-block.d.ts +49 -0
- package/dist/frontmatter-block.js +62 -0
- package/dist/frontmatter.d.ts +5 -1
- package/dist/frontmatter.js +11 -5
- package/dist/gate-contract.js +1 -1
- package/dist/gate-contract.json +1 -1
- package/dist/inline-links.d.ts +35 -0
- package/dist/inline-links.js +120 -0
- package/dist/line-scanners.d.ts +31 -0
- package/dist/line-scanners.js +111 -0
- package/dist/link-labels.d.ts +22 -0
- package/dist/link-labels.js +71 -0
- package/dist/markdown-links.js +11 -9
- package/dist/rendering-policy.js +18 -4
- package/dist/repo-page-move.js +21 -13
- package/dist/wiki-ingest.js +94 -29
- package/dist/wiki-utils.d.ts +2 -2
- package/dist/wiki-utils.js +17 -14
- package/package.json +1 -1
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface IndexEntryLine {
|
|
2
|
+
/** The text between `- [[` and `]]`, not trimmed. */
|
|
3
|
+
readonly slug: string;
|
|
4
|
+
/** The whole entry line, from the dash to the end of its text. */
|
|
5
|
+
readonly line: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Finds the index entry lines `- [[slug]] — description` of an index document, left to right, as
|
|
9
|
+
* `/^- \[\[([^\]|]+)\]\]\s*—\s*(?:\S.*|[\t\v\f \u00A0\u1680\u2000-\u200A\u202F\u205F\u3000\uFEFF])$/gmu`
|
|
10
|
+
* does:
|
|
11
|
+
* - the line starts at the beginning of the text or after a line terminator, with `- [[`;
|
|
12
|
+
* - the slug is a non-empty run up to the first `]` or `|`, which may cross lines, and `]]` follows it;
|
|
13
|
+
* - whitespace (newlines included), an em dash, whitespace again;
|
|
14
|
+
* - then either a non-whitespace character and the rest of its line, or, when only whitespace remains to the end
|
|
15
|
+
* of the text, a single non-newline whitespace character that ends its line.
|
|
16
|
+
*
|
|
17
|
+
* Each run ends at the first character that cannot continue it, so a run is read once from a table of stop
|
|
18
|
+
* offsets and each candidate line is decided in constant time. The regex rescans the rest of the text from every
|
|
19
|
+
* `- [[` line that has no `]]` after it, which is quadratic in the length of the text.
|
|
20
|
+
*/
|
|
21
|
+
export declare function findIndexEntryLines(index: string): IndexEntryLine[];
|
|
22
|
+
/**
|
|
23
|
+
* The inside of a flow-style `related: [a, b]` line, or `undefined` when the line is not one, as
|
|
24
|
+
* `/^related:\s*\[(.*)\]\s*(?:#.*)?$/u` does. The inside runs to the last `]` before the line's first terminator
|
|
25
|
+
* that is followed only by whitespace and an optional `#` comment running to the end of the text.
|
|
26
|
+
*
|
|
27
|
+
* The regex tries every `]` from the right and rescans the tail from each, which is quadratic when the tail
|
|
28
|
+
* stops short of the end of the text (a carriage return inside a `#` comment, say). Here the tail is decided
|
|
29
|
+
* from two tables of stop offsets in constant time per `]`.
|
|
30
|
+
*/
|
|
31
|
+
export declare function parseRelatedFlow(line: string): string | undefined;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { buildStopTable, isWhitespaceCode } from "./inline-links.js";
|
|
2
|
+
const NEWLINE = 0x0a;
|
|
3
|
+
const CARRIAGE_RETURN = 0x0d;
|
|
4
|
+
const LINE_SEPARATOR = 0x20_28;
|
|
5
|
+
const PARAGRAPH_SEPARATOR = 0x20_29;
|
|
6
|
+
const EM_DASH = 0x20_14;
|
|
7
|
+
const CLOSE_BRACKET = 0x5d;
|
|
8
|
+
const PIPE = 0x7c;
|
|
9
|
+
const HASH = 0x23;
|
|
10
|
+
/** The characters a regex `^`, `$` and `.` treat as line boundaries. */
|
|
11
|
+
function isLineTerminator(code) {
|
|
12
|
+
return code === NEWLINE || code === CARRIAGE_RETURN || code === LINE_SEPARATOR || code === PARAGRAPH_SEPARATOR;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The offset just past the last character of `text` that is a whitespace character other than a line terminator
|
|
16
|
+
* and is followed by a line terminator or the end of the text, or -1 when there is none.
|
|
17
|
+
*/
|
|
18
|
+
function findLastBlankEnd(text) {
|
|
19
|
+
for (let at = text.length - 1; at >= 0; at -= 1) {
|
|
20
|
+
const code = text.charCodeAt(at);
|
|
21
|
+
if (isWhitespaceCode(code) &&
|
|
22
|
+
!isLineTerminator(code) &&
|
|
23
|
+
(at + 1 === text.length || isLineTerminator(text.charCodeAt(at + 1)))) {
|
|
24
|
+
return at + 1;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return -1;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Finds the index entry lines `- [[slug]] — description` of an index document, left to right, as
|
|
31
|
+
* `/^- \[\[([^\]|]+)\]\]\s*—\s*(?:\S.*|[\t\v\f \u00A0\u1680\u2000-\u200A\u202F\u205F\u3000\uFEFF])$/gmu`
|
|
32
|
+
* does:
|
|
33
|
+
* - the line starts at the beginning of the text or after a line terminator, with `- [[`;
|
|
34
|
+
* - the slug is a non-empty run up to the first `]` or `|`, which may cross lines, and `]]` follows it;
|
|
35
|
+
* - whitespace (newlines included), an em dash, whitespace again;
|
|
36
|
+
* - then either a non-whitespace character and the rest of its line, or, when only whitespace remains to the end
|
|
37
|
+
* of the text, a single non-newline whitespace character that ends its line.
|
|
38
|
+
*
|
|
39
|
+
* Each run ends at the first character that cannot continue it, so a run is read once from a table of stop
|
|
40
|
+
* offsets and each candidate line is decided in constant time. The regex rescans the rest of the text from every
|
|
41
|
+
* `- [[` line that has no `]]` after it, which is quadratic in the length of the text.
|
|
42
|
+
*/
|
|
43
|
+
export function findIndexEntryLines(index) {
|
|
44
|
+
const entries = [];
|
|
45
|
+
const opener = '- [[';
|
|
46
|
+
if (!index.includes(opener))
|
|
47
|
+
return entries;
|
|
48
|
+
const slugStop = buildStopTable(index, code => code === CLOSE_BRACKET || code === PIPE);
|
|
49
|
+
const nonSpace = buildStopTable(index, code => !isWhitespaceCode(code));
|
|
50
|
+
const lineEnd = buildStopTable(index, isLineTerminator);
|
|
51
|
+
let lastBlankEnd;
|
|
52
|
+
const trailingBlankEnd = () => (lastBlankEnd ??= findLastBlankEnd(index));
|
|
53
|
+
const entryEndAt = (slugEnd) => {
|
|
54
|
+
const dash = nonSpace[slugEnd + 2] ?? index.length;
|
|
55
|
+
if (index.charCodeAt(dash) !== EM_DASH)
|
|
56
|
+
return -1;
|
|
57
|
+
const textStart = nonSpace[dash + 1] ?? index.length;
|
|
58
|
+
if (textStart < index.length)
|
|
59
|
+
return lineEnd[textStart + 1] ?? index.length;
|
|
60
|
+
// Only whitespace follows the dash: the entry ends at the last character of the text that is a whitespace
|
|
61
|
+
// character, not a line terminator, and the last in its line. That character does not depend on the dash,
|
|
62
|
+
// so it is found once, and an entry exists when it lies after the dash.
|
|
63
|
+
return trailingBlankEnd() > dash + 1 ? trailingBlankEnd() : -1;
|
|
64
|
+
};
|
|
65
|
+
for (let start = index.indexOf(opener); start !== -1;) {
|
|
66
|
+
const atLineStart = start === 0 || isLineTerminator(index.charCodeAt(start - 1));
|
|
67
|
+
const slugEnd = slugStop[start + 4] ?? index.length;
|
|
68
|
+
if (atLineStart && slugEnd > start + 4 && index.startsWith(']]', slugEnd)) {
|
|
69
|
+
const end = entryEndAt(slugEnd);
|
|
70
|
+
if (end !== -1) {
|
|
71
|
+
entries.push({ slug: index.slice(start + 4, slugEnd), line: index.slice(start, end) });
|
|
72
|
+
start = index.indexOf(opener, end);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
start = index.indexOf(opener, start + 1);
|
|
77
|
+
}
|
|
78
|
+
return entries;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The inside of a flow-style `related: [a, b]` line, or `undefined` when the line is not one, as
|
|
82
|
+
* `/^related:\s*\[(.*)\]\s*(?:#.*)?$/u` does. The inside runs to the last `]` before the line's first terminator
|
|
83
|
+
* that is followed only by whitespace and an optional `#` comment running to the end of the text.
|
|
84
|
+
*
|
|
85
|
+
* The regex tries every `]` from the right and rescans the tail from each, which is quadratic when the tail
|
|
86
|
+
* stops short of the end of the text (a carriage return inside a `#` comment, say). Here the tail is decided
|
|
87
|
+
* from two tables of stop offsets in constant time per `]`.
|
|
88
|
+
*/
|
|
89
|
+
export function parseRelatedFlow(line) {
|
|
90
|
+
const key = 'related:';
|
|
91
|
+
if (!line.startsWith(key))
|
|
92
|
+
return undefined;
|
|
93
|
+
let bracket = key.length;
|
|
94
|
+
while (bracket < line.length && isWhitespaceCode(line.charCodeAt(bracket)))
|
|
95
|
+
bracket += 1;
|
|
96
|
+
if (line.charAt(bracket) !== '[')
|
|
97
|
+
return undefined;
|
|
98
|
+
const lineEnd = buildStopTable(line, isLineTerminator);
|
|
99
|
+
const nonSpace = buildStopTable(line, code => !isWhitespaceCode(code));
|
|
100
|
+
const insideStart = bracket + 1;
|
|
101
|
+
const insideLimit = lineEnd[insideStart] ?? line.length;
|
|
102
|
+
for (let close = insideLimit - 1; close >= insideStart; close -= 1) {
|
|
103
|
+
if (line.charCodeAt(close) !== CLOSE_BRACKET)
|
|
104
|
+
continue;
|
|
105
|
+
const tail = nonSpace[close + 1] ?? line.length;
|
|
106
|
+
const tailIsComment = line.charCodeAt(tail) === HASH && (lineEnd[tail + 1] ?? line.length) === line.length;
|
|
107
|
+
if (tail === line.length || tailIsComment)
|
|
108
|
+
return line.slice(insideStart, close);
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One link in `replaceLinkLabels`: the capture groups of a markdown link `[text](target)` or a wiki link
|
|
3
|
+
* `[[target]]` / `[[target|label]]`. The groups of the branch that did not match are `undefined`.
|
|
4
|
+
*/
|
|
5
|
+
export interface LinkLabelParts {
|
|
6
|
+
readonly markdownText: string | undefined;
|
|
7
|
+
readonly wikiTarget: string | undefined;
|
|
8
|
+
readonly wikiLabel: string | undefined;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Replaces every markdown link and wiki link in `text` with `render(parts)`, scanning left to right once, as
|
|
12
|
+
* `text.replaceAll(/!?(?:\[([^\]]*)\]\([^)]*\)|\[\[([^\]|]+)(?:\|([^\]]+))?\]\])/gu, ...)` does. A leading `!`
|
|
13
|
+
* (an image) is part of the match. The markdown form is tried before the wiki form at each `[`:
|
|
14
|
+
* - markdown: `[`, any text up to the first `]`, `](`, any run up to the first `)`, and that `)`;
|
|
15
|
+
* - wiki: `[[`, a non-empty target up to the first `]` or `|`, then either `]]` or `|`, a non-empty label up to
|
|
16
|
+
* the first `]`, and `]]`.
|
|
17
|
+
*
|
|
18
|
+
* Every run ends at the first character that cannot continue it, so the stop offsets are read from tables built
|
|
19
|
+
* right to left and each `[` is decided in constant time. The regex form rescans the rest of the text from every
|
|
20
|
+
* unclosed `[`, which is quadratic in the length of the text.
|
|
21
|
+
*/
|
|
22
|
+
export declare function replaceLinkLabels(text: string, render: (parts: LinkLabelParts) => string): string;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { buildStopTable } from "./inline-links.js";
|
|
2
|
+
const OPEN_BRACKET = '[';
|
|
3
|
+
const CLOSE_BRACKET = ']';
|
|
4
|
+
const OPEN_PAREN = '(';
|
|
5
|
+
const CLOSE_PAREN = ')';
|
|
6
|
+
const PIPE = '|';
|
|
7
|
+
const BANG = '!';
|
|
8
|
+
/**
|
|
9
|
+
* Replaces every markdown link and wiki link in `text` with `render(parts)`, scanning left to right once, as
|
|
10
|
+
* `text.replaceAll(/!?(?:\[([^\]]*)\]\([^)]*\)|\[\[([^\]|]+)(?:\|([^\]]+))?\]\])/gu, ...)` does. A leading `!`
|
|
11
|
+
* (an image) is part of the match. The markdown form is tried before the wiki form at each `[`:
|
|
12
|
+
* - markdown: `[`, any text up to the first `]`, `](`, any run up to the first `)`, and that `)`;
|
|
13
|
+
* - wiki: `[[`, a non-empty target up to the first `]` or `|`, then either `]]` or `|`, a non-empty label up to
|
|
14
|
+
* the first `]`, and `]]`.
|
|
15
|
+
*
|
|
16
|
+
* Every run ends at the first character that cannot continue it, so the stop offsets are read from tables built
|
|
17
|
+
* right to left and each `[` is decided in constant time. The regex form rescans the rest of the text from every
|
|
18
|
+
* unclosed `[`, which is quadratic in the length of the text.
|
|
19
|
+
*/
|
|
20
|
+
export function replaceLinkLabels(text, render) {
|
|
21
|
+
if (!text.includes(OPEN_BRACKET))
|
|
22
|
+
return text;
|
|
23
|
+
const closeBracketAt = buildStopTable(text, code => code === 0x5d);
|
|
24
|
+
const closeParenAt = buildStopTable(text, code => code === 0x29);
|
|
25
|
+
const closeBracketOrPipeAt = buildStopTable(text, code => code === 0x5d || code === 0x7c);
|
|
26
|
+
const matchAt = (open) => {
|
|
27
|
+
const textEnd = closeBracketAt[open + 1] ?? text.length;
|
|
28
|
+
if (text.charAt(textEnd) === CLOSE_BRACKET && text.charAt(textEnd + 1) === OPEN_PAREN) {
|
|
29
|
+
const parenEnd = closeParenAt[textEnd + 2] ?? text.length;
|
|
30
|
+
if (text.charAt(parenEnd) === CLOSE_PAREN) {
|
|
31
|
+
return {
|
|
32
|
+
end: parenEnd + 1,
|
|
33
|
+
markdownText: text.slice(open + 1, textEnd),
|
|
34
|
+
wikiTarget: undefined,
|
|
35
|
+
wikiLabel: undefined,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (text.charAt(open + 1) !== OPEN_BRACKET)
|
|
40
|
+
return undefined;
|
|
41
|
+
const targetEnd = closeBracketOrPipeAt[open + 2] ?? text.length;
|
|
42
|
+
if (targetEnd === open + 2)
|
|
43
|
+
return undefined;
|
|
44
|
+
const wikiTarget = text.slice(open + 2, targetEnd);
|
|
45
|
+
if (text.charAt(targetEnd) === PIPE) {
|
|
46
|
+
const labelEnd = closeBracketAt[targetEnd + 1] ?? text.length;
|
|
47
|
+
if (labelEnd > targetEnd + 1 && text.startsWith(']]', labelEnd)) {
|
|
48
|
+
return { end: labelEnd + 2, markdownText: undefined, wikiTarget, wikiLabel: text.slice(targetEnd + 1, labelEnd) };
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
return text.startsWith(']]', targetEnd)
|
|
53
|
+
? { end: targetEnd + 2, markdownText: undefined, wikiTarget, wikiLabel: undefined }
|
|
54
|
+
: undefined;
|
|
55
|
+
};
|
|
56
|
+
let replaced = '';
|
|
57
|
+
let copiedTo = 0;
|
|
58
|
+
for (let open = text.indexOf(OPEN_BRACKET); open !== -1;) {
|
|
59
|
+
const match = matchAt(open);
|
|
60
|
+
if (match === undefined) {
|
|
61
|
+
open = text.indexOf(OPEN_BRACKET, open + 1);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
// An image's `!` belongs to the match, unless an earlier match already took it.
|
|
65
|
+
const start = open > copiedTo && text.charAt(open - 1) === BANG ? open - 1 : open;
|
|
66
|
+
replaced += text.slice(copiedTo, start) + render(match);
|
|
67
|
+
copiedTo = match.end;
|
|
68
|
+
open = text.indexOf(OPEN_BRACKET, match.end);
|
|
69
|
+
}
|
|
70
|
+
return replaced + text.slice(copiedTo);
|
|
71
|
+
}
|
package/dist/markdown-links.js
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { dirname, join, normalize } from 'node:path';
|
|
3
3
|
import process from 'node:process';
|
|
4
4
|
import { findCodeRegions, findRegionAt } from "./code-regions.js";
|
|
5
|
-
|
|
5
|
+
import { createInlineLinkFinder } from "./inline-links.js";
|
|
6
6
|
/** Resolve live relative markdown links and report whether their targets exist. */
|
|
7
7
|
export function resolveMarkdownLinks(content, containingFile, options = {}) {
|
|
8
8
|
const links = [];
|
|
@@ -11,12 +11,14 @@ export function resolveMarkdownLinks(content, containingFile, options = {}) {
|
|
|
11
11
|
for (const [lineIndex, line] of content.split('\n').entries()) {
|
|
12
12
|
const currentLineOffset = lineOffset;
|
|
13
13
|
lineOffset += line.length + 1;
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
// Each line gets its own finder; the loop asks it for the next link at or after `from`.
|
|
15
|
+
const findLink = createInlineLinkFinder(line);
|
|
16
|
+
let from = 0;
|
|
17
|
+
for (let match = findLink(from); match !== undefined; match = findLink(from)) {
|
|
18
|
+
const { target } = match;
|
|
18
19
|
const matchIndex = match.index;
|
|
19
|
-
|
|
20
|
+
from = matchIndex + match.length;
|
|
21
|
+
if (matchIndex > 0 && line[matchIndex - 1] === '!') {
|
|
20
22
|
continue;
|
|
21
23
|
}
|
|
22
24
|
// Code binds tighter than links, so a match that touches a fence or span is literal text.
|
|
@@ -24,14 +26,14 @@ export function resolveMarkdownLinks(content, containingFile, options = {}) {
|
|
|
24
26
|
// after an unclosed bracket in code. Resume after the region the match opened in, or just
|
|
25
27
|
// past its first character when it only closes inside one.
|
|
26
28
|
const matchStart = currentLineOffset + matchIndex;
|
|
27
|
-
const matchLast = matchStart + match
|
|
29
|
+
const matchLast = matchStart + match.length - 1;
|
|
28
30
|
const openingRegion = findRegionAt(regions, matchStart);
|
|
29
31
|
if (openingRegion !== undefined) {
|
|
30
|
-
|
|
32
|
+
from = Math.max(openingRegion.end - currentLineOffset, matchIndex + 1);
|
|
31
33
|
continue;
|
|
32
34
|
}
|
|
33
35
|
if (findRegionAt(regions, matchLast) !== undefined) {
|
|
34
|
-
|
|
36
|
+
from = matchIndex + 1;
|
|
35
37
|
continue;
|
|
36
38
|
}
|
|
37
39
|
const normalizedTarget = target.startsWith('<') && target.endsWith('>') ? target.slice(1, -1) : target;
|
package/dist/rendering-policy.js
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
// A tag is unsafe when its name opens it and a `>` follows anywhere after the name. The pattern stops at the
|
|
2
|
+
// name and `hasUnsafeTag` looks for the `>`: a `[^>]*>` tail rescans the rest of the text from every opening
|
|
3
|
+
// when there is no `>`, which is quadratic in the length of the text.
|
|
4
|
+
const UNSAFE_TAG_OPENING_PATTERN = /<\/?\s*(?:script|iframe|object|embed|applet|base|form|input|button|textarea|select|option|meta|link|style|template|svg|math)\b/iu;
|
|
5
|
+
// Both attribute patterns start at the first whitespace character of a run: a match that starts inside a run also
|
|
6
|
+
// starts at the run's beginning, and without the lookbehind a long run of whitespace is rescanned from every
|
|
7
|
+
// position in it.
|
|
8
|
+
const EVENT_HANDLER_PATTERN = /(?<!\s)\s+on[a-z][\w:-]*\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/iu;
|
|
9
|
+
const UNSAFE_URI_PATTERN = /(?<!\s)\s+(?:href|src|srcset|action|formaction|xlink:href|poster|background|cite)\s*=\s*(?:"\s*|')?(?:javascript|vbscript|data):/iu;
|
|
10
|
+
/**
|
|
11
|
+
* True when `text` holds an unsafe tag: an opening `<name` or `</name` from the list, then a `>` after the name.
|
|
12
|
+
* The first opening ends before any later one, so if it has no `>` after it none does.
|
|
13
|
+
*/
|
|
14
|
+
function hasUnsafeTag(text) {
|
|
15
|
+
const opening = UNSAFE_TAG_OPENING_PATTERN.exec(text);
|
|
16
|
+
return opening !== null && text.includes('>', opening.index + opening[0].length);
|
|
17
|
+
}
|
|
4
18
|
/** Return save-side findings for HTML that the render policy will strip. */
|
|
5
19
|
export function validateRenderingPolicy(params) {
|
|
6
20
|
const findings = [];
|
|
7
21
|
const renderableMarkdown = maskCodeContent(params.content);
|
|
8
|
-
if (
|
|
22
|
+
if (hasUnsafeTag(renderableMarkdown) ||
|
|
9
23
|
EVENT_HANDLER_PATTERN.test(renderableMarkdown) ||
|
|
10
24
|
UNSAFE_URI_PATTERN.test(renderableMarkdown)) {
|
|
11
25
|
findings.push({
|
package/dist/repo-page-move.js
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* repository URL. The body is never consulted.
|
|
14
14
|
* - `log.md` history and `knowledge/wiki/README.md` are never rewritten.
|
|
15
15
|
*/
|
|
16
|
+
import { splitFrontmatterBlock } from "./frontmatter-block.js";
|
|
16
17
|
import { parseFrontmatterDocument, renderFrontmatterDocument } from "./frontmatter.js";
|
|
18
|
+
import { parseRelatedFlow } from "./line-scanners.js";
|
|
17
19
|
import { appendLogEntry, rebuildWikiIndex, validateWikilinks, WikiIngestError } from "./wiki-ingest.js";
|
|
18
20
|
import { buildPrivateNameTokens, computeRepoSlug } from "./wiki-slug.js";
|
|
19
21
|
import { findWikilinkSpans } from "./wiki-utils.js";
|
|
@@ -22,7 +24,6 @@ const LOG_PATH = 'knowledge/log.md';
|
|
|
22
24
|
const REPOS_DIR = 'knowledge/wiki/repos';
|
|
23
25
|
const WIKI_PAGE_PATTERN = /^knowledge\/wiki\/(?:repos|topics|entities|comparisons)\/[^/]+\.md$/u;
|
|
24
26
|
const RELATED_BLOCK_KEY = /^related:\s*(?:#.*)?$/u;
|
|
25
|
-
const RELATED_FLOW = /^related:\s*\[(.*)\]\s*(?:#.*)?$/u;
|
|
26
27
|
/** Apply a change set to a file map, returning a new map. */
|
|
27
28
|
export function applyPageChanges(files, changes) {
|
|
28
29
|
const next = { ...files };
|
|
@@ -293,21 +294,21 @@ function repairRelated(content, oldSlug, newSlug) {
|
|
|
293
294
|
}
|
|
294
295
|
/**
|
|
295
296
|
* Edit only the `related:` lines of the frontmatter text, so the rest of the page keeps its bytes.
|
|
296
|
-
* Returns `undefined` for a layout it does not recognize
|
|
297
|
+
* Returns `undefined` for a layout it does not recognize, and for a block with a CRLF line ending anywhere in it,
|
|
298
|
+
* the closing line included, which the edit would otherwise leave half rewritten; the caller then re-renders structurally.
|
|
297
299
|
*/
|
|
298
300
|
function editRelatedText(content, oldSlug, newSlug) {
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
if (frontmatter === undefined)
|
|
301
|
+
const split = splitFrontmatterBlock(content);
|
|
302
|
+
if (split.kind !== 'block' || hasCrlf(split))
|
|
302
303
|
return undefined;
|
|
303
|
-
const lines =
|
|
304
|
-
const flowIndex = lines.findIndex(line =>
|
|
304
|
+
const lines = split.text.split('\n');
|
|
305
|
+
const flowIndex = lines.findIndex(line => parseRelatedFlow(line) !== undefined);
|
|
305
306
|
if (flowIndex !== -1) {
|
|
306
|
-
const inner =
|
|
307
|
+
const inner = parseRelatedFlow(lines[flowIndex] ?? '') ?? '';
|
|
307
308
|
const items = inner.split(',').map(item => item.trim());
|
|
308
309
|
const mapped = dedupe(items.map(item => (stripQuotes(item) === oldSlug ? swapQuoted(item, newSlug) : item)));
|
|
309
310
|
lines[flowIndex] = `related: [${mapped.join(', ')}]`;
|
|
310
|
-
return
|
|
311
|
+
return rebuild(split, lines.join('\n'));
|
|
311
312
|
}
|
|
312
313
|
const keyIndex = lines.findIndex(line => RELATED_BLOCK_KEY.test(line));
|
|
313
314
|
if (keyIndex === -1)
|
|
@@ -327,7 +328,7 @@ function editRelatedText(content, oldSlug, newSlug) {
|
|
|
327
328
|
kept.push(item.value === oldSlug ? `${item.prefix}${item.quote}${newSlug}${item.quote}${item.suffix}` : line);
|
|
328
329
|
}
|
|
329
330
|
lines.splice(keyIndex + 1, cursor - keyIndex - 1, ...kept);
|
|
330
|
-
return
|
|
331
|
+
return rebuild(split, lines.join('\n'));
|
|
331
332
|
}
|
|
332
333
|
/** Parse one ` - value` line of a block sequence without a backtracking regex. */
|
|
333
334
|
function parseBlockItem(line) {
|
|
@@ -346,9 +347,16 @@ function parseBlockItem(line) {
|
|
|
346
347
|
const quoted = raw.length >= 2 && (first === "'" || first === '"') && raw.endsWith(first);
|
|
347
348
|
return { prefix, quote: quoted ? (first ?? '') : '', value: quoted ? raw.slice(1, -1) : raw, suffix };
|
|
348
349
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
return
|
|
350
|
+
/** Whether the opening line, the block text, the line before the close or the closing line ends with CRLF. */
|
|
351
|
+
function hasCrlf(block) {
|
|
352
|
+
return (block.opening.endsWith('\r\n') ||
|
|
353
|
+
block.textEnding === '\r\n' ||
|
|
354
|
+
block.text.includes('\r\n') ||
|
|
355
|
+
block.closing.endsWith('\r\n'));
|
|
356
|
+
}
|
|
357
|
+
/** The page with the block text replaced and every other byte of it kept. */
|
|
358
|
+
function rebuild(block, text) {
|
|
359
|
+
return block.opening + text + block.textEnding + block.closing + block.body;
|
|
352
360
|
}
|
|
353
361
|
function readRelated(content) {
|
|
354
362
|
try {
|
package/dist/wiki-ingest.js
CHANGED
|
@@ -9,6 +9,9 @@ import { assertBranchWritable } from "./branch-safety.js";
|
|
|
9
9
|
import { verifyCorrectionSurvival } from "./corrections-survival.js";
|
|
10
10
|
import { CorrectionStoreError, readCorrections } from "./corrections.js";
|
|
11
11
|
import { bootstrapDataBranch as defaultBootstrapDataBranch, } from "./data-branch-bootstrap.js";
|
|
12
|
+
import { splitFrontmatterBlock } from "./frontmatter-block.js";
|
|
13
|
+
import { parseFrontmatterDocument } from "./frontmatter.js";
|
|
14
|
+
import { findIndexEntryLines } from "./line-scanners.js";
|
|
12
15
|
import { assertReposFile } from "./schemas.js";
|
|
13
16
|
import { computeRepoSlug } from "./wiki-slug.js";
|
|
14
17
|
import { collectWikilinks } from "./wiki-utils.js";
|
|
@@ -630,11 +633,7 @@ function parseIndexEntryLines(index) {
|
|
|
630
633
|
const entries = new Map();
|
|
631
634
|
// Match the full line: "- [[slug]] — description"
|
|
632
635
|
// Description may contain anything except a newline (we keep the entire trailing text).
|
|
633
|
-
const
|
|
634
|
-
for (const match of index.matchAll(pattern)) {
|
|
635
|
-
const [line, slug] = match;
|
|
636
|
-
if (line === undefined || slug === undefined)
|
|
637
|
-
continue;
|
|
636
|
+
for (const { slug, line } of findIndexEntryLines(index)) {
|
|
638
637
|
entries.set(slug.trim(), line);
|
|
639
638
|
}
|
|
640
639
|
return entries;
|
|
@@ -664,23 +663,16 @@ export function appendLogEntry(existingLog, params) {
|
|
|
664
663
|
return normalizeText(`${base}\n## [${stamp}] ${logOperation} | ${params.target}\n\n${params.summary}\n\n${sources}\n`);
|
|
665
664
|
}
|
|
666
665
|
function parseFrontmatter(path, content) {
|
|
667
|
-
const
|
|
668
|
-
if (
|
|
666
|
+
const split = splitFrontmatterBlock(content);
|
|
667
|
+
if (split.kind !== 'block') {
|
|
669
668
|
throw new WikiIngestError({
|
|
670
669
|
code: 'INVALID_FRONTMATTER',
|
|
671
670
|
message: `${path} is missing YAML frontmatter`,
|
|
672
671
|
remediation: 'Add frontmatter with type, title, created, and updated fields before ingesting the page.',
|
|
673
672
|
});
|
|
674
673
|
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
throw new WikiIngestError({
|
|
678
|
-
code: 'INVALID_FRONTMATTER',
|
|
679
|
-
message: `${path} frontmatter could not be extracted`,
|
|
680
|
-
remediation: 'Ensure the page begins with a valid YAML frontmatter block.',
|
|
681
|
-
});
|
|
682
|
-
}
|
|
683
|
-
const parsed = parse(frontmatterText);
|
|
674
|
+
// Only a block with no lines is an empty mapping. A block of one blank line parses to null, like any blank text.
|
|
675
|
+
const parsed = split.text === '' && split.textEnding === '' ? {} : parse(split.text);
|
|
684
676
|
if (!isRecord(parsed)) {
|
|
685
677
|
throw new WikiIngestError({
|
|
686
678
|
code: 'INVALID_FRONTMATTER',
|
|
@@ -718,6 +710,7 @@ function prepareWikiPage(page, existingFiles, params, deletedPaths, migrations)
|
|
|
718
710
|
const targetSlug = repoTargetSlug(params.target);
|
|
719
711
|
const trackedNodeId = params.trackedRepoNodeIds?.get(pageSlug);
|
|
720
712
|
const isTargetPage = targetSlug === pageSlug;
|
|
713
|
+
const stamp = isTargetPage ? buildTargetStamp(params, existingFiles[page.path]) : undefined;
|
|
721
714
|
const identity = resolvePageIdentity({
|
|
722
715
|
trackedNodeId,
|
|
723
716
|
targetNodeId: isTargetPage ? params.targetNodeId : undefined,
|
|
@@ -730,7 +723,7 @@ function prepareWikiPage(page, existingFiles, params, deletedPaths, migrations)
|
|
|
730
723
|
: undefined;
|
|
731
724
|
return {
|
|
732
725
|
path: page.path,
|
|
733
|
-
content: updateRepoPageFrontmatter(page.content, preserveNodeId),
|
|
726
|
+
content: updateRepoPageFrontmatter(page.content, preserveNodeId, stamp),
|
|
734
727
|
};
|
|
735
728
|
}
|
|
736
729
|
const nodeMatch = identity.trusted ? findRepoPageByNodeId(existingFiles, identity.nodeId) : undefined;
|
|
@@ -750,12 +743,13 @@ function prepareWikiPage(page, existingFiles, params, deletedPaths, migrations)
|
|
|
750
743
|
nodeId: identity.nodeId,
|
|
751
744
|
title: repoTargetTitle(params.target) ?? incomingFrontmatter.title,
|
|
752
745
|
aliases: [nodeMatch.slug],
|
|
746
|
+
stamp,
|
|
753
747
|
}),
|
|
754
748
|
};
|
|
755
749
|
}
|
|
756
750
|
return {
|
|
757
751
|
path: page.path,
|
|
758
|
-
content: updateRepoPageFrontmatter(page.content, identity.nodeId),
|
|
752
|
+
content: updateRepoPageFrontmatter(page.content, identity.nodeId, stamp),
|
|
759
753
|
};
|
|
760
754
|
}
|
|
761
755
|
function resolvePageIdentity(params) {
|
|
@@ -815,17 +809,98 @@ function mergeRepoPageContent(incomingContent, historicalContents, changes) {
|
|
|
815
809
|
body = body === '' ? historicalBody : `${body}\n\n${historicalBody}`;
|
|
816
810
|
}
|
|
817
811
|
}
|
|
812
|
+
// The ingest matched the old page to the target by a trusted node ID, so the merged page is the target's.
|
|
813
|
+
if (changes.stamp !== undefined)
|
|
814
|
+
addTargetSource(values, changes.stamp);
|
|
818
815
|
return renderFrontmatterDocument(values, body);
|
|
819
816
|
}
|
|
820
|
-
function updateRepoPageFrontmatter(content, nodeId) {
|
|
817
|
+
function updateRepoPageFrontmatter(content, nodeId, stamp) {
|
|
821
818
|
const document = parseFrontmatterDocument(content);
|
|
822
819
|
if (nodeId === undefined)
|
|
823
820
|
delete document.values.node_id;
|
|
824
821
|
else
|
|
825
822
|
document.values.node_id = nodeId;
|
|
826
823
|
delete document.values.database_id;
|
|
824
|
+
if (stamp !== undefined)
|
|
825
|
+
stampEmptySources(document.values, stamp);
|
|
827
826
|
return renderFrontmatterDocument(document.values, document.body);
|
|
828
827
|
}
|
|
828
|
+
function buildTargetStamp(params, existingContent) {
|
|
829
|
+
const parts = repoTargetParts(params.target);
|
|
830
|
+
// Only a target whose owner and name are plain GitHub names is listed on its page. Anything else is written
|
|
831
|
+
// without a stamp, as before, and never makes the ingest throw.
|
|
832
|
+
if (parts === undefined || !isPlainGitHubName(parts.owner) || !isPlainGitHubName(parts.name))
|
|
833
|
+
return undefined;
|
|
834
|
+
return {
|
|
835
|
+
owner: parts.owner,
|
|
836
|
+
name: parts.name,
|
|
837
|
+
url: `https://github.com/${parts.owner}/${parts.name}`,
|
|
838
|
+
accessed: params.timestamp.toISOString().slice(0, 10),
|
|
839
|
+
existingContent,
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
/** A GitHub owner or repository name: ASCII letters, digits, `.`, `_` and `-`, and not `.` or `..`. */
|
|
843
|
+
function isPlainGitHubName(value) {
|
|
844
|
+
return value !== '.' && value !== '..' && /^[\w.-]+$/u.test(value);
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Lists the target on its own page when the page lists no sources: no `sources` key, or an empty list. A page that
|
|
848
|
+
* lists any source is never changed, and neither is a `sources` value that is not a list. When the page on the
|
|
849
|
+
* branch already lists the target, the whole `sources` list it has is carried over as it is, dates and other
|
|
850
|
+
* entries included, so a repeat write changes nothing. Otherwise the page gets the one new entry.
|
|
851
|
+
*/
|
|
852
|
+
function stampEmptySources(values, stamp) {
|
|
853
|
+
const sources = values.sources;
|
|
854
|
+
if (sources !== undefined && !Array.isArray(sources))
|
|
855
|
+
return;
|
|
856
|
+
if (sources !== undefined && sources.length > 0)
|
|
857
|
+
return;
|
|
858
|
+
values.sources = findExistingSourcesWithTarget(stamp) ?? [{ url: stamp.url, accessed: stamp.accessed }];
|
|
859
|
+
}
|
|
860
|
+
/** Adds the target to a migrated page's sources when no entry matches it, keeping every other entry. */
|
|
861
|
+
function addTargetSource(values, stamp) {
|
|
862
|
+
const sources = values.sources;
|
|
863
|
+
if (sources !== undefined && !Array.isArray(sources))
|
|
864
|
+
return;
|
|
865
|
+
const listed = sources ?? [];
|
|
866
|
+
if (listed.some(entry => sourceMatchesTarget(entry, stamp)))
|
|
867
|
+
return;
|
|
868
|
+
values.sources = [...listed, { url: stamp.url, accessed: stamp.accessed }];
|
|
869
|
+
}
|
|
870
|
+
/** The `sources` list of the page on the branch, when it has an entry for the target. */
|
|
871
|
+
function findExistingSourcesWithTarget(stamp) {
|
|
872
|
+
if (stamp.existingContent === undefined)
|
|
873
|
+
return undefined;
|
|
874
|
+
try {
|
|
875
|
+
const sources = parseFrontmatterDocument(stamp.existingContent).values.sources;
|
|
876
|
+
return Array.isArray(sources) && sources.some(entry => sourceMatchesTarget(entry, stamp)) ? sources : undefined;
|
|
877
|
+
}
|
|
878
|
+
catch {
|
|
879
|
+
return undefined;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
function sourceMatchesTarget(entry, stamp) {
|
|
883
|
+
return isRecord(entry) && typeof entry.url === 'string' && sourceUrlMatchesRepo(entry.url, stamp.owner, stamp.name);
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Whether `sourceUrl` refers to the repository `owner`/`name` on github.com by the rule the privacy gate applies to
|
|
887
|
+
* a page's sources (`sourceUrlMatchesRepo` in `check-wiki-private-presence.ts`): `https`, host `github.com`, no
|
|
888
|
+
* port, owner and name equal without case, and any path after them. This package cannot import the gate, so the
|
|
889
|
+
* rule is copied and a test pins the copy against the gate's own cases.
|
|
890
|
+
*/
|
|
891
|
+
function sourceUrlMatchesRepo(sourceUrl, owner, name) {
|
|
892
|
+
let parsed;
|
|
893
|
+
try {
|
|
894
|
+
parsed = new URL(sourceUrl);
|
|
895
|
+
}
|
|
896
|
+
catch {
|
|
897
|
+
return false;
|
|
898
|
+
}
|
|
899
|
+
if (parsed.protocol !== 'https:' || parsed.hostname !== 'github.com' || parsed.port !== '')
|
|
900
|
+
return false;
|
|
901
|
+
const segments = parsed.pathname.split('/').filter(segment => segment.length > 0);
|
|
902
|
+
return segments[0]?.toLowerCase() === owner.toLowerCase() && segments[1]?.toLowerCase() === name.toLowerCase();
|
|
903
|
+
}
|
|
829
904
|
function getExistingRepoNodeId(content, path) {
|
|
830
905
|
if (content === undefined)
|
|
831
906
|
return undefined;
|
|
@@ -862,16 +937,6 @@ function mergeFrontmatterArray(records, key) {
|
|
|
862
937
|
}
|
|
863
938
|
return values;
|
|
864
939
|
}
|
|
865
|
-
function parseFrontmatterDocument(content) {
|
|
866
|
-
const match = /^---\n([\s\S]+?)\n---\n?/u.exec(content);
|
|
867
|
-
if (match === null || match[1] === undefined) {
|
|
868
|
-
throw new Error('validated wiki page is missing frontmatter');
|
|
869
|
-
}
|
|
870
|
-
const parsed = parse(match[1]);
|
|
871
|
-
if (!isRecord(parsed))
|
|
872
|
-
throw new Error('validated wiki page frontmatter is not an object');
|
|
873
|
-
return { values: parsed, body: content.slice(match[0].length) };
|
|
874
|
-
}
|
|
875
940
|
function isRepoPagePath(path) {
|
|
876
941
|
return path.startsWith(`${WIKI_ROOT}/repos/`) && path.endsWith('.md');
|
|
877
942
|
}
|
package/dist/wiki-utils.d.ts
CHANGED
|
@@ -20,8 +20,8 @@ export interface SplitFrontmatterResult {
|
|
|
20
20
|
/**
|
|
21
21
|
* Split raw wiki page content into frontmatter and body.
|
|
22
22
|
*
|
|
23
|
-
* Stricter than a naive split: malformed YAML frontmatter is
|
|
24
|
-
* rather than silently discarded, so downstream safety/lint logic can observe
|
|
23
|
+
* Stricter than a naive split: malformed YAML frontmatter, and a block that is opened and never closed, are
|
|
24
|
+
* surfaced via `error` rather than silently discarded, so downstream safety/lint logic can observe them.
|
|
25
25
|
*/
|
|
26
26
|
export declare function splitFrontmatter(content: string): SplitFrontmatterResult;
|
|
27
27
|
/** Collect `[[target]]` and `[[target|label]]` wikilink targets from page body content. */
|