@mark1russell7/ste-lint 0.0.0-stage → 0.1.1

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 (227) hide show
  1. package/README.md +225 -2
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/cli.d.ts +3 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +9 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/config.d.ts +103 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +251 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/dictionary.d.ts +74 -0
  12. package/dist/dictionary.d.ts.map +1 -0
  13. package/dist/dictionary.js +169 -0
  14. package/dist/dictionary.js.map +1 -0
  15. package/dist/files.d.ts +35 -0
  16. package/dist/files.d.ts.map +1 -0
  17. package/dist/files.js +181 -0
  18. package/dist/files.js.map +1 -0
  19. package/dist/format.d.ts +21 -0
  20. package/dist/format.d.ts.map +1 -0
  21. package/dist/format.js +61 -0
  22. package/dist/format.js.map +1 -0
  23. package/dist/index.d.ts +17 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +17 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/lexicon.d.ts +33 -0
  28. package/dist/lexicon.d.ts.map +1 -0
  29. package/dist/lexicon.js +71 -0
  30. package/dist/lexicon.js.map +1 -0
  31. package/dist/lint.d.ts +28 -0
  32. package/dist/lint.d.ts.map +1 -0
  33. package/dist/lint.js +132 -0
  34. package/dist/lint.js.map +1 -0
  35. package/dist/rules/about-quantity.d.ts +3 -0
  36. package/dist/rules/about-quantity.d.ts.map +1 -0
  37. package/dist/rules/about-quantity.js +34 -0
  38. package/dist/rules/about-quantity.js.map +1 -0
  39. package/dist/rules/index.d.ts +8 -0
  40. package/dist/rules/index.d.ts.map +1 -0
  41. package/dist/rules/index.js +35 -0
  42. package/dist/rules/index.js.map +1 -0
  43. package/dist/rules/ing-verb.d.ts +9 -0
  44. package/dist/rules/ing-verb.d.ts.map +1 -0
  45. package/dist/rules/ing-verb.js +60 -0
  46. package/dist/rules/ing-verb.js.map +1 -0
  47. package/dist/rules/latin-abbreviation.d.ts +3 -0
  48. package/dist/rules/latin-abbreviation.d.ts.map +1 -0
  49. package/dist/rules/latin-abbreviation.js +48 -0
  50. package/dist/rules/latin-abbreviation.js.map +1 -0
  51. package/dist/rules/missing-subject.d.ts +16 -0
  52. package/dist/rules/missing-subject.d.ts.map +1 -0
  53. package/dist/rules/missing-subject.js +138 -0
  54. package/dist/rules/missing-subject.js.map +1 -0
  55. package/dist/rules/modal-verb.d.ts +3 -0
  56. package/dist/rules/modal-verb.d.ts.map +1 -0
  57. package/dist/rules/modal-verb.js +72 -0
  58. package/dist/rules/modal-verb.js.map +1 -0
  59. package/dist/rules/noun-cluster.d.ts +17 -0
  60. package/dist/rules/noun-cluster.d.ts.map +1 -0
  61. package/dist/rules/noun-cluster.js +83 -0
  62. package/dist/rules/noun-cluster.js.map +1 -0
  63. package/dist/rules/paragraph-length.d.ts +3 -0
  64. package/dist/rules/paragraph-length.d.ts.map +1 -0
  65. package/dist/rules/paragraph-length.js +19 -0
  66. package/dist/rules/paragraph-length.js.map +1 -0
  67. package/dist/rules/passive-instruction.d.ts +15 -0
  68. package/dist/rules/passive-instruction.d.ts.map +1 -0
  69. package/dist/rules/passive-instruction.js +38 -0
  70. package/dist/rules/passive-instruction.js.map +1 -0
  71. package/dist/rules/pronouns.d.ts +4 -0
  72. package/dist/rules/pronouns.d.ts.map +1 -0
  73. package/dist/rules/pronouns.js +57 -0
  74. package/dist/rules/pronouns.js.map +1 -0
  75. package/dist/rules/rule.d.ts +46 -0
  76. package/dist/rules/rule.d.ts.map +1 -0
  77. package/dist/rules/rule.js +31 -0
  78. package/dist/rules/rule.js.map +1 -0
  79. package/dist/rules/semicolon.d.ts +3 -0
  80. package/dist/rules/semicolon.d.ts.map +1 -0
  81. package/dist/rules/semicolon.js +20 -0
  82. package/dist/rules/semicolon.js.map +1 -0
  83. package/dist/rules/sentence-length.d.ts +3 -0
  84. package/dist/rules/sentence-length.d.ts.map +1 -0
  85. package/dist/rules/sentence-length.js +31 -0
  86. package/dist/rules/sentence-length.js.map +1 -0
  87. package/dist/rules/unknown-word.d.ts +9 -0
  88. package/dist/rules/unknown-word.d.ts.map +1 -0
  89. package/dist/rules/unknown-word.js +49 -0
  90. package/dist/rules/unknown-word.js.map +1 -0
  91. package/dist/rules/word-list-matcher.d.ts +25 -0
  92. package/dist/rules/word-list-matcher.d.ts.map +1 -0
  93. package/dist/rules/word-list-matcher.js +60 -0
  94. package/dist/rules/word-list-matcher.js.map +1 -0
  95. package/dist/rules/word-list.d.ts +3 -0
  96. package/dist/rules/word-list.d.ts.map +1 -0
  97. package/dist/rules/word-list.js +26 -0
  98. package/dist/rules/word-list.js.map +1 -0
  99. package/dist/run-cli.d.ts +27 -0
  100. package/dist/run-cli.d.ts.map +1 -0
  101. package/dist/run-cli.js +176 -0
  102. package/dist/run-cli.js.map +1 -0
  103. package/dist/source/blocks.d.ts +55 -0
  104. package/dist/source/blocks.d.ts.map +1 -0
  105. package/dist/source/blocks.js +464 -0
  106. package/dist/source/blocks.js.map +1 -0
  107. package/dist/source/inline.d.ts +42 -0
  108. package/dist/source/inline.d.ts.map +1 -0
  109. package/dist/source/inline.js +396 -0
  110. package/dist/source/inline.js.map +1 -0
  111. package/dist/source/jsx.d.ts +18 -0
  112. package/dist/source/jsx.d.ts.map +1 -0
  113. package/dist/source/jsx.js +137 -0
  114. package/dist/source/jsx.js.map +1 -0
  115. package/dist/source/lines.d.ts +34 -0
  116. package/dist/source/lines.d.ts.map +1 -0
  117. package/dist/source/lines.js +83 -0
  118. package/dist/source/lines.js.map +1 -0
  119. package/dist/source/markdown.d.ts +23 -0
  120. package/dist/source/markdown.d.ts.map +1 -0
  121. package/dist/source/markdown.js +42 -0
  122. package/dist/source/markdown.js.map +1 -0
  123. package/dist/source/suppressions.d.ts +38 -0
  124. package/dist/source/suppressions.d.ts.map +1 -0
  125. package/dist/source/suppressions.js +69 -0
  126. package/dist/source/suppressions.js.map +1 -0
  127. package/dist/source/tsdoc.d.ts +40 -0
  128. package/dist/source/tsdoc.d.ts.map +1 -0
  129. package/dist/source/tsdoc.js +176 -0
  130. package/dist/source/tsdoc.js.map +1 -0
  131. package/dist/text/grammar.d.ts +91 -0
  132. package/dist/text/grammar.d.ts.map +1 -0
  133. package/dist/text/grammar.js +289 -0
  134. package/dist/text/grammar.js.map +1 -0
  135. package/dist/text/phrases.d.ts +30 -0
  136. package/dist/text/phrases.d.ts.map +1 -0
  137. package/dist/text/phrases.js +65 -0
  138. package/dist/text/phrases.js.map +1 -0
  139. package/dist/text/sentences.d.ts +45 -0
  140. package/dist/text/sentences.d.ts.map +1 -0
  141. package/dist/text/sentences.js +141 -0
  142. package/dist/text/sentences.js.map +1 -0
  143. package/dist/text/tokens.d.ts +35 -0
  144. package/dist/text/tokens.d.ts.map +1 -0
  145. package/dist/text/tokens.js +113 -0
  146. package/dist/text/tokens.js.map +1 -0
  147. package/dist/text/words.d.ts +58 -0
  148. package/dist/text/words.d.ts.map +1 -0
  149. package/dist/text/words.js +159 -0
  150. package/dist/text/words.js.map +1 -0
  151. package/dist/types.d.ts +18 -0
  152. package/dist/types.d.ts.map +1 -0
  153. package/dist/types.js +20 -0
  154. package/dist/types.js.map +1 -0
  155. package/dist/units.d.ts +47 -0
  156. package/dist/units.d.ts.map +1 -0
  157. package/dist/units.js +42 -0
  158. package/dist/units.js.map +1 -0
  159. package/dist/word-list.d.ts +34 -0
  160. package/dist/word-list.d.ts.map +1 -0
  161. package/dist/word-list.js +90 -0
  162. package/dist/word-list.js.map +1 -0
  163. package/package.json +46 -3
  164. package/src/cli.ts +9 -0
  165. package/src/config.test.ts +188 -0
  166. package/src/config.ts +318 -0
  167. package/src/dictionary.test.ts +82 -0
  168. package/src/dictionary.ts +177 -0
  169. package/src/files.test.ts +107 -0
  170. package/src/files.ts +191 -0
  171. package/src/format.ts +78 -0
  172. package/src/index.test.ts +28 -0
  173. package/src/index.ts +34 -0
  174. package/src/lexicon.ts +92 -0
  175. package/src/lint.test.ts +182 -0
  176. package/src/lint.ts +135 -0
  177. package/src/rules/about-quantity.ts +33 -0
  178. package/src/rules/index.ts +40 -0
  179. package/src/rules/ing-verb.test.ts +48 -0
  180. package/src/rules/ing-verb.ts +54 -0
  181. package/src/rules/latin-abbreviation.ts +46 -0
  182. package/src/rules/missing-subject.test.ts +53 -0
  183. package/src/rules/missing-subject.ts +129 -0
  184. package/src/rules/modal-verb.test.ts +45 -0
  185. package/src/rules/modal-verb.ts +72 -0
  186. package/src/rules/noun-cluster.test.ts +49 -0
  187. package/src/rules/noun-cluster.ts +82 -0
  188. package/src/rules/paragraph-length.test.ts +30 -0
  189. package/src/rules/paragraph-length.ts +19 -0
  190. package/src/rules/passive-instruction.test.ts +38 -0
  191. package/src/rules/passive-instruction.ts +37 -0
  192. package/src/rules/pronouns.test.ts +38 -0
  193. package/src/rules/pronouns.ts +54 -0
  194. package/src/rules/punctuation.test.ts +59 -0
  195. package/src/rules/rule.ts +73 -0
  196. package/src/rules/semicolon.ts +19 -0
  197. package/src/rules/sentence-length.test.ts +52 -0
  198. package/src/rules/sentence-length.ts +32 -0
  199. package/src/rules/unknown-word.test.ts +81 -0
  200. package/src/rules/unknown-word.ts +43 -0
  201. package/src/rules/word-list-matcher.ts +68 -0
  202. package/src/rules/word-list.test.ts +110 -0
  203. package/src/rules/word-list.ts +25 -0
  204. package/src/run-cli.test.ts +189 -0
  205. package/src/run-cli.ts +193 -0
  206. package/src/source/blocks.ts +534 -0
  207. package/src/source/inline.test.ts +92 -0
  208. package/src/source/inline.ts +416 -0
  209. package/src/source/jsx.test.ts +46 -0
  210. package/src/source/jsx.ts +147 -0
  211. package/src/source/lines.test.ts +51 -0
  212. package/src/source/lines.ts +91 -0
  213. package/src/source/markdown.test.ts +210 -0
  214. package/src/source/markdown.ts +49 -0
  215. package/src/source/suppressions.ts +79 -0
  216. package/src/source/tsdoc.test.ts +137 -0
  217. package/src/source/tsdoc.ts +189 -0
  218. package/src/text/grammar.ts +304 -0
  219. package/src/text/phrases.ts +72 -0
  220. package/src/text/sentences.ts +148 -0
  221. package/src/text/text.test.ts +262 -0
  222. package/src/text/tokens.ts +135 -0
  223. package/src/text/words.ts +183 -0
  224. package/src/types.ts +39 -0
  225. package/src/units.ts +86 -0
  226. package/src/word-list.ts +128 -0
  227. package/ste.config.schema.json +87 -0
