@khanacademy/perseus-linter 0.0.0-PR862-20231207182234 → 0.0.0-PR875-20250221232857

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +7 -0
  3. package/dist/es/index.js +156 -38
  4. package/dist/es/index.js.map +1 -1
  5. package/dist/index.js +181 -38
  6. package/dist/index.js.map +1 -1
  7. package/dist/proptypes.d.ts +1 -7
  8. package/dist/rule.d.ts +24 -8
  9. package/dist/rules/expression-widget.d.ts +9 -0
  10. package/dist/rules/lint-utils.d.ts +0 -1
  11. package/dist/shared-utils/add-library-version-to-perseus-debug.d.ts +9 -0
  12. package/dist/tree-transformer.d.ts +3 -1
  13. package/package.json +35 -35
  14. package/.eslintrc.js +0 -12
  15. package/CHANGELOG.md +0 -148
  16. package/src/README.md +0 -41
  17. package/src/__tests__/matcher.test.ts +0 -498
  18. package/src/__tests__/rule.test.ts +0 -110
  19. package/src/__tests__/rules.test.ts +0 -548
  20. package/src/__tests__/selector-parser.test.ts +0 -51
  21. package/src/__tests__/tree-transformer.test.ts +0 -444
  22. package/src/index.ts +0 -281
  23. package/src/proptypes.ts +0 -19
  24. package/src/rule.ts +0 -419
  25. package/src/rules/absolute-url.ts +0 -23
  26. package/src/rules/all-rules.ts +0 -71
  27. package/src/rules/blockquoted-math.ts +0 -9
  28. package/src/rules/blockquoted-widget.ts +0 -9
  29. package/src/rules/double-spacing-after-terminal.ts +0 -11
  30. package/src/rules/extra-content-spacing.ts +0 -11
  31. package/src/rules/heading-level-1.ts +0 -13
  32. package/src/rules/heading-level-skip.ts +0 -19
  33. package/src/rules/heading-sentence-case.ts +0 -10
  34. package/src/rules/heading-title-case.ts +0 -68
  35. package/src/rules/image-alt-text.ts +0 -20
  36. package/src/rules/image-in-table.ts +0 -9
  37. package/src/rules/image-spaces-around-urls.ts +0 -34
  38. package/src/rules/image-widget.ts +0 -49
  39. package/src/rules/link-click-here.ts +0 -10
  40. package/src/rules/lint-utils.ts +0 -47
  41. package/src/rules/long-paragraph.ts +0 -13
  42. package/src/rules/math-adjacent.ts +0 -9
  43. package/src/rules/math-align-extra-break.ts +0 -10
  44. package/src/rules/math-align-linebreaks.ts +0 -42
  45. package/src/rules/math-empty.ts +0 -9
  46. package/src/rules/math-font-size.ts +0 -11
  47. package/src/rules/math-frac.ts +0 -9
  48. package/src/rules/math-nested.ts +0 -10
  49. package/src/rules/math-starts-with-space.ts +0 -11
  50. package/src/rules/math-text-empty.ts +0 -9
  51. package/src/rules/math-without-dollars.ts +0 -13
  52. package/src/rules/nested-lists.ts +0 -10
  53. package/src/rules/profanity.ts +0 -9
  54. package/src/rules/table-missing-cells.ts +0 -19
  55. package/src/rules/unbalanced-code-delimiters.ts +0 -13
  56. package/src/rules/unescaped-dollar.ts +0 -9
  57. package/src/rules/widget-in-table.ts +0 -9
  58. package/src/selector.ts +0 -504
  59. package/src/tree-transformer.ts +0 -583
  60. package/src/types.ts +0 -7
  61. package/src/version.ts +0 -10
  62. package/tsconfig-build.json +0 -12
  63. package/tsconfig-build.tsbuildinfo +0 -1
  64. /package/dist/rules/{math-font-size.d.ts → image-url-empty.d.ts} +0 -0
  65. /package/dist/rules/{profanity.d.ts → static-widget-in-question-stem.d.ts} +0 -0
