chaffjs 0.13.0 → 0.15.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 (323) hide show
  1. package/dist/address-chain.d.ts +4 -1
  2. package/dist/address-chain.d.ts.map +1 -1
  3. package/dist/address-chain.js +5 -2
  4. package/dist/address-chain.js.map +1 -1
  5. package/dist/cli-text.d.ts +4 -1
  6. package/dist/cli-text.d.ts.map +1 -1
  7. package/dist/cli-text.js +14 -2
  8. package/dist/cli-text.js.map +1 -1
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +21 -21
  11. package/dist/cli.js.map +1 -1
  12. package/dist/commands/eval.d.ts +4 -0
  13. package/dist/commands/eval.d.ts.map +1 -1
  14. package/dist/commands/eval.js +20 -6
  15. package/dist/commands/eval.js.map +1 -1
  16. package/dist/commands/feedback.d.ts.map +1 -1
  17. package/dist/commands/feedback.js +2 -1
  18. package/dist/commands/feedback.js.map +1 -1
  19. package/dist/commands/test.d.ts.map +1 -1
  20. package/dist/commands/test.js +3 -2
  21. package/dist/commands/test.js.map +1 -1
  22. package/dist/commands/tree.d.ts.map +1 -1
  23. package/dist/commands/tree.js +2 -1
  24. package/dist/commands/tree.js.map +1 -1
  25. package/dist/detectors/acronym-compound.d.ts +8 -0
  26. package/dist/detectors/acronym-compound.d.ts.map +1 -0
  27. package/dist/detectors/acronym-compound.js +8 -0
  28. package/dist/detectors/acronym-compound.js.map +1 -0
  29. package/dist/detectors/acronym-context.d.ts +6 -0
  30. package/dist/detectors/acronym-context.d.ts.map +1 -1
  31. package/dist/detectors/acronym-context.js +24 -1
  32. package/dist/detectors/acronym-context.js.map +1 -1
  33. package/dist/detectors/acronym-expansion.d.ts +10 -0
  34. package/dist/detectors/acronym-expansion.d.ts.map +1 -1
  35. package/dist/detectors/acronym-expansion.js +63 -5
  36. package/dist/detectors/acronym-expansion.js.map +1 -1
  37. package/dist/detectors/char-shape.d.ts.map +1 -1
  38. package/dist/detectors/char-shape.js +38 -16
  39. package/dist/detectors/char-shape.js.map +1 -1
  40. package/dist/detectors/citation-key.d.ts +13 -0
  41. package/dist/detectors/citation-key.d.ts.map +1 -0
  42. package/dist/detectors/citation-key.js +9 -0
  43. package/dist/detectors/citation-key.js.map +1 -0
  44. package/dist/detectors/cited-name.d.ts +11 -0
  45. package/dist/detectors/cited-name.d.ts.map +1 -0
  46. package/dist/detectors/cited-name.js +15 -0
  47. package/dist/detectors/cited-name.js.map +1 -0
  48. package/dist/detectors/concrete-evidence.d.ts +7 -0
  49. package/dist/detectors/concrete-evidence.d.ts.map +1 -0
  50. package/dist/detectors/concrete-evidence.js +21 -0
  51. package/dist/detectors/concrete-evidence.js.map +1 -0
  52. package/dist/detectors/conjugated-form.d.ts +3 -0
  53. package/dist/detectors/conjugated-form.d.ts.map +1 -0
  54. package/dist/detectors/conjugated-form.js +29 -0
  55. package/dist/detectors/conjugated-form.js.map +1 -0
  56. package/dist/detectors/counter-edge.d.ts +8 -0
  57. package/dist/detectors/counter-edge.d.ts.map +1 -0
  58. package/dist/detectors/counter-edge.js +27 -0
  59. package/dist/detectors/counter-edge.js.map +1 -0
  60. package/dist/detectors/dotted-name.d.ts +13 -0
  61. package/dist/detectors/dotted-name.d.ts.map +1 -0
  62. package/dist/detectors/dotted-name.js +10 -0
  63. package/dist/detectors/dotted-name.js.map +1 -0
  64. package/dist/detectors/doubled-word.d.ts +6 -1
  65. package/dist/detectors/doubled-word.d.ts.map +1 -1
  66. package/dist/detectors/doubled-word.js +22 -2
  67. package/dist/detectors/doubled-word.js.map +1 -1
  68. package/dist/detectors/en-shape.d.ts +1 -1
  69. package/dist/detectors/en-shape.d.ts.map +1 -1
  70. package/dist/detectors/en-shape.js +11 -22
  71. package/dist/detectors/en-shape.js.map +1 -1
  72. package/dist/detectors/enumerated-runs.d.ts +22 -0
  73. package/dist/detectors/enumerated-runs.d.ts.map +1 -0
  74. package/dist/detectors/enumerated-runs.js +53 -0
  75. package/dist/detectors/enumerated-runs.js.map +1 -0
  76. package/dist/detectors/gram-place.d.ts +0 -4
  77. package/dist/detectors/gram-place.d.ts.map +1 -1
  78. package/dist/detectors/gram-place.js +6 -1
  79. package/dist/detectors/gram-place.js.map +1 -1
  80. package/dist/detectors/gram-predicate.d.ts +7 -0
  81. package/dist/detectors/gram-predicate.d.ts.map +1 -0
  82. package/dist/detectors/gram-predicate.js +44 -0
  83. package/dist/detectors/gram-predicate.js.map +1 -0
  84. package/dist/detectors/heading-case.d.ts +21 -0
  85. package/dist/detectors/heading-case.d.ts.map +1 -0
  86. package/dist/detectors/heading-case.js +38 -0
  87. package/dist/detectors/heading-case.js.map +1 -0
  88. package/dist/detectors/heading-echo.d.ts.map +1 -1
  89. package/dist/detectors/heading-echo.js +4 -1
  90. package/dist/detectors/heading-echo.js.map +1 -1
  91. package/dist/detectors/lexicon.d.ts.map +1 -1
  92. package/dist/detectors/lexicon.js +7 -4
  93. package/dist/detectors/lexicon.js.map +1 -1
  94. package/dist/detectors/middle-dot.d.ts +6 -0
  95. package/dist/detectors/middle-dot.d.ts.map +1 -0
  96. package/dist/detectors/middle-dot.js +9 -0
  97. package/dist/detectors/middle-dot.js.map +1 -0
  98. package/dist/detectors/name-numeral.d.ts +16 -0
  99. package/dist/detectors/name-numeral.d.ts.map +1 -0
  100. package/dist/detectors/name-numeral.js +30 -0
  101. package/dist/detectors/name-numeral.js.map +1 -0
  102. package/dist/detectors/name-part.d.ts +8 -0
  103. package/dist/detectors/name-part.d.ts.map +1 -0
  104. package/dist/detectors/name-part.js +20 -0
  105. package/dist/detectors/name-part.js.map +1 -0
  106. package/dist/detectors/name-run.d.ts +8 -0
  107. package/dist/detectors/name-run.d.ts.map +1 -0
  108. package/dist/detectors/name-run.js +13 -0
  109. package/dist/detectors/name-run.js.map +1 -0
  110. package/dist/detectors/orthography.d.ts.map +1 -1
  111. package/dist/detectors/orthography.js +18 -6
  112. package/dist/detectors/orthography.js.map +1 -1
  113. package/dist/detectors/placeholder.d.ts +14 -0
  114. package/dist/detectors/placeholder.d.ts.map +1 -0
  115. package/dist/detectors/placeholder.js +24 -0
  116. package/dist/detectors/placeholder.js.map +1 -0
  117. package/dist/detectors/polite-word.d.ts +4 -0
  118. package/dist/detectors/polite-word.d.ts.map +1 -0
  119. package/dist/detectors/polite-word.js +10 -0
  120. package/dist/detectors/polite-word.js.map +1 -0
  121. package/dist/detectors/quoted-variant.d.ts +7 -0
  122. package/dist/detectors/quoted-variant.d.ts.map +1 -0
  123. package/dist/detectors/quoted-variant.js +18 -0
  124. package/dist/detectors/quoted-variant.js.map +1 -0
  125. package/dist/detectors/register.d.ts +7 -5
  126. package/dist/detectors/register.d.ts.map +1 -1
  127. package/dist/detectors/register.js +6 -4
  128. package/dist/detectors/register.js.map +1 -1
  129. package/dist/detectors/roman-numeral.d.ts +6 -0
  130. package/dist/detectors/roman-numeral.d.ts.map +1 -0
  131. package/dist/detectors/roman-numeral.js +6 -0
  132. package/dist/detectors/roman-numeral.js.map +1 -0
  133. package/dist/detectors/sentence-ending.d.ts.map +1 -1
  134. package/dist/detectors/sentence-ending.js +14 -4
  135. package/dist/detectors/sentence-ending.js.map +1 -1
  136. package/dist/detectors/series-label.d.ts +13 -0
  137. package/dist/detectors/series-label.d.ts.map +1 -0
  138. package/dist/detectors/series-label.js +26 -0
  139. package/dist/detectors/series-label.js.map +1 -0
  140. package/dist/detectors/signals.d.ts.map +1 -1
  141. package/dist/detectors/signals.js +37 -25
  142. package/dist/detectors/signals.js.map +1 -1
  143. package/dist/detectors/structure-tree.js +1 -1
  144. package/dist/detectors/structure-tree.js.map +1 -1
  145. package/dist/detectors/structure.d.ts.map +1 -1
  146. package/dist/detectors/structure.js +2 -1
  147. package/dist/detectors/structure.js.map +1 -1
  148. package/dist/detectors/superlative-name.d.ts +9 -0
  149. package/dist/detectors/superlative-name.d.ts.map +1 -0
  150. package/dist/detectors/superlative-name.js +24 -0
  151. package/dist/detectors/superlative-name.js.map +1 -0
  152. package/dist/detectors/token-shape.d.ts.map +1 -1
  153. package/dist/detectors/token-shape.js +10 -2
  154. package/dist/detectors/token-shape.js.map +1 -1
  155. package/dist/document.d.ts.map +1 -1
  156. package/dist/document.js +10 -4
  157. package/dist/document.js.map +1 -1
  158. package/dist/genre-check.d.ts +34 -0
  159. package/dist/genre-check.d.ts.map +1 -0
  160. package/dist/genre-check.js +21 -0
  161. package/dist/genre-check.js.map +1 -0
  162. package/dist/genre.d.ts +8 -1
  163. package/dist/genre.d.ts.map +1 -1
  164. package/dist/genre.js +29 -5
  165. package/dist/genre.js.map +1 -1
  166. package/dist/init.d.ts +0 -1
  167. package/dist/init.d.ts.map +1 -1
  168. package/dist/init.js +0 -12
  169. package/dist/init.js.map +1 -1
  170. package/dist/joined-view.d.ts +25 -0
  171. package/dist/joined-view.d.ts.map +1 -0
  172. package/dist/joined-view.js +76 -0
  173. package/dist/joined-view.js.map +1 -0
  174. package/dist/line-continues.d.ts +6 -0
  175. package/dist/line-continues.d.ts.map +1 -0
  176. package/dist/line-continues.js +33 -0
  177. package/dist/line-continues.js.map +1 -0
  178. package/dist/measure.d.ts +1 -0
  179. package/dist/measure.d.ts.map +1 -1
  180. package/dist/measure.js +5 -1
  181. package/dist/measure.js.map +1 -1
  182. package/dist/number-name.d.ts +7 -3
  183. package/dist/number-name.d.ts.map +1 -1
  184. package/dist/number-name.js +51 -15
  185. package/dist/number-name.js.map +1 -1
  186. package/dist/orthography.d.ts +4 -0
  187. package/dist/orthography.d.ts.map +1 -1
  188. package/dist/orthography.js +11 -9
  189. package/dist/orthography.js.map +1 -1
  190. package/dist/plain-source.d.ts +6 -0
  191. package/dist/plain-source.d.ts.map +1 -0
  192. package/dist/plain-source.js +6 -0
  193. package/dist/plain-source.js.map +1 -0
  194. package/dist/plugin.d.ts +18 -1
  195. package/dist/plugin.d.ts.map +1 -1
  196. package/dist/preamble-paragraphs.d.ts +7 -0
  197. package/dist/preamble-paragraphs.d.ts.map +1 -0
  198. package/dist/preamble-paragraphs.js +7 -0
  199. package/dist/preamble-paragraphs.js.map +1 -0
  200. package/dist/quoted-span.d.ts +10 -0
  201. package/dist/quoted-span.d.ts.map +1 -0
  202. package/dist/quoted-span.js +20 -0
  203. package/dist/quoted-span.js.map +1 -0
  204. package/dist/render/eval.d.ts +2 -1
  205. package/dist/render/eval.d.ts.map +1 -1
  206. package/dist/render/eval.js +84 -43
  207. package/dist/render/eval.js.map +1 -1
  208. package/dist/resolve-genre.d.ts +11 -0
  209. package/dist/resolve-genre.d.ts.map +1 -0
  210. package/dist/resolve-genre.js +18 -0
  211. package/dist/resolve-genre.js.map +1 -0
  212. package/dist/soft-break.d.ts +19 -0
  213. package/dist/soft-break.d.ts.map +1 -0
  214. package/dist/soft-break.js +78 -0
  215. package/dist/soft-break.js.map +1 -0
  216. package/dist/structure/build.d.ts.map +1 -1
  217. package/dist/structure/build.js +23 -10
  218. package/dist/structure/build.js.map +1 -1
  219. package/dist/structure/date-order.d.ts +3 -0
  220. package/dist/structure/date-order.d.ts.map +1 -1
  221. package/dist/structure/date-order.js +9 -1
  222. package/dist/structure/date-order.js.map +1 -1
  223. package/dist/structure/heading-leaves.d.ts +7 -0
  224. package/dist/structure/heading-leaves.d.ts.map +1 -0
  225. package/dist/structure/heading-leaves.js +6 -0
  226. package/dist/structure/heading-leaves.js.map +1 -0
  227. package/dist/structure/issues.d.ts +1 -1
  228. package/dist/structure/issues.d.ts.map +1 -1
  229. package/dist/structure/issues.js +14 -3
  230. package/dist/structure/issues.js.map +1 -1
  231. package/dist/structure/listed-tags.d.ts +3 -0
  232. package/dist/structure/listed-tags.d.ts.map +1 -0
  233. package/dist/structure/listed-tags.js +6 -0
  234. package/dist/structure/listed-tags.js.map +1 -0
  235. package/dist/structure/number-in-sentence.d.ts +15 -0
  236. package/dist/structure/number-in-sentence.d.ts.map +1 -0
  237. package/dist/structure/number-in-sentence.js +13 -0
  238. package/dist/structure/number-in-sentence.js.map +1 -0
  239. package/dist/structure/unit-word.d.ts +8 -0
  240. package/dist/structure/unit-word.d.ts.map +1 -0
  241. package/dist/structure/unit-word.js +10 -0
  242. package/dist/structure/unit-word.js.map +1 -0
  243. package/dist/structure/wrapped-tail.d.ts +18 -0
  244. package/dist/structure/wrapped-tail.d.ts.map +1 -0
  245. package/dist/structure/wrapped-tail.js +11 -0
  246. package/dist/structure/wrapped-tail.js.map +1 -0
  247. package/package.json +3 -3
  248. package/rules/dangling-reference.yaml +2 -2
  249. package/rules/date-order.yaml +2 -2
  250. package/rules/duplicate-definition.yaml +2 -2
  251. package/rules/latin-spacing.yaml +4 -2
  252. package/rules/max-kanji-continuous.yaml +1 -1
  253. package/rules/no-doubled-joshi.yaml +2 -2
  254. package/rules/no-nakaguro-parallel.yaml +2 -2
  255. package/rules/title-case-consistency.yaml +2 -2
  256. package/rules/undefined-acronym.yaml +15 -1
  257. package/rules/unqualified-superlative.yaml +1 -1
  258. package/src/address-chain.ts +5 -2
  259. package/src/cli-text.ts +19 -3
  260. package/src/cli.ts +22 -19
  261. package/src/commands/eval.ts +26 -5
  262. package/src/commands/feedback.ts +2 -1
  263. package/src/commands/test.ts +3 -2
  264. package/src/commands/tree.ts +2 -1
  265. package/src/detectors/acronym-compound.ts +9 -0
  266. package/src/detectors/acronym-context.ts +32 -1
  267. package/src/detectors/acronym-expansion.ts +72 -5
  268. package/src/detectors/char-shape.ts +44 -17
  269. package/src/detectors/citation-key.ts +13 -0
  270. package/src/detectors/cited-name.ts +23 -0
  271. package/src/detectors/concrete-evidence.ts +31 -0
  272. package/src/detectors/conjugated-form.ts +40 -0
  273. package/src/detectors/counter-edge.ts +29 -0
  274. package/src/detectors/dotted-name.ts +14 -0
  275. package/src/detectors/doubled-word.ts +25 -2
  276. package/src/detectors/en-shape.ts +17 -30
  277. package/src/detectors/enumerated-runs.ts +68 -0
  278. package/src/detectors/gram-place.ts +8 -1
  279. package/src/detectors/gram-predicate.ts +55 -0
  280. package/src/detectors/heading-case.ts +41 -0
  281. package/src/detectors/heading-echo.ts +5 -1
  282. package/src/detectors/lexicon.ts +8 -5
  283. package/src/detectors/middle-dot.ts +10 -0
  284. package/src/detectors/name-numeral.ts +37 -0
  285. package/src/detectors/name-part.ts +24 -0
  286. package/src/detectors/name-run.ts +16 -0
  287. package/src/detectors/orthography.ts +20 -9
  288. package/src/detectors/placeholder.ts +38 -0
  289. package/src/detectors/polite-word.ts +14 -0
  290. package/src/detectors/quoted-variant.ts +20 -0
  291. package/src/detectors/register.ts +11 -7
  292. package/src/detectors/roman-numeral.ts +5 -0
  293. package/src/detectors/sentence-ending.ts +23 -6
  294. package/src/detectors/series-label.ts +33 -0
  295. package/src/detectors/signals.ts +44 -31
  296. package/src/detectors/structure-tree.ts +2 -2
  297. package/src/detectors/structure.ts +2 -1
  298. package/src/detectors/superlative-name.ts +30 -0
  299. package/src/detectors/token-shape.ts +12 -3
  300. package/src/document.ts +12 -5
  301. package/src/genre-check.ts +46 -0
  302. package/src/genre.ts +34 -5
  303. package/src/init.ts +0 -13
  304. package/src/joined-view.ts +96 -0
  305. package/src/line-continues.ts +43 -0
  306. package/src/measure.ts +9 -1
  307. package/src/number-name.ts +65 -15
  308. package/src/orthography.ts +15 -9
  309. package/src/plain-source.ts +5 -0
  310. package/src/plugin.ts +23 -2
  311. package/src/preamble-paragraphs.ts +9 -0
  312. package/src/quoted-span.ts +25 -0
  313. package/src/render/eval.ts +101 -38
  314. package/src/resolve-genre.ts +20 -0
  315. package/src/soft-break.ts +94 -0
  316. package/src/structure/build.ts +29 -17
  317. package/src/structure/date-order.ts +16 -1
  318. package/src/structure/heading-leaves.ts +7 -0
  319. package/src/structure/issues.ts +18 -3
  320. package/src/structure/listed-tags.ts +7 -0
  321. package/src/structure/number-in-sentence.ts +20 -0
  322. package/src/structure/unit-word.ts +11 -0
  323. package/src/structure/wrapped-tail.ts +22 -0
