chaffjs 0.10.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 (198) hide show
  1. package/dist/cli-text.d.ts.map +1 -1
  2. package/dist/cli-text.js +2 -0
  3. package/dist/cli-text.js.map +1 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +5 -1
  6. package/dist/cli.js.map +1 -1
  7. package/dist/date-stamp.d.ts +14 -0
  8. package/dist/date-stamp.d.ts.map +1 -0
  9. package/dist/date-stamp.js +40 -0
  10. package/dist/date-stamp.js.map +1 -0
  11. package/dist/declared-lists.d.ts +4 -0
  12. package/dist/declared-lists.d.ts.map +1 -0
  13. package/dist/declared-lists.js +3 -0
  14. package/dist/declared-lists.js.map +1 -0
  15. package/dist/detectors/acronym-context.d.ts +13 -0
  16. package/dist/detectors/acronym-context.d.ts.map +1 -0
  17. package/dist/detectors/acronym-context.js +42 -0
  18. package/dist/detectors/acronym-context.js.map +1 -0
  19. package/dist/detectors/acronym-expansion.d.ts +9 -0
  20. package/dist/detectors/acronym-expansion.d.ts.map +1 -0
  21. package/dist/detectors/acronym-expansion.js +67 -0
  22. package/dist/detectors/acronym-expansion.js.map +1 -0
  23. package/dist/detectors/char-shape.d.ts.map +1 -1
  24. package/dist/detectors/char-shape.js +34 -2
  25. package/dist/detectors/char-shape.js.map +1 -1
  26. package/dist/detectors/doubled-word.d.ts +27 -0
  27. package/dist/detectors/doubled-word.d.ts.map +1 -0
  28. package/dist/detectors/doubled-word.js +94 -0
  29. package/dist/detectors/doubled-word.js.map +1 -0
  30. package/dist/detectors/en-shape.d.ts.map +1 -1
  31. package/dist/detectors/en-shape.js +110 -9
  32. package/dist/detectors/en-shape.js.map +1 -1
  33. package/dist/detectors/gram-place.d.ts +14 -0
  34. package/dist/detectors/gram-place.d.ts.map +1 -0
  35. package/dist/detectors/gram-place.js +39 -0
  36. package/dist/detectors/gram-place.js.map +1 -0
  37. package/dist/detectors/heading-echo.d.ts.map +1 -1
  38. package/dist/detectors/heading-echo.js +22 -12
  39. package/dist/detectors/heading-echo.js.map +1 -1
  40. package/dist/detectors/index.d.ts.map +1 -1
  41. package/dist/detectors/index.js +2 -0
  42. package/dist/detectors/index.js.map +1 -1
  43. package/dist/detectors/lead-in.d.ts +8 -0
  44. package/dist/detectors/lead-in.d.ts.map +1 -0
  45. package/dist/detectors/lead-in.js +13 -0
  46. package/dist/detectors/lead-in.js.map +1 -0
  47. package/dist/detectors/lexicon-match.d.ts +15 -0
  48. package/dist/detectors/lexicon-match.d.ts.map +1 -0
  49. package/dist/detectors/lexicon-match.js +48 -0
  50. package/dist/detectors/lexicon-match.js.map +1 -0
  51. package/dist/detectors/lexicon.d.ts +5 -0
  52. package/dist/detectors/lexicon.d.ts.map +1 -1
  53. package/dist/detectors/lexicon.js +32 -22
  54. package/dist/detectors/lexicon.js.map +1 -1
  55. package/dist/detectors/phrase-match.d.ts.map +1 -1
  56. package/dist/detectors/phrase-match.js +7 -6
  57. package/dist/detectors/phrase-match.js.map +1 -1
  58. package/dist/detectors/repeated-head.d.ts.map +1 -1
  59. package/dist/detectors/repeated-head.js +15 -5
  60. package/dist/detectors/repeated-head.js.map +1 -1
  61. package/dist/detectors/sentence-ending.d.ts.map +1 -1
  62. package/dist/detectors/sentence-ending.js +7 -3
  63. package/dist/detectors/sentence-ending.js.map +1 -1
  64. package/dist/detectors/signals.d.ts.map +1 -1
  65. package/dist/detectors/signals.js +97 -59
  66. package/dist/detectors/signals.js.map +1 -1
  67. package/dist/detectors/structure.d.ts.map +1 -1
  68. package/dist/detectors/structure.js +14 -1
  69. package/dist/detectors/structure.js.map +1 -1
  70. package/dist/detectors/superlative-scope.d.ts +16 -0
  71. package/dist/detectors/superlative-scope.d.ts.map +1 -0
  72. package/dist/detectors/superlative-scope.js +40 -0
  73. package/dist/detectors/superlative-scope.js.map +1 -0
  74. package/dist/document.d.ts.map +1 -1
  75. package/dist/document.js +38 -19
  76. package/dist/document.js.map +1 -1
  77. package/dist/eval.d.ts.map +1 -1
  78. package/dist/eval.js +3 -2
  79. package/dist/eval.js.map +1 -1
  80. package/dist/heading-title.d.ts +6 -0
  81. package/dist/heading-title.d.ts.map +1 -0
  82. package/dist/heading-title.js +8 -0
  83. package/dist/heading-title.js.map +1 -0
  84. package/dist/in-page-nav.d.ts +19 -0
  85. package/dist/in-page-nav.d.ts.map +1 -0
  86. package/dist/in-page-nav.js +44 -0
  87. package/dist/in-page-nav.js.map +1 -0
  88. package/dist/lexicon-tokens.d.ts +4 -0
  89. package/dist/lexicon-tokens.d.ts.map +1 -0
  90. package/dist/lexicon-tokens.js +23 -0
  91. package/dist/lexicon-tokens.js.map +1 -0
  92. package/dist/orthography.d.ts +1 -0
  93. package/dist/orthography.d.ts.map +1 -1
  94. package/dist/orthography.js +4 -3
  95. package/dist/orthography.js.map +1 -1
  96. package/dist/page-furniture.d.ts +10 -0
  97. package/dist/page-furniture.d.ts.map +1 -0
  98. package/dist/page-furniture.js +35 -0
  99. package/dist/page-furniture.js.map +1 -0
  100. package/dist/plugin.d.ts +8 -0
  101. package/dist/plugin.d.ts.map +1 -1
  102. package/dist/render/compact.d.ts.map +1 -1
  103. package/dist/render/compact.js +11 -2
  104. package/dist/render/compact.js.map +1 -1
  105. package/dist/rule-load.d.ts.map +1 -1
  106. package/dist/rule-load.js +2 -0
  107. package/dist/rule-load.js.map +1 -1
  108. package/dist/run.d.ts.map +1 -1
  109. package/dist/run.js +10 -7
  110. package/dist/run.js.map +1 -1
  111. package/dist/sentence-shape.d.ts +6 -0
  112. package/dist/sentence-shape.d.ts.map +1 -1
  113. package/dist/sentence-shape.js +15 -0
  114. package/dist/sentence-shape.js.map +1 -1
  115. package/dist/structure/bare-numbers.d.ts +14 -0
  116. package/dist/structure/bare-numbers.d.ts.map +1 -0
  117. package/dist/structure/bare-numbers.js +53 -0
  118. package/dist/structure/bare-numbers.js.map +1 -0
  119. package/dist/structure/build.d.ts +2 -0
  120. package/dist/structure/build.d.ts.map +1 -1
  121. package/dist/structure/build.js +6 -3
  122. package/dist/structure/build.js.map +1 -1
  123. package/dist/structure/issues.d.ts.map +1 -1
  124. package/dist/structure/issues.js +11 -2
  125. package/dist/structure/issues.js.map +1 -1
  126. package/dist/structure/of.d.ts.map +1 -1
  127. package/dist/structure/of.js +4 -2
  128. package/dist/structure/of.js.map +1 -1
  129. package/dist/structure/total.d.ts +1 -0
  130. package/dist/structure/total.d.ts.map +1 -1
  131. package/dist/structure/total.js +37 -13
  132. package/dist/structure/total.js.map +1 -1
  133. package/dist/structure/universal.d.ts +2 -2
  134. package/dist/structure/universal.d.ts.map +1 -1
  135. package/dist/structure/universal.js +13 -4
  136. package/dist/structure/universal.js.map +1 -1
  137. package/dist/version.d.ts +5 -0
  138. package/dist/version.d.ts.map +1 -1
  139. package/dist/version.js +17 -2
  140. package/dist/version.js.map +1 -1
  141. package/package.json +3 -3
  142. package/rules/ai-tell.yaml +1 -0
  143. package/rules/closing-cliche.yaml +1 -0
  144. package/rules/concrete-evidence-density.yaml +2 -0
  145. package/rules/cushion-phrase-density.yaml +1 -0
  146. package/rules/double-keigo.yaml +1 -0
  147. package/rules/doubled-word.yaml +27 -0
  148. package/rules/empty-intensifier.yaml +1 -0
  149. package/rules/excessive-hedging.yaml +1 -0
  150. package/rules/hiragana-fukushi.yaml +1 -0
  151. package/rules/latin-spacing.yaml +2 -2
  152. package/rules/max-kanji-continuous.yaml +2 -0
  153. package/rules/ngram-repetition.yaml +2 -0
  154. package/rules/padded-intro.yaml +1 -0
  155. package/rules/repeated-conjunction.yaml +1 -0
  156. package/rules/sasete-itadaku.yaml +1 -0
  157. package/rules/undefined-acronym.yaml +1 -0
  158. package/rules/unqualified-superlative.yaml +2 -0
  159. package/src/cli-text.ts +2 -0
  160. package/src/cli.ts +5 -1
  161. package/src/date-stamp.ts +65 -0
  162. package/src/declared-lists.ts +5 -0
  163. package/src/detectors/acronym-context.ts +53 -0
  164. package/src/detectors/acronym-expansion.ts +81 -0
  165. package/src/detectors/char-shape.ts +38 -3
  166. package/src/detectors/doubled-word.ts +128 -0
  167. package/src/detectors/en-shape.ts +138 -7
  168. package/src/detectors/gram-place.ts +41 -0
  169. package/src/detectors/heading-echo.ts +13 -3
  170. package/src/detectors/index.ts +2 -0
  171. package/src/detectors/lead-in.ts +14 -0
  172. package/src/detectors/lexicon-match.ts +54 -0
  173. package/src/detectors/lexicon.ts +44 -25
  174. package/src/detectors/phrase-match.ts +8 -7
  175. package/src/detectors/repeated-head.ts +21 -7
  176. package/src/detectors/sentence-ending.ts +7 -3
  177. package/src/detectors/signals.ts +122 -60
  178. package/src/detectors/structure.ts +21 -2
  179. package/src/detectors/superlative-scope.ts +66 -0
  180. package/src/document.ts +43 -19
  181. package/src/eval.ts +2 -1
  182. package/src/heading-title.ts +8 -0
  183. package/src/in-page-nav.ts +67 -0
  184. package/src/lexicon-tokens.ts +28 -0
  185. package/src/orthography.ts +4 -3
  186. package/src/page-furniture.ts +45 -0
  187. package/src/plugin.ts +13 -1
  188. package/src/render/compact.ts +12 -2
  189. package/src/rule-load.ts +2 -0
  190. package/src/run.ts +11 -7
  191. package/src/sentence-shape.ts +16 -0
  192. package/src/structure/bare-numbers.ts +78 -0
  193. package/src/structure/build.ts +9 -3
  194. package/src/structure/issues.ts +16 -2
  195. package/src/structure/of.ts +4 -2
  196. package/src/structure/total.ts +45 -19
  197. package/src/structure/universal.ts +14 -3
  198. package/src/version.ts +21 -3
