chaffjs 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/date-stamp.d.ts +14 -0
  2. package/dist/date-stamp.d.ts.map +1 -0
  3. package/dist/date-stamp.js +40 -0
  4. package/dist/date-stamp.js.map +1 -0
  5. package/dist/declared-lists.d.ts +4 -0
  6. package/dist/declared-lists.d.ts.map +1 -0
  7. package/dist/declared-lists.js +3 -0
  8. package/dist/declared-lists.js.map +1 -0
  9. package/dist/detectors/acronym-context.d.ts +13 -0
  10. package/dist/detectors/acronym-context.d.ts.map +1 -0
  11. package/dist/detectors/acronym-context.js +42 -0
  12. package/dist/detectors/acronym-context.js.map +1 -0
  13. package/dist/detectors/acronym-expansion.d.ts +9 -0
  14. package/dist/detectors/acronym-expansion.d.ts.map +1 -0
  15. package/dist/detectors/acronym-expansion.js +67 -0
  16. package/dist/detectors/acronym-expansion.js.map +1 -0
  17. package/dist/detectors/doubled-word.d.ts +27 -0
  18. package/dist/detectors/doubled-word.d.ts.map +1 -0
  19. package/dist/detectors/doubled-word.js +94 -0
  20. package/dist/detectors/doubled-word.js.map +1 -0
  21. package/dist/detectors/heading-echo.d.ts.map +1 -1
  22. package/dist/detectors/heading-echo.js +22 -12
  23. package/dist/detectors/heading-echo.js.map +1 -1
  24. package/dist/detectors/index.d.ts.map +1 -1
  25. package/dist/detectors/index.js +2 -0
  26. package/dist/detectors/index.js.map +1 -1
  27. package/dist/detectors/lead-in.d.ts +8 -0
  28. package/dist/detectors/lead-in.d.ts.map +1 -0
  29. package/dist/detectors/lead-in.js +13 -0
  30. package/dist/detectors/lead-in.js.map +1 -0
  31. package/dist/detectors/lexicon-match.d.ts +6 -0
  32. package/dist/detectors/lexicon-match.d.ts.map +1 -1
  33. package/dist/detectors/lexicon-match.js +8 -0
  34. package/dist/detectors/lexicon-match.js.map +1 -1
  35. package/dist/detectors/lexicon.d.ts +5 -0
  36. package/dist/detectors/lexicon.d.ts.map +1 -1
  37. package/dist/detectors/lexicon.js +21 -4
  38. package/dist/detectors/lexicon.js.map +1 -1
  39. package/dist/detectors/signals.d.ts.map +1 -1
  40. package/dist/detectors/signals.js +9 -111
  41. package/dist/detectors/signals.js.map +1 -1
  42. package/dist/detectors/structure.d.ts.map +1 -1
  43. package/dist/detectors/structure.js +14 -1
  44. package/dist/detectors/structure.js.map +1 -1
  45. package/dist/detectors/superlative-scope.d.ts +16 -0
  46. package/dist/detectors/superlative-scope.d.ts.map +1 -0
  47. package/dist/detectors/superlative-scope.js +40 -0
  48. package/dist/detectors/superlative-scope.js.map +1 -0
  49. package/dist/document.d.ts.map +1 -1
  50. package/dist/document.js +17 -11
  51. package/dist/document.js.map +1 -1
  52. package/dist/eval.d.ts.map +1 -1
  53. package/dist/eval.js +3 -2
  54. package/dist/eval.js.map +1 -1
  55. package/dist/heading-title.d.ts +6 -0
  56. package/dist/heading-title.d.ts.map +1 -0
  57. package/dist/heading-title.js +8 -0
  58. package/dist/heading-title.js.map +1 -0
  59. package/dist/in-page-nav.d.ts +19 -0
  60. package/dist/in-page-nav.d.ts.map +1 -0
  61. package/dist/in-page-nav.js +44 -0
  62. package/dist/in-page-nav.js.map +1 -0
  63. package/dist/orthography.d.ts +1 -0
  64. package/dist/orthography.d.ts.map +1 -1
  65. package/dist/orthography.js +4 -3
  66. package/dist/orthography.js.map +1 -1
  67. package/dist/plugin.d.ts +4 -0
  68. package/dist/plugin.d.ts.map +1 -1
  69. package/dist/rule-load.d.ts.map +1 -1
  70. package/dist/rule-load.js +1 -0
  71. package/dist/rule-load.js.map +1 -1
  72. package/dist/run.d.ts.map +1 -1
  73. package/dist/run.js +5 -4
  74. package/dist/run.js.map +1 -1
  75. package/package.json +3 -3
  76. package/rules/doubled-word.yaml +27 -0
  77. package/rules/latin-spacing.yaml +2 -2
  78. package/rules/undefined-acronym.yaml +1 -0
  79. package/rules/unqualified-superlative.yaml +1 -0
  80. package/src/date-stamp.ts +65 -0
  81. package/src/declared-lists.ts +5 -0
  82. package/src/detectors/acronym-context.ts +53 -0
  83. package/src/detectors/acronym-expansion.ts +81 -0
  84. package/src/detectors/doubled-word.ts +128 -0
  85. package/src/detectors/heading-echo.ts +13 -3
  86. package/src/detectors/index.ts +2 -0
  87. package/src/detectors/lead-in.ts +14 -0
  88. package/src/detectors/lexicon-match.ts +10 -0
  89. package/src/detectors/lexicon.ts +32 -6
  90. package/src/detectors/signals.ts +11 -116
  91. package/src/detectors/structure.ts +21 -2
  92. package/src/detectors/superlative-scope.ts +66 -0
  93. package/src/document.ts +19 -11
  94. package/src/eval.ts +2 -1
  95. package/src/heading-title.ts +8 -0
  96. package/src/in-page-nav.ts +67 -0
  97. package/src/orthography.ts +4 -3
  98. package/src/plugin.ts +4 -0
  99. package/src/rule-load.ts +1 -0
  100. package/src/run.ts +5 -4
