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,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,14 +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
- import { buildTree, NO_OUTLINE, type Outline } from "./structure/build.ts";
9
+ import { buildTree, type Outline } from "./structure/build.ts";
9
10
  import { isMarkdownPath } from "./structure/markdown-path.ts";
11
+ import { pageFurniture, textOutline } from "./page-furniture.ts";
12
+ import { tokenizedLexicons } from "./lexicon-tokens.ts";
13
+ import { inPageAnchors, isInPageNavigation, isNavigationList, type InPageAnchors } from "./in-page-nav.ts";
10
14
  import type { BulletList, LanguageAdapter, Paragraph, ProseDocument, Section, Sentence, Span, StructureNode, DocumentProfile } from "./plugin.ts";
11
15
 
12
16
  type Place = { readonly offset?: number | undefined };
13
17
  type Node = {
14
18
  readonly type: string;
19
+ readonly url?: string | undefined;
20
+ readonly value?: string | undefined;
21
+ readonly identifier?: string | undefined;
15
22
  readonly position?: { readonly start: Place; readonly end: Place } | undefined;
16
23
  readonly children?: readonly Node[] | undefined;
17
24
  };
@@ -91,14 +98,14 @@ const matchSpans = (source: string, pattern: RegExp): Span[] =>
91
98
 
92
99
  const directiveSpans = (source: string): Span[] => [...matchSpans(source, DIRECTIVE), ...matchSpans(source, BARE_URL)];
93
100
 
94
- const collectMasks = (root: Node, source: string): Span[] => {
101
+ const collectMasks = (root: Node, source: string, anchors: InPageAnchors): Span[] => {
95
102
  const spans: Span[] = [...directiveSpans(source)];
96
103
  walk(root, (node) => {
97
104
  if (node.type === "link" || node.type === "linkReference") {
98
105
  spans.push(...linkChrome(node));
99
106
  return;
100
107
  }
101
- if (!NOT_PROSE.has(node.type) && node.type !== "heading") return;
108
+ if (!NOT_PROSE.has(node.type) && node.type !== "heading" && !isInPageNavigation(node, anchors)) return;
102
109
  const span = spanOf(node);
103
110
  if (span !== undefined) spans.push(span);
104
111
  });
@@ -134,7 +141,8 @@ const headingsOf = (root: Node, source: string): Heading[] => {
134
141
  if (span === undefined) return;
135
142
  // ATX(行頭が #)なら閉じの # も外す。setext の見出しの末尾の # は言葉なので残す。
136
143
  const read = source.startsWith("#", span.start) ? atxHeadingText : headingText;
137
- 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 });
138
146
  });
139
147
  return found;
140
148
  };
