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