@shuji-bonji/rfcxml-mcp 0.6.13 → 0.6.53

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 (67) hide show
  1. package/README.ja.md +178 -50
  2. package/README.md +181 -51
  3. package/dist/cli/prefetch.d.ts +6 -1
  4. package/dist/cli/prefetch.d.ts.map +1 -1
  5. package/dist/cli/prefetch.js +53 -23
  6. package/dist/cli/prefetch.js.map +1 -1
  7. package/dist/config.d.ts +0 -2
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +3 -2
  10. package/dist/config.js.map +1 -1
  11. package/dist/constants.d.ts +16 -0
  12. package/dist/constants.d.ts.map +1 -1
  13. package/dist/constants.js +80 -7
  14. package/dist/constants.js.map +1 -1
  15. package/dist/services/checklist-generator.d.ts.map +1 -1
  16. package/dist/services/checklist-generator.js +72 -7
  17. package/dist/services/checklist-generator.js.map +1 -1
  18. package/dist/services/rfc-fetcher.d.ts +19 -1
  19. package/dist/services/rfc-fetcher.d.ts.map +1 -1
  20. package/dist/services/rfc-fetcher.js +116 -19
  21. package/dist/services/rfc-fetcher.js.map +1 -1
  22. package/dist/services/rfc-service.d.ts +11 -1
  23. package/dist/services/rfc-service.d.ts.map +1 -1
  24. package/dist/services/rfc-service.js +45 -5
  25. package/dist/services/rfc-service.js.map +1 -1
  26. package/dist/services/rfc-text-parser.d.ts.map +1 -1
  27. package/dist/services/rfc-text-parser.js +1594 -88
  28. package/dist/services/rfc-text-parser.js.map +1 -1
  29. package/dist/services/rfcxml-parser.d.ts.map +1 -1
  30. package/dist/services/rfcxml-parser.js +366 -98
  31. package/dist/services/rfcxml-parser.js.map +1 -1
  32. package/dist/tools/definitions.d.ts +8 -0
  33. package/dist/tools/definitions.d.ts.map +1 -1
  34. package/dist/tools/definitions.js +17 -1
  35. package/dist/tools/definitions.js.map +1 -1
  36. package/dist/tools/handlers.d.ts +24 -12
  37. package/dist/tools/handlers.d.ts.map +1 -1
  38. package/dist/tools/handlers.js +161 -36
  39. package/dist/tools/handlers.js.map +1 -1
  40. package/dist/types/index.d.ts +22 -2
  41. package/dist/types/index.d.ts.map +1 -1
  42. package/dist/utils/cache.d.ts +19 -0
  43. package/dist/utils/cache.d.ts.map +1 -1
  44. package/dist/utils/cache.js +32 -0
  45. package/dist/utils/cache.js.map +1 -1
  46. package/dist/utils/disk-cache.d.ts +10 -6
  47. package/dist/utils/disk-cache.d.ts.map +1 -1
  48. package/dist/utils/disk-cache.js +13 -9
  49. package/dist/utils/disk-cache.js.map +1 -1
  50. package/dist/utils/logger.d.ts.map +1 -1
  51. package/dist/utils/logger.js +3 -1
  52. package/dist/utils/logger.js.map +1 -1
  53. package/dist/utils/requirement-extractor.d.ts.map +1 -1
  54. package/dist/utils/requirement-extractor.js +413 -21
  55. package/dist/utils/requirement-extractor.js.map +1 -1
  56. package/dist/utils/section.d.ts.map +1 -1
  57. package/dist/utils/section.js +11 -0
  58. package/dist/utils/section.js.map +1 -1
  59. package/dist/utils/statement-matcher.d.ts +69 -4
  60. package/dist/utils/statement-matcher.d.ts.map +1 -1
  61. package/dist/utils/statement-matcher.js +524 -48
  62. package/dist/utils/statement-matcher.js.map +1 -1
  63. package/dist/utils/text.d.ts +62 -0
  64. package/dist/utils/text.d.ts.map +1 -1
  65. package/dist/utils/text.js +273 -18
  66. package/dist/utils/text.js.map +1 -1
  67. package/package.json +5 -2
@@ -3,7 +3,7 @@
3
3
  * XMLが利用できない古いRFC用のフォールバック解析
4
4
  */
5
5
  import { createRequirementRegex, SECTION_HEADER_PATTERN } from '../constants.js';
6
- import { extractCrossReferences, looksLikeDiagram } from '../utils/text.js';
6
+ import { extractCrossReferences, extractRequirementMarkers as extractMarkers, dropNonDefinitions, clipAtWord, SENTENCE_OPENER, RELATIVE_CLAUSE, looksLikeDiagram, } from '../utils/text.js';
7
7
  import { extractRequirementsFromSections, } from '../utils/requirement-extractor.js';
8
8
  // ========================================
9
9
  // Text Parser Configuration
@@ -14,10 +14,18 @@ import { extractRequirementsFromSections, } from '../utils/requirement-extractor
14
14
  const METADATA_EXTRACTION = {
15
15
  /** タイトル検索で走査する最大行数 */
16
16
  MAX_LINES_TO_SCAN: 30,
17
+ /** 字下げ無しでも題名とみなす最小文字数(行幅いっぱいの題名) */
18
+ WIDE_TITLE_MIN_LENGTH: 60,
17
19
  /** タイトルとして有効な最小文字数 */
18
20
  TITLE_MIN_LENGTH: 10,
19
- /** タイトルとして有効な最大文字数 */
20
- TITLE_MAX_LENGTH: 100,
21
+ /**
22
+ * タイトルとして有効な最大文字数。
23
+ *
24
+ * RFC 1521 の "MIME (Multipurpose Internet Mail Extensions) Part One:
25
+ * Mechanisms for Specifying and Describing the Format of Internet Message
26
+ * Bodies" は 133 文字あり、100 では落ちて `metadata.title` が空になっていた。
27
+ */
28
+ TITLE_MAX_LENGTH: 200,
21
29
  };
22
30
  /**
23
31
  * 定義抽出の設定
@@ -27,12 +35,14 @@ const DEFINITION_EXTRACTION = {
27
35
  MIN_TERM_LENGTH: 2,
28
36
  /** 定義として認識する最小文字数 */
29
37
  MIN_DEFINITION_LENGTH: 10,
38
+ /** 用語として認識する最大語数。これを超えるものは文である。 */
39
+ MAX_TERM_WORDS: 5,
30
40
  };
31
41
  /**
32
42
  * RFC テキストをパースして構造化データに変換(中精度)
33
43
  */
