@khanacademy/perseus-linter 0.1.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/es/index.js +3152 -0
  3. package/dist/es/index.js.map +1 -0
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.js +3129 -0
  6. package/dist/index.js.flow +2 -0
  7. package/dist/index.js.map +1 -0
  8. package/package.json +31 -0
  9. package/src/README.md +41 -0
  10. package/src/__tests__/matcher_test.js +498 -0
  11. package/src/__tests__/rule_test.js +102 -0
  12. package/src/__tests__/rules_test.js +488 -0
  13. package/src/__tests__/selector-parser_test.js +52 -0
  14. package/src/__tests__/tree-transformer_test.js +446 -0
  15. package/src/index.js +281 -0
  16. package/src/proptypes.js +29 -0
  17. package/src/rule.js +412 -0
  18. package/src/rules/absolute-url.js +24 -0
  19. package/src/rules/all-rules.js +72 -0
  20. package/src/rules/blockquoted-math.js +10 -0
  21. package/src/rules/blockquoted-widget.js +10 -0
  22. package/src/rules/double-spacing-after-terminal.js +12 -0
  23. package/src/rules/extra-content-spacing.js +12 -0
  24. package/src/rules/heading-level-1.js +14 -0
  25. package/src/rules/heading-level-skip.js +20 -0
  26. package/src/rules/heading-sentence-case.js +11 -0
  27. package/src/rules/heading-title-case.js +63 -0
  28. package/src/rules/image-alt-text.js +21 -0
  29. package/src/rules/image-in-table.js +10 -0
  30. package/src/rules/image-spaces-around-urls.js +35 -0
  31. package/src/rules/image-widget.js +50 -0
  32. package/src/rules/link-click-here.js +11 -0
  33. package/src/rules/lint-utils.js +48 -0
  34. package/src/rules/long-paragraph.js +14 -0
  35. package/src/rules/math-adjacent.js +10 -0
  36. package/src/rules/math-align-extra-break.js +11 -0
  37. package/src/rules/math-align-linebreaks.js +43 -0
  38. package/src/rules/math-empty.js +10 -0
  39. package/src/rules/math-font-size.js +12 -0
  40. package/src/rules/math-frac.js +10 -0
  41. package/src/rules/math-nested.js +11 -0
  42. package/src/rules/math-starts-with-space.js +12 -0
  43. package/src/rules/math-text-empty.js +10 -0
  44. package/src/rules/math-without-dollars.js +14 -0
  45. package/src/rules/nested-lists.js +11 -0
  46. package/src/rules/profanity.js +10 -0
  47. package/src/rules/table-missing-cells.js +20 -0
  48. package/src/rules/unbalanced-code-delimiters.js +14 -0
  49. package/src/rules/unescaped-dollar.js +10 -0
  50. package/src/rules/widget-in-table.js +10 -0
  51. package/src/selector.js +505 -0
  52. package/src/tree-transformer.js +587 -0
  53. package/src/types.js +10 -0
