diff --git a/doc/markdown-samples/MARKUP_SPEC.md b/doc/markdown-samples/MARKUP_SPEC.md index 364bc18..e07eb21 100644 --- a/doc/markdown-samples/MARKUP_SPEC.md +++ b/doc/markdown-samples/MARKUP_SPEC.md @@ -726,7 +726,26 @@ code line 2 | `//emlist[ラベル][lang]{` ... `//}` | ```` ```lang title="ラベル" ```` ... ```` ``` ```` | | インデントテキスト(Nスペース) | `(3+N)` 個のバッククォートで囲む | -### 9.5 インラインコード +### 9.5 リスト内のコードブロック(インデントフェンス) + +リスト項目や定義リスト(§10.2)の説明の中にコードブロックを置く場合は、 +GFM と同じくフェンスを項目のインデントに合わせて書く。 +内容と閉じフェンスからはフェンス行のインデント幅までが除去される。 + +````markdown +- **`Dir.glob`**: + + 説明文。 + + ```ruby + p Dir.glob(["f*","b*"]) # => ["foo", "bar"] + ``` +```` + +§9.3 のインデント幅エンコード(行頭の 4 個以上のバッククォート)とは +行頭に空白があるかどうかで区別される。 + +### 9.6 インラインコード テキスト行中の `__WORD__` パターン(`__END__`, `__FILE__` 等)は 自動的にコードスパンに変換される。ブラケットリンク内の `__WORD__` は変換しない。 diff --git a/lib/bitclust/mdcompiler.rb b/lib/bitclust/mdcompiler.rb index 55c2a28..4cec201 100644 --- a/lib/bitclust/mdcompiler.rb +++ b/lib/bitclust/mdcompiler.rb @@ -45,6 +45,10 @@ def signature_re MD_ITEM_RE = /\A(\s*)(?:- |\d+\. )/ FENCE_RE = /\A`{3,}/ + # インデントされたフェンス(リスト項目・dlist 説明内のコードブロック) + INDENTED_FENCE_RE = /\A([ \t]+)(`{3,})/ + # dd 段落の継続行(インデント行。ただしインデントフェンスの手前で止まる) + DD_TEXT_RE = /\A[ \t](?![ \t]*`{3})/ DLIST_RE = /\A- \*\*(.+?)\*\*:(?:\s|$)/ INFO_RE = /\A- \*\*(?:param|arg|return|raise)\*\*/ SEE_RE = /\A- \*\*SEE\*\*/ @@ -76,6 +80,8 @@ def library_file raise "@item_stack should be empty. #{@item_stack.inspect}" unless @item_stack.empty? when FENCE_RE code_fence + when INDENTED_FENCE_RE + indented_code_fence when EMLIST_LEFTOVER_RE # リスト脈絡などで生のまま残った #@samplecode は前処理で # //emlist になる。RDCompiler と同じ独立ブロックとして描画する @@ -135,6 +141,8 @@ def entry_chunk raise "@item_stack should be empty. #{@item_stack.inspect}" unless @item_stack.empty? when FENCE_RE code_fence + when INDENTED_FENCE_RE + indented_code_fence when EMLIST_LEFTOVER_RE emlist when /\A@todo\b/ @@ -184,16 +192,26 @@ def read_entry_paragraph(f) def paragraph line '

' - line compile_text(text_node_from_lines(read_paragraph(@f).map { |l| unescape_hash(l) })) + line compile_text(text_node_from_lines(consume_paragraph_lines { read_paragraph(@f) })) line '

' end def entry_paragraph line '

' - line compile_text(text_node_from_lines(read_entry_paragraph(@f).map { |l| unescape_hash(l) })) + line compile_text(text_node_from_lines(consume_paragraph_lines { read_entry_paragraph(@f) })) line '