34
44
  export function parseRFCText(text, rfcNumber) {
35
- const lines = stripPageFurniture(text).split('\n');
45
+ const lines = dedent(stripPageFurniture(text).split('\n'));
36
46
  return {
37
47
  metadata: extractTextMetadata(lines, rfcNumber),
38
48
  sections: extractTextSections(lines),
@@ -40,8 +50,75 @@ export function parseRFCText(text, rfcNumber) {
40
50
  definitions: extractTextDefinitions(lines),
41
51
  };
42
52
  }
43
- /** ページ末尾の行。"Fielding & Reschke Standards Track [Page 29]" */
44
- const PAGE_FOOTER = /\[Page\s+\d+\]\s*$/;
53
+ /**
54
+ * 文書全体が字下げされているとき、その分を外す。
55
+ *
56
+ * RFC 822 は本文も見出しも 5 桁目から組む。節見出しは 1 桁目から始まる前提で
57
+ * 見ているため、**節が 1 つも取れていなかった**(取れていた 104 件はページの
58
+ * 柱と本文の折り返しだった)。
59
+ *
60
+ * 実測(RFC 142 本): 最小の字下げが 1 桁以上あるのは RFC 822 だけで、
61
+ * ほかはすべて 0 桁である。外して困る RFC は無い。
62
+ */
63
+ function dedent(lines) {
64
+ let margin = Number.POSITIVE_INFINITY;
65
+ for (const line of lines) {
66
+ if (!line.trim())
67
+ continue;
68
+ margin = Math.min(margin, line.length - line.trimStart().length);
69
+ if (margin === 0)
70
+ return lines;
71
+ }
72
+ if (!Number.isFinite(margin) || margin === 0)
73
+ return lines;
74
+ return lines.map((line) => (line.trim() ? line.slice(margin) : line));
75
+ }
76
+ /**
77
+ * ページ末尾の行。"Fielding & Reschke Standards Track [Page 29]"
78
+ *
79
+ * 1 桁目に `[Page N]` を置く RFC がある。RFC 821 は
80
+ * `[Page 68] Postel`
81
+ * と書く。行末だけを見ていたため、この形のフッタが本文として残り、
82
+ * `[Page 68]` が参考文献の項目として拾われ、節と相互参照も狂っていた。
83
+ *
84
+ * `[Page N]` を使わず、中央に `- N -` と書く RFC がある。RFC 822 は
85
+ * ` August 13, 1982 - 1 - RFC #822`
86
+ * と書く。フッタと分からないため、次のページの先頭にある柱
87
+ * (` Standard for ARPA Internet Text Messages`)が本文に残り、
88
+ * **それが節として 40 件立っていた**(RFC 822 の節 104 件のうち)。
89
+ *
90
+ * `- N -` の形は本文の減算の式にも当たる。RFC 7049 §2.4.2 の
91
+ * `n. For tag value 3, the value of the bignum is -1 - n. Decoders` と
92
+ * RFC 4271 §4.3 の `UPDATE message Length - 23 - Total Path Attributes Length`
93
+ * が丸ごと落ち、前後の文が 1 つに繋がっていた。この形は RFC 822 系の古い
94
+ * フッタにしか無いので、**日付(西暦)か `RFC #N` を伴う行、または他の語が
95
+ * 無い行に限る**(`isCenteredPageNumber`)。
96
+ *
97
+ * `[page N]` と小文字で書く RFC がある(RFC 768・1191)。大文字だけを見て
98
+ * いたため RFC 768 のフッタが残り、次のページの柱 `28 Aug 1980` が唯一の節に
99
+ * なっていた。大文字小文字を区別しない。
100
+ */
101
+ const PAGE_FOOTER = /\[Page\s+\d+\]\s*$|^\s*\[Page\s+\d+\]/i;
102
+ /** 中央に置いたページ番号。`- 1 -`。 */
103
+ const CENTERED_PAGE_NUMBER = /(?:^|\s)-\s*\d{1,4}\s*-(?:\s|$)/;
104
+ /** ページ番号のほかに置いてよい語。日付か `RFC #822` だけ。 */
105
+ const PAGE_NUMBER_COMPANION = /\b(?:19|20)\d{2}\b|\bRFC\s*#?\s*\d+\b/;
106
+ /**
107
+ * `- N -` だけを頼りにフッタと言えるか。
108
+ *
109
+ * `- N -` を外した残りが空か、日付・`RFC #N` を含むときだけフッタとする。
110
+ * 減算の式(`-1 - n. Decoders`)は語が残るので当たらない。
111
+ */
112
+ function isCenteredPageNumber(line) {
113
+ if (!CENTERED_PAGE_NUMBER.test(line))
114
+ return false;
115
+ const rest = line.replace(CENTERED_PAGE_NUMBER, ' ').trim();
116
+ return rest === '' || PAGE_NUMBER_COMPANION.test(rest);
117
+ }
118
+ /** ページの区切りの行か。 */
119
+ function isPageFooter(line) {
120
+ return PAGE_FOOTER.test(line) || isCenteredPageNumber(line);
121
+ }
45
122
  /** ページ先頭の行。1 桁目から始まり、末尾が発行年月。 */
46
123
  const PAGE_HEADER = /^\S.*\b(19|20)\d{2}\s*$/;
47
124
  /**
@@ -73,7 +150,7 @@ export function stripPageFurniture(text) {
73
150
  let i = 0;
74
151
  while (i < lines.length) {
75
152
  const line = lines[i];
76
- if (!PAGE_FOOTER.test(line)) {
153
+ if (!isPageFooter(line)) {
77
154
  out.push(line);
78
155
  i++;
79
156
  continue;
@@ -83,27 +160,57 @@ export function stripPageFurniture(text) {
83
160
  out.pop();
84
161
  i++;
85
162
  // 改ページ(\f)とその前後の空行
163
+ //
164
+ // 改ページと柱を同じ行に書く RFC がある。RFC 1661 は
165
+ // `\fRFC 1661 Point-to-Point Protocol July 1994`
166
+ // と書く。この行はここで落ちるので、**柱はもう消えている**。
167
+ // 気づかずに次の行も落としていたため、`1.2. Terminology` `2. PPP
168
+ // Encapsulation` のような、ページの先頭に来る見出しが消えていた
169
+ // (RFC 1661 の目次にある 20 節が構造に無かった)。
86
170
  let sawFormFeed = false;
171
+ let headerConsumed = false;
87
172
  while (i < lines.length && (lines[i].trim() === '' || lines[i].includes('\f'))) {
88
- if (lines[i].includes('\f'))
173
+ if (lines[i].includes('\f')) {
89
174
  sawFormFeed = true;
175
+ if (lines[i].replace(/\f/g, '').trim() !== '')
176
+ headerConsumed = true;
177
+ }
90
178
  i++;
91
179
  }
92
180
  // ページ先頭の見出し行。改ページがあったか、行の形が見出しらしいときだけ落とす。
93
- if (i < lines.length && (sawFormFeed || PAGE_HEADER.test(lines[i]))) {
181
+ if (i < lines.length && !headerConsumed && (sawFormFeed || PAGE_HEADER.test(lines[i]))) {
94
182
  i++;
95
183
  while (i < lines.length && lines[i].trim() === '')
96
184
  i++;
97
185
  }
98
186
  // 文が途中なら段落を続ける。終わっていれば段落の切れ目として残す。
187
+ //
188
+ // 値の並びは文末記号を持たないが、文の途中でもない。RFC 6749 §4.3.2 の
189
+ // 例示 `grant_type=password&username=johndoe&password=A3ddj3w` はページの
190
+ // 終わりに置かれており、次のページの `The authorization server MUST:` と
191
+ // 同じ段落になっていた。要件文がその 2 つを繋いだものになる。
99
192
  const previous = out.length > 0 ? out[out.length - 1] : '';
100
- if (!previous.trim() || /[.:;?!]\s*$/.test(previous))
193
+ if (!previous.trim() || /[.:;?!]\s*$/.test(previous) || looksLikeDisplayBlock(previous)) {
101
194
  out.push('');
195
+ }
102
196
  }
103
197
  return out.join('\n');
104
198
  }
105
- /** 参考文献の欄の見出し。番号の有無どちらもある。 */
106
- const REFERENCE_HEADING_PATTERN = /^(?:\d+(?:\.\d+)*\.?\s+)?((?:normative|informative)\s+)?references\s*$/i;
199
+ /**
200
+ * 参考文献の欄の見出し。番号の有無どちらもある。
201
+ *
202
+ * `References` だけを見ていたため、`Bibliography` と書く RFC の参考文献が
203
+ * 1 件も取れていなかった。`get_rfc_dependencies` が空を返す。
204
+ *
205
+ * | RFC | 見出し |
206
+ * |---|---|
207
+ * | 1034 / 1035 / 1058 | `REFERENCES and BIBLIOGRAPHY` |
208
+ * | 2822 | `6. Bibliography` |
209
+ *
210
+ * 参考文献の欄が本当に無い RFC もある(RFC 854・896・2045 は本文の中で引く)。
211
+ * 見出しの語を増やしても、そちらは 0 件のままである。
212
+ */
213
+ const REFERENCE_HEADING_PATTERN = /^(?:\d+(?:\.\d+)*\.?\s+)?((?:normative|informative)\s+)?(?:references(?:\s+and\s+bibliography)?|bibliography)\s*$/i;
107
214
  /**
108
215
  * 参考文献の 1 項目の始まり。
109
216
  *
@@ -114,7 +221,45 @@ const REFERENCE_HEADING_PATTERN = /^(?:\d+(?:\.\d+)*\.?\s+)?((?:normative|inform
114
221
  *
115
222
  * 見出しの角括弧は 4 桁目から始まり、続きの行はさらに深く字下げされる。
116
223
  */
117
- const REFERENCE_ENTRY_PATTERN = /^ {0,6}\[([^\]\s][^\]]*)\]/;
224
+ /**
225
+ * 付録の始まり。参考文献の欄はここで終わる。
226
+ *
227
+ * `Appendix A` と書く RFC のほか、大文字 1 文字だけで始める RFC がある。
228
+ * RFC 2328 は `A. OSPF data formats` と書く。この形を見ていなかったため
229
+ * 参考文献の欄が閉じず、**付録の本文が最後の参照項目に丸ごと入り**、
230
+ * 付録の見出し 22 節も構造から落ちていた。
231
+ *
232
+ * 参照の続きの行と区別するため、**読点を含まない短い題名**であることを求める。
233
+ * `B. Braden, R., "Requirements for Internet Hosts", …` のように著者の頭文字が
234
+ * 1 桁目に来る RFC があり(RFC 1812)、これを付録と読むと参考文献の欄が
235
+ * そこで閉じて参照が 0 件になる。
236
+ */
237
+ const APPENDIX_START_PATTERN = /^(?:APPENDIX\s+[A-Z]\b|Appendix\s+[A-Z]\b|[A-Z][.:]\s{1,3}[A-Z][^,]{0,58}$)/;
238
+ /**
239
+ * 参考文献の 1 項目の始まり。
240
+ *
241
+ * 角括弧で目印を書くのが普通だが、番号だけを書く RFC がある。RFC 3164 は
242
+ *
243
+ * ```
244
+ * 1 Postel, J., "User Datagram Protocol", STD 6, RFC 768, August 1980.
245
+ *
246
+ * 2 Crocker, D. and P. Overell, "Augmented BNF for Syntax
247
+ * Specifications: ABNF", RFC 2234, November 1997.
248
+ * ```
249
+ *
250
+ * と書く。角括弧だけを見ていたため、参考文献の欄はあるのに参照が 0 件だった。
251
+ * 番号のあとにピリオドを置く RFC もある(RFC 951 の ` 1. Ross Finlayson, …`)。
252
+ * 番号の形は、**字下げされていること**と、後ろに空白 2 個以上と大文字が来ることを
253
+ * 求める。字下げを求めないと、参考文献のあとに続く節見出しを項目として拾う
254
+ * (RFC 3629 の `14. Informative References` `16. Intellectual Property
255
+ * Statement`)。
256
+ *
257
+ * `TAG:n` の形は目印だけを 1 行に置く。RFC 1812 は `ARCH:8.` と `ARCH:9`
258
+ * (句点無し)を混ぜて書く。句点を必須にしていたため 104 件のうち 19 件が
259
+ * 直前の項目に繋がれ、ARCH:8 の題名が ARCH:9 のものになっていた。落ちた行は
260
+ * `hangingDefinition()` にも当たり、参考文献の項目が用語として出ていた。
261
+ */
262
+ const REFERENCE_ENTRY_PATTERN = /^(?: {0,6}\[([^\]\s][^\]]*)\]| {1,6}(\d{1,3})\.?\s{2,}(?=[A-Z"])| {0,6}([A-Z][A-Z0-9]*:\d+)\.?\s*$)/;
118
263
  /**
119
264
  * 参考文献の欄("14.1 Normative References" / "14.2 Informative References")から
120
265
  * 参照を取り出す。
@@ -166,7 +311,7 @@ function extractTextReferences(lines, currentRfcNumber) {
166
311
  const entryStart = bucket ? line.match(REFERENCE_ENTRY_PATTERN) : null;
167
312
  if (entryStart) {
168
313
  flush();
169
- anchor = entryStart[1].trim();
314
+ anchor = (entryStart[1] ?? entryStart[2] ?? entryStart[3]).trim();
170
315
  buffer = [line];
171
316
  continue;
172
317
  }
@@ -179,6 +324,22 @@ function extractTextReferences(lines, currentRfcNumber) {
179
324
  }
180
325
  // 1 桁目から始まる行だけが見出しになりうる。
181
326
  if (!/^\s/.test(line)) {
327
+ // 付録の見出しで欄を閉じる。`Appendix A. Example JWTs` は
328
+ // `isValidSectionHeader` を通らないので、下の節見出しの判定では閉じない。
329
+ // 閉じないまま付録の本文を読み続け、1 桁目の `[JWS]` のような行を
330
+ // 参照項目として拾っていた。実測(RFC 67 本)で 5 件。
331
+ //
332
+ // | RFC | 取り込んでいた題名 |
333
+ // |---|---|
334
+ // | 3986 | `[RFC2234]` → `?` |
335
+ // | 6749 | `[W3C.REC-html401-19991224]` → `application/x-www-form-urlencoded` |
336
+ // | 7231 | `[RFC2045]` → `text`、`[RFC7230]` → `about:blank` |
337
+ // | 7519 | `[JWS]` → `alg":"RSA1_5` |
338
+ if (APPENDIX_START_PATTERN.test(line)) {
339
+ flush();
340
+ bucket = null;
341
+ continue;
342
+ }
182
343
  const headerMatch = line.match(SECTION_HEADER_PATTERN);
183
344
  const heading = headerMatch
184
345
  ? isValidSectionHeader(headerMatch[1].replace(/\.$/, ''), headerMatch[2])
@@ -197,8 +358,22 @@ function extractTextReferences(lines, currentRfcNumber) {
197
358
  if (headerMatch) {
198
359
  flush();
199
360
  bucket = null;
361
+ continue;
200
362
  }
201
363
  }
364
+ // 1 桁目から続く項目の本体。RFC 1305 は目印だけを 1 行に置き、
365
+ // 引用を次の行から 1 桁目で書く。
366
+ //
367
+ // ```
368
+ // [ABA89]
369
+ //
370
+ // Abate, et al. AT&T's new approach to the synchronization of
371
+ // telecommunication networks. IEEE Communications Magazine (April 1989).
372
+ // ```
373
+ //
374
+ // 落としていたため、48 件の題名が目印(`ABA89`)のままだった。
375
+ if (bucket && anchor)
376
+ buffer.push(line);
202
377
  continue;
203
378
  }
204
379
  if (!bucket)
@@ -209,6 +384,59 @@ function extractTextReferences(lines, currentRfcNumber) {
209
384
  flush();
210
385
  return result;
211
386
  }
387
+ /**
388
+ * 参考文献の項目から、二重引用符で囲まれた題名を取る。
389
+ *
390
+ * 閉じ引用符は「読点・句点・セミコロン・空白のいずれかが続く引用符、または
391
+ * 項目の末尾の引用符」に限る。最初の `"…"` をそのまま採ると、題名の中に
392
+ * 引用符が入る項目で途中まで切れる。RFC 5280 の
393
+ *
394
+ * ```
395
+ * [RFC3454] Hoffman, P. and M. Blanchet, "Preparation of
396
+ * Internationalized Strings ("stringprep")", RFC 3454,
397
+ * December 2002.
398
+ * ```
399
+ *
400
+ * は題名が `Preparation of Internationalized Strings (` で終わっていた。
401
+ *
402
+ * 閉じ引用符は、次が読点・句点・セミコロンまたは項目の末尾になる **最後の**
403
+ * 引用符とする。RFC 5246 の
404
+ * `"Methods for Avoiding the "Small-Subgroup" Attacks on … S/MIME", RFC 2785`
405
+ * は `"Small-Subgroup"` の閉じのあとが空白なので、そこで切ると
406
+ * `Methods for Avoiding the "Small-Subgroup` になる。
407
+ * 読点・句点で終わる引用符が 1 つも無いときだけ、空白が続くものを採る
408
+ * (RFC 1123 の `"Addendum to RFC-987," S. Kille, RFC-???` は読点が引用符の中)。
409
+ */
410
+ function quotedTitle(entry) {
411
+ const open = entry.indexOf('"');
412
+ if (open < 0)
413
+ return null;
414
+ let closer = -1;
415
+ let fallback = -1;
416
+ for (let i = open + 1; i < entry.length; i++) {
417
+ if (entry[i] !== '"')
418
+ continue;
419
+ const after = entry[i + 1];
420
+ if (after === undefined || /[,.;]/.test(after))
421
+ closer = i;
422
+ else if (/\s/.test(after))
423
+ fallback = i;
424
+ }
425
+ const at = closer >= 0 ? closer : fallback;
426
+ if (at <= open)
427
+ return null;
428
+ // 題名の中に残る引用符。数が奇数のときは、対になっていない=行送りで
429
+ // 題名が割れたときの書き足しなので、落とす。RFC 1122 の
430
+ // `"A Standard for the Transmission of IP Datagrams over IEEE 802\n "Networks,"`
431
+ // は 2 行目の頭に引用符を置き直しており、`802 "Networks` になっていた。
432
+ const inner = entry.slice(open + 1, at);
433
+ const title = (inner.split('"').length - 1) % 2 === 1 ? inner.replace(/"/g, '') : inner;
434
+ return { full: entry.slice(open, at + 1), title };
435
+ }
436
+ /** 正誤表の項目の目印。`[Err3607]`。 */
437
+ const ERRATA_ANCHOR = /^Err\d+$/i;
438
+ /** 正誤表の項目の題名。`RFC Errata, Erratum ID 3607, RFC 4627, <…>`。 */
439
+ const ERRATA_TITLE = /^RFC\s+Errata\b/i;
212
440
  /**
213
441
  * 参考文献の 1 項目を `RFCReference` にする。
214
442
  *
@@ -220,19 +448,44 @@ function extractTextReferences(lines, currentRfcNumber) {
220
448
  */
221
449
  function parseTextReference(anchor, rawEntry, type) {
222
450
  const entry = rawEntry.replace(/\s+/g, ' ').trim();
451
+ const quoted = quotedTitle(entry);
223
452
  let rfcNumber;
224
453
  const fromAnchor = /^RFC[\s-]*(\d+)$/i.exec(anchor);
225
454
  if (fromAnchor) {
226
455
  rfcNumber = parseInt(fromAnchor[1], 10);
227
456
  }
228
457
  else {
458
+ // 題名を外してから探す。
459
+ //
460
+ // RFC 1123 の `[SMTP:5b] "Addendum to RFC-987," S. Kille, RFC-???, …` は
461
+ // 題名の中の 987 を拾い、実在しない別文書を指していた。
462
+ //
463
+ // **最初の**番号を採る。項目のあとに注釈の段落が続く RFC があり、
464
+ // 最後の番号を採ると注釈の中の番号になる。RFC 1123 の
465
+ // `[DNS:1] "Domain Names - Concepts and Facilities," P. Mockapetris,
466
+ // RFC-1034, November 1987.` は、注釈の
467
+ // "obsolete RFC-882, RFC-883, RFC-973" から 973 を拾っていた。
468
+ const body = quoted ? entry.replace(quoted.full, ' ') : entry;
229
469
  // "RFC 2119" と "RFC-817"(古い RFC の書き方)の双方を拾う
230
- const inline = [...entry.matchAll(/\bRFC[\s-]+(\d+)\b/g)];
231
- if (inline.length > 0) {
232
- rfcNumber = parseInt(inline[inline.length - 1][1], 10);
470
+ const inline = /\bRFC[\s-]+(\d+)\b/.exec(body);
471
+ if (inline) {
472
+ rfcNumber = parseInt(inline[1], 10);
233
473
  }
234
474
  }
235
- const title = /"([^"]+)"/.exec(entry)?.[1].trim() || titleWithoutQuotes(entry);
475
+ // 題名の引用符の中に読点が入る(`"Assigned Numbers," J. Reynolds, …`)。
476
+ // 実測(テキスト経路の参照 859 件)で 121 件(14.1%)が読点で終わっていた。
477
+ const quotedText = quoted ? quoted.title.trim() : '';
478
+ const title = (quotedText || titleWithoutQuotes(entry) || '').replace(/[,;]$/, '');
479
+ // 正誤表の項目は RFC ではない。`[Err3607] RFC Errata, Erratum ID 3607,
480
+ // RFC 4627, <…>` から番号を拾うと、`get_rfc_dependencies` が
481
+ // 「RFC 8259 は RFC 4627 に依存する」と言うことになる。RFC 8259 は 4627 を
482
+ // 廃止した側である。RFC 7159 は `[Err3607]` に 3607、`[Err607]` に 607 と、
483
+ // 正誤表の番号そのものを RFC 番号として付けていた。
484
+ // RFCXML 経路は番号を持たないので、この形は出ない。
485
+ // 実測(RFC 82 本): 12 件(7159 が 2 件、8259 が 6 件、8200 が 4 件)。
486
+ if (ERRATA_ANCHOR.test(anchor) || ERRATA_TITLE.test(title)) {
487
+ return { anchor, type, title: title || anchor };
488
+ }
236
489
  return {
237
490
  anchor,
238
491
  type,
@@ -240,7 +493,14 @@ function parseTextReference(anchor, rawEntry, type) {
240
493
  title: title || (rfcNumber ? `RFC ${rfcNumber}` : anchor),
241
494
  };
242
495
  }
243
- /** 引用符を使わない書式の項目から、題名にあたる部分を取る最大の長さ。 */
496
+ /**
497
+ * 引用符を使わない書式の項目から、題名にあたる部分を取る最大の長さ。
498
+ *
499
+ * 上限で切るときは語の境目で切り、三点リーダを置く(`clipAtWord`)。以前は
500
+ * 途中で切っていたため、RFC 2822 の `[ASCII]` が
501
+ * "… American National Standard Code for Informatio" で終わっていた。
502
+ * 切ったことが分からないと、読み手はそれが題名の全部だと読む。
503
+ */
244
504
  const UNQUOTED_TITLE_MAX_LENGTH = 120;
245
505
  /**
246
506
  * 二重引用符を使わない古い書式の項目から題名を取る。
@@ -258,16 +518,56 @@ const UNQUOTED_TITLE_MAX_LENGTH = 120;
258
518
  * 何も取れなければ呼び出し側が anchor に戻す。
259
519
  */
260
520
  function titleWithoutQuotes(entry) {
261
- const parts = entry
521
+ // 開き引用符だけがあって閉じていない項目がある。RFC 2131 の
522
+ // `[4] Braden, R., Editor, "Requirements for Internet Hosts --
523
+ // Application and Support, STD 3, RFC 1123, …` は閉じ引用符が無い
524
+ //(原文の誤り)。開き引用符から、出典の切れ目までを題名とみなす。
525
+ const unbalanced = /"([^"]+?)(?:,\s*(?:STD|RFC|BCP|Work in Progress)\b|$)/.exec(entry);
526
+ if (unbalanced && (entry.match(/"/g) ?? []).length === 1) {
527
+ const candidate = unbalanced[1].trim();
528
+ if (candidate.length >= 12)
529
+ return clipAtWord(candidate, UNQUOTED_TITLE_MAX_LENGTH);
530
+ }
531
+ // 目印は題名ではない。RFC 1305 は `[BEL86]` を 1 行に置き、次の行から
532
+ // 引用を書くため、繋いだ項目が目印で始まる。RFC 1812 の `ARCH:8.` も同じ。
533
+ const body = entry
534
+ .replace(/^\[[^\]]+\]\s*/, '')
535
+ .replace(/^[A-Z][A-Z0-9]*:\d+\.?\s*/, '')
536
+ .trim();
537
+ const parts = body
262
538
  .split(/\.\s+/)
263
539
  .map((part) => part.replace(/\.$/, '').trim())
264
540
  .filter((part) => part.length > 0);
265
- if (parts.length < 2)
266
- return undefined;
267
- const longest = parts.reduce((best, part) => (part.length > best.length ? part : best), '');
268
- if (longest.length < 12)
269
- return undefined;
270
- return longest.slice(0, UNQUOTED_TITLE_MAX_LENGTH).trim();
541
+ // 出典の部分は題名ではない。RFC 1305 の
542
+ // `Defense Advanced Research Projects Agency. Internet Protocol. DARPA
543
+ // Network Working Group Report RFC-791, USC Information Sciences
544
+ // Institute, September 1981.`
545
+ // で最長の部分を採ると、題名が出典と日付の塊になっていた。
546
+ // RFC 番号・西暦・ページ範囲を含む部分を落としてから選ぶ。
547
+ const withoutImprint = parts.filter((part) => !/\bRFC[\s-]*\d+\b|\b(?:19|20)\d{2}\b|\b\d+-{1,2}\d+\b/.test(part));
548
+ const candidates = withoutImprint.length > 0 ? withoutImprint : parts;
549
+ const longest = candidates.reduce((best, part) => (part.length > best.length ? part : best), '');
550
+ if (longest.length >= 12 && parts.length >= 2) {
551
+ return clipAtWord(longest, UNQUOTED_TITLE_MAX_LENGTH);
552
+ }
553
+ // 文の切れ目が無い引用がある。RFC 5246 の
554
+ // `[X680] ITU-T Recommendation X.680 (2002) | ISO/IEC 8824-1:2002,
555
+ // Information technology - Abstract Syntax Notation One (ASN.1)…`
556
+ // は句点で割れない。目印を外した本文をそのまま題名にする。
557
+ // 落とすと、題名が目印(`X680`)のままになる。
558
+ //
559
+ // 出典(`, RFC 2401, November 1998.`)は題名ではないので落とす。RFC 3168 の
560
+ // `[RFC2401] Kent, S. and R. Atkinson, Security Architecture for the
561
+ // Internet Protocol, RFC 2401, November 1998.` は句点が末尾にしか無く、
562
+ // 上の「最長の部分」の規則に入らないため、題名に RFC 番号が残っていた。
563
+ const withoutSeries = body.replace(/,\s*(?:RFC|STD|BCP|FYI)[\s-]*\d+.*$/i, '').trim();
564
+ if (withoutSeries.length >= 12) {
565
+ return clipAtWord(withoutSeries, UNQUOTED_TITLE_MAX_LENGTH);
566
+ }
567
+ if (body.length >= 12) {
568
+ return clipAtWord(body, UNQUOTED_TITLE_MAX_LENGTH);
569
+ }
570
+ return undefined;
271
571
  }
272
572
  /**
273
573
  * メタデータ抽出(テキストから)
@@ -277,8 +577,45 @@ function extractTextMetadata(lines, rfcNumber) {
277
577
  title: extractTextTitle(lines),
278
578
  number: rfcNumber,
279
579
  date: extractTextPublicationDate(lines),
580
+ authorOrder: extractTextAuthorSurnames(lines),
280
581
  };
281
582
  }
583
+ /** 著者欄が始まる桁。ここから右が著者と所属の欄になる。 */
584
+ const AUTHOR_COLUMN = 40;
585
+ /** 著者欄を探す行数。ヘッダ塊はこの範囲に収まる。 */
586
+ const AUTHOR_HEADER_LINES = 20;
587
+ /**
588
+ * 著者名の形。`I. Fette` `M. St. Laurent` `R. Fielding, Ed.`。
589
+ *
590
+ * 姓だけを返す。所属(`Google, Inc.` `Isode Ltd.`)は頭文字の点を持たないか、
591
+ * `Inc.` `Ltd.` などで終わるので当たらない。ただし `U.C. Berkeley` のように
592
+ * 所属が名前と同じ形に見えることがある。**そのため、この一覧は名前の代わりには
593
+ * 使わない。並べ替えの手掛かりにだけ使う。**
594
+ */
595
+ const AUTHOR_NAME_PATTERN = /^(?:[A-Z][a-z]?\.\s*)+([A-Z][A-Za-z'\u2019-]+)(?:,\s*Ed\.)?$/;
596
+ /**
597
+ * テキスト版 RFC のヘッダ塊から、著者の姓を印字順に取り出す。
598
+ *
599
+ * ```
600
+ * Internet Engineering Task Force (IETF) I. Fette
601
+ * Request for Comments: 6455 Google, Inc.
602
+ * Category: Standards Track A. Melnikov
603
+ * ISSN: 2070-1721 Isode Ltd.
604
+ * ```
605
+ *
606
+ * Datatracker の `documentauthor.order` は本文の並びと食い違うことがある。
607
+ * この RFC では API が 0=Melnikov、1=Fette を返す。
608
+ */
609
+ function extractTextAuthorSurnames(lines) {
610
+ const surnames = [];
611
+ for (const line of lines.slice(0, AUTHOR_HEADER_LINES)) {
612
+ const right = line.slice(AUTHOR_COLUMN).trim();
613
+ const match = AUTHOR_NAME_PATTERN.exec(right);
614
+ if (match)
615
+ surnames.push(match[1].toLowerCase());
616
+ }
617
+ return surnames;
618
+ }
282
619
  /** 題名の位置に来ることがある見出し。当たったら題名は取れていない。 */
283
620
  const FRONT_MATTER_HEADINGS = new Set([
284
621
  'abstract',
@@ -313,6 +650,10 @@ const FRONT_MATTER_HEADINGS = new Set([
313
650
  *
314
651
  * @returns 題名。判別できなければ `undefined`(呼び出し側が API の題名へ落とす)
315
652
  */
653
+ /** 題名が前置詞・接続詞で終わっている(文として途中である)。 */
654
+ const TITLE_ENDS_INCOMPLETE = /\b(?:of|for|and|or|to|in|on|with|by|from|the|a|an)\s*$/i;
655
+ /** 題名を割る空行の数の上限。 */
656
+ const TITLE_GAP_MAX_LINES = 2;
316
657
  function extractTextTitle(lines) {
317
658
  const limit = Math.min(METADATA_EXTRACTION.MAX_LINES_TO_SCAN, lines.length);
318
659
  // ヘッダ塊の始まり
@@ -329,16 +670,47 @@ function extractTextTitle(lines) {
329
670
  index++;
330
671
  if (index >= limit)
331
672
  return undefined;
332
- // 中央寄せなので字下げがある。無ければ題名ではない。
333
- if (!/^\s/.test(lines[index]))
673
+ // 中央寄せなので字下げがある。ただし行幅いっぱいの題名は 1 桁目から始まる。
674
+ // RFC 7489 の
675
+ // "Domain-based Message Authentication, Reporting, and Conformance (DMARC)"
676
+ // は 71 文字あり、字下げを課すと `metadata.title` が空になっていた。
677
+ if (!/^\s/.test(lines[index]) &&
678
+ lines[index].trim().length < METADATA_EXTRACTION.WIDE_TITLE_MIN_LENGTH) {
334
679
  return undefined;
680
+ }
335
681
  // 空行までを 1 つの題名として繋ぐ(2 行に折り返す題名がある)
336
682
  const parts = [];
337
683
  while (index < limit && lines[index].trim() !== '' && parts.length < 3) {
338
684
  parts.push(lines[index].trim());
339
685
  index++;
340
686
  }
341
- const title = parts.join(' ');
687
+ // 題名を空行で割る RFC がある。RFC 822 と RFC 733 は
688
+ //
689
+ // STANDARD FOR THE FORMAT OF
690
+ //
691
+ // ARPA INTERNET TEXT MESSAGES
692
+ //
693
+ // と書く。最初の空行で切ると `STANDARD FOR THE FORMAT OF` で終わり、
694
+ // `A5`(メタデータに題名がある)が破れていた。
695
+ //
696
+ // **前置詞・接続詞で終わっているときだけ**続きを取る。文として途中だからで、
697
+ // 完結している題名のあとに続く行(著者名や日付)は取らない。
698
+ if (parts.length > 0 && TITLE_ENDS_INCOMPLETE.test(parts[parts.length - 1])) {
699
+ let next = index;
700
+ while (next < limit && lines[next].trim() === '')
701
+ next++;
702
+ if (next < limit && next - index <= TITLE_GAP_MAX_LINES && /^\s/.test(lines[next])) {
703
+ parts.push(lines[next].trim());
704
+ }
705
+ }
706
+ // 題名に下線を引く RFC がある。RFC 768 は `User Datagram Protocol` の次の
707
+ // 行に `----------------------` を置く。繋ぐと題名の末尾に下線が付く。
708
+ const withoutUnderline = parts.filter((part) => !isUnderline(part));
709
+ // 表紙を `Title:` `Author:` `Installation:` の欄で書く最初期の RFC がある
710
+ // (RFC 1)。3 行を繋ぐと `Title: Host Software Author: Steve Crocker
711
+ // Installation: UCLA` になっていた。`Title:` の値だけを採る。
712
+ const titleField = /^Title:\s*(.+)$/.exec(withoutUnderline[0] ?? '');
713
+ const title = titleField ? titleField[1].trim() : withoutUnderline.join(' ');
342
714
  if (title.length < METADATA_EXTRACTION.TITLE_MIN_LENGTH)
343
715
  return undefined;
344
716
  if (title.length > METADATA_EXTRACTION.TITLE_MAX_LENGTH)
@@ -349,18 +721,18 @@ function extractTextTitle(lines) {
349
721
  }
350
722
  /** ヘッダ行に現れる月名 → 月番号 */
351
723
  const TEXT_MONTHS = {
352
- january: '01',
353
- february: '02',
354
- march: '03',
355
- april: '04',
724
+ jan: '01',
725
+ feb: '02',
726
+ mar: '03',
727
+ apr: '04',
356
728
  may: '05',
357
- june: '06',
358
- july: '07',
359
- august: '08',
360
- september: '09',
361
- october: '10',
362
- november: '11',
363
- december: '12',
729
+ jun: '06',
730
+ jul: '07',
731
+ aug: '08',
732
+ sep: '09',
733
+ oct: '10',
734
+ nov: '11',
735
+ dec: '12',
364
736
  };
365
737
  /**
366
738
  * テキスト版 RFC のヘッダから公開年月を取り出す。
@@ -375,12 +747,23 @@ const TEXT_MONTHS = {
375
747
  *
376
748
  * 本文中の "August 2022" を拾わないよう、走査はヘッダの範囲に限る。
377
749
  */
750
+ /**
751
+ * 公開年月を探す行数。表紙が高い RFC がある。
752
+ *
753
+ * 1980 年代の RFC は表紙を空行で大きく空ける。RFC 821 は日付が 44 行目に
754
+ * あり、30 行までしか見ていなかったため `metadata.date` が空だった。
755
+ * 最初に当たったものを返すので、既に取れている RFC の値は変わらない。
756
+ */
757
+ const DATE_MAX_LINES_TO_SCAN = 60;
378
758
  function extractTextPublicationDate(lines) {
379
- const pattern = /\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{4})\b/i;
380
- for (let i = 0; i < Math.min(METADATA_EXTRACTION.MAX_LINES_TO_SCAN, lines.length); i++) {
759
+ // 日付まで書く RFC がある。RFC 20 は `October 16, 1969` と書き、
760
+ // 月と年だけを見る規則では当たらず `metadata.date` が空だった。
761
+ // 月を 3 文字に略す RFC もある(RFC 706 の `Nov 1975`)。
762
+ const pattern = /\b(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+(?:\d{1,2},\s*)?(\d{4})\b/i;
763
+ for (let i = 0; i < Math.min(DATE_MAX_LINES_TO_SCAN, lines.length); i++) {
381
764
  const match = pattern.exec(lines[i]);
382
765
  if (match) {
383
- return `${match[2]}-${TEXT_MONTHS[match[1].toLowerCase()]}`;
766
+ return `${match[2]}-${TEXT_MONTHS[match[1].slice(0, 3).toLowerCase()]}`;
384
767
  }
385
768
  }
386
769
  return undefined;
@@ -405,6 +788,95 @@ function extractTextPublicationDate(lines) {
405
788
  function isTableOfContentsEntry(title) {
406
789
  return /(?:\.\s?){3,}\s*\d+\s*$/.test(title.trim());
407
790
  }
791
+ /**
792
+ * リーダー無しの目次の 1 行。「番号 + 題名 + 空白 + 1〜3 桁のページ番号」。
793
+ *
794
+ * ```
795
+ * 3.2. State Variables and Parameters 9
796
+ * 3.2.1. Common Variables 9
797
+ * A. Appendix A. NTP Data Format - Version 3 50
798
+ * ```
799
+ */
800
+ const LEADERLESS_TOC_ENTRY = /^\s*(\d+(?:\.\d+)*|[A-Z](?:\.\d+)*)\.?\s+([A-Za-z]\S*(?:\s+\S+)*?)\s+(\d{1,3})\s*$/;
801
+ /** リーダー無しの目次とみなすのに必要な、続きの行の数。 */
802
+ const LEADERLESS_TOC_MIN_ENTRIES = 3;
803
+ /**
804
+ * リーダー無しの目次の行番号を集める。
805
+ *
806
+ * `isTableOfContentsEntry` はドットのリーダーを必須にしている。RFC 1305 の
807
+ * 目次は `3.2. State Variables and Parameters 9` とリーダー無しで
808
+ * ページ番号を書くため、目次の全行が節になっていた(`get_rfc_structure` に
809
+ * 題名にページ番号が付いた節が 14 件、本文の無い葉が 12 件)。本文の見出しは
810
+ * 番号を持たない(`State Variables and Parameters`)ので、番号無し見出しの
811
+ * 取り直しも効かない。
812
+ *
813
+ * 1 行だけでは本文の表と区別がつかない(題名が数字で終わる見出しもある。
814
+ * RFC 1305 の `Appendix A. NTP Data Format - Version 3`)。**空行だけを挟んで
815
+ * 3 行以上続き、ページ番号が減らず、番号に段のあるものが 1 つはある塊**を
816
+ * 目次とみなす。本文の表(`Value Meaning`)は番号が 1 段しか無い。
817
+ */
818
+ function leaderlessToc(lines) {
819
+ const toc = { lines: new Set(), entries: [] };
820
+ let run = [];
821
+ let lastPage = -1;
822
+ let dotted = false;
823
+ const flush = () => {
824
+ if (run.length >= LEADERLESS_TOC_MIN_ENTRIES && dotted) {
825
+ for (const entry of run) {
826
+ toc.lines.add(entry.index);
827
+ toc.entries.push({ number: entry.number, title: entry.title });
828
+ }
829
+ }
830
+ run = [];
831
+ lastPage = -1;
832
+ dotted = false;
833
+ };
834
+ for (let index = 0; index < lines.length; index++) {
835
+ const line = lines[index];
836
+ if (line.trim() === '')
837
+ continue;
838
+ const match = LEADERLESS_TOC_ENTRY.exec(line);
839
+ const page = match ? Number(match[3]) : -1;
840
+ if (!match || page < lastPage) {
841
+ flush();
842
+ if (!match)
843
+ continue;
844
+ }
845
+ run.push({ index, number: match[1], title: match[2].trim() });
846
+ lastPage = page;
847
+ if (match[1].includes('.'))
848
+ dotted = true;
849
+ }
850
+ flush();
851
+ return toc;
852
+ }
853
+ /**
854
+ * その行が、目次の題名と同じ番号無しの見出しなら、目次の番号と題名を返す。
855
+ */
856
+ function tocTitleHeader(lines, index, toc, cursor) {
857
+ if (cursor >= toc.entries.length)
858
+ return null;
859
+ const trimmed = lines[index].trim();
860
+ if (trimmed === '' || trimmed.length > UNNUMBERED_HEADER_MAX_LENGTH)
861
+ return null;
862
+ // 直前は空行。直後は問わない。RFC 1305 は `Clock-Update Procedure` の
863
+ // 直後に本文を続ける見出しと、空行を挟む見出しが混在する。目次の題名と
864
+ // 順番どおりに一致することが手掛かりなので、直後の空行までは求めない。
865
+ const before = index > 0 ? lines[index - 1] : '';
866
+ if (before.trim() !== '')
867
+ return null;
868
+ const end = Math.min(cursor + TOC_TITLE_LOOKAHEAD, toc.entries.length);
869
+ for (let k = cursor; k < end; k++) {
870
+ if (toc.entries[k].title === trimmed)
871
+ return toc.entries[k];
872
+ }
873
+ return null;
874
+ }
875
+ /**
876
+ * 目次の題名と本文の行を突き合わせるとき、目次を何件先まで見るか。
877
+ * 本文に見出しが無い(または別の経路で取れた)項目を読み飛ばすため。
878
+ */
879
+ const TOC_TITLE_LOOKAHEAD = 3;
408
880
  /**
409
881
  * 節見出しとして妥当か。
410
882
  *
@@ -440,21 +912,236 @@ function isValidSectionHeader(sectionNum, title) {
440
912
  const trimmed = title.trim();
441
913
  if (trimmed.length === 0)
442
914
  return false;
915
+ // 記号だけの題名は無い。RFC 1305 の C の注釈 `/* test` の折り返しで残った
916
+ // `1 */` が §1 になっていた。閉じ括弧や読点で始まる題名も無い。同じ RFC の
917
+ // troff の数式 `(T sub` の折り返し `2 )>, <$E~epsilon sub 4…` が §2 になっていた。
918
+ if (!/[A-Za-z0-9]/.test(trimmed) || /^[),\]}>]/.test(trimmed))
919
+ return false;
443
920
  // 題名は文を含まない。折り返した本文が数字から始まると節に見える。
444
921
  // RFC 1035 の "…the 26th bit corresponds to TCP port" の次の行は
445
922
  // "25 (SMTP). If this bit is set, a SMTP server should be listening on TCP"
446
923
  // で、番号 25 の節として通っていた。句点のあとに語が続く題名は本文である。
447
924
  // RFC 8446 §7.4 "(EC)DHE Shared Secret Calculation" のように括弧で始まる
448
925
  // 題名はあるので、先頭の文字では判定しない。
449
- return !/[.!?]\s+\S/.test(trimmed);
926
+ return !containsSentenceBreak(trimmed);
927
+ }
928
+ /**
929
+ * 題名の中の略語。ここに挙げた語のあとの `.` は文の終わりではない。
930
+ *
931
+ * RFC 1123 は出典を題名に書く。
932
+ * "3.2.1 Option Negotiation: RFC-854, pp. 2-3" の `pp.` を文の終わりと見ると、
933
+ * §3.2.1 から §3.2.8 の 8 節が丸ごと落ちる(v0.6.14 まで落ちていた)。
934
+ */
935
+ const TITLE_ABBREVIATION = /(?:^|[\s(])(?:pp?|vol|nos?|secs?|chs?|figs?|eds?|al|etc|cf|vs|e\.g|i\.e|[A-Z]|[IVX]{1,4})\.$/i;
936
+ /** 題名の中に、略語でない句点があるか。あればそれは題名ではなく本文である。 */
937
+ function containsSentenceBreak(title) {
938
+ const pattern = /[.!?]\s+\S/g;
939
+ let match;
940
+ while ((match = pattern.exec(title)) !== null) {
941
+ const head = title.slice(0, match.index + 1);
942
+ // 三点リーダは文の終わりではない(RFC 1866 §5.4 "Headings: H1 ... H6")。
943
+ if (/\.\.$/.test(head))
944
+ continue;
945
+ if (!TITLE_ABBREVIATION.test(head))
946
+ return true;
947
+ }
948
+ return false;
450
949
  }
451
950
  /**
452
951
  * セクション構造の抽出(テキストから)
453
952
  */
953
+ /**
954
+ * 題名らしい大文字の並びか。
955
+ *
956
+ * 小文字で始まる題名を、本文の折り返しと見分ける 3 つ目の手がかりである。
957
+ * RFC 2445 §4 の `4 iCalendar Object Specification` は、番号に句点が無く、
958
+ * ページの区切りの直後なので直前も空行ではない。それでも題名である。
959
+ *
960
+ * - `iCalendar Object Specification` → 3 語中 3 語が大文字始まり
961
+ * - `characters, arriving from the user at 200ms intervals, would` → 0 語
962
+ *
963
+ * 1 語だけの題名(`origin-form`)はこの手がかりを使わない。判断できない。
964
+ */
965
+ function looksLikeTitleCase(title) {
966
+ const words = title
967
+ .trim()
968
+ .split(/\s+/)
969
+ .filter((word) => /[A-Za-z]{3}/.test(word));
970
+ if (words.length < 2)
971
+ return false;
972
+ const capitalized = words.filter((word) => /^[A-Z]/.test(word)).length;
973
+ return capitalized * 2 >= words.length;
974
+ }
975
+ function appendixHeader(lines, index, previous) {
976
+ const line = lines[index];
977
+ const previousLetter = previous.letter;
978
+ // 下位の付録は段の深さに応じて字下げすることがある(RFC 1521 の " E.1 …"、
979
+ // RFC 2328 の " C.1 Global parameters" と " D.4.1 Generating …")。
980
+ // 深さで割り切れて 1 段 2〜4 桁なら認める。字下げなしも認める(RFC 2328 の
981
+ // `A.1` は 1 桁目から始まる)。
982
+ const indent = line.length - line.trimStart().length;
983
+ if (indent > APPENDIX_MAX_INDENT && !indentMatchesDepth(indent, line))
984
+ return null;
985
+ const match = APPENDIX_HEADER_PATTERN.exec(line.trim());
986
+ if (!match)
987
+ return null;
988
+ const [, explicit, letter, digits, title] = match;
989
+ const trimmed = title.trim();
990
+ if (trimmed.length < 3)
991
+ return null;
992
+ if (containsSentenceBreak(trimmed))
993
+ return null;
994
+ // 目次の行を付録の見出しにしない。番号付きの節では
995
+ // `isValidSectionHeader` が弾いているが、付録の経路には無かった。
996
+ // RFC 791 の目次は `APPENDIX A: Examples & Scenarios ............ 34` で、
997
+ // これを拾うと本物の見出し(同じ番号)が重複で落ちる。
998
+ if (isTableOfContentsEntry(trimmed))
999
+ return null;
1000
+ // `Appendix` と書く見出しは 1 桁目から始まる。字下げして `Appendix A.2` と
1001
+ // 書いてあるのは本文からの参照である。RFC 7519 の
1002
+ // " Appendix A.2 of [JWE], including the keys used."
1003
+ // を付録 A.2 の見出しとして拾い、そのあとの本物の `A.2. Example Nested JWT`
1004
+ // が番号の重複で落ちていた。
1005
+ if (explicit && indent > 0)
1006
+ return null;
1007
+ // 見出しの次の行は空く(折り返した題名の 2 行目なら、その次が空く)。
1008
+ // 折り返した本文が `A.3.6 for details.` のように付録の番号から始まると
1009
+ // 見出しに見える。数字の見出しに課している検査を付録にも課す。
1010
+ const wrapped = appendixTitleContinuation(lines, index, line.replace(/\s+$/, '').length - trimmed.length);
1011
+ if (wrapped === null)
1012
+ return null;
1013
+ const lastLine = wrapped === '' ? index : index + 1;
1014
+ const fullTitle = wrapped === '' ? trimmed : `${trimmed} ${wrapped}`;
1015
+ // `Appendix` と明示する形は、本文からの参照と紛れる。RFC 1305 §3 の折り返し
1016
+ // `Appendix A for comprehensive list):` が Appendix A(411 ブロック)になり、
1017
+ // 本物の `Appendix A. NTP Data Format - Version 3` が重複で落ちていた。
1018
+ // 題名が小文字で始まるもの、読点・コロン・セミコロンで終わるものは本文である。
1019
+ // 句点で終わるものは認める(RFC 1305 の `Appendix D. Differences from
1020
+ // Previous Versions.`)。
1021
+ if (explicit && (/^[a-z]/.test(trimmed) || /[,;:]$/.test(trimmed)))
1022
+ return null;
1023
+ // 深い段(A.1、A.1.2)は親の文字と同じであること。
1024
+ // 親の文字が一致していれば十分なので、題名の先頭は問わない。
1025
+ // RFC 6749 の `A.1. "client_id" Syntax`、RFC 5321 の `F.4. #-literals`、
1026
+ // RFC 7489 の `B.5. mailto Transport Example` のように、引用符・記号・
1027
+ // 小文字で始まる題名がある。
1028
+ //
1029
+ // 代わりに、数字の見出し(`indentedSectionHeader`)と同じ 2 つを課す。
1030
+ //
1031
+ // - 句読点で終わらない。RFC 2328 の ` A.3.6 for details.` は
1032
+ // "See Sections A.3.2 through" の折り返しで、これが §A.3.6 になり、本物の
1033
+ // `A.3.6 The Link State Acknowledgment packet` が §A.3.5 に吸われていた。
1034
+ // - 直前の付録の次に来る番号である(`isSuccessorSectionNumber`)。
1035
+ // ` A.4.1. Detailed formats of the different types of LSAs are described`
1036
+ // は §A.3.5 の直後に現れる。A.4.1 は A.3.5 の次ではない。
1037
+ if (digits) {
1038
+ if (previousLetter !== letter)
1039
+ return null;
1040
+ if (/[.,;:]$/.test(trimmed))
1041
+ return null;
1042
+ const number = `${letter}${digits}`;
1043
+ if (!isSuccessorSectionNumber(appendixAsNumber(previous.number), appendixAsNumber(number) ?? number)) {
1044
+ return null;
1045
+ }
1046
+ return { number, title: fullTitle, lastLine };
1047
+ }
1048
+ // 1 段目は字下げしない
1049
+ if (indent > 0)
1050
+ return null;
1051
+ // 題名が大文字か数字で始まること。**`Appendix` と書いていないときだけ**課す。
1052
+ // 本文の `B. Smith` のような行を拾わないための規則であり、`Appendix` の語が
1053
+ // あるならその心配は無い。課していたため、RFC 7515 の
1054
+ // `Appendix B. "x5c" (X.509 Certificate Chain) Example` が落ち、
1055
+ // **そのあとの付録 C〜F も「A の次は B」の順番が合わずに落ちていた**(4 節)。
1056
+ if (!explicit && !/^["'([]?[A-Z0-9]/.test(trimmed))
1057
+ return null;
1058
+ // 1 段目は A から始まり、1 つずつ進む
1059
+ const expected = previousLetter ? String.fromCharCode(previousLetter.charCodeAt(0) + 1) : 'A';
1060
+ if (letter !== expected)
1061
+ return null;
1062
+ // `Appendix` と書いていない 1 文字の見出しは、A から始まるものだけ認める。
1063
+ // 本文の "B. Smith" のような行を拾わないため。
1064
+ if (!explicit && !previousLetter && letter !== 'A')
1065
+ return null;
1066
+ return { number: letter, title: fullTitle, lastLine };
1067
+ }
1068
+ /**
1069
+ * 付録の番号を数字の節番号の形にする。`A.3.5` → `1.3.5`、`A` → `1`。
1070
+ * `isSuccessorSectionNumber` に掛けるため。文字は 1 段目にしか来ない。
1071
+ */
1072
+ function appendixAsNumber(number) {
1073
+ if (number === null)
1074
+ return null;
1075
+ return number.replace(/^[A-Z]/, (letter) => String(letter.charCodeAt(0) - 64));
1076
+ }
1077
+ /**
1078
+ * 付録の見出しの続き。次の行が空いていれば `''`、折り返した題名の 2 行目なら
1079
+ * その文字列、どちらでもなければ `null`(見出しではない)。
1080
+ *
1081
+ * 折り返しは 2 通りある。
1082
+ *
1083
+ * - 題名の開始桁に揃える(`titleContinuation`)。
1084
+ * - 題名より深く字下げする。RFC 1521 の
1085
+ * ` E.2 Registration of New Access-type Values` /
1086
+ * ` for Message/external-body` は 1 行目が 46 桁で、72 桁の
1087
+ * 折り返しではない。次の行が見出しより深く、40 文字以下で、句点で終わらず、
1088
+ * その次が空くときだけ続きとする。
1089
+ *
1090
+ * 次の行が別の見出しでもよい。RFC 822 の `C.5. ADDRESS SPECIFICATION` は
1091
+ * ページの終わりに置かれ、区切りを外すと `C.5.1. ADDRESS` が直後に来る。
1092
+ */
1093
+ function appendixTitleContinuation(lines, index, titleColumn) {
1094
+ const next = (lines[index + 1] ?? '').replace(/\s+$/, '');
1095
+ const text = next.trim();
1096
+ if (text === '')
1097
+ return '';
1098
+ if (APPENDIX_HEADER_PATTERN.test(text) || SECTION_HEADER_PATTERN.test(text))
1099
+ return '';
1100
+ const aligned = titleContinuation(lines, index, titleColumn);
1101
+ if (aligned !== null)
1102
+ return aligned;
1103
+ const line = lines[index];
1104
+ const indent = line.length - line.trimStart().length;
1105
+ if (next.length - text.length <= indent)
1106
+ return null;
1107
+ if (text.length > HEADER_CONTINUATION_MAX_LENGTH)
1108
+ return null;
1109
+ if (/[.!?,;:]$/.test(text))
1110
+ return null;
1111
+ if ((lines[index + 2] ?? '').trim() !== '')
1112
+ return null;
1113
+ return text;
1114
+ }
1115
+ /**
1116
+ * `Appendix A. Title` / `A.1. Title` / `Appendix A - Title` に当たる。
1117
+ *
1118
+ * 見出しを全部大文字で書く RFC がある。RFC 1812 は
1119
+ * `APPENDIX D. Multicast Routing Protocols` と書く。`Appendix` だけを見て
1120
+ * いたため付録の始まりと分からず、続く `D.1 Introduction` 以下 18 節が
1121
+ * 構造から落ちていた。
1122
+ */
1123
+ const APPENDIX_HEADER_PATTERN = /^(APPENDIX\s+|Appendix\s+)?([A-Z])((?:\.\d+)*)[.:]?(?:\s*[-–]+\s*|\s{1,3})(\S.*)$/;
1124
+ /**
1125
+ * 字下げが段の深さに合っているか。`D.4.1` は深さ 3 なので 1 段 4 桁なら 8 桁。
1126
+ */
1127
+ function indentMatchesDepth(indent, line) {
1128
+ const match = /^([A-Z])((?:\.\d+)+)/.exec(line.trim());
1129
+ if (!match)
1130
+ return false;
1131
+ const depth = match[2].split('.').length;
1132
+ if (depth < 2 || indent % (depth - 1) !== 0)
1133
+ return false;
1134
+ const step = indent / (depth - 1);
1135
+ return step >= INDENTED_HEADER_MIN_STEP && step <= INDENTED_HEADER_MAX_STEP;
1136
+ }
1137
+ /** 下位の付録に許す字下げ。 */
1138
+ const APPENDIX_MAX_INDENT = 4;
1139
+ /** 1 段目の節番号に許す飛び。RFC には欠番がある。 */
1140
+ const MAX_SECTION_NUMBER_GAP = 5;
454
1141
  /** 字下げした見出しの、1 段あたりの字下げ幅。 */
455
- const INDENTED_HEADER_STEP = 3;
456
- /** 字下げ幅の許容差。 */
457
- const INDENTED_HEADER_TOLERANCE = 2;
1142
+ const INDENTED_HEADER_MIN_STEP = 2;
1143
+ /** 1 段あたりの字下げ幅の上限。 */
1144
+ const INDENTED_HEADER_MAX_STEP = 4;
458
1145
  /**
459
1146
  * 節番号が、直前の節の次に来る番号か。
460
1147
  *
@@ -525,10 +1212,33 @@ function indentedSectionHeader(lines, index, previousNumber) {
525
1212
  const depth = number.split('.').length;
526
1213
  if (depth < 2)
527
1214
  return null;
528
- if (Math.abs(indent - (depth - 1) * INDENTED_HEADER_STEP) > INDENTED_HEADER_TOLERANCE)
1215
+ // 1 段あたりの字下げ幅は RFC ごとに違う。RFC 1122 は 3 桁、RFC 2328 は
1216
+ // 4 桁で刻む。3 桁を基準に前後 2 桁で見ていたため、RFC 2328 の深い節
1217
+ // (` 12.4.1.1. Describing point-to-point interfaces` は 12 桁、
1218
+ // 3 桁基準なら 9 桁)が落ち、31 節が構造から消えていた。
1219
+ //
1220
+ // 「段の数で割り切れて、1 段あたりが 2〜4 桁」であることを求める。
1221
+ if (indent % (depth - 1) !== 0)
529
1222
  return null;
530
- const title = match[2].trim();
531
- if (title.length < 3 || !/^[A-Z]/.test(title) || /[.,;]$/.test(title))
1223
+ const step = indent / (depth - 1);
1224
+ if (step < INDENTED_HEADER_MIN_STEP || step > INDENTED_HEADER_MAX_STEP)
1225
+ return null;
1226
+ let title = match[2].trim();
1227
+ // 題名が読点で終わるときは、次の行が続きである。
1228
+ // RFC 1122 §4.2.2.8 の
1229
+ // "4.2.2.8 TCP Connection State Diagram: RFC-793 Section 3.2,"
1230
+ // " page 23"
1231
+ // は 2 行に分かれる。「次の行が空く」だけを課すと §4.2.2.8 から §4.2.2.11 の
1232
+ // 4 節が落ちる。
1233
+ let end = index;
1234
+ const continuation = wrappedHeaderContinuation(lines, index, indent);
1235
+ if (continuation !== null) {
1236
+ title = `${title} ${continuation}`;
1237
+ end = index + 1;
1238
+ }
1239
+ // 先頭は大文字。引用符や括弧で始まる題名があるので、それは読み飛ばす
1240
+ // (RFC 1123 §4.1.4.2 の `"QUOTE" Command`)。
1241
+ if (title.length < 3 || !/^["'“([]?[A-Z]/.test(title) || /[.,;]$/.test(title))
532
1242
  return null;
533
1243
  if (!isValidSectionHeader(number, title))
534
1244
  return null;
@@ -536,10 +1246,55 @@ function indentedSectionHeader(lines, index, previousNumber) {
536
1246
  return null;
537
1247
  // 見出しの次の行は空く。直前は図や表の行のことがあるので問わない
538
1248
  // (RFC 1122 §1.4 と §4.2 は図表の直後に置かれている)。
539
- const after = index + 1 < lines.length ? lines[index + 1] : '';
1249
+ const after = end + 1 < lines.length ? lines[end + 1] : '';
540
1250
  if (after.trim() !== '')
541
1251
  return null;
542
- return { number, title };
1252
+ return { number, title, lastLine: end };
1253
+ }
1254
+ /** 折り返した見出しとみなす続きの行の、最大の長さ。 */
1255
+ const HEADER_CONTINUATION_MAX_LENGTH = 40;
1256
+ /**
1257
+ * 折り返しが起きたとみなす、見出しの行の最小の幅。
1258
+ *
1259
+ * RFC のテキストは 72 桁で折り返す。これより短い行は折り返されていないので、
1260
+ * 次の行は続きではなく本文である。
1261
+ */
1262
+ const HEADER_WRAP_WIDTH = 60;
1263
+ /**
1264
+ * 見出しの続きの行を返す。続きでなければ `null`。
1265
+ *
1266
+ * RFC 1122 は出典を題名に書くため、題名が 72 桁で折り返す。
1267
+ *
1268
+ * ```
1269
+ * 4.2.2.9 Initial Sequence Number Selection: RFC-793 Section
1270
+ * 3.3, page 27
1271
+ *
1272
+ * A TCP MUST use the specified clock-driven selection of
1273
+ * ```
1274
+ *
1275
+ * 「次の行が空く」だけを課すと §4.2.2.8 から §4.2.2.11 の 4 節が落ち、
1276
+ * その節の要件が §4.2.2.7 に付く。
1277
+ *
1278
+ * 折り返しとみなす条件は 3 つ。
1279
+ *
1280
+ * - 見出しの行が 60 桁以上ある(折り返しが起きる幅に達している)
1281
+ * - 次の行が見出しより深く字下げされ、40 文字以下である
1282
+ * - その次の行が空く(本文の段落は 2 行目で終わらない)
1283
+ */
1284
+ function wrappedHeaderContinuation(lines, index, indent) {
1285
+ const line = lines[index].replace(/\s+$/, '');
1286
+ if (line.length < HEADER_WRAP_WIDTH)
1287
+ return null;
1288
+ const next = index + 1 < lines.length ? lines[index + 1] : '';
1289
+ const trimmed = next.trim();
1290
+ if (trimmed === '' || trimmed.length > HEADER_CONTINUATION_MAX_LENGTH)
1291
+ return null;
1292
+ if (next.length - next.trimStart().length <= indent)
1293
+ return null;
1294
+ const after = index + 2 < lines.length ? lines[index + 2] : '';
1295
+ if (after.trim() !== '')
1296
+ return null;
1297
+ return trimmed;
543
1298
  }
544
1299
  /** 中央寄せの見出しとみなす最小の字下げ。 */
545
1300
  const CENTERED_HEADER_MIN_INDENT = 8;
@@ -577,19 +1332,174 @@ function centeredSectionHeader(lines, index) {
577
1332
  return null;
578
1333
  return { number, title };
579
1334
  }
1335
+ /**
1336
+ * 段落の途中で折り返した本文か。1 桁目から本文を組む RFC で、行頭に数字が
1337
+ * 来た行を節と見分ける。
1338
+ *
1339
+ * RFC 1305 は本文を 1 桁目から組む。目次を除いたあと、次の 2 行が節になっていた。
1340
+ *
1341
+ * | 行 | 実体 |
1342
+ * |---|---|
1343
+ * | `6. When present, the data field contains a list of identifiers or` | `shown in Figure` の折り返し |
1344
+ * | `3 (July 1987), 626-645.` | `J. ACM 34,` の折り返し(参考文献) |
1345
+ *
1346
+ * どちらも**直前の行が文の途中で終わっている**(文末記号が無い)。そのうえで、
1347
+ *
1348
+ * - 直前の行が読点で終わっていれば本文である。文は読点の次の行で終わらない。
1349
+ * - 題名が前置詞・接続詞で終わり、次の行に続くなら本文である。
1350
+ *
1351
+ * 直前が空行か文末で終わっていれば見ない(RFC 1122 §1.4 は図表の直後に置かれる)。
1352
+ * 題名が句点で終わることは判断に使わない。RFC 4960 §5.2.5 の
1353
+ * `5.2.5. Handle Duplicate COOKIE-ACK.` はページの先頭にあり、前のページが
1354
+ * 文の途中で終わっているので、句点で落とすとこの節が消える。
1355
+ */
1356
+ function isWrappedBodyLine(lines, index, title) {
1357
+ const previous = (index > 0 ? lines[index - 1] : '').trim();
1358
+ if (previous === '' || /[.:;!?]$/.test(previous))
1359
+ return false;
1360
+ if (/,$/.test(previous))
1361
+ return true;
1362
+ const next = (lines[index + 1] ?? '').trim();
1363
+ return TITLE_ENDS_INCOMPLETE.test(title.trim()) && next !== '';
1364
+ }
1365
+ /**
1366
+ * その節番号を、いま新しい節として受け入れてよいか。
1367
+ *
1368
+ * `SECTION_HEADER_PATTERN` は「数字 + 空白 + 何か」に当たるので、本文の中の
1369
+ * 次のような行も節見出しに見える。
1370
+ *
1371
+ * | RFC | 行 | 実体 |
1372
+ * |---|---|---|
1373
+ * | 1123 | `1. Unless there is private agreement between particular resolver and` | 要件一覧表の脚注 |
1374
+ * | 1305 | 注釈 `test 1` の折り返しで、閉じ記号だけが残った行 | C の注釈 |
1375
+ * | 1305 | `4 is used, this is the size of the clock filter …` | `Section` から折り返した本文 |
1376
+ * | 1305 | `1.7 hours and a settling time to within one percent of the initial` | 折り返した本文 |
1377
+ *
1378
+ * どれも、その番号の節がすでに出たあとに現れる。RFC の節番号は文書の中で
1379
+ * 一度しか使われず、前に戻ることもないので、次の 2 つで落とす。
1380
+ *
1381
+ * - すでに出した番号と同じ
1382
+ * - 1 段目の番号が、いままでの最大より小さい
1383
+ *
1384
+ * RFC 1123 ではこれが実害になっていた。脚注の `1.` を節として受け入れると
1385
+ * 直前の節番号が "1" に戻り、`isSuccessorSectionNumber` から見て §6.2 が
1386
+ * 「次に来る番号」でなくなる。§6.2 以降の 8 節が丸ごと落ち、その節の要件が
1387
+ * §6.1.5 に付いていた。
1388
+ */
1389
+ function acceptsSectionNumber(state, candidate) {
1390
+ if (state.seen.has(candidate))
1391
+ return false;
1392
+ // 付録は文字で番号を振る。数字の単調増加の検査は当てはまらない。
1393
+ if (/^[A-Z]/.test(candidate))
1394
+ return true;
1395
+ const topLevel = Number(candidate.split('.')[0]);
1396
+ if (!Number.isInteger(topLevel))
1397
+ return false;
1398
+ if (state.seen.size > 0 && topLevel < state.maxTopLevel)
1399
+ return false;
1400
+ // 1 段目の番号が大きく飛ぶ行は節ではない。
1401
+ //
1402
+ // RFC 2068 は Warning ヘッダの警告コードを表にして、`99 Miscellaneous warning`
1403
+ // を 1 桁目に置く。これを §99 として受け入れると `maxTopLevel` が 99 になり、
1404
+ // **そのあとの §14.46・§15・§15.1〜15.9・§16 が丸ごと落ちていた(30 節)。**
1405
+ // 節番号は 1 ずつ増える。欠番があっても数個である。
1406
+ if (state.seen.size > 0 && topLevel > state.maxTopLevel + MAX_SECTION_NUMBER_GAP)
1407
+ return false;
1408
+ return true;
1409
+ }
1410
+ /**
1411
+ * 折り返した見出しの 2 行目を返す。折り返しでなければ `null`。
1412
+ *
1413
+ * 題名は右余白で折り返す。RFC 7519 §10.2 は
1414
+ *
1415
+ * ```
1416
+ * 10.2. Sub-Namespace Registration of
1417
+ * urn:ietf:params:oauth:token-type:jwt
1418
+ * ```
1419
+ *
1420
+ * と 2 行になる。1 行目だけを取ると題名が "Sub-Namespace Registration of" で
1421
+ * 終わり、**何の登録かが消える**。RFC 6797 §11.3 は
1422
+ * "Using HSTS in Conjunction with Self-Signed Public-Key" で終わり、
1423
+ * 何の証明書かが消えていた。
1424
+ *
1425
+ * 続きの行は**題名の開始桁にそろう**。これが本文と見分ける手掛かりである。
1426
+ * RFC 1035 §6.4.1 は見出しの直後に本文が 1 桁目から続くので当たらない。
1427
+ */
1428
+ function titleContinuation(lines, index, titleColumn) {
1429
+ if (titleColumn < MIN_CONTINUATION_COLUMN)
1430
+ return null;
1431
+ const next = (lines[index + 1] ?? '').replace(/\s+$/, '');
1432
+ const text = next.trim();
1433
+ if (text === '')
1434
+ return null;
1435
+ if (next.length - text.length !== titleColumn)
1436
+ return null;
1437
+ // 見出しは 1 つの空行で終わる。3 行に折り返す題名は無い。
1438
+ if ((lines[index + 2] ?? '').trim() !== '')
1439
+ return null;
1440
+ if (text.length > MAX_CONTINUATION_LENGTH)
1441
+ return null;
1442
+ // 別の見出しなら折り返しではない。
1443
+ if (SECTION_HEADER_PATTERN.test(text))
1444
+ return null;
1445
+ // 題名は句点で終わらない。RFC 1123 の脚注 "particular server." を落とす。
1446
+ if (/[.!?]$/.test(text))
1447
+ return null;
1448
+ if (containsSentenceBreak(text))
1449
+ return null;
1450
+ return text;
1451
+ }
1452
+ /** 続きの行とみなす字下げの下限。これより浅いと本文の 1 行目と見分けられない。 */
1453
+ const MIN_CONTINUATION_COLUMN = 3;
1454
+ /** 続きの行の文字数の上限。折り返しの 2 行目が本文 1 行分になることは無い。 */
1455
+ const MAX_CONTINUATION_LENGTH = 60;
580
1456
  function extractTextSections(lines) {
581
1457
  const sections = [];
582
1458
  let currentSection = null;
583
1459
  let currentContent = [];
584
1460
  let lastSectionNumber = null;
1461
+ /** すでに節として出した番号と、1 段目の最大値。 */
1462
+ const numbering = { seen: new Set(), maxTopLevel: 0 };
1463
+ /** 直前に出した付録。付録が A から 1 つずつ進むことの確認に使う。 */
1464
+ const lastAppendix = { letter: null, number: null };
1465
+ const claimSectionNumber = (number) => {
1466
+ numbering.seen.add(number);
1467
+ numbering.maxTopLevel = Math.max(numbering.maxTopLevel, Number(number.split('.')[0]) || 0);
1468
+ lastSectionNumber = number;
1469
+ advanceToc(number);
1470
+ };
1471
+ /** リーダー無しの目次。その行は節にしない。題名は番号無しの見出しの照合に使う。 */
1472
+ const toc = leaderlessToc(lines);
1473
+ let tocCursor = 0;
1474
+ /** 目次の並びを、いま出した番号の次まで進める。 */
1475
+ const advanceToc = (number) => {
1476
+ const at = toc.entries.findIndex((entry, k) => k >= tocCursor && entry.number === number);
1477
+ if (at >= 0)
1478
+ tocCursor = at + 1;
1479
+ };
1480
+ const openSection = (number, title) => {
1481
+ if (currentSection) {
1482
+ currentSection.content = createTextBlocks(currentContent.join('\n'));
1483
+ sections.push(currentSection);
1484
+ }
1485
+ currentSection = { number, title, content: [], subsections: [] };
1486
+ currentContent = [];
1487
+ advanceToc(number);
1488
+ };
585
1489
  for (let index = 0; index < lines.length; index++) {
586
1490
  const line = lines[index];
1491
+ if (toc.lines.has(index)) {
1492
+ if (currentSection)
1493
+ currentContent.push(line);
1494
+ continue;
1495
+ }
587
1496
  // 古い RFC は上位の節見出しを中央に寄せる(RFC 793 の "2. PHILOSOPHY")。
588
1497
  // 1 桁目の規則だけでは §1 / §2 / §3 が丸ごと落ち、その節の要件が
589
1498
  // 手前の節に付く。字下げが深く、題名が全部大文字で、前後が空行の
590
1499
  // 1 段目の見出しだけを拾う。RFC 793 の状態遷移図にある
591
1500
  // " 2. SYN-SENT --> ..." は字下げが浅く、小文字を含むので当たらない。
592
- const header = centeredSectionHeader(lines, index) ?? indentedSectionHeader(lines, index, lastSectionNumber);
1501
+ const candidate = centeredSectionHeader(lines, index) ?? indentedSectionHeader(lines, index, lastSectionNumber);
1502
+ const header = candidate && acceptsSectionNumber(numbering, candidate.number) ? candidate : null;
593
1503
  if (header) {
594
1504
  if (currentSection) {
595
1505
  currentSection.content = createTextBlocks(currentContent.join('\n'));
@@ -601,8 +1511,13 @@ function extractTextSections(lines) {
601
1511
  content: [],
602
1512
  subsections: [],
603
1513
  };
604
- lastSectionNumber = header.number;
1514
+ claimSectionNumber(header.number);
605
1515
  currentContent = [];
1516
+ // 題名に継いだ行は本文ではない。進めないと、RFC 1122 §4.2.2.9 の
1517
+ // 折り返し `3.3, page 27` が次の要件文の頭に付く
1518
+ // ("3.3, page 27 A TCP MUST use the specified clock-driven …")。
1519
+ if (header.lastLine !== undefined)
1520
+ index = header.lastLine;
606
1521
  continue;
607
1522
  }
608
1523
  // 節見出しは 1 桁目から始まる。字下げされた行は本文の番号付きリスト項目である。
@@ -614,12 +1529,80 @@ function extractTextSections(lines) {
614
1529
  // 52 件が節番号の重複だった)。番号だけが同じ「節」がいくつも並ぶと
615
1530
  // `findSection` がどれを引くか定まらず、要件の `sectionTitle` にも本文の
616
1531
  // 1 行目が出る。
1532
+ // 付録は文字で番号を振る。数字の見出しを試す前に見る。
1533
+ const appendix = appendixHeader(lines, index, lastAppendix);
1534
+ if (appendix && acceptsSectionNumber(numbering, appendix.number)) {
1535
+ if (currentSection) {
1536
+ currentSection.content = createTextBlocks(currentContent.join('\n'));
1537
+ sections.push(currentSection);
1538
+ }
1539
+ // 題名に継いだ行は本文ではない
1540
+ index = appendix.lastLine;
1541
+ currentSection = {
1542
+ number: appendix.number,
1543
+ title: appendix.title,
1544
+ content: [],
1545
+ subsections: [],
1546
+ };
1547
+ numbering.seen.add(appendix.number);
1548
+ lastAppendix.letter = appendix.number[0];
1549
+ lastAppendix.number = appendix.number;
1550
+ currentContent = [];
1551
+ advanceToc(appendix.number);
1552
+ continue;
1553
+ }
1554
+ // 本文の見出しに番号が無く、目次にだけ番号がある RFC がある。RFC 1305 は
1555
+ // 目次に `3.2. State Variables and Parameters 9` と書き、本文の見出しは
1556
+ // `State Variables and Parameters` だけである。目次の題名と同じ行が前後を
1557
+ // 空行に挟まれて現れたら、目次の番号でその節を立てる。目次の並びの順に
1558
+ // 照合し、数件先までしか見ない。同じ語が本文に単独で現れても、その番号の
1559
+ // 順番が来ていなければ節にしない。
1560
+ const fromToc = tocTitleHeader(lines, index, toc, tocCursor);
1561
+ if (fromToc && acceptsSectionNumber(numbering, fromToc.number)) {
1562
+ openSection(fromToc.number, fromToc.title);
1563
+ if (/^[A-Z]/.test(fromToc.number)) {
1564
+ numbering.seen.add(fromToc.number);
1565
+ lastAppendix.letter = fromToc.number[0];
1566
+ lastAppendix.number = fromToc.number;
1567
+ }
1568
+ else {
1569
+ claimSectionNumber(fromToc.number);
1570
+ }
1571
+ continue;
1572
+ }
617
1573
  const match = line.replace(/\s+$/, '').match(SECTION_HEADER_PATTERN);
618
1574
  if (match) {
619
1575
  const sectionNum = match[1].replace(/\.$/, '');
620
1576
  const title = match[2];
621
1577
  // セクションヘッダーとして妥当性を検証
622
- if (isValidSectionHeader(sectionNum, title)) {
1578
+ // 小文字で始まる題名は、番号が句点で終わるか、直前が空行のときだけ
1579
+ // 節とみなす。
1580
+ //
1581
+ // RFC 896 の本文は "…and would be sent immediately. The next" のあと
1582
+ // "24 characters, arriving from the user at 200ms intervals, would"
1583
+ // と折り返す。これが番号 24 の節として通っていた。この RFC には節が
1584
+ // 1 つも無いので、`get_rfc_structure` はこの 1 件だけを返していた。
1585
+ //
1586
+ // 2 つの条件はどちらか一方では足りない。
1587
+ //
1588
+ // | RFC | 行 | 番号の句点 | 直前が空行 |
1589
+ // |---|---|---|---|
1590
+ // | 7230 §5.3.1 | `5.3.1. origin-form` | あり | 無し(前ページの最終行が ABNF の `/ asterisk-form`) |
1591
+ // | 2616 §3.2.2 | `3.2.2 http URL` | 無し | あり |
1592
+ // | 896(本文) | `24 characters, arriving from…` | 無し | 無し |
1593
+ //
1594
+ // 大文字で始まる題名には課さない。RFC 1122 §1.4 と §4.2 は図表の直後に
1595
+ // 置かれており、空行を課すと 123 節が 64 節に減る。
1596
+ const startsLowercase = /^[a-z]/.test(title.trim());
1597
+ const numberEndsWithPeriod = match[1].endsWith('.');
1598
+ const followsBlankLine = (index > 0 ? lines[index - 1] : '').trim() === '';
1599
+ if (isValidSectionHeader(sectionNum, title) &&
1600
+ acceptsSectionNumber(numbering, sectionNum) &&
1601
+ (!startsLowercase ||
1602
+ numberEndsWithPeriod ||
1603
+ followsBlankLine ||
1604
+ looksLikeTitleCase(title)) &&
1605
+ !isWrappedBodyLine(lines, index, title)) {
623
1606
  // 前のセクションを保存
624
1607
  if (currentSection) {
625
1608
  currentSection.content = createTextBlocks(currentContent.join('\n'));
@@ -628,15 +1611,23 @@ function extractTextSections(lines) {
628
1611
  // 見出しと本文が 1 行に入っている RFC がある(RFC 1035 §6.4.1 の
629
1612
  // "6.4.1. The contents of inverse queries and responses Inverse")。
630
1613
  // 4 個以上の空白で切り、残りは本文に回す。
631
- const [headline, ...rest] = title.split(/ {4,}/);
1614
+ const [headline, ...rest] = title.split(/ {3,}/);
1615
+ // 折り返した題名の 2 行目を継ぐ。見出しと本文が 1 行に入っている
1616
+ // ときは折り返しではないので見ない。
1617
+ const headerLine = line.replace(/\s+$/, '');
1618
+ const continuation = rest.length === 0
1619
+ ? titleContinuation(lines, index, headerLine.length - title.length)
1620
+ : null;
1621
+ if (continuation)
1622
+ index++;
632
1623
  // 新しいセクションを開始
633
1624
  currentSection = {
634
1625
  number: sectionNum,
635
- title: headline.trim(),
1626
+ title: continuation ? `${headline.trim()} ${continuation}` : headline.trim(),
636
1627
  content: [],
637
1628
  subsections: [],
638
1629
  };
639
- lastSectionNumber = sectionNum;
1630
+ claimSectionNumber(sectionNum);
640
1631
  currentContent = rest.length > 0 ? [rest.join(' ').trim()] : [];
641
1632
  }
642
1633
  else if (currentSection) {
@@ -653,9 +1644,168 @@ function extractTextSections(lines) {
653
1644
  currentSection.content = createTextBlocks(currentContent.join('\n'));
654
1645
  sections.push(currentSection);
655
1646
  }
1647
+ // 番号の付いた見出しが無い RFC は、番号なしの見出しで取り直す。
1648
+ if (sections.length < MIN_NUMBERED_SECTIONS) {
1649
+ const unnumbered = extractUnnumberedSections(lines);
1650
+ if (unnumbered.length > sections.length)
1651
+ return unnumbered;
1652
+ }
656
1653
  // セクションを階層構造に整理
657
1654
  return organizeSections(sections);
658
1655
  }
1656
+ /** ここに満たないときは、番号なしの見出しで取り直す。 */
1657
+ const MIN_NUMBERED_SECTIONS = 2;
1658
+ /** 番号なしの見出しとみなす行の、文字数・語数・字下げの上限。 */
1659
+ const UNNUMBERED_HEADER_MAX_LENGTH = 60;
1660
+ const UNNUMBERED_HEADER_MAX_WORDS = 8;
1661
+ const UNNUMBERED_HEADER_MAX_INDENT = 12;
1662
+ /** 番号なしの見出しで取り直すのに必要な、見出しの最小数。 */
1663
+ const MIN_UNNUMBERED_HEADINGS = 2;
1664
+ /** 番号なしの見出しで作る階層の深さの上限。 */
1665
+ const UNNUMBERED_HEADER_MAX_DEPTH = 4;
1666
+ /**
1667
+ * 番号なしの見出しで節を取る。
1668
+ *
1669
+ * 1980 年代の RFC は節に番号を振らない。
1670
+ *
1671
+ * ```
1672
+ * INTRODUCTION
1673
+ *
1674
+ * The purpose of the TELNET Protocol is to provide a fairly general,
1675
+ * bi-directional, eight-bit byte oriented communications facility.
1676
+ * ```
1677
+ *
1678
+ * 番号を頼りにすると 1 つも取れない。実測(1980 年代の RFC 29 本)で
1679
+ * 14 本が節 0〜1 だった。RFC 792(ICMP)、RFC 826(ARP)、RFC 854(Telnet)、
1680
+ * RFC 894(IP over Ethernet)が含まれる。どれも RFC 1122 や RFC 1123 が
1681
+ * 繰り返し参照する文書で、`get_rfc_structure` が何も返していなかった。
1682
+ *
1683
+ * 見出しとみなす条件は 6 つ。
1684
+ *
1685
+ * - 前後が空行
1686
+ * - 3 文字以上 60 文字以下、8 語以下
1687
+ * - 文末記号(`.` `!` `?` `,` `;` `:`)で終わらない
1688
+ * - 小文字だけの行ではない
1689
+ * - ページの飾り(`RFC 792`)や表の見出しではない
1690
+ * - 字下げが 12 桁以内
1691
+ *
1692
+ * **番号は字下げの深さから作る。** 原文に番号は無いので、`§3.2` はこちらが
1693
+ * 振った番号である。RFC 854 は `THE NETWORK VIRTUAL TERMINAL` の下に
1694
+ * `TRANSMISSION OF DATA` を 3 桁字下げ、その下に `Interrupt Process (IP)` を
1695
+ * 6 桁字下げで置く。文書の見た目だけが唯一の構造である。
1696
+ */
1697
+ function extractUnnumberedSections(lines) {
1698
+ const candidates = unnumberedHeadings(lines);
1699
+ if (candidates.length < MIN_UNNUMBERED_HEADINGS)
1700
+ return [];
1701
+ // 字下げの深さを段の深さに直す。値そのものではなく、**そこまでに現れた**
1702
+ // 字下げを浅い順に並べた順位を使う。
1703
+ //
1704
+ // 文書全体の字下げを先に集めると、上位の見出しが後ろに出る文書で番号が
1705
+ // 狂う。RFC 855 は `Section 1 - …` を 3 桁字下げで先に並べ、そのあとに
1706
+ // 1 桁目の `A Note on "Subnegotiation"` を置く。先に集めると 3 桁が 2 段目に
1707
+ // なり、親のない `1.1` から始まってしまう。
1708
+ const seen = [];
1709
+ const counters = [];
1710
+ const used = new Set();
1711
+ const numberFor = (indent) => {
1712
+ if (!seen.includes(indent)) {
1713
+ seen.push(indent);
1714
+ seen.sort((a, b) => a - b);
1715
+ }
1716
+ const depth = Math.min(seen.indexOf(indent), UNNUMBERED_HEADER_MAX_DEPTH - 1);
1717
+ while (counters.length <= depth)
1718
+ counters.push(0);
1719
+ counters.length = depth + 1;
1720
+ counters[depth] += 1;
1721
+ // 番号が重ならないようにする。段の深さに上限があるため、上限より深い
1722
+ // 見出しが 2 つの塊に分かれて現れると、同じ番号が 2 度出る。RFC 765 は
1723
+ // 転送形式の一覧(`ASCII Format` …)とページ構造の一覧(`Header Length` …)が
1724
+ // どちらも 4 段目に来て、**`3.1.1.1` から `3.1.1.5` が 2 度**立っていた。
1725
+ // 番号が重なると `findSection` がどちらを引くか定まらない。
1726
+ let number = counters.map((n) => Math.max(n, 1)).join('.');
1727
+ while (used.has(number)) {
1728
+ counters[depth] += 1;
1729
+ number = counters.map((n) => Math.max(n, 1)).join('.');
1730
+ }
1731
+ used.add(number);
1732
+ return number;
1733
+ };
1734
+ const flat = [];
1735
+ let current = null;
1736
+ let content = [];
1737
+ let next = 0;
1738
+ for (let index = 0; index < lines.length; index++) {
1739
+ if (next < candidates.length && candidates[next].index === index) {
1740
+ if (current) {
1741
+ current.content = createTextBlocks(content.join('\n'));
1742
+ flat.push(current);
1743
+ }
1744
+ const heading = candidates[next++];
1745
+ current = {
1746
+ number: numberFor(heading.indent),
1747
+ title: heading.title,
1748
+ content: [],
1749
+ subsections: [],
1750
+ };
1751
+ content = [];
1752
+ // 下線の行は本文ではない
1753
+ if (heading.underlined)
1754
+ index++;
1755
+ continue;
1756
+ }
1757
+ if (current)
1758
+ content.push(lines[index]);
1759
+ }
1760
+ if (current) {
1761
+ current.content = createTextBlocks(content.join('\n'));
1762
+ flat.push(current);
1763
+ }
1764
+ return organizeSections(flat);
1765
+ }
1766
+ /** 番号なしの見出しの候補を、文書に現れる順に返す。 */
1767
+ function unnumberedHeadings(lines) {
1768
+ const found = [];
1769
+ for (let index = 0; index < lines.length; index++) {
1770
+ const line = lines[index];
1771
+ const trimmed = line.trim();
1772
+ if (trimmed.length < 3 || trimmed.length > UNNUMBERED_HEADER_MAX_LENGTH)
1773
+ continue;
1774
+ if (!/^[A-Za-z]/.test(trimmed))
1775
+ continue;
1776
+ // 下線を引いた見出しは末尾のコロンと疑問符を許す(RFC 826 の `Notes:`
1777
+ // `Why is it done this way??`)。下線が無ければ文の途中と見分けられない。
1778
+ const underlined = isUnderline(lines[index + 1] ?? '');
1779
+ if (underlined ? /[.!,;]$/.test(trimmed) : /[.!?,;:]$/.test(trimmed))
1780
+ continue;
1781
+ if (trimmed.split(/\s+/).length > UNNUMBERED_HEADER_MAX_WORDS)
1782
+ continue;
1783
+ if (trimmed === trimmed.toLowerCase())
1784
+ continue;
1785
+ // ページの飾り。RFC 792 はページ見出しを "RFC 792" の 1 行で書くため、
1786
+ // 前後が空行になり見出しの条件を満たしてしまう。
1787
+ if (/^RFC[\s-]*\d+$/i.test(trimmed))
1788
+ continue;
1789
+ // 表の見出し(RFC 854 の "NAME CODE MEANING")。
1790
+ if (looksLikeDiagram(trimmed))
1791
+ continue;
1792
+ const indent = line.length - line.trimStart().length;
1793
+ if (indent > UNNUMBERED_HEADER_MAX_INDENT)
1794
+ continue;
1795
+ const before = index > 0 ? lines[index - 1] : '';
1796
+ // 見出しの直後は空行。下線の行(`-----`)を挟む RFC がある。RFC 826 は
1797
+ // `Notes:` の次の行に `------` を置くため、空行だけを求めると節が 0 件だった。
1798
+ const after = index + (underlined ? 2 : 1) < lines.length ? lines[index + (underlined ? 2 : 1)] : '';
1799
+ if (before.trim() !== '' || after.trim() !== '')
1800
+ continue;
1801
+ found.push({ index, indent, title: trimmed, underlined });
1802
+ }
1803
+ return found;
1804
+ }
1805
+ /** 見出しの下線。`-` か `=` を 3 つ以上並べた行。 */
1806
+ function isUnderline(line) {
1807
+ return /^\s*(?:-{3,}|={3,})\s*$/.test(line);
1808
+ }
659
1809
  /**
660
1810
  * セクションを階層構造に整理
661
1811
  */
@@ -703,15 +1853,210 @@ const MAX_PARAGRAPH_JOINS = 3;
703
1853
  * 図・ABNF で終わる段落は繋がない。`acceptable-ranges = 1#range-unit | "none"` の
704
1854
  * ように、文末記号が無いのが普通だからである。
705
1855
  */
1856
+ /** 表示ブロックの 1 行にある語数の上限。 */
1857
+ const DISPLAY_BLOCK_MAX_WORDS = 3;
1858
+ /**
1859
+ * 値を並べただけの塊か。
1860
+ *
1861
+ * RFC 8259 §3 は取りうる値を字下げして並べる。
1862
+ *
1863
+ * ```
1864
+ * A JSON value MUST be an object, array, number, or string, or one of
1865
+ * the following three literal names:
1866
+ *
1867
+ * false
1868
+ * null
1869
+ * true
1870
+ *
1871
+ * The literal names MUST be lowercase. No other literal names are
1872
+ * allowed.
1873
+ * ```
1874
+ *
1875
+ * この塊は文末記号を持たないので、続く段落と繋がれて要件文が
1876
+ * "false null true The literal names MUST be lowercase." になっていた。
1877
+ *
1878
+ * `looksLikeDiagram` は当たらない。ABNF の規則でも罫線でもなく、
1879
+ * 空白で桁を揃えてもいない、ただの短い語の並びである。
1880
+ */
1881
+ function looksLikeDisplayBlock(text) {
1882
+ const lines = text.split('\n').filter((line) => line.trim() !== '');
1883
+ if (lines.length === 0)
1884
+ return false;
1885
+ const short = lines.every((line) => {
1886
+ const trimmed = line.trim();
1887
+ if (/[.!?]$/.test(trimmed))
1888
+ return false;
1889
+ return trimmed.split(/\s+/).length <= DISPLAY_BLOCK_MAX_WORDS;
1890
+ });
1891
+ if (!short)
1892
+ return false;
1893
+ if (lines.length >= 2)
1894
+ return true;
1895
+ // 1 行だけの塊は、地の文の折り返しか、値の並びかが分かれる。
1896
+ //
1897
+ // RFC 7159 §3 の ` false null true` は 1 行なので、2 行以上を求める
1898
+ // 規則では落ちていた。続く段落と繋がれ、要件文が
1899
+ // **"false null true The literal names MUST be lowercase."** になっていた。
1900
+ // RFC 6749 §4.3.2 の `grant_type=password&username=johndoe&password=A3ddj3w`、
1901
+ // RFC 3261 §19.1.1 の `parameter-name "=" parameter-value` も同じ形。
1902
+ //
1903
+ // 記号を含むか、機能語を 1 つも含まない語の並びなら、地の文ではない。
1904
+ const only = lines[0].trim();
1905
+ if (/["'=&<>{}|/\\_]|::=/.test(only))
1906
+ return true;
1907
+ const words = only.split(/\s+/);
1908
+ return words.every((word) => /^[a-z][\w-]*$/.test(word) && !FUNCTION_WORDS.has(word));
1909
+ }
1910
+ /** 地の文にしか出ない語。1 語でもあれば、値の並びではない。 */
1911
+ const FUNCTION_WORDS = new Set([
1912
+ 'the',
1913
+ 'a',
1914
+ 'an',
1915
+ 'of',
1916
+ 'to',
1917
+ 'in',
1918
+ 'on',
1919
+ 'for',
1920
+ 'and',
1921
+ 'or',
1922
+ 'is',
1923
+ 'are',
1924
+ 'be',
1925
+ 'as',
1926
+ 'at',
1927
+ 'by',
1928
+ 'it',
1929
+ 'its',
1930
+ 'this',
1931
+ 'that',
1932
+ 'with',
1933
+ 'from',
1934
+ 'if',
1935
+ 'when',
1936
+ 'which',
1937
+ 'not',
1938
+ ]);
1939
+ /**
1940
+ * 箇条書きの項目で終わっているか。
1941
+ *
1942
+ * 項目は文末記号を持たないことが多い。RFC 6455 §3 は URI の組み立てを
1943
+ *
1944
+ * ```
1945
+ * o "?" if the query component is non-empty
1946
+ *
1947
+ * o the query component
1948
+ *
1949
+ * Fragment identifiers are meaningless in the context of WebSocket URIs
1950
+ * and MUST NOT be used on these URIs.
1951
+ * ```
1952
+ *
1953
+ * と書く。`o the query component` は文末記号を持たないので、次の段落と
1954
+ * 繋がれ、要件文が **"the query component Fragment identifiers are
1955
+ * meaningless …"** になっていた。項目は 1 つの単位であって、次の段落は
1956
+ * その続きではない。
1957
+ *
1958
+ * ただし**項目のあとに項目が来るときは繋ぐ**。RFC 2616 §8.2.4 は
1959
+ *
1960
+ * ```
1961
+ * If at any point an error status is received, the client
1962
+ *
1963
+ * - SHOULD NOT continue and
1964
+ *
1965
+ * - SHOULD close the connection if it has not completed sending the
1966
+ * request message.
1967
+ * ```
1968
+ *
1969
+ * と、箇条書きで 1 つの文を作る。ここで切ると要件文が
1970
+ * "…the client - SHOULD NOT continue and" で終わる。
1971
+ */
1972
+ function endsWithListItem(paragraph) {
1973
+ const lines = paragraph.split('\n');
1974
+ for (let i = lines.length - 1; i >= 0; i--) {
1975
+ const line = lines[i];
1976
+ if (line.trim() === '')
1977
+ continue;
1978
+ return LIST_ITEM_START.test(line) || LIST_ITEM_START.test(lines[0] ?? '');
1979
+ }
1980
+ return false;
1981
+ }
1982
+ /**
1983
+ * コロンの続きとして取り込む項目の記号。
1984
+ *
1985
+ * `1.` `2.` の形は入れない。取り込むと `isSentenceEnd` が番号の句点を文の
1986
+ * 終わりと読み、要件文が **"The client MUST validate the server's response as
1987
+ * follows: 1."** で切れる(RFC 6455 §4.1)。
1988
+ */
1989
+ const COLON_LIST_MARKER = /^\s*(?:o\s{2}|[-*+•]\s|\(\d+\)\s)/;
1990
+ /** コロンの続きとして取り込む項目に、記号のほかに要る文字数。 */
1991
+ const LIST_ITEM_MIN_CONTENT = 3;
1992
+ /** 箇条書きの項目の始まり。`o` は語と紛れるので空白 2 個を求める。 */
1993
+ const LIST_ITEM_START = /^\s*(?:o\s{2}|[-*+•]\s|\(\d+\)\s|\d+[.)]\s|[a-z][.)]\s)/;
1994
+ /**
1995
+ * 直前の塊が「表示のための塊」かどうかを、字下げの深さで決める。
1996
+ *
1997
+ * RFC 7230 §6.7 の例示の中の
1998
+ *
1999
+ * ```
2000
+ * [... data stream switches to HTTP/2.0 with an appropriate response
2001
+ * (as defined by new protocol) to the "GET /hello.txt" request ...]
2002
+ *
2003
+ * When Upgrade is sent, the sender MUST also send a Connection header
2004
+ * ```
2005
+ *
2006
+ * は 6 桁目から始まり、続く地の文は 4 桁目から始まる。語数が多いので
2007
+ * `looksLikeDisplayBlock` では当たらず、要件文が例示から始まっていた。
2008
+ *
2009
+ * 2 つ外す。
2010
+ *
2011
+ * - **箇条書きの項目**。RFC 2068 §8.2 は地の文を 4 桁目、項目を 3 桁目から
2012
+ * 書く(` No matter what the server version, if an error status is
2013
+ * received, the client` / ` o MUST NOT continue and`)。字下げだけで
2014
+ * 決めると、項目が本文から切り離される。
2015
+ * - **小文字で始まる続きの文**。RFC 2616 §14.10 の ` Connection: close`
2016
+ * は、続く ` in either the request or the response header fields
2017
+ * indicates that …` の主語である。切ると `in` から始まる断片になる。
2018
+ *
2019
+ * 実測(RFC 67 本): 例示が要件文の頭に付いていたもの **44 件**。
2020
+ */
2021
+ function isDisplayByIndent(previous, paragraph) {
2022
+ if (indentOf(previous) <= indentOf(paragraph))
2023
+ return false;
2024
+ if (LIST_ITEM_START.test(paragraph))
2025
+ return false;
2026
+ return !/^\s*[a-z]/.test(paragraph);
2027
+ }
2028
+ /** 塊の字下げ。空でない最初の行で測る。 */
2029
+ function indentOf(block) {
2030
+ const line = block.split('\n').find((candidate) => candidate.trim() !== '') ?? '';
2031
+ return line.length - line.trimStart().length;
2032
+ }
706
2033
  function joinUnterminatedParagraphs(paragraphs) {
707
2034
  const joined = [];
708
2035
  const joinCount = [];
709
2036
  for (const paragraph of paragraphs) {
710
2037
  const last = joined.length - 1;
711
2038
  const previous = joined[last];
2039
+ // コロンで終わる文が、続く箇条書きで完結することがある。RFC 6797 §8.1 の
2040
+ // "the UA MUST either:" は、続く 2 つの項目を取らないと**何を選ぶのかが
2041
+ // 読めない**。RFC 1122 の "An ICMP error message MUST NOT be sent as the
2042
+ // result of receiving:" も同じ。
2043
+ // ただし**項目自身がキーワードを持つなら繋がない**。その項目は独立した
2044
+ // 要件であり、取り込むと元の要件文が失われる(RFC 1122 §3.2.1.8 の
2045
+ // "the following rules apply:" と "o The originating host MUST record …")。
2046
+ const completesWithList = previous !== undefined &&
2047
+ /:\s*$/.test(previous) &&
2048
+ COLON_LIST_MARKER.test(paragraph) &&
2049
+ // 記号だけの行は取らない(RFC 1521 の付録は "1." だけの行を挟む)
2050
+ paragraph.replace(COLON_LIST_MARKER, '').trim().length >= LIST_ITEM_MIN_CONTENT &&
2051
+ !createRequirementRegex().test(paragraph);
712
2052
  const canJoin = previous !== undefined &&
713
- !/[.!?:;]\s*$/.test(previous) &&
2053
+ (completesWithList || !/[.!?:;]\s*$/.test(previous)) &&
714
2054
  !looksLikeDiagram(previous) &&
2055
+ !looksLikeDisplayBlock(previous) &&
2056
+ !isDisplayByIndent(previous, paragraph) &&
2057
+ // 項目のあとに地の文が来たら、そこで切る。項目のあとに項目が来るなら、
2058
+ // 箇条書きが 1 つの文を作っている途中なので繋ぐ。
2059
+ !(endsWithListItem(previous) && !LIST_ITEM_START.test(paragraph)) &&
715
2060
  joinCount[last] < MAX_PARAGRAPH_JOINS &&
716
2061
  // どちらかに BCP 14 キーワードがあること。要件に関わらない箇所では繋がない。
717
2062
  // RFC 3261 §7.3.1 は "is equivalent to" と表示例を交互に並べる。繋ぐと
@@ -734,16 +2079,8 @@ function createTextBlocks(text) {
734
2079
  const trimmed = para.trim();
735
2080
  if (!trimmed)
736
2081
  continue;
737
- // 要件マーカーを抽出
738
- const requirements = [];
739
- const regex = createRequirementRegex();
740
- let match;
741
- while ((match = regex.exec(trimmed)) !== null) {
742
- requirements.push({
743
- level: match[1],
744
- position: match.index,
745
- });
746
- }
2082
+ // 要件マーカーを抽出(XML 経路と同じもの)
2083
+ const requirements = extractMarkers(trimmed);
747
2084
  blocks.push({
748
2085
  type: 'text',
749
2086
  content: trimmed,
@@ -761,31 +2098,200 @@ function extractTextDefinitions(lines) {
761
2098
  const definitions = [];
762
2099
  const defPattern = /^\s*([A-Za-z][A-Za-z0-9\s-]*[A-Za-z0-9])\s*[-:]\s+(.+)$/;
763
2100
  let currentSection = '';
764
- for (const line of lines) {
2101
+ let inIndex = false;
2102
+ /** 付録の追跡。`extractTextSections` と同じ判定・同じ状態で進める。 */
2103
+ const lastAppendix = { letter: null, number: null };
2104
+ const seenAppendices = new Set();
2105
+ for (let index = 0; index < lines.length; index++) {
2106
+ const line = lines[index];
765
2107
  const trimmed = line.trim();
766
- // セクションを追跡
767
- const sectionMatch = trimmed.match(SECTION_HEADER_PATTERN);
768
- if (sectionMatch) {
769
- currentSection = sectionMatch[1].replace(/\.$/, '');
2108
+ // 付録を追跡。数字の見出ししか見ていなかったため、付録に入っても
2109
+ // `currentSection` が更新されず、付録の定義が最後の番号付き節に付いていた。
2110
+ // RFC 1812 の Appendix B(GLOSSARY)の用語 54 件が §11(REFERENCES)、
2111
+ // RFC 8446 の Appendix E.1 の用語 10 件が §12.2(Informative References)
2112
+ // になっていた。節は実在するので `G4` では見つからない。
2113
+ const appendix = appendixHeader(lines, index, lastAppendix);
2114
+ if (appendix && !seenAppendices.has(appendix.number)) {
2115
+ seenAppendices.add(appendix.number);
2116
+ lastAppendix.letter = appendix.number[0];
2117
+ lastAppendix.number = appendix.number;
2118
+ currentSection = appendix.number;
2119
+ inIndex = /^index$/i.test(appendix.title);
2120
+ index = appendix.lastLine;
770
2121
  continue;
771
2122
  }
772
- // 定義パターンを探す
773
- const defMatch = trimmed.match(defPattern);
774
- if (defMatch) {
775
- const term = defMatch[1].trim();
776
- const definition = defMatch[2].trim();
777
- // 短すぎる用語や一般的な単語は除外
778
- if (term.length >= DEFINITION_EXTRACTION.MIN_TERM_LENGTH &&
779
- definition.length >= DEFINITION_EXTRACTION.MIN_DEFINITION_LENGTH) {
780
- definitions.push({
781
- term,
782
- definition,
783
- section: currentSection,
784
- });
2123
+ // セクションを追跡。節見出しは 1 桁目から始まる。字下げした行を数えると、
2124
+ // フレーム図の目盛り(RFC 6455 の "0 1 2 3")を節 0 として記録していた。
2125
+ if (!/^\s/.test(line)) {
2126
+ const sectionMatch = trimmed.match(SECTION_HEADER_PATTERN);
2127
+ if (sectionMatch) {
2128
+ currentSection = sectionMatch[1].replace(/\.$/, '');
2129
+ // 索引は「見出し語 / 字下げした行数」の並びで、用語欄と同じ形をしている。
2130
+ // RFC 7231 §11.2 の `A: Accept header field 38 …` を定義にしていた。
2131
+ inIndex = /^index$/i.test(sectionMatch[2]?.trim() ?? '');
2132
+ continue;
785
2133
  }
786
2134
  }
2135
+ // 節に入る前は RFC の表紙である。`Request for Comments: 7519` や
2136
+ // `Category: Standards Track` を用語として出していた(実測 111 件)。
2137
+ if (currentSection === '')
2138
+ continue;
2139
+ if (inIndex)
2140
+ continue;
2141
+ // ぶら下げの用語欄。用語だけの行のあとに、深く字下げした説明が続く。
2142
+ const hanging = hangingDefinition(lines, index);
2143
+ if (hanging) {
2144
+ definitions.push({ ...hanging.definition, section: currentSection });
2145
+ index = hanging.lastLine;
2146
+ continue;
2147
+ }
2148
+ // 定義パターンを探す
2149
+ const defMatch = trimmed.match(defPattern);
2150
+ if (!defMatch)
2151
+ continue;
2152
+ const term = defMatch[1].trim();
2153
+ const definition = defMatch[2].trim();
2154
+ if (term.length < DEFINITION_EXTRACTION.MIN_TERM_LENGTH)
2155
+ continue;
2156
+ if (definition.length < DEFINITION_EXTRACTION.MIN_DEFINITION_LENGTH)
2157
+ continue;
2158
+ if (!isDefinitionTerm(term))
2159
+ continue;
2160
+ // 段落の途中の行は定義ではない。
2161
+ //
2162
+ // これが誤りの主な出どころである。RFC の本文は 72 桁で折り返すので、
2163
+ // 文の途中から始まる行がいくらでもある。RFC 6797 §4 の
2164
+ // "…is the overall name for the combined UA and server-side security"
2165
+ // が「用語 = is the overall name for the combined UA」として出ていた。
2166
+ //
2167
+ // 定義の行は、空行のあとに来るか、前の行より深く字下げされている。
2168
+ const previous = lines[index - 1] ?? '';
2169
+ const indent = line.length - line.trimStart().length;
2170
+ const previousIndent = previous.length - previous.trimStart().length;
2171
+ if (previous.trim() !== '' && indent <= previousIndent)
2172
+ continue;
2173
+ // 用語欄は地の文の桁に置かれる。深く字下げされた `X: Y` は、見出し
2174
+ // フィールドの例示である。
2175
+ //
2176
+ // indent=3 CA: certification authority; (RFC 5280 §3)
2177
+ // indent=5 Accept-Charset: iso-8859-5, unicode-1-1;q=0.8(RFC 7231 §5.3.3)
2178
+ // indent=9 Sec-WebSocket-Extensions: foo, bar; baz=2 (RFC 6455 §9.1)
2179
+ // indent=14 Specifications: ABNF", STD 68, RFC 5234, (参考文献の欄)
2180
+ if (indent > COLON_DEFINITION_MAX_INDENT)
2181
+ continue;
2182
+ definitions.push({ term, definition, section: currentSection });
787
2183
  }
788
- return definitions;
2184
+ return dropNonDefinitions(definitions);
2185
+ }
2186
+ /** `X: Y` の形の用語欄を認める字下げの上限。これより深いものは例示である。 */
2187
+ const COLON_DEFINITION_MAX_INDENT = 4;
2188
+ /**
2189
+ * ぶら下げの用語欄を読む。読めなければ `null`。
2190
+ *
2191
+ * RFC の用語欄で最も多い形は、用語だけの行のあとに説明を字下げして置くもの。
2192
+ *
2193
+ * ```
2194
+ * JSON Web Token (JWT)
2195
+ * A string representing a set of claims as a JSON object that is
2196
+ * encoded in a JWS or JWE, ...
2197
+ * ```
2198
+ *
2199
+ * v0.6.25 まではこの形を 1 件も読めていなかった。`X: Y` の形しか見ておらず、
2200
+ * RFC 7519 §2 の 10 件、RFC 2616 §1.3 の用語欄が丸ごと落ちていた。
2201
+ *
2202
+ * 用語の行は、空行のあと・浅い字下げ・短い・句点で終わらない。説明の行は
2203
+ * それより深く字下げされ、大文字で始まる。参考文献の `[TAG]` と
2204
+ * ASN.1・C 構造体の断片(`struct {`、`Dss-Sig-Value ::= SEQUENCE {`)を除く。
2205
+ */
2206
+ function hangingDefinition(lines, index) {
2207
+ if ((lines[index - 1] ?? '').trim() !== '')
2208
+ return null;
2209
+ const line = lines[index].replace(/\s+$/, '');
2210
+ const term = line.trim();
2211
+ const indent = line.length - term.length;
2212
+ if (indent < HANGING_TERM_MIN_INDENT || indent > HANGING_TERM_MAX_INDENT)
2213
+ return null;
2214
+ if (term.length > HANGING_TERM_MAX_LENGTH)
2215
+ return null;
2216
+ if (term.split(/\s+/).length > HANGING_TERM_MAX_WORDS)
2217
+ return null;
2218
+ if (!/^[A-Za-z]/.test(term))
2219
+ return null;
2220
+ if (term.length < DEFINITION_EXTRACTION.MIN_TERM_LENGTH)
2221
+ return null;
2222
+ if (/[.:;,]$/.test(term))
2223
+ return null;
2224
+ // 参考文献の目印(RFC 1812 の `ARCH:9`)。句点無しの目印は用語欄と同じ形に
2225
+ // なる。目印の行を落とさないと、参考文献の項目 19 件が用語として出る。
2226
+ if (REFERENCE_TAG_ANCHOR.test(term))
2227
+ return null;
2228
+ // 文の書き出し・関係節を含むものは用語ではない
2229
+ // ("Implementations that have implementation")。
2230
+ if (SENTENCE_OPENER.test(term))
2231
+ return null;
2232
+ if (RELATIVE_CLAUSE.test(term))
2233
+ return null;
2234
+ // 型定義・構造体の断片
2235
+ if (/[{}|;=]|::=|\.\.\./.test(term))
2236
+ return null;
2237
+ const first = (lines[index + 1] ?? '').replace(/\s+$/, '');
2238
+ const firstText = first.trim();
2239
+ if (firstText === '')
2240
+ return null;
2241
+ if (first.length - firstText.length < indent + HANGING_BODY_MIN_OFFSET)
2242
+ return null;
2243
+ // 説明は文で始まる。`T1 f1;` のような並びを落とす。
2244
+ if (!/^[A-Z]/.test(firstText))
2245
+ return null;
2246
+ // 空行までを説明とする。
2247
+ const body = [];
2248
+ let cursor = index + 1;
2249
+ while (cursor < lines.length && lines[cursor].trim() !== '') {
2250
+ body.push(lines[cursor].trim());
2251
+ if (body.join(' ').length > HANGING_BODY_MAX_LENGTH)
2252
+ break;
2253
+ cursor++;
2254
+ }
2255
+ const definition = clipAtWord(body.join(' '), HANGING_BODY_MAX_LENGTH);
2256
+ if (definition.length < DEFINITION_EXTRACTION.MIN_DEFINITION_LENGTH)
2257
+ return null;
2258
+ return { definition: { term, definition }, lastLine: cursor - 1 };
2259
+ }
2260
+ /** `TAG:n` 形の参考文献の目印。`ARCH:9` `FORWARD:10`。 */
2261
+ const REFERENCE_TAG_ANCHOR = /^[A-Z][A-Z0-9]*:\d+\.?$/;
2262
+ /** ぶら下げの用語欄の字下げと長さ。 */
2263
+ const HANGING_TERM_MIN_INDENT = 2;
2264
+ const HANGING_TERM_MAX_INDENT = 8;
2265
+ const HANGING_TERM_MAX_LENGTH = 60;
2266
+ const HANGING_TERM_MAX_WORDS = 6;
2267
+ /** 説明は用語より、これだけ深く字下げされている。 */
2268
+ const HANGING_BODY_MIN_OFFSET = 2;
2269
+ /** 説明の文字数の上限。 */
2270
+ const HANGING_BODY_MAX_LENGTH = 500;
2271
+ /**
2272
+ * 用語として認めるか。
2273
+ *
2274
+ * テキスト経路の定義は「行の中の `X: Y`」でしか見分けられない。同じ形は
2275
+ * 用語以外にも出るので、用語の側で絞る。
2276
+ */
2277
+ function isDefinitionTerm(term) {
2278
+ // IANA 登録票の項目(`o Type name: application`、実測 58 件)。
2279
+ if (/^o\s/.test(term))
2280
+ return false;
2281
+ // 用語は大文字・数字・引用符で始まる。小文字で始まる行は、折り返した文の
2282
+ // 途中である("is the overall name for…"、"are Private Names")。
2283
+ if (!/^[A-Z0-9"']/.test(term))
2284
+ return false;
2285
+ // 用語は短い。長いものは文である("There are three classes of JWT Claim Names")。
2286
+ if (term.split(/\s+/).length > DEFINITION_EXTRACTION.MAX_TERM_WORDS)
2287
+ return false;
2288
+ // 文の書き出し・関係節を含むものは用語ではない
2289
+ // ("The protocol has two parts: a handshake and…")。
2290
+ if (SENTENCE_OPENER.test(term))
2291
+ return false;
2292
+ if (RELATIVE_CLAUSE.test(term))
2293
+ return false;
2294
+ return true;
789
2295
  }
790
2296
  /**
791
2297
  * テキストから要件を抽出