@@ -1,5 +1,7 @@
1
1
  import { charLength, lengthOf } from "../measure.ts";
2
- import type { BulletList, Detector, Finding, Paragraph, ProseDocument, Section } from "../plugin.ts";
2
+ import type { BulletList, Detector, Finding, Paragraph, ProseDocument, Section, Span } from "../plugin.ts";
3
+ import { dateStampIndexes, stampCandidates } from "../date-stamp.ts";
4
+ import { inDocumentOrder } from "../structure/issues.ts";
3
5
 
4
6
  /**
5
7
  * ばらつきは変動係数(標準偏差 ÷ 平均)で測る。
@@ -90,6 +92,21 @@ export const ruleOfThree: Detector = (doc, options): Finding[] => {
90
92
  ];
91
93
  };
92
94
 
95
+ const dateSpans = (doc: ProseDocument): Span[] =>
96
+ doc.structure === undefined ? [] : inDocumentOrder(doc.structure).flatMap((node) => (node.kind === "date" ? [node.span] : []));
97
+
98
+ /** 更新日の刻印(「最終更新日:」「2025年6月20日」)は前置きに数えない。木を作るのは、数えて上限を超えたときだけ。 */
99
+ const withoutDateStamps = (doc: ProseDocument, paragraphs: readonly Paragraph[]): Paragraph[] => {
100
+ const labels = (doc.lexicons["date-stamp-label"] ?? []).map((entry) => entry.pattern);
101
+ const candidates = stampCandidates(
102
+ doc.source,
103
+ paragraphs.map((paragraph) => paragraph.span),
104
+ dateSpans(doc),
105
+ );
106
+ const stamps = dateStampIndexes(candidates, labels);
107
+ return paragraphs.filter((_, index) => !stamps.has(index));
108
+ };
109
+
93
110
  /**
94
111
  * 本題に入るまでが長い。業務文書では、読み手は結論を探しに来ている。
95
112
  *
@@ -99,7 +116,9 @@ export const ruleOfThree: Detector = (doc, options): Finding[] => {
99
116
  export const preambleLength: Detector = (doc, options): Finding[] => {
100
117
  const body = doc.sections.find((section) => section.depth >= 2);
101
118
  if (body === undefined) return [];
102
- const before = doc.paragraphs.filter((paragraph) => paragraph.span.start < body.span.start);
119
+ const all = doc.paragraphs.filter((paragraph) => paragraph.span.start < body.span.start);
120
+ if (all.length <= options.limit) return [];
121
+ const before = withoutDateStamps(doc, all);
103
122
  if (before.length <= options.limit) return [];
104
123
  const first = before[0];
105
124
  return first === undefined
@@ -0,0 +1,66 @@
1
+ import type { Lexicon, Token } from "../plugin.ts";
2
+ import type { TokenRange } from "./lexicon-match.ts";
3
+
4
+ /**
5
+ * 最上級が範囲を持っているか。「日本で最も」「国内最大」"the best in the world" は、何の中で最もなのかを言っている。
6
+ * 範囲を示す語は言語で違い、置く側も違う(日本語は最上級の前、英語は後ろ)。どちらの語も言語パッケージの語彙表が持つ。
7
+ */
8
+ export type ScopeMarkers = {
9
+ /** 最上級の直前に置く語(で)。その前に固有名詞か地名が要る。 */
10
+ readonly before: Lexicon;
11
+ /** 最上級の名詞句の直後に置く語(in・of)。その後ろに名詞句が要る。 */
12
+ readonly after: Lexicon;
13
+ };
14
+
15
+ /** 語彙表の position で分ける。position の無い語は、どちら側の語か分からないので使わない。 */
16
+ export const scopeMarkersOf = (lexicon: Lexicon): ScopeMarkers => ({
17
+ before: lexicon.filter((entry) => entry.position === "before"),
18
+ after: lexicon.filter((entry) => entry.position === "after"),
19
+ });
20
+
21
+ const NOMINAL = new Set(["NOUN", "PROPN"]);
22
+
23
+ /** 名詞句の始まり。the world・Chicago・our products・the three・all。 */
24
+ const PHRASE_START = new Set(["DET", "NOUN", "PROPN", "PRON", "NUM", "ADJ"]);
25
+
26
+ /** 名詞の前に来る修飾(most skilled・widely used・stuffed)。名詞が出た後は名詞の連なりだけが続く。 */
27
+ const PREMODIFIER = new Set(["ADJ", "ADV", "VERB", "NOUN", "PROPN", "NUM"]);
28
+ const NOUN_RUN = new Set(["NOUN", "PROPN", "NUM"]);
29
+
30
+ /** 範囲の前に置ける語。修飾だけで終わった句(the best is in …)は範囲ではなく述語。 */
31
+ const PHRASE_END = new Set(["NOUN", "PROPN", "NUM", "ADJ"]);
32
+
33
+ const isMarker = (token: Token | undefined, markers: Lexicon): boolean =>
34
+ token !== undefined && markers.some((entry) => entry.pattern.toLowerCase() === token.surface.toLowerCase());
35
+
36
+ const isNamed = (token: Token | undefined): boolean => token !== undefined && (token.pos === "PROPN" || token.features?.["NameType"] !== undefined);
37
+
38
+ /** 空白も助詞も挟まずに名詞が付いた最上級(国内最大・業界最速・世界唯一)。前の名詞が範囲。 */
39
+ const compound = (tokens: readonly Token[], range: TokenRange): boolean => {
40
+ const before = tokens[range.start - 1];
41
+ const first = tokens[range.start];
42
+ return before !== undefined && first !== undefined && NOMINAL.has(before.pos) && before.span.end === first.span.start;
43
+ };
44
+
45
+ /** 名前 + 範囲の語 + 最上級(日本で最も・トヨタで最も・東京都で最大)。「費用で最大の効果」の「費用」は名前ではないので範囲にしない。 */
46
+ const namedBefore = (tokens: readonly Token[], range: TokenRange, markers: Lexicon): boolean =>
47
+ isMarker(tokens[range.start - 1], markers) && isNamed(tokens[range.start - 2]);
48
+
49
+ /** 最上級に続く名詞句の終わり。名詞が出るまでは修飾を、出た後は名詞の連なりを読む。 */
50
+ const phraseEnd = (tokens: readonly Token[], at: number, nounSeen = false): number => {
51
+ const token = tokens[at];
52
+ if (token === undefined || !(nounSeen ? NOUN_RUN : PREMODIFIER).has(token.pos)) return at;
53
+ return phraseEnd(tokens, at + 1, nounSeen || NOMINAL.has(token.pos));
54
+ };
55
+
56
+ /** 最上級 + 名詞句 + 範囲の語 + 名詞句(the best pizza in Chicago・the most famous of the sculptures)。 */
57
+ const phraseAfter = (tokens: readonly Token[], range: TokenRange, markers: Lexicon): boolean => {
58
+ const at = phraseEnd(tokens, range.end);
59
+ const last = tokens[at - 1];
60
+ const endsPhrase = at === range.end || (last !== undefined && PHRASE_END.has(last.pos));
61
+ const opens = tokens[at + 1];
62
+ return endsPhrase && isMarker(tokens[at], markers) && opens !== undefined && PHRASE_START.has(opens.pos);
63
+ };
64
+
65
+ export const scoped = (tokens: readonly Token[], range: TokenRange, markers: ScopeMarkers): boolean =>
66
+ compound(tokens, range) || namedBefore(tokens, range, markers.before) || phraseAfter(tokens, range, markers.after);
package/src/document.ts CHANGED
@@ -4,16 +4,21 @@ import { gfmTableFromMarkdown } from "mdast-util-gfm-table";
4
4
  import { frontmatter } from "micromark-extension-frontmatter";