@@ -0,0 +1,65 @@
1
+ import type { Span } from "./plugin.ts";
2
+
3
+ /** 段落 1 つ。dates は段落の中の日付の位置で、段落の先頭から数える。 */
4
+ export type StampCandidate = { readonly text: string; readonly dates: readonly Span[] };
5
+
6
+ /** 段落ごとの候補。dates は文書の中の日付の位置で、段落の中にすっかり収まるものだけを段落の先頭からの位置にする。 */
7
+ export const stampCandidates = (source: string, paragraphs: readonly Span[], dates: readonly Span[]): StampCandidate[] =>
8
+ paragraphs.map((paragraph) => ({
9
+ text: source.slice(paragraph.start, paragraph.end),
10
+ dates: dates
11
+ .filter((date) => date.start >= paragraph.start && date.end <= paragraph.end)
12
+ .map((date) => ({ start: date.start - paragraph.start, end: date.end - paragraph.start })),
13
+ }));
14
+
15
+ /** 文字か数字。日付を除いてこれが 1 つも残らなければ、日付だけの段落。 */
16
+ const WORD = /[\p{L}\p{N}]/u;
17
+ const ENDS_WITH_COLON = /[::]\s*$/u;
18
+ const LABEL_EDGE = /[\s::]/u;
19
+
20
+ /** 幅の無い日付は日付として読まない。段落が日付だけかどうかを、何も覆わない位置で決めない。 */
21
+ const datesOf = (candidate: StampCandidate): Span[] => candidate.dates.filter((date) => date.end > date.start);
22
+
23
+ const withoutDates = (candidate: StampCandidate): string => {
24
+ const ordered = datesOf(candidate).sort((left, right) => left.start - right.start);
25
+ const gaps = ordered.reduce<{ readonly parts: readonly string[]; readonly from: number }>(
26
+ (acc, date) => ({ parts: [...acc.parts, candidate.text.slice(acc.from, date.start)], from: Math.max(acc.from, date.end) }),
27
+ { parts: [], from: 0 },
28
+ );
29
+ return [...gaps.parts, candidate.text.slice(gaps.from)].join(" ");
30
+ };
31
+
32
+ /** 前後の空白とコロンを外したラベル。「Last updated:」も「:最終更新日」も同じ語として引く。 */
33
+ const bareLabel = (text: string): string => {
34
+ const chars = [...text];
35
+ const first = chars.findIndex((char) => !LABEL_EDGE.test(char));
36
+ const last = chars.findLastIndex((char) => !LABEL_EDGE.test(char));
37
+ return first === -1 ? "" : chars.slice(first, last + 1).join("");
38
+ };
39
+
40
+ const isLabel = (text: string, labels: readonly string[]): boolean => {
41
+ const bare = bareLabel(text).toLowerCase();
42
+ return bare !== "" && labels.some((label) => label.toLowerCase() === bare);
43
+ };
44
+
45
+ const isDateOnly = (candidate: StampCandidate | undefined): boolean =>
46
+ candidate !== undefined && datesOf(candidate).length > 0 && !WORD.test(withoutDates(candidate));
47
+
48
+ /** 「Updated 2026-03-03」「最終更新日:2025年6月20日」。日付を除くと、語彙表のラベルしか残らない。 */
49
+ const isLabelledDate = (candidate: StampCandidate, labels: readonly string[]): boolean =>
50
+ datesOf(candidate).length > 0 && isLabel(withoutDates(candidate), labels);
51
+
52
+ /** 「最終更新日:」の段落のすぐ後に日付だけの段落。Web ページを写すと、ラベルと日付が別の段落に割れる。 */
53
+ const isLabelBeforeDate = (candidate: StampCandidate, next: StampCandidate | undefined, labels: readonly string[]): boolean =>
54
+ ENDS_WITH_COLON.test(candidate.text) && isLabel(candidate.text, labels) && isDateOnly(next);
55
+
56
+ /**
57
+ * 更新日・公開日の刻印になっている段落の位置。ページの付帯情報で、書いた人が読み手に読ませる前置きではない。
58
+ * ラベルの語は言語パッケージの語彙表(date-stamp-label)から渡す。文の中に日付があるだけの段落は刻印ではない。
59
+ */
60
+ export const dateStampIndexes = (candidates: readonly StampCandidate[], labels: readonly string[]): ReadonlySet<number> =>
61
+ new Set(
62
+ candidates.flatMap((candidate, index) =>
63
+ isDateOnly(candidate) || isLabelledDate(candidate, labels) || isLabelBeforeDate(candidate, candidates[index + 1], labels) ? [index] : [],
64
+ ),
65
+ );
@@ -0,0 +1,5 @@
1
+ import type { Lexicon, RuleDefinition } from "./plugin.ts";
2
+
3
+ /** rule が宣言した語彙表のうち、その言語に無い最初のもの。無いまま動かすと、照らす語が無いだけの 0 件や指摘が本物に見える。 */
4
+ export const missingList = (rule: RuleDefinition, lexicons: Readonly<Record<string, Lexicon>>): string | undefined =>
5
+ [...(rule.word_list === undefined ? [] : [rule.word_list]), ...rule.extra_word_lists].find((name) => lexicons[name] === undefined);
@@ -0,0 +1,53 @@
1
+ /**
2
+ * 大文字の語が略語ではなく、決まった書き方の一部として読める所(3:30 PM、1pm ET、USD 1,000、Kansas City, MO 64108)。
3
+ * どれも閉じた集合で、しかも隣の数字があるときだけ認める。PM(project manager)、CT、CA(certificate authority)は
4
+ * 数字の隣でなければ略語のまま数える。
5
+ */
6
+
7
+ type Span = { readonly start: number; readonly end: number };
8
+
9
+ const MERIDIEM = ["AM", "PM"];
10
+
11
+ /** 業務文書で説明なしに書かれる時間帯。ここに無いもの(ACT など)は略語として数える。 */
12
+ const TIME_ZONE = ["UTC", "GMT", "JST", "ET", "EST", "EDT", "CT", "CST", "CDT", "MT", "MST", "MDT", "PT", "PST", "PDT", "CET", "CEST"];
13
+
14
+ /** ISO 4217 のうち、業務文書の読み手が説明なしで通じる主要な通貨。CAD は数の隣でも computer-aided design と読める(3 CAD files)ので入れない。 */
15
+ const CURRENCY = ["USD", "EUR", "JPY", "GBP", "CNY", "CHF", "AUD", "KRW", "HKD", "SGD", "INR"];
16
+
17
+ /** 米国の州・特別区・準州の郵便略号。住所の書き方(, CA 94720)の中でだけ見る。 */
18
+ const US_STATE = [
19
+ ...["AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA", "HI", "ID", "IL", "IN", "IA", "KS", "KY"],
20
+ ...["LA", "ME", "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ", "NM", "NY", "NC", "ND"],
21
+ ...["OH", "OK", "OR", "PA", "RI", "SC", "SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI", "WY"],
22
+ ...["DC", "PR", "GU", "VI", "AS", "MP"],
23
+ ];
24
+
25
+ /**
26
+ * 略語として読む字面を持たない英語の機能語。単独の NOT や AND は強調で、展開するものが無い。
27
+ * OR(Oregon、operating room)や IS(International Standard)のように略語でもある語は入れない。
28
+ */
29
+ const PLAIN_WORD = ["NOT", "AND"];
30
+
31
+ const oneOf = (words: readonly string[]): string => `(?:${words.join("|")})`;
32
+
33
+ const MINUTES = String.raw`:[0-5]\d`;
34
+ const CLOCK_12 = String.raw`(?<![\d:.])(?:1[0-2]|0?[1-9])(?:${MINUTES})?`;
35
+ const CLOCK_24 = String.raw`(?<![\d:.])(?:[01]?\d|2[0-3])${MINUTES}`;
36
+ /** 時間帯の前の午前・午後は書き方を問わない(pm、p.m.、P.M.、PM)。 */
37
+ const ANY_MERIDIEM = String.raw`[AaPp]\.?[Mm]\.?`;
38
+ /** 桁区切りは 3 桁ずつ揃っているときだけ金額と読む。「item 1, USD」の 1, は金額ではない。 */
39
+ const AMOUNT = String.raw`(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?`;
40
+
41
+ // 強調の記号は空白に置き換えてある(**3:30** PM)ので、部品の間の空白は数を問わない。
42
+ const PATTERNS: readonly RegExp[] = [
43
+ new RegExp(String.raw`${CLOCK_12}\s*${oneOf(MERIDIEM)}`, "gu"),
44
+ new RegExp(String.raw`(?:${CLOCK_24}|${CLOCK_12}\s*${ANY_MERIDIEM})\s*${oneOf(TIME_ZONE)}`, "gu"),
45
+ new RegExp(String.raw`${oneOf(CURRENCY)}\s*[$€£¥]?${AMOUNT}`, "gu"),
46
+ new RegExp(String.raw`(?<![\w.,])${AMOUNT}\s*${oneOf(CURRENCY)}`, "gu"),
47
+ new RegExp(String.raw`,\s+${oneOf(US_STATE)}\s+\d{5}(?:-\d{4})?(?!\d)`, "gu"),
48
+ new RegExp(oneOf(PLAIN_WORD), "gu"),
49
+ ];
50
+
51
+ /** 文の中で、略語として数えない大文字を含む範囲。略語は範囲にまるごと入るときだけ外れるので、語の境目は見なくてよい(NOTE の中の NOT)。 */
52
+ export const notAcronymSpans = (text: string): Span[] =>
53
+ PATTERNS.flatMap((pattern) => [...text.matchAll(pattern)].map((match) => ({ start: match.index, end: match.index + match[0].length })));
@@ -0,0 +1,81 @@
1
+ import { escapeRegExp } from "../orthography.ts";
2
+
3
+ /**
4
+ * 展開は略語の**すぐ隣**にあるときだけ認める。
5
+ * 60 文字も見ると、同じ文のどこかに括弧があるだけで「説明済み」になり、1 件も出なくなる。
6
+ *
7
+ * 認めるのは 4 つの形。どれも実際によく書かれる。
8
+ * CI(継続的インテグレーション) 略語のあとに括弧
9
+ * Continuous Integration (CI) 括弧の中が略語
10
+ * Tax Cuts and Jobs Act [TCJA] 角括弧の中が略語で、直前の語の頭文字と揃う
11
+ * 人事部(以下「HR」という。) 括弧の中が定義の語と略語だけ
12
+ * 括弧と略語の間には、引用符((“MNDA”)、(「MNDA」))と空白だけを許す。
13
+ */
14
+ const WRAP = String.raw`[\s"“”'‘’「」『』]*`;
15
+ const OPENS = new RegExp(String.raw`^${WRAP}[((]`, "u");
16
+ const CLOSES = new RegExp(String.raw`^${WRAP}[))]`, "u");
17
+ const OPENED = new RegExp(String.raw`[((]${WRAP}$`, "u");
18
+ const SQUARE_CLOSES = new RegExp(String.raw`^${WRAP}\]`, "u");
19
+ const SQUARE_OPENED = new RegExp(String.raw`\[${WRAP}$`, "u");
20
+
21
+ /** 括弧と略語の間の幅。空白は 1 つに畳んであるので、(“ MNDA ”) まで収まる。 */
22
+ const NEAR = 3;
23
+
24
+ /** 定義の語を挟む形で、括弧から略語まで・略語から括弧までに見る幅。(hereinafter referred to as “SLA”) が収まる。 */
25
+ const DEFINITION_REACH = 60;
26
+
27
+ /** 定義の語どうし、定義の語と略語の間に来てよいもの(以下、HR)。英字が続けば語の途中((theSLA))。 */
28
+ const MARKER_GAP = String.raw`(?![A-Za-z])[\s、,]*`;
29
+
30
+ /**
31
+ * 角括弧は引用の印にも使う([IANA]、[1])。直前の語のうち大文字で始まる語の頭文字が
32
+ * 略語と揃うときだけ展開と見なす。見る語は略語の文字数の 2 倍まで(and や of を挟むため)。
33
+ */
34
+ const spellsOut = (before: string, acronym: string): boolean => {
35
+ const letters = acronym.replaceAll("&", "");
36
+ const words = before
37
+ .replace(SQUARE_OPENED, "")
38
+ .trim()
39
+ .split(/\s+/u)
40
+ .slice(-letters.length * 2);
41
+ const initials = words.filter((word) => /^[A-Z]/u.test(word)).map((word) => word.charAt(0));
42
+ return initials.join("").endsWith(letters);
43
+ };
44
+
45
+ /** 言語パッケージの語彙表から読む、括弧の中で略語の前に書く語(以下、hereinafter)と後ろに書く語(という)。 */
46
+ export type DefinitionWords = { readonly markers: readonly string[]; readonly verbs: readonly string[] };
47
+
48
+ type DefinitionPatterns = { readonly opened: RegExp; readonly closes: RegExp };
49
+
50
+ const oneOf = (words: readonly string[]): string => (words.length === 0 ? "(?!)" : words.map(escapeRegExp).join("|"));
51
+
52
+ const definitionPatterns = (words: DefinitionWords): DefinitionPatterns => ({
53
+ opened: new RegExp(String.raw`[((]\s*(?<markers>(?:(?:${oneOf(words.markers)})${MARKER_GAP})*)${WRAP}(?<![A-Za-z])$`, "iu"),
54
+ closes: new RegExp(String.raw`^${WRAP}(?<verb>(?:${oneOf(words.verbs)})[。.]?)?${WRAP}[))]`, "iu"),
55
+ });
56
+
57
+ /**
58
+ * 括弧の中に定義の語と略語だけがある形。語が 1 つも無い(CI)は OPENED と CLOSES が見るので、ここでは数えない。
59
+ * 括弧の中身が語と略語で尽きることを求めるので、ふつうの意味の「以下」((以下のSRE手順))は定義にならない。
60
+ */
61
+ const isDefinedAt = (patterns: DefinitionPatterns, body: string, acronym: string, at: number): boolean => {
62
+ const opened = patterns.opened.exec(body.slice(Math.max(0, at - DEFINITION_REACH), at));
63
+ const closes = patterns.closes.exec(body.slice(at + acronym.length, at + acronym.length + DEFINITION_REACH));
64
+ if (opened === null || closes === null) return false;
65
+ return (opened.groups?.["markers"] ?? "") !== "" || (closes.groups?.["verb"] ?? "") !== "";
66
+ };
67
+
68
+ const isBracketedAt = (body: string, acronym: string, at: number): boolean => {
69
+ const after = body.slice(at + acronym.length, at + acronym.length + NEAR);
70
+ const before = body.slice(Math.max(0, at - NEAR), at);
71
+ if (OPENS.test(after) || (OPENED.test(before) && CLOSES.test(after))) return true;
72
+ return SQUARE_OPENED.test(before) && SQUARE_CLOSES.test(after) && spellsOut(body.slice(0, at), acronym);
73
+ };
74
+
75
+ export type ExpandedAt = (body: string, acronym: string, at: number) => boolean;
76
+
77
+ /** body の at にある略語が、その場で展開・定義されているか。語彙表から一度だけ組み立てて、略語ごとに呼ぶ。 */
78
+ export const expansionAt = (words: DefinitionWords): ExpandedAt => {
79
+ const patterns = definitionPatterns(words);
80
+ return (body, acronym, at) => isBracketedAt(body, acronym, at) || isDefinedAt(patterns, body, acronym, at);
81
+ };
@@ -1,15 +1,50 @@
1
- import type { Detector, DocumentProfile, Finding, Sentence } from "../plugin.ts";
1
+ import type { Detector, DocumentProfile, Finding, Sentence, Token } from "../plugin.ts";
2
+ import { compacted, placeOf } from "./gram-place.ts";
2
3
  import { maskAddresses } from "../address-chain.ts";
