@fro.bot/wiki-write-core 0.1.3 → 0.1.4
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/dist/code-regions.js +34 -15
- package/dist/corrections-survival.d.ts +7 -1
- package/dist/corrections-survival.js +48 -41
- 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 +3 -3
- package/dist/wiki-ingest.js +2 -5
- package/dist/wiki-utils.js +7 -4
- package/package.json +1 -1
package/dist/code-regions.js
CHANGED
|
@@ -88,6 +88,8 @@ export function findRegionAt(regions, offset) {
|
|
|
88
88
|
}
|
|
89
89
|
function findInlineSpans(line) {
|
|
90
90
|
const spans = [];
|
|
91
|
+
// Where each length of backtick run starts on this line, built when the first opener needs it.
|
|
92
|
+
let runStartsByLength;
|
|
91
93
|
let index = 0;
|
|
92
94
|
while (index < line.length) {
|
|
93
95
|
const character = line[index];
|
|
@@ -101,30 +103,47 @@ function findInlineSpans(line) {
|
|
|
101
103
|
}
|
|
102
104
|
const openEnd = skipBackticks(line, index);
|
|
103
105
|
const runLength = openEnd - index;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
break;
|
|
110
|
-
}
|
|
111
|
-
const candidateEnd = skipBackticks(line, closeStart);
|
|
112
|
-
if (candidateEnd - closeStart === runLength) {
|
|
113
|
-
closeEnd = candidateEnd;
|
|
114
|
-
break;
|
|
115
|
-
}
|
|
116
|
-
searchFrom = candidateEnd;
|
|
117
|
-
}
|
|
118
|
-
if (closeEnd === -1) {
|
|
106
|
+
runStartsByLength ??= indexBacktickRuns(line);
|
|
107
|
+
// The closer is the first later run of exactly the opener's length. Openers only move forward, so each
|
|
108
|
+
// length's cursor does too, and every run is passed over at most once.
|
|
109
|
+
const closeStart = nextRunAtOrAfter(runStartsByLength.get(runLength), openEnd);
|
|
110
|
+
if (closeStart === undefined) {
|
|
119
111
|
index = openEnd;
|
|
120
112
|
}
|
|
121
113
|
else {
|
|
114
|
+
const closeEnd = closeStart + runLength;
|
|
122
115
|
spans.push({ start: index, end: closeEnd });
|
|
123
116
|
index = closeEnd;
|
|
124
117
|
}
|
|
125
118
|
}
|
|
126
119
|
return spans;
|
|
127
120
|
}
|
|
121
|
+
/** The start offsets of the maximal backtick runs of `line`, grouped by the length of the run. */
|
|
122
|
+
function indexBacktickRuns(line) {
|
|
123
|
+
const runStartsByLength = new Map();
|
|
124
|
+
for (let start = line.indexOf('`'); start !== -1;) {
|
|
125
|
+
const end = skipBackticks(line, start);
|
|
126
|
+
const runs = runStartsByLength.get(end - start);
|
|
127
|
+
if (runs === undefined) {
|
|
128
|
+
runStartsByLength.set(end - start, { starts: [start], next: 0 });
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
runs.starts.push(start);
|
|
132
|
+
}
|
|
133
|
+
start = line.indexOf('`', end);
|
|
134
|
+
}
|
|
135
|
+
return runStartsByLength;
|
|
136
|
+
}
|
|
137
|
+
/** The first run start at least `minimum`. Callers ask with increasing `minimum`, so the cursor only advances. */
|
|
138
|
+
function nextRunAtOrAfter(runs, minimum) {
|
|
139
|
+
if (runs === undefined) {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
while (runs.next < runs.starts.length && (runs.starts[runs.next] ?? minimum) < minimum) {
|
|
143
|
+
runs.next += 1;
|
|
144
|
+
}
|
|
145
|
+
return runs.starts[runs.next];
|
|
146
|
+
}
|
|
128
147
|
function skipBackticks(line, from) {
|
|
129
148
|
let index = from;
|
|
130
149
|
while (line[index] === '`') {
|
|
@@ -30,6 +30,12 @@ export declare function normalizeFormattingText(value: string): string;
|
|
|
30
30
|
* Masks markdown inline links `[label](url)` to spaces so exact prose matching ignores link
|
|
31
31
|
* targets; wiki links `[[...]]` are left untouched (module docstring). A regex port was proven
|
|
32
32
|
* non-equivalent by exhaustive differential testing (corrections-survival.test.ts), so this
|
|
33
|
-
*
|
|
33
|
+
* stays a scanner.
|
|
34
|
+
*
|
|
35
|
+
* At each `]` that is followed by `(` and preceded by a `[`, the link runs from the last `[` to the
|
|
36
|
+
* `)` that closes that `(`, counting nested parentheses. A `(` with no closing `)` is not a link,
|
|
37
|
+
* and the scan goes on from the next character. After a link, the scan resumes past its `)` with no
|
|
38
|
+
* `[` open. The closing `)` of every `(` is found once, up front, so the scan is linear in the
|
|
39
|
+
* length of the content.
|
|
34
40
|
*/
|
|
35
41
|
export declare function maskMarkdownLinks(content: string): string;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { normalizeCorrectionText } from "./correction-text.js";
|
|
2
2
|
import { assertCorrectionsFile, getCorrectionLifecycle, } from "./corrections.js";
|
|
3
|
+
import { replaceLinkLabels } from "./link-labels.js";
|
|
3
4
|
import { maskNonProseContent } from "./rendering-policy.js";
|
|
4
5
|
import { collectWikiPages } from "./wiki-utils.js";
|
|
5
6
|
/**
|
|
@@ -94,21 +95,18 @@ export function verifyCorrectionSurvival(files, corrections, fallbackFiles = {})
|
|
|
94
95
|
* the pre-refactor reference implementation.
|
|
95
96
|
*/
|
|
96
97
|
export function normalizeFormattingText(value) {
|
|
97
|
-
|
|
98
|
-
return (value
|
|
99
|
-
.normalize('NFKC')
|
|
100
|
-
.replaceAll(markdownLinkPattern, renderVisibleLinkText)
|
|
98
|
+
return (replaceLinkLabels(value.normalize('NFKC'), renderVisibleLinkText)
|
|
101
99
|
// `\s+` below absorbs any run this leaves; a `+` here would be unobservable.
|
|
102
100
|
.replaceAll(/[^\p{L}\p{N}]/gu, ' ')
|
|
103
101
|
.trim()
|
|
104
102
|
.replaceAll(/\s+/gu, ' ')
|
|
105
103
|
.toLowerCase());
|
|
106
104
|
}
|
|
107
|
-
function renderVisibleLinkText(
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
105
|
+
function renderVisibleLinkText({ markdownText, wikiTarget, wikiLabel }) {
|
|
106
|
+
// replaceLinkLabels calls this with exactly one branch matched: the markdown branch always has
|
|
107
|
+
// markdownText (possibly '', never undefined), and the wiki branch always has wikiTarget
|
|
108
|
+
// (required, 1+ chars). A final '' fallback covering "neither captured" is unreachable --
|
|
109
|
+
// asserted, not defaulted.
|
|
112
110
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- see comment above
|
|
113
111
|
return (markdownText ?? wikiLabel ?? wikiTarget);
|
|
114
112
|
}
|
|
@@ -116,45 +114,54 @@ function renderVisibleLinkText(_match, markdownText, wikiTarget, wikiLabel) {
|
|
|
116
114
|
* Masks markdown inline links `[label](url)` to spaces so exact prose matching ignores link
|
|
117
115
|
* targets; wiki links `[[...]]` are left untouched (module docstring). A regex port was proven
|
|
118
116
|
* non-equivalent by exhaustive differential testing (corrections-survival.test.ts), so this
|
|
119
|
-
*
|
|
117
|
+
* stays a scanner.
|
|
118
|
+
*
|
|
119
|
+
* At each `]` that is followed by `(` and preceded by a `[`, the link runs from the last `[` to the
|
|
120
|
+
* `)` that closes that `(`, counting nested parentheses. A `(` with no closing `)` is not a link,
|
|
121
|
+
* and the scan goes on from the next character. After a link, the scan resumes past its `)` with no
|
|
122
|
+
* `[` open. The closing `)` of every `(` is found once, up front, so the scan is linear in the
|
|
123
|
+
* length of the content.
|
|
120
124
|
*/
|
|
121
125
|
export function maskMarkdownLinks(content) {
|
|
122
|
-
const
|
|
126
|
+
const characters = content.split('');
|
|
127
|
+
const closers = findClosingParentheses(characters);
|
|
128
|
+
const masked = [...characters];
|
|
123
129
|
let open = -1;
|
|
124
|
-
let
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
130
|
+
let resumeAt = 0;
|
|
131
|
+
for (const [index, character] of characters.entries()) {
|
|
132
|
+
if (index < resumeAt)
|
|
133
|
+
continue;
|
|
134
|
+
if (character === '[')
|
|
128
135
|
open = index;
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
// output, and are killed by the differential test like any other behavior change.
|
|
136
|
-
// Stryker disable next-line BlockStatement,EqualityOperator: BlockStatement mutation destroys mandatory loop progress (deterministic hang, not a timing artifact); EqualityOperator's >= variant hangs when close starts at or past content.length (a link's `](` at the very end of the string) for the same reason as the outer loop above, and its <= variant only extends the loop to content[content.length], which is always undefined and has no side effect on open/masked/depth
|
|
137
|
-
while (close < content.length) {
|
|
138
|
-
if (depth <= 0)
|
|
139
|
-
break;
|
|
140
|
-
if (content[close] === '(')
|
|
141
|
-
depth += 1;
|
|
142
|
-
else if (content[close] === ')')
|
|
143
|
-
depth -= 1;
|
|
144
|
-
// Stryker disable next-line AssignmentOperator: mutation destroys mandatory loop progress; deterministic hang, not a timing artifact
|
|
145
|
-
close += 1;
|
|
146
|
-
}
|
|
147
|
-
if (depth === 0) {
|
|
148
|
-
// Stryker disable next-line AssignmentOperator: mutation destroys mandatory loop progress; deterministic hang, not a timing artifact
|
|
149
|
-
for (let maskIndex = open; maskIndex < close; maskIndex += 1)
|
|
150
|
-
masked[maskIndex] = ' ';
|
|
151
|
-
index = close;
|
|
136
|
+
if (character === ']' && open !== -1) {
|
|
137
|
+
// `closers` has an entry only for a `(`, so the next character is a `(` with a closer or there is no link.
|
|
138
|
+
const close = closers.get(index + 1);
|
|
139
|
+
if (close !== undefined) {
|
|
140
|
+
masked.fill(' ', open, close + 1);
|
|
141
|
+
resumeAt = close + 1;
|
|
152
142
|
open = -1;
|
|
153
|
-
continue;
|
|
154
143
|
}
|
|
155
144
|
}
|
|
156
|
-
// Stryker disable next-line AssignmentOperator: mutation destroys mandatory loop progress; deterministic hang, not a timing artifact
|
|
157
|
-
index += 1;
|
|
158
145
|
}
|
|
159
146
|
return masked.join('');
|
|
160
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Maps the index of each `(` to the index of the `)` that closes it, counting nested parentheses,
|
|
150
|
+
* or to `undefined` when nothing closes it. Read from the right, each `(` takes the nearest `)`
|
|
151
|
+
* that no `(` between them has taken, which pairs the same parentheses as counting depth to the
|
|
152
|
+
* right of each `(`. A `)` with no `(` before it is left unpaired and never looked up.
|
|
153
|
+
*/
|
|
154
|
+
function findClosingParentheses(characters) {
|
|
155
|
+
const closers = new Map();
|
|
156
|
+
const unpaired = [];
|
|
157
|
+
for (const [reversedIndex, character] of characters.toReversed().entries()) {
|
|
158
|
+
const index = characters.length - 1 - reversedIndex;
|
|
159
|
+
if (character === ')') {
|
|
160
|
+
unpaired.push(index);
|
|
161
|
+
}
|
|
162
|
+
else if (character === '(') {
|
|
163
|
+
closers.set(index, unpaired.pop());
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return closers;
|
|
167
|
+
}
|
package/dist/gate-contract.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// Bump deliberately when an intentional consumer-visible gate contract break ships.
|
|
2
2
|
export const GATE_CONTRACT_VERSION = 1;
|
|
3
3
|
// Replaced with the deterministic source-tree hash by the package build.
|
|
4
|
-
export const GATE_SOURCE_TREE_HASH = '
|
|
4
|
+
export const GATE_SOURCE_TREE_HASH = 'f5e534cd9cc3438f27eee34dd45045ae986891c230ad0af86fcc35ea1e12a2f6';
|
package/dist/gate-contract.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":1,"sourceTreeHash":"
|
|
1
|
+
{"version":1,"sourceTreeHash":"f5e534cd9cc3438f27eee34dd45045ae986891c230ad0af86fcc35ea1e12a2f6"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inline-link scanner behind `resolveMarkdownLinks`. It is an internal module: the package does not
|
|
3
|
+
* export it, so the only way to reach it is through `markdown-links`.
|
|
4
|
+
*/
|
|
5
|
+
export interface InlineLink {
|
|
6
|
+
/** Offset of the opening `[` in the line. */
|
|
7
|
+
readonly index: number;
|
|
8
|
+
/** Length of the whole `[text](destination "title")` span. */
|
|
9
|
+
readonly length: number;
|
|
10
|
+
/** The destination as written: `<angle>` with its brackets, or the bare run. */
|
|
11
|
+
readonly target: string;
|
|
12
|
+
}
|
|
13
|
+
/** The characters `\s` matches: ASCII spaces and controls, then the Unicode space separators. */
|
|
14
|
+
export declare function isWhitespaceCode(code: number): boolean;
|
|
15
|
+
/** For every offset `i` up to the line length plus one: the first offset at or after `i` whose character stops a run, or the line length. */
|
|
16
|
+
export declare function buildStopTable(line: string, stops: (code: number) => boolean): Int32Array;
|
|
17
|
+
/**
|
|
18
|
+
* Builds a function that finds the inline links of one line, `[text](destination "title")`, left to right.
|
|
19
|
+
* `find(from)` returns the leftmost link that starts at or after `from`, or `undefined`.
|
|
20
|
+
*
|
|
21
|
+
* The grammar, over the characters of the line:
|
|
22
|
+
* - `[`, a non-empty text with no `]` or newline, then `](`;
|
|
23
|
+
* - optional whitespace, then the destination: `<` with a non-empty run of no `>` or newline and `>`, or else
|
|
24
|
+
* a non-empty run of no `)` or whitespace; the angle form is tried first and the bare run is the fallback
|
|
25
|
+
* when the angle form is not followed by a valid ending;
|
|
26
|
+
* - an optional title, set off by whitespace, in double quotes, in single quotes or in parentheses, each with
|
|
27
|
+
* no newline inside and no closing delimiter before its end;
|
|
28
|
+
* - optional whitespace, then `)`. A title that is present but does not lead to `)` is dropped, and the
|
|
29
|
+
* destination alone must then be followed by whitespace and `)`.
|
|
30
|
+
*
|
|
31
|
+
* Every run in the grammar ends at the first character that cannot continue it, so a run is read once from
|
|
32
|
+
* a table of stop offsets built right to left, and every start offset is decided in constant time. The
|
|
33
|
+
* tables are built on first use and only for a line that holds `](`.
|
|
34
|
+
*/
|
|
35
|
+
export declare function createInlineLinkFinder(line: string): (from: number) => InlineLink | undefined;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inline-link scanner behind `resolveMarkdownLinks`. It is an internal module: the package does not
|
|
3
|
+
* export it, so the only way to reach it is through `markdown-links`.
|
|
4
|
+
*/
|
|
5
|
+
/** The character codes the grammar tests for. */
|
|
6
|
+
const CLOSE_BRACKET = 0x5d;
|
|
7
|
+
const OPEN_PAREN = 0x28;
|
|
8
|
+
const CLOSE_PAREN = 0x29;
|
|
9
|
+
const LESS_THAN = 0x3c;
|
|
10
|
+
const GREATER_THAN = 0x3e;
|
|
11
|
+
const DOUBLE_QUOTE = 0x22;
|
|
12
|
+
const SINGLE_QUOTE = 0x27;
|
|
13
|
+
const NEWLINE = 0x0a;
|
|
14
|
+
/** The characters `\s` matches: ASCII spaces and controls, then the Unicode space separators. */
|
|
15
|
+
export function isWhitespaceCode(code) {
|
|
16
|
+
if (code <= 0x20)
|
|
17
|
+
return code === 0x20 || (code >= 0x09 && code <= 0x0d);
|
|
18
|
+
if (code < 0xa0)
|
|
19
|
+
return false;
|
|
20
|
+
return (code === 0xa0 ||
|
|
21
|
+
code === 0x1680 ||
|
|
22
|
+
(code >= 0x2000 && code <= 0x200a) ||
|
|
23
|
+
code === 0x2028 ||
|
|
24
|
+
code === 0x2029 ||
|
|
25
|
+
code === 0x202f ||
|
|
26
|
+
code === 0x205f ||
|
|
27
|
+
code === 0x3000 ||
|
|
28
|
+
code === 0xfeff);
|
|
29
|
+
}
|
|
30
|
+
/** For every offset `i` up to the line length plus one: the first offset at or after `i` whose character stops a run, or the line length. */
|
|
31
|
+
export function buildStopTable(line, stops) {
|
|
32
|
+
const table = new Int32Array(line.length + 2).fill(line.length);
|
|
33
|
+
for (let index = line.length - 1; index >= 0; index -= 1) {
|
|
34
|
+
table[index] = stops(line.charCodeAt(index)) ? index : (table[index + 1] ?? line.length);
|
|
35
|
+
}
|
|
36
|
+
return table;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Builds a function that finds the inline links of one line, `[text](destination "title")`, left to right.
|
|
40
|
+
* `find(from)` returns the leftmost link that starts at or after `from`, or `undefined`.
|
|
41
|
+
*
|
|
42
|
+
* The grammar, over the characters of the line:
|
|
43
|
+
* - `[`, a non-empty text with no `]` or newline, then `](`;
|
|
44
|
+
* - optional whitespace, then the destination: `<` with a non-empty run of no `>` or newline and `>`, or else
|
|
45
|
+
* a non-empty run of no `)` or whitespace; the angle form is tried first and the bare run is the fallback
|
|
46
|
+
* when the angle form is not followed by a valid ending;
|
|
47
|
+
* - an optional title, set off by whitespace, in double quotes, in single quotes or in parentheses, each with
|
|
48
|
+
* no newline inside and no closing delimiter before its end;
|
|
49
|
+
* - optional whitespace, then `)`. A title that is present but does not lead to `)` is dropped, and the
|
|
50
|
+
* destination alone must then be followed by whitespace and `)`.
|
|
51
|
+
*
|
|
52
|
+
* Every run in the grammar ends at the first character that cannot continue it, so a run is read once from
|
|
53
|
+
* a table of stop offsets built right to left, and every start offset is decided in constant time. The
|
|
54
|
+
* tables are built on first use and only for a line that holds `](`.
|
|
55
|
+
*/
|
|
56
|
+
export function createInlineLinkFinder(line) {
|
|
57
|
+
if (!line.includes(']('))
|
|
58
|
+
return () => undefined;
|
|
59
|
+
const tables = new Map();
|
|
60
|
+
const stopTable = (name, stops) => {
|
|
61
|
+
let table = tables.get(name);
|
|
62
|
+
if (table === undefined) {
|
|
63
|
+
table = buildStopTable(line, stops);
|
|
64
|
+
tables.set(name, table);
|
|
65
|
+
}
|
|
66
|
+
return table;
|
|
67
|
+
};
|
|
68
|
+
const stopAt = (name, from, stops) => stopTable(name, stops)[from] ?? line.length;
|
|
69
|
+
/** Offset of the `)` that ends a link whose destination ends at `destinationEnd`, plus one; -1 when there is none. */
|
|
70
|
+
const endOfLink = (destinationEnd) => {
|
|
71
|
+
const afterSpace = stopAt('nonSpace', destinationEnd, code => !isWhitespaceCode(code));
|
|
72
|
+
if (afterSpace > destinationEnd) {
|
|
73
|
+
const opener = line.charCodeAt(afterSpace);
|
|
74
|
+
const closer = opener === OPEN_PAREN ? CLOSE_PAREN : opener;
|
|
75
|
+
if (opener === DOUBLE_QUOTE || opener === SINGLE_QUOTE || opener === OPEN_PAREN) {
|
|
76
|
+
const stop = stopAt(`title${closer}`, afterSpace + 1, code => code === closer || code === NEWLINE);
|
|
77
|
+
if (line.charCodeAt(stop) === closer) {
|
|
78
|
+
const closeAt = stopAt('nonSpace', stop + 1, code => !isWhitespaceCode(code));
|
|
79
|
+
if (line.charCodeAt(closeAt) === CLOSE_PAREN)
|
|
80
|
+
return closeAt + 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return line.charCodeAt(afterSpace) === CLOSE_PAREN ? afterSpace + 1 : -1;
|
|
85
|
+
};
|
|
86
|
+
const linkAt = (start) => {
|
|
87
|
+
const textEnd = stopAt('text', start + 1, code => code === CLOSE_BRACKET || code === NEWLINE);
|
|
88
|
+
if (textEnd === start + 1 ||
|
|
89
|
+
line.charCodeAt(textEnd) !== CLOSE_BRACKET ||
|
|
90
|
+
line.charCodeAt(textEnd + 1) !== OPEN_PAREN) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
const destinationStart = stopAt('nonSpace', textEnd + 2, code => !isWhitespaceCode(code));
|
|
94
|
+
if (line.charCodeAt(destinationStart) === LESS_THAN) {
|
|
95
|
+
const angleClose = stopAt('angle', destinationStart + 1, code => code === GREATER_THAN || code === NEWLINE);
|
|
96
|
+
if (angleClose > destinationStart + 1 && line.charCodeAt(angleClose) === GREATER_THAN) {
|
|
97
|
+
const end = endOfLink(angleClose + 1);
|
|
98
|
+
if (end !== -1) {
|
|
99
|
+
return { index: start, length: end - start, target: line.slice(destinationStart, angleClose + 1) };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const bareEnd = stopAt('bare', destinationStart, code => code === CLOSE_PAREN || isWhitespaceCode(code));
|
|
104
|
+
if (bareEnd > destinationStart) {
|
|
105
|
+
const end = endOfLink(bareEnd);
|
|
106
|
+
if (end !== -1) {
|
|
107
|
+
return { index: start, length: end - start, target: line.slice(destinationStart, bareEnd) };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
};
|
|
112
|
+
return from => {
|
|
113
|
+
for (let start = line.indexOf('[', from); start !== -1; start = line.indexOf('[', start + 1)) {
|
|
114
|
+
const link = linkAt(start);
|
|
115
|
+
if (link !== undefined)
|
|
116
|
+
return link;
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -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
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* - `log.md` history and `knowledge/wiki/README.md` are never rewritten.
|
|
15
15
|
*/
|
|
16
16
|
import { parseFrontmatterDocument, renderFrontmatterDocument } from "./frontmatter.js";
|
|
17
|
+
import { parseRelatedFlow } from "./line-scanners.js";
|
|
17
18
|
import { appendLogEntry, rebuildWikiIndex, validateWikilinks, WikiIngestError } from "./wiki-ingest.js";
|
|
18
19
|
import { buildPrivateNameTokens, computeRepoSlug } from "./wiki-slug.js";
|
|
19
20
|
import { findWikilinkSpans } from "./wiki-utils.js";
|
|
@@ -22,7 +23,6 @@ const LOG_PATH = 'knowledge/log.md';
|
|
|
22
23
|
const REPOS_DIR = 'knowledge/wiki/repos';
|
|
23
24
|
const WIKI_PAGE_PATTERN = /^knowledge\/wiki\/(?:repos|topics|entities|comparisons)\/[^/]+\.md$/u;
|
|
24
25
|
const RELATED_BLOCK_KEY = /^related:\s*(?:#.*)?$/u;
|
|
25
|
-
const RELATED_FLOW = /^related:\s*\[(.*)\]\s*(?:#.*)?$/u;
|
|
26
26
|
/** Apply a change set to a file map, returning a new map. */
|
|
27
27
|
export function applyPageChanges(files, changes) {
|
|
28
28
|
const next = { ...files };
|
|
@@ -301,9 +301,9 @@ function editRelatedText(content, oldSlug, newSlug) {
|
|
|
301
301
|
if (frontmatter === undefined)
|
|
302
302
|
return undefined;
|
|
303
303
|
const lines = frontmatter.split('\n');
|
|
304
|
-
const flowIndex = lines.findIndex(line =>
|
|
304
|
+
const flowIndex = lines.findIndex(line => parseRelatedFlow(line) !== undefined);
|
|
305
305
|
if (flowIndex !== -1) {
|
|
306
|
-
const inner =
|
|
306
|
+
const inner = parseRelatedFlow(lines[flowIndex] ?? '') ?? '';
|
|
307
307
|
const items = inner.split(',').map(item => item.trim());
|
|
308
308
|
const mapped = dedupe(items.map(item => (stripQuotes(item) === oldSlug ? swapQuoted(item, newSlug) : item)));
|
|
309
309
|
lines[flowIndex] = `related: [${mapped.join(', ')}]`;
|
package/dist/wiki-ingest.js
CHANGED
|
@@ -9,6 +9,7 @@ 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 { findIndexEntryLines } from "./line-scanners.js";
|
|
12
13
|
import { assertReposFile } from "./schemas.js";
|
|
13
14
|
import { computeRepoSlug } from "./wiki-slug.js";
|
|
14
15
|
import { collectWikilinks } from "./wiki-utils.js";
|
|
@@ -630,11 +631,7 @@ function parseIndexEntryLines(index) {
|
|
|
630
631
|
const entries = new Map();
|
|
631
632
|
// Match the full line: "- [[slug]] — description"
|
|
632
633
|
// 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;
|
|
634
|
+
for (const { slug, line } of findIndexEntryLines(index)) {
|
|
638
635
|
entries.set(slug.trim(), line);
|
|
639
636
|
}
|
|
640
637
|
return entries;
|
package/dist/wiki-utils.js
CHANGED
|
@@ -200,9 +200,12 @@ export function truncateToBytes(value, maxBytes) {
|
|
|
200
200
|
if (contentBudget <= 0) {
|
|
201
201
|
return '';
|
|
202
202
|
}
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
203
|
+
const decoded = Buffer.from(value).subarray(0, contentBudget).toString('utf8');
|
|
204
|
+
// Drop the replacement characters a cut multi-byte sequence decodes to. A trailing-run regex rescans a long run
|
|
205
|
+
// from every position in it.
|
|
206
|
+
let keep = decoded.length;
|
|
207
|
+
while (keep > 0 && decoded.charCodeAt(keep - 1) === 0xff_fd)
|
|
208
|
+
keep -= 1;
|
|
209
|
+
const truncated = decoded.slice(0, keep);
|
|
207
210
|
return truncated === '' ? '' : `${truncated}${ellipsis}`;
|
|
208
211
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fro.bot/wiki-write-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Shared validation and commit primitives for Fro Bot wiki writes",
|
|
5
5
|
"homepage": "https://github.com/fro-bot/.github/tree/main/packages/wiki-write-core#readme",
|
|
6
6
|
"bugs": {
|