5
5
  import { frontmatterFromMarkdown } from "mdast-util-frontmatter";
6
6
  import { atxHeadingText, headingText } from "./heading-text.ts";
7
+ import { hasTitle } from "./heading-title.ts";
7
8
  import { maskSpans } from "./mask.ts";
8
9
  import { buildTree, type Outline } from "./structure/build.ts";
9
10
  import { isMarkdownPath } from "./structure/markdown-path.ts";
10
11
  import { pageFurniture, textOutline } from "./page-furniture.ts";
11
12
  import { tokenizedLexicons } from "./lexicon-tokens.ts";
13
+ import { inPageAnchors, isInPageNavigation, isNavigationList, type InPageAnchors } from "./in-page-nav.ts";
12
14
  import type { BulletList, LanguageAdapter, Paragraph, ProseDocument, Section, Sentence, Span, StructureNode, DocumentProfile } from "./plugin.ts";
13
15
 
14
16
  type Place = { readonly offset?: number | undefined };
15
17
  type Node = {
16
18
  readonly type: string;
19
+ readonly url?: string | undefined;
20
+ readonly value?: string | undefined;
21
+ readonly identifier?: string | undefined;
17
22
  readonly position?: { readonly start: Place; readonly end: Place } | undefined;
18
23
  readonly children?: readonly Node[] | undefined;
19
24
  };
