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