@khanacademy/perseus-linter 0.0.0-PR443-20230328215601

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 (53) hide show
  1. package/.eslintrc.js +12 -0
  2. package/CHANGELOG.md +68 -0
  3. package/dist/es/index.js +1841 -0
  4. package/dist/es/index.js.map +1 -0
  5. package/dist/index.js +1835 -0
  6. package/dist/index.js.map +1 -0
  7. package/package.json +34 -0
  8. package/src/README.md +41 -0
  9. package/src/__tests__/matcher.test.ts +498 -0
  10. package/src/__tests__/rule.test.ts +110 -0
  11. package/src/__tests__/rules.test.ts +548 -0
  12. package/src/__tests__/selector-parser.test.ts +51 -0
  13. package/src/__tests__/tree-transformer.test.ts +444 -0
  14. package/src/index.ts +279 -0
  15. package/src/proptypes.ts +19 -0
  16. package/src/rule.ts +419 -0
  17. package/src/rules/absolute-url.ts +23 -0
  18. package/src/rules/all-rules.ts +71 -0
  19. package/src/rules/blockquoted-math.ts +9 -0
  20. package/src/rules/blockquoted-widget.ts +9 -0
  21. package/src/rules/double-spacing-after-terminal.ts +11 -0
  22. package/src/rules/extra-content-spacing.ts +11 -0
  23. package/src/rules/heading-level-1.ts +13 -0
  24. package/src/rules/heading-level-skip.ts +19 -0
  25. package/src/rules/heading-sentence-case.ts +10 -0
  26. package/src/rules/heading-title-case.ts +68 -0
  27. package/src/rules/image-alt-text.ts +20 -0
  28. package/src/rules/image-in-table.ts +9 -0
  29. package/src/rules/image-spaces-around-urls.ts +34 -0
  30. package/src/rules/image-widget.ts +49 -0
  31. package/src/rules/link-click-here.ts +10 -0
  32. package/src/rules/lint-utils.ts +47 -0
  33. package/src/rules/long-paragraph.ts +13 -0
  34. package/src/rules/math-adjacent.ts +9 -0
  35. package/src/rules/math-align-extra-break.ts +10 -0
  36. package/src/rules/math-align-linebreaks.ts +42 -0
  37. package/src/rules/math-empty.ts +9 -0
  38. package/src/rules/math-font-size.ts +11 -0
  39. package/src/rules/math-frac.ts +9 -0
  40. package/src/rules/math-nested.ts +10 -0
  41. package/src/rules/math-starts-with-space.ts +11 -0
  42. package/src/rules/math-text-empty.ts +9 -0
  43. package/src/rules/math-without-dollars.ts +13 -0
  44. package/src/rules/nested-lists.ts +10 -0
  45. package/src/rules/profanity.ts +9 -0
  46. package/src/rules/table-missing-cells.ts +19 -0
  47. package/src/rules/unbalanced-code-delimiters.ts +13 -0
  48. package/src/rules/unescaped-dollar.ts +9 -0
  49. package/src/rules/widget-in-table.ts +9 -0
  50. package/src/selector.ts +504 -0
  51. package/src/tree-transformer.ts +587 -0
  52. package/src/types.ts +7 -0
  53. package/tsconfig.json +12 -0