@@ -93,14 +98,14 @@ const matchSpans = (source: string, pattern: RegExp): Span[] =>
93
98
 
94
99
  const directiveSpans = (source: string): Span[] => [...matchSpans(source, DIRECTIVE), ...matchSpans(source, BARE_URL)];
95
100
 
96
- const collectMasks = (root: Node, source: string): Span[] => {
101
+ const collectMasks = (root: Node, source: string, anchors: InPageAnchors): Span[] => {
97
102
  const spans: Span[] = [...directiveSpans(source)];
98
103
  walk(root, (node) => {
99
104
  if (node.type === "link" || node.type === "linkReference") {
100
105
  spans.push(...linkChrome(node));
101
106
  return;
102
107
  }
103
- if (!NOT_PROSE.has(node.type) && node.type !== "heading") return;
108
+ if (!NOT_PROSE.has(node.type) && node.type !== "heading" && !isInPageNavigation(node, anchors)) return;
104
109
  const span = spanOf(node);
105
110
  if (span !== undefined) spans.push(span);
106
111
  });
@@ -136,7 +141,8 @@ const headingsOf = (root: Node, source: string): Heading[] => {
136
141
  if (span === undefined) return;
137
142
  // ATX(行頭が #)なら閉じの # も外す。setext の見出しの末尾の # は言葉なので残す。
138
143
  const read = source.startsWith("#", span.start) ? atxHeadingText : headingText;
139
- found.push({ depth, text: read(textOf(node, source)), start: span.start, end: span.end });
144
+ const text = read(textOf(node, source));
145
+ if (hasTitle(text)) found.push({ depth, text, start: span.start, end: span.end });
140
146
  });
141
147
  return found;
142
148
  };
