@osdlc/markdown-link 0.0.0-stage → 0.0.1-stage
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/CHANGELOG.md +1 -0
- package/CONTRIBUTING.md +64 -0
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +21 -0
- package/README.md +113 -2
- package/SECURITY.md +23 -0
- package/dist/body-links.d.ts +64 -0
- package/dist/body-links.js +219 -0
- package/dist/code.d.ts +29 -0
- package/dist/code.js +353 -0
- package/dist/display.d.ts +36 -0
- package/dist/display.js +88 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +13 -0
- package/dist/parse.d.ts +68 -0
- package/dist/parse.js +217 -0
- package/package.json +64 -3
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The body link scanner: every link in a body, with its span. A port of the
|
|
3
|
+
* `markdown-link` crate's `body_links_spanned` and `body_links`.
|
|
4
|
+
*/
|
|
5
|
+
import { stripCode } from "./code.js";
|
|
6
|
+
import { isVaultLinkUrl, isWhitespace, mdLinkTarget, parseInner, trim } from "./parse.js";
|
|
7
|
+
/**
|
|
8
|
+
* Every wikilink, embed and relative markdown link in a note body, in the
|
|
9
|
+
* order they appear. `bodyLinksSpanned` without the spans, so the two cannot
|
|
10
|
+
* disagree. A markdown link is reported as `'wikilink'`.
|
|
11
|
+
*/
|
|
12
|
+
export function bodyLinks(body) {
|
|
13
|
+
return bodyLinksSpanned(body).map((link) => ({
|
|
14
|
+
target: link.target,
|
|
15
|
+
kind: link.kind === 'embed' ? 'embed' : 'wikilink',
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Every wikilink, embed and relative markdown link in a body, in encounter
|
|
20
|
+
* order, with spans.
|
|
21
|
+
*
|
|
22
|
+
* Code is blanked before matching, `[[` never nests, and a markdown link
|
|
23
|
+
* counts only when `isVaultLinkUrl` accepts its URL and it carries no scheme.
|
|
24
|
+
*
|
|
25
|
+
* The scan walks code points. `stripCode` keeps the code point count of its
|
|
26
|
+
* input, not its UTF-16 length, so a code point index into the stripped text
|
|
27
|
+
* is one into the body, and a table converts it to a UTF-16 offset.
|
|
28
|
+
*/
|
|
29
|
+
export function bodyLinksSpanned(body) {
|
|
30
|
+
const chars = Array.from(stripCode(body));
|
|
31
|
+
// Code point index -> UTF-16 offset in the body, plus an end sentinel.
|
|
32
|
+
const at = [];
|
|
33
|
+
let offset = 0;
|
|
34
|
+
for (const c of body) {
|
|
35
|
+
at.push(offset);
|
|
36
|
+
offset += c.length;
|
|
37
|
+
}
|
|
38
|
+
at.push(body.length);
|
|
39
|
+
if (chars.length + 1 !== at.length) {
|
|
40
|
+
// `stripCode` must keep the code point count. If it ever does not,
|
|
41
|
+
// reporting no links beats reporting spans that index the wrong text.
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
const out = [];
|
|
45
|
+
const scan = new UrlScan();
|
|
46
|
+
let i = 0;
|
|
47
|
+
while (i < chars.length) {
|
|
48
|
+
if (chars[i] === '[' && chars[i + 1] === '[') {
|
|
49
|
+
const embed = i > 0 && chars[i - 1] === '!';
|
|
50
|
+
let j = i + 2;
|
|
51
|
+
let inner = '';
|
|
52
|
+
let pipe = -1;
|
|
53
|
+
let closed = false;
|
|
54
|
+
while (j + 1 < chars.length) {
|
|
55
|
+
if (chars[j] === ']' && chars[j + 1] === ']') {
|
|
56
|
+
closed = true;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
// `[[` never nests, and `[` or `]` inside is a malformed link: stop
|
|
60
|
+
// rather than swallow the rest of the note.
|
|
61
|
+
if (chars[j] === '[' || chars[j] === ']')
|
|
62
|
+
break;
|
|
63
|
+
if (pipe === -1 && chars[j] === '|')
|
|
64
|
+
pipe = j;
|
|
65
|
+
inner += chars[j];
|
|
66
|
+
j++;
|
|
67
|
+
}
|
|
68
|
+
if (closed && inner !== '') {
|
|
69
|
+
const link = parseInner(inner, embed);
|
|
70
|
+
if (link !== null) {
|
|
71
|
+
// Split at the pipe `parseInner` used, found in the stripped chars
|
|
72
|
+
// so a `|` in code is not one, then read the alias from the body so
|
|
73
|
+
// code inside it is kept.
|
|
74
|
+
const alias = pipe === -1 ? null : trim(body.slice(at[pipe + 1], at[j])) || null;
|
|
75
|
+
out.push({
|
|
76
|
+
kind: embed ? 'embed' : 'wikilink',
|
|
77
|
+
raw: inner,
|
|
78
|
+
target: link.target,
|
|
79
|
+
alias,
|
|
80
|
+
start: at[embed ? i - 1 : i],
|
|
81
|
+
end: at[j + 2],
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
i = j + 2;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (chars[i] === '[') {
|
|
89
|
+
const found = markdownLink(chars, i, at, scan);
|
|
90
|
+
if (found !== null) {
|
|
91
|
+
out.push(found.link);
|
|
92
|
+
i = found.next;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
i++;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* `[text](relative/note.md)` starting at `start`, and the code point index
|
|
102
|
+
* just past it, or `null` when this is not one.
|
|
103
|
+
*
|
|
104
|
+
* The shape is the regex `\[[^\]]*\]\(<?([^)>\s]+?)>?\)`, walked by hand so
|
|
105
|
+
* positions index the body. A URL containing whitespace is not a link. The
|
|
106
|
+
* URL is read in place and built only for a link, so a body of failed
|
|
107
|
+
* attempts stays linear.
|
|
108
|
+
*/
|
|
109
|
+
function markdownLink(chars, start, at, scan) {
|
|
110
|
+
let j = start + 1;
|
|
111
|
+
while (j < chars.length && chars[j] !== ']' && chars[j] !== '[')
|
|
112
|
+
j++;
|
|
113
|
+
if (chars[j] !== ']' || chars[j + 1] !== '(')
|
|
114
|
+
return null;
|
|
115
|
+
const angled = chars[j + 2] === '<';
|
|
116
|
+
const urlStart = angled ? j + 3 : j + 2;
|
|
117
|
+
const urlEnd = scan.urlEnd(chars, urlStart);
|
|
118
|
+
let end = urlEnd;
|
|
119
|
+
if (angled) {
|
|
120
|
+
if (chars[end] !== '>')
|
|
121
|
+
return null;
|
|
122
|
+
end++;
|
|
123
|
+
}
|
|
124
|
+
if (chars[end] !== ')')
|
|
125
|
+
return null;
|
|
126
|
+
end++;
|
|
127
|
+
// Not a link this dialect harvests. The caller advances one code point, so
|
|
128
|
+
// a match may start at an inner `[`: `[a[b](x.md)` is a link.
|
|
129
|
+
if (!scan.isVaultLink(chars, urlStart, urlEnd) || hasScheme(chars, urlStart, urlEnd)) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
const url = chars.slice(urlStart, urlEnd).join('');
|
|
133
|
+
return {
|
|
134
|
+
link: {
|
|
135
|
+
kind: 'markdown',
|
|
136
|
+
raw: url,
|
|
137
|
+
target: mdLinkTarget(url),
|
|
138
|
+
alias: null,
|
|
139
|
+
start: at[start],
|
|
140
|
+
end: at[end],
|
|
141
|
+
},
|
|
142
|
+
next: end,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* What earlier markdown link attempts learned about the URLs they read. Each
|
|
147
|
+
* attempt starts later than the one before, so remembering the last answer of
|
|
148
|
+
* each kind keeps a body of failed attempts linear.
|
|
149
|
+
*/
|
|
150
|
+
class UrlScan {
|
|
151
|
+
/** The first `)`, `>` or whitespace at or after `from` is at `stop`. */
|
|
152
|
+
from = -1;
|
|
153
|
+
stop = -1;
|
|
154
|
+
end = -1;
|
|
155
|
+
markFrom = -1;
|
|
156
|
+
/**
|
|
157
|
+
* The last `.` or `/` in `[markFrom, end)`, or below `markFrom` when there
|
|
158
|
+
* is none.
|
|
159
|
+
*/
|
|
160
|
+
mark = -1;
|
|
161
|
+
/** `isVaultLinkUrl` of `chars[tailStart, end)`, by `tailStart`. */
|
|
162
|
+
answers = new Map();
|
|
163
|
+
/** Where a URL starting at `from` ends: the first `)`, `>` or whitespace. */
|
|
164
|
+
urlEnd(chars, from) {
|
|
165
|
+
if (from >= this.from && from <= this.stop)
|
|
166
|
+
return this.stop;
|
|
167
|
+
let k = from;
|
|
168
|
+
while (k < chars.length && chars[k] !== ')' && chars[k] !== '>' && !isWhitespace(chars[k])) {
|
|
169
|
+
k++;
|
|
170
|
+
}
|
|
171
|
+
this.from = from;
|
|
172
|
+
this.stop = k;
|
|
173
|
+
return k;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* `isVaultLinkUrl` of `chars[from, end)`. That answer depends only on
|
|
177
|
+
* whether the URL ends in `.md` after at least one character and on the
|
|
178
|
+
* extension of its last segment. So the URL from the character before its
|
|
179
|
+
* last `.` gives the same answer, and that tail is shared by every attempt
|
|
180
|
+
* ending at `end`.
|
|
181
|
+
*/
|
|
182
|
+
isVaultLink(chars, from, end) {
|
|
183
|
+
if (end !== this.end || from < this.markFrom) {
|
|
184
|
+
let mark = end - 1;
|
|
185
|
+
while (mark >= from && chars[mark] !== '.' && chars[mark] !== '/')
|
|
186
|
+
mark--;
|
|
187
|
+
this.end = end;
|
|
188
|
+
this.markFrom = from;
|
|
189
|
+
this.mark = mark;
|
|
190
|
+
this.answers.clear();
|
|
191
|
+
}
|
|
192
|
+
if (this.mark < from || chars[this.mark] === '/')
|
|
193
|
+
return false;
|
|
194
|
+
const tailStart = Math.max(from, this.mark - 1);
|
|
195
|
+
let answer = this.answers.get(tailStart);
|
|
196
|
+
if (answer === undefined) {
|
|
197
|
+
answer = isVaultLinkUrl(chars.slice(tailStart, end).join(''));
|
|
198
|
+
this.answers.set(tailStart, answer);
|
|
199
|
+
}
|
|
200
|
+
return answer;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Whether `chars[from, end)` starts with a URI scheme (`http:`, `https:`,
|
|
205
|
+
* `mailto:`), which marks the URL external. Read in place, like the rest of
|
|
206
|
+
* the URL: `/^[A-Za-z][A-Za-z0-9+.-]*:/` on a built string would cost the
|
|
207
|
+
* whole URL on every failed attempt.
|
|
208
|
+
*/
|
|
209
|
+
function hasScheme(chars, from, end) {
|
|
210
|
+
if (from >= end || !/^[A-Za-z]$/.test(chars[from]))
|
|
211
|
+
return false;
|
|
212
|
+
for (let i = from + 1; i < end; i++) {
|
|
213
|
+
if (chars[i] === ':')
|
|
214
|
+
return true;
|
|
215
|
+
if (!/^[A-Za-z0-9+.-]$/.test(chars[i]))
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
return false;
|
|
219
|
+
}
|
package/dist/code.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code fences and inline code spans: finding them, and blanking them so a
|
|
3
|
+
* lexer never harvests out of them. A port of the `markdown-link` crate's
|
|
4
|
+
* `wikilink::code` module; that module's doc states both rule sets in full.
|
|
5
|
+
*
|
|
6
|
+
* In short, `'commonmark'` (the default) follows CommonMark, and
|
|
7
|
+
* `'skill-export'` keeps the looser rules of `skill-export`'s renderer. Both read one line at a time, so neither knows about container
|
|
8
|
+
* blocks, and indented code blocks are not code.
|
|
9
|
+
*
|
|
10
|
+
* Every marker is ASCII, so a scan over UTF-16 code units never splits a
|
|
11
|
+
* character.
|
|
12
|
+
*/
|
|
13
|
+
/** Which rules the lexer follows. */
|
|
14
|
+
export type CodeRules = 'commonmark' | 'skill-export';
|
|
15
|
+
/**
|
|
16
|
+
* Blank out fenced and inline code so links and tags inside it are not
|
|
17
|
+
* harvested. `stripCodeWith` under `'commonmark'`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function stripCode(text: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* Blank out fenced and inline code under `rules`.
|
|
22
|
+
*
|
|
23
|
+
* Every character of code, fence marker lines included, becomes a space, and
|
|
24
|
+
* every `\n` stays. The output has the same number of **code points** as the
|
|
25
|
+
* input, as the Rust crate keeps the same number of `char`s. It is not the
|
|
26
|
+
* same UTF-16 length: a character outside the Basic Multilingual Plane in code
|
|
27
|
+
* becomes one space, not two.
|
|
28
|
+
*/
|
|
29
|
+
export declare function stripCodeWith(text: string, rules: CodeRules): string;
|
package/dist/code.js
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code fences and inline code spans: finding them, and blanking them so a
|
|
3
|
+
* lexer never harvests out of them. A port of the `markdown-link` crate's
|
|
4
|
+
* `wikilink::code` module; that module's doc states both rule sets in full.
|
|
5
|
+
*
|
|
6
|
+
* In short, `'commonmark'` (the default) follows CommonMark, and
|
|
7
|
+
* `'skill-export'` keeps the looser rules of `skill-export`'s renderer. Both read one line at a time, so neither knows about container
|
|
8
|
+
* blocks, and indented code blocks are not code.
|
|
9
|
+
*
|
|
10
|
+
* Every marker is ASCII, so a scan over UTF-16 code units never splits a
|
|
11
|
+
* character.
|
|
12
|
+
*/
|
|
13
|
+
const NEWLINE = 10;
|
|
14
|
+
const CR = 13;
|
|
15
|
+
const TAB = 9;
|
|
16
|
+
const SPACE = 32;
|
|
17
|
+
const BACKTICK = 96;
|
|
18
|
+
const TILDE = 126;
|
|
19
|
+
/**
|
|
20
|
+
* `^ {0,3}(`{3,}|~{3,})` on the line `s[start, end)`: the marker's shape only.
|
|
21
|
+
*/
|
|
22
|
+
function fenceRun(s, start, end) {
|
|
23
|
+
const at = start + leadingSpaces(s, start, end, 3);
|
|
24
|
+
if (at >= end)
|
|
25
|
+
return null;
|
|
26
|
+
const char = s.charCodeAt(at);
|
|
27
|
+
if (char !== BACKTICK && char !== TILDE)
|
|
28
|
+
return null;
|
|
29
|
+
let runEnd = at;
|
|
30
|
+
while (runEnd < end && s.charCodeAt(runEnd) === char)
|
|
31
|
+
runEnd++;
|
|
32
|
+
const length = runEnd - at;
|
|
33
|
+
return length >= 3 ? { char, length, end: runEnd } : null;
|
|
34
|
+
}
|
|
35
|
+
function opensFence(s, start, end, rules) {
|
|
36
|
+
const run = fenceRun(s, start, end);
|
|
37
|
+
if (run === null)
|
|
38
|
+
return null;
|
|
39
|
+
const backtickInInfo = run.char === BACKTICK && s.slice(run.end, end).includes('`');
|
|
40
|
+
if (rules === 'commonmark' && backtickInInfo)
|
|
41
|
+
return null;
|
|
42
|
+
return { char: run.char, length: run.length };
|
|
43
|
+
}
|
|
44
|
+
function closesFence(s, start, end, open, rules) {
|
|
45
|
+
const run = fenceRun(s, start, end);
|
|
46
|
+
if (run === null)
|
|
47
|
+
return false;
|
|
48
|
+
return (run.char === open.char &&
|
|
49
|
+
run.length >= open.length &&
|
|
50
|
+
(rules === 'skill-export' || isBlank(s, run.end, end)));
|
|
51
|
+
}
|
|
52
|
+
/** The fence state of a line-by-line scan: which fence, if any, is open. */
|
|
53
|
+
class Fences {
|
|
54
|
+
open = null;
|
|
55
|
+
rules;
|
|
56
|
+
constructor(rules) {
|
|
57
|
+
this.rules = rules;
|
|
58
|
+
}
|
|
59
|
+
/** Classify the line `s[start, end)` and step past it. */
|
|
60
|
+
line(s, start, end) {
|
|
61
|
+
if (this.open === null) {
|
|
62
|
+
this.open = opensFence(s, start, end, this.rules);
|
|
63
|
+
return this.open === null ? 'outside' : 'marker';
|
|
64
|
+
}
|
|
65
|
+
if (closesFence(s, start, end, this.open, this.rules)) {
|
|
66
|
+
this.open = null;
|
|
67
|
+
return 'marker';
|
|
68
|
+
}
|
|
69
|
+
if (this.rules === 'skill-export' && fenceRun(s, start, end) !== null)
|
|
70
|
+
return 'marker';
|
|
71
|
+
return 'inside';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The backtick runs in `start..end`, the rest of a block from where a search
|
|
76
|
+
* found no close.
|
|
77
|
+
*/
|
|
78
|
+
class BlockRuns {
|
|
79
|
+
/**
|
|
80
|
+
* `[length, start]` for each run length that occurs, sorted by length:
|
|
81
|
+
* where the last run that would close a span of that length starts.
|
|
82
|
+
*/
|
|
83
|
+
lastClose;
|
|
84
|
+
start;
|
|
85
|
+
end;
|
|
86
|
+
rules;
|
|
87
|
+
constructor(s, start, end, rules) {
|
|
88
|
+
this.start = start;
|
|
89
|
+
this.end = end;
|
|
90
|
+
this.rules = rules;
|
|
91
|
+
const last = new Map();
|
|
92
|
+
let at = start;
|
|
93
|
+
while (at < end) {
|
|
94
|
+
if (s.charCodeAt(at) !== BACKTICK) {
|
|
95
|
+
at++;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const run = runLength(s, at, end);
|
|
99
|
+
last.set(run, at);
|
|
100
|
+
at += run;
|
|
101
|
+
}
|
|
102
|
+
this.lastClose = [...last].sort((a, b) => a[0] - b[0]);
|
|
103
|
+
if (rules === 'skill-export') {
|
|
104
|
+
// A longer run closes a shorter span too.
|
|
105
|
+
for (let i = this.lastClose.length - 2; i >= 0; i--) {
|
|
106
|
+
const entry = this.lastClose[i];
|
|
107
|
+
entry[1] = Math.max(entry[1], this.lastClose[i + 1][1]);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** Whether a run at or after `from` closes a span opened by `length` backticks. */
|
|
112
|
+
hasClose(length, from) {
|
|
113
|
+
let lo = 0;
|
|
114
|
+
let hi = this.lastClose.length;
|
|
115
|
+
while (lo < hi) {
|
|
116
|
+
const mid = (lo + hi) >> 1;
|
|
117
|
+
if (this.lastClose[mid][0] < length)
|
|
118
|
+
lo = mid + 1;
|
|
119
|
+
else
|
|
120
|
+
hi = mid;
|
|
121
|
+
}
|
|
122
|
+
const entry = this.lastClose[lo];
|
|
123
|
+
if (entry === undefined)
|
|
124
|
+
return false;
|
|
125
|
+
return entry[1] >= from && (entry[0] === length || this.rules === 'skill-export');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Finds where inline code spans close, for a scan that reads its input once,
|
|
130
|
+
* left to right. Call `line` at each line start the scan reaches outside a
|
|
131
|
+
* fence, and `close` at each backtick run it reaches. A whole pass is linear
|
|
132
|
+
* in the size of its input.
|
|
133
|
+
*/
|
|
134
|
+
class Spans {
|
|
135
|
+
/** Whether the current line is one a span cannot leave. */
|
|
136
|
+
oneLine = false;
|
|
137
|
+
noClose = null;
|
|
138
|
+
rules;
|
|
139
|
+
s;
|
|
140
|
+
constructor(rules, s) {
|
|
141
|
+
this.rules = rules;
|
|
142
|
+
this.s = s;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Note the line `s[start, end)` the scan has reached. Under `'commonmark'`,
|
|
146
|
+
* a span opened on an ATX heading or a line starting with `|` stays on that
|
|
147
|
+
* line.
|
|
148
|
+
*/
|
|
149
|
+
line(start, end) {
|
|
150
|
+
this.oneLine = this.rules === 'commonmark' && isOneLineBlock(this.s, start, end);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The backtick run that starts at `start`: its length, and where the span it
|
|
154
|
+
* opens closes (the start of the closing run), or `null` if it does not.
|
|
155
|
+
*/
|
|
156
|
+
close(start) {
|
|
157
|
+
const length = runLength(this.s, start, this.s.length);
|
|
158
|
+
const from = start + length;
|
|
159
|
+
const runs = this.noClose;
|
|
160
|
+
if (runs !== null && runs.start <= from && from < runs.end && !runs.hasClose(length, from)) {
|
|
161
|
+
return [length, null];
|
|
162
|
+
}
|
|
163
|
+
const found = this.search(length, from);
|
|
164
|
+
if ('close' in found)
|
|
165
|
+
return [length, found.close];
|
|
166
|
+
this.noClose = new BlockRuns(this.s, from, found.blockEnd, this.rules);
|
|
167
|
+
return [length, null];
|
|
168
|
+
}
|
|
169
|
+
/** The close, or the end of the block when there is none. */
|
|
170
|
+
search(length, from) {
|
|
171
|
+
const s = this.s;
|
|
172
|
+
let at = from;
|
|
173
|
+
for (;;) {
|
|
174
|
+
while (at < s.length && s.charCodeAt(at) !== NEWLINE) {
|
|
175
|
+
if (s.charCodeAt(at) !== BACKTICK) {
|
|
176
|
+
at++;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const run = runLength(s, at, s.length);
|
|
180
|
+
if (closes(run, length, this.rules))
|
|
181
|
+
return { close: at };
|
|
182
|
+
at += run;
|
|
183
|
+
}
|
|
184
|
+
const next = at + 1;
|
|
185
|
+
if (this.oneLine || next >= s.length)
|
|
186
|
+
return { blockEnd: Math.min(next, s.length) };
|
|
187
|
+
if (endsSpanBlock(s, next, lineEnd(s, next), this.rules))
|
|
188
|
+
return { blockEnd: next };
|
|
189
|
+
at = next;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function closes(run, length, rules) {
|
|
194
|
+
return rules === 'commonmark' ? run === length : run >= length;
|
|
195
|
+
}
|
|
196
|
+
function runLength(s, at, end) {
|
|
197
|
+
let i = at;
|
|
198
|
+
while (i < end && s.charCodeAt(i) === BACKTICK)
|
|
199
|
+
i++;
|
|
200
|
+
return i - at;
|
|
201
|
+
}
|
|
202
|
+
/** Whether the line `s[start, end)` starts a block that a span cannot reach into. */
|
|
203
|
+
function endsSpanBlock(s, start, end, rules) {
|
|
204
|
+
const stop = end > start && s.charCodeAt(end - 1) === CR ? end - 1 : end;
|
|
205
|
+
if (isBlank(s, start, stop))
|
|
206
|
+
return true;
|
|
207
|
+
if (rules === 'skill-export')
|
|
208
|
+
return fenceRun(s, start, stop) !== null;
|
|
209
|
+
return opensFence(s, start, stop, rules) !== null || startsBlock(s, start, stop);
|
|
210
|
+
}
|
|
211
|
+
/** An ATX heading or a line starting with `|`: a block that is one line long. */
|
|
212
|
+
function isOneLineBlock(s, start, end) {
|
|
213
|
+
const at = start + leadingSpaces(s, start, end, 3);
|
|
214
|
+
return atxHeading(s, at, end) || (at < end && s[at] === '|');
|
|
215
|
+
}
|
|
216
|
+
/** `#{1,6}` then a space, a tab or the end of the line, from `at`. */
|
|
217
|
+
function atxHeading(s, at, end) {
|
|
218
|
+
let i = at;
|
|
219
|
+
while (i < end && s[i] === '#')
|
|
220
|
+
i++;
|
|
221
|
+
const hashes = i - at;
|
|
222
|
+
if (hashes < 1 || hashes > 6)
|
|
223
|
+
return false;
|
|
224
|
+
if (i >= end)
|
|
225
|
+
return true;
|
|
226
|
+
const after = s.charCodeAt(i);
|
|
227
|
+
return after === SPACE || after === TAB || after === CR;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* An ATX heading, a line starting with `|`, a setext underline, a thematic
|
|
231
|
+
* break, a block quote or a list item.
|
|
232
|
+
*/
|
|
233
|
+
function startsBlock(s, start, end) {
|
|
234
|
+
const indented = start + leadingSpaces(s, start, end, 3);
|
|
235
|
+
let rest = start;
|
|
236
|
+
while (rest < end && (s.charCodeAt(rest) === SPACE || s.charCodeAt(rest) === TAB))
|
|
237
|
+
rest++;
|
|
238
|
+
const endsMarker = (at) => at >= end || s.charCodeAt(at) === SPACE || s.charCodeAt(at) === TAB;
|
|
239
|
+
let equals = indented;
|
|
240
|
+
while (equals < end && s[equals] === '=')
|
|
241
|
+
equals++;
|
|
242
|
+
const setext = equals > indented && isBlank(s, equals, end);
|
|
243
|
+
const quote = rest < end && s[rest] === '>';
|
|
244
|
+
const bullet = rest < end && '-*+'.includes(s[rest]) && endsMarker(rest + 1);
|
|
245
|
+
let digitsEnd = rest;
|
|
246
|
+
while (digitsEnd < end && isAsciiDigit(s.charCodeAt(digitsEnd)))
|
|
247
|
+
digitsEnd++;
|
|
248
|
+
const digits = digitsEnd - rest;
|
|
249
|
+
const ordered = digits >= 1 &&
|
|
250
|
+
digits <= 9 &&
|
|
251
|
+
digitsEnd < end &&
|
|
252
|
+
(s[digitsEnd] === '.' || s[digitsEnd] === ')') &&
|
|
253
|
+
endsMarker(digitsEnd + 1);
|
|
254
|
+
return (isOneLineBlock(s, start, end) ||
|
|
255
|
+
setext ||
|
|
256
|
+
quote ||
|
|
257
|
+
bullet ||
|
|
258
|
+
ordered ||
|
|
259
|
+
thematicBreak(s, indented, end));
|
|
260
|
+
}
|
|
261
|
+
/** Three or more of one of `-`, `*` or `_`, with only spaces or tabs between. */
|
|
262
|
+
function thematicBreak(s, start, end) {
|
|
263
|
+
if (start >= end)
|
|
264
|
+
return false;
|
|
265
|
+
const mark = s[start];
|
|
266
|
+
if (mark !== '-' && mark !== '*' && mark !== '_')
|
|
267
|
+
return false;
|
|
268
|
+
let count = 0;
|
|
269
|
+
for (let i = start; i < end; i++) {
|
|
270
|
+
const c = s[i];
|
|
271
|
+
if (c === mark)
|
|
272
|
+
count++;
|
|
273
|
+
else if (c !== ' ' && c !== '\t')
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
return count >= 3;
|
|
277
|
+
}
|
|
278
|
+
function isAsciiDigit(code) {
|
|
279
|
+
return code >= 48 && code <= 57;
|
|
280
|
+
}
|
|
281
|
+
function leadingSpaces(s, start, end, max) {
|
|
282
|
+
let n = 0;
|
|
283
|
+
while (n < max && start + n < end && s.charCodeAt(start + n) === SPACE)
|
|
284
|
+
n++;
|
|
285
|
+
return n;
|
|
286
|
+
}
|
|
287
|
+
function isBlank(s, start, end) {
|
|
288
|
+
for (let i = start; i < end; i++) {
|
|
289
|
+
const c = s.charCodeAt(i);
|
|
290
|
+
if (c !== SPACE && c !== TAB && c !== CR)
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
/** The index of the `\n` ending the line that holds `from`, or the end of input. */
|
|
296
|
+
function lineEnd(s, from) {
|
|
297
|
+
const at = s.indexOf('\n', from);
|
|
298
|
+
return at === -1 ? s.length : at;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Blank out fenced and inline code so links and tags inside it are not
|
|
302
|
+
* harvested. `stripCodeWith` under `'commonmark'`.
|
|
303
|
+
*/
|
|
304
|
+
export function stripCode(text) {
|
|
305
|
+
return stripCodeWith(text, 'commonmark');
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Blank out fenced and inline code under `rules`.
|
|
309
|
+
*
|
|
310
|
+
* Every character of code, fence marker lines included, becomes a space, and
|
|
311
|
+
* every `\n` stays. The output has the same number of **code points** as the
|
|
312
|
+
* input, as the Rust crate keeps the same number of `char`s. It is not the
|
|
313
|
+
* same UTF-16 length: a character outside the Basic Multilingual Plane in code
|
|
314
|
+
* becomes one space, not two.
|
|
315
|
+
*/
|
|
316
|
+
export function stripCodeWith(text, rules) {
|
|
317
|
+
const out = [];
|
|
318
|
+
const fences = new Fences(rules);
|
|
319
|
+
const spans = new Spans(rules, text);
|
|
320
|
+
let writtenUpTo = 0;
|
|
321
|
+
const blank = (start, end) => {
|
|
322
|
+
out.push(text.slice(writtenUpTo, start));
|
|
323
|
+
for (const c of text.slice(start, end))
|
|
324
|
+
out.push(c === '\n' ? '\n' : ' ');
|
|
325
|
+
writtenUpTo = end;
|
|
326
|
+
};
|
|
327
|
+
let offset = 0;
|
|
328
|
+
while (offset < text.length) {
|
|
329
|
+
if (offset === 0 || text.charCodeAt(offset - 1) === NEWLINE) {
|
|
330
|
+
const end = lineEnd(text, offset);
|
|
331
|
+
if (fences.line(text, offset, end) !== 'outside') {
|
|
332
|
+
blank(offset, end);
|
|
333
|
+
offset = end + 1;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
spans.line(offset, end);
|
|
337
|
+
}
|
|
338
|
+
if (text.charCodeAt(offset) === BACKTICK) {
|
|
339
|
+
const [run, close] = spans.close(offset);
|
|
340
|
+
if (close === null) {
|
|
341
|
+
offset += run;
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
blank(offset, close + run);
|
|
345
|
+
offset = close + run;
|
|
346
|
+
}
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
offset++;
|
|
350
|
+
}
|
|
351
|
+
out.push(text.slice(writtenUpTo));
|
|
352
|
+
return out.join('');
|
|
353
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a reader sees for a link, and typed references. A port of the
|
|
3
|
+
* `markdown-link` crate's `display_text` and `parse_typed_ref`.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Replace each wikilink and embed with what a reader sees for it, for
|
|
7
|
+
* snippets and search: its alias, or else its target. An embed's trailing
|
|
8
|
+
* `|300` or `|300x200` is a size, not an alias, so
|
|
9
|
+
* `![[img.png|300]]` shows `img.png`. An empty alias shows the target. Code
|
|
10
|
+
* is not skipped.
|
|
11
|
+
*
|
|
12
|
+
* Every delimiter is ASCII, so the scan can step UTF-16 units: a surrogate
|
|
13
|
+
* never matches one.
|
|
14
|
+
*/
|
|
15
|
+
export declare function displayText(text: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* A typed reference, `[[type/slug]]` or `[[type/slug|Alias]]`: a frontmatter
|
|
18
|
+
* value naming one note of a known type, such as `location: "[[location/home]]"`.
|
|
19
|
+
*/
|
|
20
|
+
export interface TypedRef {
|
|
21
|
+
/** The text before the first `/`, such as `location`. */
|
|
22
|
+
kind: string;
|
|
23
|
+
/** The rest of the target, such as `home` or `2026/spring`. */
|
|
24
|
+
slug: string;
|
|
25
|
+
/** The text after the first `|`, as written. */
|
|
26
|
+
alias: string | null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Parse a whole value as a typed reference, or `null`.
|
|
30
|
+
*
|
|
31
|
+
* Shape only: the target is not resolved. The value is exactly `[[...]]`
|
|
32
|
+
* with no `[`, `]` or line break inside. The target ends at the first `|`,
|
|
33
|
+
* minus a `\` that escapes it, as in `parseInner`. Its type and each
|
|
34
|
+
* `/`-separated slug segment are non-empty and hold no whitespace.
|
|
35
|
+
*/
|
|
36
|
+
export declare function parseTypedRef(value: string): TypedRef | null;
|