@@ -1,12 +1,13 @@
1
+ import { hyphenGroups, isHyphen } from "./orthography.ts";
1
2
  import type { Span, Token } from "./plugin.ts";
2
3
 
3
4
  /**
4
5
  * 数量ではなく名前として書かれた数(番号・識別子)。鉤括弧で引いた「3.1 リサーチの原則」の節番号、※1 のような注の番号、
5
- * 「〒102-0094 東京都」の郵便番号は、後ろの空白が番号と題・項目の区切りで、「3回」「3 回」のような空け方の好みではない。
6
+ * 「〒100-8916 東京都」の郵便番号、「紀尾井町1-3 東京ガーデンテラス」の番地は、後ろの空白が番号と題・項目の区切りで、「3回」「3 回」のような空け方の好みではない。
6
7
  * 空け方を数える材料にしない。
7
8
  *
8
- * 形で名前と読むのは三つだけ。0 で始まる組を含むハイフンつなぎの番号、※ や鉤括弧のすぐ後ろの番号、文頭の階層つきの節番号(2.1、3.1.2)。
9
- * 文頭でも「223 言語」「1ターン」は数量なので、階層の無い数は文頭というだけでは名前と読まない。
9
+ * 形で名前と読むのは五つだけ。0 で始まる組を含むハイフンつなぎの番号、※・〒や鉤括弧のすぐ後ろの番号、市・区・町まで下りた地名のすぐ後ろのハイフンつなぎの番号、
10
+ * 文頭の階層つきの節番号(2.1、3.1.2)、続き番号になった行の頭の番号(白書の注)。文頭でも「223 言語」「1ターン」は数量なので、階層の無い数は文頭というだけでは名前と読まない。
10
11
  * そのうえで、すぐ後ろ(空白 1 つまで)の語が数につく語(助数詞・数・助詞・助動詞)なら数量として数え続ける(「3-5 日」「26.7 万行」)。
11
12
  * 品詞が無ければ判断せず、名前とは読まない。
12
13
  */