@@ -162,10 +168,10 @@ const strongSpans = (root: Node, masked: readonly Span[]): Span[] => {
162
168
 
163
169
  const within = (span: Span, from: number, to: number): boolean => span.start >= from && span.start < to;
164
170
 
165
- const spansOfType = (root: Node, type: string): Span[] => {
171
+ const spansOfType = (root: Node, type: string, keep: (node: Node) => boolean = () => true): Span[] => {
166
172
  const found: Span[] = [];
167
173
  walk(root, (node) => {
168
- if (node.type !== type) return;
174
+ if (node.type !== type || !keep(node)) return;
169
175
  const span = spanOf(node);
170
176
  if (span !== undefined) found.push(span);
171
177
  });
@@ -218,11 +224,11 @@ const paragraphsOf = (spans: readonly Span[], sentences: readonly Sentence[], li
218
224
  .filter((span) => !listSpans.some((list) => span.start >= list.start && span.start < list.end))
219
225
  .map((span) => ({ span, sentences: sentences.filter((sentence) => within(sentence.span, span.start, span.end)) }));
220
226
 
221
- /** 箇条書きは list ノードの直下の項目を数える。入れ子の項目は内側の list のものとして数える。 */
222
- const listsOf = (root: Node, source: string): BulletList[] => {
227
+ /** 箇条書きは list ノードの直下の項目を数える。入れ子の項目は内側の list のものとして数える。目次のような案内だけの箇条書きは数えない。 */
228
+ const listsOf = (root: Node, source: string, anchors: InPageAnchors): BulletList[] => {
223
229
  const found: BulletList[] = [];
224
230
  walk(root, (node) => {
225
- if (node.type !== "list") return;
231
+ if (node.type !== "list" || isNavigationList(node, anchors)) return;
226
232
  const span = spanOf(node);
227
233
  if (span === undefined) return;
228
234
  const items = (node.children ?? []).flatMap((child) => {
@@ -263,13 +269,15 @@ export const buildDocument = (
263
269
  profile: DocumentProfile | undefined = undefined,
264
270
  ): ProseDocument => {
265
271
  const root = parse(source);
272
+ const anchors = inPageAnchors(root);
266
273
  // 強調の記号は「本文でないもの」だが、太字の数を数えるときの「覆われた場所」ではない。
267
274
  // 同じ集合にすると、太字が自分の記号のせいで覆われた場所にあることになり、1 つも数えられなくなる。
268
275
  // テキストの文書は、ページのヘッダーとフッターも本文ではない(Markdown には改ページが無い)。
269
- const blocks = [...collectMasks(root, source), ...(isMarkdownPath(path) ? [] : pageFurniture(source))];
276
+ const blocks = [...collectMasks(root, source, anchors), ...(isMarkdownPath(path) ? [] : pageFurniture(source))];
270
277
  const masked = [...blocks, ...emphasisSpans(root, source)];
271
278
  const prose = maskSpans(source, masked);
272
- const paragraphSpans = spansOfType(root, "paragraph");
279
+ // ページの案内は段落としても数えない。数えると、目次の行が「本題までの段落」に入る。
280
+ const paragraphSpans = spansOfType(root, "paragraph", (node) => !isInPageNavigation(node, anchors));
273
281
  const listItems = spansOfType(root, "listItem");
274
282
  const sentences = sentencesOf(prose, paragraphSpans, adapter);
275
283
  const lexicons = {
@@ -292,7 +300,7 @@ export const buildDocument = (
292
300
  sentences,
293
301
  listSpans: listItems,
294
302
  paragraphs: paragraphsOf(paragraphSpans, sentences, listItems),
295
- lists: listsOf(root, source),
303
+ lists: listsOf(root, source, anchors),
296
304
  lexicons: tagged ? tokenizedLexicons(lexicons, adapter) : lexicons,
297
305
  requiredSections: team.requiredSections,
298
306
  // 構造の rule(参照先が無い・番号の抜け)が読む木。どの rule も読まなければ作らない。何万行の契約書で、他の rule の lint に代金を払わせない。
package/src/eval.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { DETECTORS } from "./detectors/index.ts";
2
2
  import { charLength } from "./measure.ts";
3
3
  import type { ProseDocument, RuleDefinition } from "./plugin.ts";
4
+ import { missingList } from "./declared-lists.ts";
4
5
 
5
6
  /**
6
7
  * 閾値の較正。
@@ -48,7 +49,6 @@ const countAt = (docs: readonly ProseDocument[], rule: RuleDefinition, limit: nu
48
49
  if (detector === undefined) return { limit, findings: 0, documents: 0, per10k: 0 };
49
50
  const perDoc = docs.map((doc) => {
50
51
  const lexicon = rule.word_list === undefined ? undefined : doc.lexicons[rule.word_list];
51
- if (rule.word_list !== undefined && lexicon === undefined) return 0;
52
52
  return detector(doc, { limit, lexicon, where: rule.where }).length;
53
53
  });
54
54
  const findings = perDoc.reduce((sum, count) => sum + count, 0);
@@ -82,6 +82,7 @@ const measurable = (rule: RuleDefinition, docs: readonly ProseDocument[], genre:
82
82
  if (rule.layer === "L4" || !rule.use_for.some((target) => genre.startsWith(target))) return false;
83
83
  if (rule.languages !== undefined && !rule.languages.includes(language)) return false;
84
84
  if (rule.from.length > 0) return false;
85
+ if (docs.some((doc) => missingList(rule, doc.lexicons) !== undefined)) return false;
85
86
  return rule.requires.every((need) => meets(need, docs));
86
87
  };
87
88
 
@@ -0,0 +1,8 @@
1
+ /** 文字か数字。記号・空白・絵文字だけの並びには、読者が指せる題が無い。 */
2
+ const TITLE_CHARACTER = /[\p{L}\p{N}]/u;
3
+
4
+ /**
5
+ * 見出しに題があるか。`###` だけの行や `## ---` は、変換で残った空の `<h3>` のような区切りで、
6
+ * 本文の始まりでも読者が辿れる節でもない。題の無い見出しは見出しとして数えず、前後の本文を 1 つの節として読む。
7
+ */
8
+ export const hasTitle = (headingWords: string): boolean => TITLE_CHARACTER.test(headingWords);
@@ -0,0 +1,67 @@
1
+ /** mdast の節のうち、ページ内の案内かどうかの判定に要る部分だけ。 */
2
+ export type NavNode = {
3
+ readonly type: string;
4
+ readonly url?: string | undefined;
5
+ readonly value?: string | undefined;
6
+ readonly identifier?: string | undefined;
7
+ readonly children?: readonly NavNode[] | undefined;
8
+ };
9
+
10
+ /** 参照形式のリンク(`[目次][toc]`)の名前のうち、定義(`[toc]: #目次`)が同じページの中を指すもの。 */
11
+ export type InPageAnchors = ReadonlySet<string>;
12
+
13
+ /** 文字か数字。これが 1 つでもあれば、書いた人の言葉がある。記号(▲ ↑ | ・)だけなら飾り。 */
14
+ const WORD = /[\p{L}\p{N}]/u;
15
+
16
+ const WRAPPERS = new Set(["strong", "emphasis", "delete"]);
17
+
18
+ const pointsInPage = (url: string | undefined): boolean => (url ?? "").startsWith("#");
19
+
20
+ const collectAnchors = (node: NavNode, found: Set<string>): void => {
21
+ if (node.type === "definition" && node.identifier !== undefined && pointsInPage(node.url)) found.add(node.identifier);
22
+ (node.children ?? []).forEach((child) => collectAnchors(child, found));
23
+ };
24
+
25
+ export const inPageAnchors = (root: NavNode): InPageAnchors => {
26
+ const found = new Set<string>();
27
+ collectAnchors(root, found);
28
+ return found;
29
+ };
30
+
31
+ const isInPageLink = (node: NavNode, anchors: InPageAnchors): boolean =>
32
+ (node.type === "link" && pointsInPage(node.url)) || (node.type === "linkReference" && anchors.has(node.identifier ?? ""));
33
+
34
+ const isNavigationPart = (node: NavNode, anchors: InPageAnchors): boolean => {
35
+ if (isInPageLink(node, anchors) || node.type === "break") return true;
36
+ if (node.type === "text") return !WORD.test(node.value ?? "");
37
+ return WRAPPERS.has(node.type) && (node.children ?? []).every((child) => isNavigationPart(child, anchors));
38
+ };
39
+
40
+ const containsInPageLink = (node: NavNode, anchors: InPageAnchors): boolean =>
41
+ isInPageLink(node, anchors) || (node.children ?? []).some((child) => containsInPageLink(child, anchors));
42
+
43
+ /**
44
+ * 同じページの中へのリンク(`](#…)`)と記号だけでできた段落。「▲ 目次に戻る」や目次の項目で、本文ではなくページの案内。
45
+ * 言葉が 1 つでも混ざれば本文(「詳しくは[第3章](#第3章)を参照」)。他のページへのリンクは書いた人が選んだ一覧なので残す。
46
+ */
47
+ export const isInPageNavigation = (paragraph: NavNode, anchors: InPageAnchors = new Set()): boolean => {
48
+ const children = paragraph.children ?? [];
49
+ return (
50
+ paragraph.type === "paragraph" &&
51
+ children.every((child) => isNavigationPart(child, anchors)) &&
52
+ children.some((child) => containsInPageLink(child, anchors))
53
+ );
54
+ };
55
+
56
+ const isNavigationBlock = (node: NavNode, anchors: InPageAnchors): boolean => isInPageNavigation(node, anchors) || isNavigationList(node, anchors);
57
+
58
+ const isNavigationItem = (item: NavNode, anchors: InPageAnchors): boolean => {
59
+ const children = item.children ?? [];
60
+ return item.type === "listItem" && children.length > 0 && children.every((child) => isNavigationBlock(child, anchors));
61
+ };
62
+
63
+ /** 項目がどれもページの案内(入れ子も含む)の箇条書き。目次の項目の数は節の数で決まり、書いた人が選んだ並べ方ではない。 */
64
+ export const isNavigationList = (list: NavNode, anchors: InPageAnchors = new Set()): boolean => {
65
+ const items = list.children ?? [];
66
+ return list.type === "list" && items.length > 0 && items.every((item) => isNavigationItem(item, anchors));
67
+ };
@@ -1,6 +1,6 @@
1
1
  // 表記のそろい。書き方の決まりのうち、文字だけで決まるもの。
2
2
 
3
- const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
3
+ export const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
4
4
 
5
5
  /** text の中で word が現れる [始まり, 終わり) を、左から順に。大文字小文字を区別しないときは i で探す(位置は元の文字列のまま)。 */
6
6
  const spansOf = (text: string, word: string, ignoreCase: boolean): [number, number][] => {
@@ -67,6 +67,7 @@ const ALPHANUMERIC = /[A-Za-z0-9.]/u;
67
67
  /**
68
68
  * 日本語の左にある英数字の並びの、先頭の字。「3GBの」の「GB」は数量の単位で、並びは数字で始まる。
69
69
  * そうした並びと日本語の境目は、英字の空け方ではなく数字の後ろの空け方として数える(「3回」と同じ)。
70
+ * 逆に「H30 等」「EC2 で」は英字で始まる名前で、書き手は英単語と同じに空ける。数字の後ろとしては数えない。
70
71
  */
71
72
  const runStart = (chars: readonly string[], last: number): string | undefined => {
72
73
  let first = last;
@@ -74,10 +75,10 @@ const runStart = (chars: readonly string[], last: number): string | undefined =>
74
75
  return chars[first];
75
76
  };
76
77
 
77
- /** 左の並びの種類。数字で始まる並び(3GB、10ms)は数字として扱う。 */
78
+ /** 左の並びの種類は先頭の字で決める。数字で始まる並び(3GB、10ms)は数字、英字で始まる並び(H30、v1.2)は英字。 */
78
79
  const leftRunBeside = (chars: readonly string[], last: number, japanese: string | undefined): SpacingKind | undefined => {
79
80
  const kind = latinBeside(japanese, chars[last], "after-digit");
80
- return kind === "letter" && DIGIT.test(runStart(chars, last) ?? "") ? "after-digit" : kind;
81
+ return kind === undefined ? undefined : (kindOf(runStart(chars, last), "after-digit") ?? kind);
81
82
  };
82
83
 
83
84
  /**
package/src/plugin.ts CHANGED
@@ -40,6 +40,8 @@ export type LexiconEntry = {
40
40
  readonly pattern: string;
41
41
  readonly weight?: number | undefined;
42
42
  readonly instead_of?: string | undefined;
43
+ /** 語が、かかる語のどちら側に立つか。語順が言語で違うものを語彙表が言う(範囲の「で」は前、"in" は後ろ)。 */
44
+ readonly position?: "before" | "after" | undefined;
43
45
  /** pattern を adapter が語に分けたもの。品詞が読めるときだけ core が入れる。語彙表を書く側は書かない。 */
44
46
  readonly tokens?: readonly Token[] | undefined;
45
47
  };
@@ -344,6 +346,8 @@ export type RuleDefinition = {
344
346
  readonly by_genre: Readonly<Record<string, LevelTable>>;
345
347
  readonly how_to_find: string;
346
348
  readonly word_list: string | undefined;
349
+ /** word_list のほかに detector が名前で引く語彙表。どれかが無い言語では rule を動かさない。 */
350
+ readonly extra_word_lists: readonly string[];
347
351
  /** L4 のみ。LLM に渡す決まり。言語別。 */
348
352
  readonly what_to_check: Localized | undefined;
349
353
  readonly where: string | undefined;
package/src/rule-load.ts CHANGED
@@ -99,6 +99,7 @@ const toRule = (raw: unknown, language: string, file: string): RuleDefinition =>
99
99
  by_genre: genreTables(raw["by_genre"], language),
100
100
  how_to_find: String(raw["how_to_find"]),
101
101
  word_list: typeof raw["word_list"] === "string" ? raw["word_list"] : undefined,
102
+ extra_word_lists: stringList(raw["extra_word_lists"]) ?? [],
102
103
  what_to_check: isLocalized(raw["what_to_check"]) ? raw["what_to_check"] : undefined,
103
104
  where: typeof raw["where"] === "string" ? raw["where"] : undefined,
104
105
  requires: stringList(raw["requires"]) ?? [],
package/src/run.ts CHANGED
@@ -4,6 +4,7 @@ import type { AdapterNeeds, Finding, Level, ProseDocument, RuleDefinition } from
4
4
  import { lineStarts, placeOf } from "./position.ts";
5
5
  import { REASONS, type Reasons } from "./reasons.ts";
6
6
  import { joinWords } from "./detectors/word-list.ts";
7
+ import { missingList } from "./declared-lists.ts";
7
8
  import { unreadStructure, type Unread } from "./structure/unread.ts";
8
9
  import { isMarkdownPath } from "./structure/markdown-path.ts";
9
10
  import { maskSpans } from "./mask.ts";
@@ -181,15 +182,15 @@ export const runRules = (
181
182
  const detector = DETECTORS[rule.how_to_find];
182
183
  if (detector === undefined)
183
184
  return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: reasonsFor(doc).noDetector(rule.how_to_find) }] };
185
+ // 語彙表を要求する rule で、その言語に語彙表が無ければ動かせない。黙って通さない。
186
+ const absent = missingList(rule, doc.lexicons);
187
+ if (absent !== undefined)
188
+ return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: reasonsFor(doc).noLexicon(doc.language, absent) }] };
184
189
  const options = {
185
190
  limit: limitFor(rule, level, genre, limits),
186
191
  lexicon: rule.word_list === undefined ? undefined : doc.lexicons[rule.word_list],
187
192
  where: rule.where,
188
193
  };
189
- // 語彙表を要求する rule で、その言語に語彙表が無ければ動かせない。黙って通さない。
190
- if (rule.word_list !== undefined && options.lexicon === undefined) {
191
- return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: reasonsFor(doc).noLexicon(doc.language, rule.word_list) }] };
192
- }
193
194
  const found = detector(doc, options).map((finding) => place(starts, { ...finding, rule: rule.id, severity: rule.severity }));
194
195
  return { findings: [...acc.findings, ...found], skipped: acc.skipped };
195
196
  },