@@ -0,0 +1,21 @@
1
+ // @flow
2
+ import Rule from "../rule.js";
3
+
4
+ export default (Rule.makeRule({
5
+ name: "image-alt-text",
6
+ severity: Rule.Severity.WARNING,
7
+ selector: "image",
8
+ lint: function (state, content, nodes, match) {
9
+ const image = nodes[0];
10
+ if (!image.alt || !image.alt.trim()) {
11
+ return `Images should have alt text:
12
+ for accessibility, all images should have alt text.
13
+ Specify alt text inside square brackets after the !.`;
14
+ }
15
+ if (image.alt.length < 8) {
16
+ return `Images should have alt text:
17
+ for accessibility, all images should have descriptive alt text.
18
+ This image's alt text is only ${image.alt.length} characters long.`;
19
+ }
20
+ },
21
+ }): Rule);
@@ -0,0 +1,10 @@
1
+ // @flow
2
+ import Rule from "../rule.js";
3
+
4
+ export default (Rule.makeRule({
5
+ name: "image-in-table",
6
+ severity: Rule.Severity.BULK_WARNING,
7
+ selector: "table image",
8
+ message: `Image in table:
9
+ do not put images inside of tables.`,
10
+ }): Rule);
@@ -0,0 +1,35 @@
1
+ // @flow
2
+ import Rule from "../rule.js";
3
+
4
+ export default (Rule.makeRule({
5
+ name: "image-spaces-around-urls",
6
+ severity: Rule.Severity.ERROR,
7
+ selector: "image",
8
+ lint: function (state, content, nodes, match, context) {
9
+ const image = nodes[0];
10
+ const url = image.target;
11
+
12
+ // The markdown parser strips leading and trailing spaces for us,
13
+ // but they're still a problem for our translation process, so
14
+ // we need to go check for them in the unparsed source string
15
+ // if we have it.
16
+ if (context && context.content) {
17
+ // Find the url in the original content and make sure that the
18
+ // character before is '(' and the character after is ')'
19
+ const index = context.content.indexOf(url);
20
+ if (index === -1) {
21
+ // It is not an error if we didn't find it.
22
+ return;
23
+ }
24
+
25
+ if (
26
+ context.content[index - 1] !== "(" ||
27
+ context.content[index + url.length] !== ")"
28
+ ) {
29
+ return `Whitespace before or after image url:
30
+ For images, don't include any space or newlines after '(' or before ')'.
31
+ Whitespace in image URLs causes translation difficulties.`;
32
+ }
33
+ }
34
+ },
35
+ }): Rule);
@@ -0,0 +1,50 @@
1
+ // @flow
2
+ import Rule from "../rule.js";
3
+
4
+ // Normally we have one rule per file. But since our selector class
5
+ // can't match specific widget types directly, this rule implements
6
+ // a number of image widget related rules in one place. This should
7
+ // slightly increase efficiency, but it means that if there is more
8
+ // than one problem with an image widget, the user will only see one
9
+ // problem at a time.
10
+ export default (Rule.makeRule({
11
+ name: "image-widget",
12
+ severity: Rule.Severity.WARNING,
13
+ selector: "widget",
14
+ lint: function (state, content, nodes, match, context) {
15
+ // This rule only looks at image widgets
16
+ if (state.currentNode().widgetType !== "image") {
17
+ return;
18
+ }
19
+
20
+ // If it can't find a definition for the widget it does nothing
21
+ const widget =
22
+ context &&
23
+ context.widgets &&
24
+ context.widgets[state.currentNode().id];
25
+ if (!widget) {
26
+ return;
27
+ }
28
+
29
+ // Make sure there is alt text
30
+ const alt = widget.options.alt;
31
+ if (!alt) {
32
+ return `Images should have alt text:
33
+ for accessibility, all images should have a text description.
34
+ Add a description in the "Alt Text" box of the image widget.`;
35
+ }
36
+
37
+ // Make sure the alt text it is not trivial
38
+ if (alt.trim().length < 8) {
39
+ return `Images should have alt text:
40
+ for accessibility, all images should have descriptive alt text.
41
+ This image's alt text is only ${alt.trim().length} characters long.`;
42
+ }
43
+
44
+ // Make sure there is no math in the caption
45
+ if (widget.options.caption && widget.options.caption.match(/[^\\]\$/)) {
46
+ return `No math in image captions:
47
+ Don't include math expressions in image captions.`;
48
+ }
49
+ },
50
+ }): Rule);
@@ -0,0 +1,11 @@
1
+ // @flow
2
+ import Rule from "../rule.js";
3
+
4
+ export default (Rule.makeRule({
5
+ name: "link-click-here",
6
+ severity: Rule.Severity.WARNING,
7
+ selector: "link",
8
+ pattern: /click here/i,
9
+ message: `Inappropriate link text:
10
+ Do not use the words "click here" in links.`,
11
+ }): Rule);
@@ -0,0 +1,48 @@
1
+ /* eslint-disable no-useless-escape */
2
+ // @flow
3
+ // Return the portion of a URL between // and /. This is the authority
4
+ // portion which is usually just the hostname, but may also include
5
+ // a username, password or port. We don't strip those things out because
6
+ // we typically want to reject any URL that includes them
7
+ const HOSTNAME = /\/\/([^\/]+)/;
8
+
9
+ // Return the hostname of the URL, with any "www." prefix removed.
10
+ // If this is a relative URL with no hostname, return an empty string.
11
+ export function getHostname(url: string): string {
12
+ if (!url) {
13
+ return "";
14
+ }
15
+ const match = url.match(HOSTNAME);
16
+ return match ? match[1] : "";
17
+ }
18
+
19
+ // This list of domains that count as internal domains is from
20
+ // webapp/content/models.py and webapp/url_util.py
21
+ const internalDomains = {
22
+ "khanacademy.org": true,
23
+ "www.khanacademy.org": true,
24
+ "kasandbox.org": true,
25
+ "fastly.kastatic.org": true,
26
+ "cdn.kastatic.org": true, // This isn't a link to cdn.kastatic.org
27
+ "ka-youtube-converted.storage.googleapis.com": true,
28
+ "KA-share.s3.amazonaws.com": true,
29
+ "ka-article-iframes.s3.amazonaws.com": true,
30
+ "ka-cs-algorithms.s3.amazonaws.com": true,
31
+ "ka-cs-challenge-images.s3.amazonaws.com": true,
32
+ "ka-cs-scratchpad-audio.s3.amazonaws.com": true,
33
+ "ka-exercise-screenshots.s3.amazonaws.com": true,
34
+ "ka-exercise-screenshots-2.s3.amazonaws.com": true,
35
+ "ka-exercise-screenshots-3.s3.amazonaws.com": true,
36
+ "ka-learnstorm.s3.amazonaws.com": true,
37
+ "ka-mobile.s3.amazonaws.com": true,
38
+ "ka-perseus-graphie.s3.amazonaws.com": true,
39
+ "ka-perseus-images.s3.amazonaws.com": true,
40
+ };
41
+
42
+ // Returns true if this URL is relative, or if it is an absolute
43
+ // URL with one of the domains listed above as its hostname.
44
+ export function isInternalURL(url: string): boolean {
45
+ const hostname = getHostname(url);
46
+ // eslint-disable-next-line no-prototype-builtins
47
+ return hostname === "" || internalDomains.hasOwnProperty(hostname);
48
+ }
@@ -0,0 +1,14 @@
1
+ // @flow
2
+ import Rule from "../rule.js";
3
+
4
+ export default (Rule.makeRule({
5
+ name: "long-paragraph",
6
+ severity: Rule.Severity.GUIDELINE,
7
+ selector: "paragraph",
8
+ pattern: /^.{501,}/,
9
+ lint: function (state, content, nodes, match) {
10
+ return `Paragraph too long:
11
+ This paragraph is ${content.length} characters long.
12
+ Shorten it to 500 characters or fewer.`;
13
+ },
14
+ }): Rule);
@@ -0,0 +1,10 @@
1
+ // @flow
2
+ import Rule from "../rule.js";
3
+
4
+ export default (Rule.makeRule({
5
+ name: "math-adjacent",
6
+ severity: Rule.Severity.WARNING,
7
+ selector: "blockMath+blockMath",
8
+ message: `Adjacent math blocks:
9
+ combine the blocks between \\begin{align} and \\end{align}`,
10
+ }): Rule);
@@ -0,0 +1,11 @@
1
+ // @flow
2
+ import Rule from "../rule.js";
3
+
4
+ export default (Rule.makeRule({
5
+ name: "math-align-extra-break",
6
+ severity: Rule.Severity.WARNING,
7
+ selector: "blockMath",
8
+ pattern: /(\\{2,})\s*\\end{align}/,
9
+ message: `Extra space at end of block:
10
+ Don't end an align block with backslashes`,
11
+ }): Rule);
@@ -0,0 +1,43 @@
1
+ // @flow
2
+ import Rule from "../rule.js";
3
+
4
+ export default (Rule.makeRule({
5
+ name: "math-align-linebreaks",
6
+ severity: Rule.Severity.WARNING,
7
+ selector: "blockMath",
8
+ // Match any align block with double backslashes in it
9
+ // Use [\s\S]* instead of .* so we match newlines as well.
10
+ pattern: /\\begin{align}[\s\S]*\\\\[\s\S]+\\end{align}/,
11
+ // Look for double backslashes and ensure that they are
12
+ // followed by optional space and another pair of backslashes.
13
+ // Note that this rule can't know where line breaks belong so
14
+ // it can't tell whether backslashes are completely missing. It just
15
+ // enforces that you don't have the wrong number of pairs of backslashes.
16
+ lint: function (state, content, nodes, match) {
17
+ let text = match[0];
18
+ while (text.length) {
19
+ const index = text.indexOf("\\\\");
20
+ if (index === -1) {
21
+ // No more backslash pairs, so we found no lint
22
+ return null;
23
+ }
24
+ text = text.substring(index + 2);
25
+
26
+ // Now we expect to find optional spaces, another pair of
27
+ // backslashes, and more optional spaces not followed immediately
28
+ // by another pair of backslashes.
29
+ const nextpair = text.match(/^\s*\\\\\s*(?!\\\\)/);
30
+
31
+ // If that does not match then we either have too few or too
32
+ // many pairs of backslashes.
33
+ if (!nextpair) {
34
+ return "Use four backslashes between lines of an align block";
35
+ }
36
+
37
+ // If it did match, then, shorten the string and continue looping
38
+ // (because a single align block may have multiple lines that
39
+ // all must be separated by two sets of double backslashes).
40
+ text = text.substring(nextpair[0].length);
41
+ }
42
+ },
43
+ }): Rule);
@@ -0,0 +1,10 @@
1
+ // @flow
2
+ import Rule from "../rule.js";
3
+
4
+ export default (Rule.makeRule({
5
+ name: "math-empty",
6
+ severity: Rule.Severity.WARNING,
7
+ selector: "math, blockMath",
8
+ pattern: /^$/,
9
+ message: "Empty math: don't use $$ in your markdown.",
10
+ }): Rule);
@@ -0,0 +1,12 @@
1
+ // @flow
2
+ import Rule from "../rule.js";
3
+
4
+ export default (Rule.makeRule({
5
+ name: "math-font-size",
6
+ severity: Rule.Severity.GUIDELINE,
7
+ selector: "math, blockMath",
8
+ pattern:
9
+ /\\(tiny|Tiny|small|large|Large|LARGE|huge|Huge|scriptsize|normalsize)\s*{/,
10
+ message: `Math font size:
11
+ Don't change the default font size with \\Large{} or similar commands`,
12
+ }): Rule);
@@ -0,0 +1,10 @@
1
+ // @flow
2
+ import Rule from "../rule.js";
3
+
4
+ export default (Rule.makeRule({
5
+ name: "math-frac",
6
+ severity: Rule.Severity.GUIDELINE,
7
+ selector: "math, blockMath",
8
+ pattern: /\\frac[ {]/,
9
+ message: "Use \\dfrac instead of \\frac in your math expressions.",
10
+ }): Rule);
@@ -0,0 +1,11 @@
1
+ // @flow
2
+ import Rule from "../rule.js";
3
+
4
+ export default (Rule.makeRule({
5
+ name: "math-nested",
6
+ severity: Rule.Severity.ERROR,
7
+ selector: "math, blockMath",
8
+ pattern: /\\text{[^$}]*\$[^$}]*\$[^}]*}/,
9
+ message: `Nested math:
10
+ Don't nest math expressions inside \\text{} blocks`,
11
+ }): Rule);
@@ -0,0 +1,12 @@
1
+ // @flow
2
+ import Rule from "../rule.js";
3
+
4
+ export default (Rule.makeRule({
5
+ name: "math-starts-with-space",
6
+ severity: Rule.Severity.GUIDELINE,
7
+ selector: "math, blockMath",
8
+ pattern: /^\s*(~|\\qquad|\\quad|\\,|\\;|\\:|\\ |\\!|\\enspace|\\phantom)/,
9
+ message: `Math starts with space:
10
+ math should not be indented. Do not begin math expressions with
11
+ LaTeX space commands like ~, \\;, \\quad, or \\phantom`,
12
+ }): Rule);
@@ -0,0 +1,10 @@
1
+ // @flow
2
+ import Rule from "../rule.js";
3
+
4
+ export default (Rule.makeRule({
5
+ name: "math-text-empty",
6
+ severity: Rule.Severity.WARNING,
7
+ selector: "math, blockMath",
8
+ pattern: /\\text{\s*}/,
9
+ message: "Empty \\text{} block in math expression",
10
+ }): Rule);
@@ -0,0 +1,14 @@
1
+ // @flow
2
+ import Rule from "../rule.js";
3
+
4
+ // Because no selector is specified, this rule only applies to text nodes.
5
+ // Math and code hold their content directly and do not have text nodes
6
+ // beneath them (unlike the HTML DOM) so this rule automatically does not
7
+ // apply inside $$ or ``.
8
+ export default (Rule.makeRule({
9
+ name: "math-without-dollars",
10
+ severity: Rule.Severity.GUIDELINE,
11
+ pattern: /\\\w+{[^}]*}|{|}/,
12
+ message: `This looks like LaTeX:
13
+ did you mean to put it inside dollar signs?`,
14
+ }): Rule);
@@ -0,0 +1,11 @@
1
+ // @flow
2
+ import Rule from "../rule.js";
3
+
4
+ export default (Rule.makeRule({
5
+ name: "nested-lists",
6
+ severity: Rule.Severity.WARNING,
7
+ selector: "list list",
8
+ message: `Nested lists:
9
+ nested lists are hard to read on mobile devices;
10
+ do not use additional indentation.`,
11
+ }): Rule);
@@ -0,0 +1,10 @@
1
+ // @flow
2
+ import Rule from "../rule.js";
3
+
4
+ export default (Rule.makeRule({
5
+ name: "profanity",
6
+ // This list could obviously be expanded a lot, but I figured we
7
+ // could start with https://en.wikipedia.org/wiki/Seven_dirty_words
8
+ pattern: /\b(shit|piss|fuck|cunt|cocksucker|motherfucker|tits)\b/i,
9
+ message: "Avoid profanity",
10
+ }): Rule);
@@ -0,0 +1,20 @@
1
+ // @flow
2
+ import Rule from "../rule.js";
3
+
4
+ export default (Rule.makeRule({
5
+ name: "table-missing-cells",
6
+ severity: Rule.Severity.WARNING,
7
+ selector: "table",
8
+ lint: function (state, content, nodes, match) {
9
+ const table = nodes[0];
10
+ const headerLength = table.header.length;
11
+ const rowLengths = table.cells.map((r) => r.length);
12
+ for (let r = 0; r < rowLengths.length; r++) {
13
+ if (rowLengths[r] !== headerLength) {
14
+ return `Table rows don't match header:
15
+ The table header has ${headerLength} cells, but
16
+ Row ${r + 1} has ${rowLengths[r]} cells.`;
17
+ }
18
+ }
19
+ },
20
+ }): Rule);
@@ -0,0 +1,14 @@
1
+ // @flow
2
+ import Rule from "../rule.js";
3
+
4
+ // Because no selector is specified, this rule only applies to text nodes.
5
+ // Math and code hold their content directly and do not have text nodes
6
+ // beneath them (unlike the HTML DOM) so this rule automatically does not
7
+ // apply inside $$ or ``.
8
+ export default (Rule.makeRule({
9
+ name: "unbalanced-code-delimiters",
10
+ severity: Rule.Severity.ERROR,
11
+ pattern: /[`~]+/,
12
+ message: `Unbalanced code delimiters:
13
+ code blocks should begin and end with the same type and number of delimiters`,
14
+ }): Rule);
@@ -0,0 +1,10 @@
1
+ // @flow
2
+ import Rule from "../rule.js";
3
+
4
+ export default (Rule.makeRule({
5
+ name: "unescaped-dollar",
6
+ severity: Rule.Severity.ERROR,
7
+ selector: "unescapedDollar",
8
+ message: `Unescaped dollar sign:
9
+ Dollar signs must appear in pairs or be escaped as \\$`,
10
+ }): Rule);
@@ -0,0 +1,10 @@
1
+ // @flow
2
+ import Rule from "../rule.js";
3
+
4
+ export default (Rule.makeRule({
5
+ name: "widget-in-table",
6
+ severity: Rule.Severity.BULK_WARNING,
7
+ selector: "table widget",
8
+ message: `Widget in table:
9
+ do not put widgets inside of tables.`,
10
+ }): Rule);