@khanacademy/perseus-linter 0.0.0-PR973-20240207204934 → 0.0.0-PR973-20240207213425
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.
- package/.eslintrc.js +12 -0
- package/CHANGELOG.md +168 -0
- package/package.json +4 -7
- package/src/README.md +41 -0
- package/src/__tests__/matcher.test.ts +498 -0
- package/src/__tests__/rule.test.ts +110 -0
- package/src/__tests__/rules.test.ts +548 -0
- package/src/__tests__/selector-parser.test.ts +51 -0
- package/src/__tests__/tree-transformer.test.ts +444 -0
- package/src/index.ts +281 -0
- package/src/proptypes.ts +19 -0
- package/src/rule.ts +419 -0
- package/src/rules/absolute-url.ts +23 -0
- package/src/rules/all-rules.ts +71 -0
- package/src/rules/blockquoted-math.ts +9 -0
- package/src/rules/blockquoted-widget.ts +9 -0
- package/src/rules/double-spacing-after-terminal.ts +11 -0
- package/src/rules/extra-content-spacing.ts +11 -0
- package/src/rules/heading-level-1.ts +13 -0
- package/src/rules/heading-level-skip.ts +19 -0
- package/src/rules/heading-sentence-case.ts +10 -0
- package/src/rules/heading-title-case.ts +68 -0
- package/src/rules/image-alt-text.ts +20 -0
- package/src/rules/image-in-table.ts +9 -0
- package/src/rules/image-spaces-around-urls.ts +34 -0
- package/src/rules/image-widget.ts +49 -0
- package/src/rules/link-click-here.ts +10 -0
- package/src/rules/lint-utils.ts +47 -0
- package/src/rules/long-paragraph.ts +13 -0
- package/src/rules/math-adjacent.ts +9 -0
- package/src/rules/math-align-extra-break.ts +10 -0
- package/src/rules/math-align-linebreaks.ts +42 -0
- package/src/rules/math-empty.ts +9 -0
- package/src/rules/math-font-size.ts +11 -0
- package/src/rules/math-frac.ts +9 -0
- package/src/rules/math-nested.ts +10 -0
- package/src/rules/math-starts-with-space.ts +11 -0
- package/src/rules/math-text-empty.ts +9 -0
- package/src/rules/math-without-dollars.ts +13 -0
- package/src/rules/nested-lists.ts +10 -0
- package/src/rules/profanity.ts +9 -0
- package/src/rules/table-missing-cells.ts +19 -0
- package/src/rules/unbalanced-code-delimiters.ts +13 -0
- package/src/rules/unescaped-dollar.ts +9 -0
- package/src/rules/widget-in-table.ts +9 -0
- package/src/selector.ts +504 -0
- package/src/tree-transformer.ts +583 -0
- package/src/types.ts +7 -0
- package/src/version.ts +10 -0
- package/tsconfig-build.json +12 -0
- package/tsconfig-build.tsbuildinfo +1 -0
package/src/rule.ts
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Rule class represents a Perseus lint rule. A Rule instance has a check()
|
|
3
|
+
* method that takes the same (node, state, content) arguments that a
|
|
4
|
+
* TreeTransformer traversal callback function does. Call the check() method
|
|
5
|
+
* during a tree traversal to determine whether the current node of the tree
|
|
6
|
+
* violates the rule. If there is no violation, then check() returns
|
|
7
|
+
* null. Otherwise, it returns an object that includes the name of the rule,
|
|
8
|
+
* an error message, and the start and end positions within the node's content
|
|
9
|
+
* string of the lint.
|
|
10
|
+
*
|
|
11
|
+
* A Perseus lint rule consists of a name, a severity, a selector, a pattern
|
|
12
|
+
* (RegExp) and two functions. The check() method uses the selector, pattern,
|
|
13
|
+
* and functions as follows:
|
|
14
|
+
*
|
|
15
|
+
* - First, when determining which rules to apply to a particular piece of
|
|
16
|
+
* content, each rule can specify an optional function provided in the fifth
|
|
17
|
+
* parameter to evaluate whether or not we should be applying this rule.
|
|
18
|
+
* If the function returns false, we don't use the rule on this content.
|
|
19
|
+
*
|
|
20
|
+
* - Next, check() tests whether the node currently being traversed matches
|
|
21
|
+
* the selector. If it does not, then the rule does not apply at this node
|
|
22
|
+
* and there is no lint and check() returns null.
|
|
23
|
+
*
|
|
24
|
+
* - If the selector matched, then check() tests the text content of the node
|
|
25
|
+
* (and its children) against the pattern. If the pattern does not match,
|
|
26
|
+
* then there is no lint, and check() returns null.
|
|
27
|
+
*
|
|
28
|
+
* - If both the selector and pattern match, then check() calls the function
|
|
29
|
+
* passing the TraversalState object, the content string for the node, the
|
|
30
|
+
* array of nodes returned by the selector match, and the array of strings
|
|
31
|
+
* returned by the pattern match. This function can use these arguments to
|
|
32
|
+
* implement any kind of lint detection logic it wants. If it determines
|
|
33
|
+
* that there is no lint, then it should return null. Otherwise, it should
|
|
34
|
+
* return an error message as a string, or an object with `message`, `start`
|
|
35
|
+
* and `end` properties. The start and end properties are numbers that mark
|
|
36
|
+
* the beginning and end of the problematic content. Note that these numbers
|
|
37
|
+
* are relative to the content string passed to the traversal callback, not
|
|
38
|
+
* to the entire string that was used to generate the parse tree in the
|
|
39
|
+
* first place. TODO(davidflanagan): modify the simple-markdown library to
|
|
40
|
+
* have an option to add the text offset of each node to the parse
|
|
41
|
+
* tree. This will allows us to pinpoint lint errors within a long string
|
|
42
|
+
* of markdown text.
|
|
43
|
+
*
|
|
44
|
+
* - If the function returns null, then check() returns null. Otherwise,
|
|
45
|
+
* check() returns an object with `rule`, `message`, `start` and `end`
|
|
46
|
+
* properties. The value of the `rule` property is the name of the rule,
|
|
47
|
+
* which is useful for error reporting purposes.
|
|
48
|
+
*
|
|
49
|
+
* The name, severity, selector, pattern and function arguments to the Rule()
|
|
50
|
+
* constructor are optional, but you may not omit both the selector and the
|
|
51
|
+
* pattern. If you do not specify a selector, a default selector that matches
|
|
52
|
+
* any node of type "text" will be used. If you do not specify a pattern, then
|
|
53
|
+
* any node that matches the selector will be assumed to match the pattern as
|
|
54
|
+
* well. If you don't pass a function as the fourth argument to the Rule()
|
|
55
|
+
* constructor, then you must pass an error message string instead. If you do
|
|
56
|
+
* this, you'll get a default function that unconditionally returns an object
|
|
57
|
+
* that includes the error message and the start and end indexes of the
|
|
58
|
+
* portion of the content string that matched the pattern. If you don't pass a
|
|
59
|
+
* function in the fifth parameter, the rule will be applied in any context.
|
|
60
|
+
*
|
|
61
|
+
* One of the design goals of this Rule class is to allow simple lint rules to
|
|
62
|
+
* be described in JSON files without any JavaScript code. So in addition to
|
|
63
|
+
* the Rule() constructor, the class also defines a Rule.makeRule() factory
|
|
64
|
+
* method. This method takes a single object as its argument and expects the
|
|
65
|
+
* object to have four string properties. The `name` property is passed as the
|
|
66
|
+
* first argument to the Rule() construtctor. The optional `selector`
|
|
67
|
+
* property, if specified, is passed to Selector.parse() and the resulting
|
|
68
|
+
* Selector object is used as the second argument to Rule(). The optional
|
|
69
|
+
* `pattern` property is converted to a RegExp before being passed as the
|
|
70
|
+
* third argument to Rule(). (See Rule.makePattern() for details on the string
|
|
71
|
+
* to RegExp conversion). Finally, the `message` property specifies an error
|
|
72
|
+
* message that is passed as the final argument to Rule(). You can also use a
|
|
73
|
+
* real RegExp as the value of the `pattern` property or define a custom lint
|
|
74
|
+
* function on the `lint` property instead of setting the `message`
|
|
75
|
+
* property. Doing either of these things means that your rule description can
|
|
76
|
+
* no longer be saved in a JSON file, however.
|
|
77
|
+
*
|
|
78
|
+
* For example, here are two lint rules defined with Rule.makeRule():
|
|
79
|
+
*
|
|
80
|
+
* let nestedLists = Rule.makeRule({
|
|
81
|
+
* name: "nested-lists",
|
|
82
|
+
* selector: "list list",
|
|
83
|
+
* message: `Nested lists:
|
|
84
|
+
* nested lists are hard to read on mobile devices;
|
|
85
|
+
* do not use additional indentation.`,
|
|
86
|
+
* });
|
|
87
|
+
*
|
|
88
|
+
* let longParagraph = Rule.makeRule({
|
|
89
|
+
* name: "long-paragraph",
|
|
90
|
+
* selector: "paragraph",
|
|
91
|
+
* pattern: /^.{501,}/,
|
|
92
|
+
* lint: function(state, content, nodes, match) {
|
|
93
|
+
* return `Paragraph too long:
|
|
94
|
+
* This paragraph is ${content.length} characters long.
|
|
95
|
+
* Shorten it to 500 characters or fewer.`;
|
|
96
|
+
* },
|
|
97
|
+
* });
|
|
98
|
+
*
|
|
99
|
+
* Certain advanced lint rules need additional information about the content
|
|
100
|
+
* being linted in order to detect lint. For example, a rule to check for
|
|
101
|
+
* whitespace at the start and end of the URL for an image can't use the
|
|
102
|
+
* information in the node or content arguments because the markdown parser
|
|
103
|
+
* strips leading and trailing whitespace when parsing. (Nevertheless, these
|
|
104
|
+
* spaces have been a practical problem for our content translation process so
|
|
105
|
+
* in order to check for them, a lint rule needs access to the original
|
|
106
|
+
* unparsed source text. Similarly, there are various lint rules that check
|
|
107
|
+
* widget usage. For example, it is easy to write a lint rule to ensure that
|
|
108
|
+
* images have alt text for images encoded in markdown. But when images are
|
|
109
|
+
* added to our content via an image widget we also want to be able to check
|
|
110
|
+
* for alt text. In order to do this, the lint rule needs to be able to look
|
|
111
|
+
* widgets up by name in the widgets object associated with the parse tree.
|
|
112
|
+
*
|
|
113
|
+
* In order to support advanced linting rules like these, the check() method
|
|
114
|
+
* takes a context object as its optional fourth argument, and passes this
|
|
115
|
+
* object on to the lint function of each rule. Rules that require extra
|
|
116
|
+
* context should not assume that they will always get it, and should verify
|
|
117
|
+
* that the necessary context has been supplied before using it. Currently the
|
|
118
|
+
* "content" property of the context object is the unparsed source text if
|
|
119
|
+
* available, and the "widgets" property of the context object is the widget
|
|
120
|
+
* object associated with that content string in the JSON object that defines
|
|
121
|
+
* the Perseus article or exercise that is being linted.
|
|
122
|
+
*/
|
|
123
|
+
|
|
124
|
+
import {Errors, PerseusError} from "@khanacademy/perseus-error";
|
|
125
|
+
|
|
126
|
+
import Selector from "./selector";
|
|
127
|
+
|
|
128
|
+
import type {TraversalState, TreeNode} from "./tree-transformer";
|
|
129
|
+
|
|
130
|
+
// This represents the type returned by String.match(). It is an
|
|
131
|
+
// array of strings, but also has index:number and input:string properties.
|
|
132
|
+
// TypeScript doesn't handle it well, so we punt and just use any.
|
|
133
|
+
export type PatternMatchType = any;
|
|
134
|
+
|
|
135
|
+
// This is the return type of the check() method of a Rule object
|
|
136
|
+
export type RuleCheckReturnType =
|
|
137
|
+
| {
|
|
138
|
+
rule: string;
|
|
139
|
+
message: string;
|
|
140
|
+
start: number;
|
|
141
|
+
end: number;
|
|
142
|
+
severity?: number;
|
|
143
|
+
}
|
|
144
|
+
| null
|
|
145
|
+
| undefined;
|
|
146
|
+
|
|
147
|
+
// This is the return type of the lint detection function passed as the 4th
|
|
148
|
+
// argument to the Rule() constructor. It can return null or a string or an
|
|
149
|
+
// object containing a string and two numbers.
|
|
150
|
+
// prettier-ignore
|
|
151
|
+
// (prettier formats this in a way that ka-lint does not like)
|
|
152
|
+
export type LintTesterReturnType = string | {
|
|
153
|
+
message: string,
|
|
154
|
+
start: number,
|
|
155
|
+
end: number
|
|
156
|
+
} | null | undefined;
|
|
157
|
+
|
|
158
|
+
export type LintRuleContextObject = any | null | undefined;
|
|
159
|
+
|
|
160
|
+
// This is the type of the lint detection function that the Rule() constructor
|
|
161
|
+
// expects as its fourth argument. It is passed the TraversalState object and
|
|
162
|
+
// content string that were passed to check(), and is also passed the array of
|
|
163
|
+
// nodes returned by the selector match and the array of strings returned by
|
|
164
|
+
// the pattern match. It should return null if no lint is detected or an
|
|
165
|
+
// error message or an object contining an error message.
|
|
166
|
+
export type LintTester = (
|
|
167
|
+
state: TraversalState,
|
|
168
|
+
content: string,
|
|
169
|
+
selectorMatch: ReadonlyArray<TreeNode>,
|
|
170
|
+
patternMatch: PatternMatchType,
|
|
171
|
+
context: LintRuleContextObject,
|
|
172
|
+
) => LintTesterReturnType;
|
|
173
|
+
|
|
174
|
+
// An optional check to verify whether or not a particular rule should
|
|
175
|
+
// be checked by context. For example, some rules only apply in exercises,
|
|
176
|
+
// and should never be applied to articles. Defaults to true, so if we
|
|
177
|
+
// omit the applies function in a rule, it'll be tested everywhere.
|
|
178
|
+
export type AppliesTester = (context: LintRuleContextObject) => boolean;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* A Rule object describes a Perseus lint rule. See the comment at the top of
|
|
182
|
+
* this file for detailed description.
|
|
183
|
+
*/
|
|
184
|
+
export default class Rule {
|
|
185
|
+
name: string; // The name of the rule
|
|
186
|
+
severity: number; // The severity of the rule
|
|
187
|
+
selector: Selector; // The specified selector or the DEFAULT_SELECTOR
|
|
188
|
+
pattern: RegExp | null | undefined; // A regular expression if one was specified
|
|
189
|
+
lint: LintTester; // The lint-testing function or a default
|
|
190
|
+
applies: AppliesTester; // Checks to see if we should apply a rule or not
|
|
191
|
+
message: string | null | undefined; // The error message for use with the default function
|
|
192
|
+
static DEFAULT_SELECTOR: Selector;
|
|
193
|
+
|
|
194
|
+
// The comment at the top of this file has detailed docs for
|
|
195
|
+
// this constructor and its arguments
|
|
196
|
+
constructor(
|
|
197
|
+
name: string | null | undefined,
|
|
198
|
+
severity: number | null | undefined,
|
|
199
|
+
selector: Selector | null | undefined,
|
|
200
|
+
pattern: RegExp | null | undefined,
|
|
201
|
+
lint: LintTester | string,
|
|
202
|
+
applies: AppliesTester,
|
|
203
|
+
) {
|
|
204
|
+
if (!selector && !pattern) {
|
|
205
|
+
throw new PerseusError(
|
|
206
|
+
"Lint rules must have a selector or pattern",
|
|
207
|
+
Errors.InvalidInput,
|
|
208
|
+
{metadata: {name}},
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
this.name = name || "unnamed rule";
|
|
213
|
+
this.severity = severity || Rule.Severity.BULK_WARNING;
|
|
214
|
+
this.selector = selector || Rule.DEFAULT_SELECTOR;
|
|
215
|
+
this.pattern = pattern || null;
|
|
216
|
+
|
|
217
|
+
// If we're called with an error message instead of a function then
|
|
218
|
+
// use a default function that will return the message.
|
|
219
|
+
if (typeof lint === "function") {
|
|
220
|
+
this.lint = lint;
|
|
221
|
+
this.message = null;
|
|
222
|
+
} else {
|
|
223
|
+
this.lint = (...args) => this._defaultLintFunction(...args);
|
|
224
|
+
this.message = lint;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
this.applies =
|
|
228
|
+
applies ||
|
|
229
|
+
function () {
|
|
230
|
+
return true;
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// A factory method for use with rules described in JSON files
|
|
235
|
+
// See the documentation at the start of this file for details.
|
|
236
|
+
static makeRule(options: any): Rule {
|
|
237
|
+
return new Rule(
|
|
238
|
+
options.name,
|
|
239
|
+
options.severity,
|
|
240
|
+
options.selector ? Selector.parse(options.selector) : null,
|
|
241
|
+
Rule.makePattern(options.pattern),
|
|
242
|
+
options.lint || options.message,
|
|
243
|
+
options.applies,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Check the node n to see if it violates this lint rule. A return value
|
|
248
|
+
// of false means there is no lint. A returned object indicates a lint
|
|
249
|
+
// error. See the documentation at the top of this file for details.
|
|
250
|
+
check(
|
|
251
|
+
node: TreeNode,
|
|
252
|
+
traversalState: TraversalState,
|
|
253
|
+
content: string,
|
|
254
|
+
context: LintRuleContextObject,
|
|
255
|
+
): RuleCheckReturnType {
|
|
256
|
+
// First, see if we match the selector.
|
|
257
|
+
// If no selector was passed to the constructor, we use a
|
|
258
|
+
// default selector that matches text nodes.
|
|
259
|
+
const selectorMatch = this.selector.match(traversalState);
|
|
260
|
+
|
|
261
|
+
// If the selector did not match, then we're done
|
|
262
|
+
if (!selectorMatch) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// If the selector matched, then see if the pattern matches
|
|
267
|
+
let patternMatch;
|
|
268
|
+
if (this.pattern) {
|
|
269
|
+
patternMatch = content.match(this.pattern);
|
|
270
|
+
} else {
|
|
271
|
+
// If there is no pattern, then just match all of the content.
|
|
272
|
+
// Use a fake RegExp match object to represent this default match.
|
|
273
|
+
patternMatch = Rule.FakePatternMatch(content, content, 0);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// If there was a pattern and it didn't match, then we're done
|
|
277
|
+
if (!patternMatch) {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
// If we get here, then the selector and pattern have matched
|
|
283
|
+
// so now we call the lint function to see if there is lint.
|
|
284
|
+
const error = this.lint(
|
|
285
|
+
traversalState,
|
|
286
|
+
content,
|
|
287
|
+
selectorMatch,
|
|
288
|
+
patternMatch,
|
|
289
|
+
context,
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
if (!error) {
|
|
293
|
+
return null; // No lint; we're done
|
|
294
|
+
}
|
|
295
|
+
if (typeof error === "string") {
|
|
296
|
+
// If the lint function returned a string we assume it
|
|
297
|
+
// applies to the entire content of the node and return it.
|
|
298
|
+
return {
|
|
299
|
+
rule: this.name,
|
|
300
|
+
severity: this.severity,
|
|
301
|
+
message: error,
|
|
302
|
+
start: 0,
|
|
303
|
+
end: content.length,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
// If the lint function returned an object, then we just
|
|
307
|
+
// add the rule name to the message, start and end.
|
|
308
|
+
return {
|
|
309
|
+
rule: this.name,
|
|
310
|
+
severity: this.severity,
|
|
311
|
+
message: error.message,
|
|
312
|
+
start: error.start,
|
|
313
|
+
end: error.end,
|
|
314
|
+
};
|
|
315
|
+
} catch (e: any) {
|
|
316
|
+
// If the lint function threw an exception we handle that as
|
|
317
|
+
// a special type of lint. We want the user to see the lint
|
|
318
|
+
// warning in this case (even though it is out of their control)
|
|
319
|
+
// so that the bug gets reported. Otherwise we'd never know that
|
|
320
|
+
// a rule was failing.
|
|
321
|
+
return {
|
|
322
|
+
rule: "lint-rule-failure",
|
|
323
|
+
message: `Exception in rule ${this.name}: ${e.message}
|
|
324
|
+
Stack trace:
|
|
325
|
+
${e.stack}`,
|
|
326
|
+
start: 0,
|
|
327
|
+
end: content.length,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// This internal method is the default lint function that we use when a
|
|
333
|
+
// rule is defined without a function. This is useful for rules where the
|
|
334
|
+
// selector and/or pattern match are enough to indicate lint. This
|
|
335
|
+
// function unconditionally returns the error message that was passed in
|
|
336
|
+
// place of a function, but also adds start and end properties that
|
|
337
|
+
// specify which particular portion of the node content matched the
|
|
338
|
+
// pattern.
|
|
339
|
+
_defaultLintFunction(
|
|
340
|
+
state: TraversalState,
|
|
341
|
+
content: string,
|
|
342
|
+
selectorMatch: ReadonlyArray<TreeNode>,
|
|
343
|
+
patternMatch: PatternMatchType,
|
|
344
|
+
context: LintRuleContextObject,
|
|
345
|
+
): {
|
|
346
|
+
end: number;
|
|
347
|
+
message: string;
|
|
348
|
+
start: number;
|
|
349
|
+
} {
|
|
350
|
+
return {
|
|
351
|
+
message: this.message || "",
|
|
352
|
+
start: patternMatch.index,
|
|
353
|
+
end: patternMatch.index + patternMatch[0].length,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// The makeRule() factory function uses this static method to turn its
|
|
358
|
+
// argument into a RegExp. If the argument is already a RegExp, we just
|
|
359
|
+
// return it. Otherwise, we compile it into a RegExp and return that.
|
|
360
|
+
// The reason this is necessary is that Rule.makeRule() is designed for
|
|
361
|
+
// use with data from JSON files and JSON files can't include RegExp
|
|
362
|
+
// literals. Strings passed to this function do not need to be delimited
|
|
363
|
+
// with / characters unless you want to include flags for the RegExp.
|
|
364
|
+
//
|
|
365
|
+
// Examples:
|
|
366
|
+
//
|
|
367
|
+
// input "" ==> output null
|
|
368
|
+
// input /foo/ ==> output /foo/
|
|
369
|
+
// input "foo" ==> output /foo/
|
|
370
|
+
// input "/foo/i" ==> output /foo/i
|
|
371
|
+
//
|
|
372
|
+
static makePattern(
|
|
373
|
+
pattern?: RegExp | string | null,
|
|
374
|
+
): RegExp | null | undefined {
|
|
375
|
+
if (!pattern) {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
if (pattern instanceof RegExp) {
|
|
379
|
+
return pattern;
|
|
380
|
+
}
|
|
381
|
+
if (pattern[0] === "/") {
|
|
382
|
+
const lastSlash = pattern.lastIndexOf("/");
|
|
383
|
+
const expression = pattern.substring(1, lastSlash);
|
|
384
|
+
const flags = pattern.substring(lastSlash + 1);
|
|
385
|
+
// @ts-expect-error - TS2713 - Cannot access 'RegExp.flags' because 'RegExp' is a type, but not a namespace. Did you mean to retrieve the type of the property 'flags' in 'RegExp' with 'RegExp["flags"]'?
|
|
386
|
+
return new RegExp(expression, flags as RegExp.flags);
|
|
387
|
+
}
|
|
388
|
+
return new RegExp(pattern);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// This static method returns an string array with index and input
|
|
392
|
+
// properties added, in order to simulate the return value of the
|
|
393
|
+
// String.match() method. We use it when a Rule has no pattern and we
|
|
394
|
+
// want to simulate a match on the entire content string.
|
|
395
|
+
static FakePatternMatch(
|
|
396
|
+
input: string,
|
|
397
|
+
match: string | null | undefined,
|
|
398
|
+
index: number,
|
|
399
|
+
): PatternMatchType {
|
|
400
|
+
const result: any = [match];
|
|
401
|
+
result.index = index;
|
|
402
|
+
result.input = input;
|
|
403
|
+
return result;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
static Severity: {
|
|
407
|
+
BULK_WARNING: number;
|
|
408
|
+
ERROR: number;
|
|
409
|
+
GUIDELINE: number;
|
|
410
|
+
WARNING: number;
|
|
411
|
+
} = {
|
|
412
|
+
ERROR: 1,
|
|
413
|
+
WARNING: 2,
|
|
414
|
+
GUIDELINE: 3,
|
|
415
|
+
BULK_WARNING: 4,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
Rule.DEFAULT_SELECTOR = Selector.parse("text");
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import Rule from "../rule";
|
|
2
|
+
|
|
3
|
+
import {getHostname} from "./lint-utils";
|
|
4
|
+
|
|
5
|
+
export default Rule.makeRule({
|
|
6
|
+
name: "absolute-url",
|
|
7
|
+
severity: Rule.Severity.GUIDELINE,
|
|
8
|
+
selector: "link, image",
|
|
9
|
+
lint: function (state, content, nodes, match) {
|
|
10
|
+
const url = nodes[0].target;
|
|
11
|
+
const hostname = getHostname(url);
|
|
12
|
+
|
|
13
|
+
if (
|
|
14
|
+
hostname === "khanacademy.org" ||
|
|
15
|
+
hostname.endsWith(".khanacademy.org")
|
|
16
|
+
) {
|
|
17
|
+
return `Don't use absolute URLs:
|
|
18
|
+
When linking to KA content or images, omit the
|
|
19
|
+
https://www.khanacademy.org URL prefix.
|
|
20
|
+
Use a relative URL beginning with / instead.`;
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
}) as Rule;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// TODO(davidflanagan):
|
|
2
|
+
// This should probably be converted to use import and to export
|
|
3
|
+
// and object that maps rule names to rules. Also, maybe this should
|
|
4
|
+
// be an auto-generated file with a script that updates it any time
|
|
5
|
+
// we add a new rule?
|
|
6
|
+
|
|
7
|
+
import AbsoluteUrl from "./absolute-url";
|
|
8
|
+
import BlockquotedMath from "./blockquoted-math";
|
|
9
|
+
import BlockquotedWidget from "./blockquoted-widget";
|
|
10
|
+
import DoubleSpacingAfterTerminal from "./double-spacing-after-terminal";
|
|
11
|
+
import ExtraContentSpacing from "./extra-content-spacing";
|
|
12
|
+
import HeadingLevel1 from "./heading-level-1";
|
|
13
|
+
import HeadingLevelSkip from "./heading-level-skip";
|
|
14
|
+
import HeadingSentenceCase from "./heading-sentence-case";
|
|
15
|
+
import HeadingTitleCase from "./heading-title-case";
|
|
16
|
+
import ImageAltText from "./image-alt-text";
|
|
17
|
+
import ImageInTable from "./image-in-table";
|
|
18
|
+
import ImageSpacesAroundUrls from "./image-spaces-around-urls";
|
|
19
|
+
import ImageWidget from "./image-widget";
|
|
20
|
+
import LinkClickHere from "./link-click-here";
|
|
21
|
+
import LongParagraph from "./long-paragraph";
|
|
22
|
+
import MathAdjacent from "./math-adjacent";
|
|
23
|
+
import MathAlignExtraBreak from "./math-align-extra-break";
|
|
24
|
+
import MathAlignLinebreaks from "./math-align-linebreaks";
|
|
25
|
+
import MathEmpty from "./math-empty";
|
|
26
|
+
import MathFontSize from "./math-font-size";
|
|
27
|
+
import MathFrac from "./math-frac";
|
|
28
|
+
import MathNested from "./math-nested";
|
|
29
|
+
import MathStartsWithSpace from "./math-starts-with-space";
|
|
30
|
+
import MathTextEmpty from "./math-text-empty";
|
|
31
|
+
import MathWithoutDollars from "./math-without-dollars";
|
|
32
|
+
import NestedLists from "./nested-lists";
|
|
33
|
+
import Profanity from "./profanity";
|
|
34
|
+
import TableMissingCells from "./table-missing-cells";
|
|
35
|
+
import UnbalancedCodeDelimiters from "./unbalanced-code-delimiters";
|
|
36
|
+
import UnescapedDollar from "./unescaped-dollar";
|
|
37
|
+
import WidgetInTable from "./widget-in-table";
|
|
38
|
+
|
|
39
|
+
export default [
|
|
40
|
+
AbsoluteUrl,
|
|
41
|
+
BlockquotedMath,
|
|
42
|
+
BlockquotedWidget,
|
|
43
|
+
DoubleSpacingAfterTerminal,
|
|
44
|
+
ExtraContentSpacing,
|
|
45
|
+
HeadingLevel1,
|
|
46
|
+
HeadingLevelSkip,
|
|
47
|
+
HeadingSentenceCase,
|
|
48
|
+
HeadingTitleCase,
|
|
49
|
+
ImageAltText,
|
|
50
|
+
ImageInTable,
|
|
51
|
+
LinkClickHere,
|
|
52
|
+
LongParagraph,
|
|
53
|
+
MathAdjacent,
|
|
54
|
+
MathAlignExtraBreak,
|
|
55
|
+
MathAlignLinebreaks,
|
|
56
|
+
MathEmpty,
|
|
57
|
+
MathFontSize,
|
|
58
|
+
MathFrac,
|
|
59
|
+
MathNested,
|
|
60
|
+
MathStartsWithSpace,
|
|
61
|
+
MathTextEmpty,
|
|
62
|
+
NestedLists,
|
|
63
|
+
TableMissingCells,
|
|
64
|
+
UnescapedDollar,
|
|
65
|
+
WidgetInTable,
|
|
66
|
+
Profanity,
|
|
67
|
+
MathWithoutDollars,
|
|
68
|
+
UnbalancedCodeDelimiters,
|
|
69
|
+
ImageSpacesAroundUrls,
|
|
70
|
+
ImageWidget,
|
|
71
|
+
];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/* eslint-disable no-useless-escape */
|
|
2
|
+
import Rule from "../rule";
|
|
3
|
+
|
|
4
|
+
export default Rule.makeRule({
|
|
5
|
+
name: "double-spacing-after-terminal",
|
|
6
|
+
severity: Rule.Severity.BULK_WARNING,
|
|
7
|
+
selector: "paragraph",
|
|
8
|
+
pattern: /[.!\?] {2}/i,
|
|
9
|
+
message: `Use a single space after a sentence-ending period, or
|
|
10
|
+
any other kind of terminal punctuation.`,
|
|
11
|
+
}) as Rule;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import Rule from "../rule";
|
|
2
|
+
|
|
3
|
+
export default Rule.makeRule({
|
|
4
|
+
name: "extra-content-spacing",
|
|
5
|
+
selector: "paragraph",
|
|
6
|
+
pattern: /\s+$/,
|
|
7
|
+
applies: function (context) {
|
|
8
|
+
return context.contentType === "article";
|
|
9
|
+
},
|
|
10
|
+
message: `No extra whitespace at the end of content blocks.`,
|
|
11
|
+
}) as Rule;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import Rule from "../rule";
|
|
2
|
+
|
|
3
|
+
export default Rule.makeRule({
|
|
4
|
+
name: "heading-level-1",
|
|
5
|
+
severity: Rule.Severity.WARNING,
|
|
6
|
+
selector: "heading",
|
|
7
|
+
lint: function (state, content, nodes, match) {
|
|
8
|
+
if (nodes[0].level === 1) {
|
|
9
|
+
return `Don't use level-1 headings:
|
|
10
|
+
Begin headings with two or more # characters.`;
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
}) as Rule;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import Rule from "../rule";
|
|
2
|
+
|
|
3
|
+
export default Rule.makeRule({
|
|
4
|
+
name: "heading-level-skip",
|
|
5
|
+
severity: Rule.Severity.WARNING,
|
|
6
|
+
selector: "heading ~ heading",
|
|
7
|
+
lint: function (state, content, nodes, match) {
|
|
8
|
+
const currentHeading = nodes[1];
|
|
9
|
+
const previousHeading = nodes[0];
|
|
10
|
+
// A heading can have a level less than, the same as
|
|
11
|
+
// or one more than the previous heading. But going up
|
|
12
|
+
// by 2 or more levels is not right
|
|
13
|
+
if (currentHeading.level > previousHeading.level + 1) {
|
|
14
|
+
return `Skipped heading level:
|
|
15
|
+
this heading is level ${currentHeading.level} but
|
|
16
|
+
the previous heading was level ${previousHeading.level}`;
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
}) as Rule;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import Rule from "../rule";
|
|
2
|
+
|
|
3
|
+
export default Rule.makeRule({
|
|
4
|
+
name: "heading-sentence-case",
|
|
5
|
+
severity: Rule.Severity.GUIDELINE,
|
|
6
|
+
selector: "heading",
|
|
7
|
+
pattern: /^\W*[a-z]/, // first letter is lowercase
|
|
8
|
+
message: `First letter is lowercase:
|
|
9
|
+
the first letter of a heading should be capitalized.`,
|
|
10
|
+
}) as Rule;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import Rule from "../rule";
|
|
2
|
+
|
|
3
|
+
// These are 3-letter and longer words that we would not expect to be
|
|
4
|
+
// capitalized even in a title-case heading. See
|
|
5
|
+
// http://blog.apastyle.org/apastyle/2012/03/title-case-and-sentence-case-capitalization-in-apa-style.html
|
|
6
|
+
const littleWords = {
|
|
7
|
+
and: true,
|
|
8
|
+
nor: true,
|
|
9
|
+
but: true,
|
|
10
|
+
the: true,
|
|
11
|
+
for: true,
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
function isCapitalized(word: any) {
|
|
15
|
+
const c = word[0];
|
|
16
|
+
return c === c.toUpperCase();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default Rule.makeRule({
|
|
20
|
+
name: "heading-title-case",
|
|
21
|
+
severity: Rule.Severity.GUIDELINE,
|
|
22
|
+
selector: "heading",
|
|
23
|
+
pattern: /[^\s:]\s+[A-Z]+[a-z]/,
|
|
24
|
+
locale: "en",
|
|
25
|
+
lint: function (state, content, nodes, match) {
|
|
26
|
+
// We want to assert that heading text is in sentence case, not
|
|
27
|
+
// title case. The pattern above requires a capital letter at the
|
|
28
|
+
// start of the heading and allows them after a colon, or in
|
|
29
|
+
// acronyms that are all capitalized.
|
|
30
|
+
//
|
|
31
|
+
// But we can't warn just because the pattern matched because
|
|
32
|
+
// proper nouns are also allowed bo be capitalized. We're not
|
|
33
|
+
// going to do dictionary lookup to check for proper nouns, so
|
|
34
|
+
// we try a heuristic: if the title is more than 3 words long
|
|
35
|
+
// and if all the words are capitalized or are on the list of
|
|
36
|
+
// words that don't get capitalized, then we'll assume that
|
|
37
|
+
// the heading is incorrectly in title case and will warn.
|
|
38
|
+
// But if there is at least one non-capitalized long word then
|
|
39
|
+
// we're not in title case and we should not warn.
|
|
40
|
+
//
|
|
41
|
+
// TODO(davidflanagan): if this rule causes a lot of false
|
|
42
|
+
// positives, we should tweak it or remove it. Note that it will
|
|
43
|
+
// fail for headings like "World War II in Russia"
|
|
44
|
+
//
|
|
45
|
+
// TODO(davidflanagan): This rule is specific to English.
|
|
46
|
+
// It is marked with a locale property above, but that is NYI
|
|
47
|
+
//
|
|
48
|
+
// for APA style rules for title case
|
|
49
|
+
|
|
50
|
+
const heading = content.trim();
|
|
51
|
+
let words = heading.split(/\s+/);
|
|
52
|
+
|
|
53
|
+
// Remove the first word and the little words
|
|
54
|
+
words.shift();
|
|
55
|
+
words = words.filter(
|
|
56
|
+
// eslint-disable-next-line no-prototype-builtins
|
|
57
|
+
(w) => w.length > 2 && !littleWords.hasOwnProperty(w),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// If there are at least 3 remaining words and all
|
|
61
|
+
// are capitalized, then the heading is in title case.
|
|
62
|
+
if (words.length >= 3 && words.every((w) => isCapitalized(w))) {
|
|
63
|
+
return `Title-case heading:
|
|
64
|
+
This heading appears to be in title-case, but should be sentence-case.
|
|
65
|
+
Only capitalize the first letter and proper nouns.`;
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
}) as Rule;
|