3
4
  import { proseText } from "../measure.ts";
4
5
 
5
6
  /**
6
- * 文字の並びだけを見る検出。品詞も語彙表も要らない。
7
+ * 文字の並びを見る検出。漢字の連なりが住所かどうかだけは、形態素解析の地名と数で決める。
7
8
  * 覆った箇所は空白になっているので、proseText で潰してから数える。
8
9
  */
9
10
  const KANJI_RUN = /[一-鿿]+/gu;
10
11
 
12
+ /** 地名(Geo)と、地名に付く単位(GeoUnit: 都・県・市・区・町)。 */
13
+ const isGeoName = (token: Token | undefined): boolean => token?.features?.["NameType"] === "Geo";
14
+ const isGeo = (token: Token | undefined): boolean => isGeoName(token) || token?.features?.["NameType"] === "GeoUnit";
15
+ const isNumber = (token: Token | undefined): boolean => token?.features?.["NumType"] === "Card";
16
+
17
+ /**
18
+ * 住所の語: 地名と地名の単位(NameType=Geo / GeoUnit)、数(NumType=Card)、数のすぐ後ろの助数詞(丁目、NounType=Class)。
19
+ * 辞書に無い町名(新橋)は、地名の後ろで数の前に来るときだけ住所の一部とする。
20
+ */
21
+ const isPlacePart = (tokens: readonly Token[], index: number): boolean =>
22
+ isGeo(tokens[index]) ||
23
+ isNumber(tokens[index]) ||
24
+ (isNumber(tokens[index - 1]) && tokens[index]?.features?.["NounType"] === "Class") ||
25
+ (isGeo(tokens[index - 1]) && isNumber(tokens[index + 1]));
26
+
27
+ /**
28
+ * 漢字の連なりが住所か(東京都港区新橋二丁目)。形態素解析が地名と読んだ語で始まり、住所の語だけでできている。
29
+ * 住所は決まった形で、ひらがなを挟んで書けない。組織名(日本経済団体連合会)は地名の後ろに普通の語が続くので住所ではない。品詞が無ければ判定しない。
30
+ */
31
+ const isPlaceName = (sentence: Sentence, run: string): boolean => {
32
+ const tokens = sentence.tokens;
33
+ const place = tokens === undefined ? undefined : placeOf(compacted(sentence.text, "word"), run);
34
+ if (tokens === undefined || place === undefined) return false;
35
+ const [start, end] = [sentence.span.start + place.start, sentence.span.start + place.end];
36
+ const covering = tokens.flatMap((token, index) => (token.span.start < end && start < token.span.end ? [index] : []));
37
+ const first = covering[0];
38
+ // 地名が単位を挟まずに続けば(東京大阪名古屋福岡)、住所ではなく地名の並び。
39
+ const listed = covering.some((index) => isGeoName(tokens[index]) && isGeoName(tokens[index + 1]));
40
+ return first !== undefined && isGeo(tokens[first]) && !listed && covering.every((index) => isPlacePart(tokens, index));
41
+ };
42
+
11
43
  const longestKanji = (sentence: Sentence, profile: DocumentProfile | undefined): string =>
