red_quilt 0.6.1

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 (50) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +109 -0
  4. data/.rubocop_todo.yml +7 -0
  5. data/CHANGELOG.md +57 -0
  6. data/README.md +284 -0
  7. data/Rakefile +8 -0
  8. data/ast-spec.md +1227 -0
  9. data/docs/architecture.md +81 -0
  10. data/docs/arena-usage.md +363 -0
  11. data/docs/commonmark-conformance.md +241 -0
  12. data/exe/redquilt +7 -0
  13. data/lib/red_quilt/arena.rb +366 -0
  14. data/lib/red_quilt/block_parser.rb +724 -0
  15. data/lib/red_quilt/blockquote.rb +151 -0
  16. data/lib/red_quilt/cli.rb +182 -0
  17. data/lib/red_quilt/diagnostic.rb +47 -0
  18. data/lib/red_quilt/document.rb +126 -0
  19. data/lib/red_quilt/extended_autolink_pass.rb +185 -0
  20. data/lib/red_quilt/footnote_definition.rb +147 -0
  21. data/lib/red_quilt/footnote_pass.rb +39 -0
  22. data/lib/red_quilt/footnote_registry.rb +68 -0
  23. data/lib/red_quilt/indentation.rb +73 -0
  24. data/lib/red_quilt/inline/builder.rb +674 -0
  25. data/lib/red_quilt/inline/flanking.rb +120 -0
  26. data/lib/red_quilt/inline/html_entities.rb +2180 -0
  27. data/lib/red_quilt/inline/lexer.rb +280 -0
  28. data/lib/red_quilt/inline/link_scanner.rb +315 -0
  29. data/lib/red_quilt/inline/token_kind.rb +39 -0
  30. data/lib/red_quilt/inline/tokens.rb +73 -0
  31. data/lib/red_quilt/inline.rb +34 -0
  32. data/lib/red_quilt/inline_pass.rb +53 -0
  33. data/lib/red_quilt/line.rb +14 -0
  34. data/lib/red_quilt/lint_pass.rb +71 -0
  35. data/lib/red_quilt/list.rb +317 -0
  36. data/lib/red_quilt/node_ref.rb +114 -0
  37. data/lib/red_quilt/node_type.rb +66 -0
  38. data/lib/red_quilt/plain_text.rb +46 -0
  39. data/lib/red_quilt/reference_definition.rb +309 -0
  40. data/lib/red_quilt/renderer/html.rb +279 -0
  41. data/lib/red_quilt/renderer/mdast.rb +152 -0
  42. data/lib/red_quilt/source_map.rb +29 -0
  43. data/lib/red_quilt/source_span.rb +26 -0
  44. data/lib/red_quilt/theme.rb +28 -0
  45. data/lib/red_quilt/themes/default.css +87 -0
  46. data/lib/red_quilt/version.rb +5 -0
  47. data/lib/red_quilt.rb +86 -0
  48. data/mise.toml +2 -0
  49. data/sig/red_quilt.rbs +45 -0
  50. metadata +91 -0