@@ -14,8 +15,8 @@ import type { Span, Token } from "./plugin.ts";
14
15
  /** 数と結びついて読まれる語。助数詞は NounType=Class、数は NumType=Card で渡る。 */
15
16
  const BOUND_TO_NUMBER = new Set(["ADP", "PART", "AUX", "SCONJ", "CCONJ"]);
16
17
 
17
- /** 番号が立つ位置の直前: 題を包む鉤括弧、注の印。 */
18
- const LABEL_LEAD = /[「『【※]$/u;
18
+ /** 番号が立つ位置の直前: 題を包む鉤括弧、注の印、郵便番号の印。 */
19
+ const LABEL_LEAD = /[「『【※〒]$/u;
19
20
 
20
21
  /** 文頭(前に空白と箇条書きの印だけ)。 */
21
22
  const LINE_HEAD = /^[\s\-*+・•]*$/u;
@@ -28,17 +29,18 @@ const SECTION_NUMBER = /^\d+(?:\.\d+)+$/u;
28
29
  * 数え続ける。三つ以上つないだもの(073-489-5909)は、orthography.ts が境目を作る前に外している。
29
30
  */
30
31
  const isHyphenIdentifier = (digits: string): boolean => {
31
- const parts = digits.split("-").filter((part) => part !== "");
32
+ const parts = hyphenGroups(digits);
32
33
  return parts.length > 1 && parts.some((part) => part.startsWith("0"));
33
34
  };