12
- [...maskAddresses(proseText(sentence), profile).matchAll(KANJI_RUN)].reduce((longest, match) => (match[0].length > longest.length ? match[0] : longest), "");
44
+ [...maskAddresses(proseText(sentence), profile).matchAll(KANJI_RUN)]
45
+ .map((match) => match[0])
46
+ .filter((run) => !isPlaceName(sentence, run))
47
+ .reduce((longest, run) => (run.length > longest.length ? run : longest), "");
13
48
 
14
49
  /**
15
50
  * 漢字が続くと、どこで語が切れるのか読み手が探すことになる。
@@ -0,0 +1,128 @@
1
+ import type { Detector, Finding, Lexicon, Sentence, Token } from "../plugin.ts";
2
+
3
+ /**
4
+ * 語を書き損じて二度書いた(the the / 資料をを)。消し忘れと、書き換えの途中で残った語(our the platform)の二つ。
5
+ *
6
+ * 同じ語は、表層(大文字小文字は問わない)と品詞の両方が同じときだけ。「that that」は接続詞と指示語で品詞が違う。
7
+ * 冠詞と所有の語は名詞の前に一つしか立たないので、違う語でも二つ並べば書き損じ。印はアダプタが UD の FEATS で付ける。
8
+ * どの言語でも書き損じでない重なり(had had / 一つ一つ)は、言語ごとの語彙表に置く。
9
+ */
10
+
11
+ /**
12
+ * 重ねても書き損じにならない品詞。記号と数は語ではない。間投詞(はいはい / no no)と副詞(そうそう / very very)は
13
+ * 重ねて強める書き方がどの語でもできるので、語彙表では尽くせない。固有名詞は重なった名前(Walla Walla)がある。
14
+ */
15
+ const REPEATABLE = new Set(["PUNCT", "SYM", "NUM", "X", "INTJ", "ADV", "PROPN"]);
16
+
17
+ /** 強調やリンクの印を挟んで重なった内容語は、画面名とその名詞(**app settings** settings screen)。機能語だけが書き損じ。 */
18
+ const FUNCTION_WORD = new Set(["DET", "ADP", "PRON", "AUX", "CCONJ", "SCONJ", "PART"]);
19
+
20
+ const LETTER = /\p{L}/u;
21
+ const LETTER_OR_DIGIT = /[\p{L}\p{N}]/u;
22
+ const SPACE_ONLY = /^\s*$/u;
23
+ /** 語を空白で区切る言語で、語に接していれば長い語の一部(114A The の A、Content-Type の Content)。 */
24
+ const JOINED = /[\p{L}\p{N}-]/u;
25
+
26
+ const isWord = (token: Token): boolean => LETTER.test(token.surface) && !REPEATABLE.has(token.pos) && token.features?.["NumType"] !== "Card";
27
+
28
+ const isDeterminer = (token: Token): boolean => token.features?.["PronType"] === "Art" || token.features?.["Poss"] === "Yes";
29
+
30
+ const sameWord = (first: Token, second: Token): boolean => first.surface.toLowerCase() === second.surface.toLowerCase() && first.pos === second.pos;
31
+
32
+ /** 二語のあいだ。space は空白だけ、markup は印だけ(文字も数字も無い)、text はそれ以外で、別々の語。 */
33
+ export type Gap = "space" | "markup" | "text";
34
+
35
+ export const gapBetween = (source: string, first: Token, second: Token): Gap => {
36
+ const between = source.slice(first.span.end, second.span.start);
37
+ if (SPACE_ONLY.test(between)) return "space";
38
+ return LETTER_OR_DIGIT.test(between) ? "text" : "markup";
39
+ };
40
+
41
+ /** 語を空白で区切る言語だけ。日本語は語どうしが接しているのが普通。 */
42
+ export const isPartOfLongerWord = (source: string, first: Token, second: Token): boolean =>
43
+ JOINED.test(source.charAt(first.span.start - 1)) || JOINED.test(source.charAt(second.span.end));
44
+
45
+ const surfacesOf = (tokens: readonly Token[]): string => tokens.map((token) => token.surface.toLowerCase()).join("\u0000");
46
+
47
+ /**
48
+ * 語彙表の行(had had、sign in in、set up up to)の k 語目と k+1 語目を重なりに重ねて、行の語が全部並んでいるか。
49
+ * 一語の行は重なりを言っていないので、重ねる位置が無い。
50
+ */
51
+ const coversPairAt = (tokens: readonly Token[], at: number, words: readonly Token[]): boolean =>
52
+ Array.from({ length: words.length - 1 }, (_, k) => tokens.slice(at - k, at - k + words.length)).some((row) => surfacesOf(row) === surfacesOf(words));
53
+
54
+ /**
55
+ * tokens の at 語目と次の語の重なりを、語彙表のどれかの行がそのまま含むか。
56
+ * 語彙表の語は、文と同じ解析器で分けたもの(entry.tokens)で比べる。書き方の空白の有無に左右されない。
57
+ */
58
+ export const isAllowed = (tokens: readonly Token[], at: number, allowed: Lexicon): boolean =>
59
+ allowed.some((entry) => coversPairAt(tokens, at, entry.tokens ?? []));
60
+
61
+ export type Doubled = { readonly first: Token; readonly second: Token };
62
+
63
+ const LOWER_START = /^\p{Ll}/u;
64
+ const UPPER_START = /^\p{Lu}/u;
65
+
66
+ /** 小文字の語の後ろの大文字は、題名や名前の書き出し(the [Your Rights section])。同じ語の the The は書き損じのまま。 */
67
+ export const startsTitle = (first: Token, second: Token): boolean => LOWER_START.test(first.surface) && UPPER_START.test(second.surface);
68
+
69
+ /** アダプタが重ね言葉(UD の Echo=Rdp)と読んだ語。「会社会社で」の二つ目。 */
70
+ const isEcho = (token: Token): boolean => token.features?.["Echo"] === "Rdp";
71
+
72
+ const isDeterminerPair = (first: Token, second: Token): boolean => isDeterminer(first) && isDeterminer(second) && !startsTitle(first, second);
73
+
74
+ const isDoubled = (first: Token, second: Token): boolean =>
75
+ isWord(first) && isWord(second) && !isEcho(second) && (sameWord(first, second) || isDeterminerPair(first, second));
76
+
77
+ /** 並んだ二語が書き損じか。source は文書全体で、token の span もその座標。 */
78
+ export const doubledAt = (source: string, first: Token, second: Token, spaced: boolean): boolean => {
79
+ if (!isDoubled(first, second)) return false;
80
+ if (spaced && isPartOfLongerWord(source, first, second)) return false;
81
+ const gap = gapBetween(source, first, second);
82
+ return gap === "space" || (gap === "markup" && FUNCTION_WORD.has(first.pos));
83
+ };
84
+
85
+ /**
86
+ * 文の途中で、大文字の語の後ろに小文字の同じ語が来たら、一つ目は名前(Payment for May may be delayed)。
87
+ * 文頭の The the と、冠詞・所有の語(review The the draft)は名前にならないので書き損じ。
88
+ */
89
+ export const isNameBefore = (first: Token, second: Token, opensSentence: boolean): boolean =>
90
+ !opensSentence && !isDeterminer(first) && UPPER_START.test(first.surface) && LOWER_START.test(second.surface);
91
+
92
+ const opensSentence = (tokens: readonly Token[], at: number): boolean => !tokens.slice(0, at).some((token) => LETTER.test(token.surface));
93
+
94
+ const repeatsPrevious = (tokens: readonly Token[], at: number): boolean => {
95
+ const [previous, token] = [tokens[at - 1], tokens[at]];
96
+ return previous !== undefined && token !== undefined && surfacesOf([previous]) === surfacesOf([token]);
97
+ };
98
+
99
+ /** 語彙表の重なり(had had)も、三つ続けば(had had had)書き損じ。 */
100
+ const allowedAt = (tokens: readonly Token[], at: number, allowed: Lexicon): Lexicon => (repeatsPrevious(tokens, at) ? [] : allowed);
101
+
102
+ export const doubledIn = (source: string, tokens: readonly Token[], spaced: boolean, allowed: Lexicon): Doubled[] =>
103
+ tokens.flatMap((second, index) => {
104
+ const first = tokens[index - 1];
105
+ if (first === undefined || !doubledAt(source, first, second, spaced)) return [];
106
+ if (isAllowed(tokens, index - 1, allowedAt(tokens, index - 1, allowed))) return [];
107
+ return isNameBefore(first, second, opensSentence(tokens, index - 1)) ? [] : [{ first, second }];
108
+ });
109
+
110
+ /** 印を挟んでいても(the [the)、見せるのは二語だけ。 */
111
+ const wordOf = (doubled: Doubled, spaced: boolean): string => [doubled.first.surface, doubled.second.surface].join(spaced ? " " : "");
112
+
113
+ const findingOf = (sentence: Sentence, doubled: Doubled, spaced: boolean): Finding => ({
114
+ rule: "doubled-word",
115
+ severity: "warning",
116
+ line: 0,
117
+ column: 0,
118
+ quote: sentence.text.trim(),
119
+ values: { word: wordOf(doubled, spaced), offset: doubled.second.span.start },
120
+ });
121
+
122
+ export const doubledWord: Detector = (doc, options): Finding[] => {
123
+ const spaced = doc.lengthUnit === "word";
124
+ const allowed = options.lexicon ?? [];
125
+ return doc.sentences.flatMap((sentence) =>
126
+ doubledIn(doc.source, sentence.tokens ?? [], spaced, allowed).map((doubled) => findingOf(sentence, doubled, spaced)),
127
+ );
128
+ };
@@ -150,6 +150,139 @@ export const titleCaseMix: Detector = (doc, options): Finding[] => {
150
150
 
151
151
  const LIST_CONJUNCTION = new Set(["and", "or"]);
152
152
 
153
+ const isComma = (token: Token): boolean => token.surface === ",";
154
+
155
+ const commaBefore = (tokens: readonly Token[], at: number): boolean => tokens[at - 1]?.surface === ",";
156
+
157
+ /** 並列はこれをまたがない。セミコロンの前後は別の節。 */
158
+ const CLAUSE_BREAK = new Set([";", ":", "—"]);
159
+
160
+ /** 各 token の前で閉じていない括弧の数。括弧の中の読点(external users (e.g., guests), and ...)は外の並列を切らない。 */
161
+ const PAREN_STEP: Readonly<Record<string, number>> = { "(": 1, ")": -1 };
162
+
163
+ const depthsOf = (tokens: readonly Token[]): number[] =>
164
+ tokens.reduce<{ depths: number[]; open: number }>(
165
+ (acc, token) => ({ depths: [...acc.depths, acc.open], open: Math.max(0, acc.open + (PAREN_STEP[token.surface] ?? 0)) }),
166
+ { depths: [], open: 0 },
167
+ ).depths;
168
+
169
+ type Clause = { readonly tokens: readonly Token[]; readonly depths: readonly number[] };
170
+
171
+ /** 冠詞や引用符を飛ばした、項目の頭の品詞。the parser と an exporter と samples を同じ形と見る。 */
172
+ const NOMINAL = new Set(["NOUN", "PROPN", "PRON", "NUM", "ADJ"]);
173
+
174
+ const isContent = (token: Token): boolean => token.pos !== "DET" && token.pos !== "PUNCT" && token.pos !== "X";
175
+
176
+ const shapeOf = (item: readonly Token[]): string | undefined => {
177
+ const head = item.find(isContent);
178
+ if (head === undefined) return undefined;
179
+ return NOMINAL.has(head.pos) ? "NOMINAL" : head.pos;
180
+ };
181
+
182
+ const VERBAL = new Set(["VERB", "AUX"]);
183
+
184
+ /** 主語と述語のある項目。the team fixed the bug / these are crucial。gets us more は述語だけ。 */
185
+ const isClause = (item: readonly Token[]): boolean => {
186
+ const first = item.find((token) => token.pos !== "PUNCT" && token.pos !== "X");
187
+ if (first === undefined || !(first.pos === "DET" || NOMINAL.has(first.pos))) return false;
188
+ return item.some((token) => token !== first && VERBAL.has(token.pos));
189
+ };
190
+
191
+ /** 節の頭から and / or の手前までを、同じ深さの読点で項目に切る。Oxford comma の読点のあとは空なので項目にならない。 */
192
+ const itemsBefore = (clause: Clause, at: number): Token[][] => {
193
+ const level = clause.depths[at] ?? 0;
194
+ const start = clause.tokens.slice(0, at).findLastIndex((token) => CLAUSE_BREAK.has(token.surface)) + 1;
195
+ return clause.tokens
196
+ .slice(start, at)
197
+ .reduce<Token[][]>(
198
+ (items, token, offset) => {
199
+ if (isComma(token) && clause.depths[start + offset] === level) return [...items, []];
200
+ return [...items.slice(0, -1), [...(items.at(-1) ?? []), token]];
201
+ },
202
+ [[]],
203
+ )
204
+ .filter((item) => item.length > 0);
205
+ };
206
+
207
+ /** and / or の後ろの項目。次の読点か節の切れ目まで。 */
208
+ const itemAfter = (tokens: readonly Token[], at: number): Token[] => {
209
+ const end = tokens.findIndex((token, index) => index > at && (isComma(token) || CLAUSE_BREAK.has(token.surface)));
210
+ return tokens.slice(at + 1, end === -1 ? undefined : end);
211
+ };
212
+
213
+ /**
214
+ * 導入の句(After the review, / If it fails, / Finally, / To test it, / Based on the review,)は並列の項目ではない。
215
+ * 節の最初の項目が前置詞・接続詞・副詞・to・過去分詞で始まり、次の項目と頭の形が違えば外す。
216
+ * Quickly, quietly and carefully は残る。For managers, engineers and designers, も外れるが、前置詞のあとの名詞を
217
+ * 項目と読むと、Over this period, the subcommittees and the full committee ... を並びと誤る。こちらのほうが多い。
218
+ */
219
+ const LEAD_POS = new Set(["ADP", "SCONJ", "ADV", "PART"]);
220
+
221
+ const isParticiple = (token: Token): boolean => token.features?.["VerbForm"] === "Part";
222
+
223
+ const openingOf = (item: readonly Token[]): Token | undefined => item.find((token) => token.pos !== "PUNCT" && token.pos !== "X");
224
+
225
+ const openingKind = (item: readonly Token[]): string | undefined => {
226
+ const opening = openingOf(item);
227
+ return opening !== undefined && isParticiple(opening) ? "PARTICIPLE" : shapeOf(item);
228
+ };
229
+
230
+ const withoutLead = (items: readonly Token[][]): readonly Token[][] => {
231
+ const [first, second] = items;
232
+ const opening = first === undefined ? undefined : openingOf(first);
233
+ if (first === undefined || second === undefined || opening === undefined) return items;
234
+ if (!LEAD_POS.has(opening.pos) && !isParticiple(opening)) return items;
235
+ return openingKind(first) === openingKind(second) ? items : items.slice(1);
236
+ };
237
+
238
+ /**
239
+ * 形容詞のあとの読点は、名詞の前で形容詞を重ねているだけのことが多い(the long, winding bridge)。
240
+ * 次の項目が名詞で終わるならつなげ直す。形容詞そのものの並び(quick, cheap and reliable)は切ったまま。
241
+ */
242
+ const lastContent = (item: readonly Token[]): Token | undefined => item.findLast(isContent);
243
+
244
+ const joinAdjectives = (items: readonly Token[][]): Token[][] =>
245
+ items.reduce<Token[][]>((joined, item) => {
246
+ const previous = joined.at(-1);
247
+ const stacked = previous !== undefined && lastContent(previous)?.pos === "ADJ" && lastContent(item)?.pos !== "ADJ";
248
+ return stacked ? [...joined.slice(0, -1), [...previous, ...item]] : [...joined, item];
249
+ }, []);
250
+
251
+ /**
252
+ * 節を並べるなら、どの項目も節。Additionally, others can learn, and the mistake is rarer. の
253
+ * Additionally は項目ではなく、and の前の読点は節をつなぐ読点。名詞の並びは最初の項目に前置き
254
+ * (We shipped the parser)を抱えるので、この確かめは節の並びだけにする。
255
+ * and の後ろだけが節なのは主語の並び(The parser, the renderer and the exporter shipped.)なので外さない。
256
+ */
257
+ const clausesAgree = (items: readonly Token[][], after: readonly Token[]): boolean => {
258
+ const last = items.at(-1);
259
+ if (last === undefined || !isClause(last)) return true;
260
+ return isClause(after) && items.every(isClause);
261
+ };
262
+
263
+ /**
264
+ * 動詞で始まる項目を並べるなら、どの項目にも動詞がある。The scope, in contrast, is larger, and covers ... の
265
+ * The scope は項目ではなく、後ろの述語の主語。
266
+ */
267
+ const hasVerb = (item: readonly Token[]): boolean => item.some((token) => VERBAL.has(token.pos));
268
+
269
+ const predicatesAgree = (items: readonly Token[][], shape: string): boolean => !VERBAL.has(shape) || items.every(hasVerb);
270
+
271
+ /**
272
+ * 読点で区切った項目が and / or の前に 2 つ以上あり、最後の項目と and の後ろが同じ形のときだけ並列。
273
+ * 導入の読点(After the review, the team fixed the bug and shipped it.)や、節をつなぐ読点
274
+ * (We tested it, and the team shipped it.)は、Oxford comma を打つかどうかの選択を見せない。
275
+ */
276
+ const listAt = (clause: Clause, at: number): boolean | undefined => {
277
+ const items = withoutLead(joinAdjectives(itemsBefore(clause, at)));
278
+ const after = itemAfter(clause.tokens, at);
279
+ const last = items.at(-1);
280
+ const shape = last === undefined ? undefined : shapeOf(last);
281
+ if (items.length < 2 || shape === undefined || shape !== shapeOf(after)) return undefined;
282
+ if (!clausesAgree(items, after) || !predicatesAgree(items, shape)) return undefined;
283
+ return commaBefore(clause.tokens, at);
284
+ };
285
+
153
286
  /**
154
287
  * 3 つ以上の並列の最後の and / or の前に読点を打つか。Oxford comma。
155
288
  *
@@ -157,13 +290,11 @@ const LIST_CONJUNCTION = new Set(["and", "or"]);
157
290
  * 見るのは 1 つの文書で揃っているかだけ。spec §12.3。
158
291
  */
159
292
  const oxfordIn = (tokens: readonly Token[]): boolean | undefined => {
160
- const at = tokens.findIndex((token) => LIST_CONJUNCTION.has(token.surface.toLowerCase()));
161
- if (at < 1) return undefined;
162
- const before = tokens.slice(0, at);
163
- // 並列が 3 つ以上あるときだけ判定できる。読点が 1 つも無ければ 2 つの並列。
164
- const commas = before.filter((token) => token.surface === ",").length;
165
- if (commas === 0) return undefined;
166
- return before.at(-1)?.surface === ",";
293
+ const clause = { tokens, depths: depthsOf(tokens) };
294
+ return tokens.reduce<boolean | undefined>(
295
+ (found, token, at) => found ?? (at > 0 && LIST_CONJUNCTION.has(token.surface.toLowerCase()) ? listAt(clause, at) : undefined),
296
+ undefined,
297
+ );
167
298
  };
168
299
 
169
300
  export const oxfordComma: Detector = (doc, options): Finding[] => {
@@ -0,0 +1,41 @@
1
+ import type { LengthUnit, Span } from "../plugin.ts";
2
+
3
+ /** 数えるために詰めた文と、詰めた文の各文字が元の文のどこにあったか。 */
4
+ export type Compacted = { readonly text: string; readonly offsets: readonly number[] };
5
+
6
+ /**
7
+ * n-gram を数えるときと同じ詰め方。word 単位は空白の並びを 1 つにして前後を削り、char 単位は空白を全部除く。
8
+ * 語句が元の文のどこにあるかを戻せるよう、残した文字ごとに元の位置を持つ。
9
+ */
10
+ export const compacted = (text: string, unit: LengthUnit): Compacted => {
11
+ const chars: string[] = [];
12
+ const offsets: number[] = [];
13
+ const state = { pendingSpace: -1, at: 0 };
14
+ Array.from(text).forEach((char) => {
15
+ const at = state.at;
16
+ state.at += char.length;
17
+ if (/\s/u.test(char)) {
18
+ if (unit === "word" && chars.length > 0 && state.pendingSpace === -1) state.pendingSpace = at;
19
+ return;
20
+ }
21
+ if (state.pendingSpace !== -1) {
22
+ chars.push(" ");
23
+ offsets.push(state.pendingSpace);
24
+ state.pendingSpace = -1;
25
+ }
26
+ chars.push(char);
27
+ // 位置は UTF-16 の単位で持つ。placeOf は indexOf の位置で引くので、絵文字(2 単位)には 2 つ置く。
28
+ Array.from({ length: char.length }, (_, unit) => offsets.push(at + unit));
29
+ });
30
+ return { text: chars.join(""), offsets };
31
+ };
32
+
33
+ /** 詰めた文の中の gram の最初の出現を、元の文の範囲に戻す。無ければ undefined。 */
34
+ export const placeOf = (source: Compacted, gram: string): Span | undefined => {
35
+ const at = source.text.indexOf(gram);
36
+ if (at === -1) return undefined;
37
+ const start = source.offsets[at];
38
+ const last = source.offsets[at + gram.length - 1];
39
+ if (start === undefined || last === undefined) return undefined;
40
+ return { start, end: last + 1 };
41
+ };
@@ -1,5 +1,6 @@
1
1
  import { lengthOf } from "../measure.ts";
2
- import type { Detector, Finding, LengthUnit, Section } from "../plugin.ts";
2
+ import { handsOver } from "./lead-in.ts";
3
+ import type { Detector, Finding, LengthUnit, ProseDocument, Section } from "../plugin.ts";
3
4
 
4
5
  /** 見出しが短すぎると、偶然の一致で 100% になる。これ未満の見出しは見ない。 */
5
6
  const MIN_GRAMS = 4;
@@ -51,9 +52,17 @@ const addsLittle = (section: Section, unit: LengthUnit): boolean => {
51
52
  return lengthOf(first, unit) - headingUnits(section.heading, unit) <= NEW_MATERIAL[unit];
52
53
  };
53
54
 
54
- export const headingEcho: Detector = (doc, options): Finding[] =>
55
- doc.sections
55
+ /** 最初の文が、同じ節の後ろ(箇条書き・表・コード)へ読者を渡している。 */
56
+ const leadsIn = (doc: ProseDocument, section: Section, phrases: readonly string[]): boolean => {
57
+ const first = section.firstSentence;
58
+ return first !== undefined && handsOver(first.text, doc.source.slice(first.span.end, section.span.end), phrases);
59
+ };
60
+
61
+ export const headingEcho: Detector = (doc, options): Finding[] => {
62
+ const leadIns = (doc.lexicons["lead-in"] ?? []).map((entry) => entry.pattern);
63
+ return doc.sections
56
64
  .filter((section) => section.heading.length > 0 && section.firstSentence !== undefined && addsLittle(section, doc.lengthUnit))
65
+ .filter((section) => !leadsIn(doc, section, leadIns))
57
66
  .map((section) => ({ section, overlap: Math.round(containment(trigrams(section.heading), trigrams(section.firstSentence?.text ?? "")) * 100) }))
58
67
  .filter(({ overlap }) => overlap >= options.limit)
59
68
  .map(({ section, overlap }) => ({
@@ -64,3 +73,4 @@ export const headingEcho: Detector = (doc, options): Finding[] =>
64
73
  quote: section.firstSentence?.text.trim() ?? "",
65
74
  values: { heading: section.heading, count: overlap, limit: options.limit, offset: section.firstSentence?.span.start ?? section.span.start },
66
75
  }));
76
+ };
@@ -6,6 +6,7 @@ import { repeatedHead } from "./repeated-head.ts";
6
6
  import { sentenceRhythm } from "./sentence-rhythm.ts";
7
7
  import { phraseMatch } from "./phrase-match.ts";
8
8
  import { agentlessPassive } from "./agentless-passive.ts";
9
+ import { doubledWord } from "./doubled-word.ts";
9
10
  import { sentenceEnding } from "./sentence-ending.ts";
10
11
  import { doubledParticle, nounEnding } from "./token-shape.ts";
11
12
  import { kanjiRun, middleDot } from "./char-shape.ts";
@@ -29,6 +30,7 @@ export const DETECTORS: Readonly<Record<string, Detector>> = {
29
30
  "sentence-ending": sentenceEnding,
30
31
  "noun-ending": nounEnding,
31
32
  "doubled-particle": doubledParticle,
33
+ "doubled-word": doubledWord,
32
34
  "kanji-run": kanjiRun,
33
35
  "middle-dot": middleDot,
34
36
  "adverb-density": adverbDensity,