Skip to content

Commit 83ab432

Browse files
authored
Merge pull request #241 from znz/md-inline-links-and-indented-fences
Markdown リンクとリスト内のインデントフェンスを描画対応
2 parents 7c077fc + b254496 commit 83ab432

4 files changed

Lines changed: 365 additions & 26 deletions

File tree

doc/markdown-samples/MARKUP_SPEC.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -726,7 +726,26 @@ code line 2
726726
| `//emlist[ラベル][lang]{` ... `//}` | ```` ```lang title="ラベル" ```` ... ```` ``` ```` |
727727
| インデントテキスト(Nスペース) | `(3+N)` 個のバッククォートで囲む |
728728

729-
### 9.5 インラインコード
729+
### 9.5 リスト内のコードブロック(インデントフェンス)
730+
731+
リスト項目や定義リスト(§10.2)の説明の中にコードブロックを置く場合は、
732+
GFM と同じくフェンスを項目のインデントに合わせて書く。
733+
内容と閉じフェンスからはフェンス行のインデント幅までが除去される。
734+
735+
````markdown
736+
- **`Dir.glob`**:
737+
738+
説明文。
739+
740+
```ruby
741+
p Dir.glob(["f*","b*"]) # => ["foo", "bar"]
742+
```
743+
````
744+
745+
§9.3 のインデント幅エンコード(行頭の 4 個以上のバッククォート)とは
746+
行頭に空白があるかどうかで区別される。
747+
748+
### 9.6 インラインコード
730749

731750
テキスト行中の `__WORD__` パターン(`__END__`, `__FILE__` 等)は
732751
自動的にコードスパンに変換される。ブラケットリンク内の `__WORD__` は変換しない。

lib/bitclust/mdcompiler.rb

Lines changed: 167 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ def signature_re
4545