34
35
 
35
- const RUN_CHAR = /[\d.-]/u;
36
+ const DIGIT_OR_POINT = /[\d.]/u;
37
+ const isRunChar = (char: string | undefined): boolean => DIGIT_OR_POINT.test(char ?? "") || isHyphen(char);
36
38
 
37
- /** at を含む、数字・小数点・ハイフンの並び。at が並びの中に無ければ undefined。 */
39
+ /** at を含む、数字・小数点・ハイフン(全角の「-」なども)の並び。at が並びの中に無ければ undefined。 */
38
40
  export const digitRunAround = (text: string, at: number): Span | undefined => {
39
- if (!RUN_CHAR.test(text[at] ?? "")) return undefined;
40
- const startAt = (index: number): number => (index > 0 && RUN_CHAR.test(text[index - 1] ?? "") ? startAt(index - 1) : index);
41
- const endAt = (index: number): number => (index < text.length && RUN_CHAR.test(text[index] ?? "") ? endAt(index + 1) : index);
41
+ if (!isRunChar(text[at])) return undefined;
42
+ const startAt = (index: number): number => (index > 0 && isRunChar(text[index - 1]) ? startAt(index - 1) : index);
43
+ const endAt = (index: number): number => (index < text.length && isRunChar(text[index]) ? endAt(index + 1) : index);
42
44
  return { start: startAt(at), end: endAt(at) };
43
45
  };
44
46
 