@@ -160,10 +168,10 @@ const strongSpans = (root: Node, masked: readonly Span[]): Span[] => {
160
168
 
161
169
  const within = (span: Span, from: number, to: number): boolean => span.start >= from && span.start < to;
162
170
 
163
- const spansOfType = (root: Node, type: string): Span[] => {
171
+ const spansOfType = (root: Node, type: string, keep: (node: Node) => boolean = () => true): Span[] => {
164
172
  const found: Span[] = [];
165
173
  walk(root, (node) => {
166
- if (node.type !== type) return;
174
+ if (node.type !== type || !keep(node)) return;
167
175
  const span = spanOf(node);
168
176
  if (span !== undefined) found.push(span);
169
177
  });
@@ -216,11 +224,11 @@ const paragraphsOf = (spans: readonly Span[], sentences: readonly Sentence[], li
216
224
  .filter((span) => !listSpans.some((list) => span.start >= list.start && span.start < list.end))
217
225
  .map((span) => ({ span, sentences: sentences.filter((sentence) => within(sentence.span, span.start, span.end)) }));
218
226
 
219
- /** 箇条書きは list ノードの直下の項目を数える。入れ子の項目は内側の list のものとして数える。 */
220
- const listsOf = (root: Node, source: string): BulletList[] => {
227
+ /** 箇条書きは list ノードの直下の項目を数える。入れ子の項目は内側の list のものとして数える。目次のような案内だけの箇条書きは数えない。 */
228
+ const listsOf = (root: Node, source: string, anchors: InPageAnchors): BulletList[] => {
221
229
  const found: BulletList[] = [];
222
230
  walk(root, (node) => {
223
- if (node.type !== "list") return;
231
+ if (node.type !== "list" || isNavigationList(node, anchors)) return;
224
232
  const span = spanOf(node);
225
233
  if (span === undefined) return;
226
234
  const items = (node.children ?? []).flatMap((child) => {
@@ -261,14 +269,24 @@ export const buildDocument = (
261
269
  profile: DocumentProfile | undefined = undefined,
262
270
  ): ProseDocument => {
263
271
  const root = parse(source);
272
+ const anchors = inPageAnchors(root);
264
273
  // 強調の記号は「本文でないもの」だが、太字の数を数えるときの「覆われた場所」ではない。
265
274
  // 同じ集合にすると、太字が自分の記号のせいで覆われた場所にあることになり、1 つも数えられなくなる。
266
- const blocks = collectMasks(root, source);
275
+ // テキストの文書は、ページのヘッダーとフッターも本文ではない(Markdown には改ページが無い)。
276
+ const blocks = [...collectMasks(root, source, anchors), ...(isMarkdownPath(path) ? [] : pageFurniture(source))];
267
277
  const masked = [...blocks, ...emphasisSpans(root, source)];
268
278
  const prose = maskSpans(source, masked);
269
- const paragraphSpans = spansOfType(root, "paragraph");
279
+ // ページの案内は段落としても数えない。数えると、目次の行が「本題までの段落」に入る。
280
+ const paragraphSpans = spansOfType(root, "paragraph", (node) => !isInPageNavigation(node, anchors));
270
281
  const listItems = spansOfType(root, "listItem");
271
282
  const sentences = sentencesOf(prose, paragraphSpans, adapter);
283
+ const lexicons = {
284
+ ...adapter.lexicons,
285
+ "internal-jargon": team.jargon.map((pattern) => ({ pattern })),
286
+ // 使わない書き方を pattern に、使う書き方を instead_of に置く。語彙表の「同じことの別の書き方」と同じ向き。
287
+ "preferred-term": Object.entries(team.prefer ?? {}).map(([pattern, use]) => ({ pattern, instead_of: use })),
288
+ };
289
+ const tagged = sentences.some((sentence) => sentence.tokens !== undefined);
272
290
  const patterns = adapter.structure;
273
291
  // null は「作ったが構造を読めない言語だった」、undefined は「まだ作っていない」。
274
292
  const tree: { value: StructureNode | null | undefined } = { value: undefined };
@@ -282,20 +300,26 @@ export const buildDocument = (
282
300
  sentences,
283
301
  listSpans: listItems,
284
302
  paragraphs: paragraphsOf(paragraphSpans, sentences, listItems),
285
- lists: listsOf(root, source),
286
- lexicons: {
287
- ...adapter.lexicons,
288
- "internal-jargon": team.jargon.map((pattern) => ({ pattern })),
289
- // 使わない書き方を pattern に、使う書き方を instead_of に置く。語彙表の「同じことの別の書き方」と同じ向き。
290
- "preferred-term": Object.entries(team.prefer ?? {}).map(([pattern, use]) => ({ pattern, instead_of: use })),
291
- },
303
+ lists: listsOf(root, source, anchors),
304
+ lexicons: tagged ? tokenizedLexicons(lexicons, adapter) : lexicons,
292
305
  requiredSections: team.requiredSections,
293
306
  // 構造の rule(参照先が無い・番号の抜け)が読む木。どの rule も読まなければ作らない。何万行の契約書で、他の rule の lint に代金を払わせない。
294
307
  get structure(): StructureNode | undefined {
295
308
  tree.value ??=
296
309
  patterns === undefined
297
310
  ? null
298
- : buildTree({ path, source, language: adapter.id, outline: isMarkdownPath(path) ? outlineOf(root, source) : NO_OUTLINE, profile }, patterns);
311
+ : buildTree(
312
+ {
313
+ path,
314
+ source,
315
+ language: adapter.id,
316
+ // テキストの文書は、ページの飾りを覆って読む。フッターの「Section 9」を木の節にしない。
317
+ outline: isMarkdownPath(path) ? outlineOf(root, source) : textOutline(source),
318
+ markdown: isMarkdownPath(path),
319
+ profile,
320
+ },
321
+ patterns,
322
+ );
299
323
  return tree.value ?? undefined;
300
324
  },
301
325
  profile,
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
+ };
@@ -0,0 +1,28 @@
1
+ import type { LanguageAdapter, Lexicon, LexiconEntry } from "./plugin.ts";
2
+
3
+ /** adapter の語彙表は adapter ごとに一度だけ分ける。チームの語彙表は文書ごとに作られるので、この表に残らない。 */
4
+ const split = new WeakMap<LanguageAdapter, WeakMap<Lexicon, Lexicon>>();
5
+
6
+ const splitBy = (adapter: LanguageAdapter): WeakMap<Lexicon, Lexicon> => {
7
+ const known = split.get(adapter) ?? new WeakMap<Lexicon, Lexicon>();
8
+ split.set(adapter, known);
9
+ return known;
10
+ };
11
+
12
+ const tokenized = (entry: LexiconEntry, adapter: LanguageAdapter): LexiconEntry => {
13
+ const tokens = adapter.segment(entry.pattern).sentences.flatMap((sentence) => sentence.tokens ?? []);
14
+ return tokens.length === 0 ? entry : { ...entry, tokens };
15
+ };
16
+
17
+ const withTokens = (lexicon: Lexicon, adapter: LanguageAdapter): Lexicon => {
18
+ const cache = splitBy(adapter);
19
+ const known = cache.get(lexicon);
20
+ if (known !== undefined) return known;
21
+ const made = lexicon.map((entry) => tokenized(entry, adapter));
22
+ cache.set(lexicon, made);
23
+ return made;
24
+ };
25
+
26
+ /** 語彙表の語を、文と同じ解析器で語に分けておく。語彙表の検査が原形で照らせるように。品詞を読んでいない文書には何もしない。 */
27
+ export const tokenizedLexicons = (lexicons: Readonly<Record<string, Lexicon>>, adapter: LanguageAdapter): Readonly<Record<string, Lexicon>> =>
28
+ Object.fromEntries(Object.entries(lexicons).map(([name, lexicon]) => [name, withTokens(lexicon, adapter)]));
@@ -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
  /**
@@ -0,0 +1,45 @@
1
+ import type { Span } from "./plugin.ts";
2
+ import { NO_OUTLINE, type Outline } from "./structure/build.ts";
3
+
4
+ /** 改ページから、飾りの行を探しにいく行数。飾りと改ページのあいだには空行が数行入る。 */
5
+ const REACH = 3;
6
+
7
+ type Line = { readonly start: number; readonly end: number; readonly text: string };
8
+
9
+ const linesOf = (source: string): Line[] => {
10
+ const scan = { start: 0 };
11
+ return source.split("\n").map((text) => {
12
+ const line = { start: scan.start, end: scan.start + text.length, text };
13
+ scan.start = line.end + 1;
14
+ return line;
15
+ });
16
+ };
17
+
18
+ const isBlank = (line: Line | undefined): boolean => line !== undefined && line.text.trim() === "";
19
+
20
+ /** 改ページだけの行。行の途中の \f は本文の一部(写し間違い)で、ページの区切りではない。 */
21
+ const PAGE_BREAK = /^[ \t]*\f[ \t\r]*$/u;
22
+
23
+ /** index から step の向きに、空行を飛ばして最初の空でない行。REACH 行を越えたら探さない。 */
24
+ const nearestText = (lines: readonly Line[], index: number, step: number): Line | undefined => {
25
+ const candidates = Array.from({ length: REACH }, (_, offset) => lines[index + step * (offset + 1)]);
26
+ const skipped = candidates.findIndex((line) => !isBlank(line));
27
+ return skipped === -1 ? undefined : candidates[skipped];
28
+ };
29
+
30
+ /**
31
+ * 紙の版を写したテキスト(RFC など)の、ページのヘッダーとフッター。改ページ(\f)の直前と直後の、空でない 1 行ずつ。
32
+ * ページごとに同じ行が出るので、本文として読むと「同じ言い回しの繰り返し」や長い文になる。
33
+ */
34
+ export const pageFurniture = (source: string): Span[] => {
35
+ const lines = linesOf(source);
36
+ return lines.flatMap((line, index) => {
37
+ if (!PAGE_BREAK.test(line.text)) return [];
38
+ return [nearestText(lines, index, -1), line, nearestText(lines, index, 1)].flatMap((found) =>
39
+ found === undefined ? [] : [{ start: found.start, end: found.end }],
40
+ );
41
+ });
42
+ };
43
+
44
+ /** テキストの文書の外形。見出しもコードも無く、ページの飾りだけを覆う。木を作る入口はどれもこれを使う。 */
45
+ export const textOutline = (source: string): Outline => ({ ...NO_OUTLINE, opaque: pageFurniture(source) });
package/src/plugin.ts CHANGED
@@ -36,7 +36,15 @@ export type LengthUnit = "char" | "word";
36
36
  * `instead_of` は「同じことを言う別の書きかた」。文体の一貫性を見る rule が使う。
37
37
  * 2 つの書きかたのどちらが正しいかは決めず、**1 つの文書で混ざっていないか**だけを見る。
38
38
  */
39
- export type LexiconEntry = { readonly pattern: string; readonly weight?: number | undefined; readonly instead_of?: string | undefined };
39
+ export type LexiconEntry = {
40
+ readonly pattern: string;
41
+ readonly weight?: number | undefined;
42
+ readonly instead_of?: string | undefined;
43
+ /** 語が、かかる語のどちら側に立つか。語順が言語で違うものを語彙表が言う(範囲の「で」は前、"in" は後ろ)。 */
44
+ readonly position?: "before" | "after" | undefined;
45
+ /** pattern を adapter が語に分けたもの。品詞が読めるときだけ core が入れる。語彙表を書く側は書かない。 */
46
+ readonly tokens?: readonly Token[] | undefined;
47
+ };
40
48
 
41
49
  export type Lexicon = readonly LexiconEntry[];
42
50
 
@@ -338,11 +346,15 @@ export type RuleDefinition = {
338
346
  readonly by_genre: Readonly<Record<string, LevelTable>>;
339
347
  readonly how_to_find: string;
340
348
  readonly word_list: string | undefined;
349
+ /** word_list のほかに detector が名前で引く語彙表。どれかが無い言語では rule を動かさない。 */
350
+ readonly extra_word_lists: readonly string[];
341
351
  /** L4 のみ。LLM に渡す決まり。言語別。 */
342
352
  readonly what_to_check: Localized | undefined;
343
353
  readonly where: string | undefined;
344
354
  /** adapter に要る capability。"pos" / "lemma"。満たさなければ動かさない。spec §16。 */
345
355
  readonly requires: readonly string[];
356
+ /** 使えるなら用意してほしい capability("pos")。requires と違い、満たせなくても動かす(品詞が無ければ文字だけで見る)。 */
357
+ readonly uses: readonly string[];
346
358
  /** 複合シグナル。ここに並べた rule のうち何本が出たかを見る。spec §20.2。 */
347
359
  readonly from: readonly string[];
348
360
  /** 動かす言語。未指定は全言語。「ですます調」のように言語に固有の rule が使う。 */
@@ -1,10 +1,19 @@
1
1
  import type { RuleDefinition } from "../plugin.ts";
2
2
  import type { RunResult } from "../run.ts";
3
3
  import { messageOf } from "./text.ts";
4
+ import { uiLanguageOf, type Texts } from "../ui.ts";
4
5
 
5
6
  /** 「1 finding」「2 findings」。 */
6
7
  const counted = (count: number, noun: string): string => `${String(count)} ${noun}${count === 1 ? "" : "s"}`;
7
8
 
9
+ /**
10
+ * 最後の集計の行は、画面のほかの部分と同じく文書の言語で出す。行ごとの重さ(warning / error)は、grep や別の道具が読むので英語のまま。
11
+ */
12
+ const TALLY: Texts<{ readonly findings: (n: number) => string; readonly notRun: (n: number) => string; readonly separator: string }> = {
13
+ ja: { findings: (n) => `指摘 ${String(n)} 件`, notRun: (n) => `動いていない rule ${String(n)} 件`, separator: "、" },
14
+ en: { findings: (n) => counted(n, "finding"), notRun: (n) => `${counted(n, "rule")} not run`, separator: ", " },
15
+ };
16
+
8
17
  /** エンジニア向け。1 件 2 行。 */
9
18
  export const renderCompact = (header: string, result: RunResult, rules: readonly RuleDefinition[], language: string): string => {
10
19
  const byId = new Map(rules.map((rule) => [rule.id, rule]));
@@ -14,8 +23,9 @@ export const renderCompact = (header: string, result: RunResult, rules: readonly
14
23
  const at = `${finding.line}:${finding.column}`.padEnd(8);
15
24
  return [` ${at}${finding.severity.padEnd(8)}${messageOf(rule, finding, language)}`, ` ${" ".repeat(8)}${finding.rule}`];
16
25
  });
17
- const tail = [counted(result.findings.length, "finding"), result.skipped.length > 0 ? `${counted(result.skipped.length, "rule")} not run` : undefined].filter(
26
+ const text = TALLY[uiLanguageOf(language)];
27
+ const tail = [text.findings(result.findings.length), result.skipped.length > 0 ? text.notRun(result.skipped.length) : undefined].filter(
18
28
  (part) => part !== undefined,
19
29
  );
20
- return ["", header, "", ...lines, "", tail.join(", "), ""].join("\n");
30
+ return ["", header, "", ...lines, "", tail.join(text.separator), ""].join("\n");
21
31
  };
package/src/rule-load.ts CHANGED
@@ -99,9 +99,11 @@ 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"]) ?? [],
106
+ uses: stringList(raw["uses"]) ?? [],
105
107
  from: stringList(raw["from"]) ?? [],
106
108
  languages: stringList(raw["languages"]),
107
109
  use_for: Array.isArray(raw["use_for"]) ? raw["use_for"].map((entry) => String(entry)) : [],
package/src/run.ts CHANGED
@@ -4,8 +4,11 @@ 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";
10
+ import { maskSpans } from "./mask.ts";
11
+ import { textOutline } from "./page-furniture.ts";
9
12
  import { uiLanguageOf } from "./ui.ts";
10
13
 
11
14
  export type Skipped = { readonly rule: string; readonly why: string };
@@ -51,8 +54,9 @@ const treeProblem = (doc: ProseDocument): string | undefined => {
51
54
  return unread === undefined ? undefined : reasonsFor(doc).unreadStructure(unread.clauses, unread.units);
52
55
  };
53
56
 
54
- /** 木が読んだのと同じ本文。Markdown はコードを覆ったもの、.txt はそのまま(.txt の字下げはコードではない)。 */
55
- const textTheTreeRead = (doc: ProseDocument): string => (isMarkdownPath(doc.path) ? (doc.prose ?? doc.source) : doc.source);
57
+ /** 木が読んだのと同じ本文。Markdown はコードを覆ったもの、.txt はページの飾りだけを覆ったもの(.txt の字下げはコードではない)。 */
58
+ const textTheTreeRead = (doc: ProseDocument): string =>
59
+ isMarkdownPath(doc.path) ? (doc.prose ?? doc.source) : maskSpans(doc.source, textOutline(doc.source).opaque);
56
60
 
57
61
  const unreadByDocument = new WeakMap<ProseDocument, Unread | undefined>();
58
62
 
@@ -104,7 +108,7 @@ export const neededBy = (rules: readonly RuleDefinition[], settings: Settings, e
104
108
  pos: forGenre(rules, genre)
105
109
  .filter((rule) => rule.layer !== "L4" && levelFor(rule, settings, experimental) !== "off")
106
110
  .filter((rule) => rule.languages === undefined || rule.languages.includes(language))
107
- .some((rule) => rule.requires.includes("pos") || rule.requires.includes("lemma")),
111
+ .some((rule) => [...rule.requires, ...rule.uses].some((need) => need === "pos" || need === "lemma")),
108
112
  });
109
113
 
110
114
  const place = (starts: readonly number[], finding: Finding): Finding => {
@@ -178,15 +182,15 @@ export const runRules = (
178
182
  const detector = DETECTORS[rule.how_to_find];
179
183
  if (detector === undefined)
180
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) }] };
181
189
  const options = {
182
190
  limit: limitFor(rule, level, genre, limits),
183
191
  lexicon: rule.word_list === undefined ? undefined : doc.lexicons[rule.word_list],
184
192
  where: rule.where,
185
193
  };
186
- // 語彙表を要求する rule で、その言語に語彙表が無ければ動かせない。黙って通さない。
187
- if (rule.word_list !== undefined && options.lexicon === undefined) {
188
- return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: reasonsFor(doc).noLexicon(doc.language, rule.word_list) }] };
189
- }
190
194
  const found = detector(doc, options).map((finding) => place(starts, { ...finding, rule: rule.id, severity: rule.severity }));
191
195
  return { findings: [...acc.findings, ...found], skipped: acc.skipped };
192
196
  },
@@ -37,4 +37,20 @@ const beforeTrailing = (sentence: Sentence): readonly Token[] => {
37
37
  return kept.length === 0 ? tokens : kept;
38
38
  };
39
39
 
40
+ /** 閉じた引用。文がこれで終われば、文末は引用した文のもので、書き手の文末ではない。 */
41
+ const CLOSING_QUOTES = ["」", "』", "\u201d"];
42
+
43
+ /**
44
+ * 文末の語: 句読点・終助詞・添えた括弧を除いた後ろから、助動詞の連なりと、その前の述語一つ。
45
+ * 「測定しません」なら「し・ませ・ん」、「十分でしたか」なら「十分・でし・た」。文の途中の引用(「ご覧ください」という)は入らない。
46
+ * 引用で終わる文(画面には「確認します。」。)は、書き手の文末が無いので空。
47
+ */
48
+ export const endingTokens = (sentence: Sentence): Token[] => {
49
+ const reversed = [...beforeTrailing(sentence)].reverse();
50
+ const last = reversed.findIndex((token) => !SKIP.has(token.pos));
51
+ if (last === -1 || reversed.slice(0, last).some((token) => CLOSING_QUOTES.some((quote) => token.surface.includes(quote)))) return [];
52
+ const head = reversed.findIndex((token, index) => index >= last && token.pos !== "AUX");
53
+ return reversed.slice(last, head === -1 ? reversed.length : head + 1).reverse();
54
+ };
55
+
40
56
  export const lastContent = (sentence: Sentence): Token | undefined => [...beforeTrailing(sentence)].reverse().find((token) => !SKIP.has(token.pos));