@khanacademy/perseus-linter 0.3.0 → 0.3.1
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/CHANGELOG.md +8 -0
- package/dist/es/index.js +405 -220
- package/dist/es/index.js.map +1 -1
- package/package.json +3 -3
package/dist/es/index.js
CHANGED
|
@@ -1,56 +1,25 @@
|
|
|
1
1
|
import { PerseusError, Errors } from '@khanacademy/perseus-error';
|
|
2
2
|
import PropTypes from 'prop-types';
|
|
3
3
|
|
|
4
|
-
function
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key));
|
|
21
|
-
});
|
|
22
|
-
}
|
|
23
|
-
return target;
|
|
24
|
-
}
|
|
25
|
-
function _defineProperty(obj, key, value) {
|
|
26
|
-
key = _toPropertyKey(key);
|
|
27
|
-
if (key in obj) {
|
|
28
|
-
Object.defineProperty(obj, key, {
|
|
29
|
-
value: value,
|
|
30
|
-
enumerable: true,
|
|
31
|
-
configurable: true,
|
|
32
|
-
writable: true
|
|
33
|
-
});
|
|
34
|
-
} else {
|
|
35
|
-
obj[key] = value;
|
|
36
|
-
}
|
|
37
|
-
return obj;
|
|
38
|
-
}
|
|
39
|
-
function _toPrimitive(input, hint) {
|
|
40
|
-
if (typeof input !== "object" || input === null) return input;
|
|
41
|
-
var prim = input[Symbol.toPrimitive];
|
|
42
|
-
if (prim !== undefined) {
|
|
43
|
-
var res = prim.call(input, hint || "default");
|
|
44
|
-
if (typeof res !== "object") return res;
|
|
45
|
-
throw new TypeError("@@toPrimitive must return a primitive value.");
|
|
46
|
-
}
|
|
47
|
-
return (hint === "string" ? String : Number)(input);
|
|
48
|
-
}
|
|
49
|
-
function _toPropertyKey(arg) {
|
|
50
|
-
var key = _toPrimitive(arg, "string");
|
|
51
|
-
return typeof key === "symbol" ? key : String(key);
|
|
4
|
+
function _extends() {
|
|
5
|
+
_extends = Object.assign || function (target) {
|
|
6
|
+
for (var i = 1; i < arguments.length; i++) {
|
|
7
|
+
var source = arguments[i];
|
|
8
|
+
|
|
9
|
+
for (var key in source) {
|
|
10
|
+
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
|
11
|
+
target[key] = source[key];
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return target;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
return _extends.apply(this, arguments);
|
|
52
20
|
}
|
|
53
21
|
|
|
22
|
+
/* eslint-disable no-useless-escape */
|
|
54
23
|
/**
|
|
55
24
|
* This is the base class for all Selector types. The key method that all
|
|
56
25
|
* selector subclasses must implement is match(). It takes a TraversalState
|
|
@@ -96,8 +65,8 @@ class Parser {
|
|
|
96
65
|
// Which token in the array we're looking at now
|
|
97
66
|
|
|
98
67
|
constructor(s) {
|
|
99
|
-
|
|
100
|
-
|
|
68
|
+
this.tokens = void 0;
|
|
69
|
+
this.tokenIndex = void 0;
|
|
101
70
|
// Normalize whitespace:
|
|
102
71
|
// - remove leading and trailing whitespace
|
|
103
72
|
// - replace runs of whitespace with single space characters
|
|
@@ -124,7 +93,7 @@ class Parser {
|
|
|
124
93
|
isIdentifier() {
|
|
125
94
|
// The Parser.TOKENS regexp ensures that we only have to check
|
|
126
95
|
// the first character of a token to know what kind of token it is.
|
|
127
|
-
|
|
96
|
+
const c = this.tokens[this.tokenIndex][0];
|
|
128
97
|
return c >= "a" && c <= "z" || c >= "A" && c <= "Z";
|
|
129
98
|
}
|
|
130
99
|
|
|
@@ -140,10 +109,10 @@ class Parser {
|
|
|
140
109
|
// ever need to call.
|
|
141
110
|
parse() {
|
|
142
111
|
// We expect at least one tree selector
|
|
143
|
-
|
|
112
|
+
const ts = this.parseTreeSelector();
|
|
144
113
|
|
|
145
114
|
// Now see what's next
|
|
146
|
-
|
|
115
|
+
let token = this.nextToken();
|
|
147
116
|
|
|
148
117
|
// If there is no next token then we're done parsing and can return
|
|
149
118
|
// the tree selector object we got above
|
|
@@ -153,7 +122,7 @@ class Parser {
|
|
|
153
122
|
|
|
154
123
|
// Otherwise, there is more go come and we're going to need a
|
|
155
124
|
// list of tree selectors
|
|
156
|
-
|
|
125
|
+
const treeSelectors = [ts];
|
|
157
126
|
while (token) {
|
|
158
127
|
// The only character we allow after a tree selector is a comma
|
|
159
128
|
if (token === ",") {
|
|
@@ -179,7 +148,7 @@ class Parser {
|
|
|
179
148
|
this.skipSpace(); // Ignore space after a comma, for example
|
|
180
149
|
|
|
181
150
|
// A tree selector must begin with a node selector
|
|
182
|
-
|
|
151
|
+
let ns = this.parseNodeSelector();
|
|
183
152
|
for (;;) {
|
|
184
153
|
// Now check the next token. If there is none, or if it is a
|
|
185
154
|
// comma, then we're done with the treeSelector. Otherwise
|
|
@@ -188,7 +157,7 @@ class Parser {
|
|
|
188
157
|
// do see a combinator and another node selector then we
|
|
189
158
|
// combine the current node selector with the new node selector
|
|
190
159
|
// using a Selector subclass that depends on the combinator.
|
|
191
|
-
|
|
160
|
+
const token = this.nextToken();
|
|
192
161
|
if (!token || token === ",") {
|
|
193
162
|
break;
|
|
194
163
|
} else if (token === " ") {
|
|
@@ -219,7 +188,7 @@ class Parser {
|
|
|
219
188
|
parseNodeSelector() {
|
|
220
189
|
// First, skip any whitespace
|
|
221
190
|
this.skipSpace();
|
|
222
|
-
|
|
191
|
+
const t = this.nextToken();
|
|
223
192
|
if (t === "*") {
|
|
224
193
|
this.consume();
|
|
225
194
|
return new AnyNode();
|
|
@@ -236,7 +205,7 @@ class Parser {
|
|
|
236
205
|
// are identifiers, integers, punctuation and spaces. Note that spaces
|
|
237
206
|
// tokens are only returned when they appear before an identifier or
|
|
238
207
|
// wildcard token and are otherwise omitted.
|
|
239
|
-
|
|
208
|
+
Parser.TOKENS = void 0;
|
|
240
209
|
Parser.TOKENS = /([a-zA-Z][\w-]*)|(\d+)|[^\s]|(\s(?=[a-zA-Z\*]))/g;
|
|
241
210
|
|
|
242
211
|
/**
|
|
@@ -257,13 +226,13 @@ class ParseError extends Error {
|
|
|
257
226
|
class SelectorList extends Selector {
|
|
258
227
|
constructor(selectors) {
|
|
259
228
|
super();
|
|
260
|
-
|
|
229
|
+
this.selectors = void 0;
|
|
261
230
|
this.selectors = selectors;
|
|
262
231
|
}
|
|
263
232
|
match(state) {
|
|
264
|
-
for (
|
|
265
|
-
|
|
266
|
-
|
|
233
|
+
for (let i = 0; i < this.selectors.length; i++) {
|
|
234
|
+
const s = this.selectors[i];
|
|
235
|
+
const result = s.match(state);
|
|
267
236
|
if (result) {
|
|
268
237
|
return result;
|
|
269
238
|
}
|
|
@@ -271,8 +240,8 @@ class SelectorList extends Selector {
|
|
|
271
240
|
return null;
|
|
272
241
|
}
|
|
273
242
|
toString() {
|
|
274
|
-
|
|
275
|
-
for (
|
|
243
|
+
let result = "";
|
|
244
|
+
for (let i = 0; i < this.selectors.length; i++) {
|
|
276
245
|
result += i > 0 ? ", " : "";
|
|
277
246
|
result += this.selectors[i].toString();
|
|
278
247
|
}
|
|
@@ -300,11 +269,11 @@ class AnyNode extends Selector {
|
|
|
300
269
|
class TypeSelector extends Selector {
|
|
301
270
|
constructor(type) {
|
|
302
271
|
super();
|
|
303
|
-
|
|
272
|
+
this.type = void 0;
|
|
304
273
|
this.type = type;
|
|
305
274
|
}
|
|
306
275
|
match(state) {
|
|
307
|
-
|
|
276
|
+
const node = state.currentNode();
|
|
308
277
|
if (node.type === this.type) {
|
|
309
278
|
return [node];
|
|
310
279
|
}
|
|
@@ -324,8 +293,8 @@ class TypeSelector extends Selector {
|
|
|
324
293
|
class SelectorCombinator extends Selector {
|
|
325
294
|
constructor(left, right) {
|
|
326
295
|
super();
|
|
327
|
-
|
|
328
|
-
|
|
296
|
+
this.left = void 0;
|
|
297
|
+
this.right = void 0;
|
|
329
298
|
this.left = left;
|
|
330
299
|
this.right = right;
|
|
331
300
|
}
|
|
@@ -341,12 +310,12 @@ class AncestorCombinator extends SelectorCombinator {
|
|
|
341
310
|
super(left, right);
|
|
342
311
|
}
|
|
343
312
|
match(state) {
|
|
344
|
-
|
|
313
|
+
const rightResult = this.right.match(state);
|
|
345
314
|
if (rightResult) {
|
|
346
315
|
state = state.clone();
|
|
347
316
|
while (state.hasParent()) {
|
|
348
317
|
state.goToParent();
|
|
349
|
-
|
|
318
|
+
const leftResult = this.left.match(state);
|
|
350
319
|
if (leftResult) {
|
|
351
320
|
return leftResult.concat(rightResult);
|
|
352
321
|
}
|
|
@@ -369,12 +338,12 @@ class ParentCombinator extends SelectorCombinator {
|
|
|
369
338
|
super(left, right);
|
|
370
339
|
}
|
|
371
340
|
match(state) {
|
|
372
|
-
|
|
341
|
+
const rightResult = this.right.match(state);
|
|
373
342
|
if (rightResult) {
|
|
374
343
|
if (state.hasParent()) {
|
|
375
344
|
state = state.clone();
|
|
376
345
|
state.goToParent();
|
|
377
|
-
|
|
346
|
+
const leftResult = this.left.match(state);
|
|
378
347
|
if (leftResult) {
|
|
379
348
|
return leftResult.concat(rightResult);
|
|
380
349
|
}
|
|
@@ -397,12 +366,12 @@ class PreviousCombinator extends SelectorCombinator {
|
|
|
397
366
|
super(left, right);
|
|
398
367
|
}
|
|
399
368
|
match(state) {
|
|
400
|
-
|
|
369
|
+
const rightResult = this.right.match(state);
|
|
401
370
|
if (rightResult) {
|
|
402
371
|
if (state.hasPreviousSibling()) {
|
|
403
372
|
state = state.clone();
|
|
404
373
|
state.goToPreviousSibling();
|
|
405
|
-
|
|
374
|
+
const leftResult = this.left.match(state);
|
|
406
375
|
if (leftResult) {
|
|
407
376
|
return leftResult.concat(rightResult);
|
|
408
377
|
}
|
|
@@ -425,12 +394,12 @@ class SiblingCombinator extends SelectorCombinator {
|
|
|
425
394
|
super(left, right);
|
|
426
395
|
}
|
|
427
396
|
match(state) {
|
|
428
|
-
|
|
397
|
+
const rightResult = this.right.match(state);
|
|
429
398
|
if (rightResult) {
|
|
430
399
|
state = state.clone();
|
|
431
400
|
while (state.hasPreviousSibling()) {
|
|
432
401
|
state.goToPreviousSibling();
|
|
433
|
-
|
|
402
|
+
const leftResult = this.left.match(state);
|
|
434
403
|
if (leftResult) {
|
|
435
404
|
return leftResult.concat(rightResult);
|
|
436
405
|
}
|
|
@@ -443,6 +412,128 @@ class SiblingCombinator extends SelectorCombinator {
|
|
|
443
412
|
}
|
|
444
413
|
}
|
|
445
414
|
|
|
415
|
+
/**
|
|
416
|
+
* The Rule class represents a Perseus lint rule. A Rule instance has a check()
|
|
417
|
+
* method that takes the same (node, state, content) arguments that a
|
|
418
|
+
* TreeTransformer traversal callback function does. Call the check() method
|
|
419
|
+
* during a tree traversal to determine whether the current node of the tree
|
|
420
|
+
* violates the rule. If there is no violation, then check() returns
|
|
421
|
+
* null. Otherwise, it returns an object that includes the name of the rule,
|
|
422
|
+
* an error message, and the start and end positions within the node's content
|
|
423
|
+
* string of the lint.
|
|
424
|
+
*
|
|
425
|
+
* A Perseus lint rule consists of a name, a severity, a selector, a pattern
|
|
426
|
+
* (RegExp) and two functions. The check() method uses the selector, pattern,
|
|
427
|
+
* and functions as follows:
|
|
428
|
+
*
|
|
429
|
+
* - First, when determining which rules to apply to a particular piece of
|
|
430
|
+
* content, each rule can specify an optional function provided in the fifth
|
|
431
|
+
* parameter to evaluate whether or not we should be applying this rule.
|
|
432
|
+
* If the function returns false, we don't use the rule on this content.
|
|
433
|
+
*
|
|
434
|
+
* - Next, check() tests whether the node currently being traversed matches
|
|
435
|
+
* the selector. If it does not, then the rule does not apply at this node
|
|
436
|
+
* and there is no lint and check() returns null.
|
|
437
|
+
*
|
|
438
|
+
* - If the selector matched, then check() tests the text content of the node
|
|
439
|
+
* (and its children) against the pattern. If the pattern does not match,
|
|
440
|
+
* then there is no lint, and check() returns null.
|
|
441
|
+
*
|
|
442
|
+
* - If both the selector and pattern match, then check() calls the function
|
|
443
|
+
* passing the TraversalState object, the content string for the node, the
|
|
444
|
+
* array of nodes returned by the selector match, and the array of strings
|
|
445
|
+
* returned by the pattern match. This function can use these arguments to
|
|
446
|
+
* implement any kind of lint detection logic it wants. If it determines
|
|
447
|
+
* that there is no lint, then it should return null. Otherwise, it should
|
|
448
|
+
* return an error message as a string, or an object with `message`, `start`
|
|
449
|
+
* and `end` properties. The start and end properties are numbers that mark
|
|
450
|
+
* the beginning and end of the problematic content. Note that these numbers
|
|
451
|
+
* are relative to the content string passed to the traversal callback, not
|
|
452
|
+
* to the entire string that was used to generate the parse tree in the
|
|
453
|
+
* first place. TODO(davidflanagan): modify the simple-markdown library to
|
|
454
|
+
* have an option to add the text offset of each node to the parse
|
|
455
|
+
* tree. This will allows us to pinpoint lint errors within a long string
|
|
456
|
+
* of markdown text.
|
|
457
|
+
*
|
|
458
|
+
* - If the function returns null, then check() returns null. Otherwise,
|
|
459
|
+
* check() returns an object with `rule`, `message`, `start` and `end`
|
|
460
|
+
* properties. The value of the `rule` property is the name of the rule,
|
|
461
|
+
* which is useful for error reporting purposes.
|
|
462
|
+
*
|
|
463
|
+
* The name, severity, selector, pattern and function arguments to the Rule()
|
|
464
|
+
* constructor are optional, but you may not omit both the selector and the
|
|
465
|
+
* pattern. If you do not specify a selector, a default selector that matches
|
|
466
|
+
* any node of type "text" will be used. If you do not specify a pattern, then
|
|
467
|
+
* any node that matches the selector will be assumed to match the pattern as
|
|
468
|
+
* well. If you don't pass a function as the fourth argument to the Rule()
|
|
469
|
+
* constructor, then you must pass an error message string instead. If you do
|
|
470
|
+
* this, you'll get a default function that unconditionally returns an object
|
|
471
|
+
* that includes the error message and the start and end indexes of the
|
|
472
|
+
* portion of the content string that matched the pattern. If you don't pass a
|
|
473
|
+
* function in the fifth parameter, the rule will be applied in any context.
|
|
474
|
+
*
|
|
475
|
+
* One of the design goals of this Rule class is to allow simple lint rules to
|
|
476
|
+
* be described in JSON files without any JavaScript code. So in addition to
|
|
477
|
+
* the Rule() constructor, the class also defines a Rule.makeRule() factory
|
|
478
|
+
* method. This method takes a single object as its argument and expects the
|
|
479
|
+
* object to have four string properties. The `name` property is passed as the
|
|
480
|
+
* first argument to the Rule() construtctor. The optional `selector`
|
|
481
|
+
* property, if specified, is passed to Selector.parse() and the resulting
|
|
482
|
+
* Selector object is used as the second argument to Rule(). The optional
|
|
483
|
+
* `pattern` property is converted to a RegExp before being passed as the
|
|
484
|
+
* third argument to Rule(). (See Rule.makePattern() for details on the string
|
|
485
|
+
* to RegExp conversion). Finally, the `message` property specifies an error
|
|
486
|
+
* message that is passed as the final argument to Rule(). You can also use a
|
|
487
|
+
* real RegExp as the value of the `pattern` property or define a custom lint
|
|
488
|
+
* function on the `lint` property instead of setting the `message`
|
|
489
|
+
* property. Doing either of these things means that your rule description can
|
|
490
|
+
* no longer be saved in a JSON file, however.
|
|
491
|
+
*
|
|
492
|
+
* For example, here are two lint rules defined with Rule.makeRule():
|
|
493
|
+
*
|
|
494
|
+
* let nestedLists = Rule.makeRule({
|
|
495
|
+
* name: "nested-lists",
|
|
496
|
+
* selector: "list list",
|
|
497
|
+
* message: `Nested lists:
|
|
498
|
+
* nested lists are hard to read on mobile devices;
|
|
499
|
+
* do not use additional indentation.`,
|
|
500
|
+
* });
|
|
501
|
+
*
|
|
502
|
+
* let longParagraph = Rule.makeRule({
|
|
503
|
+
* name: "long-paragraph",
|
|
504
|
+
* selector: "paragraph",
|
|
505
|
+
* pattern: /^.{501,}/,
|
|
506
|
+
* lint: function(state, content, nodes, match) {
|
|
507
|
+
* return `Paragraph too long:
|
|
508
|
+
* This paragraph is ${content.length} characters long.
|
|
509
|
+
* Shorten it to 500 characters or fewer.`;
|
|
510
|
+
* },
|
|
511
|
+
* });
|
|
512
|
+
*
|
|
513
|
+
* Certain advanced lint rules need additional information about the content
|
|
514
|
+
* being linted in order to detect lint. For example, a rule to check for
|
|
515
|
+
* whitespace at the start and end of the URL for an image can't use the
|
|
516
|
+
* information in the node or content arguments because the markdown parser
|
|
517
|
+
* strips leading and trailing whitespace when parsing. (Nevertheless, these
|
|
518
|
+
* spaces have been a practical problem for our content translation process so
|
|
519
|
+
* in order to check for them, a lint rule needs access to the original
|
|
520
|
+
* unparsed source text. Similarly, there are various lint rules that check
|
|
521
|
+
* widget usage. For example, it is easy to write a lint rule to ensure that
|
|
522
|
+
* images have alt text for images encoded in markdown. But when images are
|
|
523
|
+
* added to our content via an image widget we also want to be able to check
|
|
524
|
+
* for alt text. In order to do this, the lint rule needs to be able to look
|
|
525
|
+
* widgets up by name in the widgets object associated with the parse tree.
|
|
526
|
+
*
|
|
527
|
+
* In order to support advanced linting rules like these, the check() method
|
|
528
|
+
* takes a context object as its optional fourth argument, and passes this
|
|
529
|
+
* object on to the lint function of each rule. Rules that require extra
|
|
530
|
+
* context should not assume that they will always get it, and should verify
|
|
531
|
+
* that the necessary context has been supplied before using it. Currently the
|
|
532
|
+
* "content" property of the context object is the unparsed source text if
|
|
533
|
+
* available, and the "widgets" property of the context object is the widget
|
|
534
|
+
* object associated with that content string in the JSON object that defines
|
|
535
|
+
* the Perseus article or exercise that is being linted.
|
|
536
|
+
*/
|
|
446
537
|
/**
|
|
447
538
|
* A Rule object describes a Perseus lint rule. See the comment at the top of
|
|
448
539
|
* this file for detailed description.
|
|
@@ -459,14 +550,13 @@ class Rule {
|
|
|
459
550
|
// The comment at the top of this file has detailed docs for
|
|
460
551
|
// this constructor and its arguments
|
|
461
552
|
constructor(name, severity, selector, pattern, lint, applies) {
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
_defineProperty(this, "message", void 0);
|
|
553
|
+
this.name = void 0;
|
|
554
|
+
this.severity = void 0;
|
|
555
|
+
this.selector = void 0;
|
|
556
|
+
this.pattern = void 0;
|
|
557
|
+
this.lint = void 0;
|
|
558
|
+
this.applies = void 0;
|
|
559
|
+
this.message = void 0;
|
|
470
560
|
if (!selector && !pattern) {
|
|
471
561
|
throw new PerseusError("Lint rules must have a selector or pattern", Errors.InvalidInput, {
|
|
472
562
|
metadata: {
|
|
@@ -485,9 +575,7 @@ class Rule {
|
|
|
485
575
|
this.lint = lint;
|
|
486
576
|
this.message = null;
|
|
487
577
|
} else {
|
|
488
|
-
this.lint =
|
|
489
|
-
return _this._defaultLintFunction(...arguments);
|
|
490
|
-
};
|
|
578
|
+
this.lint = (...args) => this._defaultLintFunction(...args);
|
|
491
579
|
this.message = lint;
|
|
492
580
|
}
|
|
493
581
|
this.applies = applies || function () {
|
|
@@ -508,7 +596,7 @@ class Rule {
|
|
|
508
596
|
// First, see if we match the selector.
|
|
509
597
|
// If no selector was passed to the constructor, we use a
|
|
510
598
|
// default selector that matches text nodes.
|
|
511
|
-
|
|
599
|
+
const selectorMatch = this.selector.match(traversalState);
|
|
512
600
|
|
|
513
601
|
// If the selector did not match, then we're done
|
|
514
602
|
if (!selectorMatch) {
|
|
@@ -516,7 +604,7 @@ class Rule {
|
|
|
516
604
|
}
|
|
517
605
|
|
|
518
606
|
// If the selector matched, then see if the pattern matches
|
|
519
|
-
|
|
607
|
+
let patternMatch;
|
|
520
608
|
if (this.pattern) {
|
|
521
609
|
patternMatch = content.match(this.pattern);
|
|
522
610
|
} else {
|
|
@@ -532,7 +620,7 @@ class Rule {
|
|
|
532
620
|
try {
|
|
533
621
|
// If we get here, then the selector and pattern have matched
|
|
534
622
|
// so now we call the lint function to see if there is lint.
|
|
535
|
-
|
|
623
|
+
const error = this.lint(traversalState, content, selectorMatch, patternMatch, context);
|
|
536
624
|
if (!error) {
|
|
537
625
|
return null; // No lint; we're done
|
|
538
626
|
}
|
|
@@ -565,7 +653,9 @@ class Rule {
|
|
|
565
653
|
// a rule was failing.
|
|
566
654
|
return {
|
|
567
655
|
rule: "lint-rule-failure",
|
|
568
|
-
message:
|
|
656
|
+
message: `Exception in rule ${this.name}: ${e.message}
|
|
657
|
+
Stack trace:
|
|
658
|
+
${e.stack}`,
|
|
569
659
|
start: 0,
|
|
570
660
|
end: content.length
|
|
571
661
|
};
|
|
@@ -610,9 +700,9 @@ class Rule {
|
|
|
610
700
|
return pattern;
|
|
611
701
|
}
|
|
612
702
|
if (pattern[0] === "/") {
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
703
|
+
const lastSlash = pattern.lastIndexOf("/");
|
|
704
|
+
const expression = pattern.substring(1, lastSlash);
|
|
705
|
+
const flags = pattern.substring(lastSlash + 1);
|
|
616
706
|
// @ts-expect-error [FEI-5003] - 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"]'?
|
|
617
707
|
return new RegExp(expression, flags);
|
|
618
708
|
}
|
|
@@ -624,19 +714,19 @@ class Rule {
|
|
|
624
714
|
// String.match() method. We use it when a Rule has no pattern and we
|
|
625
715
|
// want to simulate a match on the entire content string.
|
|
626
716
|
static FakePatternMatch(input, match, index) {
|
|
627
|
-
|
|
717
|
+
const result = [match];
|
|
628
718
|
result.index = index;
|
|
629
719
|
result.input = input;
|
|
630
720
|
return result;
|
|
631
721
|
}
|
|
632
722
|
}
|
|
633
|
-
|
|
634
|
-
|
|
723
|
+
Rule.DEFAULT_SELECTOR = void 0;
|
|
724
|
+
Rule.Severity = {
|
|
635
725
|
ERROR: 1,
|
|
636
726
|
WARNING: 2,
|
|
637
727
|
GUIDELINE: 3,
|
|
638
728
|
BULK_WARNING: 4
|
|
639
|
-
}
|
|
729
|
+
};
|
|
640
730
|
Rule.DEFAULT_SELECTOR = Selector.parse("text");
|
|
641
731
|
|
|
642
732
|
/* eslint-disable no-useless-escape */
|
|
@@ -644,7 +734,7 @@ Rule.DEFAULT_SELECTOR = Selector.parse("text");
|
|
|
644
734
|
// portion which is usually just the hostname, but may also include
|
|
645
735
|
// a username, password or port. We don't strip those things out because
|
|
646
736
|
// we typically want to reject any URL that includes them
|
|
647
|
-
|
|
737
|
+
const HOSTNAME = /\/\/([^\/]+)/;
|
|
648
738
|
|
|
649
739
|
// Return the hostname of the URL, with any "www." prefix removed.
|
|
650
740
|
// If this is a relative URL with no hostname, return an empty string.
|
|
@@ -652,7 +742,7 @@ function getHostname(url) {
|
|
|
652
742
|
if (!url) {
|
|
653
743
|
return "";
|
|
654
744
|
}
|
|
655
|
-
|
|
745
|
+
const match = url.match(HOSTNAME);
|
|
656
746
|
return match ? match[1] : "";
|
|
657
747
|
}
|
|
658
748
|
|
|
@@ -660,11 +750,14 @@ var AbsoluteUrl = Rule.makeRule({
|
|
|
660
750
|
name: "absolute-url",
|
|
661
751
|
severity: Rule.Severity.GUIDELINE,
|
|
662
752
|
selector: "link, image",
|
|
663
|
-
lint: function
|
|
664
|
-
|
|
665
|
-
|
|
753
|
+
lint: function (state, content, nodes, match) {
|
|
754
|
+
const url = nodes[0].target;
|
|
755
|
+
const hostname = getHostname(url);
|
|
666
756
|
if (hostname === "khanacademy.org" || hostname.endsWith(".khanacademy.org")) {
|
|
667
|
-
return
|
|
757
|
+
return `Don't use absolute URLs:
|
|
758
|
+
When linking to KA content or images, omit the
|
|
759
|
+
https://www.khanacademy.org URL prefix.
|
|
760
|
+
Use a relative URL beginning with / instead.`;
|
|
668
761
|
}
|
|
669
762
|
}
|
|
670
763
|
});
|
|
@@ -673,14 +766,16 @@ var BlockquotedMath = Rule.makeRule({
|
|
|
673
766
|
name: "blockquoted-math",
|
|
674
767
|
severity: Rule.Severity.WARNING,
|
|
675
768
|
selector: "blockQuote math, blockQuote blockMath",
|
|
676
|
-
message:
|
|
769
|
+
message: `Blockquoted math:
|
|
770
|
+
math should not be indented.`
|
|
677
771
|
});
|
|
678
772
|
|
|
679
773
|
var BlockquotedWidget = Rule.makeRule({
|
|
680
774
|
name: "blockquoted-widget",
|
|
681
775
|
severity: Rule.Severity.WARNING,
|
|
682
776
|
selector: "blockQuote widget",
|
|
683
|
-
message:
|
|
777
|
+
message: `Blockquoted widget:
|
|
778
|
+
widgets should not be indented.`
|
|
684
779
|
});
|
|
685
780
|
|
|
686
781
|
/* eslint-disable no-useless-escape */
|
|
@@ -689,26 +784,28 @@ var DoubleSpacingAfterTerminal = Rule.makeRule({
|
|
|
689
784
|
severity: Rule.Severity.BULK_WARNING,
|
|
690
785
|
selector: "paragraph",
|
|
691
786
|
pattern: /[.!\?] {2}/i,
|
|
692
|
-
message:
|
|
787
|
+
message: `Use a single space after a sentence-ending period, or
|
|
788
|
+
any other kind of terminal punctuation.`
|
|
693
789
|
});
|
|
694
790
|
|
|
695
791
|
var ExtraContentSpacing = Rule.makeRule({
|
|
696
792
|
name: "extra-content-spacing",
|
|
697
793
|
selector: "paragraph",
|
|
698
794
|
pattern: /\s+$/,
|
|
699
|
-
applies: function
|
|
795
|
+
applies: function (context) {
|
|
700
796
|
return context.contentType === "article";
|
|
701
797
|
},
|
|
702
|
-
message:
|
|
798
|
+
message: `No extra whitespace at the end of content blocks.`
|
|
703
799
|
});
|
|
704
800
|
|
|
705
801
|
var HeadingLevel1 = Rule.makeRule({
|
|
706
802
|
name: "heading-level-1",
|
|
707
803
|
severity: Rule.Severity.WARNING,
|
|
708
804
|
selector: "heading",
|
|
709
|
-
lint: function
|
|
805
|
+
lint: function (state, content, nodes, match) {
|
|
710
806
|
if (nodes[0].level === 1) {
|
|
711
|
-
return
|
|
807
|
+
return `Don't use level-1 headings:
|
|
808
|
+
Begin headings with two or more # characters.`;
|
|
712
809
|
}
|
|
713
810
|
}
|
|
714
811
|
});
|
|
@@ -717,14 +814,16 @@ var HeadingLevelSkip = Rule.makeRule({
|
|
|
717
814
|
name: "heading-level-skip",
|
|
718
815
|
severity: Rule.Severity.WARNING,
|
|
719
816
|
selector: "heading ~ heading",
|
|
720
|
-
lint: function
|
|
721
|
-
|
|
722
|
-
|
|
817
|
+
lint: function (state, content, nodes, match) {
|
|
818
|
+
const currentHeading = nodes[1];
|
|
819
|
+
const previousHeading = nodes[0];
|
|
723
820
|
// A heading can have a level less than, the same as
|
|
724
821
|
// or one more than the previous heading. But going up
|
|
725
822
|
// by 2 or more levels is not right
|
|
726
823
|
if (currentHeading.level > previousHeading.level + 1) {
|
|
727
|
-
return
|
|
824
|
+
return `Skipped heading level:
|
|
825
|
+
this heading is level ${currentHeading.level} but
|
|
826
|
+
the previous heading was level ${previousHeading.level}`;
|
|
728
827
|
}
|
|
729
828
|
}
|
|
730
829
|
});
|
|
@@ -735,13 +834,14 @@ var HeadingSentenceCase = Rule.makeRule({
|
|
|
735
834
|
selector: "heading",
|
|
736
835
|
pattern: /^\W*[a-z]/,
|
|
737
836
|
// first letter is lowercase
|
|
738
|
-
message:
|
|
837
|
+
message: `First letter is lowercase:
|
|
838
|
+
the first letter of a heading should be capitalized.`
|
|
739
839
|
});
|
|
740
840
|
|
|
741
841
|
// These are 3-letter and longer words that we would not expect to be
|
|
742
842
|
// capitalized even in a title-case heading. See
|
|
743
843
|
// http://blog.apastyle.org/apastyle/2012/03/title-case-and-sentence-case-capitalization-in-apa-style.html
|
|
744
|
-
|
|
844
|
+
const littleWords = {
|
|
745
845
|
and: true,
|
|
746
846
|
nor: true,
|
|
747
847
|
but: true,
|
|
@@ -749,7 +849,7 @@ var littleWords = {
|
|
|
749
849
|
for: true
|
|
750
850
|
};
|
|
751
851
|
function isCapitalized(word) {
|
|
752
|
-
|
|
852
|
+
const c = word[0];
|
|
753
853
|
return c === c.toUpperCase();
|
|
754
854
|
}
|
|
755
855
|
var HeadingTitleCase = Rule.makeRule({
|
|
@@ -758,7 +858,7 @@ var HeadingTitleCase = Rule.makeRule({
|
|
|
758
858
|
selector: "heading",
|
|
759
859
|
pattern: /[^\s:]\s+[A-Z]+[a-z]/,
|
|
760
860
|
locale: "en",
|
|
761
|
-
lint: function
|
|
861
|
+
lint: function (state, content, nodes, match) {
|
|
762
862
|
// We want to assert that heading text is in sentence case, not
|
|
763
863
|
// title case. The pattern above requires a capital letter at the
|
|
764
864
|
// start of the heading and allows them after a colon, or in
|
|
@@ -783,8 +883,8 @@ var HeadingTitleCase = Rule.makeRule({
|
|
|
783
883
|
//
|
|
784
884
|
// for APA style rules for title case
|
|
785
885
|
|
|
786
|
-
|
|
787
|
-
|
|
886
|
+
const heading = content.trim();
|
|
887
|
+
let words = heading.split(/\s+/);
|
|
788
888
|
|
|
789
889
|
// Remove the first word and the little words
|
|
790
890
|
words.shift();
|
|
@@ -795,7 +895,9 @@ var HeadingTitleCase = Rule.makeRule({
|
|
|
795
895
|
// If there are at least 3 remaining words and all
|
|
796
896
|
// are capitalized, then the heading is in title case.
|
|
797
897
|
if (words.length >= 3 && words.every(w => isCapitalized(w))) {
|
|
798
|
-
return
|
|
898
|
+
return `Title-case heading:
|
|
899
|
+
This heading appears to be in title-case, but should be sentence-case.
|
|
900
|
+
Only capitalize the first letter and proper nouns.`;
|
|
799
901
|
}
|
|
800
902
|
}
|
|
801
903
|
});
|
|
@@ -804,13 +906,17 @@ var ImageAltText = Rule.makeRule({
|
|
|
804
906
|
name: "image-alt-text",
|
|
805
907
|
severity: Rule.Severity.WARNING,
|
|
806
908
|
selector: "image",
|
|
807
|
-
lint: function
|
|
808
|
-
|
|
909
|
+
lint: function (state, content, nodes, match) {
|
|
910
|
+
const image = nodes[0];
|
|
809
911
|
if (!image.alt || !image.alt.trim()) {
|
|
810
|
-
return
|
|
912
|
+
return `Images should have alt text:
|
|
913
|
+
for accessibility, all images should have alt text.
|
|
914
|
+
Specify alt text inside square brackets after the !.`;
|
|
811
915
|
}
|
|
812
916
|
if (image.alt.length < 8) {
|
|
813
|
-
return
|
|
917
|
+
return `Images should have alt text:
|
|
918
|
+
for accessibility, all images should have descriptive alt text.
|
|
919
|
+
This image's alt text is only ${image.alt.length} characters long.`;
|
|
814
920
|
}
|
|
815
921
|
}
|
|
816
922
|
});
|
|
@@ -819,16 +925,17 @@ var ImageInTable = Rule.makeRule({
|
|
|
819
925
|
name: "image-in-table",
|
|
820
926
|
severity: Rule.Severity.BULK_WARNING,
|
|
821
927
|
selector: "table image",
|
|
822
|
-
message:
|
|
928
|
+
message: `Image in table:
|
|
929
|
+
do not put images inside of tables.`
|
|
823
930
|
});
|
|
824
931
|
|
|
825
932
|
var ImageSpacesAroundUrls = Rule.makeRule({
|
|
826
933
|
name: "image-spaces-around-urls",
|
|
827
934
|
severity: Rule.Severity.ERROR,
|
|
828
935
|
selector: "image",
|
|
829
|
-
lint: function
|
|
830
|
-
|
|
831
|
-
|
|
936
|
+
lint: function (state, content, nodes, match, context) {
|
|
937
|
+
const image = nodes[0];
|
|
938
|
+
const url = image.target;
|
|
832
939
|
|
|
833
940
|
// The markdown parser strips leading and trailing spaces for us,
|
|
834
941
|
// but they're still a problem for our translation process, so
|
|
@@ -837,13 +944,15 @@ var ImageSpacesAroundUrls = Rule.makeRule({
|
|
|
837
944
|
if (context && context.content) {
|
|
838
945
|
// Find the url in the original content and make sure that the
|
|
839
946
|
// character before is '(' and the character after is ')'
|
|
840
|
-
|
|
947
|
+
const index = context.content.indexOf(url);
|
|
841
948
|
if (index === -1) {
|
|
842
949
|
// It is not an error if we didn't find it.
|
|
843
950
|
return;
|
|
844
951
|
}
|
|
845
952
|
if (context.content[index - 1] !== "(" || context.content[index + url.length] !== ")") {
|
|
846
|
-
return
|
|
953
|
+
return `Whitespace before or after image url:
|
|
954
|
+
For images, don't include any space or newlines after '(' or before ')'.
|
|
955
|
+
Whitespace in image URLs causes translation difficulties.`;
|
|
847
956
|
}
|
|
848
957
|
}
|
|
849
958
|
}
|
|
@@ -859,32 +968,37 @@ var ImageWidget = Rule.makeRule({
|
|
|
859
968
|
name: "image-widget",
|
|
860
969
|
severity: Rule.Severity.WARNING,
|
|
861
970
|
selector: "widget",
|
|
862
|
-
lint: function
|
|
971
|
+
lint: function (state, content, nodes, match, context) {
|
|
863
972
|
// This rule only looks at image widgets
|
|
864
973
|
if (state.currentNode().widgetType !== "image") {
|
|
865
974
|
return;
|
|
866
975
|
}
|
|
867
976
|
|
|
868
977
|
// If it can't find a definition for the widget it does nothing
|
|
869
|
-
|
|
978
|
+
const widget = context && context.widgets && context.widgets[state.currentNode().id];
|
|
870
979
|
if (!widget) {
|
|
871
980
|
return;
|
|
872
981
|
}
|
|
873
982
|
|
|
874
983
|
// Make sure there is alt text
|
|
875
|
-
|
|
984
|
+
const alt = widget.options.alt;
|
|
876
985
|
if (!alt) {
|
|
877
|
-
return
|
|
986
|
+
return `Images should have alt text:
|
|
987
|
+
for accessibility, all images should have a text description.
|
|
988
|
+
Add a description in the "Alt Text" box of the image widget.`;
|
|
878
989
|
}
|
|
879
990
|
|
|
880
991
|
// Make sure the alt text it is not trivial
|
|
881
992
|
if (alt.trim().length < 8) {
|
|
882
|
-
return
|
|
993
|
+
return `Images should have alt text:
|
|
994
|
+
for accessibility, all images should have descriptive alt text.
|
|
995
|
+
This image's alt text is only ${alt.trim().length} characters long.`;
|
|
883
996
|
}
|
|
884
997
|
|
|
885
998
|
// Make sure there is no math in the caption
|
|
886
999
|
if (widget.options.caption && widget.options.caption.match(/[^\\]\$/)) {
|
|
887
|
-
return
|
|
1000
|
+
return `No math in image captions:
|
|
1001
|
+
Don't include math expressions in image captions.`;
|
|
888
1002
|
}
|
|
889
1003
|
}
|
|
890
1004
|
});
|
|
@@ -894,7 +1008,8 @@ var LinkClickHere = Rule.makeRule({
|
|
|
894
1008
|
severity: Rule.Severity.WARNING,
|
|
895
1009
|
selector: "link",
|
|
896
1010
|
pattern: /click here/i,
|
|
897
|
-
message:
|
|
1011
|
+
message: `Inappropriate link text:
|
|
1012
|
+
Do not use the words "click here" in links.`
|
|
898
1013
|
});
|
|
899
1014
|
|
|
900
1015
|
var LongParagraph = Rule.makeRule({
|
|
@@ -902,8 +1017,10 @@ var LongParagraph = Rule.makeRule({
|
|
|
902
1017
|
severity: Rule.Severity.GUIDELINE,
|
|
903
1018
|
selector: "paragraph",
|
|
904
1019
|
pattern: /^.{501,}/,
|
|
905
|
-
lint: function
|
|
906
|
-
return
|
|
1020
|
+
lint: function (state, content, nodes, match) {
|
|
1021
|
+
return `Paragraph too long:
|
|
1022
|
+
This paragraph is ${content.length} characters long.
|
|
1023
|
+
Shorten it to 500 characters or fewer.`;
|
|
907
1024
|
}
|
|
908
1025
|
});
|
|
909
1026
|
|
|
@@ -911,7 +1028,8 @@ var MathAdjacent = Rule.makeRule({
|
|
|
911
1028
|
name: "math-adjacent",
|
|
912
1029
|
severity: Rule.Severity.WARNING,
|
|
913
1030
|
selector: "blockMath+blockMath",
|
|
914
|
-
message:
|
|
1031
|
+
message: `Adjacent math blocks:
|
|
1032
|
+
combine the blocks between \\begin{align} and \\end{align}`
|
|
915
1033
|
});
|
|
916
1034
|
|
|
917
1035
|
var MathAlignExtraBreak = Rule.makeRule({
|
|
@@ -919,7 +1037,8 @@ var MathAlignExtraBreak = Rule.makeRule({
|
|
|
919
1037
|
severity: Rule.Severity.WARNING,
|
|
920
1038
|
selector: "blockMath",
|
|
921
1039
|
pattern: /(\\{2,})\s*\\end{align}/,
|
|
922
|
-
message:
|
|
1040
|
+
message: `Extra space at end of block:
|
|
1041
|
+
Don't end an align block with backslashes`
|
|
923
1042
|
});
|
|
924
1043
|
|
|
925
1044
|
var MathAlignLinebreaks = Rule.makeRule({
|
|
@@ -934,10 +1053,10 @@ var MathAlignLinebreaks = Rule.makeRule({
|
|
|
934
1053
|
// Note that this rule can't know where line breaks belong so
|
|
935
1054
|
// it can't tell whether backslashes are completely missing. It just
|
|
936
1055
|
// enforces that you don't have the wrong number of pairs of backslashes.
|
|
937
|
-
lint: function
|
|
938
|
-
|
|
1056
|
+
lint: function (state, content, nodes, match) {
|
|
1057
|
+
let text = match[0];
|
|
939
1058
|
while (text.length) {
|
|
940
|
-
|
|
1059
|
+
const index = text.indexOf("\\\\");
|
|
941
1060
|
if (index === -1) {
|
|
942
1061
|
// No more backslash pairs, so we found no lint
|
|
943
1062
|
return null;
|
|
@@ -947,7 +1066,7 @@ var MathAlignLinebreaks = Rule.makeRule({
|
|
|
947
1066
|
// Now we expect to find optional spaces, another pair of
|
|
948
1067
|
// backslashes, and more optional spaces not followed immediately
|
|
949
1068
|
// by another pair of backslashes.
|
|
950
|
-
|
|
1069
|
+
const nextpair = text.match(/^\s*\\\\\s*(?!\\\\)/);
|
|
951
1070
|
|
|
952
1071
|
// If that does not match then we either have too few or too
|
|
953
1072
|
// many pairs of backslashes.
|
|
@@ -976,7 +1095,8 @@ var MathFontSize = Rule.makeRule({
|
|
|
976
1095
|
severity: Rule.Severity.GUIDELINE,
|
|
977
1096
|
selector: "math, blockMath",
|
|
978
1097
|
pattern: /\\(tiny|Tiny|small|large|Large|LARGE|huge|Huge|scriptsize|normalsize)\s*{/,
|
|
979
|
-
message:
|
|
1098
|
+
message: `Math font size:
|
|
1099
|
+
Don't change the default font size with \\Large{} or similar commands`
|
|
980
1100
|
});
|
|
981
1101
|
|
|
982
1102
|
var MathFrac = Rule.makeRule({
|
|
@@ -992,7 +1112,8 @@ var MathNested = Rule.makeRule({
|
|
|
992
1112
|
severity: Rule.Severity.ERROR,
|
|
993
1113
|
selector: "math, blockMath",
|
|
994
1114
|
pattern: /\\text{[^$}]*\$[^$}]*\$[^}]*}/,
|
|
995
|
-
message:
|
|
1115
|
+
message: `Nested math:
|
|
1116
|
+
Don't nest math expressions inside \\text{} blocks`
|
|
996
1117
|
});
|
|
997
1118
|
|
|
998
1119
|
var MathStartsWithSpace = Rule.makeRule({
|
|
@@ -1000,7 +1121,9 @@ var MathStartsWithSpace = Rule.makeRule({
|
|
|
1000
1121
|
severity: Rule.Severity.GUIDELINE,
|
|
1001
1122
|
selector: "math, blockMath",
|
|
1002
1123
|
pattern: /^\s*(~|\\qquad|\\quad|\\,|\\;|\\:|\\ |\\!|\\enspace|\\phantom)/,
|
|
1003
|
-
message:
|
|
1124
|
+
message: `Math starts with space:
|
|
1125
|
+
math should not be indented. Do not begin math expressions with
|
|
1126
|
+
LaTeX space commands like ~, \\;, \\quad, or \\phantom`
|
|
1004
1127
|
});
|
|
1005
1128
|
|
|
1006
1129
|
var MathTextEmpty = Rule.makeRule({
|
|
@@ -1019,14 +1142,17 @@ var MathWithoutDollars = Rule.makeRule({
|
|
|
1019
1142
|
name: "math-without-dollars",
|
|
1020
1143
|
severity: Rule.Severity.GUIDELINE,
|
|
1021
1144
|
pattern: /\\\w+{[^}]*}|{|}/,
|
|
1022
|
-
message:
|
|
1145
|
+
message: `This looks like LaTeX:
|
|
1146
|
+
did you mean to put it inside dollar signs?`
|
|
1023
1147
|
});
|
|
1024
1148
|
|
|
1025
1149
|
var NestedLists = Rule.makeRule({
|
|
1026
1150
|
name: "nested-lists",
|
|
1027
1151
|
severity: Rule.Severity.WARNING,
|
|
1028
1152
|
selector: "list list",
|
|
1029
|
-
message:
|
|
1153
|
+
message: `Nested lists:
|
|
1154
|
+
nested lists are hard to read on mobile devices;
|
|
1155
|
+
do not use additional indentation.`
|
|
1030
1156
|
});
|
|
1031
1157
|
|
|
1032
1158
|
var Profanity = Rule.makeRule({
|
|
@@ -1041,13 +1167,15 @@ var TableMissingCells = Rule.makeRule({
|
|
|
1041
1167
|
name: "table-missing-cells",
|
|
1042
1168
|
severity: Rule.Severity.WARNING,
|
|
1043
1169
|
selector: "table",
|
|
1044
|
-
lint: function
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
for (
|
|
1170
|
+
lint: function (state, content, nodes, match) {
|
|
1171
|
+
const table = nodes[0];
|
|
1172
|
+
const headerLength = table.header.length;
|
|
1173
|
+
const rowLengths = table.cells.map(r => r.length);
|
|
1174
|
+
for (let r = 0; r < rowLengths.length; r++) {
|
|
1049
1175
|
if (rowLengths[r] !== headerLength) {
|
|
1050
|
-
return
|
|
1176
|
+
return `Table rows don't match header:
|
|
1177
|
+
The table header has ${headerLength} cells, but
|
|
1178
|
+
Row ${r + 1} has ${rowLengths[r]} cells.`;
|
|
1051
1179
|
}
|
|
1052
1180
|
}
|
|
1053
1181
|
}
|
|
@@ -1061,26 +1189,87 @@ var UnbalancedCodeDelimiters = Rule.makeRule({
|
|
|
1061
1189
|
name: "unbalanced-code-delimiters",
|
|
1062
1190
|
severity: Rule.Severity.ERROR,
|
|
1063
1191
|
pattern: /[`~]+/,
|
|
1064
|
-
message:
|
|
1192
|
+
message: `Unbalanced code delimiters:
|
|
1193
|
+
code blocks should begin and end with the same type and number of delimiters`
|
|
1065
1194
|
});
|
|
1066
1195
|
|
|
1067
1196
|
var UnescapedDollar = Rule.makeRule({
|
|
1068
1197
|
name: "unescaped-dollar",
|
|
1069
1198
|
severity: Rule.Severity.ERROR,
|
|
1070
1199
|
selector: "unescapedDollar",
|
|
1071
|
-
message:
|
|
1200
|
+
message: `Unescaped dollar sign:
|
|
1201
|
+
Dollar signs must appear in pairs or be escaped as \\$`
|
|
1072
1202
|
});
|
|
1073
1203
|
|
|
1074
1204
|
var WidgetInTable = Rule.makeRule({
|
|
1075
1205
|
name: "widget-in-table",
|
|
1076
1206
|
severity: Rule.Severity.BULK_WARNING,
|
|
1077
1207
|
selector: "table widget",
|
|
1078
|
-
message:
|
|
1208
|
+
message: `Widget in table:
|
|
1209
|
+
do not put widgets inside of tables.`
|
|
1079
1210
|
});
|
|
1080
1211
|
|
|
1081
1212
|
// TODO(davidflanagan):
|
|
1082
1213
|
var AllRules = [AbsoluteUrl, BlockquotedMath, BlockquotedWidget, DoubleSpacingAfterTerminal, ExtraContentSpacing, HeadingLevel1, HeadingLevelSkip, HeadingSentenceCase, HeadingTitleCase, ImageAltText, ImageInTable, LinkClickHere, LongParagraph, MathAdjacent, MathAlignExtraBreak, MathAlignLinebreaks, MathEmpty, MathFontSize, MathFrac, MathNested, MathStartsWithSpace, MathTextEmpty, NestedLists, TableMissingCells, UnescapedDollar, WidgetInTable, Profanity, MathWithoutDollars, UnbalancedCodeDelimiters, ImageSpacesAroundUrls, ImageWidget];
|
|
1083
1214
|
|
|
1215
|
+
/**
|
|
1216
|
+
* TreeTransformer is a class for traversing and transforming trees. Create a
|
|
1217
|
+
* TreeTransformer by passing the root node of the tree to the
|
|
1218
|
+
* constructor. Then traverse that tree by calling the traverse() method. The
|
|
1219
|
+
* argument to traverse() is a callback function that will be called once for
|
|
1220
|
+
* each node in the tree. This is a post-order depth-first traversal: the
|
|
1221
|
+
* callback is not called on the a way down, but on the way back up. That is,
|
|
1222
|
+
* the children of a node are traversed before the node itself is.
|
|
1223
|
+
*
|
|
1224
|
+
* The traversal callback function is passed three arguments, the node being
|
|
1225
|
+
* traversed, a TraversalState object, and the concatentated text content of
|
|
1226
|
+
* the node and all of its descendants. The TraversalState object is the most
|
|
1227
|
+
* most interesting argument: it has methods for querying the ancestors and
|
|
1228
|
+
* siblings of the node, and for deleting or replacing the node. These
|
|
1229
|
+
* transformation methods are why this class is a tree transformer and not
|
|
1230
|
+
* just a tree traverser.
|
|
1231
|
+
*
|
|
1232
|
+
* A typical tree traversal looks like this:
|
|
1233
|
+
*
|
|
1234
|
+
* new TreeTransformer(root).traverse((node, state, content) => {
|
|
1235
|
+
* let parent = state.parent();
|
|
1236
|
+
* let previous = state.previousSibling();
|
|
1237
|
+
* // etc.
|
|
1238
|
+
* });
|
|
1239
|
+
*
|
|
1240
|
+
* The traverse() method descends through nodes and arrays of nodes and calls
|
|
1241
|
+
* the traverse callback on each node on the way back up to the root of the
|
|
1242
|
+
* tree. (Note that it only calls the callback on the nodes themselves, not
|
|
1243
|
+
* any arrays that contain nodes.) A node is loosely defined as any object
|
|
1244
|
+
* with a string-valued `type` property. Objects that do not have a type
|
|
1245
|
+
* property are assumed to not be part of the tree and are not traversed. When
|
|
1246
|
+
* traversing an array, all elements of the array are examined, and any that
|
|
1247
|
+
* are nodes or arrays are recursively traversed. When traversing a node, all
|
|
1248
|
+
* properties of the object are examined and any node or array values are
|
|
1249
|
+
* recursively traversed. In typical parse trees, the children of a node are
|
|
1250
|
+
* in a `children` or `content` array, but this class is designed to handle
|
|
1251
|
+
* more general trees. The Perseus markdown parser, for example, produces
|
|
1252
|
+
* nodes of type "table" that have children in the `header` and `cells`
|
|
1253
|
+
* properties.
|
|
1254
|
+
*
|
|
1255
|
+
* CAUTION: the traverse() method does not make any attempt to detect
|
|
1256
|
+
* cycles. If you call it on a cyclic graph instead of a tree, it will cause
|
|
1257
|
+
* infinite recursion (or, more likely, a stack overflow).
|
|
1258
|
+
*
|
|
1259
|
+
* TODO(davidflanagan): it probably wouldn't be hard to detect cycles: when
|
|
1260
|
+
* pushing a new node onto the containers stack we could just check that it
|
|
1261
|
+
* isn't already there.
|
|
1262
|
+
*
|
|
1263
|
+
* If a node has a text-valued `content` property, it is taken to be the
|
|
1264
|
+
* plain-text content of the node. The traverse() method concatenates these
|
|
1265
|
+
* content strings and passes them to the traversal callback for each
|
|
1266
|
+
* node. This means that the callback has access the full text content of its
|
|
1267
|
+
* node and all of the nodes descendants.
|
|
1268
|
+
*
|
|
1269
|
+
* See the TraversalState class for more information on what information and
|
|
1270
|
+
* methods are available to the traversal callback.
|
|
1271
|
+
**/
|
|
1272
|
+
|
|
1084
1273
|
// TreeNode is the type of a node in a parse tree. The only real requirement is
|
|
1085
1274
|
// that every node has a string-valued `type` property
|
|
1086
1275
|
|
|
@@ -1089,7 +1278,7 @@ var AllRules = [AbsoluteUrl, BlockquotedMath, BlockquotedWidget, DoubleSpacingAf
|
|
|
1089
1278
|
class TreeTransformer {
|
|
1090
1279
|
// To create a tree transformer, just pass the root node of the tree
|
|
1091
1280
|
constructor(root) {
|
|
1092
|
-
|
|
1281
|
+
this.root = void 0;
|
|
1093
1282
|
this.root = root;
|
|
1094
1283
|
}
|
|
1095
1284
|
|
|
@@ -1124,15 +1313,15 @@ class TreeTransformer {
|
|
|
1124
1313
|
// details. Note that this method uses the TraversalState object to store
|
|
1125
1314
|
// information about the structure of the tree.
|
|
1126
1315
|
_traverse(n, state, f) {
|
|
1127
|
-
|
|
1316
|
+
let content = "";
|
|
1128
1317
|
if (TreeTransformer.isNode(n)) {
|
|
1129
1318
|
// If we were called on a node object, then we handle it
|
|
1130
1319
|
// this way.
|
|
1131
|
-
|
|
1320
|
+
const node = n; // safe cast; we just tested
|
|
1132
1321
|
|
|
1133
1322
|
// Put the node on the stack before recursing on its children
|
|
1134
|
-
state._containers.push(
|
|
1135
|
-
state._ancestors.push(
|
|
1323
|
+
state._containers.push(node);
|
|
1324
|
+
state._ancestors.push(node);
|
|
1136
1325
|
|
|
1137
1326
|
// Record the node's text content if it has any.
|
|
1138
1327
|
// Usually this is for nodes with a type property of "text",
|
|
@@ -1140,9 +1329,9 @@ class TreeTransformer {
|
|
|
1140
1329
|
// TODO(mdr): We found a new Flow error when upgrading:
|
|
1141
1330
|
// "node.content (property `content` is missing in `TreeNode` [1].)"
|
|
1142
1331
|
// @ts-expect-error [FEI-5003] - TS2339 - Property 'content' does not exist on type 'TreeNode'.
|
|
1143
|
-
if (typeof
|
|
1332
|
+
if (typeof node.content === "string") {
|
|
1144
1333
|
// @ts-expect-error [FEI-5003] - TS2339 - Property 'content' does not exist on type 'TreeNode'.
|
|
1145
|
-
content =
|
|
1334
|
+
content = node.content;
|
|
1146
1335
|
}
|
|
1147
1336
|
|
|
1148
1337
|
// Recurse on the node. If there was content above, then there
|
|
@@ -1154,7 +1343,7 @@ class TreeTransformer {
|
|
|
1154
1343
|
// put a switch statement here to dispatch on the node type
|
|
1155
1344
|
// property with specific recursion steps for each known type of
|
|
1156
1345
|
// node.
|
|
1157
|
-
|
|
1346
|
+
const keys = Object.keys(node);
|
|
1158
1347
|
keys.forEach(key => {
|
|
1159
1348
|
// Never recurse on the type property
|
|
1160
1349
|
if (key === "type") {
|
|
@@ -1168,7 +1357,7 @@ class TreeTransformer {
|
|
|
1168
1357
|
// content to the content for this node. Also note that we
|
|
1169
1358
|
// push the name of the property we're recursing over onto a
|
|
1170
1359
|
// TraversalState stack.
|
|
1171
|
-
|
|
1360
|
+
const value = node[key];
|
|
1172
1361
|
if (value && typeof value === "object") {
|
|
1173
1362
|
state._indexes.push(key);
|
|
1174
1363
|
content += this._traverse(value, state, f);
|
|
@@ -1184,11 +1373,11 @@ class TreeTransformer {
|
|
|
1184
1373
|
// that this is post-order traversal. We call the callback on the
|
|
1185
1374
|
// way back up the tree, not on the way down. That way we already
|
|
1186
1375
|
// know all the content contained within the node.
|
|
1187
|
-
f(
|
|
1376
|
+
f(node, state, content);
|
|
1188
1377
|
} else if (Array.isArray(n)) {
|
|
1189
1378
|
// If we were called on an array instead of a node, then
|
|
1190
1379
|
// this is the code we use to recurse.
|
|
1191
|
-
|
|
1380
|
+
const nodes = n;
|
|
1192
1381
|
|
|
1193
1382
|
// Push the array onto the stack. This will allow the
|
|
1194
1383
|
// TraversalState object to locate siblings of this node.
|
|
@@ -1202,7 +1391,7 @@ class TreeTransformer {
|
|
|
1202
1391
|
// are careful here to test the array length on each iteration and
|
|
1203
1392
|
// to reset the index when we pop the stack. Also note that we
|
|
1204
1393
|
// concatentate the text content of the children.
|
|
1205
|
-
|
|
1394
|
+
let index = 0;
|
|
1206
1395
|
while (index < nodes.length) {
|
|
1207
1396
|
state._indexes.push(index);
|
|
1208
1397
|
content += this._traverse(nodes[index], state, f);
|
|
@@ -1247,11 +1436,11 @@ class TraversalState {
|
|
|
1247
1436
|
|
|
1248
1437
|
// The constructor just stores the root node and creates empty stacks.
|
|
1249
1438
|
constructor(root) {
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1439
|
+
this.root = void 0;
|
|
1440
|
+
this._currentNode = void 0;
|
|
1441
|
+
this._containers = void 0;
|
|
1442
|
+
this._indexes = void 0;
|
|
1443
|
+
this._ancestors = void 0;
|
|
1255
1444
|
this.root = root;
|
|
1256
1445
|
|
|
1257
1446
|
// When the callback is called, this property will hold the
|
|
@@ -1309,7 +1498,7 @@ class TraversalState {
|
|
|
1309
1498
|
* Return the next sibling of this node, if it has one, or null otherwise.
|
|
1310
1499
|
*/
|
|
1311
1500
|
nextSibling() {
|
|
1312
|
-
|
|
1501
|
+
const siblings = this._containers.top();
|
|
1313
1502
|
|
|
1314
1503
|
// If we're at the root of the tree or if the parent is an
|
|
1315
1504
|
// object instead of an array, then there are no siblings.
|
|
@@ -1318,7 +1507,7 @@ class TraversalState {
|
|
|
1318
1507
|
}
|
|
1319
1508
|
|
|
1320
1509
|
// The top index is a number because the top container is an array
|
|
1321
|
-
|
|
1510
|
+
const index = this._indexes.top();
|
|
1322
1511
|
if (siblings.length > index + 1) {
|
|
1323
1512
|
return siblings[index + 1];
|
|
1324
1513
|
}
|
|
@@ -1330,7 +1519,7 @@ class TraversalState {
|
|
|
1330
1519
|
* otherwise.
|
|
1331
1520
|
*/
|
|
1332
1521
|
previousSibling() {
|
|
1333
|
-
|
|
1522
|
+
const siblings = this._containers.top();
|
|
1334
1523
|
|
|
1335
1524
|
// If we're at the root of the tree or if the parent is an
|
|
1336
1525
|
// object instead of an array, then there are no siblings.
|
|
@@ -1339,7 +1528,7 @@ class TraversalState {
|
|
|
1339
1528
|
}
|
|
1340
1529
|
|
|
1341
1530
|
// The top index is a number because the top container is an array
|
|
1342
|
-
|
|
1531
|
+
const index = this._indexes.top();
|
|
1343
1532
|
if (index > 0) {
|
|
1344
1533
|
return siblings[index - 1];
|
|
1345
1534
|
}
|
|
@@ -1352,10 +1541,10 @@ class TraversalState {
|
|
|
1352
1541
|
* tree and concatenate adjacent text nodes into a single node.
|
|
1353
1542
|
*/
|
|
1354
1543
|
removeNextSibling() {
|
|
1355
|
-
|
|
1544
|
+
const siblings = this._containers.top();
|
|
1356
1545
|
if (siblings && Array.isArray(siblings)) {
|
|
1357
1546
|
// top index is a number because top container is an array
|
|
1358
|
-
|
|
1547
|
+
const index = this._indexes.top();
|
|
1359
1548
|
if (siblings.length > index + 1) {
|
|
1360
1549
|
return siblings.splice(index + 1, 1)[0];
|
|
1361
1550
|
}
|
|
@@ -1374,8 +1563,8 @@ class TraversalState {
|
|
|
1374
1563
|
* This method throws an error if you attempt to replace the root node of
|
|
1375
1564
|
* the tree.
|
|
1376
1565
|
*/
|
|
1377
|
-
replace() {
|
|
1378
|
-
|
|
1566
|
+
replace(...replacements) {
|
|
1567
|
+
const parent = this._containers.top();
|
|
1379
1568
|
if (!parent) {
|
|
1380
1569
|
throw new PerseusError("Can't replace the root of the tree", Errors.Internal);
|
|
1381
1570
|
}
|
|
@@ -1384,11 +1573,8 @@ class TraversalState {
|
|
|
1384
1573
|
// and the top of the indexes stack is a corresponding array index
|
|
1385
1574
|
// or object property. This is hard for Flow, so we have to do some
|
|
1386
1575
|
// unsafe casting and be careful when we use which cast version
|
|
1387
|
-
for (var _len = arguments.length, replacements = new Array(_len), _key = 0; _key < _len; _key++) {
|
|
1388
|
-
replacements[_key] = arguments[_key];
|
|
1389
|
-
}
|
|
1390
1576
|
if (Array.isArray(parent)) {
|
|
1391
|
-
|
|
1577
|
+
const index = this._indexes.top();
|
|
1392
1578
|
// For an array parent we just splice the new nodes in
|
|
1393
1579
|
parent.splice(index, 1, ...replacements);
|
|
1394
1580
|
// Adjust the index to account for the changed array length.
|
|
@@ -1396,7 +1582,7 @@ class TraversalState {
|
|
|
1396
1582
|
this._indexes.pop();
|
|
1397
1583
|
this._indexes.push(index + replacements.length - 1);
|
|
1398
1584
|
} else {
|
|
1399
|
-
|
|
1585
|
+
const property = this._indexes.top();
|
|
1400
1586
|
// For an object parent we care how many new nodes there are
|
|
1401
1587
|
if (replacements.length === 0) {
|
|
1402
1588
|
// Deletion
|
|
@@ -1436,7 +1622,7 @@ class TraversalState {
|
|
|
1436
1622
|
// Since we know that we have a previous sibling, we know that
|
|
1437
1623
|
// the value on top of the stack is a number, but we have to do
|
|
1438
1624
|
// this unsafe cast because Flow doesn't know that.
|
|
1439
|
-
|
|
1625
|
+
const index = this._indexes.pop();
|
|
1440
1626
|
this._indexes.push(index - 1);
|
|
1441
1627
|
}
|
|
1442
1628
|
|
|
@@ -1483,7 +1669,7 @@ class TraversalState {
|
|
|
1483
1669
|
* goToParent() and goToPreviousSibling().
|
|
1484
1670
|
*/
|
|
1485
1671
|
clone() {
|
|
1486
|
-
|
|
1672
|
+
const clone = new TraversalState(this.root);
|
|
1487
1673
|
clone._currentNode = this._currentNode;
|
|
1488
1674
|
clone._containers = this._containers.clone();
|
|
1489
1675
|
clone._indexes = this._indexes.clone();
|
|
@@ -1511,7 +1697,7 @@ class TraversalState {
|
|
|
1511
1697
|
*/
|
|
1512
1698
|
class Stack {
|
|
1513
1699
|
constructor(array) {
|
|
1514
|
-
|
|
1700
|
+
this.stack = void 0;
|
|
1515
1701
|
this.stack = array ? array.slice(0) : [];
|
|
1516
1702
|
}
|
|
1517
1703
|
|
|
@@ -1559,7 +1745,7 @@ class Stack {
|
|
|
1559
1745
|
if (!that || !that.stack || that.stack.length !== this.stack.length) {
|
|
1560
1746
|
return false;
|
|
1561
1747
|
}
|
|
1562
|
-
for (
|
|
1748
|
+
for (let i = 0; i < this.stack.length; i++) {
|
|
1563
1749
|
if (this.stack[i] !== that.stack[i]) {
|
|
1564
1750
|
return false;
|
|
1565
1751
|
}
|
|
@@ -1569,20 +1755,20 @@ class Stack {
|
|
|
1569
1755
|
}
|
|
1570
1756
|
|
|
1571
1757
|
// Define the shape of the linter context object that is passed through the
|
|
1572
|
-
|
|
1758
|
+
const linterContextProps = PropTypes.shape({
|
|
1573
1759
|
contentType: PropTypes.string,
|
|
1574
1760
|
highlightLint: PropTypes.bool,
|
|
1575
1761
|
paths: PropTypes.arrayOf(PropTypes.string),
|
|
1576
1762
|
stack: PropTypes.arrayOf(PropTypes.string)
|
|
1577
1763
|
});
|
|
1578
|
-
|
|
1764
|
+
const linterContextDefault = {
|
|
1579
1765
|
contentType: "",
|
|
1580
1766
|
highlightLint: false,
|
|
1581
1767
|
paths: [],
|
|
1582
1768
|
stack: []
|
|
1583
1769
|
};
|
|
1584
1770
|
|
|
1585
|
-
|
|
1771
|
+
const allLintRules = AllRules.filter(r => r.severity < Rule.Severity.BULK_WARNING);
|
|
1586
1772
|
|
|
1587
1773
|
//
|
|
1588
1774
|
// Run the Perseus linter over the specified markdown parse tree,
|
|
@@ -1608,16 +1794,15 @@ var allLintRules = AllRules.filter(r => r.severity < Rule.Severity.BULK_WARNING)
|
|
|
1608
1794
|
// in that case). This would allow the one function to be used for both
|
|
1609
1795
|
// online linting and batch linting.
|
|
1610
1796
|
//
|
|
1611
|
-
function runLinter(tree, context, highlight) {
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
var tt = new TreeTransformer(tree);
|
|
1797
|
+
function runLinter(tree, context, highlight, rules = allLintRules) {
|
|
1798
|
+
const warnings = [];
|
|
1799
|
+
const tt = new TreeTransformer(tree);
|
|
1615
1800
|
|
|
1616
1801
|
// The markdown parser often outputs adjacent text nodes. We
|
|
1617
1802
|
// coalesce them before linting for efficiency and accuracy.
|
|
1618
1803
|
tt.traverse((node, state, content) => {
|
|
1619
1804
|
if (TreeTransformer.isTextNode(node)) {
|
|
1620
|
-
|
|
1805
|
+
let next = state.nextSibling();
|
|
1621
1806
|
while (TreeTransformer.isTextNode(next)) {
|
|
1622
1807
|
// @ts-expect-error [FEI-5003] - TS2339 - Property 'content' does not exist on type 'TreeNode'. | TS2533 - Object is possibly 'null' or 'undefined'. | TS2339 - Property 'content' does not exist on type 'TreeNode'.
|
|
1623
1808
|
node.content += next.content;
|
|
@@ -1644,29 +1829,29 @@ function runLinter(tree, context, highlight) {
|
|
|
1644
1829
|
// issue too. But using JavaScript has its own downsides: there is
|
|
1645
1830
|
// risk that the linter JavaScript would interfere with
|
|
1646
1831
|
// widget-related Javascript.
|
|
1647
|
-
|
|
1648
|
-
|
|
1832
|
+
let tableWarnings = [];
|
|
1833
|
+
let insideTable = false;
|
|
1649
1834
|
|
|
1650
1835
|
// Traverse through the nodes of the parse tree. At each node, loop
|
|
1651
1836
|
// through the array of lint rules and check whether there is a
|
|
1652
1837
|
// lint violation at that node.
|
|
1653
1838
|
tt.traverse((node, state, content) => {
|
|
1654
|
-
|
|
1839
|
+
const nodeWarnings = [];
|
|
1655
1840
|
|
|
1656
1841
|
// If our rule is only designed to be tested against a particular
|
|
1657
1842
|
// content type and we're not in that content type, we don't need to
|
|
1658
1843
|
// consider that rule.
|
|
1659
|
-
|
|
1844
|
+
const applicableRules = rules.filter(r => r.applies(context));
|
|
1660
1845
|
|
|
1661
1846
|
// Generate a stack so we can identify our position in the tree in
|
|
1662
1847
|
// lint rules
|
|
1663
|
-
|
|
1848
|
+
const stack = [...context.stack];
|
|
1664
1849
|
stack.push(node.type);
|
|
1665
|
-
|
|
1850
|
+
const nodeContext = _extends({}, context, {
|
|
1666
1851
|
stack: stack.join(".")
|
|
1667
1852
|
});
|
|
1668
1853
|
applicableRules.forEach(rule => {
|
|
1669
|
-
|
|
1854
|
+
const warning = rule.check(node, state, content, nodeContext);
|
|
1670
1855
|
if (warning) {
|
|
1671
1856
|
// The start and end locations are relative to this
|
|
1672
1857
|
// particular node, and so are not generally very useful.
|
|
@@ -1782,16 +1967,16 @@ function runLinter(tree, context, highlight) {
|
|
|
1782
1967
|
// be the best thing, anyway.
|
|
1783
1968
|
//
|
|
1784
1969
|
// @ts-expect-error [FEI-5003] - TS2339 - Property 'content' does not exist on type 'TreeNode'.
|
|
1785
|
-
|
|
1786
|
-
|
|
1970
|
+
const _content = node.content; // Text nodes have content
|
|
1971
|
+
const warning = nodeWarnings[0]; // There is only one warning.
|
|
1787
1972
|
// These are the lint boundaries within the content
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1973
|
+
const start = warning.start || 0;
|
|
1974
|
+
const end = warning.end || _content.length;
|
|
1975
|
+
const prefix = _content.substring(0, start);
|
|
1976
|
+
const lint = _content.substring(start, end);
|
|
1977
|
+
const suffix = _content.substring(end);
|
|
1793
1978
|
// TODO(FEI-5003): Give this a real type.
|
|
1794
|
-
|
|
1979
|
+
const replacements = []; // What we'll replace the node with
|
|
1795
1980
|
|
|
1796
1981
|
// The prefix text node, if there is one
|
|
1797
1982
|
if (prefix) {
|
|
@@ -1831,8 +2016,8 @@ function runLinter(tree, context, highlight) {
|
|
|
1831
2016
|
return warnings;
|
|
1832
2017
|
}
|
|
1833
2018
|
function pushContextStack(context, name) {
|
|
1834
|
-
|
|
1835
|
-
return
|
|
2019
|
+
const stack = context.stack || [];
|
|
2020
|
+
return _extends({}, context, {
|
|
1836
2021
|
stack: stack.concat(name)
|
|
1837
2022
|
});
|
|
1838
2023
|
}
|