From eaa11e202ab897c2e792b9afc9f0ba0047d5b37d Mon Sep 17 00:00:00 2001 From: Kazuhiro NISHIYAMA Date: Wed, 15 Jul 2026 13:46:57 +0900 Subject: [PATCH] =?UTF-8?q?Markdown=20=E3=81=AE=E8=A8=98=E8=BF=B0=E3=83=AA?= =?UTF-8?q?=E3=82=B9=E3=83=88=E5=86=85=E3=82=B3=E3=83=BC=E3=83=89=E3=83=95?= =?UTF-8?q?=E3=82=A7=E3=83=B3=E3=82=B9=E3=82=92=20CommonMark=20=E6=BA=96?= =?UTF-8?q?=E6=8B=A0=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit これまで MDCompiler は、記述リストの説明(dd)の直後にカラム0で 書かれたコードフェンスも dd に取り込んで描画していた。これは RD の //emlist が dd に取り込まれる挙動を踏襲したものだが、CommonMark には 無い挙動で、GitHub などのプレビューではフェンスがリストの外に出る。 説明(dd)の一部として取り込むのは「項目の内容カラムにインデントされた フェンス」だけにし、カラム0のフェンスは dd の外(トップレベル)として 扱うよう改める。 - mdcompiler.rb: dd_with_p / dd_without_p からカラム0フェンスの取り込みを 削除(インデントフェンスのみ INDENTED_FENCE_RE で取り込む)。 - rrd_to_markdown.rb: RD→MD 変換で、dd や @param 等の説明の中にある //emlist・#@samplecode を、項目の内容カラム(2スペース)にインデント したフェンスとして出力する(これまでカラム0で出力していたのが、 doctree のソースにカラム0フェンスが残っていた原因)。前処理ディレクティブ (#@since 等)は Preprocessor の要件でカラム0のまま残す。 - markdown_to_rrd.rb: 逆変換で、インデントされた dd/@param 内フェンスを カラム0の //emlist{ / #@samplecode に戻す(ラウンドトリップ維持)。 doctree 側で該当フェンスをすべてインデント済みのため、この変更後も 生成される HTML は変わらない(doctree の manual/ 全 1257 ファイル× サポート全バージョンで、変更前後の描画がバイト単位一致することを確認済み)。 全テスト(17619)green・steep 型エラーなし。 Co-Authored-By: Claude Fable 5 --- doc/markdown-samples/MARKUP_SPEC.md | 3 ++ lib/bitclust/markdown_to_rrd.rb | 55 ++++++++++++++++++++++- lib/bitclust/mdcompiler.rb | 13 +++--- lib/bitclust/rrd_to_markdown.rb | 68 +++++++++++++++++++++-------- sig/bitclust/markdown_to_rrd.rbs | 6 +++ sig/bitclust/rrd_to_markdown.rbs | 15 +++++-- test/test_rrd_to_markdown.rb | 6 ++- 7 files changed, 133 insertions(+), 33 deletions(-) diff --git a/doc/markdown-samples/MARKUP_SPEC.md b/doc/markdown-samples/MARKUP_SPEC.md index e07eb21c..32f6aa01 100644 --- a/doc/markdown-samples/MARKUP_SPEC.md +++ b/doc/markdown-samples/MARKUP_SPEC.md @@ -731,6 +731,9 @@ code line 2 リスト項目や定義リスト(§10.2)の説明の中にコードブロックを置く場合は、 GFM と同じくフェンスを項目のインデントに合わせて書く。 内容と閉じフェンスからはフェンス行のインデント幅までが除去される。 +CommonMark と同じく、項目の説明に取り込まれるのはインデントされた +フェンスだけで、カラム0(インデントなし)のフェンスは項目の外の +トップレベルのコードブロックとして扱われる。 ````markdown - **`Dir.glob`**: diff --git a/lib/bitclust/markdown_to_rrd.rb b/lib/bitclust/markdown_to_rrd.rb index 60196d3a..3b4ce059 100644 --- a/lib/bitclust/markdown_to_rrd.rb +++ b/lib/bitclust/markdown_to_rrd.rb @@ -365,6 +365,53 @@ def convert_code_block(line) end end + # dd/@param 系の説明内にある、インデントされたフェンス(rd では + # //emlist・#@samplecode の桁0出力に対応。CommonMark 準拠のため + # rrd_to_markdown.rb が項目の内容カラムへインデントして出すように + # なった分の桁0版)を、桁0の //emlist{ / #@samplecode に戻す。 + # convert_code_block の 3 バッククォート(lang 付き)分岐と対称。 + # 4+ バッククォート(インデントコード由来)のインデント版は + # rrd_to_markdown.rb が dd/@param からその形式を出さないため未対応 + def convert_indented_code_block(line, indent) + line =~ /\A[ \t]+(`{3,})(\w*)(?:\s+title="((?:[^"\\]|\\.)*)")?\s*$/ or raise + fence_len = ($1 || raise).length + lang = ($2 || raise).empty? ? nil : $2 + title = $3&.gsub(/\\(["\\])/, '\1') + strip_re = /\A[ \t]{1,#{indent.length}}/ + + if lang == 'ruby' + samplecode = '#@samplecode' + samplecode_end = '#@end' + @out << (title && !title.empty? ? "#{samplecode} #{title}\n" : "#{samplecode}\n") + else + parts = ['//emlist'] + if title && !title.empty? + parts << "[#{title}]" + parts << "[#{lang}]" if lang + elsif lang + parts << '[]' + parts << "[#{lang}]" + end + parts << "{" + @out << parts.join + "\n" + end + advance + terminator = /\A[ \t]{0,#{indent.length}}`{#{fence_len}}\s*\n?\z/ + while @index < @lines.length + l = current_line + if l =~ terminator + @out << (lang == 'ruby' ? "#{samplecode_end}\n" : "//}\n") + advance + return + elsif l =~ /\A\#@/ + @out << l # ディレクティブはカラム0のまま(rrd_to_markdown.rb 側と対称) + else + @out << l.sub(strip_re, '') + end + advance + end + end + def convert_method_signature(line) @out << line.sub(/\A### def /, '--- ') advance @@ -419,7 +466,7 @@ def collect_md_continuation_lines(greedy: false) # rd 側と対称: nest 0 の #@end も透過(fileutils の @param 継続) nest -= 1 if nest > 0 raw_passthrough(line) - elsif line =~ /\A\s+\S/ && line !~ /\A- / && line !~ /\A\*\*/ && line !~ /\A```/ + elsif line =~ /\A\s+\S/ && line !~ /\A- / && line !~ /\A\*\*/ && line !~ /\A[ \t]*`{3,}/ @out << convert_inline_refs(line) advance elsif greedy && line =~ /\A[ \t]+$/ @@ -427,6 +474,8 @@ def collect_md_continuation_lines(greedy: false) advance elsif greedy && line =~ /\A`{3,}/ convert_code_block(line) # rd 側と対称: 直結するコードブロックは説明の一部 + elsif greedy && line =~ /\A([ \t]+)`{3,}/ + convert_indented_code_block(line, $1 || raise) # 上と同じくインデント版 else break end @@ -515,11 +564,13 @@ def convert_dlist_colon_item(line) # 交互に何個でも説明として受ける(String#% 型) while @index < @lines.length l = current_line - if l =~ /\A\s+\S/ + if l =~ /\A\s+\S/ && l !~ /\A[ \t]+`{3,}/ @out << convert_inline_refs(l) advance elsif l =~ /\A`{3,}/ convert_code_block(l) + elsif l =~ /\A([ \t]+)`{3,}/ + convert_indented_code_block(l, $1 || raise) # 上と同じくインデント版 elsif l =~ /\A\#@/ # rd 側と対称: #@ 指令行は dd の文脈に透明 raw_passthrough(l) diff --git a/lib/bitclust/mdcompiler.rb b/lib/bitclust/mdcompiler.rb index 4cec2012..4fa2b63e 100644 --- a/lib/bitclust/mdcompiler.rb +++ b/lib/bitclust/mdcompiler.rb @@ -502,10 +502,13 @@ def item_list(level = 0) end # RDCompiler の dd_with_p / dd_without_p の md 版: - # コードブロック(rd では //emlist)の判定をフェンスに差し替える + # コードブロック(rd では //emlist)の判定をインデントフェンスに差し替える。 + # RD の //emlist は桁0で書いても dd に取り込まれたが、Markdown では + # CommonMark に合わせて「項目の内容カラムにインデントされたフェンス」だけを + # 説明(dd)の一部として取り込む。桁0のフェンスは dd の外(トップレベル)。 def dd_with_p line '
' - while /\A(?:\s|\z)/ =~ @f.peek or FENCE_RE =~ @f.peek + while /\A(?:\s|\z)/ =~ @f.peek case @f.peek when /\A$/ @f.gets @@ -515,8 +518,6 @@ def dd_with_p line '