' end + # 段落行の読み取り。リストと空行で切り離された残余のインデント行は + # どのブロック処理にも該当しないため、ここで段落として消費する + # (読み取りが空のままだとディスパッチが進まなくなる) + def consume_paragraph_lines + lines = yield.map { |l| unescape_hash(l) } + lines = @f.span(DD_TEXT_RE) if lines.empty? + lines = [@f.gets || raise] if lines.empty? # 最低1行は必ず進める + lines + end + def unescape_hash(line) line.sub(/\A\\#/, '#') end @@ -338,25 +356,10 @@ def split_table_row(row) def code_fence open_line = @f.gets or raise fence = open_line[/\A`+/] or raise - rest = open_line[fence.size..].to_s.strip - lang = nil - caption = nil - if rest =~ /\A(\w+)?(?:\s+title="((?:[^"\\]|\\.)*)")?\z/ - lang = $1 - caption = $2&.gsub(/\\(["\\])/, '\1') - end + lang, caption = parse_fence_info(open_line[fence.size..].to_s.strip) terminator = /\A`{#{fence.size}}\s*$/ if lang - # caption はタブとして pre の前に置く(rd 側と同期) - line "#{escape_html(caption)}" if caption - line "
"
-        line ""
-        src = +""
-        @f.until_terminator(terminator) do |code_line|
-          src << code_line
-        end
-        string highlight_source(src, lang, caption)
-        line '
' + highlighted_fence_body(lang, caption, terminator) elsif fence.size == 3 line '
'
         @f.until_terminator(terminator) do |code_line|
@@ -409,6 +412,51 @@ def collect_fence_lines(terminator)
       lines
     end
 