@@ -0,0 +1,534 @@
1
+ /**
2
+ * A block parser for the part of Markdown, GFM and MDX that has prose in it.
3
+ *
4
+ * The parser keeps paragraphs, headings, list items, block quotes and table
5
+ * cells. It removes fenced and indented code, HTML blocks, JSX tags, MDX
6
+ * import and export lines, MDX expressions, link reference definitions and
7
+ * thematic breaks. The TSDoc extractor uses the same parser for the text in
8
+ * a doc comment.
9
+ *
10
+ * The parser does not build a full CommonMark tree. It knows only the
11
+ * structure that the lint rules use. That is where a block starts and
12
+ * stops, and if the block is a heading, a table cell or text in a list item.
13
+ */
14
+
15
+ import { indentWidth, isBlank, sliceLine, stripIndent, type Line } from "./lines.js";
16
+
17
+ export type ListContext = {
18
+ readonly ordered : boolean;
19
+ };
20
+
21
+ /** A block of prose that the lint rules examine. */
22
+ export type ProseBlock = {
23
+ readonly kind : "paragraph" | "heading" | "table-cell";
24
+ /** The text lines of the block, without Markdown block syntax. */
25
+ readonly lines : readonly Line[];
26
+ /** The innermost list that contains the block, or null. */
27
+ readonly list : ListContext | null;
28
+ readonly quote : boolean;
29
+ /** 1 to 6 for a heading, 0 for other blocks. */
30
+ readonly headingDepth : number;
31
+ /** True for the cells of a table header row. */
32
+ readonly tableHeader : boolean;
33
+ };
34
+
35
+ export type DirectiveCommand = "disable" | "enable" | "disable-next";
36
+
37
+ /** A `ste-disable`, `ste-enable` or `ste-disable-next` comment. */
38
+ export type Directive = {
39
+ readonly kind : "directive";
40
+ readonly command : DirectiveCommand;
41
+ /** The rule IDs. An empty list means all rules. */
42
+ readonly rules : readonly string[];
43
+ readonly offset : number;
44
+ };
45
+
46
+ export type Block = ProseBlock | Directive;
47
+
48
+ export type BlockOptions = {
49
+ /** Parse JSX tags, ESM lines and `{...}` expressions as MDX does. */
50
+ readonly mdx : boolean;
51
+ };
52
+
53
+ type Context = {
54
+ readonly list : ListContext | null;
55
+ readonly quote : boolean;
56
+ };
57
+
58
+ /** HTML tags that start an HTML block (CommonMark type 6). */
59
+ const BLOCK_TAGS = new Set([
60
+ "address", "article", "aside", "base", "basefont", "blockquote", "body", "caption", "center", "col",
61
+ "colgroup", "dd", "details", "dialog", "dir", "div", "dl", "dt", "fieldset", "figcaption", "figure",
62
+ "footer", "form", "frame", "frameset", "h1", "h2", "h3", "h4", "h5", "h6", "head", "header", "hr",
63
+ "html", "iframe", "legend", "li", "link", "main", "menu", "menuitem", "nav", "noframes", "ol",
64
+ "optgroup", "option", "p", "param", "search", "section", "summary", "table", "tbody", "td",
65
+ "tfoot", "th", "thead", "title", "tr", "track", "ul",
66
+ ]);
67
+
68
+ /** HTML tags whose block stops at the closing tag, not at a blank line (CommonMark type 1). */
69
+ const RAW_TAGS = new Set(["script", "pre", "style", "textarea"]);
70
+
71
+ const DIRECTIVE = /^ste-(disable-next|disable|enable)\b(.*)$/s;
72
+
73
+ /** This function parses `lines` into prose blocks and directive comments. */
74
+ export function parseBlocks(lines : readonly Line[], options : BlockOptions) : Block[] {
75
+ const out : Block[] = [];
76
+ parseInto(lines, options, { list : null, quote : false }, out);
77
+ return out;
78
+ }
79
+
80
+ /** The directive in the text of a comment, or null. */
81
+ export function parseDirective(commentText : string, offset : number) : Directive | null {
82
+ const match = DIRECTIVE.exec(commentText.trim());
83
+ if (!match) return null;
84
+ const rest = match[2]!.split(" -- ")[0]!;
85
+ const rules = rest.split(/[\s,]+/).filter((rule) => rule.length > 0);
86
+ return { kind : "directive", command : match[1] as DirectiveCommand, rules, offset };
87
+ }
88
+
89
+ function proseBlock(kind : ProseBlock["kind"], lines : readonly Line[], context : Context, headingDepth : number = 0, tableHeader : boolean = false) : ProseBlock {
90
+ return { kind, lines, list : context.list, quote : context.quote, headingDepth, tableHeader };
91
+ }
92
+
93
+ function parseInto(lines : readonly Line[], options : BlockOptions, context : Context, out : Block[]) : void {
94
+ let paragraph : Line[] = [];
95
+ const flush = () : void => {
96
+ if (paragraph.length > 0) out.push(proseBlock("paragraph", paragraph, context));
97
+ paragraph = [];
98
+ };
99
+
100
+ let i = 0;
101
+ while (i < lines.length) {
102
+ const line = lines[i]!;
103
+ if (isBlank(line.text)) {
104
+ flush();
105
+ i++;
106
+ continue;
107
+ }
108
+ const indent = indentWidth(line.text);
109
+ const inParagraph = paragraph.length > 0;
110
+ if (!inParagraph && indent >= 4) {
111
+ i = skipIndentedCode(lines, i);
112
+ continue;
113
+ }
114
+ const body = stripIndent(line, 3);
115
+ const text = body.text;
116
+
117
+ const fence = fenceOpening(text);
118
+ if (fence !== null) {
119
+ flush();
120
+ i = skipFence(lines, i, fence);
121
+ continue;
122
+ }
123
+
124
+ const atx = /^(#{1,6})(?:[ \t]+|$)/.exec(text);
125
+ if (atx) {
126
+ flush();
127
+ out.push(proseBlock("heading", [headingContent(body, atx[0].length)], context, atx[1]!.length));
128
+ i++;
129
+ continue;
130
+ }
131
+
132
+ if (inParagraph && /^(?:=+|-+)[ \t]*$/.test(text)) {
133
+ out.push(proseBlock("heading", paragraph, context, text.startsWith("=") ? 1 : 2));
134
+ paragraph = [];
135
+ i++;
136
+ continue;
137
+ }
138
+
139
+ if (isThematicBreak(text)) {
140
+ flush();
141
+ i++;
142
+ continue;
143
+ }
144
+
145
+ if (text.startsWith(">")) {
146
+ flush();
147
+ i = parseQuote(lines, i, options, context, out);
148
+ continue;
149
+ }
150
+
151
+ const marker = listMarker(text);
152
+ if (marker !== null && (!inParagraph || canInterruptParagraph(marker))) {
153
+ flush();
154
+ i = parseListItem(lines, i, options, context, out);
155
+ continue;
156
+ }
157
+
158
+ if (text.startsWith("<!--")) {
159
+ flush();
160
+ i = parseHtmlComment(lines, i, out);
161
+ continue;
162
+ }
163
+
164
+ if (text.startsWith("<")) {
165
+ const next = parseTagLine(lines, i, body, inParagraph, options);
166
+ if (next !== null) {
167
+ flush();
168
+ if (next.rest !== null) paragraph.push(next.rest);
169
+ i = next.index;
170
+ continue;
171
+ }
172
+ }
173
+
174
+ if (options.mdx && !inParagraph && /^(?:import|export)\b/.test(text)) {
175
+ i = skipUntilBlank(lines, i);
176
+ continue;
177
+ }
178
+
179
+ if (options.mdx && text.startsWith("{")) {
180
+ flush();
181
+ i = parseExpressionBlock(lines, i, body, out);
182
+ continue;
183
+ }
184
+
185
+ if (text.startsWith(":::")) {
186
+ flush();
187
+ i++;
188
+ continue;
189
+ }
190
+
191
+ if (!inParagraph && /^\[[^\]]+\]:/.test(text)) {
192
+ i++;
193
+ continue;
194
+ }
195
+
196
+ if (text.includes("|") && i + 1 < lines.length && isDelimiterRow(lines[i + 1]!.text)) {
197
+ flush();
198
+ i = parseTable(lines, i, context, out);
199
+ continue;
200
+ }
201
+
202
+ paragraph.push(sliceLine(body, body.text.length - body.text.trimStart().length));
203
+ i++;
204
+ }
205
+ flush();
206
+ }
207
+
208
+ type Fence = { readonly char : string; readonly length : number };
209
+
210
+ function fenceOpening(text : string) : Fence | null {
211
+ const match = /^(`{3,}|~{3,})(.*)$/.exec(text);
212
+ if (!match) return null;
213
+ const run = match[1]!;
214
+ // A backtick fence cannot have a backtick in its info string.
215
+ if (run[0] === "`" && match[2]!.includes("`")) return null;
216
+ return { char : run[0]!, length : run.length };
217
+ }
218
+
219
+ function skipFence(lines : readonly Line[], start : number, fence : Fence) : number {
220
+ for (let i = start + 1; i < lines.length; i++) {
221
+ const text = stripIndent(lines[i]!, 3).text;
222
+ const match = /^(`+|~+)[ \t]*$/.exec(text);
223
+ if (match && match[1]![0] === fence.char && match[1]!.length >= fence.length) return i + 1;
224
+ }
225
+ return lines.length;
226
+ }
227
+
228
+ function skipIndentedCode(lines : readonly Line[], start : number) : number {
229
+ let i = start;
230
+ while (i < lines.length && (isBlank(lines[i]!.text) || indentWidth(lines[i]!.text) >= 4)) i++;
231
+ return i;
232
+ }
233
+
234
+ function skipUntilBlank(lines : readonly Line[], start : number) : number {
235
+ let i = start;
236
+ while (i < lines.length && !isBlank(lines[i]!.text)) i++;
237
+ return i;
238
+ }
239
+
240
+ function headingContent(body : Line, markerLength : number) : Line {
241
+ const content = sliceLine(body, markerLength);
242
+ const closing = /(?:^|[ \t]+)#+[ \t]*$/.exec(content.text);
243
+ const text = closing ? content.text.slice(0, closing.index) : content.text;
244
+ return { text : text.trimEnd(), offset : content.offset };
245
+ }
246
+
247
+ function isThematicBreak(text : string) : boolean {
248
+ return /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/.test(text);
249
+ }
250
+
251
+ type Marker = {
252
+ readonly ordered : boolean;
253
+ readonly start : number;
254
+ /** The length of the marker, for example 1 for `-` and 2 for `1.`. */
255
+ readonly length : number;
256
+ /** The number of spaces after the marker. */
257
+ readonly spaces : number;
258
+ readonly empty : boolean;
259
+ };
260
+
261
+ function listMarker(text : string) : Marker | null {
262
+ const match = /^(?:([-*+])|(\d{1,9})[.)])([ \t]+|$)/.exec(text);
263
+ if (!match) return null;
264
+ const ordered = match[2] !== undefined;
265
+ const length = ordered ? match[2]!.length + 1 : 1;
266
+ const empty = text.slice(length).trim().length === 0;
267
+ return { ordered, start : ordered ? Number(match[2]) : 0, length, spaces : match[3]!.length, empty };
268
+ }
269
+
270
+ /** A list can interrupt a paragraph only when it is not empty and an ordered list starts at 1 (CommonMark). */
271
+ function canInterruptParagraph(marker : Marker) : boolean {
272
+ return !marker.empty && (!marker.ordered || marker.start === 1);
273
+ }
274
+
275
+ /** True when `text` starts a block that stops a lazy continuation line. */
276
+ function startsBlock(text : string) : boolean {
277
+ return fenceOpening(text) !== null
278
+ || /^#{1,6}(?:[ \t]|$)/.test(text)
279
+ || text.startsWith(">")
280
+ || isThematicBreak(text)
281
+ || listMarker(text) !== null
282
+ || /^<(?:!--|\/?[A-Za-z])/.test(text);
283
+ }
284
+
285
+ function parseQuote(lines : readonly Line[], start : number, options : BlockOptions, context : Context, out : Block[]) : number {
286
+ const quoted : Line[] = [];
287
+ let i = start;
288
+ while (i < lines.length) {
289
+ const line = lines[i]!;
290
+ const body = stripIndent(line, 3);
291
+ if (body.text.startsWith(">")) {
292
+ let inner = sliceLine(body, 1);
293
+ if (inner.text.startsWith(" ")) inner = sliceLine(inner, 1);
294
+ quoted.push(inner);
295
+ i++;
296
+ continue;
297
+ }
298
+ const previous = quoted[quoted.length - 1];
299
+ if (!isBlank(line.text) && previous !== undefined && !isBlank(previous.text) && !startsBlock(body.text)) {
300
+ quoted.push(body);
301
+ i++;
302
+ continue;
303
+ }
304
+ break;
305
+ }
306
+ // A GitHub alert marker, for example `[!NOTE]`, is not prose.
307
+ const first = quoted[0];
308
+ if (first !== undefined && /^\[![A-Za-z]+\]\s*$/.test(first.text)) quoted.shift();
309
+ parseInto(quoted, options, { list : context.list, quote : true }, out);
310
+ return i;
311
+ }
312
+
313
+ function parseListItem(lines : readonly Line[], start : number, options : BlockOptions, context : Context, out : Block[]) : number {
314
+ const first = lines[start]!;
315
+ const markerIndent = indentWidth(first.text);
316
+ const atMarker = stripIndent(first, markerIndent);
317
+ const marker = listMarker(atMarker.text)!;
318
+ const spaces = marker.empty || marker.spaces > 4 ? 1 : marker.spaces;
319
+ const contentColumn = markerIndent + marker.length + spaces;
320
+ const itemLines : Line[] = [sliceLine(atMarker, marker.length + Math.min(marker.spaces, spaces))];
321
+
322
+ let i = start + 1;
323
+ let previousBlank = marker.empty;
324
+ while (i < lines.length) {
325
+ const line = lines[i]!;
326
+ if (isBlank(line.text)) {
327
+ itemLines.push(line);
328
+ previousBlank = true;
329
+ i++;
330
+ continue;
331
+ }
332
+ const indent = indentWidth(line.text);
333
+ if (indent >= contentColumn) {
334
+ itemLines.push(stripIndent(line, contentColumn));
335
+ previousBlank = false;
336
+ i++;
337
+ continue;
338
+ }
339
+ if (!previousBlank && !startsBlock(stripIndent(line, 3).text) && !(i + 1 < lines.length && isDelimiterRow(lines[i + 1]!.text))) {
340
+ itemLines.push(stripIndent(line, indent));
341
+ i++;
342
+ continue;
343
+ }
344
+ break;
345
+ }
346
+ parseInto(itemLines, options, { list : { ordered : marker.ordered }, quote : context.quote }, out);
347
+ return i;
348
+ }
349
+
350
+ function parseHtmlComment(lines : readonly Line[], start : number, out : Block[]) : number {
351
+ const first = stripIndent(lines[start]!, 3);
352
+ let text = first.text.slice(4);
353
+ let i = start;
354
+ while (!text.includes("-->") && i + 1 < lines.length) {
355
+ i++;
356
+ text += "\n" + lines[i]!.text;
357
+ }
358
+ const directive = parseDirective(text.split("-->")[0]!, first.offset);
359
+ if (directive !== null) out.push(directive);
360
+ return i + 1;
361
+ }
362
+
363
+ type TagLineResult = {
364
+ /** The index of the next line to parse. */
365
+ readonly index : number;
366
+ /** Text after a JSX tag on the same line, which is prose. */
367
+ readonly rest : Line | null;
368
+ };
369
+
370
+ /**
371
+ * This function handles a line that starts with `<`. The result is null when
372
+ * the line is prose with inline HTML.
373
+ */
374
+ function parseTagLine(lines : readonly Line[], start : number, body : Line, inParagraph : boolean, options : BlockOptions) : TagLineResult | null {
375
+ const text = body.text;
376
+ const tag = /^<(\/?)([A-Za-z][A-Za-z0-9.:_-]*)(?=[\s/>]|$)/.exec(text);
377
+ const fragment = /^<\/?>/.test(text);
378
+ if (tag === null && !fragment) return null;
379
+
380
+ if (options.mdx) {
381
+ const end = findTagEnd(lines, start, body.offset - lines[start]!.offset);
382
+ const endLine = lines[end.line]!;
383
+ const rest = sliceLine(endLine, end.column);
384
+ // A line with only a tag is a JSX block, also after paragraph text. A tag with text after it in a paragraph is inline JSX.
385
+ if (isBlank(rest.text) || rest.text.trimStart().startsWith("<")) return { index : end.line + 1, rest : null };
386
+ if (inParagraph) return null;
387
+ return { index : end.line + 1, rest : sliceLine(rest, rest.text.length - rest.text.trimStart().length) };
388
+ }
389
+
390
+ const name = tag === null ? "" : tag[2]!.toLowerCase();
391
+ if (tag !== null && tag[1] === "" && RAW_TAGS.has(name)) {
392
+ let i = start;
393
+ while (i < lines.length && !lines[i]!.text.toLowerCase().includes(`</${name}>`)) i++;
394
+ return { index : Math.min(i + 1, lines.length), rest : null };
395
+ }
396
+ if (BLOCK_TAGS.has(name)) return { index : skipUntilBlank(lines, start), rest : null };
397
+ // CommonMark type 7: a line with only a complete tag, which cannot interrupt a paragraph.
398
+ if (!inParagraph && /^<\/?[A-Za-z][^<>]*>\s*$/.test(text)) return { index : skipUntilBlank(lines, start), rest : null };
399
+ return null;
400
+ }
401
+
402
+ type TagEnd = { readonly line : number; readonly column : number };
403
+
404
+ /** This function finds the `>` that closes a JSX tag. JSX can continue on the lines after the tag start. */
405
+ function findTagEnd(lines : readonly Line[], startLine : number, startColumn : number) : TagEnd {
406
+ let quote : string | null = null;
407
+ let depth = 0;
408
+ for (let l = startLine; l < lines.length; l++) {
409
+ const text = lines[l]!.text;
410
+ for (let c = l === startLine ? startColumn + 1 : 0; c < text.length; c++) {
411
+ const char = text[c]!;
412
+ if (quote !== null) {
413
+ if (char === quote) quote = null;
414
+ } else if (char === "\"" || char === "'" || char === "`") {
415
+ quote = char;
416
+ } else if (char === "{") {
417
+ depth++;
418
+ } else if (char === "}") {
419
+ depth = Math.max(0, depth - 1);
420
+ } else if (char === ">" && depth === 0) {
421
+ return { line : l, column : c + 1 };
422
+ }
423
+ }
424
+ }
425
+ return { line : lines.length - 1, column : lines[lines.length - 1]!.text.length };
426
+ }
427
+
428
+ /** This function skips an MDX `{...}` expression, and keeps a directive comment in it. */
429
+ function parseExpressionBlock(lines : readonly Line[], start : number, body : Line, out : Block[]) : number {
430
+ let depth = 0;
431
+ let quote : string | null = null;
432
+ let collected = "";
433
+ for (let l = start; l < lines.length; l++) {
434
+ const text = l === start ? body.text : lines[l]!.text;
435
+ for (let c = 0; c < text.length; c++) {
436
+ const char = text[c]!;
437
+ collected += char;
438
+ if (quote !== null) {
439
+ if (char === quote) quote = null;
440
+ } else if (char === "\"" || char === "'" || char === "`") {
441
+ quote = char;
442
+ } else if (char === "{") {
443
+ depth++;
444
+ } else if (char === "}") {
445
+ depth--;
446
+ if (depth === 0) {
447
+ const comment = /^\{\s*\/\*([\s\S]*)\*\/\s*\}$/.exec(collected);
448
+ if (comment) {
449
+ const directive = parseDirective(comment[1]!, body.offset);
450
+ if (directive !== null) out.push(directive);
451
+ }
452
+ return l + 1;
453
+ }
454
+ }
455
+ }
456
+ collected += "\n";
457
+ }
458
+ return lines.length;
459
+ }
460
+
461
+ /** True for a GFM table delimiter row, for example `| --- | :-: |`. */
462
+ export function isDelimiterRow(text : string) : boolean {
463
+ return text.includes("|") && /^\s*\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*)*\|?\s*$/.test(text);
464
+ }
465
+
466
+ /** This function divides a table row into cells. A `|` in a code span or after `\` does not divide cells. */
467
+ export function splitCells(row : Line) : Line[] {
468
+ const text = row.text;
469
+ const cells : Line[] = [];
470
+ let cellStart = 0;
471
+ let i = 0;
472
+ const pushCell = (end : number) : void => {
473
+ const raw = text.slice(cellStart, end);
474
+ const lead = raw.length - raw.trimStart().length;
475
+ cells.push({ text : raw.trim(), offset : row.offset + cellStart + lead });
476
+ };
477
+ while (i < text.length) {
478
+ const char = text[i]!;
479
+ if (char === "\\") {
480
+ i += 2;
481
+ continue;
482
+ }
483
+ if (char === "`") {
484
+ let run = 1;
485
+ while (text[i + run] === "`") run++;
486
+ const close = findBacktickRun(text, i + run, run);
487
+ i = close === -1 ? i + run : close + run;
488
+ continue;
489
+ }
490
+ if (char === "|") {
491
+ pushCell(i);
492
+ cellStart = i + 1;
493
+ }
494
+ i++;
495
+ }
496
+ pushCell(text.length);
497
+ // A leading or trailing pipe makes an empty cell at the edge. That cell is not part of the table.
498
+ if (text.trimStart().startsWith("|")) cells.shift();
499
+ if (text.trimEnd().endsWith("|") && !text.trimEnd().endsWith("\\|")) cells.pop();
500
+ return cells;
501
+ }
502
+
503
+ /** The index of the next run of exactly `length` backticks at or after `from`, or -1. */
504
+ export function findBacktickRun(text : string, from : number, length : number) : number {
505
+ let i = from;
506
+ while (i < text.length) {
507
+ if (text[i] === "`") {
508
+ let run = 1;
509
+ while (text[i + run] === "`") run++;
510
+ if (run === length) return i;
511
+ i += run;
512
+ } else {
513
+ i++;
514
+ }
515
+ }
516
+ return -1;
517
+ }
518
+
519
+ function parseTable(lines : readonly Line[], start : number, context : Context, out : Block[]) : number {
520
+ const emitRow = (line : Line, header : boolean) : void => {
521
+ for (const cell of splitCells(stripIndent(line, 3))) {
522
+ if (cell.text.length > 0) out.push(proseBlock("table-cell", [cell], context, 0, header));
523
+ }
524
+ };
525
+ emitRow(lines[start]!, true);
526
+ let i = start + 2;
527
+ while (i < lines.length) {
528
+ const line = lines[i]!;
529
+ if (isBlank(line.text) || !line.text.includes("|") || startsBlock(stripIndent(line, 3).text)) break;
530
+ emitRow(line, false);
531
+ i++;
532
+ }
533
+ return i;
534
+ }
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { normalizeInline, PLACEHOLDER, type InlineOptions } from "./inline.js";
3
+ import { splitLines } from "./lines.js";
4
+
5
+ const markdown : InlineOptions = { mdx : false, tsdoc : false };
6
+
7
+ /** The clean text, with each placeholder shown as `[kind:text]`. */
8
+ function clean(text : string, options : InlineOptions = markdown) : string {
9
+ const inline = normalizeInline(splitLines(text), options);
10
+ let out = "";
11
+ for (let i = 0; i < inline.clean.length; i++) {
12
+ const placeholder = inline.placeholders.get(i);
13
+ out += placeholder === undefined ? inline.clean[i] : `[${placeholder.kind}:${placeholder.text}]`;
14
+ }
15
+ return out;
16
+ }
17
+
18
+ describe("normalizeInline", () => {
19
+ it("makes each code span one placeholder", () => {
20
+ expect(clean("Use `pnpm -r build` and ``a ` b`` now.")).toBe("Use [code:pnpm -r build] and [code:a ` b] now.");
21
+ });
22
+
23
+ it("keeps backticks without a partner", () => {
24
+ expect(clean("A ` alone")).toBe("A ` alone");
25
+ });
26
+
27
+ it("keeps link text, and removes the destination, images and footnote references", () => {
28
+ expect(clean("Read [the `guide`](https://x.dev/a_(b) \"Title (x)\") now![^1] ![logo](logo.png)")).toBe("Read the [code:guide] now! ");
29
+ expect(clean("See [the docs][docs] and [this][].")).toBe("See the docs and this.");
30
+ expect(clean("A [bracket] alone.")).toBe("A [bracket] alone.");
31
+ });
32
+
33
+ it("makes URLs and autolinks one placeholder", () => {
34
+ expect(clean("Go to https://example.com/a(b). Or <https://x.dev>.")).toBe("Go to [url:https://example.com/a(b)]. Or [url:https://x.dev].");
35
+ expect(clean("Mail <me@example.com>, or www.example.com.")).toBe("Mail [url:me@example.com], or [url:www.example.com].");
36
+ expect(clean("(https://example.com)")).toBe("([url:https://example.com])");
37
+ });
38
+
39
+ it("removes HTML tags and comments, and makes code elements placeholders", () => {
40
+ expect(clean("Press <kbd>Ctrl</kbd> and <b>Save</b>.<br/>Next <!-- note --> line.")).toBe("Press [code:Ctrl] and Save. Next line.");
41
+ expect(clean("A <x-foo a=\"1 > 2\">tag</x-foo>.")).toBe("A tag.");
42
+ expect(clean("Compare a < b and x<y.")).toBe("Compare a < b and x<y.");
43
+ });
44
+
45
+ it("removes emphasis markers but keeps snake_case and spaced asterisks", () => {
46
+ expect(clean("**Bold** and *it* and _em_ and ~~old~~ and snake_case and 2 * 3.")).toBe("Bold and it and em and old and snake_case and 2 * 3.");
47
+ });
48
+
49
+ it("removes the backslash of an escape", () => {
50
+ expect(clean("Not \\*emphasis\\* and a\\\\b.")).toBe("Not *emphasis* and a\\b.");
51
+ });
52
+
53
+ it("decodes HTML entities, and ignores names that are not entities", () => {
54
+ expect(clean("A&nbsp;B &amp; C &#65; &#x42; &unknown; &constructor;")).toBe("A B & C A B &unknown; &constructor;");
55
+ });
56
+
57
+ it("gives each UTF-16 unit of a decoded character outside the BMP an offset", () => {
58
+ const text = "A &#x1F600; and &#128512; b.";
59
+ const inline = normalizeInline(splitLines(text), markdown);
60
+ expect(inline.clean).toBe("A \u{1F600} and \u{1F600} b.");
61
+ expect(inline.offsets).toHaveLength(inline.clean.length);
62
+ expect(inline.offsets[inline.clean.indexOf("b")]).toBe(text.indexOf("b"));
63
+ });
64
+
65
+ it("makes quoted text one placeholder and keeps a final period outside it", () => {
66
+ expect(clean("The word \"should\" is a modal. He said “Stop.” Then left.")).toBe("The word [quote:should] is a modal. He said [quote:Stop.]. Then left.");
67
+ expect(clean("Use 'single quotes' here, but it's fine.")).toBe("Use [quote:single quotes] here, but it's fine.");
68
+ });
69
+
70
+ it("does not pair quotes that are apostrophes or inch marks", () => {
71
+ expect(clean("The users' settings and a 5\" disk.")).toBe("The users' settings and a 5\" disk.");
72
+ expect(clean("A \" lone quote.")).toBe("A \" lone quote.");
73
+ });
74
+
75
+ it("makes TSDoc inline tags one placeholder only in doc comments", () => {
76
+ expect(clean("Refer to {@link Foo | the foo}.", { mdx : false, tsdoc : true })).toBe("Refer to [link:@link Foo | the foo].");
77
+ expect(clean("A {literal} brace.")).toBe("A {literal} brace.");
78
+ });
79
+
80
+ it("makes MDX expressions placeholders and removes MDX comments", () => {
81
+ expect(clean("Value {props.x} here {/* note */}.", { mdx : true, tsdoc : false })).toBe("Value [expr:props.x] here .");
82
+ expect(clean("Open {brace", { mdx : true, tsdoc : false })).toBe("Open {brace");
83
+ });
84
+
85
+ it("keeps the file offset of each character", () => {
86
+ const lines = splitLines("ab\n`c` d", 100);
87
+ const inline = normalizeInline(lines, markdown);
88
+ expect(inline.clean).toBe(`ab\n${PLACEHOLDER} d`);
89
+ expect(inline.offsets).toEqual([100, 101, 102, 103, 106, 107]);
90
+ expect(inline.placeholders.get(3)).toEqual({ kind : "code", text : "c", start : 103, end : 106 });
91
+ });
92
+ });