mdrefine 0.2.0-rc.2 → 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/CHANGELOG.md +577 -0
- package/CODE_OF_CONDUCT.md +30 -0
- package/CONTRIBUTING.md +189 -16
- package/README.md +100 -154
- package/SECURITY.md +15 -13
- package/config.schema.json +91 -0
- package/dist/cli/diff.d.ts +10 -0
- package/dist/cli/diff.js +247 -0
- package/dist/cli/diff.js.map +1 -0
- package/dist/cli/main.js +270 -150
- package/dist/cli/main.js.map +1 -1
- package/dist/config/load.js +67 -6
- package/dist/config/load.js.map +1 -1
- package/dist/config/problems.d.ts +7 -0
- package/dist/config/problems.js +208 -0
- package/dist/config/problems.js.map +1 -0
- package/dist/config/resolve.d.ts +42 -109
- package/dist/config/resolve.js +193 -52
- package/dist/config/resolve.js.map +1 -1
- package/dist/core/directives.d.ts +7 -0
- package/dist/core/directives.js +25 -13
- package/dist/core/directives.js.map +1 -1
- package/dist/core/engine.d.ts +42 -4
- package/dist/core/engine.js +518 -79
- package/dist/core/engine.js.map +1 -1
- package/dist/core/frozen.d.ts +6 -0
- package/dist/core/frozen.js +15 -0
- package/dist/core/frozen.js.map +1 -0
- package/dist/core/scope.d.ts +30 -0
- package/dist/core/scope.js +122 -0
- package/dist/core/scope.js.map +1 -0
- package/dist/core/types.d.ts +202 -23
- package/dist/core/types.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js.map +1 -1
- package/dist/node.d.ts +3 -0
- package/dist/node.js +4 -0
- package/dist/node.js.map +1 -0
- package/dist/rules/blocks.d.ts +34 -0
- package/dist/rules/blocks.js +550 -0
- package/dist/rules/blocks.js.map +1 -0
- package/dist/rules/content.d.ts +2 -0
- package/dist/rules/content.js +85 -0
- package/dist/rules/content.js.map +1 -0
- package/dist/rules/dialects.js +10 -6
- package/dist/rules/dialects.js.map +1 -1
- package/dist/rules/links.js +302 -6
- package/dist/rules/links.js.map +1 -1
- package/dist/rules/lists.d.ts +2 -0
- package/dist/rules/lists.js +924 -0
- package/dist/rules/lists.js.map +1 -0
- package/dist/rules/structure.js +127 -1
- package/dist/rules/structure.js.map +1 -1
- package/dist/rules/style.d.ts +8 -2
- package/dist/rules/style.js +1137 -165
- package/dist/rules/style.js.map +1 -1
- package/dist/syntax/block-identifier.d.ts +18 -0
- package/dist/syntax/block-identifier.js +24 -0
- package/dist/syntax/block-identifier.js.map +1 -0
- package/dist/syntax/obsidian.d.ts +29 -11
- package/dist/syntax/obsidian.js +9 -10
- package/dist/syntax/obsidian.js.map +1 -1
- package/dist/syntax/parse.d.ts +87 -3
- package/dist/syntax/parse.js +247 -19
- package/dist/syntax/parse.js.map +1 -1
- package/dist/workspace/access.d.ts +12 -3
- package/dist/workspace/access.js +33 -5
- package/dist/workspace/access.js.map +1 -1
- package/dist/workspace/discovery.d.ts +85 -0
- package/dist/workspace/discovery.js +155 -0
- package/dist/workspace/discovery.js.map +1 -0
- package/dist/workspace/encoding.d.ts +24 -0
- package/dist/workspace/encoding.js +113 -0
- package/dist/workspace/encoding.js.map +1 -0
- package/dist/workspace/files.d.ts +92 -6
- package/dist/workspace/files.js +715 -59
- package/dist/workspace/files.js.map +1 -1
- package/dist/workspace/git.d.ts +54 -0
- package/dist/workspace/git.js +198 -0
- package/dist/workspace/git.js.map +1 -0
- package/dist/workspace/index.d.ts +58 -3
- package/dist/workspace/index.js +136 -37
- package/dist/workspace/index.js.map +1 -1
- package/dist/workspace/selection.d.ts +2 -2
- package/dist/workspace/selection.js +3 -3
- package/dist/workspace/selection.js.map +1 -1
- package/dist/workspace/slug.js +8 -1
- package/dist/workspace/slug.js.map +1 -1
- package/dist/workspace/vault.js +3 -1
- package/dist/workspace/vault.js.map +1 -1
- package/docs/api.md +239 -0
- package/docs/benchmarks.md +140 -200
- package/docs/changelog.md +1 -0
- package/docs/cli.md +281 -89
- package/docs/configuration.md +193 -480
- package/docs/design.md +163 -284
- package/docs/dialects.md +169 -0
- package/docs/examples.md +151 -109
- package/docs/index.md +21 -37
- package/docs/integrations.md +144 -0
- package/docs/obsidian.md +91 -115
- package/docs/plugins.md +147 -212
- package/docs/quick-start.md +68 -74
- package/docs/rules.md +1237 -0
- package/docs/versions.md +56 -0
- package/examples/README.md +17 -14
- package/examples/automation/summarize-report.mjs +5 -2
- package/examples/library/lint-folder.mjs +5 -4
- package/examples/library/lint-workspace.mjs +32 -0
- package/examples/plugin/notes/release-checklist.md +2 -2
- package/examples/plugin/rules.mjs +17 -23
- package/examples/workspaces/obsidian-vault/after/mdtools.config.jsonc +2 -2
- package/examples/workspaces/obsidian-vault/before/mdtools.config.jsonc +2 -2
- package/examples/workspaces/repository-docs/after/README.md +2 -2
- package/examples/workspaces/repository-docs/after/mdtools.config.jsonc +0 -2
- package/examples/workspaces/repository-docs/before/README.md +2 -2
- package/examples/workspaces/repository-docs/before/docs/guide.md +7 -7
- package/examples/workspaces/repository-docs/before/docs/reference.md +3 -1
- package/examples/workspaces/repository-docs/before/mdtools.config.jsonc +0 -2
- package/package.json +38 -12
- package/docs/contributing.md +0 -218
- package/docs/releases/0.1.0-beta.1.md +0 -39
- package/docs/releases/0.1.0-beta.2.md +0 -83
- package/docs/releases/0.1.0-beta.3.md +0 -61
- package/docs/releases/0.1.0-beta.4.md +0 -50
- package/docs/releases/0.1.0-rc.1.md +0 -99
- package/docs/releases/0.2.0-rc.1.md +0 -141
- package/docs/releases/0.2.0-rc.2.md +0 -225
- package/docs/releases.md +0 -144
- package/docs/testing.md +0 -93
package/dist/rules/style.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { walk } from "../syntax/walk.js";
|
|
2
2
|
import stringWidth from "string-width";
|
|
3
|
-
import {
|
|
3
|
+
import { directive } from "../core/directives.js";
|
|
4
|
+
import { blockIdentifier } from "../syntax/block-identifier.js";
|
|
5
|
+
import { codeSpanText, labelEnd, lazyIndentation, parse, range, startsWithMarker, } from "../syntax/parse.js";
|
|
4
6
|
import { changesForgeHeading, headingAttributeBlock } from "../workspace/heading-attributes.js";
|
|
7
|
+
import { takesFinalLineEnding, takesLines, underlinedHeadings, underlineEdit } from "./blocks.js";
|
|
5
8
|
export function optionsSchema(properties) {
|
|
6
9
|
return { type: "object", properties, additionalProperties: false };
|
|
7
10
|
}
|
|
@@ -21,6 +24,54 @@ function lineBounds(source, start, end) {
|
|
|
21
24
|
lineEnd++;
|
|
22
25
|
return [lineStart, lineEnd];
|
|
23
26
|
}
|
|
27
|
+
const lineIndexes = new WeakMap();
|
|
28
|
+
function lineIndex(document) {
|
|
29
|
+
let index = lineIndexes.get(document);
|
|
30
|
+
if (!index) {
|
|
31
|
+
const breaks = [];
|
|
32
|
+
for (let at = 0; at < document.source.length; at++)
|
|
33
|
+
if (document.source[at] === "\r" || document.source[at] === "\n")
|
|
34
|
+
breaks.push(at);
|
|
35
|
+
lineIndexes.set(document, (index = { breaks, found: new Map() }));
|
|
36
|
+
}
|
|
37
|
+
return index;
|
|
38
|
+
}
|
|
39
|
+
/** The number of values in the sorted `values` that are less than `limit`. */
|
|
40
|
+
function countBelow(values, limit) {
|
|
41
|
+
let low = 0;
|
|
42
|
+
let high = values.length;
|
|
43
|
+
while (low < high) {
|
|
44
|
+
const middle = (low + high) >> 1;
|
|
45
|
+
if (values[middle] < limit)
|
|
46
|
+
low = middle + 1;
|
|
47
|
+
else
|
|
48
|
+
high = middle;
|
|
49
|
+
}
|
|
50
|
+
return low;
|
|
51
|
+
}
|
|
52
|
+
/** `lineBounds` for a document, from its line index. */
|
|
53
|
+
function indexedLineBounds(document, start, end) {
|
|
54
|
+
const { breaks } = lineIndex(document);
|
|
55
|
+
const before = countBelow(breaks, start);
|
|
56
|
+
const after = countBelow(breaks, end);
|
|
57
|
+
return [before ? breaks[before - 1] + 1 : 0, breaks[after] ?? document.source.length];
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Where `text` occurs in the document between `from` and `to`, wholly: the
|
|
61
|
+
* last occurrence, or -1 for none.
|
|
62
|
+
*/
|
|
63
|
+
function lastWithin(document, text, from, to) {
|
|
64
|
+
const { found } = lineIndex(document);
|
|
65
|
+
let positions = found.get(text);
|
|
66
|
+
if (!positions) {
|
|
67
|
+
positions = [];
|
|
68
|
+
for (let at = document.source.indexOf(text); at >= 0; at = document.source.indexOf(text, at + 1))
|
|
69
|
+
positions.push(at);
|
|
70
|
+
found.set(text, positions);
|
|
71
|
+
}
|
|
72
|
+
const last = positions[countBelow(positions, to - text.length + 1) - 1];
|
|
73
|
+
return last !== undefined && last >= from ? last : -1;
|
|
74
|
+
}
|
|
24
75
|
/**
|
|
25
76
|
* Gitea does not parse a `_` run as an emphasis delimiter when the next `_` on
|
|
26
77
|
* its physical line starts `_.py`, or when the run itself does and no `_`
|
|
@@ -28,8 +79,8 @@ function lineBounds(source, start, end) {
|
|
|
28
79
|
* else on the line, code spans included: a line containing `_.py` before or
|
|
29
80
|
* after an edit can gain or lose emphasis when markers change or lines join.
|
|
30
81
|
*/
|
|
31
|
-
export function giteaPyLine(
|
|
32
|
-
return
|
|
82
|
+
export function giteaPyLine(document, start, end) {
|
|
83
|
+
return lastWithin(document, "_.py", ...indexedLineBounds(document, start, end)) >= 0;
|
|
33
84
|
}
|
|
34
85
|
/** Forgejo and its upstream Gitea build on goldmark with shared extensions. */
|
|
35
86
|
const goldmarkForges = { forgejo: "Forgejo", gitea: "Gitea" };
|
|
@@ -38,11 +89,214 @@ const goldmarkForges = { forgejo: "Forgejo", gitea: "Gitea" };
|
|
|
38
89
|
* Gitea where enabled, render as math. GFM parses emphasis and code spans
|
|
39
90
|
* there, and changing them would change the formula.
|
|
40
91
|
*/
|
|
41
|
-
function inForgeMath(
|
|
42
|
-
const [lineStart, lineEnd] =
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
92
|
+
export function inForgeMath(document, start, end) {
|
|
93
|
+
const [lineStart, lineEnd] = indexedLineBounds(document, start, end);
|
|
94
|
+
return (lastWithin(document, "\\(", lineStart, start) > lastWithin(document, "\\)", lineStart, start) &&
|
|
95
|
+
lastWithin(document, "\\)", end, lineEnd) >= 0);
|
|
96
|
+
}
|
|
97
|
+
/** The blocks whose inline content the parser reads, and pairs delimiters in, as a whole. */
|
|
98
|
+
const inlineBlocks = new Set(["paragraph", "heading", "tableCell"]);
|
|
99
|
+
/** Whether the character at `index` is escaped by an odd number of backslashes before it. */
|
|
100
|
+
function escaped(source, index) {
|
|
101
|
+
let count = 0;
|
|
102
|
+
while (source[index - count - 1] === "\\")
|
|
103
|
+
count++;
|
|
104
|
+
return count % 2 === 1;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* How micromark classifies a character next to a delimiter run: 1 for
|
|
108
|
+
* whitespace and for the start or end of the text, 2 for punctuation and
|
|
109
|
+
* symbols, and 0 for anything else. It looks at UTF-16 code units, so half of
|
|
110
|
+
* a surrogate pair counts as anything else.
|
|
111
|
+
*/
|
|
112
|
+
function characterGroup(character) {
|
|
113
|
+
if (character === undefined || /\s/.test(character))
|
|
114
|
+
return 1;
|
|
115
|
+
return /\p{P}|\p{S}/u.test(character) ? 2 : 0;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Whether the installed micromark lets a run of `*` or `_` next to the other
|
|
119
|
+
* marker open and close whatever else surrounds it, as
|
|
120
|
+
* micromark-core-commonmark did before 2.0.4. It is read from the parse of
|
|
121
|
+
* `*_c_*a`, whose last `*` then closes emphasis.
|
|
122
|
+
*/
|
|
123
|
+
let markersLoosen;
|
|
124
|
+
function markersLoosenRuns() {
|
|
125
|
+
markersLoosen ??= parse("*_c_*a", "commonmark").tree.children.some((block) => block.type === "paragraph" && block.children[0]?.type === "emphasis");
|
|
126
|
+
return markersLoosen;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The delimiter runs at `positions`, sorted, with the characters that `at`
|
|
130
|
+
* gives. Whether a run can open or close follows micromark, which also lets a
|
|
131
|
+
* run next to a `~` in GFM do both. Container markers such as `>` count as
|
|
132
|
+
* punctuation here, while micromark sees the line ending before them; the
|
|
133
|
+
* caller compares the result with the parse.
|
|
134
|
+
*/
|
|
135
|
+
function delimiterRuns(positions, at, gfm) {
|
|
136
|
+
const runs = [];
|
|
137
|
+
for (const position of positions) {
|
|
138
|
+
const last = runs.at(-1);
|
|
139
|
+
if (last && last.end === position && at(position) === last.marker)
|
|
140
|
+
last.end++;
|
|
141
|
+
else
|
|
142
|
+
runs.push({
|
|
143
|
+
marker: at(position),
|
|
144
|
+
start: position,
|
|
145
|
+
end: position + 1,
|
|
146
|
+
open: false,
|
|
147
|
+
close: false,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const loosen = markersLoosenRuns();
|
|
151
|
+
const attention = (character) => (gfm && character === "~") || (loosen && (character === "*" || character === "_"));
|
|
152
|
+
for (const run of runs) {
|
|
153
|
+
const previous = at(run.start - 1);
|
|
154
|
+
const next = at(run.end);
|
|
155
|
+
const before = characterGroup(previous);
|
|
156
|
+
const after = characterGroup(next);
|
|
157
|
+
const open = after === 0 || (after === 2 && before !== 0) || attention(next);
|
|
158
|
+
const close = before === 0 || (before === 2 && after !== 0) || attention(previous);
|
|
159
|
+
run.open = run.marker === "*" ? open : open && (before !== 0 || !close);
|
|
160
|
+
run.close = run.marker === "*" ? close : close && (after !== 0 || !open);
|
|
161
|
+
}
|
|
162
|
+
return runs;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The emphasis and strong emphasis that micromark makes of `runs`, as
|
|
166
|
+
* `start:end:size` strings: each closing run, in order, pairs with the nearest
|
|
167
|
+
* opening run of its marker before it, except where the rule of three forbids
|
|
168
|
+
* it, and the runs between them become text.
|
|
169
|
+
*/
|
|
170
|
+
function pairings(runs) {
|
|
171
|
+
const left = runs.map((run) => ({ ...run }));
|
|
172
|
+
const pairs = [];
|
|
173
|
+
for (let index = 0; index < left.length; index++) {
|
|
174
|
+
const closer = left[index];
|
|
175
|
+
while (closer.close && closer.end > closer.start) {
|
|
176
|
+
const closeSize = closer.end - closer.start;
|
|
177
|
+
let found = index - 1;
|
|
178
|
+
for (; found >= 0; found--) {
|
|
179
|
+
const opener = left[found];
|
|
180
|
+
const openSize = opener.end - opener.start;
|
|
181
|
+
if (!opener.open || !openSize || opener.marker !== closer.marker)
|
|
182
|
+
continue;
|
|
183
|
+
if ((opener.close || closer.open) && closeSize % 3 && !((openSize + closeSize) % 3))
|
|
184
|
+
continue;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
if (found < 0)
|
|
188
|
+
break;
|
|
189
|
+
const opener = left[found];
|
|
190
|
+
const use = opener.end - opener.start > 1 && closeSize > 1 ? 2 : 1;
|
|
191
|
+
opener.end -= use;
|
|
192
|
+
closer.start += use;
|
|
193
|
+
pairs.push(`${opener.end}:${closer.start}:${use}`);
|
|
194
|
+
for (let between = found + 1; between < index; between++)
|
|
195
|
+
left[between].end = left[between].start;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return pairs.sort();
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The `*` and `_` characters that micromark reads as delimiters in the text of
|
|
202
|
+
* `scope`, a block or a link whose text it pairs delimiters in on its own: the
|
|
203
|
+
* markers of emphasis, and unescaped characters left as text. Also the
|
|
204
|
+
* emphasis there, as `start:end:size` strings, and whether changing markers
|
|
205
|
+
* could change how they pair: when a character left as text could open or
|
|
206
|
+
* close emphasis, as in `*a _b* c_`, or when one run holds the markers of two
|
|
207
|
+
* emphases, or a marker and text, as in `***a***` or `*****a***`.
|
|
208
|
+
*/
|
|
209
|
+
function scopeDelimiters(source, scope) {
|
|
210
|
+
const positions = [];
|
|
211
|
+
const markers = new Set();
|
|
212
|
+
const emphasis = [];
|
|
213
|
+
const literal = new Set();
|
|
214
|
+
const visit = (node) => {
|
|
215
|
+
if (node !== scope && (node.type === "link" || node.type === "linkReference"))
|
|
216
|
+
return;
|
|
217
|
+
if (node.type === "emphasis" || node.type === "strong") {
|
|
218
|
+
const [a, b] = range(node);
|
|
219
|
+
const size = node.type === "strong" ? 2 : 1;
|
|
220
|
+
emphasis.push(`${a}:${b}:${size}`);
|
|
221
|
+
markers.add(`${a}:${a + size}`).add(`${b - size}:${b}`);
|
|
222
|
+
for (let i = 0; i < size; i++)
|
|
223
|
+
positions.push(a + i, b - size + i);
|
|
224
|
+
}
|
|
225
|
+
else if (node.type === "text") {
|
|
226
|
+
const [a, b] = range(node);
|
|
227
|
+
for (let i = a; i < b; i++)
|
|
228
|
+
if ((source[i] === "*" || source[i] === "_") && !escaped(source, i)) {
|
|
229
|
+
positions.push(i);
|
|
230
|
+
literal.add(i);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if ("children" in node)
|
|
234
|
+
for (const child of node.children)
|
|
235
|
+
visit(child);
|
|
236
|
+
};
|
|
237
|
+
visit(scope);
|
|
238
|
+
positions.sort((a, b) => a - b);
|
|
239
|
+
const suspicious = delimiterRuns(positions, (index) => source[index], true).some((run) => {
|
|
240
|
+
if (markers.has(`${run.start}:${run.end}`))
|
|
241
|
+
return false;
|
|
242
|
+
for (let index = run.start; index < run.end; index++)
|
|
243
|
+
if (literal.has(index))
|
|
244
|
+
return run.open || run.close;
|
|
245
|
+
return true;
|
|
246
|
+
});
|
|
247
|
+
return { positions, emphasis: emphasis.sort(), suspicious };
|
|
248
|
+
}
|
|
249
|
+
/** A link label as CommonMark compares labels: whitespace collapsed and case folded. */
|
|
250
|
+
const normalizedLabel = (label) => label
|
|
251
|
+
.replace(/[\t\n\r ]+/g, " ")
|
|
252
|
+
.trim()
|
|
253
|
+
.toLowerCase()
|
|
254
|
+
.toUpperCase();
|
|
255
|
+
/** The labels of the document's definitions and footnotes that hold `*` or `_`, normalized. */
|
|
256
|
+
function markerLabels(tree) {
|
|
257
|
+
const labels = [];
|
|
258
|
+
walk(tree, (node) => {
|
|
259
|
+
if ((node.type === "definition" || node.type === "footnoteDefinition") &&
|
|
260
|
+
node.label &&
|
|
261
|
+
/[*_]/.test(node.label))
|
|
262
|
+
labels.push(normalizedLabel(node.label));
|
|
263
|
+
});
|
|
264
|
+
return labels;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* The first lines of the document's Obsidian callouts and GitHub, Forgejo, or
|
|
268
|
+
* Gitea alerts, from the marker on, which hold an Obsidian callout's title and
|
|
269
|
+
* an alert's marker on its own.
|
|
270
|
+
*/
|
|
271
|
+
function calloutTitles(document) {
|
|
272
|
+
const titles = [];
|
|
273
|
+
if (document.dialect !== "commonmark")
|
|
274
|
+
walk(document.tree, "blockquote", (quote) => {
|
|
275
|
+
const first = quote.children[0];
|
|
276
|
+
if (first?.type !== "paragraph")
|
|
277
|
+
return;
|
|
278
|
+
const [start] = range(first);
|
|
279
|
+
const end = lineBounds(document.source, start, start)[1];
|
|
280
|
+
if (startsWithMarker(document.source.slice(start, end), first))
|
|
281
|
+
titles.push([start, end]);
|
|
282
|
+
});
|
|
283
|
+
return titles;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Visit the nodes of `type` with their scope: the innermost link, whose text
|
|
287
|
+
* micromark pairs delimiters in on its own, or else the block that holds them.
|
|
288
|
+
*/
|
|
289
|
+
function eachInline(tree, type, visitor) {
|
|
290
|
+
const step = (node, scope) => {
|
|
291
|
+
if (inlineBlocks.has(node.type) || node.type === "link" || node.type === "linkReference")
|
|
292
|
+
scope = node;
|
|
293
|
+
if (node.type === type)
|
|
294
|
+
visitor(node, scope);
|
|
295
|
+
if ("children" in node)
|
|
296
|
+
for (const child of node.children)
|
|
297
|
+
step(child, scope);
|
|
298
|
+
};
|
|
299
|
+
step(tree, tree);
|
|
46
300
|
}
|
|
47
301
|
function markerRule(type, fallback) {
|
|
48
302
|
return {
|
|
@@ -52,11 +306,17 @@ function markerRule(type, fallback) {
|
|
|
52
306
|
schema: optionsSchema({ marker: { enum: ["*", "_"] } }),
|
|
53
307
|
check({ document, options }) {
|
|
54
308
|
const findings = [];
|
|
55
|
-
|
|
309
|
+
const { source } = document;
|
|
310
|
+
const gfm = document.dialect !== "commonmark";
|
|
311
|
+
const size = type === "strong" ? 2 : 1;
|
|
312
|
+
const marker = String(options.marker ?? fallback).repeat(size);
|
|
313
|
+
const labels = markerLabels(document.tree);
|
|
314
|
+
const titles = calloutTitles(document);
|
|
315
|
+
/** The ranges of the emphasis to change, by the scope that holds them. */
|
|
316
|
+
const candidates = new Map();
|
|
317
|
+
eachInline(document.tree, type, (node, scope) => {
|
|
56
318
|
const [start, end] = range(node);
|
|
57
|
-
const
|
|
58
|
-
const marker = String(options.marker ?? fallback).repeat(size);
|
|
59
|
-
const original = document.source.slice(start, end);
|
|
319
|
+
const original = source.slice(start, end);
|
|
60
320
|
if (original.startsWith(marker))
|
|
61
321
|
return;
|
|
62
322
|
// Underscores cannot delimit intraword emphasis. Preserve these valid forms.
|
|
@@ -69,7 +329,7 @@ function markerRule(type, fallback) {
|
|
|
69
329
|
const neighbours = [start - 1, end, start + size, end - size - 1].map((index) => document.source[index]);
|
|
70
330
|
if (neighbours.includes(marker[0]))
|
|
71
331
|
return;
|
|
72
|
-
if (goldmarkForges[document.dialect] && inForgeMath(document
|
|
332
|
+
if (goldmarkForges[document.dialect] && inForgeMath(document, start, end))
|
|
73
333
|
return;
|
|
74
334
|
// Forgejo, and Gitea before 1.26, build a heading's anchor from its last
|
|
75
335
|
// line as written, keeping `_` and dropping `*`, and both read a trailing
|
|
@@ -77,84 +337,420 @@ function markerRule(type, fallback) {
|
|
|
77
337
|
if (changesForgeHeading(document, start, end))
|
|
78
338
|
return;
|
|
79
339
|
if (document.dialect === "gitea" &&
|
|
80
|
-
(giteaPyLine(document
|
|
340
|
+
(giteaPyLine(document, start, end) ||
|
|
81
341
|
document.source.startsWith(".py", end) ||
|
|
82
342
|
document.source.startsWith(".py", start + size)))
|
|
83
343
|
return;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
344
|
+
// GitHub, Forgejo, and Gitea end dollar math only at dollars that no
|
|
345
|
+
// `_` follows, so a marker after a `$` can decide where math ends.
|
|
346
|
+
if (gfm &&
|
|
347
|
+
document.dialect !== "obsidian" &&
|
|
348
|
+
(source[start - 1] === "$" || source[end - size - 1] === "$"))
|
|
349
|
+
return;
|
|
350
|
+
// Emphasis that begins on the line of a callout's or alert's marker
|
|
351
|
+
// and ends below it is text on that line, whose markers would change.
|
|
352
|
+
if (titles.length &&
|
|
353
|
+
/[\r\n]/.test(original) &&
|
|
354
|
+
titles.some(([a, b]) => start >= a && start < b && end > b))
|
|
355
|
+
return;
|
|
356
|
+
// The text of a collapsed or shortcut reference, such as `[*Title*][]`
|
|
357
|
+
// or `[*Title*]`, is also its label, which would no longer match the
|
|
358
|
+
// definition's.
|
|
359
|
+
if (scope.type === "linkReference" && scope.referenceType !== "full")
|
|
360
|
+
return;
|
|
361
|
+
// Text in brackets that would match a definition with the new markers
|
|
362
|
+
// would become a reference.
|
|
363
|
+
if (labels.length) {
|
|
364
|
+
const rewritten = normalizedLabel(marker + original.slice(size, -size) + marker);
|
|
365
|
+
if (labels.some((label) => label.includes(rewritten)))
|
|
366
|
+
return;
|
|
94
367
|
}
|
|
368
|
+
// micromark lets a run next to a `~` open and close, so a new marker
|
|
369
|
+
// that a `~` separates from another `*` or `_` like it could pair with it.
|
|
370
|
+
const reaches = (index, step) => {
|
|
371
|
+
while (source[index] === "~")
|
|
372
|
+
index += step;
|
|
373
|
+
return source[index] === marker[0];
|
|
374
|
+
};
|
|
375
|
+
if (gfm &&
|
|
376
|
+
(reaches(start - 1, -1) ||
|
|
377
|
+
reaches(end, 1) ||
|
|
378
|
+
reaches(start + size, 1) ||
|
|
379
|
+
reaches(end - size - 1, -1)))
|
|
380
|
+
return;
|
|
381
|
+
const list = candidates.get(scope) ?? [];
|
|
382
|
+
list.push([start, end]);
|
|
383
|
+
candidates.set(scope, list);
|
|
95
384
|
});
|
|
96
|
-
|
|
385
|
+
/** The positions of the markers of the emphasis in `ranges`. */
|
|
386
|
+
const markers = (ranges) => {
|
|
387
|
+
const set = new Set();
|
|
388
|
+
for (const [start, end] of ranges)
|
|
389
|
+
for (let i = 0; i < size; i++)
|
|
390
|
+
set.add(start + i).add(end - size + i);
|
|
391
|
+
return set;
|
|
392
|
+
};
|
|
393
|
+
for (const [scope, list] of candidates) {
|
|
394
|
+
let accepted = list;
|
|
395
|
+
// The parser's pairing of delimiter runs is modelled, and the markers
|
|
396
|
+
// change where the model, which has to reproduce the parse, pairs the
|
|
397
|
+
// same runs with the new markers: all of them, or else those that can
|
|
398
|
+
// be added one at a time. Where the model cannot be used, markers
|
|
399
|
+
// change only when no run could pair differently, which it could when
|
|
400
|
+
// a `*` or `_` in the same text did not pair, or when two emphases
|
|
401
|
+
// share a run of markers, as in `***a***` or `*a****c***`.
|
|
402
|
+
const { positions, emphasis, suspicious } = scopeDelimiters(source, scope);
|
|
403
|
+
const expected = emphasis.join(" ");
|
|
404
|
+
const pairing = (changed) => pairings(delimiterRuns(positions, (at) => (changed.has(at) ? marker[0] : source[at]), gfm)).join(" ");
|
|
405
|
+
// micromark pairs `~` in a pass of its own, which can come first, and
|
|
406
|
+
// lets a run next to a `~` pair with one of the new marker, as in `_f~*a*e_`.
|
|
407
|
+
const tilde = gfm && source.slice(...range(scope)).includes("~");
|
|
408
|
+
const modelled = !tilde && positions.length <= 500 && pairing(new Set()) === expected;
|
|
409
|
+
if (!modelled)
|
|
410
|
+
accepted =
|
|
411
|
+
suspicious || (tilde && positions.some((at) => source[at] === marker[0])) ? [] : list;
|
|
412
|
+
else if (pairing(markers(list)) !== expected) {
|
|
413
|
+
accepted = [];
|
|
414
|
+
let changed = new Set();
|
|
415
|
+
for (const item of list) {
|
|
416
|
+
const trial = new Set([...changed, ...markers([item])]);
|
|
417
|
+
if (pairing(trial) !== expected)
|
|
418
|
+
continue;
|
|
419
|
+
changed = trial;
|
|
420
|
+
accepted.push(item);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
for (const [start, end] of accepted)
|
|
424
|
+
for (const [a, b] of [
|
|
425
|
+
[start, start + size],
|
|
426
|
+
[end - size, end],
|
|
427
|
+
]) {
|
|
428
|
+
findings.push({
|
|
429
|
+
start: a,
|
|
430
|
+
end: b,
|
|
431
|
+
message: `Use ${JSON.stringify(marker)} for ${type}.`,
|
|
432
|
+
edit: { start: a, end: b, text: marker },
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return findings.sort((a, b) => a.start - b.start);
|
|
97
437
|
},
|
|
98
438
|
};
|
|
99
439
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
440
|
+
/**
|
|
441
|
+
* Join words into the atoms that reflow puts on lines. Whitespace-only words
|
|
442
|
+
* separate atoms; whitespace inside a word, such as in a code span, does not.
|
|
443
|
+
*/
|
|
444
|
+
function wrappingAtoms(words, openers, dollars) {
|
|
445
|
+
const separated = [];
|
|
103
446
|
let current = "";
|
|
104
447
|
for (const word of words) {
|
|
105
448
|
if (/^[ \t]+$/.test(word)) {
|
|
106
449
|
if (current)
|
|
107
|
-
|
|
450
|
+
separated.push(current);
|
|
108
451
|
current = "";
|
|
109
452
|
}
|
|
110
453
|
else
|
|
111
454
|
current += word;
|
|
112
455
|
}
|
|
113
456
|
if (current)
|
|
114
|
-
|
|
457
|
+
separated.push(current);
|
|
115
458
|
// Keep atoms that would open a block or underline a Setext heading at the
|
|
116
|
-
// start of a line attached to the preceding atom
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
459
|
+
// start of a line attached to the preceding atom, and so a dollar that
|
|
460
|
+
// could end math there.
|
|
461
|
+
const joined = [];
|
|
462
|
+
let dollarBefore = false;
|
|
463
|
+
for (const atom of separated) {
|
|
464
|
+
if (joined.length &&
|
|
465
|
+
(openers.some((opener) => opener.test(atom)) ||
|
|
466
|
+
(dollars?.start && dollarBefore && atom.startsWith("$"))))
|
|
467
|
+
joined[joined.length - 1] += ` ${atom}`;
|
|
468
|
+
else
|
|
469
|
+
joined.push(atom);
|
|
470
|
+
if (atom.includes("$"))
|
|
471
|
+
dollarBefore = true;
|
|
122
472
|
}
|
|
123
473
|
// An atom ending in an unescaped backslash would become a hard break at the
|
|
124
|
-
// end of a line; keep it attached to the following atom
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
474
|
+
// end of a line; keep it attached to the following atom, and so one ending
|
|
475
|
+
// in a dollar that could begin math there.
|
|
476
|
+
let lastDollar = -1;
|
|
477
|
+
joined.forEach((atom, index) => {
|
|
478
|
+
if (atom.includes("$"))
|
|
479
|
+
lastDollar = index;
|
|
480
|
+
});
|
|
481
|
+
const atoms = [];
|
|
482
|
+
for (let i = 0; i < joined.length; i++) {
|
|
483
|
+
let atom = joined[i];
|
|
484
|
+
while (i < joined.length - 1 &&
|
|
485
|
+
(/(?:^|[^\\])(?:\\\\)*\\$/.test(atom) || (i < lastDollar && dollars?.end?.(atom))))
|
|
486
|
+
atom += ` ${joined[++i]}`;
|
|
487
|
+
atoms.push(atom);
|
|
130
488
|
}
|
|
131
489
|
return atoms;
|
|
132
490
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
491
|
+
/**
|
|
492
|
+
* Words that open a block or underline a heading at the start of a line, the
|
|
493
|
+
* way CommonMark does with them: as a whole word only. These are list markers,
|
|
494
|
+
* ATX heading markers, and runs of the character of a thematic break or a
|
|
495
|
+
* Setext underline, which a line of such words alone forms. A word that only
|
|
496
|
+
* starts with those characters, such as `--config`, `*delta*`, `#tag`, or
|
|
497
|
+
* `-5`, opens nothing.
|
|
498
|
+
*/
|
|
499
|
+
const wordOpener = /^(?:[-+*]|\d{1,9}[.)]|#{1,6}|\*+|-+|_+|=+)$/;
|
|
500
|
+
/**
|
|
501
|
+
* The words of a GFM table's delimiter row, which a line of them alone forms
|
|
502
|
+
* below a line that then becomes the table's header row.
|
|
503
|
+
*/
|
|
504
|
+
const delimiterRowWord = /^[|:-]*[|-][|:-]*$/;
|
|
505
|
+
/**
|
|
506
|
+
* Word starts that open a block whatever follows them on the line: block
|
|
507
|
+
* quotes, fences, math, Obsidian comments, HTML blocks, and footnote
|
|
508
|
+
* definitions.
|
|
509
|
+
*/
|
|
510
|
+
const prefixOpener = /^(?:>|~{3,}|`{3,}|\$\$|%%|<[!?/A-Za-z]|\[\^[^\]]+\]:)/;
|
|
511
|
+
/** A word that ends in an unescaped dollar, which can begin dollar math at the end of a line. */
|
|
512
|
+
const dollarEnd = /(?:^|[^\\])(?:\\\\)*\$$/;
|
|
513
|
+
/**
|
|
514
|
+
* A paragraph's first word that would open a thematic break or TOML front
|
|
515
|
+
* matter alone on the first line. The paragraph's first line holds more than
|
|
516
|
+
* this word, so it opened neither there.
|
|
517
|
+
*/
|
|
518
|
+
const firstWordAlone = /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,}|\+\+\+)$/;
|
|
519
|
+
/**
|
|
520
|
+
* The start of a fence or of display math, which the first line of a
|
|
521
|
+
* paragraph can start with when what follows on the line, such as a backtick
|
|
522
|
+
* after a backtick fence, keeps it from opening one.
|
|
523
|
+
*/
|
|
524
|
+
const fenceStart = /^(?:`{3,}|~{3,}|\$\$)/;
|
|
525
|
+
/** A link reference definition's label and colon, which a paragraph can start with. */
|
|
526
|
+
const definitionStart = /^\[(?:[^[\]\\]|\\[^])+\]:/;
|
|
527
|
+
/** A GFM table's delimiter row, such as `| --- | :-: |`, after any container markers. */
|
|
528
|
+
const delimiterRow = /^[ \t>]*\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$/;
|
|
529
|
+
/**
|
|
530
|
+
* Whether text that the parser read as a paragraph or a heading's text has a
|
|
531
|
+
* table delimiter row as its second line. GitHub reads a table there, and so
|
|
532
|
+
* does the parser, except directly below a table that ends in a list item or
|
|
533
|
+
* block quote, where it reads the lines as text (issue #283). Joining the lines
|
|
534
|
+
* or adding a blank line above them would change what GitHub or the parser
|
|
535
|
+
* reads.
|
|
536
|
+
*/
|
|
537
|
+
function misreadTable(text) {
|
|
538
|
+
const second = text.split(lineBreak, 2)[1];
|
|
539
|
+
return second !== undefined && second.includes("|") && delimiterRow.test(second);
|
|
540
|
+
}
|
|
136
541
|
// Forgejo and Gitea additionally open display math with `\[` and definition
|
|
137
542
|
// descriptions with `:` followed by at least one space or tab, so only a bare
|
|
138
543
|
// `:` atom could open one at the start of a reflowed line.
|
|
139
544
|
const forgeLineOpener = /^(?::$|\\\[)/;
|
|
140
|
-
/**
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
545
|
+
/**
|
|
546
|
+
* Site-generator syntax at the start of a line: a container such as
|
|
547
|
+
* Docusaurus's `:::note`, or a template tag such as Liquid's `{% tab %}` or
|
|
548
|
+
* `{{ title }}`. Site generators read it by line, and no dialect gives it
|
|
549
|
+
* meaning, so a paragraph with a line that starts with it keeps its lines, and
|
|
550
|
+
* a word that starts with it stays on the line before it (issue #296).
|
|
551
|
+
*/
|
|
552
|
+
const siteSyntax = /^(?::::|\{%|\{\{)/;
|
|
553
|
+
/**
|
|
554
|
+
* The template tags in `text`, which starts at offset `base`, that each lie on
|
|
555
|
+
* one line, such as Liquid's `{% data x %}` or `{{ page.title }}`: wrapping
|
|
556
|
+
* keeps them whole.
|
|
557
|
+
*/
|
|
558
|
+
function templateTags(text, base) {
|
|
559
|
+
const ranges = [];
|
|
560
|
+
if (!text.includes("{%") && !text.includes("{{"))
|
|
561
|
+
return ranges;
|
|
562
|
+
let at = base;
|
|
563
|
+
for (const line of text.split(/(?<=\n|\r(?!\n))/)) {
|
|
564
|
+
for (const [open, close] of [
|
|
565
|
+
["{%", "%}"],
|
|
566
|
+
["{{", "}}"],
|
|
567
|
+
])
|
|
568
|
+
for (let from = 0;;) {
|
|
569
|
+
const opener = line.indexOf(open, from);
|
|
570
|
+
const closer = opener < 0 ? -1 : line.indexOf(close, opener + open.length);
|
|
571
|
+
if (closer < 0)
|
|
572
|
+
break;
|
|
573
|
+
ranges.push([at + opener, at + closer + close.length]);
|
|
574
|
+
from = closer + close.length;
|
|
575
|
+
}
|
|
576
|
+
at += line.length;
|
|
577
|
+
}
|
|
578
|
+
return ranges;
|
|
579
|
+
}
|
|
580
|
+
/** Whether a line of a paragraph, after its container markers, starts with `siteSyntax`. */
|
|
581
|
+
function startsSiteSyntax(text) {
|
|
582
|
+
return text
|
|
583
|
+
.split(lineBreak)
|
|
584
|
+
.some((line, index) => siteSyntax.test(index ? line.replace(/^[ \t>]+/, "") : line));
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* A list, heading, or block quote marker as the whole first word of a
|
|
588
|
+
* paragraph, with more words after it. Directly below text, the parser reads
|
|
589
|
+
* a marker that would open an empty list item there, such as the second `-` of
|
|
590
|
+
* `- -`, as text, and the paragraph can continue on the next line. Joining the
|
|
591
|
+
* next word to the marker would open the block. On Forgejo and Gitea, a bare
|
|
592
|
+
* `:` joined to the next word would turn a paragraph above it into the term of
|
|
593
|
+
* a definition list.
|
|
594
|
+
*/
|
|
595
|
+
const leadingMarker = /^(?:[-+*]|\d{1,9}[.)]|#{1,6}|>)[ \t\r\n]/;
|
|
596
|
+
/**
|
|
597
|
+
* The positions of a paragraph's source where whitespace is not a wrapping
|
|
598
|
+
* opportunity: those strictly inside a protected source range.
|
|
599
|
+
*/
|
|
600
|
+
class Protection {
|
|
601
|
+
start;
|
|
602
|
+
mask;
|
|
603
|
+
constructor(start, end) {
|
|
604
|
+
this.start = start;
|
|
605
|
+
this.mask = new Uint8Array(Math.max(0, end - start));
|
|
606
|
+
}
|
|
607
|
+
add(from, to) {
|
|
608
|
+
this.mask.fill(1, Math.max(0, from + 1 - this.start), Math.max(0, to - this.start));
|
|
609
|
+
}
|
|
610
|
+
has(at) {
|
|
611
|
+
return this.mask[at - this.start] === 1;
|
|
612
|
+
}
|
|
613
|
+
/** Split text that starts at offset `base` at whitespace outside protected ranges. */
|
|
614
|
+
split(text, base) {
|
|
615
|
+
const words = [];
|
|
616
|
+
let cursor = 0;
|
|
617
|
+
for (const match of text.matchAll(/[ \t]+/g)) {
|
|
618
|
+
if (this.has(base + match.index))
|
|
619
|
+
continue;
|
|
620
|
+
if (match.index > cursor)
|
|
621
|
+
words.push(text.slice(cursor, match.index));
|
|
622
|
+
words.push(match[0]);
|
|
623
|
+
cursor = match.index + match[0].length;
|
|
624
|
+
}
|
|
625
|
+
if (cursor < text.length)
|
|
626
|
+
words.push(text.slice(cursor));
|
|
627
|
+
return words;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Inline containers whose text reflow wraps like the paragraph's own: a line
|
|
632
|
+
* ending in them renders as a space, like one between words. Their delimiters,
|
|
633
|
+
* and a link's destination, title, or reference label, stay attached to the
|
|
634
|
+
* neighboring words.
|
|
635
|
+
*/
|
|
636
|
+
const wrappable = new Set(["emphasis", "strong", "delete", "link", "linkReference"]);
|
|
637
|
+
const lineBreakIn = {
|
|
638
|
+
html: "inline HTML",
|
|
639
|
+
inlineMath: "math",
|
|
640
|
+
obsidianComment: "an Obsidian comment",
|
|
641
|
+
};
|
|
642
|
+
function located(node) {
|
|
643
|
+
return node.position?.start.offset !== undefined && node.position.end.offset !== undefined;
|
|
644
|
+
}
|
|
645
|
+
function holds(node, type) {
|
|
646
|
+
let found = false;
|
|
647
|
+
walk(node, (child) => {
|
|
648
|
+
if (child.type === type)
|
|
649
|
+
found = true;
|
|
650
|
+
});
|
|
651
|
+
return found;
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* The inline nodes of a paragraph, from `start` on, that reflow keeps whole,
|
|
655
|
+
* sorted by position, and the hard line breaks anywhere in it. A container
|
|
656
|
+
* that holds inline HTML is kept whole too, because HTML can make the
|
|
657
|
+
* whitespace inside it significant. With `wholeLinks`, the text of a link is
|
|
658
|
+
* kept whole as an image's description is: Obsidian's Live Preview shows a
|
|
659
|
+
* link whose text spans lines as Markdown source (issue #306).
|
|
660
|
+
*/
|
|
661
|
+
function inlineAtoms(paragraph, start, wholeLinks) {
|
|
662
|
+
const atoms = [];
|
|
663
|
+
const breaks = [];
|
|
664
|
+
let unlocated = false;
|
|
665
|
+
const destination = { reason: "line break in a link destination or title" };
|
|
666
|
+
const visit = (parent) => {
|
|
667
|
+
for (const child of parent.children) {
|
|
668
|
+
if (child.type === "text")
|
|
669
|
+
continue;
|
|
670
|
+
if (!located(child)) {
|
|
671
|
+
unlocated = true;
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
const [a, b] = range(child);
|
|
675
|
+
if (b <= start)
|
|
676
|
+
continue;
|
|
677
|
+
if (child.type === "break")
|
|
678
|
+
breaks.push([a, b]);
|
|
679
|
+
else if (wholeLinks &&
|
|
680
|
+
(child.type === "link" || child.type === "linkReference") &&
|
|
681
|
+
child.children.length &&
|
|
682
|
+
labelEnd(child) &&
|
|
683
|
+
!holds(child, "html")) {
|
|
684
|
+
// The code spans, images, and other atoms in the text keep their own
|
|
685
|
+
// joining; between them, a line ending joins with the space that it
|
|
686
|
+
// renders as, as in an image's description.
|
|
687
|
+
const close = labelEnd(child);
|
|
688
|
+
const inner = atoms.length;
|
|
689
|
+
visit(child);
|
|
690
|
+
const within = atoms.splice(inner).sort((x, y) => x.from - y.from);
|
|
691
|
+
atoms.push({ from: a, to: close, joining: { within } });
|
|
692
|
+
atoms.push({ from: close, to: b, joining: destination });
|
|
693
|
+
}
|
|
694
|
+
else if (wrappable.has(child.type) &&
|
|
695
|
+
"children" in child &&
|
|
696
|
+
child.children.length &&
|
|
697
|
+
!holds(child, "html")) {
|
|
698
|
+
if (child.type === "link" || child.type === "linkReference") {
|
|
699
|
+
const close = labelEnd(child) ?? range(child.children.at(-1))[1];
|
|
700
|
+
atoms.push({ from: close, to: b, joining: destination });
|
|
701
|
+
}
|
|
702
|
+
visit(child);
|
|
703
|
+
}
|
|
704
|
+
else if (child.type === "inlineCode")
|
|
705
|
+
atoms.push({ from: a, to: b, joining: "code" });
|
|
706
|
+
else if ((child.type === "image" || child.type === "imageReference") && labelEnd(child)) {
|
|
707
|
+
const close = labelEnd(child);
|
|
708
|
+
atoms.push({ from: a, to: close, joining: "description" });
|
|
709
|
+
atoms.push({ from: close, to: b, joining: destination });
|
|
710
|
+
}
|
|
711
|
+
else {
|
|
712
|
+
const name = lineBreakIn[child.type] ?? (holds(child, "html") ? "inline HTML" : undefined);
|
|
713
|
+
const reason = `line break in ${name ?? "inline syntax"}`;
|
|
714
|
+
atoms.push({ from: a, to: b, joining: { reason } });
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
visit(paragraph);
|
|
719
|
+
atoms.sort((x, y) => x.from - y.from);
|
|
720
|
+
return { atoms, breaks, unlocated };
|
|
721
|
+
}
|
|
722
|
+
/** The atom whose source holds `at`, among atoms sorted by position. */
|
|
723
|
+
function atomAt(atoms, at) {
|
|
724
|
+
let low = 0;
|
|
725
|
+
let high = atoms.length - 1;
|
|
726
|
+
while (low <= high) {
|
|
727
|
+
const middle = (low + high) >> 1;
|
|
728
|
+
const atom = atoms[middle];
|
|
729
|
+
if (at <= atom.from)
|
|
730
|
+
high = middle - 1;
|
|
731
|
+
else if (at >= atom.to)
|
|
732
|
+
low = middle + 1;
|
|
733
|
+
else
|
|
734
|
+
return atom;
|
|
154
735
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
736
|
+
return undefined;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* The width of a line or line prefix, with a tab advancing to the next
|
|
740
|
+
* multiple of four columns, the tab stop that CommonMark uses for indentation.
|
|
741
|
+
*/
|
|
742
|
+
function lineWidth(text, measure) {
|
|
743
|
+
if (!text.includes("\t"))
|
|
744
|
+
return measure(text);
|
|
745
|
+
let width = 0;
|
|
746
|
+
for (const part of text.split(/(\t)/))
|
|
747
|
+
width += part === "\t" ? 4 - (width % 4) : measure(part);
|
|
748
|
+
return width;
|
|
749
|
+
}
|
|
750
|
+
/** Whether text parses as a single paragraph. */
|
|
751
|
+
function oneParagraph(text, dialect) {
|
|
752
|
+
const { children } = parse(text, dialect).tree;
|
|
753
|
+
return children.length === 1 && children[0].type === "paragraph";
|
|
158
754
|
}
|
|
159
755
|
/**
|
|
160
756
|
* Syntax that Forgejo and Gitea render but GFM parses as prose. A line starting
|
|
@@ -217,6 +813,38 @@ function giteaPyParagraph(source, node, start, end) {
|
|
|
217
813
|
}
|
|
218
814
|
return false;
|
|
219
815
|
}
|
|
816
|
+
/** Inline nodes whose source joined lines cannot change. */
|
|
817
|
+
const obsidianOpaque = new Set(["inlineCode", "inlineMath", "wikiLink", "obsidianHighlight"]);
|
|
818
|
+
/**
|
|
819
|
+
* Whether joining an Obsidian paragraph's lines could create a highlight or a
|
|
820
|
+
* wikilink. The parser reads neither across a line break, so `==` and `[[` or
|
|
821
|
+
* `]]` that it left as text, outside code, math, and the highlights and links
|
|
822
|
+
* that it did read, could pair up once two lines become one.
|
|
823
|
+
*/
|
|
824
|
+
function obsidianMarkers(source, node, start, end) {
|
|
825
|
+
if (!/==|\[\[|\]\]/.test(source.slice(start, end)))
|
|
826
|
+
return false;
|
|
827
|
+
const opaque = [];
|
|
828
|
+
walk(node, (child) => {
|
|
829
|
+
if (obsidianOpaque.has(child.type))
|
|
830
|
+
opaque.push(range(child));
|
|
831
|
+
});
|
|
832
|
+
let text = "";
|
|
833
|
+
let cursor = start;
|
|
834
|
+
for (const [a, b] of opaque.sort((x, y) => x[0] - y[0])) {
|
|
835
|
+
if (b <= cursor)
|
|
836
|
+
continue;
|
|
837
|
+
// A space keeps the text on both sides from forming a marker.
|
|
838
|
+
text += source.slice(cursor, Math.max(cursor, a)) + " ";
|
|
839
|
+
cursor = Math.min(Math.max(cursor, b), end);
|
|
840
|
+
}
|
|
841
|
+
text += source.slice(cursor, end);
|
|
842
|
+
return /==|\[\[|\]\]/.test(text);
|
|
843
|
+
}
|
|
844
|
+
/** A line's container prefix, with list item markers and checkboxes made blank. */
|
|
845
|
+
function continuationOf(prefix) {
|
|
846
|
+
return prefix.replace(/(?:[-+*]|\d+[.)]|\[[xX ]\])(?=\s)/g, (value) => " ".repeat(value.length));
|
|
847
|
+
}
|
|
220
848
|
const wrap = {
|
|
221
849
|
description: "Reflow paragraphs using configurable width and protected inline atoms.",
|
|
222
850
|
kind: "style",
|
|
@@ -248,15 +876,35 @@ const wrap = {
|
|
|
248
876
|
start++;
|
|
249
877
|
}
|
|
250
878
|
let original = document.source.slice(start, end);
|
|
251
|
-
|
|
879
|
+
const forge = goldmarkForges[document.dialect];
|
|
880
|
+
// An Obsidian callout or a GitHub, Forgejo, or Gitea alert keeps the line
|
|
881
|
+
// with its marker, and its title in Obsidian, as written. On Forgejo and
|
|
882
|
+
// Gitea, a marker written as `\[!NOTE]` starts like display math, and
|
|
883
|
+
// its paragraph stays as written.
|
|
884
|
+
let calloutHeader = document.dialect !== "commonmark" &&
|
|
252
885
|
parent?.type === "blockquote" &&
|
|
253
886
|
index === 0 &&
|
|
254
|
-
|
|
887
|
+
startsWithMarker(original, node) &&
|
|
888
|
+
!(forge && original.startsWith("\\["));
|
|
889
|
+
/** Whether the lines to wrap are the body below that line, in the same paragraph. */
|
|
890
|
+
let belowHeader = false;
|
|
255
891
|
let lineStart = start;
|
|
256
892
|
while (lineStart > 0 && !/[\r\n]/.test(document.source[lineStart - 1]))
|
|
257
893
|
lineStart--;
|
|
258
894
|
let prefix = document.source.slice(lineStart, start);
|
|
259
|
-
const continuation = prefix
|
|
895
|
+
const continuation = continuationOf(prefix);
|
|
896
|
+
// Directly below a link reference definition, a paragraph can start with
|
|
897
|
+
// a lazy continuation line, which lacks some of the container markers of
|
|
898
|
+
// the lines around it. The later lines' prefixes cannot be told from it.
|
|
899
|
+
const above = index ? parent?.children[index - 1] : undefined;
|
|
900
|
+
let lazyStart = false;
|
|
901
|
+
if (above?.type === "definition" &&
|
|
902
|
+
above.position.end.line === node.position.start.line - 1) {
|
|
903
|
+
const [definitionStart] = range(above);
|
|
904
|
+
const definitionLine = lineBounds(document.source, definitionStart, definitionStart)[0];
|
|
905
|
+
lazyStart =
|
|
906
|
+
continuationOf(document.source.slice(definitionLine, definitionStart)) !== continuation;
|
|
907
|
+
}
|
|
260
908
|
if (calloutHeader) {
|
|
261
909
|
const headerBreak = lineBreak.exec(original);
|
|
262
910
|
const headerEnd = headerBreak ? start + headerBreak.index : end;
|
|
@@ -272,7 +920,7 @@ const wrap = {
|
|
|
272
920
|
});
|
|
273
921
|
// Keep unusual continuations and inline syntax spanning the boundary untouched.
|
|
274
922
|
if (supported) {
|
|
275
|
-
if (options.reportUnbreakable === true &&
|
|
923
|
+
if (options.reportUnbreakable === true && lineWidth(prefix + header, measure) > width)
|
|
276
924
|
findings.push({
|
|
277
925
|
start,
|
|
278
926
|
end: start + header.length,
|
|
@@ -285,11 +933,48 @@ const wrap = {
|
|
|
285
933
|
children = children.filter((child) => range(child)[1] > start);
|
|
286
934
|
prefix = continuation;
|
|
287
935
|
calloutHeader = false;
|
|
936
|
+
belowHeader = true;
|
|
288
937
|
}
|
|
289
938
|
}
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
939
|
+
const openers = [wordOpener, prefixOpener, siteSyntax];
|
|
940
|
+
if (document.dialect !== "commonmark")
|
|
941
|
+
openers.push(delimiterRowWord);
|
|
942
|
+
if (forge)
|
|
943
|
+
openers.push(forgeLineOpener);
|
|
944
|
+
const { source } = document;
|
|
945
|
+
// GitHub and Obsidian end dollar math at a single dollar after a line
|
|
946
|
+
// ending, but not after a space, so a word that starts with `$` stays on
|
|
947
|
+
// the line of the word before it when a dollar comes before it. Obsidian
|
|
948
|
+
// also begins math at a single dollar before a line ending, but not
|
|
949
|
+
// before a space, so a word that ends in a dollar of its text, such as a
|
|
950
|
+
// lone `$`, stays on the line of the word after it when a dollar comes
|
|
951
|
+
// after it. Forgejo and Gitea have neither condition.
|
|
952
|
+
const maths = [];
|
|
953
|
+
if (document.dialect === "obsidian")
|
|
954
|
+
walk(node, "inlineMath", (math) => {
|
|
955
|
+
maths.push(source.slice(...range(math)));
|
|
956
|
+
});
|
|
957
|
+
const dollars = {
|
|
958
|
+
start: document.dialect === "github" || document.dialect === "obsidian",
|
|
959
|
+
...(document.dialect === "obsidian"
|
|
960
|
+
? {
|
|
961
|
+
end: (atom) => dollarEnd.test(atom) && !maths.some((math) => atom.endsWith(math)),
|
|
962
|
+
}
|
|
963
|
+
: {}),
|
|
964
|
+
};
|
|
965
|
+
const inline = inlineAtoms(node, start, document.dialect === "obsidian");
|
|
966
|
+
const protection = new Protection(start, end);
|
|
967
|
+
for (const atom of inline.atoms)
|
|
968
|
+
protection.add(atom.from, atom.to);
|
|
969
|
+
// Forgejo and Gitea protections apply before paragraphs are skipped, so
|
|
970
|
+
// that diagnostics still count one-line math and shortlinks as atoms.
|
|
971
|
+
const protections = forge
|
|
972
|
+
? forgeProtections(source, start, end, forge)
|
|
973
|
+
: { ranges: [] };
|
|
974
|
+
for (const [from, to] of protections.ranges)
|
|
975
|
+
protection.add(from, to);
|
|
976
|
+
for (const [from, to] of templateTags(source.slice(start, end), start))
|
|
977
|
+
protection.add(from, to);
|
|
293
978
|
const reportUnbreakable = () => findings.push({
|
|
294
979
|
start,
|
|
295
980
|
end,
|
|
@@ -301,7 +986,6 @@ const wrap = {
|
|
|
301
986
|
let breakable = false;
|
|
302
987
|
let unbreakable = false;
|
|
303
988
|
let offset = start;
|
|
304
|
-
let childIndex = 0;
|
|
305
989
|
for (const line of original.split(/(?<=\n|\r(?!\n))/)) {
|
|
306
990
|
const content = line.replace(/(?:\r\n|\r|\n)$/, "");
|
|
307
991
|
const container = offset === start
|
|
@@ -312,29 +996,20 @@ const wrap = {
|
|
|
312
996
|
const linePrefix = offset === start ? prefix : container;
|
|
313
997
|
// An Obsidian title occupies one physical line, even when it contains spaces.
|
|
314
998
|
if (calloutHeader && offset === start) {
|
|
315
|
-
if (
|
|
999
|
+
if (lineWidth(prefix + content, measure) > width)
|
|
316
1000
|
unbreakable = true;
|
|
317
1001
|
offset += line.length;
|
|
318
1002
|
continue;
|
|
319
1003
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
continue;
|
|
330
|
-
const from = Math.max(a, offset + container.length);
|
|
331
|
-
const raw = document.source.slice(from, Math.min(b, offset + content.length));
|
|
332
|
-
if (!raw)
|
|
333
|
-
continue;
|
|
334
|
-
words.push(...(child.type === "text" ? splitWords(raw, from, protectedRanges) : [raw]));
|
|
335
|
-
}
|
|
336
|
-
const atoms = wrappingAtoms(words, extraOpener);
|
|
337
|
-
const available = width - measure(linePrefix);
|
|
1004
|
+
// Hard-break markers do not count toward the width.
|
|
1005
|
+
let contentEnd = offset + content.length;
|
|
1006
|
+
for (const [a] of inline.breaks)
|
|
1007
|
+
if (a >= offset && a < contentEnd)
|
|
1008
|
+
contentEnd = a;
|
|
1009
|
+
const from = offset + container.length;
|
|
1010
|
+
const words = protection.split(source.slice(from, contentEnd), from);
|
|
1011
|
+
const atoms = wrappingAtoms(words, openers, dollars);
|
|
1012
|
+
const available = width - lineWidth(linePrefix, measure);
|
|
338
1013
|
if (measure(words.join("").trimEnd()) > available) {
|
|
339
1014
|
if (atoms.length > 1)
|
|
340
1015
|
breakable = true;
|
|
@@ -352,87 +1027,143 @@ const wrap = {
|
|
|
352
1027
|
if (options.reportUnbreakable === true && unbreakable)
|
|
353
1028
|
reportUnbreakable();
|
|
354
1029
|
};
|
|
355
|
-
|
|
1030
|
+
// Only the first paragraph of a block quote can be a callout, so a
|
|
1031
|
+
// paragraph elsewhere that starts with a linked image, `[![`, is wrapped.
|
|
1032
|
+
// Below the line of a marker, the paragraph's text no longer starts the
|
|
1033
|
+
// lines to wrap.
|
|
1034
|
+
const markerLike = /^\[![^\]]+\]/;
|
|
1035
|
+
const startsLikeMarker = parent?.type === "blockquote" &&
|
|
1036
|
+
index === 0 &&
|
|
1037
|
+
(belowHeader ? markerLike.test(original) : startsWithMarker(original, node, markerLike));
|
|
1038
|
+
const reason = inline.breaks.length
|
|
356
1039
|
? "hard line break"
|
|
357
1040
|
: children.some((child) => child.type === "html")
|
|
358
1041
|
? "inline HTML"
|
|
359
|
-
:
|
|
1042
|
+
: startsLikeMarker
|
|
360
1043
|
? "callout header"
|
|
361
|
-
:
|
|
1044
|
+
: blockIdentifier(document.source, node)
|
|
362
1045
|
? "block identifier"
|
|
363
|
-
:
|
|
364
|
-
? "
|
|
365
|
-
:
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
1046
|
+
: leadingMarker.test(original) || (forge && /^:(?:\r|\n)/.test(original))
|
|
1047
|
+
? "leading block marker"
|
|
1048
|
+
: document.dialect !== "commonmark" && misreadTable(original)
|
|
1049
|
+
? "table delimiter row as its second line"
|
|
1050
|
+
: startsSiteSyntax(original)
|
|
1051
|
+
? "site-generator syntax at the start of a line"
|
|
1052
|
+
: lazyStart
|
|
1053
|
+
? "lazy first line"
|
|
1054
|
+
: !/^[\s>\-*+\d.[\]xX)]*$/.test(prefix)
|
|
1055
|
+
? "unsupported container prefix"
|
|
1056
|
+
: document.dialect === "obsidian" &&
|
|
1057
|
+
obsidianMarkers(source, node, start, end)
|
|
1058
|
+
? "Obsidian `==`, `[[`, or `]]` outside highlights and links"
|
|
1059
|
+
: inline.unlocated
|
|
1060
|
+
? "inline syntax without a source position"
|
|
1061
|
+
: undefined;
|
|
372
1062
|
const skipped = reason ??
|
|
373
1063
|
protections.reason ??
|
|
374
|
-
(document.dialect === "gitea" && giteaPyParagraph(
|
|
1064
|
+
(document.dialect === "gitea" && giteaPyParagraph(source, node, start, end)
|
|
375
1065
|
? "Gitea `_.py` emphasis exception"
|
|
376
1066
|
: undefined);
|
|
377
1067
|
if (skipped) {
|
|
378
1068
|
reportSkipped(skipped);
|
|
379
1069
|
return;
|
|
380
1070
|
}
|
|
1071
|
+
// Collect the words line by line, without the container prefixes. A line
|
|
1072
|
+
// ending between words, or in the text of emphasis or a link, separates
|
|
1073
|
+
// words like a space. One inside an atom joins its lines with the space
|
|
1074
|
+
// that it renders as, or keeps the paragraph as written.
|
|
381
1075
|
const words = [];
|
|
382
|
-
let
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
let line = pieces[i];
|
|
393
|
-
let lineAt = at;
|
|
394
|
-
at += line.length + (pieces[i + 1]?.length ?? 0);
|
|
395
|
-
if (i > 0 && continuation && line.startsWith(continuation)) {
|
|
1076
|
+
let failure;
|
|
1077
|
+
const pieces = original.split(/(\r\n|\r|\n)/);
|
|
1078
|
+
let at = start;
|
|
1079
|
+
let lineEnd = start;
|
|
1080
|
+
for (let i = 0; i < pieces.length && !failure; i += 2) {
|
|
1081
|
+
let line = pieces[i];
|
|
1082
|
+
let lineAt = at;
|
|
1083
|
+
at += line.length + (pieces[i + 1]?.length ?? 0);
|
|
1084
|
+
if (i > 0) {
|
|
1085
|
+
if (continuation && line.startsWith(continuation)) {
|
|
396
1086
|
line = line.slice(continuation.length);
|
|
397
1087
|
lineAt += continuation.length;
|
|
398
1088
|
}
|
|
399
|
-
else if (
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
lines.push(line);
|
|
403
|
-
continue;
|
|
1089
|
+
else if (continuation && /^[\s>]/.test(line)) {
|
|
1090
|
+
failure = "unsupported continuation";
|
|
1091
|
+
break;
|
|
404
1092
|
}
|
|
405
|
-
|
|
406
|
-
|
|
1093
|
+
const last = words.at(-1);
|
|
1094
|
+
let joining = protection.has(lineEnd)
|
|
1095
|
+
? (atomAt(inline.atoms, lineEnd)?.joining ?? { reason: "line break in inline syntax" })
|
|
1096
|
+
: undefined;
|
|
1097
|
+
if (joining && typeof joining === "object" && "within" in joining)
|
|
1098
|
+
joining = atomAt(joining.within, lineEnd)?.joining ?? "description";
|
|
1099
|
+
if (!joining)
|
|
407
1100
|
words.push(" ");
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
1101
|
+
else if (last === undefined || /^[ \t]+$/.test(last))
|
|
1102
|
+
failure = "line break in inline syntax";
|
|
1103
|
+
else if (joining === "code" || joining === "description") {
|
|
1104
|
+
// In an image description, two spaces or a backslash before the
|
|
1105
|
+
// line ending make a hard break, and other spaces collapse; in a
|
|
1106
|
+
// code span, the spaces before it are code. The spaces and tabs
|
|
1107
|
+
// after it go, as `codeSpanText` reads the span, also the
|
|
1108
|
+
// indentation below a task item's checkbox, which the parser
|
|
1109
|
+
// keeps. A lazy line, which keeps them, is not supported above.
|
|
1110
|
+
if (joining === "description" && /(?: {2}|\\)$/.test(last))
|
|
1111
|
+
failure = "hard line break";
|
|
1112
|
+
const trimmed = line.replace(/^[ \t]+/, "");
|
|
1113
|
+
lineAt += line.length - trimmed.length;
|
|
1114
|
+
line = trimmed;
|
|
1115
|
+
words[words.length - 1] = `${joining === "code" ? last : last.trimEnd()} `;
|
|
1116
|
+
}
|
|
1117
|
+
else
|
|
1118
|
+
failure = "reason" in joining ? joining.reason : "line break in inline syntax";
|
|
414
1119
|
}
|
|
1120
|
+
words.push(...protection.split(line, lineAt));
|
|
1121
|
+
lineEnd = lineAt + line.length;
|
|
415
1122
|
}
|
|
416
|
-
if (
|
|
417
|
-
reportSkipped(
|
|
1123
|
+
if (failure) {
|
|
1124
|
+
reportSkipped(failure);
|
|
418
1125
|
return;
|
|
419
1126
|
}
|
|
420
|
-
const atoms = wrappingAtoms(words,
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
1127
|
+
const atoms = wrappingAtoms(words, openers, dollars);
|
|
1128
|
+
if (!atoms.length)
|
|
1129
|
+
return;
|
|
1130
|
+
// The first line keeps the words after a first word that would open a
|
|
1131
|
+
// block alone on the line, until the line can no longer open one, and
|
|
1132
|
+
// below an alert's or callout's marker line, after a run of `=` or `-`
|
|
1133
|
+
// that would underline that line as a heading.
|
|
1134
|
+
while (atoms.length > 1 &&
|
|
1135
|
+
(firstWordAlone.test(atoms[0]) ||
|
|
1136
|
+
(belowHeader && /^(?:=+|-+)$/.test(atoms[0])) ||
|
|
1137
|
+
(fenceStart.test(atoms[0]) && !oneParagraph(atoms[0], document.dialect))))
|
|
1138
|
+
atoms.splice(0, 2, `${atoms[0]} ${atoms[1]}`);
|
|
1139
|
+
/** Fill lines greedily, with the first `first` atoms on the first line. */
|
|
1140
|
+
const fill = (first) => {
|
|
1141
|
+
const lines = [];
|
|
1142
|
+
let line = atoms.slice(0, first).join(" ");
|
|
1143
|
+
let linePrefix = prefix;
|
|
1144
|
+
for (let i = first; i < atoms.length; i++) {
|
|
1145
|
+
const candidate = `${line} ${atoms[i]}`;
|
|
1146
|
+
if (lineWidth(linePrefix + candidate, measure) > width) {
|
|
1147
|
+
lines.push(line);
|
|
1148
|
+
line = atoms[i];
|
|
1149
|
+
linePrefix = continuation;
|
|
1150
|
+
}
|
|
1151
|
+
else
|
|
1152
|
+
line = candidate;
|
|
430
1153
|
}
|
|
431
|
-
|
|
432
|
-
line
|
|
1154
|
+
if (line)
|
|
1155
|
+
lines.push(line);
|
|
1156
|
+
return lines;
|
|
1157
|
+
};
|
|
1158
|
+
let output = fill(1);
|
|
1159
|
+
// A paragraph that starts like a link reference definition becomes one
|
|
1160
|
+
// when a line ends after the definition's destination or title. Keep
|
|
1161
|
+
// words on the first line until the lines read as one paragraph again.
|
|
1162
|
+
if (output.length > 1 && atoms[0].startsWith("[") && definitionStart.test(atoms.join(" "))) {
|
|
1163
|
+
let first = atoms.findIndex((atom) => atom.includes("]:")) + 1;
|
|
1164
|
+
while (output.length > 1 && !oneParagraph(output.join("\n"), document.dialect))
|
|
1165
|
+
output = fill(++first);
|
|
433
1166
|
}
|
|
434
|
-
if (line)
|
|
435
|
-
output.push(line);
|
|
436
1167
|
const replacement = output.join(newline + continuation);
|
|
437
1168
|
if (replacement !== original)
|
|
438
1169
|
findings.push({
|
|
@@ -442,19 +1173,21 @@ const wrap = {
|
|
|
442
1173
|
edit: { start, end, text: replacement },
|
|
443
1174
|
});
|
|
444
1175
|
if (options.reportUnbreakable === true &&
|
|
445
|
-
(prefix + replacement).split(lineBreak).some((line) =>
|
|
1176
|
+
(prefix + replacement).split(lineBreak).some((line) => lineWidth(line, measure) > width))
|
|
446
1177
|
reportUnbreakable();
|
|
447
1178
|
});
|
|
448
1179
|
return findings;
|
|
449
1180
|
},
|
|
450
1181
|
};
|
|
451
1182
|
const table = {
|
|
452
|
-
description: "Align GFM table cells while preserving their inline source.",
|
|
1183
|
+
description: "Align or compact GFM table cells while preserving their inline source.",
|
|
453
1184
|
kind: "style",
|
|
454
1185
|
phase: "block",
|
|
455
|
-
schema: optionsSchema({}),
|
|
456
|
-
check({ document }) {
|
|
1186
|
+
schema: optionsSchema({ style: { enum: ["aligned", "compact"] } }),
|
|
1187
|
+
check({ document, options }) {
|
|
457
1188
|
const findings = [];
|
|
1189
|
+
// A compact table has one space around each cell and the shortest delimiters.
|
|
1190
|
+
const compact = options.style === "compact";
|
|
458
1191
|
const newline = lineEnding(document.source);
|
|
459
1192
|
walk(document.tree, "table", (node) => {
|
|
460
1193
|
const [start, end] = range(node);
|
|
@@ -471,9 +1204,10 @@ const table = {
|
|
|
471
1204
|
const count = node.align?.length ?? rows[0]?.length ?? 0;
|
|
472
1205
|
if (rows.some((row) => row.length > count))
|
|
473
1206
|
return;
|
|
474
|
-
const widths = Array.from({ length: count }, (_, i) => Math.max(3, ...rows.map((row) => stringWidth(row[i] ?? ""))));
|
|
1207
|
+
const widths = Array.from({ length: count }, (_, i) => compact ? 3 : Math.max(3, ...rows.map((row) => stringWidth(row[i] ?? ""))));
|
|
1208
|
+
const pad = (cell, i) => compact ? cell : cell + " ".repeat(widths[i] - stringWidth(cell));
|
|
475
1209
|
// Keep each row's own cell count: adding cells to a short row changes the parsed table.
|
|
476
|
-
const rendered = rows.map((row) => `| ${row.map(
|
|
1210
|
+
const rendered = rows.map((row) => `| ${row.map(pad).join(" | ")} |`);
|
|
477
1211
|
rendered.splice(1, 0, `| ${widths
|
|
478
1212
|
.map((width, i) => {
|
|
479
1213
|
const align = node.align?.[i];
|
|
@@ -487,13 +1221,148 @@ const table = {
|
|
|
487
1221
|
findings.push({
|
|
488
1222
|
start,
|
|
489
1223
|
end,
|
|
490
|
-
message: "Align table cells.",
|
|
1224
|
+
message: compact ? "Write the table compactly." : "Align table cells.",
|
|
491
1225
|
edit: { start, end, text: replacement },
|
|
492
1226
|
});
|
|
493
1227
|
});
|
|
494
1228
|
return findings;
|
|
495
1229
|
},
|
|
496
1230
|
};
|
|
1231
|
+
/** A line holding nothing but block quote markers and blank space. */
|
|
1232
|
+
const blankLine = /^[ \t>]*$/;
|
|
1233
|
+
/**
|
|
1234
|
+
* A list item or quote marker followed by another list marker on its line,
|
|
1235
|
+
* including the ones that cannot open a list there, such as `* * *`.
|
|
1236
|
+
*/
|
|
1237
|
+
const nestedListStart = /^[ \t]*(?:[-+*]|\d{1,9}[.)]|>)[ \t>]*(?:[-+*]|\d{1,9}[.)])(?:[ \t]|$)/;
|
|
1238
|
+
/** The innermost last block of a container, where its text ends. */
|
|
1239
|
+
function lastBlock(node) {
|
|
1240
|
+
while (["blockquote", "list", "listItem", "footnoteDefinition"].includes(node.type) &&
|
|
1241
|
+
"children" in node &&
|
|
1242
|
+
node.children.length)
|
|
1243
|
+
node = node.children.at(-1);
|
|
1244
|
+
return node;
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* Whether two neighboring blocks have to stay as they are, although one of
|
|
1248
|
+
* them is a heading or a list, because a blank line between them would change
|
|
1249
|
+
* what they mean or what a comment or callout applies to.
|
|
1250
|
+
*/
|
|
1251
|
+
function together(document, parent, index, directiveLines) {
|
|
1252
|
+
const before = parent.children[index - 1];
|
|
1253
|
+
const after = parent.children[index];
|
|
1254
|
+
const { source } = document;
|
|
1255
|
+
const [beforeStart, beforeEnd] = range(before);
|
|
1256
|
+
const [afterStart, afterEnd] = range(after);
|
|
1257
|
+
// Front matter is not part of the document's text.
|
|
1258
|
+
if (["yaml", "toml"].includes(before.type))
|
|
1259
|
+
return true;
|
|
1260
|
+
// An unclosed HTML comment or block at the end of a list would take in the blank line.
|
|
1261
|
+
if (takesLines(before))
|
|
1262
|
+
return true;
|
|
1263
|
+
// Other tools, too, read a comment as applying to the line below it, and
|
|
1264
|
+
// `mdtools-disable-next-line` does: a blank line would take that line's place.
|
|
1265
|
+
if (before.type === "html" && /^<!--[^]*-->$/.test(before.value.trim()))
|
|
1266
|
+
return true;
|
|
1267
|
+
if (directiveLines.has(after.position.start.line - 1))
|
|
1268
|
+
return true;
|
|
1269
|
+
// The line that opens a callout or alert stays with the body below it.
|
|
1270
|
+
if (index === 1 &&
|
|
1271
|
+
parent.type === "blockquote" &&
|
|
1272
|
+
before.type === "paragraph" &&
|
|
1273
|
+
before.position.start.line === before.position.end.line &&
|
|
1274
|
+
startsWithMarker(source.slice(beforeStart, beforeEnd), before))
|
|
1275
|
+
return true;
|
|
1276
|
+
// Obsidian may attach a block identifier on its own line to the block above.
|
|
1277
|
+
const alone = document.dialect === "obsidian" ? blockIdentifier(source, after) : undefined;
|
|
1278
|
+
if (alone && source.slice(afterStart, afterEnd).trimEnd() === `^${alone.id}`)
|
|
1279
|
+
return true;
|
|
1280
|
+
// Directly below a table that ends inside a list or block quote, the parser
|
|
1281
|
+
// reads the next table as a paragraph, or as the text of a heading
|
|
1282
|
+
// underlined below it, while GitHub reads a table, and a blank line would
|
|
1283
|
+
// make it a table there as well.
|
|
1284
|
+
if (document.dialect !== "commonmark" &&
|
|
1285
|
+
(after.type === "paragraph" || after.type === "heading") &&
|
|
1286
|
+
misreadTable(source.slice(afterStart, afterEnd)))
|
|
1287
|
+
return true;
|
|
1288
|
+
// In a block quote, the parser counts a line of quote markers below a list
|
|
1289
|
+
// that ends in an empty footnote definition as part of the list item, which
|
|
1290
|
+
// makes the item loose.
|
|
1291
|
+
const last = lastBlock(before);
|
|
1292
|
+
if (parent.type === "blockquote" &&
|
|
1293
|
+
before.type === "list" &&
|
|
1294
|
+
last.type === "footnoteDefinition" &&
|
|
1295
|
+
!last.children.length)
|
|
1296
|
+
return true;
|
|
1297
|
+
// Directly below text or a link reference definition, the parser does not
|
|
1298
|
+
// start a list that would be empty or not start at 1, though it does below
|
|
1299
|
+
// a blank line: there, `- -` is a list item holding the text `-`, and `-`
|
|
1300
|
+
// underlined with `---` is a heading. Such a list could start on the first
|
|
1301
|
+
// line of a list or quote, and such a line could begin an underlined heading.
|
|
1302
|
+
if (!["paragraph", "definition"].includes(lastBlock(before).type))
|
|
1303
|
+
return false;
|
|
1304
|
+
const firstLine = source.slice(afterStart, lineBounds(source, afterStart, afterStart)[1]);
|
|
1305
|
+
if (after.type === "heading")
|
|
1306
|
+
return !/^[ \t]*#/.test(firstLine);
|
|
1307
|
+
return (after.type === "list" || after.type === "blockquote") && nestedListStart.test(firstLine);
|
|
1308
|
+
}
|
|
1309
|
+
const blockSpacing = {
|
|
1310
|
+
description: "Separate headings and lists from neighboring blocks with a blank line.",
|
|
1311
|
+
kind: "style",
|
|
1312
|
+
phase: "block",
|
|
1313
|
+
schema: optionsSchema({}),
|
|
1314
|
+
check({ document }) {
|
|
1315
|
+
const { source } = document;
|
|
1316
|
+
const findings = [];
|
|
1317
|
+
const newline = lineEnding(source);
|
|
1318
|
+
const directiveLines = new Set();
|
|
1319
|
+
if (source.includes("mdtools-"))
|
|
1320
|
+
walk(document.tree, "html", (node) => {
|
|
1321
|
+
if (directive(node.value)?.kind === "disable-next-line")
|
|
1322
|
+
directiveLines.add(node.position.end.line);
|
|
1323
|
+
});
|
|
1324
|
+
// Only the blocks of the document and of block quotes are compared. A
|
|
1325
|
+
// blank line between the blocks of a list item makes its list loose.
|
|
1326
|
+
walk(document.tree, (parent) => {
|
|
1327
|
+
if (parent.type !== "root" && parent.type !== "blockquote")
|
|
1328
|
+
return;
|
|
1329
|
+
for (let index = 1; index < parent.children.length; index++) {
|
|
1330
|
+
const before = parent.children[index - 1];
|
|
1331
|
+
const after = parent.children[index];
|
|
1332
|
+
const subject = [after, before].find((node) => node.type === "heading" || node.type === "list");
|
|
1333
|
+
// A plugin's syntax can add a node without a source position.
|
|
1334
|
+
const located = [before, after].every(({ position }) => position?.start.offset !== undefined && position.end.offset !== undefined);
|
|
1335
|
+
if (!subject || !located || together(document, parent, index, directiveLines))
|
|
1336
|
+
continue;
|
|
1337
|
+
const end = range(before)[1];
|
|
1338
|
+
const start = range(after)[0];
|
|
1339
|
+
const lineStart = lineBounds(source, start, start)[0];
|
|
1340
|
+
const breakStart = lineStart - (source.startsWith("\r\n", lineStart - 2) ? 2 : 1);
|
|
1341
|
+
if (breakStart < end)
|
|
1342
|
+
continue;
|
|
1343
|
+
// A blank line, or one with only quote markers, already separates them.
|
|
1344
|
+
const previous = source.slice(lineBounds(source, breakStart, breakStart)[0], breakStart);
|
|
1345
|
+
if (blankLine.test(previous))
|
|
1346
|
+
continue;
|
|
1347
|
+
// The block's own line holds the quote markers that the new line needs.
|
|
1348
|
+
const prefix = source.slice(lineStart, start);
|
|
1349
|
+
if (!blankLine.test(prefix))
|
|
1350
|
+
continue;
|
|
1351
|
+
const kind = subject.type === "heading" ? "heading" : "list";
|
|
1352
|
+
findings.push({
|
|
1353
|
+
start: subject === after ? start : end,
|
|
1354
|
+
message: `Add a blank line ${subject === after ? "before" : "after"} the ${kind}.`,
|
|
1355
|
+
edit: {
|
|
1356
|
+
start: breakStart,
|
|
1357
|
+
end: lineStart,
|
|
1358
|
+
text: source.slice(breakStart, lineStart) + prefix.replace(/[ \t]+$/, "") + newline,
|
|
1359
|
+
},
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
});
|
|
1363
|
+
return findings;
|
|
1364
|
+
},
|
|
1365
|
+
};
|
|
497
1366
|
export const styleRules = {
|
|
498
1367
|
"style/wrap": wrap,
|
|
499
1368
|
"style/inline-code": {
|
|
@@ -503,22 +1372,39 @@ export const styleRules = {
|
|
|
503
1372
|
schema: optionsSchema({}),
|
|
504
1373
|
check({ document }) {
|
|
505
1374
|
const findings = [];
|
|
1375
|
+
const { source } = document;
|
|
506
1376
|
const forge = goldmarkForges[document.dialect] !== undefined;
|
|
1377
|
+
// In Obsidian, the inline nodes whose source joined lines cannot change.
|
|
1378
|
+
const opaque = [];
|
|
1379
|
+
if (document.dialect === "obsidian")
|
|
1380
|
+
walk(document.tree, (node) => {
|
|
1381
|
+
if (obsidianOpaque.has(node.type) || node.type === "obsidianComment")
|
|
1382
|
+
opaque.push(range(node));
|
|
1383
|
+
});
|
|
1384
|
+
const titles = calloutTitles(document);
|
|
507
1385
|
let headingEnd = 0;
|
|
1386
|
+
let blockStart = 0;
|
|
508
1387
|
walk(document.tree, (node) => {
|
|
509
1388
|
if (node.type === "heading" && forge)
|
|
510
1389
|
headingEnd = range(node)[1];
|
|
1390
|
+
if (inlineBlocks.has(node.type))
|
|
1391
|
+
blockStart = range(node)[0];
|
|
511
1392
|
if (node.type !== "inlineCode")
|
|
512
1393
|
return;
|
|
513
1394
|
const [start, end] = range(node);
|
|
514
|
-
if (!/[\r\n]/.test(
|
|
1395
|
+
if (!/[\r\n]/.test(source.slice(start, end)))
|
|
515
1396
|
return;
|
|
516
1397
|
// Forgejo and Gitea look for a heading's attribute block on its last
|
|
517
1398
|
// text line, and Forgejo also builds the anchor from that line alone.
|
|
518
1399
|
// Joining lines would change what that line holds.
|
|
519
1400
|
if (start < headingEnd)
|
|
520
1401
|
return;
|
|
521
|
-
|
|
1402
|
+
// Joining keeps the spaces and tabs that start a lazy continuation line,
|
|
1403
|
+
// as GitHub shows them; commonmark.js drops them, and the other
|
|
1404
|
+
// dialects' renderers were not observed there.
|
|
1405
|
+
if (document.dialect !== "github" && lazyIndentation(node))
|
|
1406
|
+
return;
|
|
1407
|
+
if (document.dialect === "gitea" && giteaPyLine(document, start, end))
|
|
522
1408
|
return;
|
|
523
1409
|
// Joining lines could put a `\(` and `\)` on one line and turn them into math.
|
|
524
1410
|
if (forge) {
|
|
@@ -526,9 +1412,48 @@ export const styleRules = {
|
|
|
526
1412
|
if (lines.includes("\\(") && lines.includes("\\)"))
|
|
527
1413
|
return;
|
|
528
1414
|
}
|
|
529
|
-
|
|
1415
|
+
// Joining would move text onto the line of a callout's or alert's
|
|
1416
|
+
// marker, which holds an Obsidian callout's title.
|
|
1417
|
+
if (titles.some(([a, b]) => start >= a && start < b))
|
|
1418
|
+
return;
|
|
1419
|
+
if (document.dialect === "obsidian") {
|
|
1420
|
+
// Obsidian reads highlights and wikilinks only within a line. Once
|
|
1421
|
+
// the lines are one, an `==` or `[[` left as text could open one that
|
|
1422
|
+
// runs to the next `==` or `]]`, even inside this or another span.
|
|
1423
|
+
const [from, to] = lineBounds(source, start, end);
|
|
1424
|
+
const lines = source.slice(from, to);
|
|
1425
|
+
let text = "";
|
|
1426
|
+
let cursor = from;
|
|
1427
|
+
for (const [a, b] of opaque) {
|
|
1428
|
+
if (b <= cursor || a >= to)
|
|
1429
|
+
continue;
|
|
1430
|
+
// A space keeps the text on both sides from forming a marker.
|
|
1431
|
+
text += source.slice(cursor, Math.max(cursor, a)) + " ";
|
|
1432
|
+
cursor = Math.min(b, to);
|
|
1433
|
+
}
|
|
1434
|
+
text += source.slice(cursor, to);
|
|
1435
|
+
if ((text.includes("==") && (lines.match(/==/g) ?? []).length > 1) ||
|
|
1436
|
+
(text.includes("[[") && lines.includes("]]")))
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
// The joined span holds the text that GitHub shows. A space at each end
|
|
1440
|
+
// keeps a backtick there from joining the fence, or spaces there from
|
|
1441
|
+
// being taken off.
|
|
1442
|
+
const value = codeSpanText(node);
|
|
530
1443
|
const longest = Math.max(0, ...(value.match(/`+/g) ?? []).map((run) => run.length));
|
|
531
1444
|
const marker = "`".repeat(longest + 1);
|
|
1445
|
+
// An earlier run of the new backticks that the parser left as text, or
|
|
1446
|
+
// that lies in a link destination, would pair with the new opening one.
|
|
1447
|
+
// The span's own backticks cannot have such a run before them.
|
|
1448
|
+
if (marker.length !== /^`+/.exec(source.slice(start, end))[0].length) {
|
|
1449
|
+
const before = source.slice(blockStart, start);
|
|
1450
|
+
for (const run of before.matchAll(/`+/g)) {
|
|
1451
|
+
const length = run[0].length;
|
|
1452
|
+
if (length === marker.length ||
|
|
1453
|
+
(escaped(before, run.index) && length - 1 === marker.length))
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
532
1457
|
const pad = /^`|`$/.test(value) || (/^ .* $/.test(value) && /[^ ]/.test(value)) ? " " : "";
|
|
533
1458
|
findings.push({
|
|
534
1459
|
start,
|
|
@@ -548,8 +1473,10 @@ export const styleRules = {
|
|
|
548
1473
|
kind: "style",
|
|
549
1474
|
phase: "document",
|
|
550
1475
|
schema: optionsSchema({}),
|
|
551
|
-
check({ document
|
|
552
|
-
|
|
1476
|
+
check({ document }) {
|
|
1477
|
+
const { source } = document;
|
|
1478
|
+
// An unclosed block at the end, such as an HTML comment, would take in the line ending.
|
|
1479
|
+
if (!source || /[\r\n]$/.test(source) || takesFinalLineEnding(document))
|
|
553
1480
|
return [];
|
|
554
1481
|
return [
|
|
555
1482
|
{
|
|
@@ -561,21 +1488,65 @@ export const styleRules = {
|
|
|
561
1488
|
},
|
|
562
1489
|
},
|
|
563
1490
|
"style/heading": {
|
|
564
|
-
description: "Use ATX headings.",
|
|
1491
|
+
description: "Use ATX or Setext headings, without closing sequences.",
|
|
565
1492
|
kind: "style",
|
|
566
1493
|
phase: "block",
|
|
567
|
-
schema: optionsSchema({
|
|
568
|
-
|
|
1494
|
+
schema: optionsSchema({
|
|
1495
|
+
style: { enum: ["atx", "setext", "consistent"] },
|
|
1496
|
+
closing: { enum: ["remove", "keep"] },
|
|
1497
|
+
}),
|
|
1498
|
+
check({ document, options }) {
|
|
569
1499
|
const findings = [];
|
|
1500
|
+
const { source } = document;
|
|
1501
|
+
// Only levels 1 and 2 can be underlined; deeper headings keep their `#`.
|
|
1502
|
+
const underlined = underlinedHeadings(document, options.style);
|
|
570
1503
|
walk(document.tree, "heading", (node) => {
|
|
571
|
-
const [
|
|
572
|
-
if (document.source[start] === "#" || node.position.start.column !== 1)
|
|
573
|
-
return;
|
|
1504
|
+
const [headingStart, end] = range(node);
|
|
574
1505
|
const first = node.children[0];
|
|
575
1506
|
const last = node.children.at(-1);
|
|
576
|
-
|
|
1507
|
+
// An ATX heading is one line, a Setext heading also has its underline.
|
|
1508
|
+
if (!/[\r\n]/.test(source.slice(headingStart, end))) {
|
|
1509
|
+
const edit = underlined ? underlineEdit(document, node) : undefined;
|
|
1510
|
+
if (edit) {
|
|
1511
|
+
findings.push({
|
|
1512
|
+
start: edit.start,
|
|
1513
|
+
end: edit.end,
|
|
1514
|
+
message: "Use an underlined heading.",
|
|
1515
|
+
edit,
|
|
1516
|
+
});
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
if (options.closing === "keep")
|
|
1520
|
+
return;
|
|
1521
|
+
const textEnd = last
|
|
1522
|
+
? range(last)[1]
|
|
1523
|
+
: headingStart + /^#*/.exec(source.slice(headingStart))[0].length;
|
|
1524
|
+
const lineEnd = lineBounds(source, textEnd, textEnd)[1];
|
|
1525
|
+
if (!/^[ \t]+#+[ \t]*$/.test(source.slice(textEnd, lineEnd)))
|
|
1526
|
+
return;
|
|
1527
|
+
// Gitea before 1.26 reads an attribute block that a closing sequence
|
|
1528
|
+
// follows, and Forgejo and later Gitea versions do not.
|
|
1529
|
+
if (goldmarkForges[document.dialect] &&
|
|
1530
|
+
first &&
|
|
1531
|
+
headingAttributeBlock(source.slice(range(first)[0], textEnd)))
|
|
1532
|
+
return;
|
|
1533
|
+
findings.push({
|
|
1534
|
+
start: textEnd,
|
|
1535
|
+
end: lineEnd,
|
|
1536
|
+
message: "Remove the closing # sequence.",
|
|
1537
|
+
edit: { start: textEnd, end: lineEnd, text: "" },
|
|
1538
|
+
});
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
if (underlined || !first || !last)
|
|
1542
|
+
return;
|
|
1543
|
+
// Below link reference definitions, the heading's position starts at
|
|
1544
|
+
// the first definition; its own text starts on a later line. Headings
|
|
1545
|
+
// that are indented or inside lists or block quotes are left as written.
|
|
1546
|
+
const start = range(first)[0];
|
|
1547
|
+
if (lineBounds(source, start, start)[0] !== start)
|
|
577
1548
|
return;
|
|
578
|
-
let text =
|
|
1549
|
+
let text = source.slice(start, range(last)[1]);
|
|
579
1550
|
if (/[\r\n]/.test(text))
|
|
580
1551
|
return;
|
|
581
1552
|
// A trailing run of # after whitespace would become an ATX closing
|
|
@@ -598,6 +1569,7 @@ export const styleRules = {
|
|
|
598
1569
|
return findings;
|
|
599
1570
|
},
|
|
600
1571
|
},
|
|
1572
|
+
"style/block-spacing": blockSpacing,
|
|
601
1573
|
};
|
|
602
1574
|
/** Collect source-positioned nodes for rule/plugin consumers. */
|
|
603
1575
|
export function nodesOfType(document, type) {
|