+    # info string(lang と title="cap")のパース
+    def parse_fence_info(rest)
+      if rest =~ /\A(\w+)?(?:\s+title="((?:[^"\\]|\\.)*)")?\z/
+        [$1, $2&.gsub(/\\(["\\])/, '\1')]
+      else
+        [nil, nil]
+      end
+    end
+
+    # ハイライト付き 
 の本体。caption はタブとして pre の前に置く
+    # (rd 側と同期)。strip_re はインデントフェンスのデデント用
+    def highlighted_fence_body(lang, caption, terminator, strip_re = nil)
+      line "#{escape_html(caption)}" if caption
+      line "
"
+      line ""
+      src = +""
+      @f.until_terminator(terminator) do |code_line|
+        src << (strip_re ? code_line.sub(strip_re, '') : code_line)
+      end
+      string highlight_source(src, lang, caption)
+      line '
' + end + + # リスト項目・dlist 説明の中のインデントされたフェンス(GFM のリスト内 + # コードブロック)。GFM と同じく、内容と閉じフェンスからフェンス行の + # インデント幅までを除去する + def indented_code_fence + open_line = @f.gets or raise + INDENTED_FENCE_RE =~ open_line or raise + indent = ($1 || raise) + fence = ($2 || raise) + lang, caption = parse_fence_info(open_line[(indent.size + fence.size)..].to_s.strip) + terminator = /\A[ \t]{0,#{indent.size}}`{#{fence.size}}\s*$/ + strip_re = /\A[ \t]{1,#{indent.size}}/ + if lang + highlighted_fence_body(lang, caption, terminator, strip_re) + else + line '
'
+        @f.until_terminator(terminator) do |code_line|
+          line escape_html(code_line.sub(strip_re, '').rstrip)
+        end
+        line '
' + end + end + # 箇条書き(- item / N. item、ネスト・継続行あり) def item_list(level = 0) open_tag = nil @@ -427,10 +475,16 @@ def item_list(level = 0) string "
  • " @item_stack.push("
  • ") string compile_text(item_line.sub(/\A\s*(?:-|\d+\.)\s?/, '').strip) - if /\A(\s+)(?!- |\d+\. )\S/ =~ @f.peek - @f.while_match(/\A\s+(?!- |\d+\. )\S/) do |cont| + # 継続行(折り返しテキスト)とインデントフェンス(項目内コードブロック) + while @f.peek + if INDENTED_FENCE_RE =~ @f.peek + nl + indented_code_fence + elsif /\A\s+(?!- |\d+\. )\S/ =~ @f.peek nl - string compile_text(cont.strip) + string compile_text((@f.gets || raise).strip) + else + break end end if (m = MD_ITEM_RE.match(@f.peek)) && level < (m[1] || raise).size @@ -455,9 +509,11 @@ def dd_with_p case @f.peek when /\A$/ @f.gets + when INDENTED_FENCE_RE + indented_code_fence when /\A[ \t]/ line '

    ' - line compile_text(text_node_from_lines(@f.span(/\A[ \t]/))) + line compile_text(text_node_from_lines(@f.span(DD_TEXT_RE))) line '

    ' when FENCE_RE code_fence @@ -472,8 +528,10 @@ def dd_without_p line '
    ' while /\A[ \t]/ =~ @f.peek or FENCE_RE =~ @f.peek case @f.peek + when INDENTED_FENCE_RE + indented_code_fence when /\A[ \t]/ - line compile_text(text_node_from_lines(@f.span(/\A[ \t]/))) + line compile_text(text_node_from_lines(@f.span(DD_TEXT_RE))) when FENCE_RE code_fence end @@ -491,21 +549,105 @@ def compile_text(str) # GFM モードのテキストコンパイル: # コードスパン `x` → (中身は参照解決しない・HTML エスケープのみ)、 - # 行頭 **N.** → 。エスケープ済み \` はリテラルのバッククォート + # 行頭 **N.** → 、Markdown リンク( 自動リンク・ + # [テキスト](URL)・[テキスト](#アンカー))→ 。 + # エスケープ済み \` はリテラルのバッククォート def compile_gfm_text(str) hidden = str.gsub(/\\`/, "\x00") hidden.split(/(`[^`\n]+`)/, -1).map { |seg| if seg =~ /\A`([^`\n]+)`\z/ "#{escape_html(($1 || raise).gsub("\x00", '`'))}" else + links = [] #: Array[String] + seg = extract_md_links(seg, links) seg = seg.gsub(/^\*\*(\d+\.)\*\* /, "\x01\\1\x02 ") rd_compile_text(MarkdownToRRD.restore_inline(seg)) .gsub("\x01", '').gsub("\x02", '') .gsub("\x00", '`') + .gsub(/\x03(\d+)\x03/) { links[($1 || raise).to_i] || raise } end }.join end + AUTOLINK_RE = %r{<(https?://[^<>\s]+)>} + # インラインリンクの宛先として受ける形。散文の「[c:String](を参照)」の + # ような括弧書きをリンクと誤認しないため URL と #フラグメントに限る + # (MARKUP_SPEC §7.4/§7.5) + MD_LINK_DEST_RE = %r{\A(?:https?://|\#)} + + # Markdown のリンクを描画済み へ退避し \x03idx\x03 プレースホルダに + # 置き換える。後段の rd_compile_text(HTML エスケープ・参照解決)を + # 素通しし、compile_gfm_text の最後で戻す + def extract_md_links(str, saved) + str = str.gsub(AUTOLINK_RE) { + saved << direct_url($1 || raise) + "\x03#{saved.size - 1}\x03" + } + return str unless str.include?('[') + result = +'' + i = 0 + while i < str.length + if str[i] == '\\' && i + 1 < str.length + result << (str[i, 2] || raise) + i += 2 + next + end + if str[i] == '[' + close = matching_delimiter(str, i, '[', ']') + if close && str[close + 1] == '(' && + (dest_end = matching_delimiter(str, close + 1, '(', ')', space_ends: true)) && + (dest = str[(close + 2)...dest_end]) && MD_LINK_DEST_RE =~ dest + saved << md_link(str[(i + 1)...close] || raise, dest) + result << "\x03#{saved.size - 1}\x03" + i = dest_end + 1 + next + end + end + result << (str[i] || raise) + i += 1 + end + result + end + + # open 位置の括弧に対応する閉じ括弧の位置(\ エスケープ対応・ネスト可)。 + # space_ends: リンク宛先用。空白が現れたらリンクではない(nil) + def matching_delimiter(str, open, open_char, close_char, space_ends: false) + depth = 0 + i = open + while i < str.length + c = str[i] + if c == '\\' + i += 1 + elsif space_ends && c =~ /\s/ + return nil + elsif c == open_char + depth += 1 + elsif c == close_char + depth -= 1 + return i if depth == 0 + end + i += 1 + end + nil + end + + # 表示テキスト付きリンクの描画。テキストは参照解決しないプレーン表示 + # (リンク内リンクは HTML として成立しないため)。外部 URL は rd の + # [[url:]] と同じ external クラス、#アンカーはページ内リンク + def md_link(text, dest) + label = escape_html(unescape_md_brackets(text).gsub("\x00", '`')) + href = escape_html(unescape_md_brackets(dest)) + if dest.start_with?('#') + %Q(#{label}) + else + %Q(#{label}) + end + end + + def unescape_md_brackets(str) + str.gsub(/\\([\[\]\\])/, '\1') + end + def restore_rd_text(str) # M1 等価モード: md のコードスパンを rd の元表記へ戻して描画する MarkdownToRRD.restore_text(str) diff --git a/sig/bitclust/mdcompiler.rbs b/sig/bitclust/mdcompiler.rbs index 7ecad17..b127630 100644 --- a/sig/bitclust/mdcompiler.rbs +++ b/sig/bitclust/mdcompiler.rbs @@ -24,6 +24,12 @@ module BitClust FENCE_RE: ::Regexp + # インデントされたフェンス(リスト項目・dlist 説明内のコードブロック) + INDENTED_FENCE_RE: ::Regexp + + # dd 段落の継続行(インデントフェンスの手前で止まる) + DD_TEXT_RE: ::Regexp + DLIST_RE: ::Regexp INFO_RE: ::Regexp @@ -53,6 +59,9 @@ module BitClust def entry_paragraph: () -> void + # 段落行の読み取り(残余のインデント行も段落として消費する) + def consume_paragraph_lines: () { () -> Array[String] } -> Array[String] + def unescape_hash: (String line) -> String # - **param** `name` -- desc / - **return** -- desc / - **raise** `Ex` -- desc @@ -88,6 +97,15 @@ module BitClust def collect_fence_lines: (Regexp terminator) -> Array[String] + # info string(lang と title="cap")のパース + def parse_fence_info: (String rest) -> [String?, String?] + + # ハイライト付き
     の本体
    +    def highlighted_fence_body: (String lang, String? caption, Regexp terminator, ?Regexp? strip_re) -> void
    +
    +    # リスト項目・dlist 説明の中のインデントされたフェンス
    +    def indented_code_fence: () -> void
    +
         # 箇条書き(- item / N. item、ネスト・継続行あり)
         def item_list: (?::Integer level) -> void
     
    @@ -103,6 +121,22 @@ module BitClust
         # GFM モードのテキストコンパイル
         def compile_gfm_text: (String str) -> String
     
    +    AUTOLINK_RE: ::Regexp
    +
    +    # インラインリンクの宛先として受ける形(URL と #フラグメントのみ)
    +    MD_LINK_DEST_RE: ::Regexp
    +
    +    # Markdown のリンクを描画済み  へ退避しプレースホルダに置き換える
    +    def extract_md_links: (String str, Array[String] saved) -> String
    +
    +    # open 位置の括弧に対応する閉じ括弧の位置
    +    def matching_delimiter: (String str, Integer open, String open_char, String close_char, ?space_ends: bool) -> Integer?
    +
    +    # 表示テキスト付きリンクの描画
    +    def md_link: (String text, String dest) -> String
    +
    +    def unescape_md_brackets: (String str) -> String
    +
         def restore_rd_text: (String str) -> String
       end
     end
    diff --git a/test/test_mdcompiler.rb b/test/test_mdcompiler.rb
    index 00c4510..9edd9e7 100644
    --- a/test/test_mdcompiler.rb
    +++ b/test/test_mdcompiler.rb
    @@ -594,6 +594,150 @@ def test_gfm_off_by_default
         assert_not_include html, "code"
       end
     
    +  # ---- M2: Markdown リンク(MARKUP_SPEC §7.4/§7.5、news/1_9_0 型) ----
    +
    +  def test_gfm_autolink
    +    html = gfm_compiler.compile("# T\n\n を参照。\n")
    +    assert_include html,
    +      'https://example.com/a#b を参照。'
    +  end
    +
    +  def test_gfm_autolink_in_dlist_description
    +    md = "# T\n\n- **`__callee__`**:\n\n  \n"
    +    html = gfm_compiler.compile(md)
    +    assert_include html, 'https://example.com/x'
    +  end
    +
    +  def test_gfm_autolink_requires_url_scheme
    +    html = gfm_compiler.compile("# T\n\na  c です。\n")
    +    assert_include html, "a <b> c です。"
    +    assert_not_include html, "例示ドメインのサポート'
    +  end
    +
    +  def test_gfm_inline_link_with_bracketed_text
    +    # news/1_9_0 型: [[ruby-cvs:16833]](アーカイブURL) — 表示テキストに角括弧
    +    html = gfm_compiler.compile("# T\n\n[[ruby-cvs:16833]](https://example.com/16833)\n")
    +    assert_include html, '[ruby-cvs:16833]'
    +  end
    +
    +  def test_gfm_inline_link_text_with_parens_and_url_query
    +    md = "# T\n\n[patch: activity in 1.9 (2006-06)](https://example.com/hiki.rb?Changes+in+Ruby)\n"
    +    html = gfm_compiler.compile(md)
    +    assert_include html,
    +      'patch: activity in 1.9 (2006-06)'
    +  end
    +
    +  def test_gfm_fragment_link
    +    html = gfm_compiler.compile("# T\n\n[破壊的な変更](#mutable) を参照。\n")
    +    assert_include html, '破壊的な変更 を参照。'
    +  end
    +
    +  def test_gfm_non_url_destination_stays_text
    +    # 参照直後の括弧書きをリンクと誤認しない(宛先は URL/#フラグメントのみ)
    +    html = gfm_compiler.compile("# T\n\n[c:String](文字列)を参照。\n")
    +    assert_include html, "(文字列)を参照。"
    +  end
    +
    +  def test_gfm_link_inside_code_span_stays_code
    +    html = gfm_compiler.compile("# T\n\n`[x](https://example.com)` はコード。\n")
    +    assert_include html, "[x](https://example.com) はコード。"
    +  end
    +
    +  def test_gfm_link_escapes_html_in_text_and_url
    +    html = gfm_compiler.compile("# T\n\n[a](https://example.com/?q=)\n")
    +    assert_include html, 'a<b>'
    +  end
    +
    +  # ---- M2: インデントされたコードフェンス(リスト・dlist 内、news/1_9_0 型) ----
    +
    +  def test_gfm_indented_fence_in_dlist_description
    +    md = <<~MD
    +      # T
    +
    +      - **`Dir.glob`**:
    +
    +        説明文です。
    +
    +        ```ruby
    +        p Dir.glob(["f*","b*"])  # => ["foo", "bar"]
    +        ```
    +
    +        続きの説明。
    +    MD
    +    html = gfm_compiler.compile(md)
    +    assert_include html, '
    '
    +    assert_include html, 'Dir.glob'
    +    assert_not_include html, '```'
    +    # 内容はフェンス行のインデント分がデデントされる
    +    assert_include html, "p"
    +    assert_include html, "

    \n続きの説明。\n

    " + end + + def test_gfm_indented_fence_without_lang + md = "# T\n\n- **`proc`**:\n\n 説明。\n\n ```text\n x = {|a| p a}\n ```\n" + html = gfm_compiler.compile(md) + assert_include html, "x = {|a| p a}" + assert_not_include html, '```' + end + + def test_gfm_indented_fence_in_list_item + md = "# T\n\n- 項目\n ```ruby\n p 1\n ```\n- 次の項目\n" + html = gfm_compiler.compile(md) + assert_include html, '
    '
    +    assert_include html, "次の項目"
    +    assert_not_include html, '```'
    +  end
    +
    +  def test_gfm_indented_fence_at_top_level
    +    # リスト項目と空行で切り離されたフェンス(行駆動モデルではトップレベル扱い)
    +    md = "# T\n\n- 項目\n\n  ```ruby\n  p 1\n  ```\n\n本文。\n"
    +    html = gfm_compiler.compile(md)
    +    assert_include html, '
    '
    +    assert_include html, "本文。"
    +    assert_not_include html, '```'
    +  end
    +
    +  def test_gfm_indented_fence_in_method_entry
    +    md = "### def m(v) -> String\n\n説明。\n\n- **`opt`**:\n\n  ```ruby\n  p 1\n  ```\n"
    +    html = compile_method(gfm_compiler, md)
    +    assert_include html, '
    '
    +    assert_not_include html, '```'
    +  end
    +
    +  def test_gfm_indented_fence_in_param_continuation
    +    md = "### def m(v) -> String\n\n説明。\n\n- **param** `v` -- 値。\n\n  ```ruby\n  p 1\n  ```\n"
    +    html = compile_method(gfm_compiler, md)
    +    assert_include html, '
    '
    +    assert_not_include html, '```'
    +  end
    +
    +  def test_gfm_stray_indented_line_does_not_hang
    +    # リストと空行で切り離された残余のインデント行はどのブロックにも
    +    # 該当しない。段落として消費する(従来はディスパッチが進まずハング)
    +    md = "# T\n\n- 項目\n\n  切り離された継続行。\n\n本文。\n"
    +    html = gfm_compiler.compile(md)
    +    assert_include html, "切り離された継続行。"
    +    assert_include html, "本文。"
    +  end
    +
    +  def test_gfm_stray_indented_line_in_method_entry_does_not_hang
    +    md = "### def m(v) -> String\n\n- 項目\n\n  切り離された継続行。\n"
    +    html = compile_method(gfm_compiler, md)
    +    assert_include html, "切り離された継続行。"
    +  end
    +
    +  def test_gfm_indented_fence_with_title
    +    md = "# T\n\n- 項目\n  ```ruby title=\"例\"\n  p 1\n  ```\n"
    +    html = gfm_compiler.compile(md)
    +    assert_include html, ''
    +    assert_include html, '
    '
    +  end
    +
       # ---- doc / ライブラリページ ----
     
       def test_doc_headline_and_paragraph