chaffjs 0.20.0 → 0.21.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 +5 -4
- package/dist/api.d.ts +16 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js.map +1 -1
- package/dist/cli-text.d.ts +2 -0
- package/dist/cli-text.d.ts.map +1 -1
- package/dist/cli-text.js +14 -5
- package/dist/cli-text.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +4 -0
- package/dist/cli.js.map +1 -1
- package/dist/command-help.d.ts +7 -0
- package/dist/command-help.d.ts.map +1 -0
- package/dist/command-help.js +18 -0
- package/dist/command-help.js.map +1 -0
- package/dist/commands/eval.js +2 -2
- package/dist/commands/eval.js.map +1 -1
- package/dist/commands/fix-plan.d.ts.map +1 -1
- package/dist/commands/fix-plan.js +11 -1
- package/dist/commands/fix-plan.js.map +1 -1
- package/dist/commands/help.d.ts +4 -0
- package/dist/commands/help.d.ts.map +1 -0
- package/dist/commands/help.js +7 -0
- package/dist/commands/help.js.map +1 -0
- package/dist/config/load.d.ts +2 -0
- 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/option-problems.js +2 -2
- package/dist/config/option-problems.js.map +1 -1
- package/dist/config/rule-problems.js +2 -2
- package/dist/config/rule-problems.js.map +1 -1
- package/dist/custom/checked.d.ts +9 -0
- package/dist/custom/checked.d.ts.map +1 -0
- package/dist/custom/checked.js +3 -0
- package/dist/custom/checked.js.map +1 -0
- package/dist/custom/dsl-fields.d.ts +27 -0
- package/dist/custom/dsl-fields.d.ts.map +1 -0
- package/dist/custom/dsl-fields.js +86 -0
- package/dist/custom/dsl-fields.js.map +1 -0
- package/dist/custom/load.d.ts.map +1 -1
- package/dist/custom/load.js +2 -0
- package/dist/custom/load.js.map +1 -1
- package/dist/custom/parse.d.ts +14 -3
- package/dist/custom/parse.d.ts.map +1 -1
- package/dist/custom/parse.js +44 -40
- package/dist/custom/parse.js.map +1 -1
- package/dist/custom/problems.d.ts.map +1 -1
- package/dist/custom/problems.js +11 -1
- package/dist/custom/problems.js.map +1 -1
- package/dist/detectors/blank-structure.d.ts.map +1 -1
- package/dist/detectors/blank-structure.js +6 -4
- package/dist/detectors/blank-structure.js.map +1 -1
- package/dist/detectors/clause-shape.d.ts +22 -0
- package/dist/detectors/clause-shape.d.ts.map +1 -0
- package/dist/detectors/clause-shape.js +149 -0
- package/dist/detectors/clause-shape.js.map +1 -0
- package/dist/detectors/code-fences.d.ts +34 -0
- package/dist/detectors/code-fences.d.ts.map +1 -0
- package/dist/detectors/code-fences.js +100 -0
- package/dist/detectors/code-fences.js.map +1 -0
- package/dist/detectors/cross-definitions.d.ts +4 -0
- package/dist/detectors/cross-definitions.d.ts.map +1 -0
- package/dist/detectors/cross-definitions.js +43 -0
- package/dist/detectors/cross-definitions.js.map +1 -0
- package/dist/detectors/cross-facts.d.ts +4 -0
- package/dist/detectors/cross-facts.d.ts.map +1 -0
- package/dist/detectors/cross-facts.js +53 -0
- package/dist/detectors/cross-facts.js.map +1 -0
- package/dist/detectors/cross-registry/cross-doc-duplicate-definition.d.ts +3 -0
- package/dist/detectors/cross-registry/cross-doc-duplicate-definition.d.ts.map +1 -0
- package/dist/detectors/cross-registry/cross-doc-duplicate-definition.js +3 -0
- package/dist/detectors/cross-registry/cross-doc-duplicate-definition.js.map +1 -0
- package/dist/detectors/cross-registry/cross-doc-fact-conflict.d.ts +3 -0
- package/dist/detectors/cross-registry/cross-doc-fact-conflict.d.ts.map +1 -0
- package/dist/detectors/cross-registry/cross-doc-fact-conflict.js +3 -0
- package/dist/detectors/cross-registry/cross-doc-fact-conflict.js.map +1 -0
- package/dist/detectors/custom.d.ts.map +1 -1
- package/dist/detectors/custom.js +4 -1
- package/dist/detectors/custom.js.map +1 -1
- package/dist/detectors/defined-term-form.d.ts +25 -0
- package/dist/detectors/defined-term-form.d.ts.map +1 -0
- package/dist/detectors/defined-term-form.js +170 -0
- package/dist/detectors/defined-term-form.js.map +1 -0
- package/dist/detectors/email-letter.d.ts +46 -0
- package/dist/detectors/email-letter.d.ts.map +1 -0
- package/dist/detectors/email-letter.js +234 -0
- package/dist/detectors/email-letter.js.map +1 -0
- package/dist/detectors/impossible-date.d.ts +20 -0
- package/dist/detectors/impossible-date.d.ts.map +1 -0
- package/dist/detectors/impossible-date.js +105 -0
- package/dist/detectors/impossible-date.js.map +1 -0
- package/dist/detectors/impossible-time.d.ts +18 -0
- package/dist/detectors/impossible-time.d.ts.map +1 -0
- package/dist/detectors/impossible-time.js +116 -0
- package/dist/detectors/impossible-time.js.map +1 -0
- package/dist/detectors/link-shape.d.ts +11 -0
- package/dist/detectors/link-shape.d.ts.map +1 -0
- package/dist/detectors/link-shape.js +56 -0
- package/dist/detectors/link-shape.js.map +1 -0
- package/dist/detectors/mark-shape.d.ts +53 -0
- package/dist/detectors/mark-shape.d.ts.map +1 -0
- package/dist/detectors/mark-shape.js +160 -0
- package/dist/detectors/mark-shape.js.map +1 -0
- package/dist/detectors/registry/adverb-polarity.d.ts +3 -0
- package/dist/detectors/registry/adverb-polarity.d.ts.map +1 -0
- package/dist/detectors/registry/adverb-polarity.js +3 -0
- package/dist/detectors/registry/adverb-polarity.js.map +1 -0
- package/dist/detectors/registry/attachment-missing.d.ts +3 -0
- package/dist/detectors/registry/attachment-missing.d.ts.map +1 -0
- package/dist/detectors/registry/attachment-missing.js +3 -0
- package/dist/detectors/registry/attachment-missing.js.map +1 -0
- package/dist/detectors/registry/bare-url-mix.d.ts +3 -0
- package/dist/detectors/registry/bare-url-mix.d.ts.map +1 -0
- package/dist/detectors/registry/bare-url-mix.js +3 -0
- package/dist/detectors/registry/bare-url-mix.js.map +1 -0
- package/dist/detectors/registry/caps-shouting.d.ts +3 -0
- package/dist/detectors/registry/caps-shouting.d.ts.map +1 -0
- package/dist/detectors/registry/caps-shouting.js +3 -0
- package/dist/detectors/registry/caps-shouting.js.map +1 -0
- package/dist/detectors/registry/code-fence-language.d.ts +3 -0
- package/dist/detectors/registry/code-fence-language.d.ts.map +1 -0
- package/dist/detectors/registry/code-fence-language.js +3 -0
- package/dist/detectors/registry/code-fence-language.js.map +1 -0
- package/dist/detectors/registry/defined-name-repeated.d.ts +3 -0
- package/dist/detectors/registry/defined-name-repeated.d.ts.map +1 -0
- package/dist/detectors/registry/defined-name-repeated.js +3 -0
- package/dist/detectors/registry/defined-name-repeated.js.map +1 -0
- package/dist/detectors/registry/defined-term-form.d.ts +3 -0
- package/dist/detectors/registry/defined-term-form.d.ts.map +1 -0
- package/dist/detectors/registry/defined-term-form.js +3 -0
- package/dist/detectors/registry/defined-term-form.js.map +1 -0
- package/dist/detectors/registry/email-frame.d.ts +3 -0
- package/dist/detectors/registry/email-frame.d.ts.map +1 -0
- package/dist/detectors/registry/email-frame.js +3 -0
- package/dist/detectors/registry/email-frame.js.map +1 -0
- package/dist/detectors/registry/email-subject.d.ts +3 -0
- package/dist/detectors/registry/email-subject.d.ts.map +1 -0
- package/dist/detectors/registry/email-subject.js +3 -0
- package/dist/detectors/registry/email-subject.js.map +1 -0
- package/dist/detectors/registry/ending-run.d.ts +3 -0
- package/dist/detectors/registry/ending-run.d.ts.map +1 -0
- package/dist/detectors/registry/ending-run.js +3 -0
- package/dist/detectors/registry/ending-run.js.map +1 -0
- package/dist/detectors/registry/heading-command.d.ts +3 -0
- package/dist/detectors/registry/heading-command.d.ts.map +1 -0
- package/dist/detectors/registry/heading-command.js +3 -0
- package/dist/detectors/registry/heading-command.js.map +1 -0
- package/dist/detectors/registry/hyphen-dash.d.ts +3 -0
- package/dist/detectors/registry/hyphen-dash.d.ts.map +1 -0
- package/dist/detectors/registry/hyphen-dash.js +3 -0
- package/dist/detectors/registry/hyphen-dash.js.map +1 -0
- package/dist/detectors/registry/image-file-name-alt.d.ts +3 -0
- package/dist/detectors/registry/image-file-name-alt.d.ts.map +1 -0
- package/dist/detectors/registry/image-file-name-alt.js +3 -0
- package/dist/detectors/registry/image-file-name-alt.js.map +1 -0
- package/dist/detectors/registry/impossible-date.d.ts +3 -0
- package/dist/detectors/registry/impossible-date.d.ts.map +1 -0
- package/dist/detectors/registry/impossible-date.js +3 -0
- package/dist/detectors/registry/impossible-date.js.map +1 -0
- package/dist/detectors/registry/impossible-time.d.ts +3 -0
- package/dist/detectors/registry/impossible-time.d.ts.map +1 -0
- package/dist/detectors/registry/impossible-time.js +3 -0
- package/dist/detectors/registry/impossible-time.js.map +1 -0
- package/dist/detectors/registry/latin-abbreviation.d.ts +3 -0
- package/dist/detectors/registry/latin-abbreviation.d.ts.map +1 -0
- package/dist/detectors/registry/latin-abbreviation.js +3 -0
- package/dist/detectors/registry/latin-abbreviation.js.map +1 -0
- package/dist/detectors/registry/link-text-url-mismatch.d.ts +3 -0
- package/dist/detectors/registry/link-text-url-mismatch.d.ts.map +1 -0
- package/dist/detectors/registry/link-text-url-mismatch.js +3 -0
- package/dist/detectors/registry/link-text-url-mismatch.js.map +1 -0
- package/dist/detectors/registry/mark-consistency.d.ts +3 -0
- package/dist/detectors/registry/mark-consistency.d.ts.map +1 -0
- package/dist/detectors/registry/mark-consistency.js +3 -0
- package/dist/detectors/registry/mark-consistency.js.map +1 -0
- package/dist/detectors/registry/origin-particle.d.ts +3 -0
- package/dist/detectors/registry/origin-particle.d.ts.map +1 -0
- package/dist/detectors/registry/origin-particle.js +3 -0
- package/dist/detectors/registry/origin-particle.js.map +1 -0
- package/dist/detectors/registry/request-without-deadline.d.ts +3 -0
- package/dist/detectors/registry/request-without-deadline.d.ts.map +1 -0
- package/dist/detectors/registry/request-without-deadline.js +3 -0
- package/dist/detectors/registry/request-without-deadline.js.map +1 -0
- package/dist/detectors/registry/sentence-spacing.d.ts +3 -0
- package/dist/detectors/registry/sentence-spacing.d.ts.map +1 -0
- package/dist/detectors/registry/sentence-spacing.js +3 -0
- package/dist/detectors/registry/sentence-spacing.js.map +1 -0
- package/dist/detectors/registry/shell-prompt.d.ts +3 -0
- package/dist/detectors/registry/shell-prompt.d.ts.map +1 -0
- package/dist/detectors/registry/shell-prompt.js +3 -0
- package/dist/detectors/registry/shell-prompt.js.map +1 -0
- package/dist/detectors/registry/step-statement.d.ts +3 -0
- package/dist/detectors/registry/step-statement.d.ts.map +1 -0
- package/dist/detectors/registry/step-statement.js +3 -0
- package/dist/detectors/registry/step-statement.js.map +1 -0
- package/dist/detectors/registry/unpaired-parallel.d.ts +3 -0
- package/dist/detectors/registry/unpaired-parallel.d.ts.map +1 -0
- package/dist/detectors/registry/unpaired-parallel.js +3 -0
- package/dist/detectors/registry/unpaired-parallel.js.map +1 -0
- package/dist/detectors/request-owner.d.ts +23 -0
- package/dist/detectors/request-owner.d.ts.map +1 -0
- package/dist/detectors/request-owner.js +110 -0
- package/dist/detectors/request-owner.js.map +1 -0
- package/dist/detectors/technical-docs.d.ts +32 -0
- package/dist/detectors/technical-docs.d.ts.map +1 -0
- package/dist/detectors/technical-docs.js +199 -0
- package/dist/detectors/technical-docs.js.map +1 -0
- package/dist/extension/pack-read.d.ts +21 -0
- package/dist/extension/pack-read.d.ts.map +1 -0
- package/dist/extension/pack-read.js +117 -0
- package/dist/extension/pack-read.js.map +1 -0
- package/dist/extension/plugin-load.d.ts.map +1 -1
- package/dist/extension/plugin-load.js +44 -10
- package/dist/extension/plugin-load.js.map +1 -1
- package/dist/extension/plugin-parse.d.ts.map +1 -1
- package/dist/extension/plugin-parse.js +10 -3
- package/dist/extension/plugin-parse.js.map +1 -1
- package/dist/facts/address-values.d.ts +5 -0
- package/dist/facts/address-values.d.ts.map +1 -0
- package/dist/facts/address-values.js +64 -0
- package/dist/facts/address-values.js.map +1 -0
- package/dist/facts/cross-facts.d.ts +20 -0
- package/dist/facts/cross-facts.d.ts.map +1 -0
- package/dist/facts/cross-facts.js +41 -0
- package/dist/facts/cross-facts.js.map +1 -0
- package/dist/fix-plan/chosen-depth.d.ts +10 -0
- package/dist/fix-plan/chosen-depth.d.ts.map +1 -0
- package/dist/fix-plan/chosen-depth.js +24 -0
- package/dist/fix-plan/chosen-depth.js.map +1 -0
- package/dist/fix-plan/mode.d.ts +13 -4
- package/dist/fix-plan/mode.d.ts.map +1 -1
- package/dist/fix-plan/mode.js +38 -7
- package/dist/fix-plan/mode.js.map +1 -1
- package/dist/fix-plan/plan.d.ts +20 -0
- package/dist/fix-plan/plan.d.ts.map +1 -1
- package/dist/fix-plan/plan.js +23 -5
- 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 +24 -0
- package/dist/fix-plan/render.js.map +1 -1
- package/dist/fix-plan/text.d.ts +8 -0
- package/dist/fix-plan/text.d.ts.map +1 -1
- package/dist/fix-plan/text.js +36 -10
- package/dist/fix-plan/text.js.map +1 -1
- package/dist/genre-load.d.ts +1 -1
- package/dist/genre-load.d.ts.map +1 -1
- package/dist/genre-load.js +7 -4
- package/dist/genre-load.js.map +1 -1
- package/dist/genre-parse.d.ts +6 -0
- package/dist/genre-parse.d.ts.map +1 -1
- package/dist/genre-parse.js +18 -0
- package/dist/genre-parse.js.map +1 -1
- package/dist/init.js +10 -10
- package/dist/init.js.map +1 -1
- package/dist/known-genres.d.ts +8 -0
- package/dist/known-genres.d.ts.map +1 -0
- package/dist/known-genres.js +17 -0
- package/dist/known-genres.js.map +1 -0
- package/dist/link-shape.d.ts +20 -0
- package/dist/link-shape.d.ts.map +1 -0
- package/dist/link-shape.js +58 -0
- package/dist/link-shape.js.map +1 -0
- package/dist/reasons.d.ts.map +1 -1
- package/dist/reasons.js +15 -4
- package/dist/reasons.js.map +1 -1
- package/dist/render/explain.d.ts.map +1 -1
- package/dist/render/explain.js +11 -2
- package/dist/render/explain.js.map +1 -1
- package/dist/render/friendly.js +1 -1
- package/dist/render/friendly.js.map +1 -1
- package/dist/render/rules-json.d.ts.map +1 -1
- package/dist/render/rules-json.js +12 -3
- package/dist/render/rules-json.js.map +1 -1
- package/dist/render/rules-table.js +6 -6
- package/dist/render/rules-table.js.map +1 -1
- package/dist/render/semantic.js +2 -2
- package/dist/render/semantic.js.map +1 -1
- package/dist/render/suppressions.js +2 -2
- package/dist/render/suppressions.js.map +1 -1
- package/dist/rewrite-depth.d.ts +22 -0
- package/dist/rewrite-depth.d.ts.map +1 -0
- package/dist/rewrite-depth.js +42 -0
- package/dist/rewrite-depth.js.map +1 -0
- package/dist/rule-fields.d.ts +23 -0
- package/dist/rule-fields.d.ts.map +1 -0
- package/dist/rule-fields.js +64 -0
- package/dist/rule-fields.js.map +1 -0
- package/dist/rule-guide.d.ts +3 -0
- package/dist/rule-guide.d.ts.map +1 -1
- package/dist/rule-guide.js +6 -0
- package/dist/rule-guide.js.map +1 -1
- package/dist/rule-load.d.ts.map +1 -1
- package/dist/rule-load.js +13 -5
- package/dist/rule-load.js.map +1 -1
- package/dist/rule-offs.d.ts +14 -0
- package/dist/rule-offs.d.ts.map +1 -0
- package/dist/rule-offs.js +45 -0
- package/dist/rule-offs.js.map +1 -0
- package/dist/structure/build.d.ts.map +1 -1
- package/dist/structure/build.js +14 -1
- package/dist/structure/build.js.map +1 -1
- package/dist/structure/cross-definitions.d.ts +23 -0
- package/dist/structure/cross-definitions.d.ts.map +1 -0
- package/dist/structure/cross-definitions.js +34 -0
- package/dist/structure/cross-definitions.js.map +1 -0
- package/genres.yaml +14 -313
- package/package.json +3 -3
- package/rules/absolute-exception.yaml +3 -1
- package/rules/abstract-length.yaml +7 -1
- package/rules/acronym-expansion-conflict.yaml +2 -1
- package/rules/adverb-overuse.yaml +7 -1
- package/rules/adverb-without-negation.yaml +60 -0
- package/rules/adversative-ga-repeat.yaml +2 -1
- package/rules/agentless-passive.yaml +10 -1
- package/rules/agreement-slip.yaml +4 -1
- package/rules/ai-generated-composite.yaml +6 -1
- package/rules/ai-structure.yaml +8 -2
- package/rules/ai-tell.yaml +7 -1
- package/rules/all-caps-shouting.yaml +61 -0
- package/rules/announced-count-mismatch.yaml +3 -1
- package/rules/announcing-opener.yaml +7 -1
- package/rules/article-sound.yaml +6 -2
- package/rules/assistant-residue.yaml +4 -1
- package/rules/attachment-not-attached.yaml +112 -0
- package/rules/baito-keigo.yaml +64 -0
- package/rules/bare-url-mix.yaml +86 -0
- package/rules/bold-density.yaml +1 -0
- package/rules/bold-label-list.yaml +9 -1
- package/rules/bracket-nesting.yaml +2 -1
- package/rules/bracket-width-consistency.yaml +64 -0
- package/rules/broken-link.yaml +1 -1
- package/rules/chat-citation-residue.yaml +2 -1
- package/rules/citation-reference-mismatch.yaml +7 -1
- package/rules/citation-style-mix.yaml +7 -1
- package/rules/clause-chain.yaml +6 -1
- package/rules/closing-cliche.yaml +7 -0
- package/rules/code-fence-language-mix.yaml +176 -0
- package/rules/colloquial-opener.yaml +62 -0
- package/rules/colon-lead-in.yaml +7 -1
- package/rules/concrete-evidence-density.yaml +6 -0
- package/rules/contraction-consistency.yaml +4 -1
- package/rules/contrast-framing.yaml +7 -1
- package/rules/cross-doc-duplicate-definition.yaml +70 -0
- package/rules/cross-doc-fact-conflict.yaml +64 -0
- package/rules/cushion-phrase-density.yaml +8 -1
- package/rules/dangling-figure-reference.yaml +3 -1
- package/rules/dangling-reference.yaml +3 -1
- package/rules/date-format-consistency.yaml +3 -2
- package/rules/date-order.yaml +3 -1
- package/rules/date-range-reversed.yaml +3 -1
- package/rules/date-weekday-mismatch.yaml +3 -1
- package/rules/date-without-year.yaml +4 -3
- package/rules/defined-name-repeated.yaml +97 -0
- package/rules/defined-term-form.yaml +113 -0
- package/rules/demonstrative-opener-run.yaml +2 -1
- package/rules/double-keigo.yaml +6 -1
- package/rules/double-negative.yaml +5 -1
- package/rules/doubled-nado.yaml +2 -1
- package/rules/doubled-punctuation.yaml +1 -1
- package/rules/doubled-word.yaml +4 -1
- package/rules/duplicate-definition.yaml +3 -1
- package/rules/duplicate-heading.yaml +1 -1
- package/rules/duration-mismatch.yaml +2 -1
- package/rules/elapsed-years-mismatch.yaml +4 -1
- package/rules/email-greeting-closing.yaml +122 -0
- package/rules/email-subject-length.yaml +112 -0
- package/rules/emoji-density.yaml +3 -1
- package/rules/emoji-heading.yaml +7 -1
- package/rules/empty-intensifier.yaml +4 -0
- package/rules/empty-list-item.yaml +2 -1
- package/rules/empty-section.yaml +1 -1
- package/rules/empty-table-cell.yaml +2 -1
- package/rules/excessive-hedging.yaml +7 -1
- package/rules/exclamation-width-consistency.yaml +64 -0
- package/rules/expletive-construction.yaml +7 -1
- package/rules/fact-conflict.yaml +4 -1
- package/rules/figure-reference-order.yaml +7 -1
- package/rules/fullwidth-alnum-consistency.yaml +2 -2
- package/rules/hankaku-kana.yaml +1 -1
- package/rules/heading-command-missing.yaml +109 -0
- package/rules/heading-echo.yaml +6 -1
- package/rules/heading-level-skip.yaml +3 -3
- package/rules/heading-numbering-mix.yaml +4 -3
- package/rules/hiragana-fukushi.yaml +4 -1
- package/rules/homophone-slip.yaml +5 -1
- package/rules/humble-for-others.yaml +63 -0
- package/rules/hyphen-as-dash.yaml +59 -0
- package/rules/idiom-misuse.yaml +4 -1
- package/rules/image-file-name-alt.yaml +83 -0
- package/rules/impossible-date.yaml +79 -0
- package/rules/impossible-time.yaml +76 -0
- package/rules/internal-jargon.yaml +1 -1
- package/rules/invisible-character.yaml +1 -1
- package/rules/katakana-density.yaml +2 -1
- package/rules/katakana-long-vowel.yaml +1 -1
- package/rules/known-misspelling.yaml +2 -1
- package/rules/kutoten-consistency.yaml +1 -1
- package/rules/latin-abbreviation-form.yaml +58 -0
- package/rules/latin-spacing.yaml +6 -2
- package/rules/link-text-url-mismatch.yaml +72 -0
- package/rules/list-item-form-mix.yaml +7 -1
- package/rules/max-kanji-continuous.yaml +10 -2
- package/rules/max-paragraph-length.yaml +4 -1
- package/rules/max-sentence-length.yaml +29 -21
- package/rules/max-ten.yaml +4 -1
- package/rules/misconversion.yaml +2 -1
- package/rules/modal-conflict.yaml +2 -1
- package/rules/name-variant.yaml +4 -3
- package/rules/ngram-repetition.yaml +5 -0
- package/rules/no-doubled-joshi.yaml +5 -1
- package/rules/no-em-dash.yaml +4 -1
- package/rules/no-lead.yaml +6 -1
- package/rules/no-mixed-desumasu.yaml +5 -2
- package/rules/no-nakaguro-parallel.yaml +3 -0
- package/rules/nominalization.yaml +5 -1
- package/rules/number-style-consistency.yaml +6 -1
- package/rules/number-without-unit.yaml +2 -1
- package/rules/numbering-gap.yaml +1 -1
- package/rules/one-sentence-paragraph-run.yaml +5 -1
- package/rules/orthographic-variant.yaml +4 -1
- package/rules/outside-announced-list.yaml +2 -1
- package/rules/oxford-comma-consistency.yaml +3 -1
- package/rules/padded-intro.yaml +7 -0
- package/rules/paragraph-length-variance.yaml +7 -1
- package/rules/paragraph-restatement.yaml +6 -1
- package/rules/percent-sum-mismatch.yaml +3 -1
- package/rules/person-consistency.yaml +4 -1
- package/rules/preamble-length.yaml +6 -0
- package/rules/preferred-term.yaml +1 -1
- package/rules/proper-noun-density.yaml +8 -1
- package/rules/quote-style-consistency.yaml +66 -0
- package/rules/ra-nuki.yaml +4 -1
- package/rules/redundant-expression.yaml +4 -1
- package/rules/reference-title-mismatch.yaml +4 -3
- package/rules/reference-topic-missing.yaml +2 -1
- package/rules/relative-date-mismatch.yaml +2 -1
- package/rules/repeated-conjunction.yaml +4 -1
- package/rules/repeated-sentence-ending.yaml +62 -0
- package/rules/repeated-sentence-head.yaml +4 -0
- package/rules/request-without-deadline.yaml +92 -0
- package/rules/required-sections.yaml +1 -1
- package/rules/requirement-modal.yaml +1 -1
- package/rules/requirement-smell.yaml +12 -4
- package/rules/rule-of-three.yaml +8 -1
- package/rules/sasete-itadaku.yaml +7 -1
- package/rules/section-length-uniformity.yaml +7 -1
- package/rules/sentence-initial-conjunction-run.yaml +5 -1
- package/rules/sentence-initial-so.yaml +6 -1
- package/rules/sentence-rhythm.yaml +8 -1
- package/rules/sentence-spacing-consistency.yaml +63 -0
- package/rules/shell-prompt-mix.yaml +128 -0
- package/rules/space-before-punctuation.yaml +1 -1
- package/rules/spelling-consistency.yaml +3 -1
- package/rules/step-statement-mix.yaml +76 -0
- package/rules/stock-transition.yaml +8 -1
- package/rules/stray-space.yaml +2 -2
- package/rules/summary-fact-mismatch.yaml +4 -1
- package/rules/table-header-variant.yaml +2 -1
- package/rules/taigen-dome-in-prose.yaml +6 -0
- package/rules/tari-unpaired.yaml +58 -0
- package/rules/teki-overuse.yaml +2 -1
- package/rules/title-case-consistency.yaml +1 -1
- package/rules/title-length.yaml +7 -1
- package/rules/topic-predicate-distance.yaml +6 -1
- package/rules/total-mismatch.yaml +3 -1
- package/rules/translationese-density.yaml +4 -1
- package/rules/unbalanced-bracket.yaml +3 -3
- package/rules/undefined-acronym.yaml +8 -1
- package/rules/unfilled-placeholder.yaml +1 -1
- package/rules/unit-mismatch.yaml +2 -1
- package/rules/unknown-word.yaml +4 -1
- package/rules/unqualified-superlative.yaml +6 -1
- package/rules/unused-definition.yaml +4 -3
- package/rules/url-run-on.yaml +1 -1
- package/rules/use-before-definition.yaml +4 -3
- package/rules/vague-figure-reference.yaml +3 -1
- package/rules/vague-link-text.yaml +2 -1
- package/rules/vague-word-density.yaml +2 -1
- package/rules/very-adjective.yaml +5 -1
- package/rules/weasel-word.yaml +6 -1
- package/rules/wordy-phrase.yaml +6 -1
- package/rules/yori-as-from.yaml +61 -0
- package/src/api.ts +13 -1
- package/src/cli-text.ts +18 -7
- package/src/cli.ts +3 -0
- package/src/command-help.ts +26 -0
- package/src/commands/eval.ts +2 -2
- package/src/commands/fix-plan.ts +13 -1
- package/src/commands/help.ts +8 -0
- package/src/config/load.ts +3 -0
- package/src/config/option-problems.ts +2 -2
- package/src/config/rule-problems.ts +2 -2
- package/src/custom/checked.ts +8 -0
- package/src/custom/dsl-fields.ts +102 -0
- package/src/custom/load.ts +2 -0
- package/src/custom/parse.ts +72 -50
- package/src/custom/problems.ts +18 -2
- package/src/detectors/blank-structure.ts +12 -4
- package/src/detectors/clause-shape.ts +199 -0
- package/src/detectors/code-fences.ts +113 -0
- package/src/detectors/cross-definitions.ts +49 -0
- package/src/detectors/cross-facts.ts +60 -0
- package/src/detectors/cross-registry/cross-doc-duplicate-definition.ts +4 -0
- package/src/detectors/cross-registry/cross-doc-fact-conflict.ts +4 -0
- package/src/detectors/custom.ts +10 -2
- package/src/detectors/defined-term-form.ts +200 -0
- package/src/detectors/email-letter.ts +262 -0
- package/src/detectors/impossible-date.ts +141 -0
- package/src/detectors/impossible-time.ts +151 -0
- package/src/detectors/link-shape.ts +70 -0
- package/src/detectors/mark-shape.ts +202 -0
- package/src/detectors/registry/adverb-polarity.ts +4 -0
- package/src/detectors/registry/attachment-missing.ts +4 -0
- package/src/detectors/registry/bare-url-mix.ts +4 -0
- package/src/detectors/registry/caps-shouting.ts +4 -0
- package/src/detectors/registry/code-fence-language.ts +4 -0
- package/src/detectors/registry/defined-name-repeated.ts +4 -0
- package/src/detectors/registry/defined-term-form.ts +4 -0
- package/src/detectors/registry/email-frame.ts +4 -0
- package/src/detectors/registry/email-subject.ts +4 -0
- package/src/detectors/registry/ending-run.ts +4 -0
- package/src/detectors/registry/heading-command.ts +4 -0
- package/src/detectors/registry/hyphen-dash.ts +4 -0
- package/src/detectors/registry/image-file-name-alt.ts +4 -0
- package/src/detectors/registry/impossible-date.ts +4 -0
- package/src/detectors/registry/impossible-time.ts +4 -0
- package/src/detectors/registry/latin-abbreviation.ts +4 -0
- package/src/detectors/registry/link-text-url-mismatch.ts +4 -0
- package/src/detectors/registry/mark-consistency.ts +4 -0
- package/src/detectors/registry/origin-particle.ts +4 -0
- package/src/detectors/registry/request-without-deadline.ts +4 -0
- package/src/detectors/registry/sentence-spacing.ts +4 -0
- package/src/detectors/registry/shell-prompt.ts +4 -0
- package/src/detectors/registry/step-statement.ts +4 -0
- package/src/detectors/registry/unpaired-parallel.ts +4 -0
- package/src/detectors/request-owner.ts +141 -0
- package/src/detectors/technical-docs.ts +230 -0
- package/src/extension/pack-read.ts +128 -0
- package/src/extension/plugin-load.ts +49 -9
- package/src/extension/plugin-parse.ts +10 -3
- package/src/facts/address-values.ts +74 -0
- package/src/facts/cross-facts.ts +54 -0
- package/src/fix-plan/chosen-depth.ts +28 -0
- package/src/fix-plan/mode.ts +57 -11
- package/src/fix-plan/plan.ts +37 -5
- package/src/fix-plan/render.ts +24 -0
- package/src/fix-plan/text.ts +47 -10
- package/src/genre-load.ts +7 -4
- package/src/genre-parse.ts +19 -0
- package/src/init.ts +10 -10
- package/src/known-genres.ts +19 -0
- package/src/link-shape.ts +62 -0
- package/src/reasons.ts +16 -4
- package/src/render/explain.ts +14 -2
- package/src/render/friendly.ts +1 -1
- package/src/render/rules-json.ts +14 -3
- package/src/render/rules-table.ts +6 -6
- package/src/render/semantic.ts +2 -2
- package/src/render/suppressions.ts +2 -2
- package/src/rewrite-depth.ts +60 -0
- package/src/rule-fields.ts +95 -0
- package/src/rule-guide.ts +9 -0
- package/src/rule-load.ts +10 -4
- package/src/rule-offs.ts +50 -0
- package/src/structure/build.ts +21 -1
- package/src/structure/cross-definitions.ts +60 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// A work email or letter's own parts: a greeting and a closing, a subject line, and an attachment the text mentions.
|
|
2
|
+
// Pure. The words (お世話になっております, Dear, Best regards, 件名, attached) come from the language's email-greeting,
|
|
3
|
+
// email-closing, email-subject-field, attachment-mention, attachment-not-mention and attachment-line lexicons. The
|
|
4
|
+
// header and the signature are masked in doc.prose; a quoted reply is someone else's words and is left out.
|
|
5
|
+
import { escapeRegExp } from "../orthography.ts";
|
|
6
|
+
import { quoteAt } from "./structure-tree.ts";
|
|
7
|
+
import type { Detector, Finding, ProseDocument, Span } from "../plugin.ts";
|
|
8
|
+
|
|
9
|
+
/** A greeting comes before the body: past the subject, the addressee (株式会社… / 総務部 山田様) and the writer's name. */
|
|
10
|
+
const GREETING_LINES = 5;
|
|
11
|
+
/** A closing comes at the end, before the writer's name, title, company, address and contact lines, or an acceptance block. */
|
|
12
|
+
const CLOSING_LINES = 10;
|
|
13
|
+
|
|
14
|
+
/** One line of the writer's own text. breaks: a heading, the header or the signature stood between it and the line before. */
|
|
15
|
+
export type BodyLine = { readonly start: number; readonly text: string; readonly breaks: boolean };
|
|
16
|
+
|
|
17
|
+
const patternsOf = (doc: ProseDocument, id: string): string[] => (doc.lexicons[id] ?? []).map((entry) => entry.pattern);
|
|
18
|
+
|
|
19
|
+
const LATIN_START = /^[A-Za-z]/u;
|
|
20
|
+
const LETTER = /\p{L}/u;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether a line holds one of the words. A Latin word opens the line (Dear, Best regards) and is followed by no letter,
|
|
24
|
+
* so "Hi" does not match "History"; a Japanese word may stand anywhere in it (いつもお世話になっております).
|
|
25
|
+
*/
|
|
26
|
+
export const lineHas = (line: string, words: readonly string[]): boolean => {
|
|
27
|
+
const trimmed = line.trim();
|
|
28
|
+
const folded = trimmed.toLowerCase();
|
|
29
|
+
return words.some((word) => {
|
|
30
|
+
if (!LATIN_START.test(word)) return trimmed.includes(word);
|
|
31
|
+
return folded.startsWith(word.toLowerCase()) && !LETTER.test(trimmed.charAt(word.length));
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether each offset, asked in rising order, is inside one of the spans (sorted by start). One pass over the spans for
|
|
37
|
+
* the whole document: an archive with a quoted reply in every message stays linear.
|
|
38
|
+
*/
|
|
39
|
+
const insideCursor = (spans: readonly Span[]): ((offset: number) => boolean) => {
|
|
40
|
+
const sorted = spans.toSorted((left, right) => left.start - right.start);
|
|
41
|
+
const cursor = { next: 0, farthestEnd: -1 };
|
|
42
|
+
return (offset) => {
|
|
43
|
+
while (cursor.next < sorted.length && (sorted[cursor.next]?.start ?? Infinity) <= offset) {
|
|
44
|
+
cursor.farthestEnd = Math.max(cursor.farthestEnd, sorted[cursor.next]?.end ?? 0);
|
|
45
|
+
cursor.next += 1;
|
|
46
|
+
}
|
|
47
|
+
return offset < cursor.farthestEnd;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A line prose masks words at the head of: a header field whose value stays (Subject: …), not the writer's text. Emphasis
|
|
53
|
+
* marks are masked too, but they hold no letter.
|
|
54
|
+
*/
|
|
55
|
+
const isFieldLine = (sourceLine: string, proseLine: string): boolean => {
|
|
56
|
+
const sourceChars = [...sourceLine];
|
|
57
|
+
const kept = [...proseLine].findIndex((char, index) => char.trim() !== "" || sourceChars[index]?.trim() === "");
|
|
58
|
+
return LETTER.test(sourceChars.slice(0, kept === -1 ? sourceChars.length : kept).join(""));
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type SourceLine = { readonly start: number; readonly text: string };
|
|
62
|
+
|
|
63
|
+
const linesWithOffsets = (text: string): SourceLine[] => {
|
|
64
|
+
const lines: SourceLine[] = [];
|
|
65
|
+
text.split("\n").forEach((line) => lines.push({ start: (lines.at(-1)?.start ?? 0) + (lines.at(-1)?.text.length ?? -1) + 1, text: line }));
|
|
66
|
+
return lines;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The writer's own lines, in order: not blank once the header and signature are masked (prose), not a quoted reply, not a
|
|
71
|
+
* heading. A heading, or a line the source has and prose masks (a header, a signature), breaks the text into parts.
|
|
72
|
+
*/
|
|
73
|
+
export const bodyLines = (source: string, prose: string, replyQuotes: readonly Span[]): BodyLine[] => {
|
|
74
|
+
const quoted = insideCursor(replyQuotes);
|
|
75
|
+
const lines: BodyLine[] = [];
|
|
76
|
+
const state = { broken: false };
|
|
77
|
+
linesWithOffsets(prose).forEach(({ start, text }) => {
|
|
78
|
+
const trimmed = text.trimStart();
|
|
79
|
+
const sourceLine = source.slice(start, start + text.length);
|
|
80
|
+
const masked = trimmed === "" ? sourceLine.trim() !== "" : isFieldLine(sourceLine, text);
|
|
81
|
+
const inQuote = quoted(start);
|
|
82
|
+
if (masked || trimmed.startsWith("#")) state.broken = true;
|
|
83
|
+
else if (trimmed !== "" && !trimmed.startsWith(">") && !inQuote) {
|
|
84
|
+
lines.push({ start, text, breaks: state.broken });
|
|
85
|
+
state.broken = false;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
return lines;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** The lines split where a heading, a header or a signature breaks them: a letter and its appendix, or one message and the next. */
|
|
92
|
+
const partsOf = (lines: readonly BodyLine[]): BodyLine[][] => {
|
|
93
|
+
const parts: BodyLine[][] = [];
|
|
94
|
+
lines.forEach((line) => {
|
|
95
|
+
const current = parts.at(-1);
|
|
96
|
+
if (line.breaks || current === undefined) parts.push([line]);
|
|
97
|
+
else current.push(line);
|
|
98
|
+
});
|
|
99
|
+
return parts;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export type FrameSlip = { readonly variant: "greeting" | "closing"; readonly offset: number };
|
|
103
|
+
|
|
104
|
+
/** A missing greeting (none in the first lines) and a missing closing (none in the last lines of any part). */
|
|
105
|
+
export const frameSlips = (lines: readonly BodyLine[], greetings: readonly string[], closings: readonly string[]): FrameSlip[] => {
|
|
106
|
+
const first = lines[0];
|
|
107
|
+
const last = lines.at(-1);
|
|
108
|
+
if (first === undefined || last === undefined) return [];
|
|
109
|
+
const greeted = lines.slice(0, GREETING_LINES).some((line) => lineHas(line.text, greetings));
|
|
110
|
+
const closed = partsOf(lines).some((part) => part.slice(-CLOSING_LINES).some((line) => lineHas(line.text, closings)));
|
|
111
|
+
return [
|
|
112
|
+
...(greeted ? [] : [{ variant: "greeting" as const, offset: first.start }]),
|
|
113
|
+
...(closed ? [] : [{ variant: "closing" as const, offset: last.start }]),
|
|
114
|
+
];
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export const emailFrame: Detector = (doc): Finding[] =>
|
|
118
|
+
frameSlips(bodyLines(doc.source, doc.prose ?? doc.source, doc.replyQuotes ?? []), patternsOf(doc, "email-greeting"), patternsOf(doc, "email-closing")).map(
|
|
119
|
+
(slip) => ({
|
|
120
|
+
rule: "email-greeting-closing",
|
|
121
|
+
severity: "info",
|
|
122
|
+
line: 0,
|
|
123
|
+
column: 0,
|
|
124
|
+
quote: quoteAt(doc.source, slip.offset),
|
|
125
|
+
values: { offset: slip.offset },
|
|
126
|
+
variant: slip.variant,
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
export type SubjectLine = { readonly start: number; readonly subject: string };
|
|
131
|
+
|
|
132
|
+
/** A field's name is one word of at most this many characters. */
|
|
133
|
+
const MAX_FIELD_NAME = 20;
|
|
134
|
+
const BLANK = /\s/u;
|
|
135
|
+
|
|
136
|
+
/** "Subject: Lunch" as its name and value; undefined for a line that is no field. */
|
|
137
|
+
export const fieldOf = (line: string): { readonly name: string; readonly value: string } | undefined => {
|
|
138
|
+
const colon = [...line].findIndex((char) => char === ":" || char === ":");
|
|
139
|
+
if (colon <= 0) return undefined;
|
|
140
|
+
const chars = [...line];
|
|
141
|
+
const name = chars.slice(0, colon).join("").trimEnd();
|
|
142
|
+
return name.length > MAX_FIELD_NAME || BLANK.test(name)
|
|
143
|
+
? undefined
|
|
144
|
+
: {
|
|
145
|
+
name,
|
|
146
|
+
value: chars
|
|
147
|
+
.slice(colon + 1)
|
|
148
|
+
.join("")
|
|
149
|
+
.trim(),
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** The subject lines (Subject: …, 件名:…), outside a quoted reply, with reply and forward marks (Re:, Fwd:) taken off the value. */
|
|
154
|
+
export const subjectLines = (source: string, fields: readonly string[], replyMarks: readonly string[], replyQuotes: readonly Span[]): SubjectLine[] => {
|
|
155
|
+
const names = new Set(fields.map((field) => field.toLowerCase()));
|
|
156
|
+
const quoted = insideCursor(replyQuotes);
|
|
157
|
+
return linesWithOffsets(source).flatMap(({ start, text }): SubjectLine[] => {
|
|
158
|
+
const field = fieldOf(text);
|
|
159
|
+
const inQuote = quoted(start);
|
|
160
|
+
return field === undefined || inQuote || !names.has(field.name.toLowerCase()) ? [] : [{ start, subject: withoutReplyMarks(field.value, replyMarks) }];
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** "Re: Fwd: Lunch" is "Lunch": the marks a mail program adds are not what the writer wrote. Read piece by piece between colons. */
|
|
165
|
+
export const withoutReplyMarks = (subject: string, marks: readonly string[]): string => {
|
|
166
|
+
const folded = new Set(marks.map((mark) => mark.toLowerCase()));
|
|
167
|
+
const pieces = subject.split(/[::]/u);
|
|
168
|
+
const kept = pieces.findIndex((piece, index) => index === pieces.length - 1 || !folded.has(piece.trim().toLowerCase()));
|
|
169
|
+
const dropped = pieces.slice(0, kept).reduce((length, piece) => length + piece.length + 1, 0);
|
|
170
|
+
return subject.slice(dropped).trim();
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export const emailSubject: Detector = (doc, options): Finding[] =>
|
|
174
|
+
subjectLines(doc.source, patternsOf(doc, "email-subject-field"), patternsOf(doc, "email-reply-mark"), doc.replyQuotes ?? []).flatMap((line): Finding[] => {
|
|
175
|
+
const length = [...line.subject].length;
|
|
176
|
+
if (length > 0 && length <= options.limit) return [];
|
|
177
|
+
return [
|
|
178
|
+
{
|
|
179
|
+
rule: "email-subject-length",
|
|
180
|
+
severity: "warning",
|
|
181
|
+
line: 0,
|
|
182
|
+
column: 0,
|
|
183
|
+
quote: quoteAt(doc.source, line.start),
|
|
184
|
+
values: { count: length, limit: options.limit, offset: line.start },
|
|
185
|
+
...(length === 0 ? { variant: "empty" } : {}),
|
|
186
|
+
},
|
|
187
|
+
];
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
/** Where the text first mentions an attachment (添付します, attached), outside the phrases that only look like one (attached to). */
|
|
191
|
+
export const firstMention = (lines: readonly BodyLine[], mentions: readonly string[], notMentions: readonly string[]): number | undefined => {
|
|
192
|
+
const folded = (text: string): string => text.toLowerCase();
|
|
193
|
+
const isMention = (text: string): boolean => {
|
|
194
|
+
const cleared = notMentions.reduce((rest, phrase) => rest.split(folded(phrase)).join(" "), folded(text));
|
|
195
|
+
return mentions.some((word) => cleared.includes(folded(word)));
|
|
196
|
+
};
|
|
197
|
+
const line = lines.find((candidate) => isMention(candidate.text));
|
|
198
|
+
return line === undefined ? undefined : line.start + line.text.length - line.text.trimStart().length;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/** The marks around a label line: a heading's #, emphasis, 【】. */
|
|
202
|
+
const LINE_MARKS = new Set("#*_【】 \t\u3000");
|
|
203
|
+
|
|
204
|
+
const withoutMarks = (line: string): string => {
|
|
205
|
+
const chars = [...line];
|
|
206
|
+
const first = chars.findIndex((char) => !LINE_MARKS.has(char));
|
|
207
|
+
return first === -1 ? "" : chars.slice(first, chars.findLastIndex((char) => !LINE_MARKS.has(char)) + 1).join("");
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/** A line that labels what is attached: the label and a colon (Attachment: quote.pdf), or the label alone (### Enclosures, 【添付資料】). */
|
|
211
|
+
const labelsAttachment = (line: string, labels: readonly string[]): boolean => {
|
|
212
|
+
const bare = withoutMarks(line).toLowerCase();
|
|
213
|
+
return labels.some((label) => {
|
|
214
|
+
const folded = label.toLowerCase();
|
|
215
|
+
return bare === folded || (bare.startsWith(folded) && /^\s*[::]/u.test(bare.slice(folded.length)));
|
|
216
|
+
});
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/** An attachment shown: a line that labels it, a file name with a listed extension, or the archive's note. text is the writer's own (no quoted reply). */
|
|
220
|
+
export const showsAttachment = (text: string, labels: readonly string[], extensions: readonly string[], notes: readonly string[]): boolean => {
|
|
221
|
+
const labelled = text.split("\n").some((line) => labelsAttachment(line, labels));
|
|
222
|
+
const fileName = new RegExp(`[\\w\\-ぁ-んァ-ヶ一-龠]\\.(?:${extensions.map(escapeRegExp).join("|")})\\b`, "iu");
|
|
223
|
+
return labelled || (extensions.length > 0 && fileName.test(text)) || notes.some((note) => text.includes(note));
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/** The source without someone else's words: a quoted reply's spans blanked and its "> " lines dropped. An old message's attachment is not this one's. */
|
|
227
|
+
export const ownText = (source: string, replyQuotes: readonly Span[]): string => {
|
|
228
|
+
const pieces: string[] = [];
|
|
229
|
+
const cursor = { at: 0 };
|
|
230
|
+
replyQuotes
|
|
231
|
+
.toSorted((left, right) => left.start - right.start)
|
|
232
|
+
.forEach((span) => {
|
|
233
|
+
const from = Math.max(span.start, cursor.at);
|
|
234
|
+
pieces.push(source.slice(cursor.at, from), " ".repeat(Math.max(0, span.end - from)));
|
|
235
|
+
cursor.at = Math.max(cursor.at, span.end);
|
|
236
|
+
});
|
|
237
|
+
pieces.push(source.slice(cursor.at));
|
|
238
|
+
return pieces
|
|
239
|
+
.join("")
|
|
240
|
+
.split("\n")
|
|
241
|
+
.filter((line) => !line.trimStart().startsWith(">"))
|
|
242
|
+
.join("\n");
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
export const attachmentMissing: Detector = (doc): Finding[] => {
|
|
246
|
+
const lines = bodyLines(doc.source, doc.prose ?? doc.source, doc.replyQuotes ?? []);
|
|
247
|
+
const mention = firstMention(lines, patternsOf(doc, "attachment-mention"), patternsOf(doc, "attachment-not-mention"));
|
|
248
|
+
if (mention === undefined) return [];
|
|
249
|
+
const extensions = patternsOf(doc, "attachment-file-extension").map((extension) => extension.replace(/^\./u, ""));
|
|
250
|
+
if (showsAttachment(ownText(doc.source, doc.replyQuotes ?? []), patternsOf(doc, "attachment-line"), extensions, patternsOf(doc, "email-attachment-note")))
|
|
251
|
+
return [];
|
|
252
|
+
return [
|
|
253
|
+
{
|
|
254
|
+
rule: "attachment-not-attached",
|
|
255
|
+
severity: "warning",
|
|
256
|
+
line: 0,
|
|
257
|
+
column: 0,
|
|
258
|
+
quote: quoteAt(doc.source, mention),
|
|
259
|
+
values: { offset: mention },
|
|
260
|
+
},
|
|
261
|
+
];
|
|
262
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { Detector, Finding, ProseDocument } from "../plugin.ts";
|
|
2
|
+
import { escapeRegExp } from "../orthography.ts";
|
|
3
|
+
import { quoteAround } from "./quote-around.ts";
|
|
4
|
+
|
|
5
|
+
// 暦に無い日付(2月30日、4月31日、2023-02-29)。月の長さとうるう年は暦で決まるので、書き損じは機械で言える。
|
|
6
|
+
// 月の名前は語彙表 month-name(初めの十二が一月から順に)、年月日の単位は date-time-unit(大きい順の初めの三つ)、元号は calendar-era が言う。
|
|
7
|
+
|
|
8
|
+
/** 日付を読むのに要る語。months は一月から順の月の名前と略した名前、units は年・月・日の単位、eras は元号。 */
|
|
9
|
+
export type CalendarWords = { readonly months: readonly string[]; readonly units: readonly string[]; readonly eras: readonly string[] };
|
|
10
|
+
|
|
11
|
+
/** 暦に無い日付 1 つ。reason は、月が無い(month)、その月にその日が無い(day)、うるう年でない年の 2 月 29 日(leap)。 */
|
|
12
|
+
export type ImpossibleDate = {
|
|
13
|
+
readonly offset: number;
|
|
14
|
+
readonly written: string;
|
|
15
|
+
readonly reason: "month" | "day" | "leap";
|
|
16
|
+
readonly month: number;
|
|
17
|
+
readonly days: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const DIGIT = "[0-90-9]";
|
|
21
|
+
const NOT_AFTER_NUMBER = "(?<![\\w/.\\-0-9])";
|
|
22
|
+
/** 日付の後ろに来ない字。ただし日時(2023-02-29T10:00)の T は日付の終わり。 */
|
|
23
|
+
const NOT_BEFORE_NUMBER = "(?:(?=T\\d)|(?![\\w//\\-0-9]|\\.\\w))";
|
|
24
|
+
const MONTHS_IN_YEAR = 12;
|
|
25
|
+
const LONGEST_MONTH = 31;
|
|
26
|
+
const FEBRUARY = 2;
|
|
27
|
+
const LEAP_DAY = 29;
|
|
28
|
+
const FIRST_CALENDAR_YEAR = 1000;
|
|
29
|
+
const DAYS_IN_MONTH: readonly number[] = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
30
|
+
const FULLWIDTH_OFFSET = 0xfee0;
|
|
31
|
+
|
|
32
|
+
const halfWidth = (text: string): string => text.replace(/[0-9]/gu, (char) => String.fromCharCode(char.charCodeAt(0) - FULLWIDTH_OFFSET));
|
|
33
|
+
|
|
34
|
+
const isLeapYear = (year: number): boolean => (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
35
|
+
|
|
36
|
+
/** 書き方ごとの読み方。strict は数の組が日付だと単位や名前で分かる形で、月が 1〜12 にない・日が 31 を超えるのも言う。 */
|
|
37
|
+
type Shape = { readonly pattern: RegExp; readonly strict: boolean };
|
|
38
|
+
|
|
39
|
+
/** 年の数。元号の年や 4 桁でない年は、うるう年を決められないので undefined。 */
|
|
40
|
+
const yearOf = (groups: Readonly<Record<string, string | undefined>>): number | undefined => {
|
|
41
|
+
const written = groups["y"];
|
|
42
|
+
if (written === undefined || groups["era"] !== undefined) return undefined;
|
|
43
|
+
const year = Number(halfWidth(written));
|
|
44
|
+
return year >= FIRST_CALENDAR_YEAR ? year : undefined;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** 一月から順の月の名前(初めの十二)で、略した名前(Sept、Feb.)が何月か。 */
|
|
48
|
+
const monthNamed = (months: readonly string[], written: string): number => {
|
|
49
|
+
const name = written.replace(/\.$/u, "");
|
|
50
|
+
return months.slice(0, MONTHS_IN_YEAR).findIndex((full) => full.startsWith(name)) + 1;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type Groups = Readonly<Record<string, string | undefined>>;
|
|
54
|
+
|
|
55
|
+
const monthOf = (groups: Groups, months: readonly string[]): number =>
|
|
56
|
+
groups["name"] === undefined ? Number(halfWidth(groups["m"] ?? "")) : monthNamed(months, groups["name"]);
|
|
57
|
+
|
|
58
|
+
/** その月の日数。2 月は、年が分かってうるう年でなければ 28 日、分からなければ 29 日。 */
|
|
59
|
+
const daysIn = (month: number, year: number | undefined): number =>
|
|
60
|
+
month === FEBRUARY && year !== undefined && !isLeapYear(year) ? LEAP_DAY - 1 : (DAYS_IN_MONTH[month - 1] ?? LONGEST_MONTH);
|
|
61
|
+
|
|
62
|
+
const problemOf = (month: number, day: number, year: number | undefined, strict: boolean): ImpossibleDate["reason"] | undefined => {
|
|
63
|
+
if (month < 1 || month > MONTHS_IN_YEAR) return strict ? "month" : undefined;
|
|
64
|
+
if (day < 1 || day > LONGEST_MONTH) return strict ? "day" : undefined;
|
|
65
|
+
if (day <= daysIn(month, year)) return undefined;
|
|
66
|
+
return month === FEBRUARY && day === LEAP_DAY ? "leap" : "day";
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const shapesOf = (words: CalendarWords): Shape[] => {
|
|
70
|
+
const [yearUnit, monthUnit, dayUnit] = words.units.map(escapeRegExp);
|
|
71
|
+
const names = words.months.map(escapeRegExp).join("|");
|
|
72
|
+
const eras = words.eras.map(escapeRegExp).join("|");
|
|
73
|
+
const era = eras === "" ? "" : `(?:(?<era>${eras})\\s?)?`;
|
|
74
|
+
const unitShape =
|
|
75
|
+
yearUnit === undefined || monthUnit === undefined || dayUnit === undefined
|
|
76
|
+
? []
|
|
77
|
+
: [
|
|
78
|
+
{
|
|
79
|
+
pattern: new RegExp(
|
|
80
|
+
`(?<!${DIGIT})${era}(?:(?<y>${DIGIT}{1,4}|元)${yearUnit}\\s?)?(?<m>${DIGIT}{1,2})${monthUnit}\\s?(?<d>${DIGIT}{1,2})${dayUnit}`,
|
|
81
|
+
"gu",
|
|
82
|
+
),
|
|
83
|
+
strict: true,
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
const nameShapes =
|
|
87
|
+
names === ""
|
|
88
|
+
? []
|
|
89
|
+
: [
|
|
90
|
+
{ pattern: new RegExp(`\\b(?<name>${names})\\.?\\s(?<d>\\d{1,2})(?:st|nd|rd|th)?(?![\\d:])(?:,?\\s(?<y>\\d{4})\\b)?`, "gu"), strict: false },
|
|
91
|
+
{ pattern: new RegExp(`(?<![\\d.,])\\b(?<d>\\d{1,2})(?:st|nd|rd|th)?\\s(?<name>${names})\\b\\.?(?:,?\\s(?<y>\\d{4})\\b)?`, "gu"), strict: false },
|
|
92
|
+
];
|
|
93
|
+
return [
|
|
94
|
+
{ pattern: new RegExp(`${NOT_AFTER_NUMBER}(?<y>\\d{4})-(?<m>\\d{1,2})-(?<d>\\d{1,2})${NOT_BEFORE_NUMBER}`, "gu"), strict: false },
|
|
95
|
+
{ pattern: new RegExp(`${NOT_AFTER_NUMBER}(?<y>\\d{4})[//](?<m>\\d{1,2})[//](?<d>\\d{1,2})${NOT_BEFORE_NUMBER}`, "gu"), strict: false },
|
|
96
|
+
...unitShape,
|
|
97
|
+
...nameShapes,
|
|
98
|
+
];
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const impossibleIn = (text: string, shape: Shape, months: readonly string[]): ImpossibleDate[] =>
|
|
102
|
+
[...text.matchAll(shape.pattern)].flatMap((match) => {
|
|
103
|
+
const groups = match.groups ?? {};
|
|
104
|
+
const month = monthOf(groups, months);
|
|
105
|
+
const year = yearOf(groups);
|
|
106
|
+
const reason = problemOf(month, Number(halfWidth(groups["d"] ?? "")), year, shape.strict);
|
|
107
|
+
if (reason === undefined) return [];
|
|
108
|
+
return [{ offset: match.index, written: match[0].trim(), reason, month, days: daysIn(month, year) }];
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/** 文字列の中の、暦に無い日付。同じ所に二つの形が当たれば、先に見つけたほうだけ。 */
|
|
112
|
+
export const impossibleDates = (text: string, words: CalendarWords): ImpossibleDate[] => {
|
|
113
|
+
const byOffset = new Map<number, ImpossibleDate>();
|
|
114
|
+
shapesOf(words)
|
|
115
|
+
.flatMap((shape) => impossibleIn(text, shape, words.months))
|
|
116
|
+
.forEach((found) => {
|
|
117
|
+
if (!byOffset.has(found.offset)) byOffset.set(found.offset, found);
|
|
118
|
+
});
|
|
119
|
+
return [...byOffset.values()].toSorted((left, right) => left.offset - right.offset);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const patternsOf = (doc: ProseDocument, id: string): string[] => (doc.lexicons[id] ?? []).map((entry) => entry.pattern);
|
|
123
|
+
|
|
124
|
+
export const calendarWordsOf = (doc: ProseDocument): CalendarWords => ({
|
|
125
|
+
months: patternsOf(doc, "month-name"),
|
|
126
|
+
units: patternsOf(doc, "date-time-unit").slice(0, 3),
|
|
127
|
+
eras: patternsOf(doc, "calendar-era"),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
export const impossibleDate: Detector = (doc): Finding[] => {
|
|
131
|
+
const text = doc.prose ?? doc.source;
|
|
132
|
+
return impossibleDates(text, calendarWordsOf(doc)).map((found) => ({
|
|
133
|
+
rule: "",
|
|
134
|
+
severity: "warning",
|
|
135
|
+
line: 0,
|
|
136
|
+
column: 0,
|
|
137
|
+
quote: quoteAround(text, found.offset, found.offset + found.written.length),
|
|
138
|
+
values: { written: found.written, month: found.month, days: found.days, offset: found.offset },
|
|
139
|
+
variant: found.reason,
|
|
140
|
+
}));
|
|
141
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { Detector, Finding, ProseDocument } from "../plugin.ts";
|
|
2
|
+
import { escapeRegExp } from "../orthography.ts";
|
|
3
|
+
import { quoteAround } from "./quote-around.ts";
|
|
4
|
+
|
|
5
|
+
// 時計に無い時刻(午後13時、13 PM、10:75、9時75分)。午前・午後が付けば時は 12 まで、分と秒は 59 まで。
|
|
6
|
+
// 午前・午後の語とその置き場所は語彙表 clock-meridiem、時・分・秒の単位は date-time-unit(大きい順の四つ目から)が言う。
|
|
7
|
+
// 午前・午後の無い「25:00」は、深夜の番組表のように 24 を超えて数える書き方があるので言わない。
|
|
8
|
+
|
|
9
|
+
/** 時刻を読むのに要る語。before は時刻の前に書く午前・午後、after は後ろに書く AM・PM、units は時・分・秒の単位。 */
|
|
10
|
+
export type ClockWords = { readonly before: readonly string[]; readonly after: readonly string[]; readonly units: readonly string[] };
|
|
11
|
+
|
|
12
|
+
/** 時計に無い時刻 1 つ。reason は、午前・午後の付いた時が 12 を超える(hour)か、分か秒が 59 を超える(minute)か。 */
|
|
13
|
+
export type ImpossibleTime = { readonly offset: number; readonly written: string; readonly reason: "hour" | "minute" };
|
|
14
|
+
|
|
15
|
+
const DIGIT = "[0-90-9]";
|
|
16
|
+
const COLON = "[::]";
|
|
17
|
+
/** 数の前後に来ない字。英字の語や番号の中(abc10:75、v10:75)は時刻ではない。日本語の字のすぐ後ろ(会議は10:75)は読む。 */
|
|
18
|
+
const NOT_AFTER_CLOCK = "(?<![A-Za-z0-90-9_::.])";
|
|
19
|
+
const NOT_BEFORE_CLOCK = "(?![A-Za-z0-90-9_::])";
|
|
20
|
+
const HALF_DAY = 12;
|
|
21
|
+
const MINUTES_IN_HOUR = 60;
|
|
22
|
+
/** 午前・午後の付いた時で、時刻の書き損じと読む上限。50 pm(ピコメートル)のような量を時刻と読まない。 */
|
|
23
|
+
const HOURS_IN_DAY = 24;
|
|
24
|
+
const FULLWIDTH_OFFSET = 0xfee0;
|
|
25
|
+
|
|
26
|
+
const halfWidth = (text: string): string => text.replace(/[0-9]/gu, (char) => String.fromCharCode(char.charCodeAt(0) - FULLWIDTH_OFFSET));
|
|
27
|
+
|
|
28
|
+
type Groups = Readonly<Record<string, string | undefined>>;
|
|
29
|
+
|
|
30
|
+
const numberIn = (groups: Groups, name: string): number | undefined => {
|
|
31
|
+
const written = groups[name];
|
|
32
|
+
return written === undefined ? undefined : Number(halfWidth(written));
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** 分か秒が 59 を超えるか。 */
|
|
36
|
+
const isPastMinute = (groups: Groups): boolean =>
|
|
37
|
+
["m", "colonMinute", "s"].some((name) => {
|
|
38
|
+
const value = numberIn(groups, name);
|
|
39
|
+
return value !== undefined && value >= MINUTES_IN_HOUR;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** 午前・午後の付いた時が 12 を超えるか(24 まで)。 */
|
|
43
|
+
const isPastHalfDay = (groups: Groups): boolean => {
|
|
44
|
+
const hour = numberIn(groups, "h") ?? 0;
|
|
45
|
+
return hour > HALF_DAY && hour <= HOURS_IN_DAY;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** 時刻の書き方と、その中の書き損じの見分け方。 */
|
|
49
|
+
type Shape = { readonly pattern: RegExp; readonly problem: (groups: Groups, text: string, at: number) => ImpossibleTime["reason"] | undefined };
|
|
50
|
+
|
|
51
|
+
const withMeridiem = (groups: Groups): ImpossibleTime["reason"] | undefined => {
|
|
52
|
+
if (isPastHalfDay(groups)) return "hour";
|
|
53
|
+
return isPastMinute(groups) ? "minute" : undefined;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const minutesOnly = (groups: Groups): ImpossibleTime["reason"] | undefined => (isPastMinute(groups) ? "minute" : undefined);
|
|
57
|
+
|
|
58
|
+
/** 「Genesis 24:67」「Science, 12:75」のような章と節、巻と頁は時刻ではない。頭が大文字の語や区切りの印のすぐ後ろは読まない。 */
|
|
59
|
+
const isCitationBefore = (before: string): boolean => {
|
|
60
|
+
const trimmed = before.trimEnd();
|
|
61
|
+
if ([",", ";", "."].includes(trimmed.slice(-1))) return true;
|
|
62
|
+
const lastWord = trimmed.split(/\s/u).at(-1) ?? "";
|
|
63
|
+
return /^\p{Lu}/u.test(lastWord);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** 時を 2 以上か 0 埋め(09:75)で書いた、時と分だけの時刻。1:75 は縮尺や比。 */
|
|
67
|
+
const bareClock = (groups: Groups, text: string, at: number): ImpossibleTime["reason"] | undefined => {
|
|
68
|
+
const hourText = groups["h"] ?? "";
|
|
69
|
+
const hour = Number(halfWidth(hourText));
|
|
70
|
+
const looksLikeClock = hour <= HOURS_IN_DAY && (hour >= 2 || /^[00]/u.test(hourText));
|
|
71
|
+
if (!looksLikeClock || isCitationBefore(text.slice(Math.max(0, at - HALF_DAY), at))) return undefined;
|
|
72
|
+
return minutesOnly(groups);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const alternation = (words: readonly string[]): string => words.map(escapeRegExp).join("|");
|
|
76
|
+
|
|
77
|
+
const meridiemShapes = (words: ClockWords): Shape[] => {
|
|
78
|
+
const [hourUnit, minuteUnit] = words.units.map(escapeRegExp);
|
|
79
|
+
const before = alternation(words.before);
|
|
80
|
+
const after = alternation(words.after);
|
|
81
|
+
const minuteTail = minuteUnit === undefined ? "" : `(?:\\s?(?<m>${DIGIT}{1,2})${minuteUnit})?`;
|
|
82
|
+
const unitTail = hourUnit === undefined ? "" : `${hourUnit}${minuteTail}|`;
|
|
83
|
+
return [
|
|
84
|
+
...(after === ""
|
|
85
|
+
? []
|
|
86
|
+
: [
|
|
87
|
+
{
|
|
88
|
+
pattern: new RegExp(`${NOT_AFTER_CLOCK}(?<![,])(?<h>${DIGIT}{1,2})(?:${COLON}(?<m>${DIGIT}{2}))?\\s?(?:${after})(?![\\p{L}\\p{N}])`, "gu"),
|
|
89
|
+
problem: withMeridiem,
|
|
90
|
+
},
|
|
91
|
+
]),
|
|
92
|
+
...(before === ""
|
|
93
|
+
? []
|
|
94
|
+
: [{ pattern: new RegExp(`(?:${before})\\s?(?<h>${DIGIT}{1,2})(?:${unitTail}[::](?<colonMinute>${DIGIT}{2}))`, "gu"), problem: withMeridiem }]),
|
|
95
|
+
];
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const unitShapes = (words: ClockWords): Shape[] => {
|
|
99
|
+
const [hourUnit, minuteUnit, secondUnit] = words.units.map(escapeRegExp);
|
|
100
|
+
if (hourUnit === undefined || minuteUnit === undefined) return [];
|
|
101
|
+
const seconds = secondUnit === undefined ? "" : `(?:\\s?(?<s>${DIGIT}{1,2})${secondUnit})?`;
|
|
102
|
+
return [{ pattern: new RegExp(`(?<!${DIGIT})(?<h>${DIGIT}{1,2})${hourUnit}\\s?(?<m>${DIGIT}{1,2})${minuteUnit}${seconds}`, "gu"), problem: minutesOnly }];
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const shapesOf = (words: ClockWords): Shape[] => [
|
|
106
|
+
...meridiemShapes(words),
|
|
107
|
+
...unitShapes(words),
|
|
108
|
+
{
|
|
109
|
+
pattern: new RegExp(`${NOT_AFTER_CLOCK}(?<h>${DIGIT}{1,3})${COLON}(?<m>${DIGIT}{2})${COLON}(?<s>${DIGIT}{2})${NOT_BEFORE_CLOCK}`, "gu"),
|
|
110
|
+
problem: minutesOnly,
|
|
111
|
+
},
|
|
112
|
+
{ pattern: new RegExp(`${NOT_AFTER_CLOCK}(?<![,/])(?<h>${DIGIT}{1,2})${COLON}(?<m>${DIGIT}{2})${NOT_BEFORE_CLOCK}`, "gu"), problem: bareClock },
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
const impossibleIn = (text: string, shape: Shape): ImpossibleTime[] =>
|
|
116
|
+
[...text.matchAll(shape.pattern)].flatMap((match) => {
|
|
117
|
+
const reason = shape.problem(match.groups ?? {}, text, match.index);
|
|
118
|
+
return reason === undefined ? [] : [{ offset: match.index, written: match[0].trim(), reason }];
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const overlaps = (left: ImpossibleTime, right: ImpossibleTime): boolean =>
|
|
122
|
+
left.offset < right.offset + right.written.length && right.offset < left.offset + left.written.length;
|
|
123
|
+
|
|
124
|
+
/** 文字列の中の、時計に無い時刻。重なる所に二つの形が当たれば、先の形(午前・午後の付いたもの)だけ。 */
|
|
125
|
+
export const impossibleTimes = (text: string, words: ClockWords): ImpossibleTime[] =>
|
|
126
|
+
shapesOf(words)
|
|
127
|
+
.flatMap((shape) => impossibleIn(text, shape))
|
|
128
|
+
.reduce<ImpossibleTime[]>((kept, found) => (kept.some((earlier) => overlaps(earlier, found)) ? kept : [...kept, found]), [])
|
|
129
|
+
.toSorted((left, right) => left.offset - right.offset);
|
|
130
|
+
|
|
131
|
+
const meridiemOf = (doc: ProseDocument, position: "before" | "after"): string[] =>
|
|
132
|
+
(doc.lexicons["clock-meridiem"] ?? []).filter((entry) => entry.position === position).map((entry) => entry.pattern);
|
|
133
|
+
|
|
134
|
+
export const clockWordsOf = (doc: ProseDocument): ClockWords => ({
|
|
135
|
+
before: meridiemOf(doc, "before"),
|
|
136
|
+
after: meridiemOf(doc, "after"),
|
|
137
|
+
units: (doc.lexicons["date-time-unit"] ?? []).map((entry) => entry.pattern).slice(3),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
export const impossibleTime: Detector = (doc): Finding[] => {
|
|
141
|
+
const text = doc.prose ?? doc.source;
|
|
142
|
+
return impossibleTimes(text, clockWordsOf(doc)).map((found) => ({
|
|
143
|
+
rule: "",
|
|
144
|
+
severity: "warning",
|
|
145
|
+
line: 0,
|
|
146
|
+
column: 0,
|
|
147
|
+
quote: quoteAround(text, found.offset, found.offset + found.written.length),
|
|
148
|
+
values: { written: found.written, offset: found.offset },
|
|
149
|
+
variant: found.reason,
|
|
150
|
+
}));
|
|
151
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Detector, Finding, Markup, ProseDocument, Span } from "../plugin.ts";
|
|
2
|
+
import { findingAt, markupOf, quoteOf } from "./markup-finding.ts";
|
|
3
|
+
import { linkTextOf } from "../link-text.ts";
|
|
4
|
+
import { bareUrls } from "../bare-url.ts";
|
|
5
|
+
import { isFileNameAlt, isUrlText, mismatchedHost } from "../link-shape.ts";
|
|
6
|
+
import { minorityWithin } from "../orthography.ts";
|
|
7
|
+
import { readMarkdown, spansOfType } from "../markdown-read.ts";
|
|
8
|
+
|
|
9
|
+
/** A bare URL is reported only while bare URLs are at most this share of the document's links; past it, bare is a style. */
|
|
10
|
+
const BARE_SHARE_PERCENT = 25;
|
|
11
|
+
|
|
12
|
+
/** The link text, or undefined for an autolink, a definition or a link whose text is itself an address. */
|
|
13
|
+
const wordedText = (source: string, link: Span): string | undefined => {
|
|
14
|
+
const text = linkTextOf(source.slice(link.start, link.end));
|
|
15
|
+
return text === undefined || text.trim() === "" || isUrlText(text) ? undefined : text;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** An inline link the reader clicks on words (`[text](url)`). A reference definition's span does not show the words used with it, so it is not counted. */
|
|
19
|
+
const isWorded = (source: string, link: Span): boolean => wordedText(source, link) !== undefined;
|
|
20
|
+
|
|
21
|
+
/** A link destination the parser gave up on (`[a](https://…/{{ page }})`, a space in it): what is left is a link, not a bare URL. */
|
|
22
|
+
const FAILED_DESTINATION = "](";
|
|
23
|
+
|
|
24
|
+
const inside = (span: Span, regions: readonly Span[]): boolean => regions.some((region) => span.start >= region.start && span.start < region.end);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The bare URLs the reader sees as text (outside links, code and quotations). A URL in a table cell is a value the table lists
|
|
28
|
+
* (an example request, a field's content), not a link the prose points to, so tables are left out.
|
|
29
|
+
*/
|
|
30
|
+
const bareUrlSpans = (source: string, markup: Markup): Span[] => {
|
|
31
|
+
const tables = spansOfType(readMarkdown(source).root, "table");
|
|
32
|
+
return markup.texts.flatMap((text) =>
|
|
33
|
+
bareUrls(source.slice(text.start, text.end))
|
|
34
|
+
.map((url) => ({ start: text.start + url.start, end: text.start + url.end }))
|
|
35
|
+
.filter((url) => !source.startsWith(FAILED_DESTINATION, url.start - FAILED_DESTINATION.length) && !inside(url, tables)),
|
|
36
|
+
);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type Address = Span & { readonly bare: boolean };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A URL written bare (`https://…`) in a document whose other links are words (`[the guide](https://…)`). Reported only when the
|
|
43
|
+
* document has at least `limit` worded links and the bare ones are the few; a document that writes its URLs bare throughout is not.
|
|
44
|
+
*/
|
|
45
|
+
export const bareUrlMix: Detector = (doc: ProseDocument, options): Finding[] => {
|
|
46
|
+
const markup = markupOf(doc);
|
|
47
|
+
if (markup === undefined) return [];
|
|
48
|
+
const worded: Address[] = markup.links.filter((link) => isWorded(doc.source, link)).map((link) => ({ ...link, bare: false }));
|
|
49
|
+
if (worded.length < options.limit) return [];
|
|
50
|
+
const bare: Address[] = bareUrlSpans(doc.source, markup).map((url) => ({ ...url, bare: true }));
|
|
51
|
+
return minorityWithin([...worded, ...bare], (address) => address.bare, BARE_SHARE_PERCENT)
|
|
52
|
+
.filter((address) => address.bare)
|
|
53
|
+
.map((address) => findingAt(doc, address, { url: quoteOf(doc.source, address), count: worded.length }));
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** A link whose text is an address on one host and whose destination is on another (`[https://a.example](https://b.example)`). */
|
|
57
|
+
export const linkTextUrlMismatch: Detector = (doc): Finding[] =>
|
|
58
|
+
(markupOf(doc)?.links ?? []).flatMap((link) => {
|
|
59
|
+
const text = linkTextOf(doc.source.slice(link.start, link.end));
|
|
60
|
+
const hosts = text === undefined ? undefined : mismatchedHost(text, link.destination);
|
|
61
|
+
return hosts === undefined ? [] : [findingAt(doc, link, hosts)];
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** An image whose alt text is its file's name (`IMG_1234.png`, `Screenshot 2026-04-01`). The name prefixes come from the lexicon. */
|
|
65
|
+
export const imageFileNameAlt: Detector = (doc, options): Finding[] => {
|
|
66
|
+
const prefixes = (options.lexicon ?? []).map((entry) => entry.pattern);
|
|
67
|
+
return (markupOf(doc)?.images ?? []).flatMap((image) =>
|
|
68
|
+
image.alt !== undefined && isFileNameAlt(image.alt, prefixes) ? [findingAt(doc, image, { alt: image.alt.trim() })] : [],
|
|
69
|
+
);
|
|
70
|
+
};
|