4646
MD_ITEM_RE = /\A(\s*)(?:- |\d+\. )/
4747
FENCE_RE = /\A`{3,}/
48+
# インデントされたフェンス(リスト項目・dlist 説明内のコードブロック)
49+
INDENTED_FENCE_RE = /\A([ \t]+)(`{3,})/
50+
# dd 段落の継続行(インデント行。ただしインデントフェンスの手前で止まる)
51+
DD_TEXT_RE = /\A[ \t](?![ \t]*`{3})/
4852
DLIST_RE = /\A- \*\*(.+?)\*\*:(?:\s|$)/
4953
INFO_RE = /\A- \*\*(?:param|arg|return|raise)\*\*/
5054
SEE_RE = /\A- \*\*SEE\*\*/
@@ -76,6 +80,8 @@ def library_file
7680
raise "@item_stack should be empty. #{@item_stack.inspect}" unless @item_stack.empty?
7781
when FENCE_RE
7882
code_fence
83+
when INDENTED_FENCE_RE
84+
indented_code_fence
7985
when EMLIST_LEFTOVER_RE
8086
# リスト脈絡などで生のまま残った #@samplecode は前処理で
8187
# //emlist になる。RDCompiler と同じ独立ブロックとして描画する
@@ -135,6 +141,8 @@ def entry_chunk
135141
raise "@item_stack should be empty. #{@item_stack.inspect}" unless @item_stack.empty?
136142
when FENCE_RE
137143
code_fence
144+
when INDENTED_FENCE_RE
145+
indented_code_fence
138146
when EMLIST_LEFTOVER_RE
139147
emlist
140148
when /\A@todo\b/
@@ -184,16 +192,26 @@ def read_entry_paragraph(f)
184192

185193
def paragraph
186194
line '<p>'
187-
line compile_text(text_node_from_lines(read_paragraph(@f).map { |l| unescape_hash(l) }))
195+
line compile_text(text_node_from_lines(consume_paragraph_lines { read_paragraph(@f) }))
188196
line '</p>'
189197
end
190198

191199
def entry_paragraph
192200
line '<p>'
193-
line compile_text(text_node_from_lines(read_entry_paragraph(@f).map { |l| unescape_hash(l) }))
201+
line compile_text(text_node_from_lines(consume_paragraph_lines { read_entry_paragraph(@f) }))
194202
line '</p>'
195203
end
196204

205+
# 段落行の読み取り。リストと空行で切り離された残余のインデント行は
206+
# どのブロック処理にも該当しないため、ここで段落として消費する
207+
# (読み取りが空のままだとディスパッチが進まなくなる)
208+
def consume_paragraph_lines
209+
lines = yield.map { |l| unescape_hash(l) }
210+
lines = @f.span(DD_TEXT_RE) if lines.empty?
211+
lines = [@f.gets || raise] if lines.empty? # 最低1行は必ず進める
212+
lines
213+
end
214+
197215
def unescape_hash(line)
198216
line.sub(/\A\\#/, '#')
199217
end
@@ -338,25 +356,10 @@ def split_table_row(row)
338356
def code_fence
339357
open_line = @f.gets or raise
340358
fence = open_line[/\A`+/] or raise
341-
rest = open_line[fence.size..].to_s.strip
342-
lang = nil
343-
caption = nil
344-
if rest =~ /\A(\w+)?(?:\s+title="((?:[^"\\]|\\.)*)")?\z/
345-
lang = $1
346-
caption = $2&.gsub(/\\(["\\])/, '\1')
347-
end
359+
lang, caption = parse_fence_info(open_line[fence.size..].to_s.strip)
348360
terminator = /\A`{#{fence.size}}\s*$/
349361
if lang
350-
# caption はタブとして pre の前に置く(rd 側と同期)
351-
line "<span class=\"caption\">#{escape_html(caption)}</span>" if caption
352-
line "<pre class=\"highlight #{lang}\">"
353-
line "<code>"
354-
src = +""
355-
@f.until_terminator(terminator) do |code_line|
356-
src << code_line
357-
end
358-
string highlight_source(src, lang, caption)
359-
line '</code></pre>'
362+
highlighted_fence_body(lang, caption, terminator)
360363
elsif fence.size == 3
361364
line '<pre>'
362365
@f.until_terminator(terminator) do |code_line|
@@ -409,6 +412,51 @@ def collect_fence_lines(terminator)
409412
lines
410413
end
411414

415+
# info string(lang と title="cap")のパース
416+
def parse_fence_info(rest)
417+
if rest =~ /\A(\w+)?(?:\s+title="((?:[^"\\]|\\.)*)")?\z/
418+
[$1, $2&.gsub(/\\(["\\])/, '\1')]
419+
else
420+
[nil, nil]
421+
end
422+
end
423+
424+
# ハイライト付き <pre> の本体。caption はタブとして pre の前に置く
425+
# (rd 側と同期)。strip_re はインデントフェンスのデデント用
426+
def highlighted_fence_body(lang, caption, terminator, strip_re = nil)
427+
line "<span class=\"caption\">#{escape_html(caption)}</span>" if caption
428+
line "<pre class=\"highlight #{lang}\">"
429+
line "<code>"
430+
src = +""
431+
@f.until_terminator(terminator) do |code_line|
432+
src << (strip_re ? code_line.sub(strip_re, '') : code_line)
433+
end
434+
string highlight_source(src, lang, caption)
435+
line '</code></pre>'
436+
end
437+
438+
# リスト項目・dlist 説明の中のインデントされたフェンス(GFM のリスト内
439+
# コードブロック)。GFM と同じく、内容と閉じフェンスからフェンス行の
440+
# インデント幅までを除去する
441+
def indented_code_fence
442+
open_line = @f.gets or raise
443+
INDENTED_FENCE_RE =~ open_line or raise
444+
indent = ($1 || raise)
445+
fence = ($2 || raise)
446+
lang, caption = parse_fence_info(open_line[(indent.size + fence.size)..].to_s.strip)
447+
terminator = /\A[ \t]{0,#{indent.size}}`{#{fence.size}}\s*$/
448+
strip_re = /\A[ \t]{1,#{indent.size}}/
449+
if lang
450+
highlighted_fence_body(lang, caption, terminator, strip_re)
451+
else
452+
line '<pre>'
453+
@f.until_terminator(terminator) do |code_line|
454+
line escape_html(code_line.sub(strip_re, '').rstrip)
455+
end
456+
line '</pre>'
457+
end
458+
end
459+
412460
# 箇条書き(- item / N. item、ネスト・継続行あり)
413461
def item_list(level = 0)
414462
open_tag = nil
@@ -427,10 +475,16 @@ def item_list(level = 0)
427475
string "<li>"
428476
@item_stack.push("</li>")
429477
string compile_text(item_line.sub(/\A\s*(?:-|\d+\.)\s?/, '').strip)
430-
if /\A(\s+)(?!- |\d+\. )\S/ =~ @f.peek
431-
@f.while_match(/\A\s+(?!- |\d+\. )\S/) do |cont|
478+
# 継続行(折り返しテキスト)とインデントフェンス(項目内コードブロック)
479+
while @f.peek
480+
if INDENTED_FENCE_RE =~ @f.peek
481+
nl
482+
indented_code_fence
483+
elsif /\A\s+(?!- |\d+\. )\S/ =~ @f.peek
432484
nl
433-
string compile_text(cont.strip)
485+
string compile_text((@f.gets || raise).strip)
486+
else
487+
break
434488
end
435489
end
436490
if (m = MD_ITEM_RE.match(@f.peek)) && level < (m[1] || raise).size
@@ -455,9 +509,11 @@ def dd_with_p
455509
case @f.peek
456510
when /\A$/
457511
@f.gets
512+
when INDENTED_FENCE_RE
513+
indented_code_fence
458514
when /\A[ \t]/
459515
line '<p>'
460-
line compile_text(text_node_from_lines(@f.span(/\A[ \t]/)))
516+
line compile_text(text_node_from_lines(@f.span(DD_TEXT_RE)))
461517
line '</p>'
462518
when FENCE_RE
463519
code_fence
@@ -472,8 +528,10 @@ def dd_without_p
472528
line '<dd>'
473529
while /\A[ \t]/ =~ @f.peek or FENCE_RE =~ @f.peek
474530
case @f.peek
531+
when INDENTED_FENCE_RE
532+
indented_code_fence
475533
when /\A[ \t]/
476-
line compile_text(text_node_from_lines(@f.span(/\A[ \t]/)))
534+
line compile_text(text_node_from_lines(@f.span(DD_TEXT_RE)))
477535
when FENCE_RE
478536
code_fence
479537
end
@@ -491,21 +549,105 @@ def compile_text(str)
491549

492550
# GFM モードのテキストコンパイル:
493551
# コードスパン `x` → <code>(中身は参照解決しない・HTML エスケープのみ)、
494-
# 行頭 **N.** → <strong>。エスケープ済み \` はリテラルのバッククォート
552+
# 行頭 **N.** → <strong>、Markdown リンク(<url> 自動リンク・
553+
# [テキスト](URL)・[テキスト](#アンカー))→ <a>。
554+
# エスケープ済み \` はリテラルのバッククォート
495555
def compile_gfm_text(str)
496556
hidden = str.gsub(/\\`/, "\x00")
497557
hidden.split(/(`[^`\n]+`)/, -1).map { |seg|
498558
if seg =~ /\A`([^`\n]+)`\z/
499559
"<code>#{escape_html(($1 || raise).gsub("\x00", '`'))}</code>"
500560
else
561+
links = [] #: Array[String]
562+
seg = extract_md_links(seg, links)
501563
seg = seg.gsub(/^\*\*(\d+\.)\*\* /, "\x01\\1\x02 ")
502564
rd_compile_text(MarkdownToRRD.restore_inline(seg))
503565
.gsub("\x01", '<strong>').gsub("\x02", '</strong>')
504566
.gsub("\x00", '`')
567+
.gsub(/\x03(\d+)\x03/) { links[($1 || raise).to_i] || raise }
505568
end
506569
}.join
507570
end
508571

572+
AUTOLINK_RE = %r{<(https?://[^<>\s]+)>}
573+
# インラインリンクの宛先として受ける形。散文の「[c:String](を参照)」の
574+
# ような括弧書きをリンクと誤認しないため URL と #フラグメントに限る
575+
# (MARKUP_SPEC §7.4/§7.5)
576+
MD_LINK_DEST_RE = %r{\A(?:https?://|\#)}
577+
578+
# Markdown のリンクを描画済み <a> へ退避し \x03idx\x03 プレースホルダに
579+
# 置き換える。後段の rd_compile_text(HTML エスケープ・参照解決)を
580+
# 素通しし、compile_gfm_text の最後で戻す
581+
def extract_md_links(str, saved)
582+
str = str.gsub(AUTOLINK_RE) {
583+
saved << direct_url($1 || raise)
584+
"\x03#{saved.size - 1}\x03"
585+
}
586+
return str unless str.include?('[')
587+
result = +''
588+
i = 0
589+
while i < str.length
590+
if str[i] == '\\' && i + 1 < str.length
591+
result << (str[i, 2] || raise)
592+
i += 2
593+
next
594+
end
595+
if str[i] == '['
596+
close = matching_delimiter(str, i, '[', ']')
597+
if close && str[close + 1] == '(' &&
598+
(dest_end = matching_delimiter(str, close + 1, '(', ')', space_ends: true)) &&
599+
(dest = str[(close + 2)...dest_end]) && MD_LINK_DEST_RE =~ dest
600+
saved << md_link(str[(i + 1)...close] || raise, dest)
601+
result << "\x03#{saved.size - 1}\x03"
602+
i = dest_end + 1
603+
next
604+
end
605+
end
606+
result << (str[i] || raise)
607+
i += 1
608+
end
609+
result
610+
end
611+
612+
# open 位置の括弧に対応する閉じ括弧の位置(\ エスケープ対応・ネスト可)。
613+
# space_ends: リンク宛先用。空白が現れたらリンクではない(nil)
614+
def matching_delimiter(str, open, open_char, close_char, space_ends: false)
615+
depth = 0
616+
i = open
617+
while i < str.length
618+
c = str[i]
619+
if c == '\\'
620+
i += 1
621+
elsif space_ends && c =~ /\s/
622+
return nil
623+
elsif c == open_char
624+
depth += 1
625+
elsif c == close_char
626+
depth -= 1
627+
return i if depth == 0
628+
end
629+
i += 1
630+
end
631+
nil
632+
end
633+
634+
# 表示テキスト付きリンクの描画。テキストは参照解決しないプレーン表示
635+
# (リンク内リンクは HTML として成立しないため)。外部 URL は rd の
636+
# [[url:]] と同じ external クラス、#アンカーはページ内リンク
637+
def md_link(text, dest)
638+
label = escape_html(unescape_md_brackets(text).gsub("\x00", '`'))
639+
href = escape_html(unescape_md_brackets(dest))
640+
if dest.start_with?('#')
641+
%Q(<a href="#{href}">#{label}</a>)
642+
else
643+
%Q(<a class="external" href="#{href}">#{label}</a>)
644+
end
645+
end
646+
647+
def unescape_md_brackets(str)
648+
str.gsub(/\\([\[\]\\])/, '\1')
649+
end
650+
509651
def restore_rd_text(str)
510652
# M1 等価モード: md のコードスパンを rd の元表記へ戻して描画する
511653
MarkdownToRRD.restore_text(str)

sig/bitclust/mdcompiler.rbs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ module BitClust
2424

2525
FENCE_RE: ::Regexp
2626

27+
# インデントされたフェンス(リスト項目・dlist 説明内のコードブロック)
28+
INDENTED_FENCE_RE: ::Regexp
29+
30+
# dd 段落の継続行(インデントフェンスの手前で止まる)
31+
DD_TEXT_RE: ::Regexp
32+
2733
DLIST_RE: ::Regexp
2834

2935
INFO_RE: ::Regexp
@@ -53,6 +59,9 @@ module BitClust
5359

5460
def entry_paragraph: () -> void
5561

62+
# 段落行の読み取り(残余のインデント行も段落として消費する)
63+
def consume_paragraph_lines: () { () -> Array[String] } -> Array[String]
64+
5665
def unescape_hash: (String line) -> String
5766

5867
# - **param** `name` -- desc / - **return** -- desc / - **raise** `Ex` -- desc
@@ -88,6 +97,15 @@ module BitClust
8897

8998
def collect_fence_lines: (Regexp terminator) -> Array[String]
9099

100+
# info string(lang と title="cap")のパース
101+
def parse_fence_info: (String rest) -> [String?, String?]
102+
103+
# ハイライト付き <pre> の本体
104+
def highlighted_fence_body: (String lang, String? caption, Regexp terminator, ?Regexp? strip_re) -> void
105+
106+
# リスト項目・dlist 説明の中のインデントされたフェンス
107+
def indented_code_fence: () -> void
108+
91109
# 箇条書き(- item / N. item、ネスト・継続行あり)
92110
def item_list: (?::Integer level) -> void
93111

@@ -103,6 +121,22 @@ module BitClust
103121
# GFM モードのテキストコンパイル
104122
def compile_gfm_text: (String str) -> String
105123

124+
AUTOLINK_RE: ::Regexp
125+
126+
# インラインリンクの宛先として受ける形(URL と #フラグメントのみ)
127+
MD_LINK_DEST_RE: ::Regexp
128+
129+
# Markdown のリンクを描画済み <a> へ退避しプレースホルダに置き換える
130+
def extract_md_links: (String str, Array[String] saved) -> String
131+
132+
# open 位置の括弧に対応する閉じ括弧の位置
133+
def matching_delimiter: (String str, Integer open, String open_char, String close_char, ?space_ends: bool) -> Integer?
134+
135+
# 表示テキスト付きリンクの描画
136+
def md_link: (String text, String dest) -> String
137+
138+
def unescape_md_brackets: (String str) -> String
139+
106140
def restore_rd_text: (String str) -> String
107141
end
108142
end

0 commit comments

Comments
 (0)