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.
Files changed (130) hide show
  1. package/CHANGELOG.md +577 -0
  2. package/CODE_OF_CONDUCT.md +30 -0
  3. package/CONTRIBUTING.md +189 -16
  4. package/README.md +100 -154
  5. package/SECURITY.md +15 -13
  6. package/config.schema.json +91 -0
  7. package/dist/cli/diff.d.ts +10 -0
  8. package/dist/cli/diff.js +247 -0
  9. package/dist/cli/diff.js.map +1 -0
  10. package/dist/cli/main.js +270 -150
  11. package/dist/cli/main.js.map +1 -1
  12. package/dist/config/load.js +67 -6
  13. package/dist/config/load.js.map +1 -1
  14. package/dist/config/problems.d.ts +7 -0
  15. package/dist/config/problems.js +208 -0
  16. package/dist/config/problems.js.map +1 -0
  17. package/dist/config/resolve.d.ts +42 -109
  18. package/dist/config/resolve.js +193 -52
  19. package/dist/config/resolve.js.map +1 -1
  20. package/dist/core/directives.d.ts +7 -0
  21. package/dist/core/directives.js +25 -13
  22. package/dist/core/directives.js.map +1 -1
  23. package/dist/core/engine.d.ts +42 -4
  24. package/dist/core/engine.js +518 -79
  25. package/dist/core/engine.js.map +1 -1
  26. package/dist/core/frozen.d.ts +6 -0
  27. package/dist/core/frozen.js +15 -0
  28. package/dist/core/frozen.js.map +1 -0
  29. package/dist/core/scope.d.ts +30 -0
  30. package/dist/core/scope.js +122 -0
  31. package/dist/core/scope.js.map +1 -0
  32. package/dist/core/types.d.ts +202 -23
  33. package/dist/core/types.js.map +1 -1
  34. package/dist/index.d.ts +2 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/node.d.ts +3 -0
  37. package/dist/node.js +4 -0
  38. package/dist/node.js.map +1 -0
  39. package/dist/rules/blocks.d.ts +34 -0
  40. package/dist/rules/blocks.js +550 -0
  41. package/dist/rules/blocks.js.map +1 -0
  42. package/dist/rules/content.d.ts +2 -0
  43. package/dist/rules/content.js +85 -0
  44. package/dist/rules/content.js.map +1 -0
  45. package/dist/rules/dialects.js +10 -6
  46. package/dist/rules/dialects.js.map +1 -1
  47. package/dist/rules/links.js +302 -6
  48. package/dist/rules/links.js.map +1 -1
  49. package/dist/rules/lists.d.ts +2 -0
  50. package/dist/rules/lists.js +924 -0
  51. package/dist/rules/lists.js.map +1 -0
  52. package/dist/rules/structure.js +127 -1
  53. package/dist/rules/structure.js.map +1 -1
  54. package/dist/rules/style.d.ts +8 -2
  55. package/dist/rules/style.js +1137 -165
  56. package/dist/rules/style.js.map +1 -1
  57. package/dist/syntax/block-identifier.d.ts +18 -0
  58. package/dist/syntax/block-identifier.js +24 -0
  59. package/dist/syntax/block-identifier.js.map +1 -0
  60. package/dist/syntax/obsidian.d.ts +29 -11
  61. package/dist/syntax/obsidian.js +9 -10
  62. package/dist/syntax/obsidian.js.map +1 -1
  63. package/dist/syntax/parse.d.ts +87 -3
  64. package/dist/syntax/parse.js +247 -19
  65. package/dist/syntax/parse.js.map +1 -1
  66. package/dist/workspace/access.d.ts +12 -3
  67. package/dist/workspace/access.js +33 -5
  68. package/dist/workspace/access.js.map +1 -1
  69. package/dist/workspace/discovery.d.ts +85 -0
  70. package/dist/workspace/discovery.js +155 -0
  71. package/dist/workspace/discovery.js.map +1 -0
  72. package/dist/workspace/encoding.d.ts +24 -0
  73. package/dist/workspace/encoding.js +113 -0
  74. package/dist/workspace/encoding.js.map +1 -0
  75. package/dist/workspace/files.d.ts +92 -6
  76. package/dist/workspace/files.js +715 -59
  77. package/dist/workspace/files.js.map +1 -1
  78. package/dist/workspace/git.d.ts +54 -0
  79. package/dist/workspace/git.js +198 -0
  80. package/dist/workspace/git.js.map +1 -0
  81. package/dist/workspace/index.d.ts +58 -3
  82. package/dist/workspace/index.js +136 -37
  83. package/dist/workspace/index.js.map +1 -1
  84. package/dist/workspace/selection.d.ts +2 -2
  85. package/dist/workspace/selection.js +3 -3
  86. package/dist/workspace/selection.js.map +1 -1
  87. package/dist/workspace/slug.js +8 -1
  88. package/dist/workspace/slug.js.map +1 -1
  89. package/dist/workspace/vault.js +3 -1
  90. package/dist/workspace/vault.js.map +1 -1
  91. package/docs/api.md +239 -0
  92. package/docs/benchmarks.md +140 -200
  93. package/docs/changelog.md +1 -0
  94. package/docs/cli.md +281 -89
  95. package/docs/configuration.md +193 -480
  96. package/docs/design.md +163 -284
  97. package/docs/dialects.md +169 -0
  98. package/docs/examples.md +151 -109
  99. package/docs/index.md +21 -37
  100. package/docs/integrations.md +144 -0
  101. package/docs/obsidian.md +91 -115
  102. package/docs/plugins.md +147 -212
  103. package/docs/quick-start.md +68 -74
  104. package/docs/rules.md +1237 -0
  105. package/docs/versions.md +56 -0
  106. package/examples/README.md +17 -14
  107. package/examples/automation/summarize-report.mjs +5 -2
  108. package/examples/library/lint-folder.mjs +5 -4
  109. package/examples/library/lint-workspace.mjs +32 -0
  110. package/examples/plugin/notes/release-checklist.md +2 -2
  111. package/examples/plugin/rules.mjs +17 -23
  112. package/examples/workspaces/obsidian-vault/after/mdtools.config.jsonc +2 -2
  113. package/examples/workspaces/obsidian-vault/before/mdtools.config.jsonc +2 -2
  114. package/examples/workspaces/repository-docs/after/README.md +2 -2
  115. package/examples/workspaces/repository-docs/after/mdtools.config.jsonc +0 -2
  116. package/examples/workspaces/repository-docs/before/README.md +2 -2
  117. package/examples/workspaces/repository-docs/before/docs/guide.md +7 -7
  118. package/examples/workspaces/repository-docs/before/docs/reference.md +3 -1
  119. package/examples/workspaces/repository-docs/before/mdtools.config.jsonc +0 -2
  120. package/package.json +38 -12
  121. package/docs/contributing.md +0 -218
  122. package/docs/releases/0.1.0-beta.1.md +0 -39
  123. package/docs/releases/0.1.0-beta.2.md +0 -83
  124. package/docs/releases/0.1.0-beta.3.md +0 -61
  125. package/docs/releases/0.1.0-beta.4.md +0 -50
  126. package/docs/releases/0.1.0-rc.1.md +0 -99
  127. package/docs/releases/0.2.0-rc.1.md +0 -141
  128. package/docs/releases/0.2.0-rc.2.md +0 -225
  129. package/docs/releases.md +0 -144
  130. package/docs/testing.md +0 -93
