chaffjs 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/dist/api.d.ts +14 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js.map +1 -1
- package/dist/cli-args.d.ts +2 -0
- package/dist/cli-args.d.ts.map +1 -1
- package/dist/cli-args.js +2 -0
- package/dist/cli-args.js.map +1 -1
- package/dist/cli-text.d.ts.map +1 -1
- package/dist/cli-text.js +6 -0
- package/dist/cli-text.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +17 -30
- package/dist/cli.js.map +1 -1
- package/dist/commands/feedback.d.ts +3 -0
- package/dist/commands/feedback.d.ts.map +1 -1
- package/dist/commands/feedback.js.map +1 -1
- package/dist/commands/fix-plan.d.ts.map +1 -1
- package/dist/commands/fix-plan.js +5 -0
- package/dist/commands/fix-plan.js.map +1 -1
- package/dist/commands/init-command.d.ts +4 -0
- package/dist/commands/init-command.d.ts.map +1 -0
- package/dist/commands/init-command.js +15 -0
- package/dist/commands/init-command.js.map +1 -0
- package/dist/commands/plugin-init.d.ts +20 -0
- package/dist/commands/plugin-init.d.ts.map +1 -0
- package/dist/commands/plugin-init.js +129 -0
- package/dist/commands/plugin-init.js.map +1 -0
- package/dist/commands/plugin-test.d.ts +11 -0
- package/dist/commands/plugin-test.d.ts.map +1 -0
- package/dist/commands/plugin-test.js +81 -0
- package/dist/commands/plugin-test.js.map +1 -0
- package/dist/commands/rules.d.ts +9 -0
- package/dist/commands/rules.d.ts.map +1 -0
- package/dist/commands/rules.js +26 -0
- package/dist/commands/rules.js.map +1 -0
- package/dist/config/load.d.ts +6 -1
- package/dist/config/load.d.ts.map +1 -1
- package/dist/config/load.js +1 -0
- package/dist/config/load.js.map +1 -1
- package/dist/config/style.js +1 -1
- package/dist/config/style.js.map +1 -1
- package/dist/config/warnings.d.ts +1 -1
- package/dist/config/warnings.d.ts.map +1 -1
- package/dist/config/warnings.js +3 -1
- package/dist/config/warnings.js.map +1 -1
- package/dist/custom/problems.d.ts.map +1 -1
- package/dist/custom/problems.js +1 -2
- package/dist/custom/problems.js.map +1 -1
- package/dist/detectors/amount-for-count.d.ts +21 -0
- package/dist/detectors/amount-for-count.d.ts.map +1 -0
- package/dist/detectors/amount-for-count.js +55 -0
- package/dist/detectors/amount-for-count.js.map +1 -0
- package/dist/detectors/currency-notation.d.ts +29 -0
- package/dist/detectors/currency-notation.d.ts.map +1 -0
- package/dist/detectors/currency-notation.js +58 -0
- package/dist/detectors/currency-notation.js.map +1 -0
- package/dist/detectors/dangling-opener.d.ts +14 -0
- package/dist/detectors/dangling-opener.d.ts.map +1 -0
- package/dist/detectors/dangling-opener.js +55 -0
- package/dist/detectors/dangling-opener.js.map +1 -0
- package/dist/detectors/ellipsis.d.ts +10 -0
- package/dist/detectors/ellipsis.d.ts.map +1 -0
- package/dist/detectors/ellipsis.js +56 -0
- package/dist/detectors/ellipsis.js.map +1 -0
- package/dist/detectors/footnote-mismatch.d.ts +11 -0
- package/dist/detectors/footnote-mismatch.d.ts.map +1 -0
- package/dist/detectors/footnote-mismatch.js +55 -0
- package/dist/detectors/footnote-mismatch.js.map +1 -0
- package/dist/detectors/form-minority.d.ts +13 -0
- package/dist/detectors/form-minority.d.ts.map +1 -0
- package/dist/detectors/form-minority.js +19 -0
- package/dist/detectors/form-minority.js.map +1 -0
- package/dist/detectors/heading-end-mark.d.ts +10 -0
- package/dist/detectors/heading-end-mark.d.ts.map +1 -0
- package/dist/detectors/heading-end-mark.js +59 -0
- package/dist/detectors/heading-end-mark.js.map +1 -0
- package/dist/detectors/list-marker.d.ts +7 -0
- package/dist/detectors/list-marker.d.ts.map +1 -0
- package/dist/detectors/list-marker.js +50 -0
- package/dist/detectors/list-marker.js.map +1 -0
- package/dist/detectors/markdown-slip.d.ts +19 -0
- package/dist/detectors/markdown-slip.d.ts.map +1 -0
- package/dist/detectors/markdown-slip.js +122 -0
- package/dist/detectors/markdown-slip.js.map +1 -0
- package/dist/detectors/numeral-opener.d.ts +10 -0
- package/dist/detectors/numeral-opener.d.ts.map +1 -0
- package/dist/detectors/numeral-opener.js +65 -0
- package/dist/detectors/numeral-opener.js.map +1 -0
- package/dist/detectors/ordinal-suffix.d.ts +12 -0
- package/dist/detectors/ordinal-suffix.d.ts.map +1 -0
- package/dist/detectors/ordinal-suffix.js +33 -0
- package/dist/detectors/ordinal-suffix.js.map +1 -0
- package/dist/detectors/range-notation.d.ts +15 -0
- package/dist/detectors/range-notation.d.ts.map +1 -0
- package/dist/detectors/range-notation.js +58 -0
- package/dist/detectors/range-notation.js.map +1 -0
- package/dist/detectors/registry/amount-for-count.d.ts +3 -0
- package/dist/detectors/registry/amount-for-count.d.ts.map +1 -0
- package/dist/detectors/registry/amount-for-count.js +3 -0
- package/dist/detectors/registry/amount-for-count.js.map +1 -0
- package/dist/detectors/registry/currency-notation.d.ts +3 -0
- package/dist/detectors/registry/currency-notation.d.ts.map +1 -0
- package/dist/detectors/registry/currency-notation.js +3 -0
- package/dist/detectors/registry/currency-notation.js.map +1 -0
- package/dist/detectors/registry/dangling-opener.d.ts +3 -0
- package/dist/detectors/registry/dangling-opener.d.ts.map +1 -0
- package/dist/detectors/registry/dangling-opener.js +3 -0
- package/dist/detectors/registry/dangling-opener.js.map +1 -0
- package/dist/detectors/registry/ellipsis-consistency.d.ts +3 -0
- package/dist/detectors/registry/ellipsis-consistency.d.ts.map +1 -0
- package/dist/detectors/registry/ellipsis-consistency.js +3 -0
- package/dist/detectors/registry/ellipsis-consistency.js.map +1 -0
- package/dist/detectors/registry/footnote-mismatch.d.ts +3 -0
- package/dist/detectors/registry/footnote-mismatch.d.ts.map +1 -0
- package/dist/detectors/registry/footnote-mismatch.js +3 -0
- package/dist/detectors/registry/footnote-mismatch.js.map +1 -0
- package/dist/detectors/registry/heading-end-punctuation.d.ts +3 -0
- package/dist/detectors/registry/heading-end-punctuation.d.ts.map +1 -0
- package/dist/detectors/registry/heading-end-punctuation.js +3 -0
- package/dist/detectors/registry/heading-end-punctuation.js.map +1 -0
- package/dist/detectors/registry/list-marker.d.ts +3 -0
- package/dist/detectors/registry/list-marker.d.ts.map +1 -0
- package/dist/detectors/registry/list-marker.js +3 -0
- package/dist/detectors/registry/list-marker.js.map +1 -0
- package/dist/detectors/registry/numeral-opener.d.ts +3 -0
- package/dist/detectors/registry/numeral-opener.d.ts.map +1 -0
- package/dist/detectors/registry/numeral-opener.js +3 -0
- package/dist/detectors/registry/numeral-opener.js.map +1 -0
- package/dist/detectors/registry/ordinal-suffix.d.ts +3 -0
- package/dist/detectors/registry/ordinal-suffix.d.ts.map +1 -0
- package/dist/detectors/registry/ordinal-suffix.js +3 -0
- package/dist/detectors/registry/ordinal-suffix.js.map +1 -0
- package/dist/detectors/registry/range-notation.d.ts +3 -0
- package/dist/detectors/registry/range-notation.d.ts.map +1 -0
- package/dist/detectors/registry/range-notation.js +3 -0
- package/dist/detectors/registry/range-notation.js.map +1 -0
- package/dist/detectors/registry/relative-pronoun-mix.d.ts +3 -0
- package/dist/detectors/registry/relative-pronoun-mix.d.ts.map +1 -0
- package/dist/detectors/registry/relative-pronoun-mix.js +3 -0
- package/dist/detectors/registry/relative-pronoun-mix.js.map +1 -0
- package/dist/detectors/registry/share-over-hundred.d.ts +3 -0
- package/dist/detectors/registry/share-over-hundred.d.ts.map +1 -0
- package/dist/detectors/registry/share-over-hundred.js +3 -0
- package/dist/detectors/registry/share-over-hundred.js.map +1 -0
- package/dist/detectors/registry/step-reference.d.ts +3 -0
- package/dist/detectors/registry/step-reference.d.ts.map +1 -0
- package/dist/detectors/registry/step-reference.js +3 -0
- package/dist/detectors/registry/step-reference.js.map +1 -0
- package/dist/detectors/registry/table-row-overflow.d.ts +3 -0
- package/dist/detectors/registry/table-row-overflow.d.ts.map +1 -0
- package/dist/detectors/registry/table-row-overflow.js +3 -0
- package/dist/detectors/registry/table-row-overflow.js.map +1 -0
- package/dist/detectors/registry/toc-heading.d.ts +3 -0
- package/dist/detectors/registry/toc-heading.d.ts.map +1 -0
- package/dist/detectors/registry/toc-heading.js +3 -0
- package/dist/detectors/registry/toc-heading.js.map +1 -0
- package/dist/detectors/registry/unclosed-code-fence.d.ts +3 -0
- package/dist/detectors/registry/unclosed-code-fence.d.ts.map +1 -0
- package/dist/detectors/registry/unclosed-code-fence.js +3 -0
- package/dist/detectors/registry/unclosed-code-fence.js.map +1 -0
- package/dist/detectors/registry/unrendered-emphasis.d.ts +3 -0
- package/dist/detectors/registry/unrendered-emphasis.d.ts.map +1 -0
- package/dist/detectors/registry/unrendered-emphasis.js +3 -0
- package/dist/detectors/registry/unrendered-emphasis.js.map +1 -0
- package/dist/detectors/relative-pronoun-mix.d.ts +19 -0
- package/dist/detectors/relative-pronoun-mix.d.ts.map +1 -0
- package/dist/detectors/relative-pronoun-mix.js +60 -0
- package/dist/detectors/relative-pronoun-mix.js.map +1 -0
- package/dist/detectors/share-over-hundred.d.ts +20 -0
- package/dist/detectors/share-over-hundred.d.ts.map +1 -0
- package/dist/detectors/share-over-hundred.js +55 -0
- package/dist/detectors/share-over-hundred.js.map +1 -0
- package/dist/detectors/step-reference.d.ts +20 -0
- package/dist/detectors/step-reference.d.ts.map +1 -0
- package/dist/detectors/step-reference.js +57 -0
- package/dist/detectors/step-reference.js.map +1 -0
- package/dist/detectors/toc-heading.d.ts +10 -0
- package/dist/detectors/toc-heading.d.ts.map +1 -0
- package/dist/detectors/toc-heading.js +99 -0
- package/dist/detectors/toc-heading.js.map +1 -0
- package/dist/extension/load.d.ts +3 -0
- package/dist/extension/load.d.ts.map +1 -1
- package/dist/extension/load.js +1 -0
- package/dist/extension/load.js.map +1 -1
- package/dist/extension/pack-read.d.ts.map +1 -1
- package/dist/extension/pack-read.js +1 -0
- package/dist/extension/pack-read.js.map +1 -1
- package/dist/extension/plugin-load.js.map +1 -1
- package/dist/extension/plugin-parse.d.ts +7 -0
- package/dist/extension/plugin-parse.d.ts.map +1 -1
- package/dist/extension/plugin-parse.js +18 -2
- package/dist/extension/plugin-parse.js.map +1 -1
- package/dist/extension/problem-text.d.ts.map +1 -1
- package/dist/extension/problem-text.js +5 -0
- package/dist/extension/problem-text.js.map +1 -1
- package/dist/fix-plan/plan.d.ts +4 -0
- package/dist/fix-plan/plan.d.ts.map +1 -1
- package/dist/fix-plan/plan.js +1 -0
- package/dist/fix-plan/plan.js.map +1 -1
- package/dist/fix-plan/render.d.ts.map +1 -1
- package/dist/fix-plan/render.js +9 -0
- package/dist/fix-plan/render.js.map +1 -1
- package/dist/fix-plan/text.d.ts +4 -0
- package/dist/fix-plan/text.d.ts.map +1 -1
- package/dist/fix-plan/text.js +8 -2
- package/dist/fix-plan/text.js.map +1 -1
- package/dist/genre-guide/key-rules.d.ts +10 -0
- package/dist/genre-guide/key-rules.d.ts.map +1 -0
- package/dist/genre-guide/key-rules.js +23 -0
- package/dist/genre-guide/key-rules.js.map +1 -0
- package/dist/genre-guide/layer.d.ts +44 -0
- package/dist/genre-guide/layer.d.ts.map +1 -0
- package/dist/genre-guide/layer.js +43 -0
- package/dist/genre-guide/layer.js.map +1 -0
- package/dist/genre-guide/lines.d.ts +15 -0
- package/dist/genre-guide/lines.d.ts.map +1 -0
- package/dist/genre-guide/lines.js +39 -0
- package/dist/genre-guide/lines.js.map +1 -0
- package/dist/genre-guide/of-config.d.ts +13 -0
- package/dist/genre-guide/of-config.d.ts.map +1 -0
- package/dist/genre-guide/of-config.js +18 -0
- package/dist/genre-guide/of-config.js.map +1 -0
- package/dist/genre-guide/problem-text.d.ts +5 -0
- package/dist/genre-guide/problem-text.d.ts.map +1 -0
- package/dist/genre-guide/problem-text.js +29 -0
- package/dist/genre-guide/problem-text.js.map +1 -0
- package/dist/genre-guide/render.d.ts +5 -0
- package/dist/genre-guide/render.d.ts.map +1 -0
- package/dist/genre-guide/render.js +38 -0
- package/dist/genre-guide/render.js.map +1 -0
- package/dist/genre-guide/report.d.ts +32 -0
- package/dist/genre-guide/report.d.ts.map +1 -0
- package/dist/genre-guide/report.js +42 -0
- package/dist/genre-guide/report.js.map +1 -0
- package/dist/genre-guide/resolve.d.ts +16 -0
- package/dist/genre-guide/resolve.d.ts.map +1 -0
- package/dist/genre-guide/resolve.js +31 -0
- package/dist/genre-guide/resolve.js.map +1 -0
- package/dist/genre-parse.d.ts +5 -1
- package/dist/genre-parse.d.ts.map +1 -1
- package/dist/genre-parse.js +8 -1
- package/dist/genre-parse.js.map +1 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +14 -0
- package/dist/init.js.map +1 -1
- package/dist/list-marks.d.ts +21 -0
- package/dist/list-marks.d.ts.map +1 -0
- package/dist/list-marks.js +82 -0
- package/dist/list-marks.js.map +1 -0
- package/dist/render/rules-json.d.ts +2 -1
- package/dist/render/rules-json.d.ts.map +1 -1
- package/dist/render/rules-json.js +3 -1
- package/dist/render/rules-json.js.map +1 -1
- package/dist/render/sarif.d.ts +3 -1
- package/dist/render/sarif.d.ts.map +1 -1
- package/dist/render/sarif.js +3 -1
- package/dist/render/sarif.js.map +1 -1
- package/dist/rule-fields.d.ts +5 -0
- package/dist/rule-fields.d.ts.map +1 -1
- package/dist/rule-fields.js +21 -0
- package/dist/rule-fields.js.map +1 -1
- package/dist/style-parse.d.ts +3 -0
- package/dist/style-parse.d.ts.map +1 -1
- package/dist/style-parse.js +18 -2
- package/dist/style-parse.js.map +1 -1
- package/genres.yaml +329 -0
- package/package.json +3 -3
- package/rules/cliche.yaml +67 -0
- package/rules/currency-notation-consistency.yaml +77 -0
- package/rules/dangling-participle.yaml +58 -0
- package/rules/ellipsis-consistency.yaml +73 -0
- package/rules/figurative-density.yaml +84 -0
- package/rules/footnote-mismatch.yaml +91 -0
- package/rules/heading-end-punctuation-mix.yaml +158 -0
- package/rules/less-for-fewer.yaml +59 -0
- package/rules/list-marker-mix.yaml +111 -0
- package/rules/nonstandard-word.yaml +80 -0
- package/rules/numeral-sentence-start.yaml +56 -0
- package/rules/ordinal-suffix-mismatch.yaml +65 -0
- package/rules/range-notation-consistency.yaml +73 -0
- package/rules/share-over-hundred.yaml +65 -0
- package/rules/step-reference-missing.yaml +85 -0
- package/rules/table-row-overflow.yaml +90 -0
- package/rules/toc-heading-mismatch.yaml +136 -0
- package/rules/unclosed-code-fence.yaml +107 -0
- package/rules/uncomparable-graded.yaml +65 -0
- package/rules/unrendered-emphasis.yaml +73 -0
- package/rules/which-that-consistency.yaml +64 -0
- package/skills/chaff/SKILL.md +13 -7
- package/src/api.ts +13 -0
- package/src/cli-args.ts +3 -0
- package/src/cli-text.ts +6 -0
- package/src/cli.ts +21 -29
- package/src/commands/feedback.ts +3 -0
- package/src/commands/fix-plan.ts +8 -0
- package/src/commands/init-command.ts +13 -0
- package/src/commands/plugin-init.ts +150 -0
- package/src/commands/plugin-test.ts +111 -0
- package/src/commands/rules.ts +34 -0
- package/src/config/load.ts +7 -1
- package/src/config/style.ts +1 -1
- package/src/config/warnings.ts +3 -1
- package/src/custom/problems.ts +1 -3
- package/src/detectors/amount-for-count.ts +90 -0
- package/src/detectors/currency-notation.ts +90 -0
- package/src/detectors/dangling-opener.ts +74 -0
- package/src/detectors/ellipsis.ts +74 -0
- package/src/detectors/footnote-mismatch.ts +72 -0
- package/src/detectors/form-minority.ts +21 -0
- package/src/detectors/heading-end-mark.ts +82 -0
- package/src/detectors/list-marker.ts +60 -0
- package/src/detectors/markdown-slip.ts +140 -0
- package/src/detectors/numeral-opener.ts +72 -0
- package/src/detectors/ordinal-suffix.ts +40 -0
- package/src/detectors/range-notation.ts +76 -0
- package/src/detectors/registry/amount-for-count.ts +4 -0
- package/src/detectors/registry/currency-notation.ts +4 -0
- package/src/detectors/registry/dangling-opener.ts +4 -0
- package/src/detectors/registry/ellipsis-consistency.ts +4 -0
- package/src/detectors/registry/footnote-mismatch.ts +4 -0
- package/src/detectors/registry/heading-end-punctuation.ts +4 -0
- package/src/detectors/registry/list-marker.ts +4 -0
- package/src/detectors/registry/numeral-opener.ts +4 -0
- package/src/detectors/registry/ordinal-suffix.ts +4 -0
- package/src/detectors/registry/range-notation.ts +4 -0
- package/src/detectors/registry/relative-pronoun-mix.ts +4 -0
- package/src/detectors/registry/share-over-hundred.ts +4 -0
- package/src/detectors/registry/step-reference.ts +4 -0
- package/src/detectors/registry/table-row-overflow.ts +4 -0
- package/src/detectors/registry/toc-heading.ts +4 -0
- package/src/detectors/registry/unclosed-code-fence.ts +4 -0
- package/src/detectors/registry/unrendered-emphasis.ts +4 -0
- package/src/detectors/relative-pronoun-mix.ts +79 -0
- package/src/detectors/share-over-hundred.ts +75 -0
- package/src/detectors/step-reference.ts +83 -0
- package/src/detectors/toc-heading.ts +125 -0
- package/src/extension/load.ts +4 -0
- package/src/extension/pack-read.ts +1 -0
- package/src/extension/plugin-load.ts +1 -1
- package/src/extension/plugin-parse.ts +27 -4
- package/src/extension/problem-text.ts +7 -1
- package/src/fix-plan/plan.ts +5 -0
- package/src/fix-plan/render.ts +9 -0
- package/src/fix-plan/text.ts +12 -2
- package/src/genre-guide/key-rules.ts +37 -0
- package/src/genre-guide/layer.ts +77 -0
- package/src/genre-guide/lines.ts +41 -0
- package/src/genre-guide/of-config.ts +26 -0
- package/src/genre-guide/problem-text.ts +34 -0
- package/src/genre-guide/render.ts +50 -0
- package/src/genre-guide/report.ts +63 -0
- package/src/genre-guide/resolve.ts +36 -0
- package/src/genre-parse.ts +13 -3
- package/src/init.ts +14 -0
- package/src/list-marks.ts +106 -0
- package/src/render/rules-json.ts +4 -0
- package/src/render/sarif.ts +4 -1
- package/src/rule-fields.ts +28 -0
- package/src/style-parse.ts +19 -1
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Detector, Finding, ProseDocument } from "../plugin.ts";
|
|
2
|
+
import { escapeRegExp } from "../orthography.ts";
|
|
3
|
+
import { formMinority } from "./form-minority.ts";
|
|
4
|
+
import { quoteAround } from "./quote-around.ts";
|
|
5
|
+
|
|
6
|
+
// 一つの文書で、同じ通貨の金額を二通りに書いた所(1,000円 と ¥1,000、$20 と 20 dollars)。どちらが正しいかは決めず、少ないほうを指す。
|
|
7
|
+
// 通貨の書き方、それを金額のどちら側に書くか、どの通貨か(group)は語彙表 currency-notation、桁の語は amount-multiplier が言う。
|
|
8
|
+
|
|
9
|
+
export type CurrencyForm = { readonly pattern: string; readonly position: "before" | "after"; readonly currency: string };
|
|
10
|
+
|
|
11
|
+
/** 金額 1 つ。form は書き方(側と記号)、currency はその通貨。 */
|
|
12
|
+
export type WrittenAmount = { readonly offset: number; readonly written: string; readonly form: string; readonly currency: string };
|
|
13
|
+
|
|
14
|
+
/** 少ないほうの書き方の金額と、多いほうの書き方の例、その通貨の金額の数。 */
|
|
15
|
+
export type CurrencyMinority = { readonly odd: WrittenAmount; readonly usual: string; readonly count: number; readonly of: number };
|
|
16
|
+
|
|
17
|
+
const AMOUNT = "[0-90-9]+(?:[,,][0-90-9]{3})*(?:[..][0-90-9]+)?";
|
|
18
|
+
|
|
19
|
+
const alternation = (words: readonly string[]): string => words.map(escapeRegExp).join("|");
|
|
20
|
+
|
|
21
|
+
/** 記号が英字で始まる・終わるなら、語の中(USDT、dollarsign)を読まないよう英字の切れ目を求める。 */
|
|
22
|
+
const latinEdge = (pattern: string, side: "start" | "end"): string => {
|
|
23
|
+
const edge = side === "start" ? pattern.charAt(0) : pattern.charAt(pattern.length - 1);
|
|
24
|
+
if (!/[A-Za-z]/u.test(edge)) return "";
|
|
25
|
+
return side === "start" ? "(?<![A-Za-z])" : "(?![A-Za-z])";
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const amountPattern = (form: CurrencyForm, multipliers: readonly string[]): RegExp => {
|
|
29
|
+
const mark = escapeRegExp(form.pattern);
|
|
30
|
+
const multiplier = multipliers.length === 0 ? "" : `(?:\\s?(?:${alternation(multipliers)}))?`;
|
|
31
|
+
return form.position === "before"
|
|
32
|
+
? new RegExp(`${latinEdge(form.pattern, "start")}${mark}\\s?${AMOUNT}${multiplier}(?![A-Za-z0-90-9])`, "gu")
|
|
33
|
+
: new RegExp(`(?<![\\w.,,0-9])${AMOUNT}${multiplier}\\s?${mark}${latinEdge(form.pattern, "end")}`, "gu");
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const overlaps = (left: WrittenAmount, right: WrittenAmount): boolean =>
|
|
37
|
+
left.offset < right.offset + right.written.length && right.offset < left.offset + left.written.length;
|
|
38
|
+
|
|
39
|
+
/** 文字列の中の金額と、その書き方。重なる所に二つの書き方が当たれば、記号の長いほう(US$ と $ なら US$、米ドル と ドル なら 米ドル)。 */
|
|
40
|
+
export const amountsIn = (text: string, forms: readonly CurrencyForm[], multipliers: readonly string[]): WrittenAmount[] =>
|
|
41
|
+
forms
|
|
42
|
+
.toSorted((left, right) => right.pattern.length - left.pattern.length)
|
|
43
|
+
.flatMap((form) =>
|
|
44
|
+
[...text.matchAll(amountPattern(form, multipliers))].map((match) => ({
|
|
45
|
+
offset: match.index,
|
|
46
|
+
written: match[0],
|
|
47
|
+
form: `${form.position}:${form.pattern}`,
|
|
48
|
+
currency: form.currency,
|
|
49
|
+
})),
|
|
50
|
+
)
|
|
51
|
+
.reduce<WrittenAmount[]>((kept, amount) => (kept.some((other) => overlaps(other, amount)) ? kept : [...kept, amount]), [])
|
|
52
|
+
.toSorted((left, right) => left.offset - right.offset);
|
|
53
|
+
|
|
54
|
+
const minorityOf = (ofCurrency: readonly WrittenAmount[], limit: number): CurrencyMinority[] => {
|
|
55
|
+
const minority = formMinority(ofCurrency, limit);
|
|
56
|
+
if (minority === undefined) return [];
|
|
57
|
+
return minority.odd.map((amount) => ({ odd: amount, usual: minority.usual.written, count: minority.odd.length, of: ofCurrency.length }));
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 通貨ごとに、多いほうと違う書き方の金額。少ないほうが limit パーセントを超えれば、使い分けている文書と読んで言わない。
|
|
62
|
+
* いちばん多い書き方が同じ数で並べば、どちらにも立たない。
|
|
63
|
+
*/
|
|
64
|
+
export const currencyMinorities = (amounts: readonly WrittenAmount[], limit: number): CurrencyMinority[] =>
|
|
65
|
+
[...new Set(amounts.map((amount) => amount.currency))]
|
|
66
|
+
.flatMap((currency) =>
|
|
67
|
+
minorityOf(
|
|
68
|
+
amounts.filter((amount) => amount.currency === currency),
|
|
69
|
+
limit,
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
.toSorted((left, right) => left.odd.offset - right.odd.offset);
|
|
73
|
+
|
|
74
|
+
const formsOf = (doc: ProseDocument): CurrencyForm[] =>
|
|
75
|
+
(doc.lexicons["currency-notation"] ?? []).flatMap((entry) =>
|
|
76
|
+
entry.position === undefined || entry.group === undefined ? [] : [{ pattern: entry.pattern, position: entry.position, currency: entry.group }],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
export const currencyNotation: Detector = (doc, options): Finding[] => {
|
|
80
|
+
const text = doc.prose ?? doc.source;
|
|
81
|
+
const multipliers = (doc.lexicons["amount-multiplier"] ?? []).map((entry) => entry.pattern);
|
|
82
|
+
return currencyMinorities(amountsIn(text, formsOf(doc), multipliers), options.limit).map(({ odd, usual, count, of }) => ({
|
|
83
|
+
rule: "",
|
|
84
|
+
severity: "info",
|
|
85
|
+
line: 0,
|
|
86
|
+
column: 0,
|
|
87
|
+
quote: quoteAround(text, odd.offset, odd.offset + odd.written.length),
|
|
88
|
+
values: { written: odd.written.trim(), usual: usual.trim(), count, of, offset: odd.offset },
|
|
89
|
+
}));
|
|
90
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Detector, Finding, Lexicon, Token } from "../plugin.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 分詞で始めた句の後ろの主語が、形だけの it か there の文(Having reviewed the code, it is ready.)。
|
|
5
|
+
* 分詞の動作をしたのは書き手か読み手で、it ではない。読むのはこの形だけに絞り、主語が名詞の文は読まない。
|
|
6
|
+
* 読むのは -ing の分詞だけ。決まった言い方として読まれる分詞(Given, Based on, Generally speaking)は語彙表 dangling-participle の exempt が言う。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type DanglingOpener = { readonly opener: string; readonly subject: Token; readonly offset: number };
|
|
10
|
+
|
|
11
|
+
/** 分詞の句として読む語の数の上限。長い句は読点の位置が当てにならない。 */
|
|
12
|
+
const MAX_OPENER_TOKENS = 12;
|
|
13
|
+
const BE = "be";
|
|
14
|
+
const HAVE = "have";
|
|
15
|
+
/** be の後ろで読点が来たら、it is … は挿入句(Testing the integration, it is worth noting, requires …)。 */
|
|
16
|
+
const PARENTHETICAL_REACH = 4;
|
|
17
|
+
|
|
18
|
+
const lower = (token: Token | undefined): string => token?.surface.toLowerCase() ?? "";
|
|
19
|
+
|
|
20
|
+
/** -ing の分詞だけ。過去分詞の句(Configured with SSO, it is ready.)は it そのものを言うことが多い。 */
|
|
21
|
+
const isPresentParticiple = (token: Token | undefined): boolean => token?.pos === "VERB" && token.features?.["VerbForm"] === "Ger";
|
|
22
|
+
|
|
23
|
+
const lemmaOf = (token: Token | undefined): string => token?.lemma?.toLowerCase() ?? "";
|
|
24
|
+
|
|
25
|
+
/** 文の頭が決まった言い方か。語彙表の語は空白で区切って、頭の語と順に比べる。 */
|
|
26
|
+
const opensWithExempt = (tokens: readonly Token[], exempt: readonly string[]): boolean =>
|
|
27
|
+
exempt.some((phrase) => phrase.split(/\s+/u).every((word, at) => lower(tokens[at]) === word));
|
|
28
|
+
|
|
29
|
+
/** 形だけの主語の後ろの be(it is / there are)、助動詞と be(it can be)、have と been(it has been)。 */
|
|
30
|
+
const isBeAfter = (tokens: readonly Token[], at: number): boolean => {
|
|
31
|
+
const verb = tokens[at];
|
|
32
|
+
if (lemmaOf(verb) === BE) return true;
|
|
33
|
+
return (verb?.pos === "AUX" || lemmaOf(verb) === HAVE) && lemmaOf(tokens[at + 1]) === BE;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const isParenthetical = (tokens: readonly Token[], at: number): boolean => tokens.slice(at, at + PARENTHETICAL_REACH).some((token) => token.surface === ",");
|
|
37
|
+
|
|
38
|
+
export const danglingOpenerIn = (
|
|
39
|
+
source: string,
|
|
40
|
+
tokens: readonly Token[],
|
|
41
|
+
subjects: ReadonlySet<string>,
|
|
42
|
+
exempt: readonly string[],
|
|
43
|
+
): DanglingOpener | undefined => {
|
|
44
|
+
const [first] = tokens;
|
|
45
|
+
if (first === undefined || !isPresentParticiple(first) || opensWithExempt(tokens, exempt)) return undefined;
|
|
46
|
+
const comma = tokens.findIndex((token, at) => at > 0 && token.surface === ",");
|
|
47
|
+
const subject = tokens[comma + 1];
|
|
48
|
+
if (comma === -1 || comma > MAX_OPENER_TOKENS || subject === undefined || !subjects.has(lower(subject)) || !isBeAfter(tokens, comma + 2)) return undefined;
|
|
49
|
+
if (isParenthetical(tokens, comma + 3)) return undefined;
|
|
50
|
+
const last = tokens[comma - 1] ?? first;
|
|
51
|
+
return { opener: source.slice(first.span.start, last.span.end), subject, offset: first.span.start };
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const patternsIn = (lexicon: Lexicon, group: string): string[] => lexicon.filter((entry) => entry.group === group).map((entry) => entry.pattern.toLowerCase());
|
|
55
|
+
|
|
56
|
+
export const danglingOpener: Detector = (doc): Finding[] => {
|
|
57
|
+
const words = doc.lexicons["dangling-participle"] ?? [];
|
|
58
|
+
const subjects = new Set(patternsIn(words, "subject"));
|
|
59
|
+
const exempt = patternsIn(words, "exempt");
|
|
60
|
+
return doc.sentences.flatMap((sentence) => {
|
|
61
|
+
const found = sentence.embeddedLanguage === undefined ? danglingOpenerIn(doc.source, sentence.tokens ?? [], subjects, exempt) : undefined;
|
|
62
|
+
if (found === undefined) return [];
|
|
63
|
+
return [
|
|
64
|
+
{
|
|
65
|
+
rule: "",
|
|
66
|
+
severity: "info",
|
|
67
|
+
line: 0,
|
|
68
|
+
column: 0,
|
|
69
|
+
quote: sentence.text.trim(),
|
|
70
|
+
values: { opener: found.opener, subject: found.subject.surface, offset: found.offset },
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
});
|
|
74
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Detector, Finding } from "../plugin.ts";
|
|
2
|
+
import { escapeRegExp } from "../orthography.ts";
|
|
3
|
+
import { formMinority } from "./form-minority.ts";
|
|
4
|
+
import { quoteAround } from "./quote-around.ts";
|
|
5
|
+
|
|
6
|
+
// One document writing an ellipsis two ways ("..." and "…", "……" and "・・・"). Neither is called right; the less common is
|
|
7
|
+
// reported. The forms come from each language's lexicon "ellipsis".
|
|
8
|
+
|
|
9
|
+
/** One ellipsis as written. form is the lexicon's pattern it matched. */
|
|
10
|
+
export type WrittenEllipsis = { readonly offset: number; readonly form: string };
|
|
11
|
+
|
|
12
|
+
/** Characters an ellipsis is made of. A longer run ("....", "…・") is not read, since its form is unclear. */
|
|
13
|
+
const ELLIPSIS_PART = "[..…・‥]";
|
|
14
|
+
|
|
15
|
+
/** A digit or a slash beside the dots: a range (1...10), a path (docs/.../index.md) or a page number after a leader (第1章・・・3). */
|
|
16
|
+
const NOT_PROSE_BESIDE = /[\p{N}/]/u;
|
|
17
|
+
/** The rest of a line after a leader in a table of contents ("Intro ... 12"). */
|
|
18
|
+
const PAGE_NUMBER_LINE = /^\s*\p{N}+\s*(?:\n|$)/u;
|
|
19
|
+
/** A word holding a scheme or a slash around the dots is an address (https://example.com/a...b). */
|
|
20
|
+
const ADDRESS_WORD = /:\/\/|\//u;
|
|
21
|
+
|
|
22
|
+
const SPACE = /\s/u;
|
|
23
|
+
|
|
24
|
+
/** The run of non-space characters holding the dots. */
|
|
25
|
+
const wordAround = (text: string, start: number, end: number): string => {
|
|
26
|
+
const from =
|
|
27
|
+
text
|
|
28
|
+
.slice(0, start)
|
|
29
|
+
.split("")
|
|
30
|
+
.findLastIndex((char) => SPACE.test(char)) + 1;
|
|
31
|
+
const after = text
|
|
32
|
+
.slice(end)
|
|
33
|
+
.split("")
|
|
34
|
+
.findIndex((char) => SPACE.test(char));
|
|
35
|
+
return text.slice(from, after === -1 ? text.length : end + after);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Dots that are not an ellipsis in running text: a range, a path or address, a table of contents leader. */
|
|
39
|
+
const isNotProse = (text: string, start: number, end: number): boolean =>
|
|
40
|
+
NOT_PROSE_BESIDE.test(text.charAt(start - 1)) ||
|
|
41
|
+
NOT_PROSE_BESIDE.test(text.charAt(end)) ||
|
|
42
|
+
PAGE_NUMBER_LINE.test(text.slice(end)) ||
|
|
43
|
+
ADDRESS_WORD.test(wordAround(text, start, end));
|
|
44
|
+
|
|
45
|
+
/** The ellipses in a text, matched longest form first, standing alone (not part of a longer run of dots). */
|
|
46
|
+
export const ellipsesIn = (text: string, forms: readonly string[]): WrittenEllipsis[] => {
|
|
47
|
+
if (forms.length === 0) return [];
|
|
48
|
+
const alternatives = forms
|
|
49
|
+
.toSorted((left, right) => right.length - left.length)
|
|
50
|
+
.map(escapeRegExp)
|
|
51
|
+
.join("|");
|
|
52
|
+
const pattern = new RegExp(`(?<!${ELLIPSIS_PART})(?:${alternatives})(?!${ELLIPSIS_PART})`, "gu");
|
|
53
|
+
return [...text.matchAll(pattern)].flatMap((match) =>
|
|
54
|
+
isNotProse(text, match.index, match.index + match[0].length) ? [] : [{ offset: match.index, form: match[0] }],
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const ellipsisConsistency: Detector = (doc, options): Finding[] => {
|
|
59
|
+
const text = doc.prose ?? doc.source;
|
|
60
|
+
const ellipses = ellipsesIn(
|
|
61
|
+
text,
|
|
62
|
+
(doc.lexicons["ellipsis"] ?? []).map((entry) => entry.pattern),
|
|
63
|
+
);
|
|
64
|
+
const minority = formMinority(ellipses, options.limit);
|
|
65
|
+
if (minority === undefined) return [];
|
|
66
|
+
return minority.odd.map((ellipsis) => ({
|
|
67
|
+
rule: "",
|
|
68
|
+
severity: "info",
|
|
69
|
+
line: 0,
|
|
70
|
+
column: 0,
|
|
71
|
+
quote: quoteAround(text, ellipsis.offset, ellipsis.offset + ellipsis.form.length),
|
|
72
|
+
values: { written: ellipsis.form, usual: minority.usual.form, count: minority.odd.length, of: ellipses.length, offset: ellipsis.offset },
|
|
73
|
+
}));
|
|
74
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Detector, Finding } from "../plugin.ts";
|
|
2
|
+
import { escapeRegExp } from "../orthography.ts";
|
|
3
|
+
import { quoteAround } from "./quote-around.ts";
|
|
4
|
+
|
|
5
|
+
// 注の無い注の印と、本文が一度も付けない注。Markdown の脚注([^1] と行頭の [^1]:)と、語彙表 note-mark の印(※1 と行頭の ※1)を読む。
|
|
6
|
+
|
|
7
|
+
/** 合わない注 1 つ。missing は印に注が無い、unused は注に印が無い。label は書いたままの印([^1]、※1)。 */
|
|
8
|
+
export type NoteSlip = { readonly offset: number; readonly label: string; readonly reason: "missing" | "unused" };
|
|
9
|
+
|
|
10
|
+
/** 印 1 つ。kind は印の種類(markdown か note-mark の語)、key は番号、note は行の頭の注そのものか。 */
|
|
11
|
+
type NoteMark = { readonly offset: number; readonly label: string; readonly kind: string; readonly key: string; readonly note: boolean };
|
|
12
|
+
|
|
13
|
+
const MARKDOWN = "markdown";
|
|
14
|
+
const FULLWIDTH_OFFSET = 0xfee0;
|
|
15
|
+
/** 行の頭から印までに来てよいもの(字下げ、箇条書きの印、開き括弧)。 */
|
|
16
|
+
const NOTE_LINE_HEAD = /^[ \t]*(?:[-*+][ \t]+)?[((]?$/u;
|
|
17
|
+
|
|
18
|
+
const halfWidth = (text: string): string => text.replace(/[0-9]/gu, (char) => String.fromCharCode(char.charCodeAt(0) - FULLWIDTH_OFFSET));
|
|
19
|
+
|
|
20
|
+
const lineHeadOf = (text: string, offset: number): string => text.slice(text.lastIndexOf("\n", offset - 1) + 1, offset);
|
|
21
|
+
|
|
22
|
+
/** Markdown の脚注。名前は大文字と小文字を区別しない(CommonMark の参照と同じ)。 */
|
|
23
|
+
const markdownMarks = (text: string): NoteMark[] =>
|
|
24
|
+
[...text.matchAll(/\[\^(?<key>[^\]\s]+)\](?<colon>:)?/gu)].map((match) => {
|
|
25
|
+
const note = match.groups?.["colon"] !== undefined && /^[ \t]{0,3}$/u.test(lineHeadOf(text, match.index));
|
|
26
|
+
return { offset: match.index, label: `[^${match.groups?.["key"] ?? ""}]`, kind: MARKDOWN, key: (match.groups?.["key"] ?? "").toLowerCase(), note };
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/** 印と一桁か二桁の番号。後ろに数字や小数が続けば年や数(※2018年度、※1.2倍)で、注の番号ではない。 */
|
|
30
|
+
const wordMarks = (text: string, mark: string): NoteMark[] =>
|
|
31
|
+
[...text.matchAll(new RegExp(`${escapeRegExp(mark)}(?<key>[0-90-9]{1,2})(?![0-90-9]|[..,,][0-90-9])`, "gu"))].map((match) => ({
|
|
32
|
+
offset: match.index,
|
|
33
|
+
label: match[0],
|
|
34
|
+
kind: mark,
|
|
35
|
+
key: halfWidth(match.groups?.["key"] ?? ""),
|
|
36
|
+
note: NOTE_LINE_HEAD.test(lineHeadOf(text, match.index)),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
/** 一つの種類の印のうち、合わないもの。印の語(※)は、その種類の注が一つも無ければ注を別に置いた文書と読んで言わない。 */
|
|
40
|
+
const slipsOfKind = (marks: readonly NoteMark[], kind: string): NoteSlip[] => {
|
|
41
|
+
const ofKind = marks.filter((mark) => mark.kind === kind);
|
|
42
|
+
const notes = new Set(ofKind.filter((mark) => mark.note).map((mark) => mark.key));
|
|
43
|
+
const referenced = new Set(ofKind.filter((mark) => !mark.note).map((mark) => mark.key));
|
|
44
|
+
if (notes.size === 0 && kind !== MARKDOWN) return [];
|
|
45
|
+
const missing = ofKind
|
|
46
|
+
.filter((mark) => !mark.note && !notes.has(mark.key))
|
|
47
|
+
.map((mark): NoteSlip => ({ offset: mark.offset, label: mark.label, reason: "missing" }));
|
|
48
|
+
const unused = ofKind
|
|
49
|
+
.filter((mark) => mark.note && !referenced.has(mark.key))
|
|
50
|
+
.map((mark): NoteSlip => ({ offset: mark.offset, label: mark.label, reason: "unused" }));
|
|
51
|
+
return [...missing, ...unused];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** 文字列の中の、注の無い印と印の無い注。 */
|
|
55
|
+
export const noteSlips = (text: string, words: readonly string[]): NoteSlip[] => {
|
|
56
|
+
const marks = [...markdownMarks(text), ...words.flatMap((word) => wordMarks(text, word))];
|
|
57
|
+
return [...new Set(marks.map((mark) => mark.kind))].flatMap((kind) => slipsOfKind(marks, kind)).toSorted((left, right) => left.offset - right.offset);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const footnoteMismatch: Detector = (doc): Finding[] => {
|
|
61
|
+
const text = doc.prose ?? doc.source;
|
|
62
|
+
const words = (doc.lexicons["note-mark"] ?? []).map((entry) => entry.pattern);
|
|
63
|
+
return noteSlips(text, words).map((slip) => ({
|
|
64
|
+
rule: "",
|
|
65
|
+
severity: "warning",
|
|
66
|
+
line: 0,
|
|
67
|
+
column: 0,
|
|
68
|
+
quote: quoteAround(text, slip.offset, slip.offset + slip.label.length),
|
|
69
|
+
values: { label: slip.label, offset: slip.offset },
|
|
70
|
+
variant: slip.reason,
|
|
71
|
+
}));
|
|
72
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// 一つの文書の中で、同じものを何通りかに書いたときの少ないほう。どの書き方が正しいかは決めない。
|
|
2
|
+
|
|
3
|
+
/** 少ないほうの書き方のものと、いちばん多い書き方の例。 */
|
|
4
|
+
export type FormMinority<T> = { readonly odd: readonly T[]; readonly usual: T };
|
|
5
|
+
|
|
6
|
+
const PERCENT = 100;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* いちばん多い書き方と違うもの。少ないほうが limit パーセントを超えれば使い分けている文書と読んで言わない。
|
|
10
|
+
* いちばん多い書き方が同じ数で並べば、どちらにも立たない。
|
|
11
|
+
*/
|
|
12
|
+
export const formMinority = <T extends { readonly form: string }>(items: readonly T[], limit: number): FormMinority<T> | undefined => {
|
|
13
|
+
const counts = new Map<string, number>();
|
|
14
|
+
items.forEach((item) => counts.set(item.form, (counts.get(item.form) ?? 0) + 1));
|
|
15
|
+
const [top, second] = [...counts].toSorted((left, right) => right[1] - left[1]);
|
|
16
|
+
if (top === undefined || second === undefined || top[1] === second[1]) return undefined;
|
|
17
|
+
const odd = items.filter((item) => item.form !== top[0]);
|
|
18
|
+
const usual = items.find((item) => item.form === top[0]);
|
|
19
|
+
if (usual === undefined || odd.length * PERCENT > items.length * limit) return undefined;
|
|
20
|
+
return { odd, usual };
|
|
21
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Detector, Finding, MarkupHeading, ProseDocument } from "../plugin.ts";
|
|
2
|
+
import { findingAt, writtenHeadings } from "./markup-finding.ts";
|
|
3
|
+
import { pageTitleOf } from "./heading-case.ts";
|
|
4
|
+
import { minorityWithin } from "../orthography.ts";
|
|
5
|
+
|
|
6
|
+
/** A mark a heading may end with or go without: a colon or a full stop, in either width. A question mark belongs to the words. */
|
|
7
|
+
const END_MARK = /[::.。]$/u;
|
|
8
|
+
|
|
9
|
+
/** An ellipsis: the full stops are the words trailing off. */
|
|
10
|
+
const ELLIPSIS = /(?:\.\.\.|…)$/u;
|
|
11
|
+
|
|
12
|
+
/** Emphasis closed at the very end (`## **Note:**`): the mark the reader sees is the one before it. */
|
|
13
|
+
const EMPHASIS_MARKS = new Set(["*", "_"]);
|
|
14
|
+
|
|
15
|
+
const withoutClosingEmphasis = (text: string): string => {
|
|
16
|
+
const kept = [...text];
|
|
17
|
+
while (EMPHASIS_MARKS.has(kept.at(-1) ?? "")) kept.pop();
|
|
18
|
+
return kept.join("");
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const LETTER = /\p{L}/u;
|
|
22
|
+
|
|
23
|
+
/** Past this share, both ways are in use on purpose, and neither is the odd one. */
|
|
24
|
+
const MINORITY_PERCENT = 25;
|
|
25
|
+
|
|
26
|
+
/** Fewer section headings than this, and the majority is not yet a habit. */
|
|
27
|
+
const MIN_HEADINGS = 4;
|
|
28
|
+
|
|
29
|
+
/** Whether the heading's last word is one that ends in a full stop of its own (etc., Inc., from the lexicon). */
|
|
30
|
+
const endsWithWord = (shown: string, words: readonly string[]): boolean =>
|
|
31
|
+
words.some((word) => shown.endsWith(word) && !LETTER.test(shown.charAt(shown.length - word.length - 1)));
|
|
32
|
+
|
|
33
|
+
/** The mark a heading ends with, "" for none, or undefined when its last full stop belongs to its words. */
|
|
34
|
+
export const endMarkOf = (heading: string, words: readonly string[] = []): string | undefined => {
|
|
35
|
+
const shown = withoutClosingEmphasis(heading.trim()).trimEnd();
|
|
36
|
+
if (ELLIPSIS.test(shown) || endsWithWord(shown, words)) return undefined;
|
|
37
|
+
return END_MARK.exec(shown)?.[0] ?? "";
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type Judged = { readonly heading: MarkupHeading; readonly mark: string };
|
|
41
|
+
|
|
42
|
+
/** The odd ones among headings of one depth, each with the usual side's first heading and count. */
|
|
43
|
+
const oddAtDepth = (doc: ProseDocument, judged: readonly Judged[]): Finding[] => {
|
|
44
|
+
if (judged.length < MIN_HEADINGS) return [];
|
|
45
|
+
const odd = minorityWithin(judged, (entry) => entry.mark !== "", MINORITY_PERCENT);
|
|
46
|
+
const usual = judged.filter((entry) => !odd.includes(entry));
|
|
47
|
+
const example = usual[0]?.heading.text.trim() ?? "";
|
|
48
|
+
return odd.map((entry) =>
|
|
49
|
+
findingAt(
|
|
50
|
+
doc,
|
|
51
|
+
entry.heading,
|
|
52
|
+
{ heading: entry.heading.text.trim(), mark: entry.mark, example, count: usual.length },
|
|
53
|
+
entry.mark === "" ? "bare" : "marked",
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Headings that end with a colon or a full stop where most headings of the same depth do not, or the other way round. Headings
|
|
60
|
+
* of one depth are one kind of heading; a depth of labels ("Optional:") is its own way. The page title (one top-level heading
|
|
61
|
+
* that opens the document) is a name and is not compared.
|
|
62
|
+
*/
|
|
63
|
+
export const headingEndMark: Detector = (doc, options): Finding[] => {
|
|
64
|
+
const words = (options.lexicon ?? []).map((entry) => entry.pattern);
|
|
65
|
+
const headings = writtenHeadings(doc);
|
|
66
|
+
const title = pageTitleOf(headings);
|
|
67
|
+
const judged = headings
|
|
68
|
+
.filter((heading) => heading !== title)
|
|
69
|
+
.flatMap((heading): Judged[] => {
|
|
70
|
+
const mark = endMarkOf(heading.text, words);
|
|
71
|
+
return mark === undefined ? [] : [{ heading, mark }];
|
|
72
|
+
});
|
|
73
|
+
const depths = [...new Set(judged.map((entry) => entry.heading.depth))];
|
|
74
|
+
return depths
|
|
75
|
+
.flatMap((depth) =>
|
|
76
|
+
oddAtDepth(
|
|
77
|
+
doc,
|
|
78
|
+
judged.filter((entry) => entry.heading.depth === depth),
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
.toSorted((left, right) => Number(left.values["offset"]) - Number(right.values["offset"]));
|
|
82
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Detector, Finding, ProseDocument } from "../plugin.ts";
|
|
2
|
+
import { findingAt, markupOf } from "./markup-finding.ts";
|
|
3
|
+
import { readMarkdown } from "../markdown-read.ts";
|
|
4
|
+
import { readLists, type ListMark } from "../list-marks.ts";
|
|
5
|
+
|
|
6
|
+
/** Fewer lists than this at one depth, and the majority bullet is not yet a habit. */
|
|
7
|
+
const MIN_LISTS = 3;
|
|
8
|
+
|
|
9
|
+
/** Past this share, the document uses both bullets on purpose. */
|
|
10
|
+
const MINORITY_PERCENT = 25;
|
|
11
|
+
|
|
12
|
+
const PERCENT = 100;
|
|
13
|
+
|
|
14
|
+
/** The length of the bullet quoted: the bullet itself. */
|
|
15
|
+
const BULLET_LENGTH = 1;
|
|
16
|
+
|
|
17
|
+
const at = (doc: ProseDocument, start: number, values: Readonly<Record<string, string | number>>, variant: string): Finding =>
|
|
18
|
+
findingAt(doc, { start, end: start + BULLET_LENGTH }, values, variant);
|
|
19
|
+
|
|
20
|
+
/** The bullet most lists use; undefined on a tie. */
|
|
21
|
+
const majorityOf = (lists: readonly ListMark[]): { readonly mark: string; readonly count: number } | undefined => {
|
|
22
|
+
const counts = [...lists.reduce((acc, list) => acc.set(list.mark, (acc.get(list.mark) ?? 0) + 1), new Map<string, number>())].toSorted(
|
|
23
|
+
([, left], [, right]) => right - left,
|
|
24
|
+
);
|
|
25
|
+
const [first, second] = counts;
|
|
26
|
+
return first === undefined || second?.[1] === first[1] ? undefined : { mark: first[0], count: first[1] };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Lists whose bullet differs from the one most lists of the same depth use, while those are the few. */
|
|
30
|
+
const minorityAt = (doc: ProseDocument, lists: readonly ListMark[]): Finding[] => {
|
|
31
|
+
const majority = lists.length < MIN_LISTS ? undefined : majorityOf(lists);
|
|
32
|
+
if (majority === undefined) return [];
|
|
33
|
+
const odd = lists.filter((list) => list.mark !== majority.mark);
|
|
34
|
+
if (odd.length * PERCENT > MINORITY_PERCENT * lists.length) return [];
|
|
35
|
+
return odd.map((list) => at(doc, list.start, { mark: list.mark, usual: majority.mark, count: majority.count }, "document"));
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** A list written right after another in the same place with another bullet: Markdown starts a new list where the bullet changes. */
|
|
39
|
+
const splitAt = (doc: ProseDocument, list: ListMark): Finding[] =>
|
|
40
|
+
list.previous === undefined || list.previous === list.mark ? [] : [at(doc, list.start, { mark: list.mark, usual: list.previous }, "split")];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Bullets mixed in one list or one document: a list that changes bullet partway (`-` then `*`, which Markdown splits in two),
|
|
44
|
+
* a `・` line inside a list item (Markdown joins it to the item), and a list whose bullet differs from most lists at its depth.
|
|
45
|
+
*/
|
|
46
|
+
export const listMarkerMix: Detector = (doc): Finding[] => {
|
|
47
|
+
if (markupOf(doc) === undefined) return [];
|
|
48
|
+
const { lists, foldedTyped } = readLists(doc.source, readMarkdown(doc.source).root);
|
|
49
|
+
const split = lists.flatMap((list) => splitAt(doc, list));
|
|
50
|
+
const folded = foldedTyped.map((start) => at(doc, start, { mark: "・" }, "folded"));
|
|
51
|
+
const reported = new Set(split.map((finding) => finding.values["offset"]));
|
|
52
|
+
const byDepth = new Map<number, ListMark[]>();
|
|
53
|
+
lists.forEach((list) => {
|
|
54
|
+
const same = byDepth.get(list.depth) ?? [];
|
|
55
|
+
same.push(list);
|
|
56
|
+
byDepth.set(list.depth, same);
|
|
57
|
+
});
|
|
58
|
+
const minority = [...byDepth.values()].flatMap((same) => minorityAt(doc, same)).filter((finding) => !reported.has(finding.values["offset"]));
|
|
59
|
+
return [...split, ...folded, ...minority].toSorted((left, right) => Number(left.values["offset"]) - Number(right.values["offset"]));
|
|
60
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Markdown that does not render the way it was written: a table row with more cells than its header (GFM drops the
|
|
2
|
+
// extra ones), a code fence that is never closed (the rest of the document becomes code), and strong-emphasis marks
|
|
3
|
+
// (`**`) that Markdown cannot pair and shows as they are. Pure; reads the source's Markdown tree.
|
|
4
|
+
import type { Detector, Finding, ProseDocument, Span } from "../plugin.ts";
|
|
5
|
+
import { readMarkdown } from "../markdown-read.ts";
|
|
6
|
+
import { spanOf, type MarkdownNode } from "../markdown-node.ts";
|
|
7
|
+
import { findingAt, markupOf } from "./markup-finding.ts";
|
|
8
|
+
|
|
9
|
+
/** Quotations are someone else's text, and their markup is theirs. */
|
|
10
|
+
const QUOTED = "blockquote";
|
|
11
|
+
|
|
12
|
+
/** Nodes of one type outside quotations, in source order. Walks with its own stack: Markdown nests without limit. */
|
|
13
|
+
const nodesOf = (root: MarkdownNode, type: string): MarkdownNode[] => {
|
|
14
|
+
const found: MarkdownNode[] = [];
|
|
15
|
+
const pending: MarkdownNode[] = [root];
|
|
16
|
+
for (let node = pending.pop(); node !== undefined; node = pending.pop()) {
|
|
17
|
+
if (node.type === QUOTED) continue;
|
|
18
|
+
if (node.type === type) found.push(node);
|
|
19
|
+
(node.children ?? []).toReversed().forEach((child) => pending.push(child));
|
|
20
|
+
}
|
|
21
|
+
return found;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const markdownRoot = (doc: ProseDocument): MarkdownNode | undefined => (markupOf(doc) === undefined ? undefined : readMarkdown(doc.source).root);
|
|
25
|
+
|
|
26
|
+
/** An HTML comment: a cell holding only one shows the reader nothing, and losing it loses nothing. */
|
|
27
|
+
const COMMENT = /<!--[\s\S]*?-->/gu;
|
|
28
|
+
|
|
29
|
+
/** Whether a cell shows the reader anything: its source without the bars, comments and spaces. */
|
|
30
|
+
const cellShows = (source: string, cell: MarkdownNode): boolean => {
|
|
31
|
+
const span = spanOf(cell);
|
|
32
|
+
return span !== undefined && source.slice(span.start, span.end).replaceAll("|", "").replace(COMMENT, "").trim() !== "";
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A body row with more cells than the header: GFM shows only as many cells as the header has, and drops the rest. Extra cells
|
|
37
|
+
* that are empty or hold only a comment lose nothing and are not reported.
|
|
38
|
+
*/
|
|
39
|
+
export const tableRowOverflow: Detector = (doc): Finding[] => {
|
|
40
|
+
const root = markdownRoot(doc);
|
|
41
|
+
if (root === undefined) return [];
|
|
42
|
+
return nodesOf(root, "table").flatMap((table) => {
|
|
43
|
+
const [header, ...body] = table.children ?? [];
|
|
44
|
+
const columns = header?.children?.length ?? 0;
|
|
45
|
+
return body.flatMap((row) => {
|
|
46
|
+
const cells = row.children ?? [];
|
|
47
|
+
const span = spanOf(row);
|
|
48
|
+
const lost = cells.slice(columns).some((cell) => cellShows(doc.source, cell));
|
|
49
|
+
return lost && span !== undefined ? [findingAt(doc, span, { cells: cells.length, columns })] : [];
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** A fence opening a code block: three or more backticks or tildes, after up to three spaces. */
|
|
55
|
+
const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/u;
|
|
56
|
+
|
|
57
|
+
/** A closing fence may sit at most this many columns further in than the container's text; four in, it is code. */
|
|
58
|
+
const MAX_FENCE_INDENT = 3;
|
|
59
|
+
|
|
60
|
+
const TAB_WIDTH = 4;
|
|
61
|
+
|
|
62
|
+
const indentOf = (line: string): number =>
|
|
63
|
+
Array.from(line.slice(0, line.length - line.trimStart().length)).reduce((width, char) => width + (char === "\t" ? TAB_WIDTH : 1), 0);
|
|
64
|
+
|
|
65
|
+
/** Whether a line closes a fence: not indented past the limit, the same character, at least as many, and nothing after but spaces. */
|
|
66
|
+
const closes = (line: string, fence: string, maxIndent: number): boolean => {
|
|
67
|
+
const trimmed = line.trim();
|
|
68
|
+
const mark = fence.charAt(0);
|
|
69
|
+
return indentOf(line) <= maxIndent && trimmed.length >= fence.length && [...trimmed].every((char) => char === mark);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** The opening fence of a code block that is never closed; undefined for a closed or an indented block. */
|
|
73
|
+
const unclosedFenceOf = (source: string, span: Span): Span | undefined => {
|
|
74
|
+
const lines = source.slice(span.start, span.end).split("\n");
|
|
75
|
+
const first = lines[0] ?? "";
|
|
76
|
+
const fence = FENCE_OPEN.exec(first)?.[1];
|
|
77
|
+
if (fence === undefined) return undefined;
|
|
78
|
+
const column = span.start - (source.lastIndexOf("\n", span.start - 1) + 1);
|
|
79
|
+
const last = lines.length > 1 ? (lines.at(-1) ?? "") : "";
|
|
80
|
+
return closes(last, fence, column + MAX_FENCE_INDENT) ? undefined : { start: span.start, end: span.start + first.trimEnd().length };
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A fenced code block with no closing fence. Markdown runs it to the end of the document (or of the list item it is in), so
|
|
85
|
+
* everything after the opening fence shows as code. Reported at the opening fence.
|
|
86
|
+
*/
|
|
87
|
+
export const unclosedCodeFence: Detector = (doc): Finding[] => {
|
|
88
|
+
const root = markdownRoot(doc);
|
|
89
|
+
if (root === undefined) return [];
|
|
90
|
+
return nodesOf(root, "code").flatMap((code) => {
|
|
91
|
+
const span = spanOf(code);
|
|
92
|
+
const open = span === undefined ? undefined : unclosedFenceOf(doc.source, span);
|
|
93
|
+
return open === undefined ? [] : [findingAt(doc, open, { fence: doc.source.slice(open.start, open.end).trim() })];
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** `**` or `__` left in text: Markdown would have made it strong emphasis if it could pair it. Longer runs are rules or blanks to fill. */
|
|
98
|
+
const STRONG_RUN = /(?<![*_])(?:\*\*|__)(?![*_])/gu;
|
|
99
|
+
|
|
100
|
+
/** A run with Latin letters or digits on both sides (`2**10`, `snake__case`) is part of the word. Japanese has no spaces, so 「は**金」 is a mark. */
|
|
101
|
+
const WORD_CHAR = /[A-Za-z0-9]/u;
|
|
102
|
+
|
|
103
|
+
const isSpace = (char: string): boolean => char === "" || /\s/u.test(char);
|
|
104
|
+
|
|
105
|
+
/** Python's dunder names (`__init__`) written outside code. */
|
|
106
|
+
const DUNDER = /__\w+__/u;
|
|
107
|
+
|
|
108
|
+
const dunderAround = (text: string, run: Span): boolean => {
|
|
109
|
+
const start = text.lastIndexOf(" ", run.start) + 1;
|
|
110
|
+
const end = text.indexOf(" ", run.end);
|
|
111
|
+
return DUNDER.test(text.slice(start, end === -1 ? text.length : end));
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** The strong-emphasis runs in one text node that Markdown left as text. */
|
|
115
|
+
export const strayStrongRuns = (text: string): Span[] =>
|
|
116
|
+
[...text.matchAll(STRONG_RUN)].flatMap((match) => {
|
|
117
|
+
const run = { start: match.index, end: match.index + match[0].length };
|
|
118
|
+
const before = text.charAt(run.start - 1);
|
|
119
|
+
const after = text.charAt(run.end);
|
|
120
|
+
if (before === "\\" || (WORD_CHAR.test(before) && WORD_CHAR.test(after))) return [];
|
|
121
|
+
if (isSpace(before) && isSpace(after)) return [];
|
|
122
|
+
return dunderAround(text, run) ? [] : [run];
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* `**` or `__` that Markdown shows as it is: strong emphasis left open (`**bold`), or one CommonMark cannot close because a
|
|
127
|
+
* bracket or a quotation mark touches the mark from inside while a letter touches it from outside (`**「重要」**です`).
|
|
128
|
+
*/
|
|
129
|
+
export const unrenderedEmphasis: Detector = (doc): Finding[] => {
|
|
130
|
+
const root = markdownRoot(doc);
|
|
131
|
+
if (root === undefined) return [];
|
|
132
|
+
return nodesOf(root, "text").flatMap((node) => {
|
|
133
|
+
const span = spanOf(node);
|
|
134
|
+
if (span === undefined) return [];
|
|
135
|
+
const written = doc.source.slice(span.start, span.end);
|
|
136
|
+
return strayStrongRuns(written).map((run) =>
|
|
137
|
+
findingAt(doc, { start: span.start + run.start, end: span.start + run.end }, { mark: written.slice(run.start, run.end) }),
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
};
|