@khanacademy/perseus-linter 1.3.6 → 2.0.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.
package/dist/es/index.js DELETED
@@ -1,2161 +0,0 @@
1
- import _extends from '@babel/runtime/helpers/extends';
2
- import { PerseusError, Errors } from '@khanacademy/perseus-core';
3
- import PropTypes from 'prop-types';
4
-
5
- /* eslint-disable no-useless-escape */
6
- /**
7
- * This is the base class for all Selector types. The key method that all
8
- * selector subclasses must implement is match(). It takes a TraversalState
9
- * object (from a TreeTransformer traversal) and tests whether the selector
10
- * matches at the current node. See the comment at the start of this file for
11
- * more details on the match() method.
12
- */
13
- class Selector {
14
- static parse(selectorText) {
15
- return new Parser(selectorText).parse();
16
- }
17
-
18
- /**
19
- * Return an array of the nodes that matched or null if no match.
20
- * This is the base class so we just throw an exception. All Selector
21
- * subclasses must provide an implementation of this method.
22
- */
23
- match(state) {
24
- throw new PerseusError("Selector subclasses must implement match()", Errors.NotAllowed);
25
- }
26
-
27
- /**
28
- * Selector subclasses all define a toString() method primarily
29
- * because it makes it easy to write parser tests.
30
- */
31
- toString() {
32
- return "Unknown selector class";
33
- }
34
- }
35
-
36
- /**
37
- * This class implements a parser for the selector grammar. Pass the source
38
- * text to the Parser() constructor, and then call the parse() method to
39
- * obtain a corresponding Selector object. parse() throws an exception
40
- * if there are syntax errors in the selector.
41
- *
42
- * This class is not exported, and you don't need to use it directly.
43
- * Instead call the static Selector.parse() method.
44
- */
45
- class Parser {
46
- // Which token in the array we're looking at now
47
-
48
- constructor(s) {
49
- // We do lexing with a simple regular expression
50
- this.tokens = void 0;
51
- // The array of tokens
52
- this.tokenIndex = void 0;
53
- // Normalize whitespace:
54
- // - remove leading and trailing whitespace
55
- // - replace runs of whitespace with single space characters
56
- s = s.trim().replace(/\s+/g, " ");
57
- // Convert the string to an array of tokens. Note that the TOKENS
58
- // pattern ignores spaces that do not appear before identifiers
59
- // or the * wildcard.
60
- this.tokens = s.match(Parser.TOKENS) || [];
61
- this.tokenIndex = 0;
62
- }
63
-
64
- // Return the next token or the empty string if there are no more
65
- nextToken() {
66
- return this.tokens[this.tokenIndex] || "";
67
- }
68
-
69
- // Increment the token index to "consume" the token we were looking at
70
- // and move on to the next one.
71
- consume() {
72
- this.tokenIndex++;
73
- }
74
-
75
- // Return true if the current token is an identifier or false otherwise
76
- isIdentifier() {
77
- // The Parser.TOKENS regexp ensures that we only have to check
78
- // the first character of a token to know what kind of token it is.
79
- const c = this.tokens[this.tokenIndex][0];
80
- return c >= "a" && c <= "z" || c >= "A" && c <= "Z";
81
- }
82
-
83
- // Consume space tokens until the next token is not a space.
84
- skipSpace() {
85
- while (this.nextToken() === " ") {
86
- this.consume();
87
- }
88
- }
89
-
90
- // Parse a comma-separated sequence of tree selectors. This is the
91
- // entry point for the Parser class and the only method that clients
92
- // ever need to call.
93
- parse() {
94
- // We expect at least one tree selector
95
- const ts = this.parseTreeSelector();
96
-
97
- // Now see what's next
98
- let token = this.nextToken();
99
-
100
- // If there is no next token then we're done parsing and can return
101
- // the tree selector object we got above
102
- if (!token) {
103
- return ts;
104
- }
105
-
106
- // Otherwise, there is more go come and we're going to need a
107
- // list of tree selectors
108
- const treeSelectors = [ts];
109
- while (token) {
110
- // The only character we allow after a tree selector is a comma
111
- if (token === ",") {
112
- this.consume();
113
- } else {
114
- throw new ParseError("Expected comma");
115
- }
116
-
117
- // And if we saw a comma, then it must be followed by another
118
- // tree selector
119
- treeSelectors.push(this.parseTreeSelector());
120
- token = this.nextToken();
121
- }
122
-
123
- // If we parsed more than one tree selector, return them in a
124
- // SelectorList object.
125
- return new SelectorList(treeSelectors);
126
- }
127
-
128
- // Parse a sequence of node selectors linked together with
129
- // hierarchy combinators: space, >, + and ~.
130
- parseTreeSelector() {
131
- this.skipSpace(); // Ignore space after a comma, for example
132
-
133
- // A tree selector must begin with a node selector
134
- let ns = this.parseNodeSelector();
135
- for (;;) {
136
- // Now check the next token. If there is none, or if it is a
137
- // comma, then we're done with the treeSelector. Otherwise
138
- // we expect a combinator followed by another node selector.
139
- // If we don't see a combinator, we throw an error. If we
140
- // do see a combinator and another node selector then we
141
- // combine the current node selector with the new node selector
142
- // using a Selector subclass that depends on the combinator.
143
- const token = this.nextToken();
144
- if (!token || token === ",") {
145
- break;
146
- } else if (token === " ") {
147
- this.consume();
148
- ns = new AncestorCombinator(ns, this.parseNodeSelector());
149
- } else if (token === ">") {
150
- this.consume();
151
- ns = new ParentCombinator(ns, this.parseNodeSelector());
152
- } else if (token === "+") {
153
- this.consume();
154
- ns = new PreviousCombinator(ns, this.parseNodeSelector());
155
- } else if (token === "~") {
156
- this.consume();
157
- ns = new SiblingCombinator(ns, this.parseNodeSelector());
158
- } else {
159
- throw new ParseError("Unexpected token: " + token);
160
- }
161
- }
162
- return ns;
163
- }
164
-
165
- // Parse a single node selector.
166
- // For now, this is just a node type or a wildcard.
167
- //
168
- // TODO(davidflanagan): we may need to extend this with attribute
169
- // selectors like 'heading[level=3]', or with pseudo-classes like
170
- // paragraph:first-child
171
- parseNodeSelector() {
172
- // First, skip any whitespace
173
- this.skipSpace();
174
- const t = this.nextToken();
175
- if (t === "*") {
176
- this.consume();
177
- return new AnyNode();
178
- }
179
- if (this.isIdentifier()) {
180
- this.consume();
181
- return new TypeSelector(t);
182
- }
183
- throw new ParseError("Expected node type");
184
- }
185
- }
186
-
187
- // We break the input string into tokens with this regexp. Token types
188
- // are identifiers, integers, punctuation and spaces. Note that spaces
189
- // tokens are only returned when they appear before an identifier or
190
- // wildcard token and are otherwise omitted.
191
- Parser.TOKENS = void 0;
192
- Parser.TOKENS = /([a-zA-Z][\w-]*)|(\d+)|[^\s]|(\s(?=[a-zA-Z\*]))/g;
193
-
194
- /**
195
- * This is a trivial Error subclass that the Parser uses to signal parse errors
196
- */
197
- class ParseError extends Error {
198
- constructor(message) {
199
- super(message);
200
- }
201
- }
202
-
203
- /**
204
- * This Selector subclass is a list of selectors. It matches a node if any of
205
- * the selectors on the list matches the node. It considers the selectors in
206
- * order, and returns the array of nodes returned by whichever one matches
207
- * first.
208
- */
209
- class SelectorList extends Selector {
210
- constructor(selectors) {
211
- super();
212
- this.selectors = void 0;
213
- this.selectors = selectors;
214
- }
215
- match(state) {
216
- for (let i = 0; i < this.selectors.length; i++) {
217
- const s = this.selectors[i];
218
- const result = s.match(state);
219
- if (result) {
220
- return result;
221
- }
222
- }
223
- return null;
224
- }
225
- toString() {
226
- let result = "";
227
- for (let i = 0; i < this.selectors.length; i++) {
228
- result += i > 0 ? ", " : "";
229
- result += this.selectors[i].toString();
230
- }
231
- return result;
232
- }
233
- }
234
-
235
- /**
236
- * This trivial Selector subclass implements the '*' wildcard and
237
- * matches any node.
238
- */
239
- class AnyNode extends Selector {
240
- match(state) {
241
- return [state.currentNode()];
242
- }
243
- toString() {
244
- return "*";
245
- }
246
- }
247
-
248
- /**
249
- * This selector subclass implements the <IDENTIFIER> part of the grammar.
250
- * it matches any node whose `type` property is a specified string
251
- */
252
- class TypeSelector extends Selector {
253
- constructor(type) {
254
- super();
255
- this.type = void 0;
256
- this.type = type;
257
- }
258
- match(state) {
259
- const node = state.currentNode();
260
- if (node.type === this.type) {
261
- return [node];
262
- }
263
- return null;
264
- }
265
- toString() {
266
- return this.type;
267
- }
268
- }
269
-
270
- /**
271
- * This selector subclass is the superclass of the classes that implement
272
- * matching for the four combinators. It defines left and right properties for
273
- * the two selectors that are to be combined, but does not define a match
274
- * method.
275
- */
276
- class SelectorCombinator extends Selector {
277
- constructor(left, right) {
278
- super();
279
- this.left = void 0;
280
- this.right = void 0;
281
- this.left = left;
282
- this.right = right;
283
- }
284
- }
285
-
286
- /**
287
- * This Selector subclass implements the space combinator. It matches if the
288
- * right selector matches the current node and the left selector matches some
289
- * ancestor of the current node.
290
- */
291
- class AncestorCombinator extends SelectorCombinator {
292
- constructor(left, right) {
293
- super(left, right);
294
- }
295
- match(state) {
296
- const rightResult = this.right.match(state);
297
- if (rightResult) {
298
- state = state.clone();
299
- while (state.hasParent()) {
300
- state.goToParent();
301
- const leftResult = this.left.match(state);
302
- if (leftResult) {
303
- return leftResult.concat(rightResult);
304
- }
305
- }
306
- }
307
- return null;
308
- }
309
- toString() {
310
- return this.left.toString() + " " + this.right.toString();
311
- }
312
- }
313
-
314
- /**
315
- * This Selector subclass implements the > combinator. It matches if the
316
- * right selector matches the current node and the left selector matches
317
- * the parent of the current node.
318
- */
319
- class ParentCombinator extends SelectorCombinator {
320
- constructor(left, right) {
321
- super(left, right);
322
- }
323
- match(state) {
324
- const rightResult = this.right.match(state);
325
- if (rightResult) {
326
- if (state.hasParent()) {
327
- state = state.clone();
328
- state.goToParent();
329
- const leftResult = this.left.match(state);
330
- if (leftResult) {
331
- return leftResult.concat(rightResult);
332
- }
333
- }
334
- }
335
- return null;
336
- }
337
- toString() {
338
- return this.left.toString() + " > " + this.right.toString();
339
- }
340
- }
341
-
342
- /**
343
- * This Selector subclass implements the + combinator. It matches if the
344
- * right selector matches the current node and the left selector matches
345
- * the immediate previous sibling of the current node.
346
- */
347
- class PreviousCombinator extends SelectorCombinator {
348
- constructor(left, right) {
349
- super(left, right);
350
- }
351
- match(state) {
352
- const rightResult = this.right.match(state);
353
- if (rightResult) {
354
- if (state.hasPreviousSibling()) {
355
- state = state.clone();
356
- state.goToPreviousSibling();
357
- const leftResult = this.left.match(state);
358
- if (leftResult) {
359
- return leftResult.concat(rightResult);
360
- }
361
- }
362
- }
363
- return null;
364
- }
365
- toString() {
366
- return this.left.toString() + " + " + this.right.toString();
367
- }
368
- }
369
-
370
- /**
371
- * This Selector subclass implements the ~ combinator. It matches if the
372
- * right selector matches the current node and the left selector matches
373
- * any previous sibling of the current node.
374
- */
375
- class SiblingCombinator extends SelectorCombinator {
376
- constructor(left, right) {
377
- super(left, right);
378
- }
379
- match(state) {
380
- const rightResult = this.right.match(state);
381
- if (rightResult) {
382
- state = state.clone();
383
- while (state.hasPreviousSibling()) {
384
- state.goToPreviousSibling();
385
- const leftResult = this.left.match(state);
386
- if (leftResult) {
387
- return leftResult.concat(rightResult);
388
- }
389
- }
390
- }
391
- return null;
392
- }
393
- toString() {
394
- return this.left.toString() + " ~ " + this.right.toString();
395
- }
396
- }
397
-
398
- /**
399
- * The Rule class represents a Perseus lint rule. A Rule instance has a check()
400
- * method that takes the same (node, state, content) arguments that a
401
- * TreeTransformer traversal callback function does. Call the check() method
402
- * during a tree traversal to determine whether the current node of the tree
403
- * violates the rule. If there is no violation, then check() returns
404
- * null. Otherwise, it returns an object that includes the name of the rule,
405
- * an error message, and the start and end positions within the node's content
406
- * string of the lint.
407
- *
408
- * A Perseus lint rule consists of a name, a severity, a selector, a pattern
409
- * (RegExp) and two functions. The check() method uses the selector, pattern,
410
- * and functions as follows:
411
- *
412
- * - First, when determining which rules to apply to a particular piece of
413
- * content, each rule can specify an optional function provided in the fifth
414
- * parameter to evaluate whether or not we should be applying this rule.
415
- * If the function returns false, we don't use the rule on this content.
416
- *
417
- * - Next, check() tests whether the node currently being traversed matches
418
- * the selector. If it does not, then the rule does not apply at this node
419
- * and there is no lint and check() returns null.
420
- *
421
- * - If the selector matched, then check() tests the text content of the node
422
- * (and its children) against the pattern. If the pattern does not match,
423
- * then there is no lint, and check() returns null.
424
- *
425
- * - If both the selector and pattern match, then check() calls the function
426
- * passing the TraversalState object, the content string for the node, the
427
- * array of nodes returned by the selector match, and the array of strings
428
- * returned by the pattern match. This function can use these arguments to
429
- * implement any kind of lint detection logic it wants. If it determines
430
- * that there is no lint, then it should return null. Otherwise, it should
431
- * return an error message as a string, or an object with `message`, `start`
432
- * and `end` properties. The start and end properties are numbers that mark
433
- * the beginning and end of the problematic content. Note that these numbers
434
- * are relative to the content string passed to the traversal callback, not
435
- * to the entire string that was used to generate the parse tree in the
436
- * first place. TODO(davidflanagan): modify the simple-markdown library to
437
- * have an option to add the text offset of each node to the parse
438
- * tree. This will allows us to pinpoint lint errors within a long string
439
- * of markdown text.
440
- *
441
- * - If the function returns null, then check() returns null. Otherwise,
442
- * check() returns an object with `rule`, `message`, `start` and `end`
443
- * properties. The value of the `rule` property is the name of the rule,
444
- * which is useful for error reporting purposes.
445
- *
446
- * The name, severity, selector, pattern and function arguments to the Rule()
447
- * constructor are optional, but you may not omit both the selector and the
448
- * pattern. If you do not specify a selector, a default selector that matches
449
- * any node of type "text" will be used. If you do not specify a pattern, then
450
- * any node that matches the selector will be assumed to match the pattern as
451
- * well. If you don't pass a function as the fourth argument to the Rule()
452
- * constructor, then you must pass an error message string instead. If you do
453
- * this, you'll get a default function that unconditionally returns an object
454
- * that includes the error message and the start and end indexes of the
455
- * portion of the content string that matched the pattern. If you don't pass a
456
- * function in the fifth parameter, the rule will be applied in any context.
457
- *
458
- * One of the design goals of this Rule class is to allow simple lint rules to
459
- * be described in JSON files without any JavaScript code. So in addition to
460
- * the Rule() constructor, the class also defines a Rule.makeRule() factory
461
- * method. This method takes a single object as its argument and expects the
462
- * object to have four string properties. The `name` property is passed as the
463
- * first argument to the Rule() construtctor. The optional `selector`
464
- * property, if specified, is passed to Selector.parse() and the resulting
465
- * Selector object is used as the second argument to Rule(). The optional
466
- * `pattern` property is converted to a RegExp before being passed as the
467
- * third argument to Rule(). (See Rule.makePattern() for details on the string
468
- * to RegExp conversion). Finally, the `message` property specifies an error
469
- * message that is passed as the final argument to Rule(). You can also use a
470
- * real RegExp as the value of the `pattern` property or define a custom lint
471
- * function on the `lint` property instead of setting the `message`
472
- * property. Doing either of these things means that your rule description can
473
- * no longer be saved in a JSON file, however.
474
- *
475
- * For example, here are two lint rules defined with Rule.makeRule():
476
- *
477
- * let nestedLists = Rule.makeRule({
478
- * name: "nested-lists",
479
- * selector: "list list",
480
- * message: `Nested lists:
481
- * nested lists are hard to read on mobile devices;
482
- * do not use additional indentation.`,
483
- * });
484
- *
485
- * let longParagraph = Rule.makeRule({
486
- * name: "long-paragraph",
487
- * selector: "paragraph",
488
- * pattern: /^.{501,}/,
489
- * lint: function(state, content, nodes, match) {
490
- * return `Paragraph too long:
491
- * This paragraph is ${content.length} characters long.
492
- * Shorten it to 500 characters or fewer.`;
493
- * },
494
- * });
495
- *
496
- * Certain advanced lint rules need additional information about the content
497
- * being linted in order to detect lint. For example, a rule to check for
498
- * whitespace at the start and end of the URL for an image can't use the
499
- * information in the node or content arguments because the markdown parser
500
- * strips leading and trailing whitespace when parsing. (Nevertheless, these
501
- * spaces have been a practical problem for our content translation process so
502
- * in order to check for them, a lint rule needs access to the original
503
- * unparsed source text. Similarly, there are various lint rules that check
504
- * widget usage. For example, it is easy to write a lint rule to ensure that
505
- * images have alt text for images encoded in markdown. But when images are
506
- * added to our content via an image widget we also want to be able to check
507
- * for alt text. In order to do this, the lint rule needs to be able to look
508
- * widgets up by name in the widgets object associated with the parse tree.
509
- *
510
- * In order to support advanced linting rules like these, the check() method
511
- * takes a context object as its optional fourth argument, and passes this
512
- * object on to the lint function of each rule. Rules that require extra
513
- * context should not assume that they will always get it, and should verify
514
- * that the necessary context has been supplied before using it. Currently the
515
- * "content" property of the context object is the unparsed source text if
516
- * available, and the "widgets" property of the context object is the widget
517
- * object associated with that content string in the JSON object that defines
518
- * the Perseus article or exercise that is being linted.
519
- */
520
-
521
- // This represents the type returned by String.match(). It is an
522
- // array of strings, but also has index:number and input:string properties.
523
- // TypeScript doesn't handle it well, so we punt and just use any.
524
-
525
- // This is the return type of the check() method of a Rule object
526
-
527
- // This is the return type of the lint detection function passed as the 4th
528
- // argument to the Rule() constructor. It can return null or a string or an
529
- // object containing a string and two numbers.
530
- // prettier-ignore
531
- // (prettier formats this in a way that ka-lint does not like)
532
-
533
- // This is the type of the lint detection function that the Rule() constructor
534
- // expects as its fourth argument. It is passed the TraversalState object and
535
- // content string that were passed to check(), and is also passed the array of
536
- // nodes returned by the selector match and the array of strings returned by
537
- // the pattern match. It should return null if no lint is detected or an
538
- // error message or an object contining an error message.
539
-
540
- // An optional check to verify whether or not a particular rule should
541
- // be checked by context. For example, some rules only apply in exercises,
542
- // and should never be applied to articles. Defaults to true, so if we
543
- // omit the applies function in a rule, it'll be tested everywhere.
544
-
545
- /**
546
- * A Rule object describes a Perseus lint rule. See the comment at the top of
547
- * this file for detailed description.
548
- */
549
- class Rule {
550
- // The comment at the top of this file has detailed docs for
551
- // this constructor and its arguments
552
- constructor(name, severity, selector, pattern, lint, applies) {
553
- this.name = void 0;
554
- // The name of the rule
555
- this.severity = void 0;
556
- // The severity of the rule
557
- this.selector = void 0;
558
- // The specified selector or the DEFAULT_SELECTOR
559
- this.pattern = void 0;
560
- // A regular expression if one was specified
561
- this.lint = void 0;
562
- // The lint-testing function or a default
563
- this.applies = void 0;
564
- // Checks to see if we should apply a rule or not
565
- this.message = void 0;
566
- if (!selector && !pattern) {
567
- throw new PerseusError("Lint rules must have a selector or pattern", Errors.InvalidInput, {
568
- metadata: {
569
- name
570
- }
571
- });
572
- }
573
- this.name = name || "unnamed rule";
574
- this.severity = severity || Rule.Severity.BULK_WARNING;
575
- this.selector = selector || Rule.DEFAULT_SELECTOR;
576
- this.pattern = pattern || null;
577
-
578
- // If we're called with an error message instead of a function then
579
- // use a default function that will return the message.
580
- if (typeof lint === "function") {
581
- this.lint = lint;
582
- this.message = null;
583
- } else {
584
- this.lint = (...args) => this._defaultLintFunction(...args);
585
- this.message = lint;
586
- }
587
- this.applies = applies || function () {
588
- return true;
589
- };
590
- }
591
-
592
- // A factory method for use with rules described in JSON files
593
- // See the documentation at the start of this file for details.
594
- static makeRule(options) {
595
- return new Rule(options.name, options.severity, options.selector ? Selector.parse(options.selector) : null, Rule.makePattern(options.pattern), options.lint || options.message, options.applies);
596
- }
597
-
598
- // Check the node n to see if it violates this lint rule. A return value
599
- // of false means there is no lint. A returned object indicates a lint
600
- // error. See the documentation at the top of this file for details.
601
- check(node, traversalState, content, context) {
602
- // First, see if we match the selector.
603
- // If no selector was passed to the constructor, we use a
604
- // default selector that matches text nodes.
605
- const selectorMatch = this.selector.match(traversalState);
606
-
607
- // If the selector did not match, then we're done
608
- if (!selectorMatch) {
609
- return null;
610
- }
611
-
612
- // If the selector matched, then see if the pattern matches
613
- let patternMatch;
614
- if (this.pattern) {
615
- patternMatch = content.match(this.pattern);
616
- } else {
617
- // If there is no pattern, then just match all of the content.
618
- // Use a fake RegExp match object to represent this default match.
619
- patternMatch = Rule.FakePatternMatch(content, content, 0);
620
- }
621
-
622
- // If there was a pattern and it didn't match, then we're done
623
- if (!patternMatch) {
624
- return null;
625
- }
626
- try {
627
- // If we get here, then the selector and pattern have matched
628
- // so now we call the lint function to see if there is lint.
629
- const error = this.lint(traversalState, content, selectorMatch, patternMatch, context);
630
- if (!error) {
631
- return null; // No lint; we're done
632
- }
633
- if (typeof error === "string") {
634
- // If the lint function returned a string we assume it
635
- // applies to the entire content of the node and return it.
636
- return {
637
- rule: this.name,
638
- severity: this.severity,
639
- message: error,
640
- start: 0,
641
- end: content.length
642
- };
643
- }
644
- // If the lint function returned an object, then we just
645
- // add the rule name to the message, start and end.
646
- return {
647
- rule: this.name,
648
- severity: this.severity,
649
- message: error.message,
650
- start: error.start,
651
- end: error.end
652
- };
653
- } catch (e) {
654
- // If the lint function threw an exception we handle that as
655
- // a special type of lint. We want the user to see the lint
656
- // warning in this case (even though it is out of their control)
657
- // so that the bug gets reported. Otherwise we'd never know that
658
- // a rule was failing.
659
- return {
660
- rule: "lint-rule-failure",
661
- message: `Exception in rule ${this.name}: ${e.message}
662
- Stack trace:
663
- ${e.stack}`,
664
- start: 0,
665
- end: content.length
666
- };
667
- }
668
- }
669
-
670
- // This internal method is the default lint function that we use when a
671
- // rule is defined without a function. This is useful for rules where the
672
- // selector and/or pattern match are enough to indicate lint. This
673
- // function unconditionally returns the error message that was passed in
674
- // place of a function, but also adds start and end properties that
675
- // specify which particular portion of the node content matched the
676
- // pattern.
677
- _defaultLintFunction(state, content, selectorMatch, patternMatch, context) {
678
- return {
679
- message: this.message || "",
680
- start: patternMatch.index,
681
- end: patternMatch.index + patternMatch[0].length
682
- };
683
- }
684
-
685
- // The makeRule() factory function uses this static method to turn its
686
- // argument into a RegExp. If the argument is already a RegExp, we just
687
- // return it. Otherwise, we compile it into a RegExp and return that.
688
- // The reason this is necessary is that Rule.makeRule() is designed for
689
- // use with data from JSON files and JSON files can't include RegExp
690
- // literals. Strings passed to this function do not need to be delimited
691
- // with / characters unless you want to include flags for the RegExp.
692
- //
693
- // Examples:
694
- //
695
- // input "" ==> output null
696
- // input /foo/ ==> output /foo/
697
- // input "foo" ==> output /foo/
698
- // input "/foo/i" ==> output /foo/i
699
- //
700
- static makePattern(pattern) {
701
- if (!pattern) {
702
- return null;
703
- }
704
- if (pattern instanceof RegExp) {
705
- return pattern;
706
- }
707
- if (pattern[0] === "/") {
708
- const lastSlash = pattern.lastIndexOf("/");
709
- const expression = pattern.substring(1, lastSlash);
710
- const flags = pattern.substring(lastSlash + 1);
711
- // @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"]'?
712
- return new RegExp(expression, flags);
713
- }
714
- return new RegExp(pattern);
715
- }
716
-
717
- // This static method returns an string array with index and input
718
- // properties added, in order to simulate the return value of the
719
- // String.match() method. We use it when a Rule has no pattern and we
720
- // want to simulate a match on the entire content string.
721
- static FakePatternMatch(input, match, index) {
722
- const result = [match];
723
- result.index = index;
724
- result.input = input;
725
- return result;
726
- }
727
- }
728
- // The error message for use with the default function
729
- Rule.DEFAULT_SELECTOR = void 0;
730
- Rule.Severity = {
731
- ERROR: 1,
732
- WARNING: 2,
733
- GUIDELINE: 3,
734
- BULK_WARNING: 4
735
- };
736
- Rule.DEFAULT_SELECTOR = Selector.parse("text");
737
-
738
- /* eslint-disable no-useless-escape */
739
- // Return the portion of a URL between // and /. This is the authority
740
- // portion which is usually just the hostname, but may also include
741
- // a username, password or port. We don't strip those things out because
742
- // we typically want to reject any URL that includes them
743
- const HOSTNAME = /\/\/([^\/]+)/;
744
-
745
- // Return the hostname of the URL, with any "www." prefix removed.
746
- // If this is a relative URL with no hostname, return an empty string.
747
- function getHostname(url) {
748
- if (!url) {
749
- return "";
750
- }
751
- const match = url.match(HOSTNAME);
752
- return match ? match[1] : "";
753
- }
754
-
755
- var AbsoluteUrl = Rule.makeRule({
756
- name: "absolute-url",
757
- severity: Rule.Severity.GUIDELINE,
758
- selector: "link, image",
759
- lint: function (state, content, nodes, match) {
760
- const url = nodes[0].target;
761
- const hostname = getHostname(url);
762
- if (hostname === "khanacademy.org" || hostname.endsWith(".khanacademy.org")) {
763
- return `Don't use absolute URLs:
764
- When linking to KA content or images, omit the
765
- https://www.khanacademy.org URL prefix.
766
- Use a relative URL beginning with / instead.`;
767
- }
768
- }
769
- });
770
-
771
- var BlockquotedMath = Rule.makeRule({
772
- name: "blockquoted-math",
773
- severity: Rule.Severity.WARNING,
774
- selector: "blockQuote math, blockQuote blockMath",
775
- message: `Blockquoted math:
776
- math should not be indented.`
777
- });
778
-
779
- var BlockquotedWidget = Rule.makeRule({
780
- name: "blockquoted-widget",
781
- severity: Rule.Severity.WARNING,
782
- selector: "blockQuote widget",
783
- message: `Blockquoted widget:
784
- widgets should not be indented.`
785
- });
786
-
787
- /* eslint-disable no-useless-escape */
788
- var DoubleSpacingAfterTerminal = Rule.makeRule({
789
- name: "double-spacing-after-terminal",
790
- severity: Rule.Severity.BULK_WARNING,
791
- selector: "paragraph",
792
- pattern: /[.!\?] {2}/i,
793
- message: `Use a single space after a sentence-ending period, or
794
- any other kind of terminal punctuation.`
795
- });
796
-
797
- function buttonNotInButtonSet(name, set) {
798
- return `Answer requires a button not found in the button sets: ${name} (in ${set})`;
799
- }
800
- const stringToButtonSet = {
801
- "\\sqrt": "prealgebra",
802
- "\\sin": "trig",
803
- "\\cos": "trig",
804
- "\\tan": "trig",
805
- "\\log": "logarithms",
806
- "\\ln": "logarithms"
807
- };
808
-
809
- /**
810
- * Rule to make sure that Expression questions that require
811
- * a specific math symbol to answer have that math symbol
812
- * available in the keypad (desktop learners can use a keyboard,
813
- * but mobile learners must use the MathInput keypad)
814
- */
815
- var ExpressionWidget = Rule.makeRule({
816
- name: "expression-widget",
817
- severity: Rule.Severity.WARNING,
818
- selector: "widget",
819
- lint: function (state, content, nodes, match, context) {
820
- var _context$widgets;
821
- // This rule only looks at image widgets
822
- if (state.currentNode().widgetType !== "expression") {
823
- return;
824
- }
825
- const nodeId = state.currentNode().id;
826
- if (!nodeId) {
827
- return;
828
- }
829
-
830
- // If it can't find a definition for the widget it does nothing
831
- const widget = context == null || (_context$widgets = context.widgets) == null ? void 0 : _context$widgets[nodeId];
832
- if (!widget) {
833
- return;
834
- }
835
- const answers = widget.options.answerForms;
836
- const buttons = widget.options.buttonSets;
837
- for (const answer of answers) {
838
- for (const [str, set] of Object.entries(stringToButtonSet)) {
839
- if (answer.value.includes(str) && !buttons.includes(set)) {
840
- return buttonNotInButtonSet(str, set);
841
- }
842
- }
843
- }
844
- }
845
- });
846
-
847
- var ExtraContentSpacing = Rule.makeRule({
848
- name: "extra-content-spacing",
849
- selector: "paragraph",
850
- pattern: /\s+$/,
851
- applies: function (context) {
852
- return (context == null ? void 0 : context.contentType) === "article";
853
- },
854
- message: `No extra whitespace at the end of content blocks.`
855
- });
856
-
857
- var HeadingLevel1 = Rule.makeRule({
858
- name: "heading-level-1",
859
- severity: Rule.Severity.WARNING,
860
- selector: "heading",
861
- lint: function (state, content, nodes, match) {
862
- if (nodes[0].level === 1) {
863
- return `Don't use level-1 headings:
864
- Begin headings with two or more # characters.`;
865
- }
866
- }
867
- });
868
-
869
- var HeadingLevelSkip = Rule.makeRule({
870
- name: "heading-level-skip",
871
- severity: Rule.Severity.WARNING,
872
- selector: "heading ~ heading",
873
- lint: function (state, content, nodes, match) {
874
- const currentHeading = nodes[1];
875
- const previousHeading = nodes[0];
876
- // A heading can have a level less than, the same as
877
- // or one more than the previous heading. But going up
878
- // by 2 or more levels is not right
879
- if (currentHeading.level > previousHeading.level + 1) {
880
- return `Skipped heading level:
881
- this heading is level ${currentHeading.level} but
882
- the previous heading was level ${previousHeading.level}`;
883
- }
884
- }
885
- });
886
-
887
- var HeadingSentenceCase = Rule.makeRule({
888
- name: "heading-sentence-case",
889
- severity: Rule.Severity.GUIDELINE,
890
- selector: "heading",
891
- pattern: /^\W*[a-z]/,
892
- // first letter is lowercase
893
- message: `First letter is lowercase:
894
- the first letter of a heading should be capitalized.`
895
- });
896
-
897
- // These are 3-letter and longer words that we would not expect to be
898
- // capitalized even in a title-case heading. See
899
- // http://blog.apastyle.org/apastyle/2012/03/title-case-and-sentence-case-capitalization-in-apa-style.html
900
- const littleWords = {
901
- and: true,
902
- nor: true,
903
- but: true,
904
- the: true,
905
- for: true
906
- };
907
- function isCapitalized(word) {
908
- const c = word[0];
909
- return c === c.toUpperCase();
910
- }
911
- var HeadingTitleCase = Rule.makeRule({
912
- name: "heading-title-case",
913
- severity: Rule.Severity.GUIDELINE,
914
- selector: "heading",
915
- pattern: /[^\s:]\s+[A-Z]+[a-z]/,
916
- locale: "en",
917
- lint: function (state, content, nodes, match) {
918
- // We want to assert that heading text is in sentence case, not
919
- // title case. The pattern above requires a capital letter at the
920
- // start of the heading and allows them after a colon, or in
921
- // acronyms that are all capitalized.
922
- //
923
- // But we can't warn just because the pattern matched because
924
- // proper nouns are also allowed bo be capitalized. We're not
925
- // going to do dictionary lookup to check for proper nouns, so
926
- // we try a heuristic: if the title is more than 3 words long
927
- // and if all the words are capitalized or are on the list of
928
- // words that don't get capitalized, then we'll assume that
929
- // the heading is incorrectly in title case and will warn.
930
- // But if there is at least one non-capitalized long word then
931
- // we're not in title case and we should not warn.
932
- //
933
- // TODO(davidflanagan): if this rule causes a lot of false
934
- // positives, we should tweak it or remove it. Note that it will
935
- // fail for headings like "World War II in Russia"
936
- //
937
- // TODO(davidflanagan): This rule is specific to English.
938
- // It is marked with a locale property above, but that is NYI
939
- //
940
- // for APA style rules for title case
941
-
942
- const heading = content.trim();
943
- let words = heading.split(/\s+/);
944
-
945
- // Remove the first word and the little words
946
- words.shift();
947
- words = words.filter(
948
- // eslint-disable-next-line no-prototype-builtins
949
- w => w.length > 2 && !littleWords.hasOwnProperty(w));
950
-
951
- // If there are at least 3 remaining words and all
952
- // are capitalized, then the heading is in title case.
953
- if (words.length >= 3 && words.every(w => isCapitalized(w))) {
954
- return `Title-case heading:
955
- This heading appears to be in title-case, but should be sentence-case.
956
- Only capitalize the first letter and proper nouns.`;
957
- }
958
- }
959
- });
960
-
961
- var ImageAltText = Rule.makeRule({
962
- name: "image-alt-text",
963
- severity: Rule.Severity.WARNING,
964
- selector: "image",
965
- lint: function (state, content, nodes, match) {
966
- const image = nodes[0];
967
- if (!image.alt || !image.alt.trim()) {
968
- return `Images should have alt text:
969
- for accessibility, all images should have alt text.
970
- Specify alt text inside square brackets after the !.`;
971
- }
972
- if (image.alt.length < 8) {
973
- return `Images should have alt text:
974
- for accessibility, all images should have descriptive alt text.
975
- This image's alt text is only ${image.alt.length} characters long.`;
976
- }
977
- }
978
- });
979
-
980
- var ImageInTable = Rule.makeRule({
981
- name: "image-in-table",
982
- severity: Rule.Severity.BULK_WARNING,
983
- selector: "table image",
984
- message: `Image in table:
985
- do not put images inside of tables.`
986
- });
987
-
988
- var ImageSpacesAroundUrls = Rule.makeRule({
989
- name: "image-spaces-around-urls",
990
- severity: Rule.Severity.ERROR,
991
- selector: "image",
992
- lint: function (state, content, nodes, match, context) {
993
- const image = nodes[0];
994
- const url = image.target;
995
-
996
- // The markdown parser strips leading and trailing spaces for us,
997
- // but they're still a problem for our translation process, so
998
- // we need to go check for them in the unparsed source string
999
- // if we have it.
1000
- if (context && context.content) {
1001
- // Find the url in the original content and make sure that the
1002
- // character before is '(' and the character after is ')'
1003
- const index = context.content.indexOf(url);
1004
- if (index === -1) {
1005
- // It is not an error if we didn't find it.
1006
- return;
1007
- }
1008
- if (context.content[index - 1] !== "(" || context.content[index + url.length] !== ")") {
1009
- return `Whitespace before or after image url:
1010
- For images, don't include any space or newlines after '(' or before ')'.
1011
- Whitespace in image URLs causes translation difficulties.`;
1012
- }
1013
- }
1014
- }
1015
- });
1016
-
1017
- var ImageUrlEmpty = Rule.makeRule({
1018
- name: "image-url-empty",
1019
- severity: Rule.Severity.ERROR,
1020
- selector: "image",
1021
- lint: function (state, content, nodes) {
1022
- const image = nodes[0];
1023
- const url = image.target;
1024
-
1025
- // If no URL is provided, an infinite spinner will be shown in articles
1026
- // overlaying the page where the image should be. This prevents the page
1027
- // from fully loading. As a result, we check for URLS with all images.
1028
- if (!url || !url.trim()) {
1029
- return "Images should have a URL";
1030
- }
1031
-
1032
- // NOTE(TB): Ideally there would be a check to confirm the URL works
1033
- // and leads to a valid resource, but fetching the URL would require
1034
- // linting to be able to handle async functions, which it currently
1035
- // cannot do.
1036
- }
1037
- });
1038
-
1039
- // Normally we have one rule per file. But since our selector class
1040
- // can't match specific widget types directly, this rule implements
1041
- // a number of image widget related rules in one place. This should
1042
- // slightly increase efficiency, but it means that if there is more
1043
- // than one problem with an image widget, the user will only see one
1044
- // problem at a time.
1045
- var ImageWidget = Rule.makeRule({
1046
- name: "image-widget",
1047
- severity: Rule.Severity.WARNING,
1048
- selector: "widget",
1049
- lint: function (state, content, nodes, match, context) {
1050
- // This rule only looks at image widgets
1051
- if (state.currentNode().widgetType !== "image") {
1052
- return;
1053
- }
1054
- const nodeId = state.currentNode().id;
1055
- if (!nodeId) {
1056
- return;
1057
- }
1058
-
1059
- // If it can't find a definition for the widget it does nothing
1060
- const widget = context && context.widgets && context.widgets[nodeId];
1061
- if (!widget) {
1062
- return;
1063
- }
1064
-
1065
- // Make sure there is alt text
1066
- const alt = widget.options.alt;
1067
- if (!alt) {
1068
- return `Images should have alt text:
1069
- for accessibility, all images should have a text description.
1070
- Add a description in the "Alt Text" box of the image widget.`;
1071
- }
1072
-
1073
- // Make sure the alt text it is not trivial
1074
- if (alt.trim().length < 8) {
1075
- return `Images should have alt text:
1076
- for accessibility, all images should have descriptive alt text.
1077
- This image's alt text is only ${alt.trim().length} characters long.`;
1078
- }
1079
-
1080
- // Make sure there is no math in the caption
1081
- if (widget.options.caption && widget.options.caption.match(/[^\\]\$/)) {
1082
- return `No math in image captions:
1083
- Don't include math expressions in image captions.`;
1084
- }
1085
- }
1086
- });
1087
-
1088
- var LinkClickHere = Rule.makeRule({
1089
- name: "link-click-here",
1090
- severity: Rule.Severity.WARNING,
1091
- selector: "link",
1092
- pattern: /click here/i,
1093
- message: `Inappropriate link text:
1094
- Do not use the words "click here" in links.`
1095
- });
1096
-
1097
- var LongParagraph = Rule.makeRule({
1098
- name: "long-paragraph",
1099
- severity: Rule.Severity.GUIDELINE,
1100
- selector: "paragraph",
1101
- pattern: /^.{501,}/,
1102
- lint: function (state, content, nodes, match) {
1103
- return `Paragraph too long:
1104
- This paragraph is ${content.length} characters long.
1105
- Shorten it to 500 characters or fewer.`;
1106
- }
1107
- });
1108
-
1109
- var MathAdjacent = Rule.makeRule({
1110
- name: "math-adjacent",
1111
- severity: Rule.Severity.WARNING,
1112
- selector: "blockMath+blockMath",
1113
- message: `Adjacent math blocks:
1114
- combine the blocks between \\begin{align} and \\end{align}`
1115
- });
1116
-
1117
- var MathAlignExtraBreak = Rule.makeRule({
1118
- name: "math-align-extra-break",
1119
- severity: Rule.Severity.WARNING,
1120
- selector: "blockMath",
1121
- pattern: /(\\{2,})\s*\\end{align}/,
1122
- message: `Extra space at end of block:
1123
- Don't end an align block with backslashes`
1124
- });
1125
-
1126
- var MathAlignLinebreaks = Rule.makeRule({
1127
- name: "math-align-linebreaks",
1128
- severity: Rule.Severity.WARNING,
1129
- selector: "blockMath",
1130
- // Match any align block with double backslashes in it
1131
- // Use [\s\S]* instead of .* so we match newlines as well.
1132
- pattern: /\\begin{align}[\s\S]*\\\\[\s\S]+\\end{align}/,
1133
- // Look for double backslashes and ensure that they are
1134
- // followed by optional space and another pair of backslashes.
1135
- // Note that this rule can't know where line breaks belong so
1136
- // it can't tell whether backslashes are completely missing. It just
1137
- // enforces that you don't have the wrong number of pairs of backslashes.
1138
- lint: function (state, content, nodes, match) {
1139
- let text = match[0];
1140
- while (text.length) {
1141
- const index = text.indexOf("\\\\");
1142
- if (index === -1) {
1143
- // No more backslash pairs, so we found no lint
1144
- return;
1145
- }
1146
- text = text.substring(index + 2);
1147
-
1148
- // Now we expect to find optional spaces, another pair of
1149
- // backslashes, and more optional spaces not followed immediately
1150
- // by another pair of backslashes.
1151
- const nextpair = text.match(/^\s*\\\\\s*(?!\\\\)/);
1152
-
1153
- // If that does not match then we either have too few or too
1154
- // many pairs of backslashes.
1155
- if (!nextpair) {
1156
- return "Use four backslashes between lines of an align block";
1157
- }
1158
-
1159
- // If it did match, then, shorten the string and continue looping
1160
- // (because a single align block may have multiple lines that
1161
- // all must be separated by two sets of double backslashes).
1162
- text = text.substring(nextpair[0].length);
1163
- }
1164
- }
1165
- });
1166
-
1167
- var MathEmpty = Rule.makeRule({
1168
- name: "math-empty",
1169
- severity: Rule.Severity.WARNING,
1170
- selector: "math, blockMath",
1171
- pattern: /^$/,
1172
- message: "Empty math: don't use $$ in your markdown."
1173
- });
1174
-
1175
- var MathFrac = Rule.makeRule({
1176
- name: "math-frac",
1177
- severity: Rule.Severity.GUIDELINE,
1178
- selector: "math, blockMath",
1179
- pattern: /\\frac[ {]/,
1180
- message: "Use \\dfrac instead of \\frac in your math expressions."
1181
- });
1182
-
1183
- var MathNested = Rule.makeRule({
1184
- name: "math-nested",
1185
- severity: Rule.Severity.ERROR,
1186
- selector: "math, blockMath",
1187
- pattern: /\\text{[^$}]*\$[^$}]*\$[^}]*}/,
1188
- message: `Nested math:
1189
- Don't nest math expressions inside \\text{} blocks`
1190
- });
1191
-
1192
- var MathStartsWithSpace = Rule.makeRule({
1193
- name: "math-starts-with-space",
1194
- severity: Rule.Severity.GUIDELINE,
1195
- selector: "math, blockMath",
1196
- pattern: /^\s*(~|\\qquad|\\quad|\\,|\\;|\\:|\\ |\\!|\\enspace|\\phantom)/,
1197
- message: `Math starts with space:
1198
- math should not be indented. Do not begin math expressions with
1199
- LaTeX space commands like ~, \\;, \\quad, or \\phantom`
1200
- });
1201
-
1202
- var MathTextEmpty = Rule.makeRule({
1203
- name: "math-text-empty",
1204
- severity: Rule.Severity.WARNING,
1205
- selector: "math, blockMath",
1206
- pattern: /\\text{\s*}/,
1207
- message: "Empty \\text{} block in math expression"
1208
- });
1209
-
1210
- // Because no selector is specified, this rule only applies to text nodes.
1211
- // Math and code hold their content directly and do not have text nodes
1212
- // beneath them (unlike the HTML DOM) so this rule automatically does not
1213
- // apply inside $$ or ``.
1214
- var MathWithoutDollars = Rule.makeRule({
1215
- name: "math-without-dollars",
1216
- severity: Rule.Severity.GUIDELINE,
1217
- pattern: /\\\w+{[^}]*}|{|}/,
1218
- message: `This looks like LaTeX:
1219
- did you mean to put it inside dollar signs?`
1220
- });
1221
-
1222
- var NestedLists = Rule.makeRule({
1223
- name: "nested-lists",
1224
- severity: Rule.Severity.WARNING,
1225
- selector: "list list",
1226
- message: `Nested lists:
1227
- nested lists are hard to read on mobile devices;
1228
- do not use additional indentation.`
1229
- });
1230
-
1231
- var StaticWidgetInQuestionStem = Rule.makeRule({
1232
- name: "static-widget-in-question-stem",
1233
- severity: Rule.Severity.WARNING,
1234
- selector: "widget",
1235
- lint: (state, content, nodes, match, context) => {
1236
- var _context$widgets;
1237
- if ((context == null ? void 0 : context.contentType) !== "exercise") {
1238
- return;
1239
- }
1240
- if (context.stack.includes("hint")) {
1241
- return;
1242
- }
1243
- const nodeId = state.currentNode().id;
1244
- if (!nodeId) {
1245
- return;
1246
- }
1247
- const widget = context == null || (_context$widgets = context.widgets) == null ? void 0 : _context$widgets[nodeId];
1248
- if (!widget) {
1249
- return;
1250
- }
1251
- if (widget.static) {
1252
- return `Widget in question stem is static (non-interactive).`;
1253
- }
1254
- }
1255
- });
1256
-
1257
- var TableMissingCells = Rule.makeRule({
1258
- name: "table-missing-cells",
1259
- severity: Rule.Severity.WARNING,
1260
- selector: "table",
1261
- lint: function (state, content, nodes, match) {
1262
- const table = nodes[0];
1263
- const headerLength = table.header.length;
1264
- const rowLengths = table.cells.map(r => r.length);
1265
- for (let r = 0; r < rowLengths.length; r++) {
1266
- if (rowLengths[r] !== headerLength) {
1267
- return `Table rows don't match header:
1268
- The table header has ${headerLength} cells, but
1269
- Row ${r + 1} has ${rowLengths[r]} cells.`;
1270
- }
1271
- }
1272
- }
1273
- });
1274
-
1275
- // Because no selector is specified, this rule only applies to text nodes.
1276
- // Math and code hold their content directly and do not have text nodes
1277
- // beneath them (unlike the HTML DOM) so this rule automatically does not
1278
- // apply inside $$ or ``.
1279
- var UnbalancedCodeDelimiters = Rule.makeRule({
1280
- name: "unbalanced-code-delimiters",
1281
- severity: Rule.Severity.ERROR,
1282
- pattern: /[`~]+/,
1283
- message: `Unbalanced code delimiters:
1284
- code blocks should begin and end with the same type and number of delimiters`
1285
- });
1286
-
1287
- var UnescapedDollar = Rule.makeRule({
1288
- name: "unescaped-dollar",
1289
- severity: Rule.Severity.ERROR,
1290
- selector: "unescapedDollar",
1291
- message: `Unescaped dollar sign:
1292
- Dollar signs must appear in pairs or be escaped as \\$`
1293
- });
1294
-
1295
- var WidgetInTable = Rule.makeRule({
1296
- name: "widget-in-table",
1297
- severity: Rule.Severity.BULK_WARNING,
1298
- selector: "table widget",
1299
- message: `Widget in table:
1300
- do not put widgets inside of tables.`
1301
- });
1302
-
1303
- // TODO(davidflanagan):
1304
- var AllRules = [AbsoluteUrl, BlockquotedMath, BlockquotedWidget, DoubleSpacingAfterTerminal, ImageUrlEmpty, ExpressionWidget, ExtraContentSpacing, HeadingLevel1, HeadingLevelSkip, HeadingSentenceCase, HeadingTitleCase, ImageAltText, ImageInTable, LinkClickHere, LongParagraph, MathAdjacent, MathAlignExtraBreak, MathAlignLinebreaks, MathEmpty, MathFrac, MathNested, MathStartsWithSpace, MathTextEmpty, NestedLists, StaticWidgetInQuestionStem, TableMissingCells, UnescapedDollar, WidgetInTable, MathWithoutDollars, UnbalancedCodeDelimiters, ImageSpacesAroundUrls, ImageWidget];
1305
-
1306
- /**
1307
- * TreeTransformer is a class for traversing and transforming trees. Create a
1308
- * TreeTransformer by passing the root node of the tree to the
1309
- * constructor. Then traverse that tree by calling the traverse() method. The
1310
- * argument to traverse() is a callback function that will be called once for
1311
- * each node in the tree. This is a post-order depth-first traversal: the
1312
- * callback is not called on the a way down, but on the way back up. That is,
1313
- * the children of a node are traversed before the node itself is.
1314
- *
1315
- * The traversal callback function is passed three arguments, the node being
1316
- * traversed, a TraversalState object, and the concatentated text content of
1317
- * the node and all of its descendants. The TraversalState object is the most
1318
- * most interesting argument: it has methods for querying the ancestors and
1319
- * siblings of the node, and for deleting or replacing the node. These
1320
- * transformation methods are why this class is a tree transformer and not
1321
- * just a tree traverser.
1322
- *
1323
- * A typical tree traversal looks like this:
1324
- *
1325
- * new TreeTransformer(root).traverse((node, state, content) => {
1326
- * let parent = state.parent();
1327
- * let previous = state.previousSibling();
1328
- * // etc.
1329
- * });
1330
- *
1331
- * The traverse() method descends through nodes and arrays of nodes and calls
1332
- * the traverse callback on each node on the way back up to the root of the
1333
- * tree. (Note that it only calls the callback on the nodes themselves, not
1334
- * any arrays that contain nodes.) A node is loosely defined as any object
1335
- * with a string-valued `type` property. Objects that do not have a type
1336
- * property are assumed to not be part of the tree and are not traversed. When
1337
- * traversing an array, all elements of the array are examined, and any that
1338
- * are nodes or arrays are recursively traversed. When traversing a node, all
1339
- * properties of the object are examined and any node or array values are
1340
- * recursively traversed. In typical parse trees, the children of a node are
1341
- * in a `children` or `content` array, but this class is designed to handle
1342
- * more general trees. The Perseus markdown parser, for example, produces
1343
- * nodes of type "table" that have children in the `header` and `cells`
1344
- * properties.
1345
- *
1346
- * CAUTION: the traverse() method does not make any attempt to detect
1347
- * cycles. If you call it on a cyclic graph instead of a tree, it will cause
1348
- * infinite recursion (or, more likely, a stack overflow).
1349
- *
1350
- * TODO(davidflanagan): it probably wouldn't be hard to detect cycles: when
1351
- * pushing a new node onto the containers stack we could just check that it
1352
- * isn't already there.
1353
- *
1354
- * If a node has a text-valued `content` property, it is taken to be the
1355
- * plain-text content of the node. The traverse() method concatenates these
1356
- * content strings and passes them to the traversal callback for each
1357
- * node. This means that the callback has access the full text content of its
1358
- * node and all of the nodes descendants.
1359
- *
1360
- * See the TraversalState class for more information on what information and
1361
- * methods are available to the traversal callback.
1362
- **/
1363
-
1364
- // TreeNode is the type of a node in a parse tree. The only real requirement is
1365
- // that every node has a string-valued `type` property
1366
-
1367
- // TraversalCallback is the type of the callback function passed to the
1368
- // traverse() method. It is invoked with node, state, and content arguments
1369
- // and is expected to return nothing.
1370
-
1371
- // This is the TreeTransformer class described in detail at the
1372
- // top of this file.
1373
- class TreeTransformer {
1374
- // To create a tree transformer, just pass the root node of the tree
1375
- constructor(root) {
1376
- this.root = void 0;
1377
- this.root = root;
1378
- }
1379
-
1380
- // A utility function for determing whether an arbitrary value is a node
1381
- static isNode(n) {
1382
- return n && typeof n === "object" && typeof n.type === "string";
1383
- }
1384
-
1385
- // Determines whether a value is a node with type "text" and has
1386
- // a text-valued `content` property.
1387
- static isTextNode(n) {
1388
- return TreeTransformer.isNode(n) && n.type === "text" && typeof n.content === "string";
1389
- }
1390
-
1391
- // This is the main entry point for the traverse() method. See the comment
1392
- // at the top of this file for a detailed description. Note that this
1393
- // method just creates a new TraversalState object to use for this
1394
- // traversal and then invokes the internal _traverse() method to begin the
1395
- // recursion.
1396
- traverse(f) {
1397
- this._traverse(this.root, new TraversalState(this.root), f);
1398
- }
1399
-
1400
- // Do a post-order traversal of node and its descendants, invoking the
1401
- // callback function f() once for each node and returning the concatenated
1402
- // text content of the node and its descendants. f() is passed three
1403
- // arguments: the current node, a TraversalState object representing the
1404
- // current state of the traversal, and a string that holds the
1405
- // concatenated text of the node and its descendants.
1406
- //
1407
- // This private method holds all the traversal logic and implementation
1408
- // details. Note that this method uses the TraversalState object to store
1409
- // information about the structure of the tree.
1410
- _traverse(n, state, f) {
1411
- let content = "";
1412
- if (TreeTransformer.isNode(n)) {
1413
- // If we were called on a node object, then we handle it
1414
- // this way.
1415
- const node = n; // safe cast; we just tested
1416
-
1417
- // Put the node on the stack before recursing on its children
1418
- state._containers.push(node);
1419
- state._ancestors.push(node);
1420
-
1421
- // Record the node's text content if it has any.
1422
- // Usually this is for nodes with a type property of "text",
1423
- // but other nodes types like "math" may also have content.
1424
- // @ts-expect-error - TS2339 - Property 'content' does not exist on type 'TreeNode'.
1425
- if (typeof node.content === "string") {
1426
- // @ts-expect-error - TS2339 - Property 'content' does not exist on type 'TreeNode'.
1427
- content = node.content;
1428
- }
1429
-
1430
- // Recurse on the node. If there was content above, then there
1431
- // probably won't be any children to recurse on, but we check
1432
- // anyway.
1433
- //
1434
- // If we wanted to make the traversal completely specific to the
1435
- // actual Perseus parse trees that we'll be dealing with we could
1436
- // put a switch statement here to dispatch on the node type
1437
- // property with specific recursion steps for each known type of
1438
- // node.
1439
- const keys = Object.keys(node);
1440
- keys.forEach(key => {
1441
- // Never recurse on the type property
1442
- if (key === "type") {
1443
- return;
1444
- }
1445
- // Ignore properties that are null or primitive and only
1446
- // recurse on objects and arrays. Note that we don't do a
1447
- // isNode() check here. That is done in the recursive call to
1448
- // _traverse(). Note that the recursive call on each child
1449
- // returns the text content of the child and we add that
1450
- // content to the content for this node. Also note that we
1451
- // push the name of the property we're recursing over onto a
1452
- // TraversalState stack.
1453
- const value = node[key];
1454
- if (value && typeof value === "object") {
1455
- state._indexes.push(key);
1456
- content += this._traverse(value, state, f);
1457
- state._indexes.pop();
1458
- }
1459
- });
1460
-
1461
- // Restore the stacks after recursing on the children
1462
- state._currentNode = state._ancestors.pop();
1463
- state._containers.pop();
1464
-
1465
- // And finally call the traversal callback for this node. Note
1466
- // that this is post-order traversal. We call the callback on the
1467
- // way back up the tree, not on the way down. That way we already
1468
- // know all the content contained within the node.
1469
- f(node, state, content);
1470
- } else if (Array.isArray(n)) {
1471
- // If we were called on an array instead of a node, then
1472
- // this is the code we use to recurse.
1473
- const nodes = n;
1474
-
1475
- // Push the array onto the stack. This will allow the
1476
- // TraversalState object to locate siblings of this node.
1477
- state._containers.push(nodes);
1478
-
1479
- // Now loop through this array and recurse on each element in it.
1480
- // Before recursing on an element, we push its array index on a
1481
- // TraversalState stack so that the TraversalState sibling methods
1482
- // can work. Note that TraversalState methods can alter the length
1483
- // of the array, and change the index of the current node, so we
1484
- // are careful here to test the array length on each iteration and
1485
- // to reset the index when we pop the stack. Also note that we
1486
- // concatentate the text content of the children.
1487
- let index = 0;
1488
- while (index < nodes.length) {
1489
- state._indexes.push(index);
1490
- content += this._traverse(nodes[index], state, f);
1491
- // Casting to convince TypeScript that this is a number
1492
- index = state._indexes.pop() + 1;
1493
- }
1494
-
1495
- // Pop the array off the stack. Note, however, that we do not call
1496
- // the traversal callback on the array. That function is only
1497
- // called for nodes, not arrays of nodes.
1498
- state._containers.pop();
1499
- }
1500
-
1501
- // The _traverse() method always returns the text content of
1502
- // this node and its children. This is the one piece of state that
1503
- // is not tracked in the TraversalState object.
1504
- return content;
1505
- }
1506
- }
1507
-
1508
- // An instance of this class is passed to the callback function for
1509
- // each node traversed. The class itself is not exported, but its
1510
- // methods define the API available to the traversal callback.
1511
-
1512
- /**
1513
- * This class represents the state of a tree traversal. An instance is created
1514
- * by the traverse() method of the TreeTransformer class to maintain the state
1515
- * for that traversal, and the instance is passed to the traversal callback
1516
- * function for each node that is traversed. This class is not intended to be
1517
- * instantiated directly, but is exported so that its type can be used for
1518
- * type annotaions.
1519
- **/
1520
- class TraversalState {
1521
- // The constructor just stores the root node and creates empty stacks.
1522
- constructor(root) {
1523
- // The root node of the tree being traversed
1524
- this.root = void 0;
1525
- // These are internal state properties. Use the accessor methods defined
1526
- // below instead of using these properties directly. Note that the
1527
- // _containers and _indexes stacks can have two different types of
1528
- // elements, depending on whether we just recursed on an array or on a
1529
- // node. This is hard for TypeScript to deal with, so you'll see a number of
1530
- // type casts through the any type when working with these two properties.
1531
- this._currentNode = void 0;
1532
- this._containers = void 0;
1533
- this._indexes = void 0;
1534
- this._ancestors = void 0;
1535
- this.root = root;
1536
-
1537
- // When the callback is called, this property will hold the
1538
- // node that is currently being traversed.
1539
- this._currentNode = null;
1540
-
1541
- // This is a stack of the objects and arrays that we've
1542
- // traversed through before reaching the currentNode.
1543
- // It is different than the ancestors array.
1544
- this._containers = new Stack();
1545
-
1546
- // This stack has the same number of elements as the _containers
1547
- // stack. The last element of this._indexes[] is the index of
1548
- // the current node in the object or array that is the last element
1549
- // of this._containers[]. If the last element of this._containers[] is
1550
- // an array, then the last element of this stack will be a number.
1551
- // Otherwise if the last container is an object, then the last index
1552
- // will be a string property name.
1553
- this._indexes = new Stack();
1554
-
1555
- // This is a stack of the ancestor nodes of the current one.
1556
- // It is different than the containers[] stack because it only
1557
- // includes nodes, not arrays.
1558
- this._ancestors = new Stack();
1559
- }
1560
-
1561
- /**
1562
- * Return the current node in the traversal. Any time the traversal
1563
- * callback is called, this method will return the name value as the
1564
- * first argument to the callback.
1565
- */
1566
- currentNode() {
1567
- return this._currentNode || this.root;
1568
- }
1569
-
1570
- /**
1571
- * Return the parent of the current node, if there is one, or null.
1572
- */
1573
- parent() {
1574
- return this._ancestors.top();
1575
- }
1576
-
1577
- /**
1578
- * Return an array of ancestor nodes. The first element of this array is
1579
- * the same as this.parent() and the last element is the root node. If we
1580
- * are currently at the root node, the the returned array will be empty.
1581
- * This method makes a copy of the internal state, so modifications to the
1582
- * returned array have no effect on the traversal.
1583
- */
1584
- ancestors() {
1585
- return this._ancestors.values();
1586
- }
1587
-
1588
- /**
1589
- * Return the next sibling of this node, if it has one, or null otherwise.
1590
- */
1591
- nextSibling() {
1592
- const siblings = this._containers.top();
1593
-
1594
- // If we're at the root of the tree or if the parent is an
1595
- // object instead of an array, then there are no siblings.
1596
- if (!siblings || !Array.isArray(siblings)) {
1597
- return null;
1598
- }
1599
-
1600
- // The top index is a number because the top container is an array
1601
- const index = this._indexes.top();
1602
- if (siblings.length > index + 1) {
1603
- return siblings[index + 1];
1604
- }
1605
- return null; // There is no next sibling
1606
- }
1607
-
1608
- /**
1609
- * Return the previous sibling of this node, if it has one, or null
1610
- * otherwise.
1611
- */
1612
- previousSibling() {
1613
- const siblings = this._containers.top();
1614
-
1615
- // If we're at the root of the tree or if the parent is an
1616
- // object instead of an array, then there are no siblings.
1617
- if (!siblings || !Array.isArray(siblings)) {
1618
- return null;
1619
- }
1620
-
1621
- // The top index is a number because the top container is an array
1622
- const index = this._indexes.top();
1623
- if (index > 0) {
1624
- return siblings[index - 1];
1625
- }
1626
- return null; // There is no previous sibling
1627
- }
1628
-
1629
- /**
1630
- * Remove the next sibling node (if there is one) from the tree. Returns
1631
- * the removed sibling or null. This method makes it easy to traverse a
1632
- * tree and concatenate adjacent text nodes into a single node.
1633
- */
1634
- removeNextSibling() {
1635
- const siblings = this._containers.top();
1636
- if (siblings && Array.isArray(siblings)) {
1637
- // top index is a number because top container is an array
1638
- const index = this._indexes.top();
1639
- if (siblings.length > index + 1) {
1640
- return siblings.splice(index + 1, 1)[0];
1641
- }
1642
- }
1643
- return null;
1644
- }
1645
-
1646
- /**
1647
- * Replace the current node in the tree with the specified nodes. If no
1648
- * nodes are passed, this is a node deletion. If one node (or array) is
1649
- * passed, this is a 1-for-1 replacement. If more than one node is passed
1650
- * then this is a combination of deletion and insertion. The new node or
1651
- * nodes will not be traversed, so this method can safely be used to
1652
- * reparent the current node node beneath a new parent.
1653
- *
1654
- * This method throws an error if you attempt to replace the root node of
1655
- * the tree.
1656
- */
1657
- replace(...replacements) {
1658
- const parent = this._containers.top();
1659
- if (!parent) {
1660
- throw new PerseusError("Can't replace the root of the tree", Errors.Internal);
1661
- }
1662
-
1663
- // The top of the container stack is either an array or an object
1664
- // and the top of the indexes stack is a corresponding array index
1665
- // or object property. This is hard for TypeScript, so we have to do some
1666
- // unsafe casting and be careful when we use which cast version
1667
- if (Array.isArray(parent)) {
1668
- const index = this._indexes.top();
1669
- // For an array parent we just splice the new nodes in
1670
- parent.splice(index, 1, ...replacements);
1671
- // Adjust the index to account for the changed array length.
1672
- // We don't want to traverse any of the newly inserted nodes.
1673
- this._indexes.pop();
1674
- this._indexes.push(index + replacements.length - 1);
1675
- } else {
1676
- const property = this._indexes.top();
1677
- // For an object parent we care how many new nodes there are
1678
- if (replacements.length === 0) {
1679
- // Deletion
1680
- delete parent[property];
1681
- } else if (replacements.length === 1) {
1682
- // Replacement
1683
- parent[property] = replacements[0];
1684
- } else {
1685
- // Replace one node with an array of nodes
1686
- parent[property] = replacements;
1687
- }
1688
- }
1689
- }
1690
-
1691
- /**
1692
- * Returns true if the current node has a previous sibling and false
1693
- * otherwise. If this method returns false, then previousSibling() will
1694
- * return null, and goToPreviousSibling() will throw an error.
1695
- */
1696
- hasPreviousSibling() {
1697
- return Array.isArray(this._containers.top()) && this._indexes.top() > 0;
1698
- }
1699
-
1700
- /**
1701
- * Modify this traversal state object to have the state it would have had
1702
- * when visiting the previous sibling. Note that you may want to use
1703
- * clone() to make a copy before modifying the state object like this.
1704
- * This mutator method is not typically used during ordinary tree
1705
- * traversals, but is used by the Selector class for matching multi-node
1706
- * selectors.
1707
- */
1708
- goToPreviousSibling() {
1709
- if (!this.hasPreviousSibling()) {
1710
- throw new PerseusError("goToPreviousSibling(): node has no previous sibling", Errors.Internal);
1711
- }
1712
- this._currentNode = this.previousSibling();
1713
- // Since we know that we have a previous sibling, we know that
1714
- // the value on top of the stack is a number, but we have to do
1715
- // this unsafe cast because TypeScript doesn't know that.
1716
- const index = this._indexes.pop();
1717
- this._indexes.push(index - 1);
1718
- }
1719
-
1720
- /**
1721
- * Returns true if the current node has an ancestor and false otherwise.
1722
- * If this method returns false, then the parent() method will return
1723
- * null and goToParent() will throw an error
1724
- */
1725
- hasParent() {
1726
- return this._ancestors.size() !== 0;
1727
- }
1728
-
1729
- /**
1730
- * Modify this object to look like it will look when we (later) visit the
1731
- * parent node of this node. You should not modify the instance passed to
1732
- * the tree traversal callback. Instead, make a copy with the clone()
1733
- * method and modify that. This mutator method is not typically used
1734
- * during ordinary tree traversals, but is used by the Selector class for
1735
- * matching multi-node selectors that involve parent and ancestor
1736
- * selectors.
1737
- */
1738
- goToParent() {
1739
- if (!this.hasParent()) {
1740
- throw new PerseusError("goToParent(): node has no ancestor", Errors.NotAllowed);
1741
- }
1742
- this._currentNode = this._ancestors.pop();
1743
-
1744
- // We need to pop the containers and indexes stacks at least once
1745
- // and more as needed until we restore the invariant that
1746
- // this._containers.top()[this.indexes.top()] === this._currentNode
1747
- //
1748
- while (this._containers.size() && this._containers.top()[this._indexes.top()] !== this._currentNode) {
1749
- this._containers.pop();
1750
- this._indexes.pop();
1751
- }
1752
- }
1753
-
1754
- /**
1755
- * Return a new TraversalState object that is a copy of this one.
1756
- * This method is useful in conjunction with the mutating methods
1757
- * goToParent() and goToPreviousSibling().
1758
- */
1759
- clone() {
1760
- const clone = new TraversalState(this.root);
1761
- clone._currentNode = this._currentNode;
1762
- clone._containers = this._containers.clone();
1763
- clone._indexes = this._indexes.clone();
1764
- clone._ancestors = this._ancestors.clone();
1765
- return clone;
1766
- }
1767
-
1768
- /**
1769
- * Returns true if this TraversalState object is equal to that
1770
- * TraversalState object, or false otherwise. This method exists
1771
- * primarily for use by our unit tests.
1772
- */
1773
- equals(that) {
1774
- return this.root === that.root && this._currentNode === that._currentNode && this._containers.equals(that._containers) && this._indexes.equals(that._indexes) && this._ancestors.equals(that._ancestors);
1775
- }
1776
- }
1777
-
1778
- /**
1779
- * This class is an internal utility that just treats an array as a stack
1780
- * and gives us a top() method so we don't have to write expressions like
1781
- * `ancestors[ancestors.length-1]`. The values() method automatically
1782
- * copies the internal array so we don't have to worry about client code
1783
- * modifying our internal stacks. The use of this Stack abstraction makes
1784
- * the TraversalState class simpler in a number of places.
1785
- */
1786
- class Stack {
1787
- constructor(array) {
1788
- this.stack = void 0;
1789
- this.stack = array ? array.slice(0) : [];
1790
- }
1791
-
1792
- /** Push a value onto the stack. */
1793
- push(v) {
1794
- this.stack.push(v);
1795
- }
1796
-
1797
- /** Pop a value off of the stack. */
1798
- pop() {
1799
- // @ts-expect-error - TS2322 - Type 'T | undefined' is not assignable to type 'T'.
1800
- return this.stack.pop();
1801
- }
1802
-
1803
- /** Return the top value of the stack without popping it. */
1804
- top() {
1805
- return this.stack[this.stack.length - 1];
1806
- }
1807
-
1808
- /** Return a copy of the stack as an array */
1809
- values() {
1810
- return this.stack.slice(0);
1811
- }
1812
-
1813
- /** Return the number of elements in the stack */
1814
- size() {
1815
- return this.stack.length;
1816
- }
1817
-
1818
- /** Return a string representation of the stack */
1819
- toString() {
1820
- return this.stack.toString();
1821
- }
1822
-
1823
- /** Return a shallow copy of the stack */
1824
- clone() {
1825
- return new Stack(this.stack);
1826
- }
1827
-
1828
- /**
1829
- * Compare this stack to another and return true if the contents of
1830
- * the two arrays are the same.
1831
- */
1832
- equals(that) {
1833
- if (!that || !that.stack || that.stack.length !== this.stack.length) {
1834
- return false;
1835
- }
1836
- for (let i = 0; i < this.stack.length; i++) {
1837
- if (this.stack[i] !== that.stack[i]) {
1838
- return false;
1839
- }
1840
- }
1841
- return true;
1842
- }
1843
- }
1844
-
1845
- /**
1846
- * Adds the given perseus library version information to the __perseus_debug__
1847
- * object and ensures that the object is attached to `globalThis` (`window` in
1848
- * browser environments).
1849
- *
1850
- * This allows each library to provide runtime version information to assist in
1851
- * debugging in production environments.
1852
- */
1853
- const addLibraryVersionToPerseusDebug = (libraryName, libraryVersion) => {
1854
- // If the library version is the default value, then we don't want to
1855
- // prefix it with a "v" to indicate that it is a version number.
1856
- let prefix = "v";
1857
- if (libraryVersion === "__lib_version__") {
1858
- prefix = "";
1859
- }
1860
- const formattedVersion = `${prefix}${libraryVersion}`;
1861
- if (typeof globalThis !== "undefined") {
1862
- var _globalThis$__perseus;
1863
- globalThis.__perseus_debug__ = (_globalThis$__perseus = globalThis.__perseus_debug__) != null ? _globalThis$__perseus : {};
1864
- const existingVersionEntry = globalThis.__perseus_debug__[libraryName];
1865
- if (existingVersionEntry) {
1866
- // If we already have an entry and it doesn't match the registered
1867
- // version, we morph the entry into an array and log a warning.
1868
- if (existingVersionEntry !== formattedVersion) {
1869
- // Existing entry might be an array already (oops, at least 2
1870
- // versions of the library already loaded!).
1871
- const allVersions = Array.isArray(existingVersionEntry) ? existingVersionEntry : [existingVersionEntry];
1872
- allVersions.push(formattedVersion);
1873
- globalThis.__perseus_debug__[libraryName] = allVersions;
1874
-
1875
- // eslint-disable-next-line no-console
1876
- console.warn(`Multiple versions of ${libraryName} loaded on this page: ${allVersions.sort().join(", ")}`);
1877
- }
1878
- } else {
1879
- globalThis.__perseus_debug__[libraryName] = formattedVersion;
1880
- }
1881
- } else {
1882
- // eslint-disable-next-line no-console
1883
- console.warn(`globalThis not found found (${formattedVersion})`);
1884
- }
1885
- };
1886
-
1887
- // This file is processed by a Rollup plugin (replace) to inject the production
1888
- const libName = "@khanacademy/perseus-linter";
1889
- const libVersion = "1.3.6";
1890
- addLibraryVersionToPerseusDebug(libName, libVersion);
1891
-
1892
- // Define the shape of the linter context object that is passed through the
1893
- const linterContextProps = PropTypes.shape({
1894
- contentType: PropTypes.string,
1895
- highlightLint: PropTypes.bool,
1896
- paths: PropTypes.arrayOf(PropTypes.string),
1897
- stack: PropTypes.arrayOf(PropTypes.string)
1898
- });
1899
- const linterContextDefault = {
1900
- contentType: "",
1901
- highlightLint: false,
1902
- paths: [],
1903
- stack: []
1904
- };
1905
-
1906
- const allLintRules = AllRules.filter(r => r.severity < Rule.Severity.BULK_WARNING);
1907
-
1908
- /**
1909
- * Run the Perseus linter over the specified markdown parse tree,
1910
- * with the specified context object, and
1911
- * return a (possibly empty) array of lint warning objects. If the
1912
- * highlight argument is true, this function also modifies the parse
1913
- * tree to add "lint" nodes that can be visually rendered,
1914
- * highlighting the problems for the user. The optional rules argument
1915
- * is an array of Rule objects specifying which lint rules should be
1916
- * applied to this parse tree. When omitted, a default set of rules is used.
1917
- *
1918
- * The context object may have additional properties that some lint
1919
- * rules require:
1920
- *
1921
- * context.content is the source content string that was parsed to create
1922
- * the parse tree.
1923
- *
1924
- * context.widgets is the widgets object associated
1925
- * with the content string
1926
- *
1927
- * TODO: to make this even more general, allow the first argument to be
1928
- * a string and run the parser over it in that case? (but ignore highlight
1929
- * in that case). This would allow the one function to be used for both
1930
- * online linting and batch linting.
1931
- */
1932
- function runLinter(tree, context, highlight, rules = allLintRules) {
1933
- const warnings = [];
1934
- const tt = new TreeTransformer(tree);
1935
-
1936
- // The markdown parser often outputs adjacent text nodes. We
1937
- // coalesce them before linting for efficiency and accuracy.
1938
- tt.traverse((node, state, content) => {
1939
- if (TreeTransformer.isTextNode(node)) {
1940
- let next = state.nextSibling();
1941
- while (TreeTransformer.isTextNode(next)) {
1942
- // @ts-expect-error - 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'.
1943
- node.content += next.content;
1944
- state.removeNextSibling();
1945
- next = state.nextSibling();
1946
- }
1947
- }
1948
- });
1949
-
1950
- // HTML tables are complicated, and the CSS we use in
1951
- // ../components/lint.jsx to display lint does not work to
1952
- // correctly position the lint indicators in the margin when the
1953
- // lint is inside a table. So as a workaround we keep track of all
1954
- // the lint that appears within a table and move it up to the
1955
- // table element itself.
1956
- //
1957
- // It is not ideal to have to do this here,
1958
- // but it is cleaner here than fixing up the lint during rendering
1959
- // in perseus-markdown.jsx. If our lint display was simpler and
1960
- // did not require indicators in the margin, this wouldn't be a
1961
- // problem. Or, if we modified the lint display stuff so that
1962
- // indicator positioning and tooltip display were both handled
1963
- // with JavaScript (instead of pure CSS), then we could avoid this
1964
- // issue too. But using JavaScript has its own downsides: there is
1965
- // risk that the linter JavaScript would interfere with
1966
- // widget-related Javascript.
1967
- let tableWarnings = [];
1968
- let insideTable = false;
1969
-
1970
- // Traverse through the nodes of the parse tree. At each node, loop
1971
- // through the array of lint rules and check whether there is a
1972
- // lint violation at that node.
1973
- tt.traverse((node, state, content) => {
1974
- const nodeWarnings = [];
1975
-
1976
- // If our rule is only designed to be tested against a particular
1977
- // content type and we're not in that content type, we don't need to
1978
- // consider that rule.
1979
- const applicableRules = rules.filter(r => r.applies(context));
1980
-
1981
- // Generate a stack so we can identify our position in the tree in
1982
- // lint rules
1983
- const stack = [...context.stack];
1984
- stack.push(node.type);
1985
- const nodeContext = _extends({}, context, {
1986
- stack: stack.join(".")
1987
- });
1988
- applicableRules.forEach(rule => {
1989
- const warning = rule.check(node, state, content, nodeContext);
1990
- if (warning) {
1991
- // The start and end locations are relative to this
1992
- // particular node, and so are not generally very useful.
1993
- // TODO: When the markdown parser saves the node
1994
- // locations in the source string then we can add
1995
- // these numbers to that one and get and absolute
1996
- // character range that will be useful
1997
- if (warning.start || warning.end) {
1998
- warning.target = content.substring(warning.start, warning.end);
1999
- }
2000
-
2001
- // Add the warning to the list of all lint we've found
2002
- warnings.push(warning);
2003
-
2004
- // If we're going to be highlighting lint, then we also
2005
- // need to keep track of warnings specific to this node.
2006
- if (highlight) {
2007
- nodeWarnings.push(warning);
2008
- }
2009
- }
2010
- });
2011
-
2012
- // If we're not highlighting lint in the tree, then we're done
2013
- // traversing this node.
2014
- if (!highlight) {
2015
- return;
2016
- }
2017
-
2018
- // If the node we are currently at is a table, and there was lint
2019
- // inside the table, then we want to add that lint here
2020
- if (node.type === "table") {
2021
- if (tableWarnings.length) {
2022
- nodeWarnings.push(...tableWarnings);
2023
- }
2024
-
2025
- // We're not in a table anymore, and don't have to remember
2026
- // the warnings for the table
2027
- insideTable = false;
2028
- tableWarnings = [];
2029
- } else if (!insideTable) {
2030
- // Otherwise, if we are not already inside a table, check
2031
- // to see if we've entered one. Because this is a post-order
2032
- // traversal we'll see the table contents before the table itself.
2033
- // Note that once we're inside the table, we don't have to
2034
- // do this check each time... We can just wait until we ascend
2035
- // up to the table, then we'll know we're out of it.
2036
- insideTable = state.ancestors().some(n => n.type === "table");
2037
- }
2038
-
2039
- // If we are inside a table and there were any warnings on
2040
- // this node, then we need to save the warnings for display
2041
- // on the table itself
2042
- if (insideTable && nodeWarnings.length) {
2043
- // @ts-expect-error - TS2345 - Argument of type 'any' is not assignable to parameter of type 'never'.
2044
- tableWarnings.push(...nodeWarnings);
2045
- }
2046
-
2047
- // If there were any warnings on this node, and if we're highlighting
2048
- // lint, then reparent the node so we can highlight it. Note that
2049
- // a single node can have multiple warnings. If this happends we
2050
- // concatenate the warnings and newline separate them. (The lint.jsx
2051
- // component that displays the warnings may want to convert the
2052
- // newlines into <br> tags.) We also provide a lint rule name
2053
- // so that lint.jsx can link to a document that provides more details
2054
- // on that particular lint rule. If there is more than one warning
2055
- // we only link to the first rule, however.
2056
- //
2057
- // Note that even if we're inside a table, we still reparent the
2058
- // linty node so that it can be highlighted. We just make a note
2059
- // of whether this lint is inside a table or not.
2060
- if (nodeWarnings.length) {
2061
- nodeWarnings.sort((a, b) => {
2062
- return a.severity - b.severity;
2063
- });
2064
- if (node.type !== "text" || nodeWarnings.length > 1) {
2065
- // If the linty node is not a text node, or if there is more
2066
- // than one warning on a text node, then reparent the entire
2067
- // node under a new lint node and put the warnings there.
2068
- state.replace({
2069
- type: "lint",
2070
- // @ts-expect-error - 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'.
2071
- content: node,
2072
- message: nodeWarnings.map(w => w.message).join("\n\n"),
2073
- ruleName: nodeWarnings[0].rule,
2074
- blockHighlight: nodeContext.blockHighlight,
2075
- insideTable: insideTable,
2076
- severity: nodeWarnings[0].severity
2077
- });
2078
- } else {
2079
- //
2080
- // Otherwise, it is a single warning on a text node, and we
2081
- // only want to highlight the actual linty part of that string
2082
- // of text. So we want to replace the text node with (in the
2083
- // general case) three nodes:
2084
- //
2085
- // 1) A new text node that holds the non-linty prefix
2086
- //
2087
- // 2) A lint node that is the parent of a new text node
2088
- // that holds the linty part
2089
- //
2090
- // 3) A new text node that holds the non-linty suffix
2091
- //
2092
- // If the lint begins and/or ends at the boundaries of the
2093
- // original text node, then nodes 1 and/or 3 won't exist, of
2094
- // course.
2095
- //
2096
- // Note that we could generalize this to work with multple
2097
- // warnings on a text node as long as the warnings are
2098
- // non-overlapping. Hopefully, though, multiple warnings in a
2099
- // single text node will be rare in practice. Also, we don't
2100
- // have a good way to display multiple lint indicators on a
2101
- // single line, so keeping them combined in that case might
2102
- // be the best thing, anyway.
2103
- //
2104
- // @ts-expect-error - TS2339 - Property 'content' does not exist on type 'TreeNode'.
2105
- const _content = node.content; // Text nodes have content
2106
- const warning = nodeWarnings[0]; // There is only one warning.
2107
- // These are the lint boundaries within the content
2108
- const start = warning.start || 0;
2109
- const end = warning.end || _content.length;
2110
- const prefix = _content.substring(0, start);
2111
- const lint = _content.substring(start, end);
2112
- const suffix = _content.substring(end);
2113
- // TODO(FEI-5003): Give this a real type.
2114
- const replacements = []; // What we'll replace the node with
2115
-
2116
- // The prefix text node, if there is one
2117
- if (prefix) {
2118
- replacements.push({
2119
- type: "text",
2120
- content: prefix
2121
- });
2122
- }
2123
-
2124
- // The lint node wrapped around the linty text
2125
- replacements.push({
2126
- type: "lint",
2127
- content: {
2128
- type: "text",
2129
- content: lint
2130
- },
2131
- message: warning.message,
2132
- ruleName: warning.rule,
2133
- insideTable: insideTable,
2134
- severity: warning.severity
2135
- });
2136
-
2137
- // The suffix node, if there is one
2138
- if (suffix) {
2139
- replacements.push({
2140
- type: "text",
2141
- content: suffix
2142
- });
2143
- }
2144
-
2145
- // Now replace the lint text node with the one to three
2146
- // nodes in the replacement array
2147
- state.replace(...replacements);
2148
- }
2149
- }
2150
- });
2151
- return warnings;
2152
- }
2153
- function pushContextStack(context, name) {
2154
- const stack = context.stack || [];
2155
- return _extends({}, context, {
2156
- stack: stack.concat(name)
2157
- });
2158
- }
2159
-
2160
- export { Rule, libVersion, linterContextDefault, linterContextProps, pushContextStack, allLintRules as rules, runLinter };
2161
- //# sourceMappingURL=index.js.map