package/dist/index.js CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
- var perseusError = require('@khanacademy/perseus-error');
6
5
  var perseusCore = require('@khanacademy/perseus-core');
7
6
  var PropTypes = require('prop-types');
8
7
 
@@ -29,7 +28,7 @@ class Selector {
29
28
  * subclasses must provide an implementation of this method.
30
29
  */
31
30
  match(state) {
32
- throw new perseusError.PerseusError("Selector subclasses must implement match()", perseusError.Errors.NotAllowed);
31
+ throw new perseusCore.PerseusError("Selector subclasses must implement match()", perseusCore.Errors.NotAllowed);
33
32
  }
34
33
 
35
34
  /**
@@ -51,9 +50,9 @@ class Selector {
51
50
  * Instead call the static Selector.parse() method.
52
51
  */
53
52
  class Parser {
54
- // We do lexing with a simple regular expression
55
- // The array of tokens
56
- // Which token in the array we're looking at now
53
+ static TOKENS; // We do lexing with a simple regular expression
54
+ tokens; // The array of tokens
55
+ tokenIndex; // Which token in the array we're looking at now
57
56
 
58
57
  constructor(s) {
59
58
  // Normalize whitespace:
@@ -212,6 +211,7 @@ class ParseError extends Error {
212
211
  * first.
213
212
  */
214
213
  class SelectorList extends Selector {
214
+ selectors;
215
215
  constructor(selectors) {
216
216
  super();
217
217
  this.selectors = selectors;
@@ -254,6 +254,7 @@ class AnyNode extends Selector {
254
254
  * it matches any node whose `type` property is a specified string
255
255
  */
256
256
  class TypeSelector extends Selector {
257
+ type;
257
258
  constructor(type) {
258
259
  super();
259
260
  this.type = type;
@@ -277,6 +278,8 @@ class TypeSelector extends Selector {
277
278
  * method.
278
279
  */
279
280
  class SelectorCombinator extends Selector {
281
+ left;
282
+ right;
280
283
  constructor(left, right) {
281
284
  super();
282
285
  this.left = left;
@@ -522,41 +525,47 @@ class SiblingCombinator extends SelectorCombinator {
522
525
  // This represents the type returned by String.match(). It is an
523
526
  // array of strings, but also has index:number and input:string properties.
524
527
  // TypeScript doesn't handle it well, so we punt and just use any.
528
+
525
529
  // This is the return type of the check() method of a Rule object
530
+
526
531
  // This is the return type of the lint detection function passed as the 4th
527
532
  // argument to the Rule() constructor. It can return null or a string or an
528
533
  // object containing a string and two numbers.
529
534
  // prettier-ignore
530
535
  // (prettier formats this in a way that ka-lint does not like)
536
+
531
537
  // This is the type of the lint detection function that the Rule() constructor
532
538
  // expects as its fourth argument. It is passed the TraversalState object and
533
539
  // content string that were passed to check(), and is also passed the array of
534
540
  // nodes returned by the selector match and the array of strings returned by
535
541
  // the pattern match. It should return null if no lint is detected or an
536
542
  // error message or an object contining an error message.
543
+
537
544
  // An optional check to verify whether or not a particular rule should
538
545
  // be checked by context. For example, some rules only apply in exercises,
539
546
  // and should never be applied to articles. Defaults to true, so if we
540
547
  // omit the applies function in a rule, it'll be tested everywhere.
548
+
541
549
  /**
542
550
  * A Rule object describes a Perseus lint rule. See the comment at the top of
543
551
  * this file for detailed description.
544
552
  */
545
553
  class Rule {
546
- // The name of the rule
547
- // The severity of the rule
548
- // The specified selector or the DEFAULT_SELECTOR
549
- // A regular expression if one was specified
550
- // The lint-testing function or a default
551
- // Checks to see if we should apply a rule or not
552
- // The error message for use with the default function
554
+ name; // The name of the rule
555
+ severity; // The severity of the rule
556
+ selector; // The specified selector or the DEFAULT_SELECTOR
557
+ pattern; // A regular expression if one was specified
558
+ lint; // The lint-testing function or a default
559
+ applies; // Checks to see if we should apply a rule or not
560
+ message; // The error message for use with the default function
561
+ static DEFAULT_SELECTOR;
553
562
 
554
563
  // The comment at the top of this file has detailed docs for
555
564
  // this constructor and its arguments
556
565
  constructor(name, severity, selector, pattern, lint, applies) {
557
566
  var _this = this;
558
567
  if (!selector && !pattern) {
559
- throw new perseusError.PerseusError("Lint rules must have a selector or pattern", perseusError.Errors.InvalidInput, {
568
+ throw new perseusCore.PerseusError("Lint rules must have a selector or pattern", perseusCore.Errors.InvalidInput, {
560
569
  metadata: {
561
570
  name
562
571
  }
@@ -624,7 +633,6 @@ class Rule {
624
633
  if (!error) {
625
634
  return null; // No lint; we're done
626
635
  }
627
-
628
636
  if (typeof error === "string") {
629
637
  // If the lint function returned a string we assume it
630
638
  // applies to the entire content of the node and return it.
@@ -787,12 +795,61 @@ var DoubleSpacingAfterTerminal = Rule.makeRule({
787
795
  any other kind of terminal punctuation.`
788
796
  });
789
797
 
798
+ function buttonNotInButtonSet(name, set) {
799
+ return `Answer requires a button not found in the button sets: ${name} (in ${set})`;
800
+ }
801
+ const stringToButtonSet = {
802
+ "\\sqrt": "prealgebra",
803
+ "\\sin": "trig",
804
+ "\\cos": "trig",
805
+ "\\tan": "trig",
806
+ "\\log": "logarithms",
807
+ "\\ln": "logarithms"
808
+ };
809
+
810
+ /**
811
+ * Rule to make sure that Expression questions that require
812
+ * a specific math symbol to answer have that math symbol
813
+ * available in the keypad (desktop learners can use a keyboard,
814
+ * but mobile learners must use the MathInput keypad)
815
+ */
816
+ var ExpressionWidget = Rule.makeRule({
817
+ name: "expression-widget",
818
+ severity: Rule.Severity.WARNING,
819
+ selector: "widget",
820
+ lint: function (state, content, nodes, match, context) {
821
+ // This rule only looks at image widgets
822
+ if (state.currentNode().widgetType !== "expression") {
823
+ return;
824
+ }
825
+ const nodeId = state.currentNode().id;
826
+ if (!nodeId) {
827
+ return;
828
+ }
829
+
830
+ // If it can't find a definition for the widget it does nothing
831
+ const widget = context?.widgets?.[nodeId];
832
+ if (!widget) {
833
+ return;
834
+ }
835
+ const answers = widget.options.answerForms;
836
+ const buttons = widget.options.buttonSets;
837
+ for (const answer of answers) {
838
+ for (const [str, set] of Object.entries(stringToButtonSet)) {
839
+ if (answer.value.includes(str) && !buttons.includes(set)) {
840
+ return buttonNotInButtonSet(str, set);
841
+ }
842
+ }
843
+ }
844
+ }
845
+ });
846
+
790
847
  var ExtraContentSpacing = Rule.makeRule({
791
848
  name: "extra-content-spacing",
792
849
  selector: "paragraph",
793
850
  pattern: /\s+$/,
794
851
  applies: function (context) {
795
- return context.contentType === "article";
852
+ return context?.contentType === "article";
796
853
  },
797
854
  message: `No extra whitespace at the end of content blocks.`
798
855
  });
@@ -957,6 +1014,28 @@ Whitespace in image URLs causes translation difficulties.`;
957
1014
  }
958
1015
  });
959
1016
 
1017
+ var ImageUrlEmpty = Rule.makeRule({
1018
+ name: "image-url-empty",
1019
+ severity: Rule.Severity.ERROR,
1020
+ selector: "image",
1021
+ lint: function (state, content, nodes) {
1022
+ const image = nodes[0];
1023
+ const url = image.target;
1024
+
1025
+ // If no URL is provided, an infinite spinner will be shown in articles
1026
+ // overlaying the page where the image should be. This prevents the page
1027
+ // from fully loading. As a result, we check for URLS with all images.
1028
+ if (!url || !url.trim()) {
1029
+ return "Images should have a URL";
1030
+ }
1031
+
1032
+ // NOTE(TB): Ideally there would be a check to confirm the URL works
1033
+ // and leads to a valid resource, but fetching the URL would require
1034
+ // linting to be able to handle async functions, which it currently
1035
+ // cannot do.
1036
+ }
1037
+ });
1038
+
960
1039
  // Normally we have one rule per file. But since our selector class
961
1040
  // can't match specific widget types directly, this rule implements
962
1041
  // a number of image widget related rules in one place. This should
@@ -972,9 +1051,13 @@ var ImageWidget = Rule.makeRule({
972
1051
  if (state.currentNode().widgetType !== "image") {
973
1052
  return;
974
1053
  }
1054
+ const nodeId = state.currentNode().id;
1055
+ if (!nodeId) {
1056
+ return;
1057
+ }
975
1058
 
976
1059
  // If it can't find a definition for the widget it does nothing
977
- const widget = context && context.widgets && context.widgets[state.currentNode().id];
1060
+ const widget = context && context.widgets && context.widgets[nodeId];
978
1061
  if (!widget) {
979
1062
  return;
980
1063
  }
@@ -1058,7 +1141,7 @@ var MathAlignLinebreaks = Rule.makeRule({
1058
1141
  const index = text.indexOf("\\\\");
1059
1142
  if (index === -1) {
1060
1143
  // No more backslash pairs, so we found no lint
1061
- return null;
1144
+ return;
1062
1145
  }
1063
1146
  text = text.substring(index + 2);
1064
1147
 
@@ -1089,15 +1172,6 @@ var MathEmpty = Rule.makeRule({
1089
1172
  message: "Empty math: don't use $$ in your markdown."
1090
1173
  });
1091
1174
 
1092
- var MathFontSize = Rule.makeRule({
1093
- name: "math-font-size",
1094
- severity: Rule.Severity.GUIDELINE,
1095
- selector: "math, blockMath",
1096
- pattern: /\\(tiny|Tiny|small|large|Large|LARGE|huge|Huge|scriptsize|normalsize)\s*{/,
1097
- message: `Math font size:
1098
- Don't change the default font size with \\Large{} or similar commands`
1099
- });
1100
-
1101
1175
  var MathFrac = Rule.makeRule({
1102
1176
  name: "math-frac",
1103
1177
  severity: Rule.Severity.GUIDELINE,
@@ -1154,12 +1228,29 @@ nested lists are hard to read on mobile devices;
1154
1228
  do not use additional indentation.`
1155
1229
  });
1156
1230
 
1157
- var Profanity = Rule.makeRule({
1158
- name: "profanity",
1159
- // This list could obviously be expanded a lot, but I figured we
1160
- // could start with https://en.wikipedia.org/wiki/Seven_dirty_words
1161
- pattern: /\b(shit|piss|fuck|cunt|cocksucker|motherfucker|tits)\b/i,
1162
- message: "Avoid profanity"
1231
+ var StaticWidgetInQuestionStem = Rule.makeRule({
1232
+ name: "static-widget-in-question-stem",
1233
+ severity: Rule.Severity.WARNING,
1234
+ selector: "widget",
1235
+ lint: (state, content, nodes, match, context) => {
1236
+ if (context?.contentType !== "exercise") {
1237
+ return;
1238
+ }
1239
+ if (context.stack.includes("hint")) {
1240
+ return;
1241
+ }
1242
+ const nodeId = state.currentNode().id;
1243
+ if (!nodeId) {
1244
+ return;
1245
+ }
1246
+ const widget = context?.widgets?.[nodeId];
1247
+ if (!widget) {
1248
+ return;
1249
+ }
1250
+ if (widget.static) {
1251
+ return `Widget in question stem is static (non-interactive).`;
1252
+ }
1253
+ }
1163
1254
  });
1164
1255
 
1165
1256
  var TableMissingCells = Rule.makeRule({
@@ -1209,7 +1300,7 @@ do not put widgets inside of tables.`
1209
1300
  });
1210
1301
 
1211
1302
  // TODO(davidflanagan):
1212
- 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];
1303
+ 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];
1213
1304
 
1214
1305
  /**
1215
1306
  * TreeTransformer is a class for traversing and transforming trees. Create a
@@ -1271,12 +1362,16 @@ var AllRules = [AbsoluteUrl, BlockquotedMath, BlockquotedWidget, DoubleSpacingAf
1271
1362
 
1272
1363
  // TreeNode is the type of a node in a parse tree. The only real requirement is
1273
1364
  // that every node has a string-valued `type` property
1365
+
1274
1366
  // TraversalCallback is the type of the callback function passed to the
1275
1367
  // traverse() method. It is invoked with node, state, and content arguments
1276
1368
  // and is expected to return nothing.
1369
+
1277
1370
  // This is the TreeTransformer class described in detail at the
1278
1371
  // top of this file.
1279
1372
  class TreeTransformer {
1373
+ root;
1374
+
1280
1375
  // To create a tree transformer, just pass the root node of the tree
1281
1376
  constructor(root) {
1282
1377
  this.root = root;
@@ -1424,6 +1519,7 @@ class TreeTransformer {
1424
1519
  **/
1425
1520
  class TraversalState {
1426
1521
  // The root node of the tree being traversed
1522
+ root;
1427
1523
 
1428
1524
  // These are internal state properties. Use the accessor methods defined
1429
1525
  // below instead of using these properties directly. Note that the
@@ -1431,6 +1527,11 @@ class TraversalState {
1431
1527
  // elements, depending on whether we just recursed on an array or on a
1432
1528
  // node. This is hard for TypeScript to deal with, so you'll see a number of
1433
1529
  // type casts through the any type when working with these two properties.
1530
+ _currentNode;
1531
+ _containers;
1532
+ _indexes;
1533
+ _ancestors;
1534
+
1434
1535
  // The constructor just stores the root node and creates empty stacks.
1435
1536
  constructor(root) {
1436
1537
  this.root = root;
@@ -1558,7 +1659,7 @@ class TraversalState {
1558
1659
  replace() {
1559
1660
  const parent = this._containers.top();
1560
1661
  if (!parent) {
1561
- throw new perseusError.PerseusError("Can't replace the root of the tree", perseusError.Errors.Internal);
1662
+ throw new perseusCore.PerseusError("Can't replace the root of the tree", perseusCore.Errors.Internal);
1562
1663
  }
1563
1664
 
1564
1665
  // The top of the container stack is either an array or an object
@@ -1611,7 +1712,7 @@ class TraversalState {
1611
1712
  */
1612
1713
  goToPreviousSibling() {
1613
1714
  if (!this.hasPreviousSibling()) {
1614
- throw new perseusError.PerseusError("goToPreviousSibling(): node has no previous sibling", perseusError.Errors.Internal);
1715
+ throw new perseusCore.PerseusError("goToPreviousSibling(): node has no previous sibling", perseusCore.Errors.Internal);
1615
1716
  }
1616
1717
  this._currentNode = this.previousSibling();
1617
1718
  // Since we know that we have a previous sibling, we know that
@@ -1641,7 +1742,7 @@ class TraversalState {
1641
1742
  */
1642
1743
  goToParent() {
1643
1744
  if (!this.hasParent()) {
1644
- throw new perseusError.PerseusError("goToParent(): node has no ancestor", perseusError.Errors.NotAllowed);
1745
+ throw new perseusCore.PerseusError("goToParent(): node has no ancestor", perseusCore.Errors.NotAllowed);
1645
1746
  }
1646
1747
  this._currentNode = this._ancestors.pop();
1647
1748
 
@@ -1688,6 +1789,7 @@ class TraversalState {
1688
1789
  * the TraversalState class simpler in a number of places.
1689
1790
  */
1690
1791
  class Stack {
1792
+ stack;
1691
1793
  constructor(array) {
1692
1794
  this.stack = array ? array.slice(0) : [];
1693
1795
  }
@@ -1745,10 +1847,51 @@ class Stack {
1745
1847
  }
1746
1848
  }
1747
1849
 
1850
+ /**
1851
+ * Adds the given perseus library version information to the __perseus_debug__
1852
+ * object and ensures that the object is attached to `globalThis` (`window` in
1853
+ * browser environments).
1854
+ *
1855
+ * This allows each library to provide runtime version information to assist in
1856
+ * debugging in production environments.
1857
+ */
1858
+ const addLibraryVersionToPerseusDebug = (libraryName, libraryVersion) => {
1859
+ // If the library version is the default value, then we don't want to
1860
+ // prefix it with a "v" to indicate that it is a version number.
1861
+ let prefix = "v";
1862
+ if (libraryVersion === "__lib_version__") {
1863
+ prefix = "";
1864
+ }
1865
+ const formattedVersion = `${prefix}${libraryVersion}`;
1866
+ if (typeof globalThis !== "undefined") {
1867
+ globalThis.__perseus_debug__ = globalThis.__perseus_debug__ ?? {};
1868
+ const existingVersionEntry = globalThis.__perseus_debug__[libraryName];
1869
+ if (existingVersionEntry) {
1870
+ // If we already have an entry and it doesn't match the registered
1871
+ // version, we morph the entry into an array and log a warning.
1872
+ if (existingVersionEntry !== formattedVersion) {
1873
+ // Existing entry might be an array already (oops, at least 2
1874
+ // versions of the library already loaded!).
1875
+ const allVersions = Array.isArray(existingVersionEntry) ? existingVersionEntry : [existingVersionEntry];
1876
+ allVersions.push(formattedVersion);
1877
+ globalThis.__perseus_debug__[libraryName] = allVersions;
1878
+
1879
+ // eslint-disable-next-line no-console
1880
+ console.warn(`Multiple versions of ${libraryName} loaded on this page: ${allVersions.sort().join(", ")}`);
1881
+ }
1882
+ } else {
1883
+ globalThis.__perseus_debug__[libraryName] = formattedVersion;
1884
+ }
1885
+ } else {
1886
+ // eslint-disable-next-line no-console
1887
+ console.warn(`globalThis not found found (${formattedVersion})`);
1888
+ }
1889
+ };
1890
+
1748
1891
  // This file is processed by a Rollup plugin (replace) to inject the production
1749
1892
  const libName = "@khanacademy/perseus-linter";
1750
- const libVersion = "0.3.9";
1751
- perseusCore.addLibraryVersionToPerseusDebug(libName, libVersion);
1893
+ const libVersion = "1.2.18";
1894
+ addLibraryVersionToPerseusDebug(libName, libVersion);
1752
1895
 
1753
1896
  // Define the shape of the linter context object that is passed through the
1754
1897
  const linterContextProps = PropTypes__default["default"].shape({