@@ -0,0 +1,81 @@
1
+ # RedQuilt Architecture Overview
2
+
3
+ RedQuiltの構成を概観する。
4
+
5
+ ## パイプライン
6
+
7
+ ```
8
+ Source (Markdown String)
9
+
10
+ ▼RedQuilt.normalize_input (lib/red_quilt.rb)
11
+
12
+ ▼BlockParser (lib/red_quilt/block_parser.rb)
13
+ │dispatch / container parsers / build_lines
14
+ │ (list.rb, blockquote.rb, reference_definition.rb)
15
+
16
+ ▼Arena (raw inline spans)
17
+ │paragraph / heading / table cellの本文はbyte spanのみで保持
18
+
19
+ ▼InlinePass (lib/red_quilt/inline_pass.rb)
20
+ │ ├─Inline::Lexer (lib/red_quilt/inline/lexer.rb)
21
+ │ │byte scan→Tokens (parallel array)
22
+ │ └─Inline::Builder (lib/red_quilt/inline/builder.rb)
23
+ │linear pass→process_emphasis (CommonMark§6.2)
24
+
25
+ ▼Arena (inline解決済み)
26
+
27
+ ▼ (option) FootnotePass (footnotes: true)
28
+ ▼ (option) ExtendedAutolinkPass (extended_autolinks: true)
29
+ ▼ (option) LintPass (lint: true)
30
+
31
+ ▼Renderer::HTML (lib/red_quilt/renderer/html.rb)
32
+ arenaをwalkしてmutable Stringにappend
33
+ ```
34
+
35
+ ## 各ステージの責務
36
+
37
+ ### `RedQuilt.normalize_input`
38
+ CommonMark§2.3/2.4の最小前処理。`\r\n`/`\r`→`\n`の行末正規化と、NUL→U+FFFDの置換だけを行う。
39
+
40
+ ### BlockParser
41
+ - 行分割: sourceを`Line` Struct配列へ。各行はbyte spanで保持する。
42
+ - dispatch: 行頭バイトでblock kindを判定(`paragraph_only_line?`が非ブロック行を早期に振り分け)。
43
+ - container委譲: list / blockquoteは`List::Parser` / `Blockquote::Parser`へ委譲し、内部で`parse_lines`を再帰。
44
+ - 定義の収集と除外: link reference定義(参照テーブル)と、opt-inのfootnote定義(`FootnoteRegistry`)を本文フローから抜き、専用collectorへ集約。
45
+ - 桁計算: タブ展開を含むインデント計算は`Indentation`に委譲。
46
+ - 出力: inline未解決のblockノードをArenaに構築する。
47
+
48
+ ### InlinePass / Lexer / Builder
49
+ - 対象選定: paragraph / heading / table cellの各inline targetを走査して処理。
50
+ - Lexer: targetのbyte spanをスキャンしTokens(parallel array)へ。
51
+ - Builder①linear pass: code span / link / image / autolink / 簡易inlineを解決。
52
+ - Builder②process_emphasis: delimiter stackを畳んでemphasis / strongを確定(CommonMark§6.2)。
53
+ - footnote参照: `[^label]`を`FootnoteRegistry`で解決し、初回参照順に採番して`FOOTNOTE_REFERENCE`を生成。
54
+
55
+ ### FootnotePass (`footnotes: true`)
56
+ - 並べ替え: `FOOTNOTES_SECTION`(root末尾)配下の定義を初回参照順へ。
57
+ - 刈り取り: 未参照の定義をdetach。
58
+ - section削除: 参照ゼロならsection自体を除去。
59
+
60
+ ### Renderer::HTML
61
+ - walk: arenaを再帰walkし、`+""`で開いたmutable Stringへ直接`<<`。
62
+ - raw HTML: `allow_html`で素通し / エスケープを切替、`disallow_raw_html`でGFM Disallowed Raw HTMLをfilter。
63
+ - footnote: `FOOTNOTE_REFERENCE`をsupリンクに、末尾`FOOTNOTES_SECTION`を`<section class="footnotes">`+backrefとして出力。
64
+
65
+ ## 主なサブシステム位置
66
+
67
+ | 領域 | ファイル |
68
+ |---|---|
69
+ | エントリ / 入力正規化 | `lib/red_quilt.rb` |
70
+ | 公開API | `lib/red_quilt/document.rb`、`node_ref.rb` |
71
+ | Arena | `lib/red_quilt/arena.rb` |
72
+ | Block解析 | `block_parser.rb`、`list.rb`、`blockquote.rb`、`indentation.rb` |
73
+ | Reference definition | `reference_definition.rb` |
74
+ | Footnotes (opt-in) | `footnote_definition.rb`、`footnote_registry.rb`、`footnote_pass.rb` |
75
+ | Inline解析 | `inline.rb`、`inline/lexer.rb`、`inline/tokens.rb`、`inline/flanking.rb`、`inline/builder.rb`、`inline/link_scanner.rb` |
76
+ | Inlineエンティティ | `inline/html_entities.rb` |
77
+ | HTML / MDAST出力 | `renderer/html.rb`、`renderer/mdast.rb` |
78
+ | 拡張パス | `inline_pass.rb`、`footnote_pass.rb`、`extended_autolink_pass.rb`、`lint_pass.rb` |
79
+ | Source位置 | `source_span.rb`、`source_map.rb` |
80
+ | Diagnostics | `diagnostic.rb` |
81
+ | CLI | `cli.rb`、`exe/redquilt` |
@@ -0,0 +1,363 @@
1
+ # Arenaクラスの使い方
2
+
3
+ `RedQuilt::Arena`はRedQuiltのAST本体を保持する低レベルストレージクラスです。
4
+ 本稿はArenaを直接触る人(block parser/ inline builder / renderer /カスタムtransformerなど、`lib/red_quilt`配下のコードを使う人)向けに、そのAPIと前提条件を整理したものです。
5
+
6
+ >外部APIとしてASTを扱うだけなら`RedQuilt::Document`と`RedQuilt::NodeRef`を経由するのが標準です。Arenaは内部寄りのレイヤーで、`NodeRef`の実装が依存しているデータ構造そのものです。
7
+
8
+ ---
9
+
10
+ ## 0. 簡単な使い方
11
+
12
+ 下のコードはそのままコピー&ペーストすれば動きます。
13
+ Arenaが「source文字列をベースに各種IDでツリーを組み立てる」ものだという雰囲気が伝わるかと思います。
14
+
15
+ ```ruby
16
+ require "red_quilt"
17
+
18
+ source = "Hello *world*"
19
+ arena = RedQuilt::Arena.new(source)
20
+
21
+ # (a)ノードを作る。戻り値はnode id (Integer)
22
+ para_id = arena.add_node(RedQuilt::NodeType::PARAGRAPH,
23
+ source_start: 0, source_len: source.bytesize)
24
+ text_id = arena.add_node(RedQuilt::NodeType::TEXT,
25
+ source_start: 0, source_len: 6) # "Hello "
26
+ em_id = arena.add_node(RedQuilt::NodeType::EMPHASIS,
27
+ source_start: 6, source_len: 7) # "*world*"
28
+ inner_id = arena.add_node(RedQuilt::NodeType::TEXT,
29
+ source_start: 7, source_len: 5) # "world"
30
+
31
+ # (b)親子関係を組む
32
+ arena.append_child(para_id, text_id)
33
+ arena.append_child(para_id, em_id)
34
+ arena.append_child(em_id, inner_id)
35
+
36
+ # (c)内容を取り出す
37
+ puts "type: #{arena.type_name(para_id)}"
38
+ puts "text: #{arena.text(text_id).inspect}"
39
+ puts "inner: #{arena.text(inner_id).inspect}"
40
+ puts "span: #{arena.source_span(em_id).inspect}"
41
+
42
+ # (d)子を走査する(ブロック形式・Enumeratorなし)
43
+ puts "children of paragraph:"
44
+ arena.each_child(para_id) do |child_id|
45
+ puts " #{arena.type_name(child_id)}: #{arena.text(child_id).inspect}"
46
+ end
47
+ ```
48
+
49
+ 出力結果は以下のようになります。
50
+
51
+ ```
52
+ type: paragraph
53
+ text: "Hello "
54
+ inner: "world"
55
+ span: #<RedQuilt::SourceSpan:0x... @start_byte=6, @end_byte=13>
56
+ children of paragraph:
57
+ text: "Hello "
58
+ emphasis: "*world*"
59
+ ```
60
+
61
+ このサンプルが作るASTは次のようになります。
62
+
63
+ ```
64
+ PARAGRAPH [0, 13) "Hello *world*"
65
+ ├─TEXT [0, 6) "Hello "
66
+ └─EMPHASIS [6, 13) "*world*"
67
+ └─TEXT [7, 12) "world"
68
+ ```
69
+
70
+ Arenaの扱いのポイントは以下になります。
71
+
72
+ - `add_node`はNode ID(Integer)を返す。以降のAPIは全部このIDをキーにする。
73
+ - `source_start` / `source_len`は元のsource文字列に対し、文字単位ではなくバイト単位で範囲を指定する。文字列そのもののコピーは持たない。
74
+ - `text(id)`はstr1があればそれを返し、なければ`source`をbytesliceする。
75
+ - `each_child(id)`を基本の走査APIとしてホットパスで使用する。
76
+
77
+ これらを頭に入れておくと、後の章は「実際にこのAPIは何を保証しているのか/どう使うべきか」として読めるはずです。
78
+
79
+ ---
80
+
81
+ ## 1.設計の要点
82
+
83
+ ArenaはASTを「オブジェクトのツリー」ではなく[parallel array](https://en.wikipedia.org/wiki/Parallel_array)として表現します。
84
+
85
+ -ノードは整数ID(`node_id`)で識別されます
86
+ -各IDに対応する属性(parent / source span / payload)はそれぞれ異なるArrayに列として保持します
87
+ -ノードの追加は各Arrayの末尾への代入操作だけで完結し、新しいRubyオブジェクトは一切生成しません
88
+
89
+ 結果として、Arenaは以下のような性質を持ちます。
90
+
91
+ -ホットパスではIDというIntegerだけを取り回せる
92
+ -メモリ局所性が良く、GC圧が小さい
93
+ -ノードを「軽い」値として扱えるのでRenderer / Builderをinline化しやすい
94
+
95
+ ###列(column)一覧
96
+
97
+ |列名|用途|
98
+ |------|------|
99
+ | `@type` | NodeType (Integer定数) |
100
+ | `@parent` / `@first_child` / `@last_child` / `@next_sibling` / `@prev_sibling` |親・子・兄弟リンク。値はnode id (`NO_NODE`で「なし」) |
101
+ | `@source_start` / `@source_len` | document source内のバイト範囲。`source_start < 0`は「spanなし、内容はstr1に持つ」を意味する|
102
+ | `@int1` / `@int2` / `@int3` | NodeTypeごとに用途が決まる整数スロット(default `0`) |
103
+ | `@str1` / `@str2` | NodeTypeごとに用途が決まる文字列スロット(default `nil`) |
104
+
105
+ ---
106
+
107
+ ## 2.不変条件
108
+
109
+ Arenaを扱う上で常に成り立つ前提です。
110
+
111
+ 1. Node IDは単調増加する
112
+ `add_node`で払い出されるIDは`@type.length`から始まり、追加するたびに1ずつ増えます。IDが再利用されることはありません。
113
+ 2. detachされたノードも列に残る
114
+ `detach`は親・兄弟リンクを`NO_NODE`にリセットするだけで、列のレコード自体はarena内に残り続けます。後続の`add_node`がそのスロットを再利用することもありません。これはallocationを単純化するための意図的な選択です。
115
+ 3. `@source`はimmutableとして扱う
116
+ Arena構築後にsourceを書き換えてはいけません。`source_start` / `source_len`は直接バイト範囲を指しているため、sourceが変わると`text` / `source_span`の戻り値が壊れます。
117
+ 4. `NO_NODE` = -1
118
+ 親や兄弟が存在しないことを示すsentinelです。`Arena::NO_NODE`定数で参照できます。
119
+ 5. `source_start < 0`は「spanなし」
120
+ この場合、ノードの内容は`@str1`にliteralとして持たれていることが期待されます(例: blockquoteを解除したparagraph、entityデコード後のTEXT)。
121
+
122
+ ---
123
+
124
+ ## 3. APIのレイヤー
125
+
126
+ Arenaの公開メソッドは以下の3レイヤーに分けて読むと意図が掴みやすくなります。
127
+
128
+ ### 3.1構造の操作(mutators)
129
+
130
+ ツリーを組み立て・編集するためのAPIです。`validなid`を渡す前提で、安全性チェックは最小限です。
131
+
132
+ |メソッド|概要|
133
+ |----------|------|
134
+ | `add_node(type, **fields)` |新規ノードを末尾に追加。IDを返す。初期状態はdetached |
135
+ | `append_child(parent_id, child_id)` |親の子リスト末尾に追加|
136
+ | `insert_before(parent_id, ref_id, new_id)` | `ref_id`の直前に挿入|
137
+ | `detach(child_id)` |親から切り離す。ノード自体は残る|
138
+ | `reparent(new_parent_id, first_id, last_id)` | `first_id..last_id`の兄弟範囲を新しい親へ移動|
139
+ | `update_span(id, start_byte, end_byte)` | source spanを再設定|
140
+ | `update_str1(id, value)` / `update_int3(id, value)` |個別slotの書き換え|
141
+
142
+ ### 3.2構造の参照(raw id accessors)
143
+
144
+ `NO_NODE`を返しうる、生のcolumn値を取り出します。命名規則`raw_X_id`は「戻り値がnode idで、-1 (`NO_NODE`)になる可能性がある」ことを示します。
145
+
146
+ |メソッド|戻り値|
147
+ |----------|--------|
148
+ | `raw_parent_id(id)` |親idか`NO_NODE` |
149
+ | `raw_first_child_id(id)` / `raw_last_child_id(id)` |子idか`NO_NODE` |
150
+ | `raw_next_sibling_id(id)` / `raw_prev_sibling_id(id)` |兄弟idか`NO_NODE` |
151
+
152
+ ### 3.3ペイロードの参照(column accessors)
153
+
154
+ 各columnを生のまま返します。「sentinelが返り得る」ことは戻り値の型から読み取ってください。
155
+
156
+ |メソッド|戻り値|
157
+ |----------|--------|
158
+ | `type(id)` | NodeType定数(Integer) |
159
+ | `type_name(id)` | Symbol (例: `:paragraph`) |
160
+ | `source_start(id)` / `source_len(id)` | byte offset / byte長。`source_start < 0`はspanなし|
161
+ | `int1(id)` / `int2(id)` / `int3(id)` | Integer (default 0) |
162
+ | `str1(id)` / `str2(id)` | String or `nil` |
163
+
164
+ ### 3.4セマンティックaccessor
165
+
166
+ 低レベル列を解釈して「使いやすい値」を返します。`nil`を返しうるのは明示的に「無い」ことを表現するためです。
167
+
168
+ |メソッド|戻り値|
169
+ |----------|--------|
170
+ | `source_span(id)` | `SourceSpan`か`nil` (spanなしの場合) |
171
+ | `text(id)` | `str1`があればそれ、なければ`source.byteslice(...)`。どちらもなければ`nil` |
172
+
173
+ ### 3.5走査
174
+
175
+ |メソッド|用途|
176
+ |----------|------|
177
+ | `each_child(id) { |child_id| ... }` |ブロック形式。ホットパス推奨(Enumerator不要) |
178
+ | `child_ids(id)` | `Enumerator`を返す。`map` / `select`などのチェイン用|
179
+
180
+ ---
181
+
182
+ ## 4. NodeTypeごとのslot用法
183
+
184
+ 各NodeTypeがどのint / strスロットを使うかは規約で決まっています。以下が現在の規約です。
185
+
186
+ ### Blockノード
187
+
188
+ | NodeType | int1 | int2 | int3 | str1 | str2 |
189
+ |----------|------|------|------|------|------|
190
+ | `DOCUMENT` | - | - | - | - | - |
191
+ | `PARAGRAPH` | - | - | - | (transformed時のみ)結合済みliteral | - |
192
+ | `HEADING` | level (1-6) | - | - | (transformed時のみ) inline literal | - |
193
+ | `THEMATIC_BREAK` | - | - | - | - | - |
194
+ | `BLOCKQUOTE` | - | - | - | - | - |
195
+ | `LIST` | ordered? (0/1) | start_number | tight? (1=tight) | marker (`-`/`*`/`+`/`.`/`)`) | - |
196
+ | `LIST_ITEM` | - | - | - | - | - |
197
+ | `CODE_BLOCK` | - | - | - | code内容(literal) | info string (fencedのみ) |
198
+ | `HTML_BLOCK` | - | - | - | HTML内容(literal) | - |
199
+ | `TABLE` | - | - | - | - | - |
200
+ | `TABLE_ROW` | header? (1/0) | - | - | - | - |
201
+ | `TABLE_CELL` | header? (1/0) | - | - | strippedセルtext | - |
202
+ | `FOOTNOTE_DEFINITION` | - | - | - | 正規化済みlabel | - |
203
+ | `FOOTNOTES_SECTION` | - | - | - | - | - |
204
+
205
+ ### Inlineノード
206
+
207
+ | NodeType | int1 | int2 | int3 | str1 | str2 |
208
+ |----------|------|------|------|------|------|
209
+ | `TEXT` | - | - | - | literal (entity decode後など)または`nil` (spanベース) | - |
210
+ | `SOFTBREAK` / `HARDBREAK` | - | - | - | `"\n"` | - |
211
+ | `EMPHASIS` / `STRONG` / `STRIKETHROUGH` | - | - | - | - | - |
212
+ | `CODE_SPAN` | - | - | - | normalized content (literal) | - |
213
+ | `LINK` | - | - | - | sanitized destination | title (or `nil`) |
214
+ | `IMAGE` | - | - | - | sanitized destination | title (or `nil`) |
215
+ | `HTML_INLINE` | - | - | - | matched HTML literal | - |
216
+ | `FOOTNOTE_REFERENCE` | footnote番号 | 出現回数(同一labelのN個目) | - | 正規化済みlabel | - |
217
+
218
+ > `-`は「使わない」を意味します(default値`0` / `nil`のまま)。
219
+
220
+ > footnoteは`footnotes: true`時のみ生成されます。`FOOTNOTES_SECTION`はroot直下の最後の子として置かれ(span-less、`source_start: -1`)、参照された`FOOTNOTE_DEFINITION`を初回参照順に保持します。backrefの個数はfootnote番号とlabelからrender時に算出します。
221
+
222
+ ### Source spanの慣習
223
+
224
+ - `source_start` / `source_len`: 元documentのbytes (絶対byte offset)
225
+ - `source_start < 0`: spanなし。内容は`str1`にliteralとして持たれる(transformed paragraphやLexerのliteralモードで生じる)
226
+ - blockノードは「自分のtext範囲」をspanとして持つ(`#`や`>`のprefixを除いた、内側のみ)
227
+
228
+ ---
229
+
230
+ ## 5.典型的な使い方
231
+
232
+ ### 5.1 Arenaを作って小さなASTを組み立てる
233
+
234
+ ```ruby
235
+ source = "Hello *world*"
236
+ arena = RedQuilt::Arena.new(source)
237
+
238
+ doc_id = arena.add_node(RedQuilt::NodeType::DOCUMENT,
239
+ source_start: 0, source_len: source.bytesize)
240
+
241
+ para_id = arena.add_node(RedQuilt::NodeType::PARAGRAPH,
242
+ source_start: 0, source_len: source.bytesize)
243
+ arena.append_child(doc_id, para_id)
244
+
245
+ text_id = arena.add_node(RedQuilt::NodeType::TEXT,
246
+ source_start: 0, source_len: 6) # "Hello "
247
+ arena.append_child(para_id, text_id)
248
+
249
+ em_id = arena.add_node(RedQuilt::NodeType::EMPHASIS,
250
+ source_start: 6, source_len: 7) # "*world*"
251
+ arena.append_child(para_id, em_id)
252
+
253
+ inner_id = arena.add_node(RedQuilt::NodeType::TEXT,
254
+ source_start: 7, source_len: 5) # "world"
255
+ arena.append_child(em_id, inner_id)
256
+
257
+ arena.text(text_id) # => "Hello "
258
+ arena.text(inner_id) # => "world"
259
+ arena.source_span(em_id) # => #<SourceSpan @start_byte=6 @end_byte=13>
260
+ ```
261
+
262
+ ### 5.2兄弟をループする(ホットパス)
263
+
264
+ ```ruby
265
+ arena.each_child(para_id) do |child_id|
266
+ case arena.type(child_id)
267
+ when RedQuilt::NodeType::TEXT
268
+ output << arena.text(child_id)
269
+ when RedQuilt::NodeType::EMPHASIS
270
+ output << "<em>"
271
+ render_children(child_id)
272
+ output << "</em>"
273
+ end
274
+ end
275
+ ```
276
+
277
+ `Enumerator`チェインしたい場合(NodeRefなど)は以下のようにします。
278
+
279
+ ```ruby
280
+ arena.child_ids(para_id).map { |id| arena.type_name(id) }
281
+ # => [:text, :emphasis]
282
+ ```
283
+
284
+ ### 5.3ノードを別の親に移動する
285
+
286
+ ```ruby
287
+ # `em_id`の子を全部`para_id`直下に移す
288
+ first = arena.raw_first_child_id(em_id)
289
+ last = arena.raw_last_child_id(em_id)
290
+ arena.reparent(para_id, first, last) if first != RedQuilt::Arena::NO_NODE
291
+
292
+ # em_idを空のまま切り離す
293
+ arena.detach(em_id)
294
+ ```
295
+
296
+ ### 5.4ノードを差し替える
297
+
298
+ ```ruby
299
+ # em_idをstrong_idに置換(中身はそのまま)
300
+ strong_id = arena.add_node(RedQuilt::NodeType::STRONG,
301
+ source_start: arena.source_start(em_id),
302
+ source_len: arena.source_len(em_id))
303
+ arena.insert_before(arena.raw_parent_id(em_id), em_id, strong_id)
304
+
305
+ first = arena.raw_first_child_id(em_id)
306
+ last = arena.raw_last_child_id(em_id)
307
+ arena.reparent(strong_id, first, last) if first != RedQuilt::Arena::NO_NODE
308
+
309
+ arena.detach(em_id)
310
+ ```
311
+
312
+ ### 5.5列の値を直接更新する
313
+
314
+ ```ruby
315
+ # headingのレベルはint1に入っているが、書き換え専用setterは無いので
316
+ #必要なら追加する。現在はstr1 / int3 / spanのみpublic setterあり:
317
+ arena.update_str1(text_id, "Hello, world!")
318
+ arena.update_int3(list_id, 1) # tightに
319
+ arena.update_span(text_id, 0, 12)
320
+ ```
321
+
322
+ なおint1 / int2 / str2のsetterは現状ありません。必要が出た時点で`update_int1`などを追加する想定です。
323
+
324
+ ---
325
+
326
+ ## 6.パフォーマンス上の注意
327
+
328
+ ####ホットパスでは`each_child`を使う
329
+
330
+ ブロック直yieldでEnumerator allocationを避ける。`child_ids`は外部API用
331
+
332
+ #### `text(id)`はstr1を優先する
333
+
334
+ 余計な`byteslice`を起こさないため、可能な限り`str1`にliteralを持たせない(`nil`のまま)のが基本
335
+
336
+ #### `source_span(id)`は`SourceSpan`を毎回allocateする
337
+
338
+ ホットパスで使うなら`source_start` / `source_len`を直接読む方が良い
339
+
340
+ #### `detach`したnodeは捨て切れない
341
+
342
+ 大量にdetachする処理を繰り返すとarenaの列が膨らみ続ける。1つのdocumentのparse内では問題ない規模だが、長寿命のarenaには向かない
343
+
344
+ ---
345
+
346
+ ## 7.落とし穴
347
+
348
+ #### `raw_*_id`の戻り値を生でforeign key参照する場合
349
+
350
+ `NO_NODE` (-1)チェックを忘れない。Array#[-1]にすると配列末尾を読んでしまい、treeが壊れる
351
+
352
+ #### `reparent`の前提
353
+
354
+ `first_id`から`next_sibling`を辿って`last_id`に到達する必要がある。違う親のノードや、`first_id`の後ろにある到達不能な`last_id`を渡すと無限ループする可能性がある(実際builderで過去にハマった)
355
+
356
+ #### `source_start < 0`の意味
357
+
358
+ 「位置情報を捨てたliteralモード」。ユーザーAPI (`SourceMap`, `node.source_location`等)はspanなし扱いになる。これを忘れてdebuggerで「位置情報がない」と困惑しないこと
359
+
360
+ #### `@source`を後から変えない
361
+
362
+ 仮にやると`text` / `source_span`の戻り値が静かに壊れる
363
+
@@ -0,0 +1,241 @@
1
+ # RedQuilt CommonMark Conformance
2
+
3
+ ## 1. 本書の位置付け
4
+
5
+ 本稿はRedQuiltとCommonMark / GFM specとの差分について解説する。
6
+ spec通りに動く挙動は仕様書(<https://spec.commonmark.org/0.31.2/>、<https://github.github.com/gfm/>)を直接参照する想定で、本書では繰り返さない。
7
+
8
+ #### 書く対象
9
+
10
+ - specが許す範囲を実装が 狭めた 箇所(曖昧な箇所の解釈・厳格化)
11
+ - specの範囲外の機能(セキュリティ・診断・オプションフラグ)
12
+ - 有効化している 拡張(GFM等)とそのオプトイン条件
13
+ - 未対応・既知の制限
14
+
15
+ #### 書かない対象
16
+
17
+ - specと一致する標準挙動の説明
18
+ - 設計の経緯やデータ構造の選択
19
+
20
+ ### 1.1 対象バージョン
21
+
22
+ - CommonMark: 0.31.2
23
+ - GitHub Flavored Markdown: 0.29-gfm
24
+
25
+ ### 1.2 実装上の前提
26
+
27
+ - 入力はUTF-8文字列。`force_encoding(Encoding::UTF_8)` 等の前処理は呼び出し側の責任。
28
+ - spec§2.3 / §2.4が要求する正規化(NUL→U+FFFD、`\r\n` / `\r` → `\n`、blank lineのspace/tab限定)はいずれも実装している。
29
+ これはspec通りなので本書では個別項目化しない。
30
+
31
+ ### 1.3 各項目のフォーマット
32
+
33
+ ```
34
+ ### N.N <タイトル>
35
+
36
+ **Spec**: 該当節とspecの規定(または曖昧さ)
37
+ **RedQuiltの挙動**: 実装がどう振る舞うか / どこを狭めたか・拡張したか
38
+ **実装**: ファイル:行 / 主要シンボル
39
+ **テスト**: specファイル / example番号
40
+ ```
41
+
42
+ ## 2. specを厳格化している点
43
+
44
+ specの文言が複数解釈を許す箇所、あるいは "must" が曖昧なまま放置されている箇所で、実装はより厳しい側を選んでいる。
45
+
46
+ ### 2.1 URI autolinkがU+007F (DEL) を拒否
47
+
48
+ **Spec**: §6.5—URI autolinkは "ASCII control characters, space, `<`, `>`" を含まない。
49
+ "ASCII control characters" の範囲がU+0000–U+001FのみかU+007Fも含むかは明示されていない。
50
+
51
+ **RedQuiltの挙動**: U+007Fを含めて拒否。
52
+
53
+ **実装**: `lib/red_quilt/inline/lexer.rb` — `URI_AUTOLINK_RE`
54
+ **テスト**: `spec/whitespace_strictness_spec.rb` — "URI autolink (CommonMark 6.5)"
55
+
56
+ ### 2.2 Raw HTML tagのseparatorをspace/tab/CR/LFに限定
57
+
58
+ **Spec**: §6.6— 属性間や `=` 周りのseparatorを "whitespace" と定義。
59
+ "whitespace" の集合はspecの用語定義(§2.1)ではspace / tab / newline / line tabulation (U+000B) / form feed (U+000C) / carriage returnを含む広い集合。
60
+
61
+ **RedQuiltの挙動**: tag grammarの中では `[ \t\r\n]` のみをseparatorとして許可。
62
+ FF (U+000C) / VT (U+000B) は含めない。インラインのraw HTML、HTML blockのtype 1 / 6 / 7すべてに同じ制約を適用。
63
+
64
+ **実装**:
65
+ - インライン: `lib/red_quilt/inline/lexer.rb` — `HTML_OPEN_TAG_RE` / `HTML_CLOSING_TAG_RE`
66
+ - ブロック: `lib/red_quilt/block_parser.rb` — `HTML_TYPE_7_OPEN_TAG_RE` /
67
+ `HTML_TYPE_7_CLOSING_TAG_RE` / `HTML_BLOCK_TYPE_6_RE` / type 1 regex
68
+
69
+ **テスト**: `spec/whitespace_strictness_spec.rb` — "raw HTML tag whitespace (CommonMark 6.6)"
70
+
71
+ ### 2.3 Inline link tail separatorをspace/tab/最大1 LFに限定
72
+
73
+ **Spec**: §6.3—linkのtail (`(dest "title")` 内部)は "spaces, tabs, and up to one line ending" で区切る。FF / VTへの言及は無い。
74
+
75
+ **RedQuiltの挙動**: link tail内ではspace / tabのみをseparatorとし、line endingは別途1回までカウント。
76
+ FF / VTが現れた場合はlinkとしては成立しない(段落の通常テキストとして扱う)。
77
+
78
+ **実装**: `lib/red_quilt/inline/builder.rb` — `link_tail_whitespace_byte?`、
79
+ `skip_link_whitespace`、`parse_link_tail`
80
+
81
+ **テスト**: `spec/whitespace_strictness_spec.rb` — "inline link tail whitespace (CommonMark 6.3)"
82
+
83
+ ### 2.4 Reference definitionのraw destination検証をinline linkと同等に
84
+
85
+ **Spec**: §6.3—link destinationのraw形式は "a nonempty sequence of
86
+ characters that does not start with `<`, does not include ASCII control
87
+ characters or space character, and includes parentheses only if (a) they are
88
+ backslash-escaped or (b) they are part of a balanced pair of unescaped
89
+ parentheses".
90
+
91
+ **RedQuiltの挙動**: reference definitionのraw destinationでも上記すべてを検証する。
92
+ 具体的にはASCII control (U+0000–U+001F) / U+007F (DEL) / spaceを拒否、unescaped parenのdepthを追跡しアンバランスなら定義無効。
93
+
94
+ **過去の挙動**: 単純な `/\A(\S+)(.*)\z/` で受けていたため、`[x]: foo(bar` や`[x]: foobar` でもdefinitionとして成立してしまっていた。
95
+
96
+ **実装**: `lib/red_quilt/reference_definition.rb` — `parse_raw_destination`、`RAW_DEST_FORBIDDEN_RE`
97
+
98
+ **テスト**: `spec/link_validation_spec.rb` — "reference definition raw destination validation"
99
+
100
+ ### 2.5 Link labelの999文字上限を全経路で適用
101
+
102
+ **Spec**: §6.3— "A link label can have at most 999 characters inside the square brackets."
103
+
104
+ **RedQuiltの挙動**: reference definition側とreference link使用側(shortcut / collapsed / fullすべて)で999文字超を拒否。
105
+
106
+ **実装**:
107
+ - 定数: `lib/red_quilt/reference_definition.rb` — `LABEL_MAX_LENGTH = 999`、`label_too_long?` ヘルパ
108
+ - 定義側: `match_label`(単一行・複数行のどちらでも判定)
109
+ - 使用側: `lib/red_quilt/inline/builder.rb` — `try_reference_link` / `read_reference_label`
110
+
111
+ **テスト**: `spec/link_validation_spec.rb` — "link label length limit (999 characters)"
112
+
113
+ ### 2.6 NCRの桁上限と無効codepointのU+FFFD置換
114
+
115
+ **Spec**: §6.4—decimal NCRは1–7桁、hex NCRは1–6桁。
116
+ decode結果がU+0000、surrogate (U+D800–U+DFFF)、またはUnicode範囲外 (>U+10FFFF) になる場合はU+FFFDに置き換える。
117
+
118
+ **RedQuiltの挙動**: 上記すべてを実装。
119
+
120
+ **過去の挙動**: `CGI.unescapeHTML` に委譲していたため、`&#00000065;` のような8桁decimalや `&#xD800;` のようなsurrogateがそれぞれ "A" としてdecodeされたり、`RangeError` を発生させていた。
121
+
122
+ **実装**: `lib/red_quilt/inline/html_entities.rb` — `Inline.decode_entity`、
123
+ `Inline::ENTITY_RE`、`decode_numeric_codepoint`。`SURROGATE_RANGE`、
124
+ `MAX_UNICODE_CODEPOINT` 定数。
125
+
126
+ **テスト**: `spec/numeric_character_reference_spec.rb`
127
+
128
+ ### 2.7 GFM tableのheader / delimiter列数一致要件
129
+
130
+ **Spec (GFM§4.10)**: "The header row must match the delimiter row in the number of cells. If not, a table will not be recognized."
131
+
132
+ **RedQuiltの挙動**: headerとdelimiterでcell数が一致しない場合、tableとして認識せずparagraphとして扱う。
133
+
134
+ **実装**: `lib/red_quilt/block_parser.rb` — `table_start?`
135
+
136
+ **テスト**: `spec/red_quilt_spec.rb` — "table separator validation (GFM spec)"
137
+
138
+ ### 2.8 GFM extended autolinkのdomain underscore制約
139
+
140
+ **Spec (GFM§6.9)**: "If the domain name contains an underscore (`_`) in its last two segments, it is invalid."
141
+
142
+ **RedQuiltの挙動**: extended autolinkを有効化した場合、URL / emailのdomain末尾2セグメントに `_` を含むものはlinkifyしない。
143
+
144
+ **実装**: `lib/red_quilt/extended_autolink_pass.rb` — `valid_domain?` / `extract_domain`
145
+
146
+ **テスト**: `spec/extended_autolink_spec.rb` — "domain validation (GFM spec)"
147
+
148
+ ## 3. specの範囲外の機能
149
+
150
+ specには規定が無いが、RedQuilt側で安全性・利便性のために提供している機能。
151
+
152
+ ### 3.1 unsafe URL schemeのサニタイズ
153
+
154
+ **RedQuiltの挙動**: link / imageのdestinationのschemeが以下の安全リストに含まれない場合、`href` を空文字列にして出力する。
155
+ 同時に `:unsafe_url` のdiagnosticをwarningとして発行する。
156
+
157
+ **安全スキーム**: `http`、`https`、`mailto`、`ftp`、`tel`、`ssh`
158
+
159
+ **ブロックされる代表例**: `javascript:`、`vbscript:`、`data:`
160
+
161
+ **実装**: `lib/red_quilt/inline/builder.rb` — `SAFE_SCHEMES`、sanitize_destination`
162
+
163
+ **テスト**: `spec/red_quilt_spec.rb` — "sanitizes unsafe URL schemes"
164
+
165
+ ### 3.2 Diagnostics
166
+
167
+ **RedQuiltの挙動**: parse / render中に検出した不審な構文・参照漏れ・潜在的なセキュリティ事象を `RedQuilt::Diagnostic` として `Document#diagnostics` に
168
+ 蓄積する。
169
+ 処理は中断しない(常にtreeとHTMLが返る)。
170
+
171
+ **現状で発行されるrule**:
172
+
173
+ | Rule | Severity | 内容 |
174
+ |---|---|---|
175
+ | `:missing_reference` | warning | full reference link `[text][ref]` の定義が無い |
176
+ | `:duplicate_reference` | warning | 同じlabelのreference definitionが複数あった(最初の定義を採用) |
177
+ | `:unsafe_url` | warning | safe schemeリストに無いURLを空hrefに置換した |
178
+
179
+ **実装**: `lib/red_quilt/diagnostic.rb`(値オブジェクト)、`lib/red_quilt/block_parser.rb`(duplicate)、`lib/red_quilt/inline/builder.rb`(missing / unsafe)
180
+
181
+ ### 3.3 `allow_html` / `disallow_raw_html` フラグ
182
+
183
+ **RedQuiltの挙動**:
184
+
185
+ | Flag | Default | 効果 |
186
+ |---|---|---|
187
+ | `allow_html` | `false` | 偽の場合、raw HTMLは全エスケープ(`&lt;` 化)で出力。真にするとHTML block / inline raw HTMLが原文のまま出力される |
188
+ | `disallow_raw_html` | `false` | `allow_html: true` 下で有効化するGFM "Disallowed Raw HTML" 拡張。指定タグ群の `<` を `&lt;` に書き換える |
189
+
190
+ GFMが定めるdisallowedタグ群: `title`, `textarea`, `style`, `xmp`, `iframe`, `noembed`, `noframes`, `script`, `plaintext`
191
+
192
+ **実装**: `lib/red_quilt/document.rb` — `allow_html?` / `disallow_raw_html?`
193
+ **実装(filter)**: `lib/red_quilt/renderer/html.rb` — `DISALLOWED_RAW_TAGS` /
194
+ `DISALLOWED_RAW_TAG_RE` / `filter_disallowed_raw`
195
+
196
+ ## 4. 有効化している拡張
197
+
198
+ ### 4.1 GFM Table
199
+
200
+ 常時有効。specの仕様に加えて2.7のcolumn count一致要件を適用。
201
+
202
+ **実装**: `lib/red_quilt/block_parser.rb` — `table_start?` / `parse_table`
203
+
204
+ ### 4.2 GFM Strikethrough
205
+
206
+ 常時有効。`~~text~~` の2連tildeのみ対応(GFMの挙動と一致)。単一tilde`~text~` は通常テキストとして扱う。
207
+
208
+ **実装**: `lib/red_quilt/inline/builder.rb`(`scan_delim_run` 内の `~` 取扱)、`lib/red_quilt/inline/lexer.rb`(`SPECIAL_BYTES` に `0x7E`)
209
+
210
+ ### 4.3 GFM Disallowed Raw HTML
211
+
212
+ オプトイン。`allow_html: true, disallow_raw_html: true` を併用したときのみ動作する(`allow_html: false` 下では全HTMLがエスケープされるため意味がない)。
213
+ 詳細は3.3参照。
214
+
215
+ ### 4.4 GFM Extended Autolink
216
+
217
+ オプトイン。`extended_autolinks: true` を指定すると `ExtendedAutolinkPass` がパスとして走り、`<...>` で囲まれていない裸のURL / email / `www.` 始まり等をリンク化する。
218
+
219
+ **追加制約**: 2.8のdomain underscoreチェックを実装。
220
+
221
+ **実装**: `lib/red_quilt/extended_autolink_pass.rb`
222
+
223
+ ## 5. 未対応 / 既知の制限
224
+
225
+ - GFM Task List Items (`- [ ]` / `- [x]`) は未対応。通常のlist itemとしてパースされる。
226
+
227
+ ## 6. テストとの対応関係
228
+
229
+ 差分項目を検証するspecファイルを集約する。
230
+
231
+ | 観点 | specファイル |
232
+ |---|---|
233
+ | 公式CommonMark exampleの通過 | `spec/commonmark_compat_spec.rb` |
234
+ | 入力正規化(行終端 / NUL / blank line) | `spec/input_normalization_spec.rb` |
235
+ | Whitespace厳格化(autolink / raw HTML / link tail) | `spec/whitespace_strictness_spec.rb` |
236
+ | Link / reference検証(label cap / raw dest) | `spec/link_validation_spec.rb` |
237
+ | NCR桁上限と無効codepoint | `spec/numeric_character_reference_spec.rb` |
238
+ | GFM tableのcolumn count一致 | `spec/red_quilt_spec.rb` — "table separator validation" |
239
+ | GFM extended autolinkのdomain検証 | `spec/extended_autolink_spec.rb` |
240
+ | URL schemeサニタイズ | `spec/red_quilt_spec.rb` — "sanitizes unsafe URL schemes" |
241
+ | Disallowed Raw HTML | `spec/red_quilt_spec.rb` —disallow_raw_html系 |