' line compile_text(text_node_from_lines(@f.span(DD_TEXT_RE))) line '

' - when FENCE_RE - code_fence else raise 'must not happen' end @@ -526,14 +527,12 @@ def dd_with_p def dd_without_p line '
' - while /\A[ \t]/ =~ @f.peek or FENCE_RE =~ @f.peek + while /\A[ \t]/ =~ @f.peek case @f.peek when INDENTED_FENCE_RE indented_code_fence when /\A[ \t]/ line compile_text(text_node_from_lines(@f.span(DD_TEXT_RE))) - when FENCE_RE - code_fence end end line '
' diff --git a/lib/bitclust/rrd_to_markdown.rb b/lib/bitclust/rrd_to_markdown.rb index 8f0af9ec..7f15228c 100644 --- a/lib/bitclust/rrd_to_markdown.rb +++ b/lib/bitclust/rrd_to_markdown.rb @@ -356,7 +356,28 @@ def process_body end end - def convert_emlist(line) + # dd(記述リストの説明)/ @param 等の説明の中でフェンスを出すときの + # 内容カラムのインデント幅。項目マーカー「- 」の幅に合わせる + DD_CONTENT_INDENT = ' ' + + # フェンス内容行にインデントを前置する。ただし: + # - 空行には前置しない(末尾空白を増やさないため) + # - 前処理ディレクティブ行(#@since/#@until/#@if/#@else/#@end 等)には + # 前置しない。Preprocessor はこれらを行頭カラム0でしか認識しない + # (bitclust/lib/bitclust/preprocessor.rb の /\A\#@since\b/ 等)ため、 + # インデントすると版解決が効かなくなる + def indent_content_line(line, indent) + return line if indent.empty? + return line if line =~ /\A[ \t]*\n?\z/ + return line if line =~ /\A\#@/ + indent + line + end + + # indent: 呼び出し文脈の内容カラム(トップレベルは既定の ''、 + # dd/@param 系の説明内から呼ぶ場合は DD_CONTENT_INDENT)。 + # CommonMark でリスト項目/dd の説明に確実にネストさせるための字下げで、 + # 新しい MDCompiler の INDENTED_FENCE_RE がこれを見て dd/li に取り込む + def convert_emlist(line, indent = '') # //emlist[caption][lang]{...//} caption = nil lang = nil @@ -375,16 +396,16 @@ def convert_emlist(line) escaped_caption = caption.gsub('\\', '\\\\\\\\').gsub('"', '\\"') parts << " title=\"#{escaped_caption}\"" end - @out << parts.join + "\n" + @out << indent + parts.join + "\n" advance while @index < @lines.length line = current_line if line =~ /\A\/\/\}\s*$/ - @out << "```\n" + @out << indent + "```\n" advance return end - @out << line + @out << indent_content_line(line, indent) advance end end @@ -392,13 +413,13 @@ def convert_emlist(line) DIRECTIVE_NEST_RE = /\A\#@(?:since|until|if)\b/ DIRECTIVE_END_RE = /\A\#@end\s*$/ - def convert_samplecode(line) + def convert_samplecode(line, indent = '') label = line.sub(SAMPLECODE_RE, '').strip if label.empty? - @out << "```ruby\n" + @out << indent + "```ruby\n" else escaped_label = label.gsub('\\', '\\\\\\\\').gsub('"', '\\"') - @out << "```ruby title=\"#{escaped_label}\"\n" + @out << indent + "```ruby title=\"#{escaped_label}\"\n" end advance nest = 0 @@ -406,20 +427,20 @@ def convert_samplecode(line) line = current_line if line =~ DIRECTIVE_NEST_RE nest += 1 - @out << line + @out << line # ディレクティブは常にカラム0(Preprocessor 要件) advance elsif line =~ DIRECTIVE_END_RE if nest > 0 nest -= 1 - @out << line + @out << line # 同上 advance else - @out << "```\n" + @out << indent + "```\n" advance return end else - @out << line + @out << indent_content_line(line, indent) advance end end @@ -514,9 +535,11 @@ def collect_continuation_lines(greedy: false) @out << line advance elsif greedy && line =~ SAMPLECODE_RE - convert_samplecode(line) # RDCompiler の dd はコードブロックも説明の一部 + # RDCompiler の dd はコードブロックも説明の一部。新 MDCompiler が + # dd に取り込めるよう、内容カラムにインデントしたフェンスで出す + convert_samplecode(line, DD_CONTENT_INDENT) elsif greedy && line =~ /\A\/\/emlist/ - convert_emlist(line) + convert_emlist(line, DD_CONTENT_INDENT) else break end @@ -627,9 +650,11 @@ def convert_dlist_item(line, term) @out << convert_inline_refs(l) advance elsif l =~ SAMPLECODE_RE - convert_samplecode(l) + # 項目の内容カラムにインデントしたフェンスにする(CommonMark で + # dd にネストさせ、新 MDCompiler の INDENTED_FENCE_RE に取り込ませる) + convert_samplecode(l, DD_CONTENT_INDENT) elsif l =~ /\A\/\/emlist/ - convert_emlist(l) + convert_emlist(l, DD_CONTENT_INDENT) elsif l =~ /\A\#@/ # #@ 指令行は dd の文脈に透明(版解決後は消えて説明が連続する。 # ここで打ち切ると後続インデント行がコード扱いになる。pack-template) @@ -656,7 +681,12 @@ def convert_dlist_item(line, term) end end - def convert_indented_code(line) + # indent: 呼び出し文脈の内容カラム(他のフェンス出力メソッドと揃えるため + # 用意。現状このメソッドはトップレベル(process_body)からしか呼ばれず、 + # RDCompiler の list(インデントのみのコード相当)は dd/li には取り込まれ + # ないため、呼び出し側は常に既定の '' のまま。将来 dd/li 文脈から呼ぶ + # 必要が生じた場合のための口として引数だけ揃えておく + def convert_indented_code(line, indent = '') # 行を収集してベースインデントを取得 code_lines = [] #: Array[String] while @index < @lines.length @@ -687,15 +717,15 @@ def convert_indented_code(line) .min || 1 fence = '`' * (3 + base_indent) - @out << "#{fence}\n" + @out << indent + "#{fence}\n" code_lines.each do |l| if l =~ /\A\s*$/ @out << l else - @out << (l[base_indent..] || raise) # ベースインデントを除去 + @out << indent + (l[base_indent..] || raise) # ベースインデントを除去 end end - @out << "#{fence}\n" + @out << indent + "#{fence}\n" end def convert_h1(line) diff --git a/sig/bitclust/markdown_to_rrd.rbs b/sig/bitclust/markdown_to_rrd.rbs index b86dc0d6..709e769e 100644 --- a/sig/bitclust/markdown_to_rrd.rbs +++ b/sig/bitclust/markdown_to_rrd.rbs @@ -61,6 +61,12 @@ module BitClust def convert_code_block: (String line) -> void + # dd/@param 系の説明内にある、インデントされたフェンス(rd では + # //emlist・#@samplecode の桁0出力に対応)を桁0の //emlist{ / + # #@samplecode に戻す。convert_code_block の 3 バッククォート + # (lang 付き)分岐と対称 + def convert_indented_code_block: (String line, String indent) -> void + def convert_method_signature: (String line) -> void def convert_module_function_signature: (String line) -> void diff --git a/sig/bitclust/rrd_to_markdown.rbs b/sig/bitclust/rrd_to_markdown.rbs index e9d4f4d4..e8677b60 100644 --- a/sig/bitclust/rrd_to_markdown.rbs +++ b/sig/bitclust/rrd_to_markdown.rbs @@ -64,13 +64,22 @@ module BitClust def process_body: () -> void - def convert_emlist: (String line) -> void + # dd(記述リストの説明)/ @param 等の説明の中でフェンスを出すときの + # 内容カラムのインデント幅。項目マーカー「- 」の幅に合わせる + DD_CONTENT_INDENT: String + + # フェンス内容行にインデントを前置する(空行・前処理ディレクティブ行は除く) + def indent_content_line: (String line, String indent) -> String + + # indent: 呼び出し文脈の内容カラム(トップレベルは既定の ''、 + # dd/@param 系の説明内から呼ぶ場合は DD_CONTENT_INDENT) + def convert_emlist: (String line, ?String indent) -> void DIRECTIVE_NEST_RE: ::Regexp DIRECTIVE_END_RE: ::Regexp - def convert_samplecode: (String line) -> void + def convert_samplecode: (String line, ?String indent) -> void def convert_signature: (String line) -> void @@ -106,7 +115,7 @@ module BitClust # term は process_body の正規表現キャプチャ($1 = String?)由来 def convert_dlist_item: (String line, untyped term) -> void - def convert_indented_code: (String line) -> void + def convert_indented_code: (String line, ?String indent) -> void def convert_h1: (String line) -> void diff --git a/test/test_rrd_to_markdown.rb b/test/test_rrd_to_markdown.rb index fa2352e6..05f2f1c5 100644 --- a/test/test_rrd_to_markdown.rb +++ b/test/test_rrd_to_markdown.rb @@ -116,10 +116,12 @@ def test_dlist_description_across_directives def test_dlist_description_across_samplecode # pack-template 型: dd 内の #@samplecode 後の字下げ行も dd 段落継続。 # 空行スキャンが #@samplecode を透過するとコード本体の行(カラム0)を見て - # dd を打ち切り、後続段落がトップレベル pre と誤分類される + # dd を打ち切り、後続段落がトップレベル pre と誤分類される。 + # フェンスは CommonMark で dd にネストするよう内容カラム(2桁)へインデント + # される(新 MDCompiler の INDENTED_FENCE_RE が取り込む対象) rrd = ": w\n\n 説明。\n\n\#@samplecode\n[0].pack(\"w\")\n\#@end\n\n なお、後続の説明。\n" md = convert(rrd) - assert_equal "- **`w`**:\n\n 説明。\n\n```ruby\n[0].pack(\"w\")\n```\n\n なお、後続の説明。\n", md + assert_equal "- **`w`**:\n\n 説明。\n\n ```ruby\n [0].pack(\"w\")\n ```\n\n なお、後続の説明。\n", md assert_equal rrd, BitClust::MarkdownToRRD.convert(md) end