@@ -51,6 +53,44 @@ const wordAfter = (tokens: readonly Token[], text: string, run: Span, base: numb
51
53
  const isBoundToNumber = (token: Token): boolean =>
52
54
  token.features?.["NounType"] === "Class" || token.features?.["NumType"] === "Card" || BOUND_TO_NUMBER.has(token.pos);
53
55
 
56
+ /** 「1 又は 2」の接続詞は数どうしをつなぐが、続き番号の注の本文は「31 ただし、…」のように接続詞でも始まる。 */
57
+ const CONJUNCTIONS = new Set(["CCONJ", "SCONJ"]);
58
+
59
+ /**
60
+ * 白書の注(「9 首相に…」「10 日本経済新聞…」)のように、行の頭に階層の無い番号を置き、次の行の頭の番号が 1 つ大きいもの。
61
+ * 一行だけ見ると「223 言語に対応」と区別がつかないので、前後の番号の行と続き番号になっているものだけを番号と読む。
62
+ * 後ろが英字(「15 Federal Bureau…」)の行も並びには数える。値は行の頭の位置(文書全体の座標)。
63
+ */
64
+ const NUMBERED_LINE = /^[ \t]*(?:[-*+][ \t]+)?(?<number>\d{1,3})[ \t]+\S/gmu;
65
+
66
+ export const sequenceLabelStarts = (text: string): ReadonlySet<number> => {
67
+ const lines = [...text.matchAll(NUMBERED_LINE)].map((match) => {
68
+ const number = match.groups?.["number"] ?? "";
69
+ return { start: match.index + match[0].indexOf(number), value: Number(number) };
70
+ });
71
+ const continues = (index: number): boolean =>
72
+ lines[index - 1]?.value === (lines[index]?.value ?? 0) - 1 || lines[index + 1]?.value === (lines[index]?.value ?? 0) + 1;
73
+ return new Set(lines.flatMap((line, index) => (continues(index) ? [line.start] : [])));
74
+ };
75
+
76
+ const isGeoUnit = (token: Token): boolean => token.features?.["NameType"] === "GeoUnit";
77
+ const isPlaceWord = (token: Token): boolean => isGeoUnit(token) || token.features?.["NameType"] === "Geo";
78
+
79
+ /** end で終わる、間を空けずに続く地名と地名の単位(東京都千代田区紀尾井町)。近いほうから。 */
80
+ export const placeChainBefore = (tokens: readonly Token[], end: number): Token[] => {
81
+ const last = tokens.find((token) => token.span.end === end && isPlaceWord(token));
82
+ return last === undefined ? [] : [last, ...placeChainBefore(tokens, last.span.start)];
83
+ };
84
+
85
+ /**
86
+ * 住所の番地(千代田区紀尾井町1-3、霞が関2-1)。都道府県より下の単位(市・区・町)まで下りた地名のすぐ後ろの、ハイフンつなぎの番号。
87
+ * 地名だけ(北海道2-3 営業日)や都道府県まで(東京都2-3 営業日)の後ろは、地域ごとの範囲のことがあるので読まない。
88
+ * ハイフンの無い数(千代田区23 番)も読まない。topUnits は都道府県の単位(語彙表 prefecture-unit)。
89
+ */
90
+ const isAddressNumber = (text: string, run: Span, tokens: readonly Token[], base: number, topUnits: ReadonlySet<string>): boolean =>
91
+ [...text.slice(run.start, run.end)].some(isHyphen) &&
92
+ placeChainBefore(tokens, base + run.start).some((token) => isGeoUnit(token) && !topUnits.has(token.surface));
93
+
54
94
  /** ハイフンでつないだ識別子か、番号の立つ位置の番号か、文頭の節番号か。 */
55
95
  const isNameShaped = (text: string, run: Span): boolean => {
56
96
  const [before, digits] = [text.slice(0, run.start), text.slice(run.start, run.end)];
@@ -58,11 +98,21 @@ const isNameShaped = (text: string, run: Span): boolean => {
58
98
  };
59
99
 
60
100
  /**
61
- * text の run が名前として書かれた数か。tokens は文書全体の座標で、base は text の先頭の位置。
101
+ * text の run が名前として書かれた数か。tokens は文書全体の座標で、base は text の先頭の位置。sequence は sequenceLabelStarts の結果、
102
+ * topUnits は都道府県の単位。
62
103
  * 後ろの語が読めない(品詞が無い、語の切れ目が合わない)ときは、数量として数え続ける。
63
104
  */
64
- export const isNumberName = (text: string, run: Span, tokens: readonly Token[] | undefined, base: number): boolean => {
65
- if (tokens === undefined || !isNameShaped(text, run)) return false;
105
+ export const isNumberName = (
106
+ text: string,
107
+ run: Span,
108
+ tokens: readonly Token[] | undefined,
109
+ base: number,
110
+ sequence: ReadonlySet<number> = new Set(),
111
+ topUnits: ReadonlySet<string> = new Set(),
112
+ ): boolean => {
113
+ const inSequence = sequence.has(base + run.start);
114
+ if (tokens === undefined || !(inSequence || isNameShaped(text, run) || isAddressNumber(text, run, tokens, base, topUnits))) return false;
66
115
  const next = wordAfter(tokens, text, run, base);
67
- return next !== undefined && !isBoundToNumber(next);
116
+ if (next === undefined) return false;
117
+ return !isBoundToNumber(next) || (inSequence && CONJUNCTIONS.has(next.pos));
68
118
  };
@@ -125,22 +125,28 @@ const isOrdinalRun = (chars: readonly string[], index: number): boolean => {
125
125
  };
126
126
 
127
127
  /**
128
- * 「073-489-5909」「2026-06-02」「Ⅰ-4-1-3」のように - でつないだ 3 組以上の数字は、電話番号・日付・図の番号で
128
+ * 「073-489-5909」「2026-06-02」「Ⅰ-4-1-3」「5-1-1」のように - でつないだ 3 組以上の数字は、電話番号・日付・図や項目の番号で
129
129
  * 数量ではない。後ろの空白は欄の区切りで、「3回」の空け方とは別のもの。両側とも数えない。
130
130
  * 2 組(「1-3ヶ月」「20-30分」)は幅のある数量なので数える。
131
131
  */
132
132
  const MIN_CODE_GROUPS = 3;
133
- const CODE_CHAR = /[\d-]/u;
133
+
134
+ // 通達の「5-1-1」は全角の「-」でつなぐ。ハイフン「‐」やマイナス「−」も同じに読む。長音「ー」は仮名の一部なので入れない。
135
+ const HYPHEN = /[-‐‑−-]/u;
136
+
137
+ /** 数字をつなぐハイフンか。半角の - のほか、全角・和文のハイフンも。 */
138
+ export const isHyphen = (char: string | undefined): boolean => char !== undefined && HYPHEN.test(char);
139
+
140
+ /** ハイフンで区切った組(空の組は除く)。「5-1-1」は ["5", "1", "1"]。 */
141
+ export const hyphenGroups = (text: string): string[] => text.split(HYPHEN).filter((group) => group !== "");
142
+
143
+ const isCodeChar = (char: string | undefined): boolean => DIGIT.test(char ?? "") || isHyphen(char);
134
144
 
135
145
  const isCode = (chars: readonly string[], digit: number): boolean => {
136
146
  let [first, last] = [digit, digit];
137
- while (first > 0 && CODE_CHAR.test(chars[first - 1] ?? "")) first -= 1;
138
- while (last < chars.length - 1 && CODE_CHAR.test(chars[last + 1] ?? "")) last += 1;
139
- const groups = chars
140
- .slice(first, last + 1)
141
- .join("")
142
- .split("-");
143
- return groups.filter((group) => group !== "").length >= MIN_CODE_GROUPS;
147
+ while (first > 0 && isCodeChar(chars[first - 1])) first -= 1;
148
+ while (last < chars.length - 1 && isCodeChar(chars[last + 1])) last += 1;
149
+ return hyphenGroups(chars.slice(first, last + 1).join("")).length >= MIN_CODE_GROUPS;
144
150
  };
145
151
 
146
152
  /** 日本語との境目にある数字が、数えない書き方(「第3条」の番地、「073-489-5909」の符号)か。 */
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The Markdown parser drops a leading BOM and reads a lone "\r" as a line break; the rest of chaff counts "\n" only.
3
+ * Reading the file this way keeps every offset, line and column on the text the writer sees.
4
+ */
5
+ export const plainSource = (raw: string): string => raw.replace(/^\uFEFF/u, "").replace(/\r\n?/gu, "\n");
package/src/plugin.ts CHANGED
@@ -8,7 +8,16 @@ export type Span = { readonly start: number; readonly end: number };
8
8
  * token の span は文の span と同じ座標系(segment に渡した文字列の先頭が 0)。
9
9
  * 2 つの基準を混ぜると、ずれたときに どちらが悪いか分からなくなる。
10
10
  */
11
- export type Sentence = { readonly span: Span; readonly text: string; readonly tokens?: readonly Token[] };
11
+ export type Sentence = {
12
+ readonly span: Span;
13
+ readonly text: string;
14
+ readonly tokens?: readonly Token[];
15
+ /**
16
+ * 行の折り返しで語の途中に入った改行(前後の行頭・行末の空白ごと)。span と同じ座標系。chaff が入れる。
17
+ * tokens は全角どうしに挟まれた段落内の改行を除いた文字列から作るので、改行をまたぐ語の span は改行ごと覆い、surface より長い。
18
+ */
19
+ readonly wrapBreaks?: readonly Span[];
20
+ };
12
21
 
13
22
  /**
14
23
  * 品詞は Universal Dependencies の UPOS に統一する。アダプタ独自の体系を露出させない。
@@ -20,6 +29,8 @@ export type Token = {
20
29
  readonly surface: string;
21
30
  readonly pos: string;
22
31
  readonly lemma?: string;
32
+ /** 書いた形の読み(日本語はカタカナ)。表記の違う同じ語(下さい・ください)を同じと見るため。読めない adapter は持たない。 */
33
+ readonly reading?: string;
23
34
  readonly features?: Readonly<Record<string, string>>;
24
35
  };
25
36
 
@@ -131,7 +142,10 @@ export type StructurePatterns = {
131
142
  readonly numbered: (line: string, context: NumberingContext) => NumberedLine | undefined;
132
143
  /** 定義。attrs.term に定義された語。 */
133
144
  readonly definitions: (text: string) => readonly Mention[];
134
- /** 参照。attrs.target に正規化した番地("12.1")、attrs.label に書かれたまま。 */
145
+ /**
146
+ * 参照。attrs.target に正規化した番地("12.1")、attrs.label に書かれたまま。他の文書を指すなら attrs.document にその名。
147
+ * 書き方だけでは他の文書か決まらない角括弧の語は attrs.citedTag に入れ、文書がその語を一覧に載せていれば core が他の文書とみなす。
148
+ */
135
149
  readonly references: (text: string) => readonly Mention[];
136
150
  /** 義務・禁止・許可。attrs.marker に語、attrs.type に must / must-not / may。 */
137
151
  readonly obligations: (text: string) => readonly Mention[];
@@ -154,6 +168,11 @@ export type StructurePatterns = {
154
168
  * 数字と点だけの通し番号は core が言語を問わず読むので、それを数量と見分けられるのは言語パッケージだけ。
155
169
  */
156
170
  readonly countedAfter?: (number: string, rest: string) => boolean;
171
+ /**
172
+ * 本文の行頭の「3.11.0 を公開しました。」の 3.11.0 は、文の中の数で通し番号ではない。番号と後ろの文字列を渡し、文の続き
173
+ * (日本語なら助詞で始まる)なら true。小文字で始まる続きは core が言語を問わず読む。
174
+ */
175
+ readonly continuesSentence?: (number: string, rest: string) => boolean;
157
176
  /** 数の書き方(「二十二」「3」)を数にする。相対の参照の「前二項」「前条第二項」が使う。 */
158
177
  readonly number?: (text: string) => number | undefined;
159
178
  };
@@ -289,6 +308,8 @@ export type ProseDocument = {
289
308
  readonly paragraphs: readonly Paragraph[];
290
309
  /** 箇条書き 1 つ。項目の数と長さのばらつきを見る rule が使う。 */
291
310
  readonly lists: readonly BulletList[];
311
+ /** リンク(`[text](url)`、`<https://…>`、`[text][ref]`)の範囲。行き先が相対パスでもページ内でも、読み手が辿れる出典。 */
312
+ readonly links: readonly Span[];
292
313
  /** アダプタが持つ語彙表と、チームが chaff.yaml に足した語彙表。detector は出所を知らない。 */
293
314
  readonly lexicons: Readonly<Record<string, Lexicon>>;
294
315
  /** この種類の文書に無いと困る見出し。チームが chaff.yaml で決める。 */
@@ -0,0 +1,9 @@
1
+ import type { Paragraph } from "./plugin.ts";
2
+ import { isClosed } from "./sentence-shape.ts";
3
+
4
+ /**
5
+ * 本題の前にあって、読み手が読み通す段落。文が一つも終止符で閉じない段落は数えない。
6
+ * 分類の札(「エネルギー・環境」)、著者と所属の行、「印刷」、テンプレートの指示は文ではなく、読ませる前置きではない。
7
+ */
8
+ export const preambleParagraphs = (paragraphs: readonly Paragraph[], bodyStart: number): Paragraph[] =>
9
+ paragraphs.filter((paragraph) => paragraph.span.start < bodyStart && paragraph.sentences.some(isClosed));
@@ -0,0 +1,25 @@
1
+ import type { Span } from "./plugin.ts";
2
+
3
+ /** 鉤括弧の開きと、それを閉じる字。 */
4
+ const CLOSER: Readonly<Record<string, string>> = { "「": "」", "『": "』" };
5
+
6
+ type Open = { readonly closer: string; readonly at: number };
7
+ type Scan = { readonly open: readonly Open[]; readonly spans: readonly Span[] };
8
+
9
+ const step = (scan: Scan, char: string, at: number): Scan => {
10
+ const closer = CLOSER[char];
11
+ if (closer !== undefined) return { open: [...scan.open, { closer, at }], spans: scan.spans };
12
+ const last = scan.open.at(-1);
13
+ if (last === undefined || last.closer !== char) return scan;
14
+ return { open: scan.open.slice(0, -1), spans: [...scan.spans, { start: last.at + 1, end: at }] };
15
+ };
16
+
17
+ /**
18
+ * 鉤括弧(「」『』)の中身の位置。括弧そのものは含まない。入れ子はそれぞれを返す。
19
+ * 閉じない括弧と、開きと組にならない閉じ括弧は中身を作らない。位置は text の先頭を 0 とする。
20
+ * 括弧は BMP の字なので、UTF-16 の 1 単位ずつ見れば位置がそのまま使える。
21
+ */
22
+ export const quotedSpans = (text: string): Span[] => [...text.split("").reduce<Scan>((scan, char, at) => step(scan, char, at), { open: [], spans: [] }).spans];
23
+
24
+ /** inner が、spans のどれかにまるごと入っているか。 */
25
+ export const isWithinAny = (spans: readonly Span[], inner: Span): boolean => spans.some((span) => inner.start >= span.start && inner.end <= span.end);
@@ -1,62 +1,125 @@
1
- import { MIN_CORPUS, TARGET_HIT_RATE, type RuleReport } from "../eval.ts";
1
+ import { MIN_CORPUS, TARGET_HIT_RATE, type Point, type RuleReport } from "../eval.ts";
2
+ import type { Texts, UiLanguage } from "../ui.ts";
2
3
 
3
4
  const pct = (part: number, whole: number): string => `${((part / Math.max(1, whole)) * 100).toFixed(1)}%`;
4
5
 
5
- const mark = (report: RuleReport, limit: number): string => {
6
- if (limit === report.current && limit === report.recommended) return " ← 現在 / 推奨";
7
- if (limit === report.current) return " ← 現在";
8
- if (limit === report.recommended) return " ← 推奨";
6
+ const TARGET = TARGET_HIT_RATE * 100;
7
+
8
+ /** In the English hint, a rule's line sits one YAML level under `rules:`. */
9
+ const EN_CONFIG_LEAD = " In chaff.yaml: ";
10
+ const YAML_INDENT = 2;
11
+
12
+ const TEXT: Texts<{
13
+ readonly current: string;
14
+ readonly recommended: string;
15
+ readonly both: string;
16
+ readonly point: (point: Point, share: string) => string;
17
+ readonly fits: string;
18
+ readonly nothingFits: string;
19
+ readonly over: (share: string) => string;
20
+ readonly recommend: (limit: number) => string;
21
+ readonly toConfig: (rule: string, limit: number) => string;
22
+ readonly measured: (paths: number, rules: number) => string;
23
+ readonly premise: readonly string[];
24
+ readonly smallCorpus: (total: number) => readonly string[];
25
+ readonly untouched: string;
26
+ }> = {
27
+ ja: {
28
+ current: " ← 現在",
29
+ recommended: " ← 推奨",
30
+ both: " ← 現在 / 推奨",
31
+ point: (point, share) =>
32
+ `${String(point.documents).padStart(3)} 文書 (${share.padStart(6)}) 指摘 ${String(point.findings).padStart(3)} 件 1万字あたり ${point.per10k.toFixed(1)}`,
33
+ fits: ` いまの閾値で目標(${TARGET}% 未満)を満たしています。`,
34
+ nothingFits: ` どの閾値でも目標を満たしません。この corpus に対して rule 自体が合っていない可能性があります。`,
35
+ over: (share) => ` いまの閾値では ${share} の文書が該当し、目標(${TARGET}% 未満)を超えています。`,
36
+ recommend: (limit) => ` 推奨: ${limit}`,
37
+ toConfig: (rule, limit) => ` chaff.yaml に書くなら: rules:\n ${rule}: ${limit}`,
38
+ measured: (paths, rules) => ` ${paths} ファイルを corpus として ${rules} 本の rule を測りました。`,
39
+ premise: [
40
+ " ここにある文書は「人間が書いて公開した良い文書」として扱っています。",
41
+ ` そこで多く発火する rule は、閾値が現実に合っていない疑いがあります(目標: ${TARGET}% 未満)。`,
42
+ ],
43
+ smallCorpus: (total) => [
44
+ ` ※ corpus が ${total} 文書しかありません。${TARGET}% を表すには ${MIN_CORPUS} 文書以上が要ります。`,
45
+ " いまは割合が「0 か全部」に振れるので、右端の密度(1万字あたりの指摘数)のほうを見てください。",
46
+ ],
47
+ untouched: ` 閾値は自動で書き換えていません。corpus が変われば答えも変わるためです。`,
48
+ },
49
+ en: {
50
+ current: " ← current",
51
+ recommended: " ← recommended",
52
+ both: " ← current / recommended",
53
+ point: (point, share) =>
54
+ `${String(point.documents).padStart(3)} ${point.documents === 1 ? "doc " : "docs"} (${share.padStart(6)}) ${String(point.findings).padStart(3)} ${point.findings === 1 ? "finding " : "findings"} ${point.per10k.toFixed(1)} per 10,000 characters`,
55
+ fits: ` The current limit meets the target (under ${TARGET}%).`,
56
+ nothingFits: " No limit meets the target. The rule itself may not suit this corpus.",
57
+ over: (share) => ` At the current limit ${share} of the documents are hit, over the target (under ${TARGET}%).`,
58
+ recommend: (limit) => ` Recommended: ${limit}`,
59
+ toConfig: (rule, limit) => `${EN_CONFIG_LEAD}rules:\n${" ".repeat(EN_CONFIG_LEAD.length + YAML_INDENT)}${rule}: ${limit}`,
60
+ measured: (paths, rules) => ` Measured ${rules} rule${rules === 1 ? "" : "s"} on ${paths} file${paths === 1 ? "" : "s"} as a corpus.`,
61
+ premise: [
62
+ " The documents here are taken as good documents that people wrote and published.",
63
+ ` A rule that fires often on them may have a limit that does not fit real writing (target: under ${TARGET}%).`,
64
+ ],
65
+ smallCorpus: (total) => [
66
+ ` Note: the corpus has only ${total} document${total === 1 ? "" : "s"}. Showing ${TARGET}% takes at least ${MIN_CORPUS}.`,
67
+ " For now each share is either 0 or all, so read the density on the right (findings per 10,000 characters).",
68
+ ],
69
+ untouched: " No limit was changed. A different corpus gives a different answer.",
70
+ },
71
+ };
72
+
73
+ type EvalText = (typeof TEXT)[UiLanguage];
74
+
75
+ const mark = (report: RuleReport, limit: number, text: EvalText): string => {
76
+ if (limit === report.current && limit === report.recommended) return text.both;
77
+ if (limit === report.current) return text.current;
78
+ if (limit === report.recommended) return text.recommended;
9
79
  return "";
10
80
  };
11
81
 
12
- const sweepLines = (report: RuleReport): string[] =>
13
- report.sweep.map((point) => {
14
- const limit = String(point.limit).padStart(6);
15
- const docs = `${String(point.documents).padStart(3)} 文書`;
16
- const density = `1万字あたり ${point.per10k.toFixed(1)}`;
17
- return ` ${limit} ${docs} (${pct(point.documents, report.total).padStart(6)}) 指摘 ${String(point.findings).padStart(3)} 件 ${density}${mark(report, point.limit)}`;
18
- });
82
+ const sweepLines = (report: RuleReport, text: EvalText): string[] =>
83
+ report.sweep.map(
84
+ (point) => ` ${String(point.limit).padStart(6)} ${text.point(point, pct(point.documents, report.total))}${mark(report, point.limit, text)}`,
85
+ );
19
86
 
20
- const verdict = (report: RuleReport): string[] => {
87
+ const verdict = (report: RuleReport, text: EvalText): string[] => {
21
88
  const now = report.sweep.find((point) => point.limit === report.current);
22
89
  const rate = now === undefined ? 0 : now.documents / Math.max(1, report.total);
23
- if (rate <= TARGET_HIT_RATE) return [` いまの閾値で目標(${TARGET_HIT_RATE * 100}% 未満)を満たしています。`];
24
- if (report.recommended === undefined) {
25
- return [` どの閾値でも目標を満たしません。この corpus に対して rule 自体が合っていない可能性があります。`];
26
- }
27
- return [
28
- ` いまの閾値では ${pct(now?.documents ?? 0, report.total)} の文書が該当し、目標(${TARGET_HIT_RATE * 100}% 未満)を超えています。`,
29
- ` 推奨: ${report.recommended}`,
30
- "",
31
- ` chaff.yaml に書くなら: rules:\n ${report.rule}: ${report.recommended}`,
32
- ];
90
+ if (rate <= TARGET_HIT_RATE) return [text.fits];
91
+ if (report.recommended === undefined) return [text.nothingFits];
92
+ return [text.over(pct(now?.documents ?? 0, report.total)), text.recommend(report.recommended), "", text.toConfig(report.rule, report.recommended)];
33
93
  };
34
94
 
35
- const block = (report: RuleReport): string[] => ["", ` ${report.name} (${report.rule})`, "", ...sweepLines(report), "", ...verdict(report), ""];
95
+ const block = (report: RuleReport, text: EvalText): string[] => [
96
+ "",
97
+ ` ${report.name} (${report.rule})`,
98
+ "",
99
+ ...sweepLines(report, text),
100
+ "",
101
+ ...verdict(report, text),
102
+ "",
103
+ ];
36
104
 
37
105
  /**
38
106
  * 閾値は自動で書き換えない。提示して選ばせる。
39
107
  * 較正は corpus に対する答えであって、正解ではないため。
40
108
  */
41
- export const renderEval = (reports: readonly RuleReport[], total: number, paths: number): string =>
42
- [
109
+ export const renderEval = (reports: readonly RuleReport[], total: number, paths: number, ui: UiLanguage): string => {
110
+ const text = TEXT[ui];
111
+ return [
43
112
  "",
44
- ` ${paths} ファイルを corpus として ${reports.length} 本の rule を測りました。`,
113
+ text.measured(paths, reports.length),
45
114
  "",
46
- " ここにある文書は「人間が書いて公開した良い文書」として扱っています。",
47
- ` そこで多く発火する rule は、閾値が現実に合っていない疑いがあります(目標: ${TARGET_HIT_RATE * 100}% 未満)。`,
48
- ...(total < MIN_CORPUS
49
- ? [
50
- "",
51
- ` ※ corpus が ${total} 文書しかありません。${TARGET_HIT_RATE * 100}% を表すには ${MIN_CORPUS} 文書以上が要ります。`,
52
- " いまは割合が「0 か全部」に振れるので、右端の密度(1万字あたりの指摘数)のほうを見てください。",
53
- ]
54
- : []),
55
- ...reports.flatMap(block),
115
+ ...text.premise,
116
+ ...(total < MIN_CORPUS ? ["", ...text.smallCorpus(total)] : []),
117
+ ...reports.flatMap((report) => block(report, text)),
56
118
  " " + "─".repeat(58),
57
119
  "",
58
- ` 閾値は自動で書き換えていません。corpus が変われば答えも変わるためです。`,
120
+ text.untouched,
59
121
  "",
60
122
  ]
61
123
  .join("\n")
62
124
  .replace(/\{total\}/gu, String(total));
125
+ };
@@ -0,0 +1,20 @@
1
+ import { applyByPath } from "./config/by-path.ts";
2
+ import type { Config } from "./config/load.ts";
3
+ import type { GenreSource } from "./cli-text.ts";
4
+ import { frontMatterGenre, GENRES, guessGenre } from "./genre.ts";
5
+ import { unknownFrontMatterGenre } from "./genre-check.ts";
6
+
7
+ /** unread: a genre the front matter wrote that chaff does not know, when the front matter was looked at. */
8
+ type ResolvedGenre = { readonly genre: string; readonly from: GenreSource; readonly unread?: string | undefined };
9
+
10
+ export const resolveGenre = (path: string, source: string, config: Config, cliGenre?: string): ResolvedGenre => {
11
+ // コマンドで指定したものが最優先。その実行だけの指定だから、設定より強い。
12
+ if (cliGenre !== undefined) return { genre: cliGenre, from: "--genre" };
13
+ // パスごとの上書きが次。「全体はこう、ここだけは違う」を書けるようにする。
14
+ const override = applyByPath(config.byPath, config.baseDir, path);
15
+ if (override.genre !== undefined) return { genre: override.genre, from: "by_path" };
16
+ if (config.genre !== undefined) return { genre: config.genre, from: "config" };
17
+ const guess = guessGenre(path, source, frontMatterGenre(source));
18
+ const unread = unknownFrontMatterGenre(source, GENRES);
19
+ return guess === undefined ? { genre: "blog/tech", from: "default", unread } : { genre: guess.genre, from: guess.from, unread };
20
+ };
@@ -0,0 +1,94 @@
1
+ import type { Span } from "./plugin.ts";
2
+
3
+ /**
4
+ * 段落の中の改行のうち、読み手には見えないもの。
5
+ *
6
+ * Markdown は段落の中の改行を空白として表示するが、全角の文字に挟まれた改行は空白にもならずに消える
7
+ * (CSS Text 3 の segment break transformation。ブラウザがそう描く)。日本語は語の間に空白を置かないので、
8
+ * PDF から写した判決や、行の途中で折り返して書いた段落をそのまま解析器に渡すと、改行が語を割る(「関」「する」)。
9
+ * 英字・数字・ハングルに接する改行は、読み手にも空白に見えるので残す。
10
+ */
11
+ const WIDE = /[\p{scx=Han}\p{scx=Hiragana}\p{scx=Katakana}、-〿!-⦆¢-₩]/u;
12
+
13
+ const NEWLINE = /\n/gu;
14
+ const isHorizontalSpace = (unit: string | undefined): boolean => unit === " " || unit === "\t";
15
+
16
+ /** 改行の前に空白が 2 つ以上あれば、Markdown の強制改行(hard line break)。読み手にも改行に見える。 */
17
+ const HARD_BREAK_SPACES = 2;
18
+
19
+ const isLowSurrogate = (unit: number): boolean => unit >= 0xdc00 && unit <= 0xdfff;
20
+
21
+ /** index の直前の 1 文字(サロゲートペアは 2 単位で 1 文字)。 */
22
+ const charBefore = (text: string, index: number): string => {
23
+ const width = index >= 2 && isLowSurrogate(text.charCodeAt(index - 1)) ? 2 : 1;
24
+ return text.slice(Math.max(0, index - width), index);
25
+ };
26
+
27
+ const charAt = (text: string, index: number): string => {
28
+ const point = text.codePointAt(index);
29
+ return point === undefined ? "" : String.fromCodePoint(point);
30
+ };
31
+
32
+ /** 改行を挟む 2 文字が、改行を消して読む組か。 */
33
+ export const joinsAcrossBreak = (before: string, after: string): boolean => WIDE.test(before) && WIDE.test(after);
34
+
35
+ /** from から back の向きに、空白(スペース・タブ)が続く先。 */
36
+ const spacesUntil = (text: string, from: number, step: 1 | -1): number => {
37
+ let at = from;
38
+ while (isHorizontalSpace(text[step === 1 ? at : at - 1])) at += step;
39
+ return at;
40
+ };
41
+
42
+ /** 改行 1 つと、その前の行末・後ろの行頭の空白。正規表現で取ると、空白の長い並びで後戻りが増える。 */
43
+ const lineBreakAt = (text: string, newline: number): { readonly span: Span; readonly trailingSpaces: number } => {
44
+ const lineEnd = text[newline - 1] === "\r" ? newline - 1 : newline;
45
+ const start = spacesUntil(text, lineEnd, -1);
46
+ return { span: { start, end: spacesUntil(text, newline + 1, 1) }, trailingSpaces: lineEnd - start };
47
+ };
48
+
49
+ const isSoft = (text: string, found: { readonly span: Span; readonly trailingSpaces: number }): boolean =>
50
+ found.trailingSpaces < HARD_BREAK_SPACES && joinsAcrossBreak(charBefore(text, found.span.start), charAt(text, found.span.end));
51
+
52
+ /** text の中の、消える改行(前後の行頭・行末の空白ごと)。text の文字だけで決める。 */
53
+ export const softBreaks = (text: string): Span[] =>
54
+ [...text.matchAll(NEWLINE)]
55
+ .map((match) => lineBreakAt(text, match.index))
56
+ .filter((found) => isSoft(text, found))
57
+ .map((found) => found.span);
58
+
59
+ /**
60
+ * 覆った文字(コード・リンクの記号・URL)に触れない、消える改行。source と prose は同じ長さで、同じ位置に同じ文字がある。
61
+ *
62
+ * 覆った文字は空白になっているので、prose だけでは行頭のインデントとインラインコードの跡を見分けられない。
63
+ * 見分けずにつなぐと、行頭の `npm` の前後の語が 1 語になる。
64
+ */
65
+ export const unmaskedSoftBreaks = (source: string, prose: string): Span[] =>
66
+ softBreaks(prose).filter((span) => source.slice(span.start, span.end) === prose.slice(span.start, span.end));
67
+
68
+ /** breaks(text の中の範囲、昇順・重ならない)を取り除いた文字列。 */
69
+ export const withoutSpans = (text: string, breaks: readonly Span[]): string => {
70
+ const parts: string[] = [];
71
+ const cursor = breaks.reduce((from, span) => {
72
+ parts.push(text.slice(from, span.start));
73
+ return span.end;
74
+ }, 0);
75
+ parts.push(text.slice(cursor));
76
+ return parts.join("");
77
+ };
78
+
79
+ /** sorted(昇順・重ならない)の中で、end が offset より後ろの最初の添字。無ければ sorted.length。 */
80
+ export const firstEndingAfter = (sorted: readonly Span[], offset: number): number => {
81
+ const search = (low: number, high: number): number => {
82
+ if (low >= high) return low;
83
+ const middle = (low + high) >> 1;
84
+ return (sorted[middle]?.end ?? Number.POSITIVE_INFINITY) > offset ? search(low, middle) : search(middle + 1, high);
85
+ };
86
+ return search(0, sorted.length);
87
+ };
88
+
89
+ /** sorted(昇順・重ならない)のうち、outer に収まるもの。段落や文ごとに全部をなめない。 */
90
+ export const spansWithin = <T extends Span>(sorted: readonly T[], outer: Span): T[] => {
91
+ const from = firstEndingAfter(sorted, outer.start);
92
+ const to = firstEndingAfter(sorted, outer.end);
93
+ return sorted.slice(from, to).filter((span) => span.start >= outer.start);
94
+ };