package/src/index.ts ADDED
@@ -0,0 +1,279 @@
1
+ import Rule from "./rule";
2
+ import AllRules from "./rules/all-rules";
3
+ import TreeTransformer from "./tree-transformer";
4
+
5
+ export {linterContextProps, linterContextDefault} from "./proptypes";
6
+ export type {LinterContextProps} from "./types";
7
+
8
+ const allLintRules: ReadonlyArray<any> = AllRules.filter(
9
+ (r) => r.severity < Rule.Severity.BULK_WARNING,
10
+ );
11
+
12
+ export {Rule, allLintRules as rules};
13
+
14
+ //
15
+ // Run the Perseus linter over the specified markdown parse tree,
16
+ // with the specified context object, and
17
+ // return a (possibly empty) array of lint warning objects. If the
18
+ // highlight argument is true, this function also modifies the parse
19
+ // tree to add "lint" nodes that can be visually rendered,
20
+ // highlighting the problems for the user. The optional rules argument
21
+ // is an array of Rule objects specifying which lint rules should be
22
+ // applied to this parse tree. When omitted, a default set of rules is used.
23
+ //
24
+ // The context object may have additional properties that some lint
25
+ // rules require:
26
+ //
27
+ // context.content is the source content string that was parsed to create
28
+ // the parse tree.
29
+ //
30
+ // context.widgets is the widgets object associated
31
+ // with the content string
32
+ //
33
+ // TODO: to make this even more general, allow the first argument to be
34
+ // a string and run the parser over it in that case? (but ignore highlight
35
+ // in that case). This would allow the one function to be used for both
36
+ // online linting and batch linting.
37
+ //
38
+ export function runLinter(
39
+ tree: any,
40
+ context: any,
41
+ highlight: boolean,
42
+ rules: ReadonlyArray<any> = allLintRules,
43
+ ): ReadonlyArray<any> {
44
+ const warnings: Array<any> = [];
45
+ const tt = new TreeTransformer(tree);
46
+
47
+ // The markdown parser often outputs adjacent text nodes. We
48
+ // coalesce them before linting for efficiency and accuracy.
49
+ tt.traverse((node, state, content) => {
50
+ if (TreeTransformer.isTextNode(node)) {
51
+ let next = state.nextSibling();
52
+ while (TreeTransformer.isTextNode(next)) {
53
+ // @ts-expect-error [FEI-5003] - TS2339 - Property 'content' does not exist on type 'TreeNode'. | TS2533 - Object is possibly 'null' or 'undefined'. | TS2339 - Property 'content' does not exist on type 'TreeNode'.
54
+ node.content += next.content;
55
+ state.removeNextSibling();
56
+ next = state.nextSibling();
57
+ }
58
+ }
59
+ });
60
+
61
+ // HTML tables are complicated, and the CSS we use in
62
+ // ../components/lint.jsx to display lint does not work to
63
+ // correctly position the lint indicators in the margin when the
64
+ // lint is inside a table. So as a workaround we keep track of all
65
+ // the lint that appears within a table and move it up to the
66
+ // table element itself.
67
+ //
68
+ // It is not ideal to have to do this here,
69
+ // but it is cleaner here than fixing up the lint during rendering
70
+ // in perseus-markdown.jsx. If our lint display was simpler and
71
+ // did not require indicators in the margin, this wouldn't be a
72
+ // problem. Or, if we modified the lint display stuff so that
73
+ // indicator positioning and tooltip display were both handled
74
+ // with JavaScript (instead of pure CSS), then we could avoid this
75
+ // issue too. But using JavaScript has its own downsides: there is
76
+ // risk that the linter JavaScript would interfere with
77
+ // widget-related Javascript.
78
+ let tableWarnings: Array<never> = [];
79
+ let insideTable = false;
80
+
81
+ // Traverse through the nodes of the parse tree. At each node, loop
82
+ // through the array of lint rules and check whether there is a
83
+ // lint violation at that node.
84
+ tt.traverse((node, state, content) => {
85
+ const nodeWarnings: Array<any> = [];
86
+
87
+ // If our rule is only designed to be tested against a particular
88
+ // content type and we're not in that content type, we don't need to
89
+ // consider that rule.
90
+ const applicableRules = rules.filter((r) => r.applies(context));
91
+
92
+ // Generate a stack so we can identify our position in the tree in
93
+ // lint rules
94
+ const stack = [...context.stack];
95
+ stack.push(node.type);
96
+
97
+ const nodeContext = {
98
+ ...context,
99
+ stack: stack.join("."),
100
+ } as const;
101
+
102
+ applicableRules.forEach((rule) => {
103
+ const warning = rule.check(node, state, content, nodeContext);
104
+ if (warning) {
105
+ // The start and end locations are relative to this
106
+ // particular node, and so are not generally very useful.
107
+ // TODO: When the markdown parser saves the node
108
+ // locations in the source string then we can add
109
+ // these numbers to that one and get and absolute
110
+ // character range that will be useful
111
+ if (warning.start || warning.end) {
112
+ warning.target = content.substring(
113
+ warning.start,
114
+ warning.end,
115
+ );
116
+ }
117
+
118
+ // Add the warning to the list of all lint we've found
119
+ warnings.push(warning);
120
+
121
+ // If we're going to be highlighting lint, then we also
122
+ // need to keep track of warnings specific to this node.
123
+ if (highlight) {
124
+ nodeWarnings.push(warning);
125
+ }
126
+ }
127
+ });
128
+
129
+ // If we're not highlighting lint in the tree, then we're done
130
+ // traversing this node.
131
+ if (!highlight) {
132
+ return;
133
+ }
134
+
135
+ // If the node we are currently at is a table, and there was lint
136
+ // inside the table, then we want to add that lint here
137
+ if (node.type === "table") {
138
+ if (tableWarnings.length) {
139
+ nodeWarnings.push(...tableWarnings);
140
+ }
141
+
142
+ // We're not in a table anymore, and don't have to remember
143
+ // the warnings for the table
144
+ insideTable = false;
145
+ tableWarnings = [];
146
+ } else if (!insideTable) {
147
+ // Otherwise, if we are not already inside a table, check
148
+ // to see if we've entered one. Because this is a post-order
149
+ // traversal we'll see the table contents before the table itself.
150
+ // Note that once we're inside the table, we don't have to
151
+ // do this check each time... We can just wait until we ascend
152
+ // up to the table, then we'll know we're out of it.
153
+ insideTable = state.ancestors().some((n) => n.type === "table");
154
+ }
155
+
156
+ // If we are inside a table and there were any warnings on
157
+ // this node, then we need to save the warnings for display
158
+ // on the table itself
159
+ if (insideTable && nodeWarnings.length) {
160
+ // @ts-expect-error [FEI-5003] - TS2345 - Argument of type 'any' is not assignable to parameter of type 'never'.
161
+ tableWarnings.push(...nodeWarnings);
162
+ }
163
+
164
+ // If there were any warnings on this node, and if we're highlighting
165
+ // lint, then reparent the node so we can highlight it. Note that
166
+ // a single node can have multiple warnings. If this happends we
167
+ // concatenate the warnings and newline separate them. (The lint.jsx
168
+ // component that displays the warnings may want to convert the
169
+ // newlines into <br> tags.) We also provide a lint rule name
170
+ // so that lint.jsx can link to a document that provides more details
171
+ // on that particular lint rule. If there is more than one warning
172
+ // we only link to the first rule, however.
173
+ //
174
+ // Note that even if we're inside a table, we still reparent the
175
+ // linty node so that it can be highlighted. We just make a note
176
+ // of whether this lint is inside a table or not.
177
+ if (nodeWarnings.length) {
178
+ nodeWarnings.sort((a, b) => {
179
+ return a.severity - b.severity;
180
+ });
181
+
182
+ if (node.type !== "text" || nodeWarnings.length > 1) {
183
+ // If the linty node is not a text node, or if there is more
184
+ // than one warning on a text node, then reparent the entire
185
+ // node under a new lint node and put the warnings there.
186
+ state.replace({
187
+ type: "lint",
188
+ // @ts-expect-error [FEI-5003] - TS2345 - Argument of type '{ type: string; content: TreeNode; message: string; ruleName: any; blockHighlight: any; insideTable: boolean; severity: any; }' is not assignable to parameter of type 'TreeNode'.
189
+ content: node,
190
+ message: nodeWarnings.map((w) => w.message).join("\n\n"),
191
+ ruleName: nodeWarnings[0].rule,
192
+ blockHighlight: nodeContext.blockHighlight,
193
+ insideTable: insideTable,
194
+ severity: nodeWarnings[0].severity,
195
+ });
196
+ } else {
197
+ //
198
+ // Otherwise, it is a single warning on a text node, and we
199
+ // only want to highlight the actual linty part of that string
200
+ // of text. So we want to replace the text node with (in the
201
+ // general case) three nodes:
202
+ //
203
+ // 1) A new text node that holds the non-linty prefix
204
+ //
205
+ // 2) A lint node that is the parent of a new text node
206
+ // that holds the linty part
207
+ //
208
+ // 3) A new text node that holds the non-linty suffix
209
+ //
210
+ // If the lint begins and/or ends at the boundaries of the
211
+ // original text node, then nodes 1 and/or 3 won't exist, of
212
+ // course.
213
+ //
214
+ // Note that we could generalize this to work with multple
215
+ // warnings on a text node as long as the warnings are
216
+ // non-overlapping. Hopefully, though, multiple warnings in a
217
+ // single text node will be rare in practice. Also, we don't
218
+ // have a good way to display multiple lint indicators on a
219
+ // single line, so keeping them combined in that case might
220
+ // be the best thing, anyway.
221
+ //
222
+ // @ts-expect-error [FEI-5003] - TS2339 - Property 'content' does not exist on type 'TreeNode'.
223
+ const content = node.content; // Text nodes have content
224
+ const warning = nodeWarnings[0]; // There is only one warning.
225
+ // These are the lint boundaries within the content
226
+ const start = warning.start || 0;
227
+ const end = warning.end || content.length;
228
+ const prefix = content.substring(0, start);
229
+ const lint = content.substring(start, end);
230
+ const suffix = content.substring(end);
231
+ // TODO(FEI-5003): Give this a real type.
232
+ const replacements: any[] = []; // What we'll replace the node with
233
+
234
+ // The prefix text node, if there is one
235
+ if (prefix) {
236
+ replacements.push({
237
+ type: "text",
238
+ content: prefix,
239
+ });
240
+ }
241
+
242
+ // The lint node wrapped around the linty text
243
+ replacements.push({
244
+ type: "lint",
245
+ content: {
246
+ type: "text",
247
+ content: lint,
248
+ },
249
+ message: warning.message,
250
+ ruleName: warning.rule,
251
+ insideTable: insideTable,
252
+ severity: warning.severity,
253
+ });
254
+
255
+ // The suffix node, if there is one
256
+ if (suffix) {
257
+ replacements.push({
258
+ type: "text",
259
+ content: suffix,
260
+ });
261
+ }
262
+
263
+ // Now replace the lint text node with the one to three
264
+ // nodes in the replacement array
265
+ state.replace(...replacements);
266
+ }
267
+ }
268
+ });
269
+
270
+ return warnings;
271
+ }
272
+
273
+ export function pushContextStack(context: any, name: string): any {
274
+ const stack = context.stack || [];
275
+ return {
276
+ ...context,
277
+ stack: stack.concat(name),
278
+ };
279
+ }
@@ -0,0 +1,19 @@
1
+ // Define the shape of the linter context object that is passed through the
2
+ // tree with additional information about what we are checking.
3
+ import PropTypes from "prop-types";
4
+
5
+ import type {LinterContextProps} from "./types";
6
+
7
+ export const linterContextProps = PropTypes.shape({
8
+ contentType: PropTypes.string,
9
+ highlightLint: PropTypes.bool,
10
+ paths: PropTypes.arrayOf(PropTypes.string),
11
+ stack: PropTypes.arrayOf(PropTypes.string),
12
+ });
13
+
14
+ export const linterContextDefault: LinterContextProps = {
15
+ contentType: "",
16
+ highlightLint: false,
17
+ paths: [] as ReadonlyArray<any>,
18
+ stack: [] as ReadonlyArray<any>,
19
+ };