@mark1russell7/ste-lint 0.0.0-stage → 0.1.2

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 (253) hide show
  1. package/README.md +246 -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-core.d.ts +92 -0
  8. package/dist/config-core.d.ts.map +1 -0
  9. package/dist/config-core.js +198 -0
  10. package/dist/config-core.js.map +1 -0
  11. package/dist/config.d.ts +29 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +68 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/core.d.ts +16 -0
  16. package/dist/core.d.ts.map +1 -0
  17. package/dist/core.js +16 -0
  18. package/dist/core.js.map +1 -0
  19. package/dist/dictionary-core.d.ts +72 -0
  20. package/dist/dictionary-core.d.ts.map +1 -0
  21. package/dist/dictionary-core.js +150 -0
  22. package/dist/dictionary-core.js.map +1 -0
  23. package/dist/dictionary.d.ts +9 -0
  24. package/dist/dictionary.d.ts.map +1 -0
  25. package/dist/dictionary.js +26 -0
  26. package/dist/dictionary.js.map +1 -0
  27. package/dist/engine.d.ts +44 -0
  28. package/dist/engine.d.ts.map +1 -0
  29. package/dist/engine.js +122 -0
  30. package/dist/engine.js.map +1 -0
  31. package/dist/files.d.ts +35 -0
  32. package/dist/files.d.ts.map +1 -0
  33. package/dist/files.js +181 -0
  34. package/dist/files.js.map +1 -0
  35. package/dist/format.d.ts +21 -0
  36. package/dist/format.d.ts.map +1 -0
  37. package/dist/format.js +61 -0
  38. package/dist/format.js.map +1 -0
  39. package/dist/index.d.ts +19 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +19 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/lexicon.d.ts +33 -0
  44. package/dist/lexicon.d.ts.map +1 -0
  45. package/dist/lexicon.js +71 -0
  46. package/dist/lexicon.js.map +1 -0
  47. package/dist/lint.d.ts +18 -0
  48. package/dist/lint.d.ts.map +1 -0
  49. package/dist/lint.js +23 -0
  50. package/dist/lint.js.map +1 -0
  51. package/dist/rules/about-quantity.d.ts +3 -0
  52. package/dist/rules/about-quantity.d.ts.map +1 -0
  53. package/dist/rules/about-quantity.js +34 -0
  54. package/dist/rules/about-quantity.js.map +1 -0
  55. package/dist/rules/index.d.ts +8 -0
  56. package/dist/rules/index.d.ts.map +1 -0
  57. package/dist/rules/index.js +35 -0
  58. package/dist/rules/index.js.map +1 -0
  59. package/dist/rules/ing-verb.d.ts +9 -0
  60. package/dist/rules/ing-verb.d.ts.map +1 -0
  61. package/dist/rules/ing-verb.js +60 -0
  62. package/dist/rules/ing-verb.js.map +1 -0
  63. package/dist/rules/latin-abbreviation.d.ts +3 -0
  64. package/dist/rules/latin-abbreviation.d.ts.map +1 -0
  65. package/dist/rules/latin-abbreviation.js +48 -0
  66. package/dist/rules/latin-abbreviation.js.map +1 -0
  67. package/dist/rules/missing-subject.d.ts +16 -0
  68. package/dist/rules/missing-subject.d.ts.map +1 -0
  69. package/dist/rules/missing-subject.js +138 -0
  70. package/dist/rules/missing-subject.js.map +1 -0
  71. package/dist/rules/modal-verb.d.ts +3 -0
  72. package/dist/rules/modal-verb.d.ts.map +1 -0
  73. package/dist/rules/modal-verb.js +72 -0
  74. package/dist/rules/modal-verb.js.map +1 -0
  75. package/dist/rules/noun-cluster.d.ts +17 -0
  76. package/dist/rules/noun-cluster.d.ts.map +1 -0
  77. package/dist/rules/noun-cluster.js +83 -0
  78. package/dist/rules/noun-cluster.js.map +1 -0
  79. package/dist/rules/paragraph-length.d.ts +3 -0
  80. package/dist/rules/paragraph-length.d.ts.map +1 -0
  81. package/dist/rules/paragraph-length.js +19 -0
  82. package/dist/rules/paragraph-length.js.map +1 -0
  83. package/dist/rules/passive-instruction.d.ts +15 -0
  84. package/dist/rules/passive-instruction.d.ts.map +1 -0
  85. package/dist/rules/passive-instruction.js +38 -0
  86. package/dist/rules/passive-instruction.js.map +1 -0
  87. package/dist/rules/pronouns.d.ts +4 -0
  88. package/dist/rules/pronouns.d.ts.map +1 -0
  89. package/dist/rules/pronouns.js +57 -0
  90. package/dist/rules/pronouns.js.map +1 -0
  91. package/dist/rules/rule.d.ts +46 -0
  92. package/dist/rules/rule.d.ts.map +1 -0
  93. package/dist/rules/rule.js +31 -0
  94. package/dist/rules/rule.js.map +1 -0
  95. package/dist/rules/semicolon.d.ts +3 -0
  96. package/dist/rules/semicolon.d.ts.map +1 -0
  97. package/dist/rules/semicolon.js +20 -0
  98. package/dist/rules/semicolon.js.map +1 -0
  99. package/dist/rules/sentence-length.d.ts +3 -0
  100. package/dist/rules/sentence-length.d.ts.map +1 -0
  101. package/dist/rules/sentence-length.js +31 -0
  102. package/dist/rules/sentence-length.js.map +1 -0
  103. package/dist/rules/unknown-word.d.ts +9 -0
  104. package/dist/rules/unknown-word.d.ts.map +1 -0
  105. package/dist/rules/unknown-word.js +49 -0
  106. package/dist/rules/unknown-word.js.map +1 -0
  107. package/dist/rules/word-list-matcher.d.ts +25 -0
  108. package/dist/rules/word-list-matcher.d.ts.map +1 -0
  109. package/dist/rules/word-list-matcher.js +60 -0
  110. package/dist/rules/word-list-matcher.js.map +1 -0
  111. package/dist/rules/word-list.d.ts +3 -0
  112. package/dist/rules/word-list.d.ts.map +1 -0
  113. package/dist/rules/word-list.js +26 -0
  114. package/dist/rules/word-list.js.map +1 -0
  115. package/dist/run-cli.d.ts +27 -0
  116. package/dist/run-cli.d.ts.map +1 -0
  117. package/dist/run-cli.js +176 -0
  118. package/dist/run-cli.js.map +1 -0
  119. package/dist/source/blocks.d.ts +55 -0
  120. package/dist/source/blocks.d.ts.map +1 -0
  121. package/dist/source/blocks.js +464 -0
  122. package/dist/source/blocks.js.map +1 -0
  123. package/dist/source/code.d.ts +9 -0
  124. package/dist/source/code.d.ts.map +1 -0
  125. package/dist/source/code.js +16 -0
  126. package/dist/source/code.js.map +1 -0
  127. package/dist/source/inline.d.ts +42 -0
  128. package/dist/source/inline.d.ts.map +1 -0
  129. package/dist/source/inline.js +396 -0
  130. package/dist/source/inline.js.map +1 -0
  131. package/dist/source/jsx.d.ts +18 -0
  132. package/dist/source/jsx.d.ts.map +1 -0
  133. package/dist/source/jsx.js +137 -0
  134. package/dist/source/jsx.js.map +1 -0
  135. package/dist/source/lines.d.ts +34 -0
  136. package/dist/source/lines.d.ts.map +1 -0
  137. package/dist/source/lines.js +83 -0
  138. package/dist/source/lines.js.map +1 -0
  139. package/dist/source/markdown.d.ts +23 -0
  140. package/dist/source/markdown.d.ts.map +1 -0
  141. package/dist/source/markdown.js +42 -0
  142. package/dist/source/markdown.js.map +1 -0
  143. package/dist/source/suppressions.d.ts +38 -0
  144. package/dist/source/suppressions.d.ts.map +1 -0
  145. package/dist/source/suppressions.js +69 -0
  146. package/dist/source/suppressions.js.map +1 -0
  147. package/dist/source/tsdoc.d.ts +40 -0
  148. package/dist/source/tsdoc.d.ts.map +1 -0
  149. package/dist/source/tsdoc.js +176 -0
  150. package/dist/source/tsdoc.js.map +1 -0
  151. package/dist/text/grammar.d.ts +91 -0
  152. package/dist/text/grammar.d.ts.map +1 -0
  153. package/dist/text/grammar.js +289 -0
  154. package/dist/text/grammar.js.map +1 -0
  155. package/dist/text/phrases.d.ts +30 -0
  156. package/dist/text/phrases.d.ts.map +1 -0
  157. package/dist/text/phrases.js +65 -0
  158. package/dist/text/phrases.js.map +1 -0
  159. package/dist/text/sentences.d.ts +45 -0
  160. package/dist/text/sentences.d.ts.map +1 -0
  161. package/dist/text/sentences.js +141 -0
  162. package/dist/text/sentences.js.map +1 -0
  163. package/dist/text/tokens.d.ts +35 -0
  164. package/dist/text/tokens.d.ts.map +1 -0
  165. package/dist/text/tokens.js +113 -0
  166. package/dist/text/tokens.js.map +1 -0
  167. package/dist/text/words.d.ts +58 -0
  168. package/dist/text/words.d.ts.map +1 -0
  169. package/dist/text/words.js +159 -0
  170. package/dist/text/words.js.map +1 -0
  171. package/dist/types.d.ts +18 -0
  172. package/dist/types.d.ts.map +1 -0
  173. package/dist/types.js +20 -0
  174. package/dist/types.js.map +1 -0
  175. package/dist/units.d.ts +47 -0
  176. package/dist/units.d.ts.map +1 -0
  177. package/dist/units.js +42 -0
  178. package/dist/units.js.map +1 -0
  179. package/dist/word-list.d.ts +34 -0
  180. package/dist/word-list.d.ts.map +1 -0
  181. package/dist/word-list.js +90 -0
  182. package/dist/word-list.js.map +1 -0
  183. package/package.json +54 -3
  184. package/src/cli.ts +9 -0
  185. package/src/config-core.ts +258 -0
  186. package/src/config.test.ts +188 -0
  187. package/src/config.ts +83 -0
  188. package/src/core.test.ts +87 -0
  189. package/src/core.ts +32 -0
  190. package/src/dictionary-core.ts +158 -0
  191. package/src/dictionary.test.ts +82 -0
  192. package/src/dictionary.ts +26 -0
  193. package/src/engine.ts +138 -0
  194. package/src/files.test.ts +107 -0
  195. package/src/files.ts +191 -0
  196. package/src/format.ts +78 -0
  197. package/src/index.test.ts +28 -0
  198. package/src/index.ts +38 -0
  199. package/src/lexicon.ts +92 -0
  200. package/src/lint.test.ts +182 -0
  201. package/src/lint.ts +30 -0
  202. package/src/rules/about-quantity.ts +33 -0
  203. package/src/rules/index.ts +40 -0
  204. package/src/rules/ing-verb.test.ts +48 -0
  205. package/src/rules/ing-verb.ts +54 -0
  206. package/src/rules/latin-abbreviation.ts +46 -0
  207. package/src/rules/missing-subject.test.ts +53 -0
  208. package/src/rules/missing-subject.ts +129 -0
  209. package/src/rules/modal-verb.test.ts +45 -0
  210. package/src/rules/modal-verb.ts +72 -0
  211. package/src/rules/noun-cluster.test.ts +49 -0
  212. package/src/rules/noun-cluster.ts +82 -0
  213. package/src/rules/paragraph-length.test.ts +30 -0
  214. package/src/rules/paragraph-length.ts +19 -0
  215. package/src/rules/passive-instruction.test.ts +38 -0
  216. package/src/rules/passive-instruction.ts +37 -0
  217. package/src/rules/pronouns.test.ts +38 -0
  218. package/src/rules/pronouns.ts +54 -0
  219. package/src/rules/punctuation.test.ts +59 -0
  220. package/src/rules/rule.ts +73 -0
  221. package/src/rules/semicolon.ts +19 -0
  222. package/src/rules/sentence-length.test.ts +52 -0
  223. package/src/rules/sentence-length.ts +32 -0
  224. package/src/rules/unknown-word.test.ts +81 -0
  225. package/src/rules/unknown-word.ts +43 -0
  226. package/src/rules/word-list-matcher.ts +68 -0
  227. package/src/rules/word-list.test.ts +110 -0
  228. package/src/rules/word-list.ts +25 -0
  229. package/src/run-cli.test.ts +189 -0
  230. package/src/run-cli.ts +193 -0
  231. package/src/source/blocks.ts +534 -0
  232. package/src/source/code.ts +17 -0
  233. package/src/source/inline.test.ts +92 -0
  234. package/src/source/inline.ts +416 -0
  235. package/src/source/jsx.test.ts +46 -0
  236. package/src/source/jsx.ts +147 -0
  237. package/src/source/lines.test.ts +51 -0
  238. package/src/source/lines.ts +91 -0
  239. package/src/source/markdown.test.ts +210 -0
  240. package/src/source/markdown.ts +49 -0
  241. package/src/source/suppressions.ts +79 -0
  242. package/src/source/tsdoc.test.ts +137 -0
  243. package/src/source/tsdoc.ts +189 -0
  244. package/src/text/grammar.ts +304 -0
  245. package/src/text/phrases.ts +72 -0
  246. package/src/text/sentences.ts +148 -0
  247. package/src/text/text.test.ts +262 -0
  248. package/src/text/tokens.ts +135 -0
  249. package/src/text/words.ts +183 -0
  250. package/src/types.ts +39 -0
  251. package/src/units.ts +86 -0
  252. package/src/word-list.ts +128 -0
  253. package/ste.config.schema.json +87 -0