@@ -1,7 +1,10 @@
1
1
  import { walk } from "../syntax/walk.js";
2
2
  import stringWidth from "string-width";
3
- import { range } from "../syntax/parse.js";
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(source, start, end) {
32
- return source.slice(...lineBounds(source, start, end)).includes("_.py");
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(source, start, end) {
42
- const [lineStart, lineEnd] = lineBounds(source, start, end);
43
- const before = source.slice(lineStart, start);
44
- return (before.lastIndexOf("\\(") > before.lastIndexOf("\\)") &&
45
- source.slice(end, lineEnd).includes("\\)"));
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
- walk(document.tree, type, (node) => {
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 size = type === "strong" ? 2 : 1;
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.source, start, end))
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.source, start, end) ||
340
+ (giteaPyLine(document, start, end) ||
81
341
  document.source.startsWith(".py", end) ||
82
342
  document.source.startsWith(".py", start + size)))
83
343
  return;
84
- for (const [a, b] of [
85
- [start, start + size],
86
- [end - size, end],
87
- ]) {
88
- findings.push({
89
- start: a,
90
- end: b,
91
- message: `Use ${JSON.stringify(marker)} for ${type}.`,
92
- edit: { start: a, end: b, text: marker },
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
- return findings;
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
- // Whitespace inside an inline node is protected; only text-node whitespace can wrap.
101
- function wrappingAtoms(words, extraOpener) {
102
- const atoms = [];
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
- atoms.push(current);
450
+ separated.push(current);
108
451
  current = "";
109
452
  }
110
453
  else
111
454
  current += word;
112
455
  }
113
456
  if (current)
114
- atoms.push(current);
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
- for (let i = 1; i < atoms.length; i++) {
118
- if (lineOpener.test(atoms[i]) || extraOpener?.test(atoms[i])) {
119
- atoms.splice(i - 1, 2, `${atoms[i - 1]} ${atoms[i]}`);
120
- i--;
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
- for (let i = 0; i < atoms.length - 1; i++) {
126
- if (/(?:^|[^\\])(?:\\\\)*\\$/.test(atoms[i])) {
127
- atoms.splice(i, 2, `${atoms[i]} ${atoms[i + 1]}`);
128
- i--;
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
- // List markers, ATX and Setext heading markers, block quotes, thematic breaks,
134
- // fences, math, Obsidian comments, HTML blocks, and footnote definitions.
135
- const lineOpener = /^(?:[-+*]|\d+[.)]|#{1,6}|>|[-*_]{3,}|-{2,}|=+|~{3,}|`{3,}|\$\$|%%|<[!?/A-Za-z]|\[\^[^\]]+\]:)/;
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
- /** Split text at source whitespace, except inside protected source ranges. */
141
- function splitWords(text, base, ranges) {
142
- if (!ranges.length)
143
- return text.split(/([ \t]+)/).filter(Boolean);
144
- const words = [];
145
- let cursor = 0;
146
- for (const match of text.matchAll(/[ \t]+/g)) {
147
- const at = base + match.index;
148
- if (ranges.some(([from, to]) => at > from && at < to))
149
- continue;
150
- if (match.index > cursor)
151
- words.push(text.slice(cursor, match.index));
152
- words.push(match[0]);
153
- cursor = match.index + match[0].length;
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
- if (cursor < text.length)
156
- words.push(text.slice(cursor));
157
- return words;
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
- let calloutHeader = document.dialect === "obsidian" &&
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
- /^\[![\w-]+\]/.test(original);
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.replace(/(?:[-+*]|\d+[.)]|\[[xX ]\])(?=\s)/g, (value) => " ".repeat(value.length));
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 && measure(prefix + header) > width)
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 forge = goldmarkForges[document.dialect];
291
- const extraOpener = forge ? forgeLineOpener : undefined;
292
- let protectedRanges = [];
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 (measure(prefix + content) > width)
999
+ if (lineWidth(prefix + content, measure) > width)
316
1000
  unbreakable = true;
317
1001
  offset += line.length;
318
1002
  continue;
319
1003
  }
320
- const words = [];
321
- while (childIndex < children.length && range(children[childIndex])[1] <= offset)
322
- childIndex++;
323
- for (let i = childIndex; i < children.length; i++) {
324
- const child = children[i];
325
- const [a, b] = range(child);
326
- if (a >= offset + content.length)
327
- break;
328
- if (child.type === "break")
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
- const reason = children.some((child) => child.type === "break")
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
- : /^\[![^\]]+\]/.test(original)
1042
+ : startsLikeMarker
360
1043
  ? "callout header"
361
- : /(?:^|\s)\^[\w-]+\s*$/.test(original)
1044
+ : blockIdentifier(document.source, node)
362
1045
  ? "block identifier"
363
- : !/^[\s>\-*+\d.[\]xX)]*$/.test(prefix)
364
- ? "unsupported container prefix"
365
- : undefined;
366
- // Protections are computed first so that skipped paragraphs still
367
- // classify one-line math and shortlinks as single atoms in diagnostics.
368
- const protections = forge
369
- ? forgeProtections(document.source, start, end, forge)
370
- : { ranges: [] };
371
- protectedRanges = protections.ranges;
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(document.source, node, start, end)
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 unsupported = false;
383
- for (const child of children) {
384
- const [a, b] = range(child);
385
- const from = Math.max(a, start);
386
- const raw = document.source.slice(from, b);
387
- // Odd pieces are the line breaks, so each line's source offset is known.
388
- const pieces = raw.split(/(\r\n|\r|\n)/);
389
- const lines = [];
390
- let at = from;
391
- for (let i = 0; i < pieces.length; i += 2) {
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 (i > 0 && continuation && /^[\s>]/.test(line))
400
- unsupported = true;
401
- if (child.type !== "text") {
402
- lines.push(line);
403
- continue;
1089
+ else if (continuation && /^[\s>]/.test(line)) {
1090
+ failure = "unsupported continuation";
1091
+ break;
404
1092
  }
405
- // Preserve escaping and entities. Split only ordinary source whitespace.
406
- if (i > 0)
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
- words.push(...splitWords(line, lineAt, protectedRanges));
409
- }
410
- if (child.type !== "text") {
411
- if (raw.includes("\n"))
412
- unsupported = true;
413
- words.push(lines.join(" "));
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 (unsupported) {
417
- reportSkipped("multiline inline syntax or unsupported continuation");
1123
+ if (failure) {
1124
+ reportSkipped(failure);
418
1125
  return;
419
1126
  }
420
- const atoms = wrappingAtoms(words, extraOpener);
421
- const output = [];
422
- let line = "";
423
- let available = width - measure(prefix);
424
- for (const atom of atoms) {
425
- const candidate = line ? `${line} ${atom}` : atom;
426
- if (line && measure(candidate) > available) {
427
- output.push(line);
428
- line = atom;
429
- available = width - measure(continuation);
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
- else
432
- line = candidate;
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) => measure(line) > width))
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((cell, i) => cell + " ".repeat(widths[i] - stringWidth(cell))).join(" | ")} |`);
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(document.source.slice(start, end)))
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
- if (document.dialect === "gitea" && giteaPyLine(document.source, start, end))
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
- const value = node.value.replace(/\r\n|\r|\n/g, " ");
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: { source } }) {
552
- if (!source || /[\r\n]$/.test(source))
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
- check({ document }) {
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 [start, end] = range(node);
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
- if (!first || !last)
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 = document.source.slice(range(first)[0], range(last)[1]);
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) {