@@ -0,0 +1,91 @@
1
+ /** A line of source text, and the file offset of its first character. */
2
+ export type Line = {
3
+ readonly text : string;
4
+ readonly offset : number;
5
+ };
6
+
7
+ /**
8
+ * This function divides `text` into lines. The offset of each line starts at
9
+ * `base`. A `\r` before a `\n` is not part of the line text.
10
+ */
11
+ export function splitLines(text : string, base : number = 0) : Line[] {
12
+ const lines : Line[] = [];
13
+ let start = 0;
14
+ for (let i = 0; i <= text.length; i++) {
15
+ if (i === text.length || text[i] === "\n") {
16
+ let end = i;
17
+ if (end > start && text[end - 1] === "\r") end--;
18
+ lines.push({ text : text.slice(start, end), offset : base + start });
19
+ start = i + 1;
20
+ }
21
+ }
22
+ return lines;
23
+ }
24
+
25
+ /** A 1-based line and column. */
26
+ export type Position = {
27
+ readonly line : number;
28
+ readonly column : number;
29
+ };
30
+
31
+ /** This index changes file offsets into 1-based line and column numbers. */
32
+ export class LineIndex {
33
+ private readonly starts : number[] = [0];
34
+
35
+ constructor(text : string) {
36
+ for (let i = 0; i < text.length; i++) {
37
+ if (text[i] === "\n") this.starts.push(i + 1);
38
+ }
39
+ }
40
+
41
+ /** The line and column of `offset`. */
42
+ position(offset : number) : Position {
43
+ let low = 0;
44
+ let high = this.starts.length - 1;
45
+ while (low < high) {
46
+ const middle = (low + high + 1) >> 1;
47
+ if (this.starts[middle]! <= offset) low = middle;
48
+ else high = middle - 1;
49
+ }
50
+ return { line : low + 1, column : offset - this.starts[low]! + 1 };
51
+ }
52
+ }
53
+
54
+ /** The number of columns of indentation at the start of `text`. A tab moves to the next multiple of 4. */
55
+ export function indentWidth(text : string) : number {
56
+ let width = 0;
57
+ for (const char of text) {
58
+ if (char === " ") width++;
59
+ else if (char === "\t") width += 4 - (width % 4);
60
+ else break;
61
+ }
62
+ return width;
63
+ }
64
+
65
+ /** The part of `line` after the first `count` characters. The offset moves with the text. */
66
+ export function sliceLine(line : Line, count : number) : Line {
67
+ const n = Math.min(Math.max(count, 0), line.text.length);
68
+ return { text : line.text.slice(n), offset : line.offset + n };
69
+ }
70
+
71
+ /**
72
+ * The part of `line` after `columns` columns of indentation. The function
73
+ * removes fewer characters when the line has less indentation.
74
+ */
75
+ export function stripIndent(line : Line, columns : number) : Line {
76
+ let width = 0;
77
+ let i = 0;
78
+ while (i < line.text.length && width < columns) {
79
+ const char = line.text[i];
80
+ if (char === " ") width++;
81
+ else if (char === "\t") width += 4 - (width % 4);
82
+ else break;
83
+ i++;
84
+ }
85
+ return sliceLine(line, i);
86
+ }
87
+
88
+ /** True when the line has only white space. */
89
+ export function isBlank(text : string) : boolean {
90
+ return text.trim().length === 0;
91
+ }
@@ -0,0 +1,210 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isDelimiterRow, parseBlocks, parseDirective, splitCells, type Block, type ProseBlock } from "./blocks.js";
3
+ import { splitLines } from "./lines.js";
4
+ import { extractMarkdown, stripFrontMatter } from "./markdown.js";
5
+
6
+ const prose = (blocks : readonly Block[]) : ProseBlock[] => blocks.filter((block) : block is ProseBlock => block.kind !== "directive");
7
+
8
+ /** The text of each prose block, with its kind. */
9
+ function outline(markdown : string, mdx : boolean = false) : string[] {
10
+ return prose(extractMarkdown(markdown, { mdx }).blocks.map((entry) => entry.block)).map((block) => {
11
+ const text = block.lines.map((line) => line.text).join(" / ");
12
+ const list = block.list === null ? "" : block.list.ordered ? " (ol)" : " (ul)";
13
+ const extra = block.kind === "heading" ? ` h${block.headingDepth}` : block.tableHeader ? " th" : "";
14
+ return `${block.kind}${extra}${list}${block.quote ? " (quote)" : ""}: ${text}`;
15
+ });
16
+ }
17
+
18
+ describe("Markdown block extraction", () => {
19
+ it("keeps paragraphs and headings, and joins the lines of a paragraph", () => {
20
+ expect(outline("# Title\n\nFirst line\nsecond line.\n\n## Part ##\n\nText.")).toEqual([
21
+ "heading h1: Title",
22
+ "paragraph: First line / second line.",
23
+ "heading h2: Part",
24
+ "paragraph: Text.",
25
+ ]);
26
+ });
27
+
28
+ it("finds setext headings", () => {
29
+ expect(outline("Title\n=====\n\nSub\n---\n")).toEqual(["heading h1: Title", "heading h2: Sub"]);
30
+ });
31
+
32
+ it("removes fenced code, indented code and thematic breaks", () => {
33
+ const markdown = "Before.\n\n```ts\nconst a = 1; // should\n```\n\n~~~\nmay\n~~~\n\n indented code;\n\n***\n\nAfter.";
34
+ expect(outline(markdown)).toEqual(["paragraph: Before.", "paragraph: After."]);
35
+ });
36
+
37
+ it("does not treat an indented line in a paragraph as code", () => {
38
+ expect(outline("A paragraph\n that continues.")).toEqual(["paragraph: A paragraph / that continues."]);
39
+ });
40
+
41
+ it("does not treat a backtick run with a backtick in its info string as a fence", () => {
42
+ expect(outline("``` `x` ```\n\nNext.")).toEqual(["paragraph: ``` `x` ```", "paragraph: Next."]);
43
+ });
44
+
45
+ it("keeps an unclosed fence as code to the end of the file", () => {
46
+ expect(outline("Text.\n\n```\ncode\nmore")).toEqual(["paragraph: Text."]);
47
+ });
48
+
49
+ it("gives each list item as a block, with the list type", () => {
50
+ const markdown = "- one\n- two\n continues\n\n1. step one\n2. step two\n - nested\n";
51
+ expect(outline(markdown)).toEqual([
52
+ "paragraph (ul): one",
53
+ "paragraph (ul): two / continues",
54
+ "paragraph (ol): step one",
55
+ "paragraph (ol): step two",
56
+ "paragraph (ul): nested",
57
+ ]);
58
+ });
59
+
60
+ it("accepts lazy continuation lines in a list item", () => {
61
+ expect(outline("- item one\ncontinues here\n- item two")).toEqual([
62
+ "paragraph (ul): item one / continues here",
63
+ "paragraph (ul): item two",
64
+ ]);
65
+ });
66
+
67
+ it("keeps a paragraph after a list item that has a blank line before it", () => {
68
+ expect(outline("- item\n\n second paragraph\n\nOutside.")).toEqual([
69
+ "paragraph (ul): item",
70
+ "paragraph (ul): second paragraph",
71
+ "paragraph: Outside.",
72
+ ]);
73
+ });
74
+
75
+ it("lets a bullet list interrupt a paragraph, but not an ordered list that starts after 1", () => {
76
+ expect(outline("Lead-in:\n- item")).toEqual(["paragraph: Lead-in:", "paragraph (ul): item"]);
77
+ expect(outline("The year\n2024. was good")).toEqual(["paragraph: The year / 2024. was good"]);
78
+ });
79
+
80
+ it("handles empty list items and wide markers", () => {
81
+ expect(outline("-\n text\n\n- code\n")).toEqual(["paragraph (ul): text"]);
82
+ });
83
+
84
+ it("parses block quotes and removes a GitHub alert marker", () => {
85
+ expect(outline("> [!NOTE]\n> The note text.\nlazy line.\n\n> # Quoted heading")).toEqual([
86
+ "paragraph (quote): The note text. / lazy line.",
87
+ "heading h1 (quote): Quoted heading",
88
+ ]);
89
+ });
90
+
91
+ it("gives table cells as separate blocks, and marks the header cells", () => {
92
+ const markdown = "| Name | Use |\n| --- | :-: |\n| `a\\|b` | Shows the value. |\n| x | |\n\nAfter.";
93
+ expect(outline(markdown)).toEqual([
94
+ "table-cell th: Name",
95
+ "table-cell th: Use",
96
+ "table-cell: `a\\|b`",
97
+ "table-cell: Shows the value.",
98
+ "table-cell: x",
99
+ "paragraph: After.",
100
+ ]);
101
+ });
102
+
103
+ it("finds a table directly after a paragraph", () => {
104
+ expect(outline("Intro\n| a | b |\n|---|---|\n| c | d |")).toEqual([
105
+ "paragraph: Intro",
106
+ "table-cell th: a",
107
+ "table-cell th: b",
108
+ "table-cell: c",
109
+ "table-cell: d",
110
+ ]);
111
+ });
112
+
113
+ it("removes HTML blocks, raw HTML elements and complete tag lines", () => {
114
+ const markdown = "<div align=\"center\">\nhidden text\n</div>\n\n<script>\nlet a;\n\nb();\n</script>\n\n<img src=\"x.png\">\n\nShown <b>bold</b> text.";
115
+ expect(outline(markdown)).toEqual(["paragraph: Shown <b>bold</b> text."]);
116
+ });
117
+
118
+ it("removes link reference definitions and Docusaurus admonition markers", () => {
119
+ expect(outline("[ref]: https://example.com\n\n:::note\nThe note.\n:::")).toEqual(["paragraph: The note."]);
120
+ });
121
+
122
+ it("removes YAML and TOML front matter", () => {
123
+ expect(outline("---\ntitle: Should not count\n---\nBody.")).toEqual(["paragraph: Body."]);
124
+ expect(outline("+++\ntitle = \"x\"\n+++\nBody.")).toEqual(["paragraph: Body."]);
125
+ const lines = splitLines("---\nno end");
126
+ expect(stripFrontMatter(lines)).toBe(lines);
127
+ });
128
+ });
129
+
130
+ describe("MDX", () => {
131
+ it("removes import and export lines, JSX tags and expressions, but keeps the prose in a component", () => {
132
+ const mdx = [
133
+ "import { Chart } from \"./chart\";",
134
+ "export const meta = { title : \"x\" };",
135
+ "",
136
+ "<Note>",
137
+ "The prose in the note.",
138
+ "</Note>",
139
+ "",
140
+ "<Chart",
141
+ " data={[1, 2]}",
142
+ " title=\"a > b\"",
143
+ "/>",
144
+ "",
145
+ "<Callout>Inline prose.</Callout>",
146
+ "",
147
+ "{props.value > 2 ? \"a\" : \"b\"}",
148
+ "",
149
+ "Text.",
150
+ ].join("\n");
151
+ expect(outline(mdx, true)).toEqual([
152
+ "paragraph: The prose in the note.",
153
+ "paragraph: Inline prose.</Callout>",
154
+ "paragraph: Text.",
155
+ ]);
156
+ });
157
+
158
+ it("keeps an expression block that does not close as code to the end", () => {
159
+ expect(outline("Text.\n\n{open(", true)).toEqual(["paragraph: Text."]);
160
+ });
161
+
162
+ it("finds a tag end at the end of the file when the tag does not close", () => {
163
+ expect(outline("<Open\n attr", true)).toEqual([]);
164
+ });
165
+ });
166
+
167
+ describe("directives", () => {
168
+ it("reads the command, the rules and an optional reason", () => {
169
+ expect(parseDirective(" ste-disable-next modal-verb, semicolon -- quoted text ", 7)).toEqual({
170
+ kind : "directive",
171
+ command : "disable-next",
172
+ rules : ["modal-verb", "semicolon"],
173
+ offset : 7,
174
+ });
175
+ expect(parseDirective("ste-enable", 0)?.rules).toEqual([]);
176
+ expect(parseDirective("a normal comment", 0)).toBeNull();
177
+ });
178
+
179
+ it("comes from HTML comments and MDX comments", () => {
180
+ const blocks = parseBlocks(splitLines("<!-- ste-disable\nmodal-verb -->\n\n{/* ste-enable */}\n"), { mdx : true });
181
+ expect(blocks.map((block) => block.kind === "directive" ? block.command : block.kind)).toEqual(["disable", "enable"]);
182
+ });
183
+
184
+ it("makes suppressions for ranges and for the next block", () => {
185
+ const markdown = "<!-- ste-disable-next semicolon -->\nA; B.\n\nC; D.\n\n<!-- ste-disable -->\nE.\n<!-- ste-enable -->\nF.";
186
+ const extraction = extractMarkdown(markdown, { mdx : false });
187
+ expect(extraction.suppressions).toHaveLength(2);
188
+ const [next, range] = extraction.suppressions;
189
+ expect(markdown.slice(next!.start, next!.end)).toBe("A; B.");
190
+ expect([...next!.rules!]).toEqual(["semicolon"]);
191
+ expect(markdown.slice(range!.start, range!.end)).toContain("E.");
192
+ expect(range!.rules).toBeNull();
193
+ });
194
+ });
195
+
196
+ describe("table helpers", () => {
197
+ it("finds delimiter rows", () => {
198
+ expect(isDelimiterRow("| --- | :---: |")).toBe(true);
199
+ expect(isDelimiterRow("--- | ---")).toBe(true);
200
+ expect(isDelimiterRow("---")).toBe(false);
201
+ expect(isDelimiterRow("| a | b |")).toBe(false);
202
+ });
203
+
204
+ it("divides rows into cells, but not at an escaped pipe or a pipe in code", () => {
205
+ const cells = splitCells({ text : "| a | `x | y` | c \\| d |", offset : 0 });
206
+ expect(cells.map((cell) => cell.text)).toEqual(["a", "`x | y`", "c \\| d"]);
207
+ expect(cells[0]!.offset).toBe(2);
208
+ expect(splitCells({ text : "a | `open", offset : 0 }).map((cell) => cell.text)).toEqual(["a", "`open"]);
209
+ });
210
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Prose extraction from Markdown and MDX files.
3
+ */
4
+
5
+ import type { ExtractedBlock } from "../units.js";
6
+ import { parseBlocks, type ProseBlock } from "./blocks.js";
7
+ import { splitLines, type Line } from "./lines.js";
8
+ import { SuppressionCollector, type Suppression } from "./suppressions.js";
9
+
10
+ export type Extraction = {
11
+ readonly blocks : readonly ExtractedBlock[];
12
+ readonly suppressions : readonly Suppression[];
13
+ };
14
+
15
+ /** This function removes YAML (`---`) or TOML (`+++`) front matter at the start of the file. */
16
+ export function stripFrontMatter(lines : readonly Line[]) : readonly Line[] {
17
+ const first = lines[0]?.text.trimEnd();
18
+ if (first !== "---" && first !== "+++") return lines;
19
+ for (let i = 1; i < lines.length; i++) {
20
+ const text = lines[i]!.text.trimEnd();
21
+ if (text === first || (first === "---" && text === "...")) return lines.slice(i + 1);
22
+ }
23
+ return lines;
24
+ }
25
+
26
+ /** The file offsets of the first and the last character of a block. */
27
+ export function blockRange(block : ProseBlock) : { start : number; end : number } {
28
+ const first = block.lines[0]!;
29
+ const last = block.lines[block.lines.length - 1]!;
30
+ return { start : first.offset, end : last.offset + last.text.length };
31
+ }
32
+
33
+ /** This function extracts the prose blocks and the suppressions of a Markdown or MDX file. */
34
+ export function extractMarkdown(text : string, options : { readonly mdx : boolean }) : Extraction {
35
+ const lines = stripFrontMatter(splitLines(text));
36
+ const collector = new SuppressionCollector();
37
+ const blocks : ExtractedBlock[] = [];
38
+ const origin = { source : options.mdx ? "mdx" as const : "markdown" as const, tsdocSection : null, tsdocTag : null };
39
+ for (const block of parseBlocks(lines, options)) {
40
+ if (block.kind === "directive") {
41
+ collector.directive(block);
42
+ continue;
43
+ }
44
+ const range = blockRange(block);
45
+ collector.block(range.start, range.end);
46
+ blocks.push({ block, origin });
47
+ }
48
+ return { blocks, suppressions : collector.finish(text.length) };
49
+ }
@@ -0,0 +1,79 @@
1
+ import type { Directive } from "./blocks.js";
2
+
3
+ /** A range of the file in which some rules (or all rules) do not report findings. */
4
+ export type Suppression = {
5
+ readonly start : number;
6
+ readonly end : number;
7
+ /** The rule IDs, or null for all rules. */
8
+ readonly rules : ReadonlySet<string> | null;
9
+ };
10
+
11
+ type OpenRange = { start : number; rules : Set<string> | null };
12
+
13
+ /**
14
+ * This class collects suppressions from directive comments:
15
+ *
16
+ * - `ste-disable [rules]` starts a range. `ste-enable [rules]` stops it.
17
+ * Without an `ste-enable`, the range continues to the end of the file.
18
+ * - `ste-disable-next [rules]` applies to the next block only: the next
19
+ * paragraph, heading, list item or table row in Markdown, or the next doc
20
+ * comment in TypeScript.
21
+ *
22
+ * Without rule IDs, a directive applies to all rules.
23
+ */
24
+ export class SuppressionCollector {
25
+ private readonly ranges : Suppression[] = [];
26
+ private readonly open : OpenRange[] = [];
27
+ private pending : Set<string> | null | undefined = undefined;
28
+
29
+ directive(directive : Directive) : void {
30
+ const rules = directive.rules.length === 0 ? null : new Set(directive.rules);
31
+ if (directive.command === "disable-next") {
32
+ this.pending = rules;
33
+ } else if (directive.command === "disable") {
34
+ this.open.push({ start : directive.offset, rules });
35
+ } else {
36
+ this.enable(directive.offset, rules);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * A plain `ste-enable` stops all open ranges. An `ste-enable` with rule
42
+ * IDs stops those rules only. A range for all rules stops only at a
43
+ * plain `ste-enable`.
44
+ */
45
+ private enable(offset : number, rules : Set<string> | null) : void {
46
+ for (let i = this.open.length - 1; i >= 0; i--) {
47
+ const range = this.open[i]!;
48
+ if (rules === null) {
49
+ this.ranges.push({ start : range.start, end : offset, rules : range.rules });
50
+ this.open.splice(i, 1);
51
+ continue;
52
+ }
53
+ if (range.rules === null) continue;
54
+ const closing = [...range.rules].filter((rule) => rules.has(rule));
55
+ if (closing.length === 0) continue;
56
+ this.ranges.push({ start : range.start, end : offset, rules : new Set(closing) });
57
+ this.open.splice(i, 1);
58
+ const remaining = [...range.rules].filter((rule) => !rules.has(rule));
59
+ if (remaining.length > 0) this.open.push({ start : range.start, rules : new Set(remaining) });
60
+ }
61
+ }
62
+
63
+ /** This method tells the collector about the next block, so that a pending `ste-disable-next` applies to it. */
64
+ block(start : number, end : number) : void {
65
+ if (this.pending === undefined) return;
66
+ this.ranges.push({ start, end, rules : this.pending });
67
+ this.pending = undefined;
68
+ }
69
+
70
+ /** The suppressions. Open ranges stop at `fileEnd`. */
71
+ finish(fileEnd : number) : Suppression[] {
72
+ return [...this.ranges, ...this.open.map((range) => ({ start : range.start, end : fileEnd, rules : range.rules }))];
73
+ }
74
+ }
75
+
76
+ /** True when a suppression covers a finding of `rule` at `offset`. */
77
+ export function isSuppressed(suppressions : readonly Suppression[], rule : string, offset : number) : boolean {
78
+ return suppressions.some((range) => offset >= range.start && offset <= range.end && (range.rules === null || range.rules.has(rule)));
79
+ }
@@ -0,0 +1,137 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { collectComments, docCommentLines, extractTsdoc, splitSections } from "./tsdoc.js";
3
+
4
+ describe("collectComments", () => {
5
+ it("finds line, block and doc comments, but not comment syntax in strings, templates or regular expressions", () => {
6
+ const source = [
7
+ "#!/usr/bin/env node",
8
+ "// line",
9
+ "/* block */",
10
+ "/** doc */",
11
+ "const a = \"/** not a comment */\";",
12
+ "const b = `/** ${a} */`;",
13
+ "const c = /\\/\\*\\* x \\*\\//;",
14
+ "/**/ const d = 1;",
15
+ "function f(/** inline doc */ x : number) { return x; }",
16
+ "/** at the end */",
17
+ ].join("\n");
18
+ const comments = collectComments(source, "a.ts").map((comment) => [comment.kind, source.slice(comment.pos, comment.end)]);
19
+ expect(comments).toEqual([
20
+ ["line", "// line"],
21
+ ["block", "/* block */"],
22
+ ["doc", "/** doc */"],
23
+ ["block", "/**/"],
24
+ ["doc", "/** inline doc */"],
25
+ ["doc", "/** at the end */"],
26
+ ]);
27
+ });
28
+
29
+ it("parses TSX, and does not read JSX text as a comment", () => {
30
+ const source = "/** Doc. */\nexport const A = () => <div>/** text */</div>;\n";
31
+ expect(collectComments(source, "a.tsx").map((comment) => source.slice(comment.pos, comment.end))).toEqual(["/** Doc. */"]);
32
+ });
33
+
34
+ it("parses JavaScript files", () => {
35
+ expect(collectComments("/** Doc. */\nexport const a = 1;", "a.mjs")).toHaveLength(1);
36
+ expect(collectComments("/** Doc. */\nconst A = <b/>;", "a.jsx")).toHaveLength(1);
37
+ });
38
+ });
39
+
40
+ describe("docCommentLines", () => {
41
+ it("removes the comment syntax and keeps the file offsets", () => {
42
+ const source = "/**\n * First line.\n * indented\n no star\n */";
43
+ const comment = collectComments(source, "a.ts")[0]!;
44
+ const lines = docCommentLines(source, comment);
45
+ expect(lines.map((line) => line.text)).toEqual(["", "First line.", " indented", "no star", ""]);
46
+ expect(source.slice(lines[1]!.offset, lines[1]!.offset + 5)).toBe("First");
47
+ });
48
+
49
+ it("handles a comment on one line", () => {
50
+ const source = "/** Short. */";
51
+ const lines = docCommentLines(source, collectComments(source, "a.ts")[0]!);
52
+ expect(lines.map((line) => line.text)).toEqual([" Short. "]);
53
+ });
54
+ });
55
+
56
+ describe("splitSections", () => {
57
+ const sections = (body : string) => splitSections(body.split("\n").map((text, index) => ({ text, offset : index * 100 })))
58
+ .map((section) => `${section.section}${section.tag === null ? "" : ` @${section.tag}`}: ${section.lines.map((line) => line.text).join(" / ")}`);
59
+
60
+ it("divides the summary and the block tags, and removes tag names, types and parameter names", () => {
61
+ expect(sections([
62
+ "Summary text.",
63
+ "@param name - The name.",
64
+ "@param {string} [other=1] Other value",
65
+ " that continues.",
66
+ "@typeParam T - The type.",
67
+ "@returns {number} The count.",
68
+ "@throws {Error} - When it fails.",
69
+ "@remarks",
70
+ "More text.",
71
+ "@deprecated Use something else.",
72
+ ].join("\n"))).toEqual([
73
+ "summary: Summary text.",
74
+ "tag @param: The name.",
75
+ "tag @param: Other value / that continues.",
76
+ "tag @typeParam: The type.",
77
+ "tag @returns: The count.",
78
+ "tag @throws: When it fails.",
79
+ "remarks @remarks: / More text.",
80
+ "tag @deprecated: Use something else.",
81
+ ]);
82
+ });
83
+
84
+ it("skips @example and other sections that are not prose", () => {
85
+ expect(sections("Summary.\n@example\nconst x = f(); // should\n@since 1.0\n@param a - Text.")).toEqual([
86
+ "summary: Summary.",
87
+ "tag @param: Text.",
88
+ ]);
89
+ });
90
+
91
+ it("does not find tags in fenced code or in names such as @lag/core", () => {
92
+ expect(sections("Text.\n```\n@param not a tag\n```\n@lag/core is a package.")).toEqual([
93
+ "summary: Text. / ``` / @param not a tag / ``` / @lag/core is a package.",
94
+ ]);
95
+ });
96
+ });
97
+
98
+ describe("extractTsdoc", () => {
99
+ it("gives the prose blocks of each doc comment with their origin", () => {
100
+ const source = [
101
+ "/**",
102
+ " * Summary sentence.",
103
+ " *",
104
+ " * - item one;",
105
+ " * @param value - The value.",
106
+ " */",
107
+ "export function f(value : number) : number { return value; }",
108
+ ].join("\n");
109
+ const { blocks } = extractTsdoc(source, "f.ts");
110
+ expect(blocks.map((entry) => [entry.origin.tsdocSection, entry.origin.tsdocTag, entry.block.list !== null, entry.block.lines.map((line) => line.text).join(" ")])).toEqual([
111
+ ["summary", null, false, "Summary sentence."],
112
+ ["summary", null, true, "item one;"],
113
+ ["tag", "param", false, "The value."],
114
+ ]);
115
+ });
116
+
117
+ it("makes suppressions from line comments before a doc comment and from ranges", () => {
118
+ const source = [
119
+ "// ste-disable-next semicolon",
120
+ "/** A; b. */",
121
+ "/* ste-disable */",
122
+ "/** C; d. */",
123
+ "// ste-enable",
124
+ "/** E; f. */",
125
+ ].join("\n");
126
+ const { suppressions } = extractTsdoc(source, "a.ts");
127
+ expect(suppressions.map((range) => source.slice(range.start, range.end))).toEqual([
128
+ "/** A; b. */",
129
+ "/* ste-disable */\n/** C; d. */\n",
130
+ ]);
131
+ });
132
+
133
+ it("reads a directive inside a doc comment", () => {
134
+ const { suppressions } = extractTsdoc("/**\n * <!-- ste-disable -->\n * Text.\n */", "a.ts");
135
+ expect(suppressions).toHaveLength(1);
136
+ });
137
+ });