Module: Vivlio::Starter::CLI::PreProcessCommands::MarkdownUtils

Defined in:
lib/vivlio/starter/cli/pre_process/markdown_utils.rb

Overview

Markdown 処理の共通ユーティリティ

Constant Summary collapse

EXT_TO_LANG =

拡張子→言語の対応表

{
  'c' => 'c',
  'cc' => 'cpp',
  'cpp' => 'cpp',
  'cs' => 'csharp',
  'css' => 'css',
  'cxx' => 'cpp',
  'go' => 'go',
  'html' => 'html',
  'java' => 'java',
  'js' => 'javascript',
  'json' => 'json',
  'kt' => 'kotlin',
  'md' => 'markdown',
  'php' => 'php',
  'py' => 'python',
  'rb' => 'ruby',
  'rs' => 'rust',
  'scala' => 'scala',
  'scss' => 'scss',
  'sh' => 'bash',
  'sql' => 'sql',
  'swift' => 'swift',
  'ts' => 'typescript',
  'xml' => 'xml',
  'yaml' => 'yaml',
  'yml' => 'yaml'
}.freeze
CODE_SPAN_PLACEHOLDER_PREFIX =
'__VS_CODE_SPAN__'

Class Method Summary collapse

Class Method Details

.detect_language(file_path) ⇒ Object

拡張子から言語名を推定



113
114
115
116
# File 'lib/vivlio/starter/cli/pre_process/markdown_utils.rb', line 113

def detect_language(file_path)
  ext = File.extname(file_path).downcase.delete_prefix('.')
  EXT_TO_LANG.fetch(ext, 'text')
end

.escape_inline_code_html(md_text) ⇒ Object

インラインコード(‘…`)内は、そのままの文字列を維持する



108
109
110
# File 'lib/vivlio/starter/cli/pre_process/markdown_utils.rb', line 108

def escape_inline_code_html(md_text)
  md_text.to_s
end

.extract_code_spans(text) ⇒ Object

コードスパン(バッククォートで囲まれた部分)を一時的に退避し、その中身を後続のテキスト変形処理から除外するためのユーティリティ。コードフェンス(“‘…“`)も退避対象とする。

対応するケース:

- 3 個以上のバッククォートまたはチルダによるフェンス(先頭 0-3 スペースまで許容)
- 単一バッククォートのインラインコード `foo`
- マルチバッククォートのインラインコード ``foo`bar`` (N 個の開き=N 個の閉じ)


64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/vivlio/starter/cli/pre_process/markdown_utils.rb', line 64

def extract_code_spans(text)
  spans = {}
  counter = 0

  alloc = lambda do |match|
    key = "#{CODE_SPAN_PLACEHOLDER_PREFIX}#{counter}__"
    spans[key] = match
    counter += 1
    key
  end

  # まずコードフェンスブロック全体を退避(インラインコードより先に処理)
  # CommonMark に合わせて先頭 0-3 スペースのインデントも許容する。
  # ```include: で始まる行はインクルード記法であり、フェンスブロックの
  # 開始ではないため除外する。
  protected_text = text.to_s.gsub(/^ {0,3}(`{3,}|~{3,}).*?^ {0,3}\1\s*$/m) do |block|
    # ```include: で始まる行はフェンスブロックではなくインクルード記法
    if block.match?(/\A\s*`{3,}include:/)
      block
    else
      alloc.call(block)
    end
  end

  # 次にインラインコードスパンを退避
  # N 個の連続バッククォート同士の対を 1 組として扱い、
  # ``foo`bar`` のように内部にバッククォートを含むケースも保護する。
  # (?<!`) / (?!`) で開き・閉じの両端が「ちょうど N 個のラン」であることを担保する。
  protected_text = protected_text.gsub(/(?<!`)(`+)(?!`).+?(?<!`)\1(?!`)/m, &alloc)

  [protected_text, spans]
end

.pipe_table_to_html(md_text) ⇒ Object

パイプテーブルを簡易HTML化



185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
# File 'lib/vivlio/starter/cli/pre_process/markdown_utils.rb', line 185

def pipe_table_to_html(md_text)
  text = md_text.to_s.strip
  lines = text.split(/\r?\n/).map(&:rstrip)
  return nil if lines.size < 2

  header = lines[0]
  sep    = lines[1]
  return nil unless header.include?('|')
  return nil unless sep && sep =~ /^\s*\|?[\s:\-|]+\|?\s*$/

  rows = lines[2..] || []

  to_cells = lambda do |line|
    parts = line.split('|')
    parts.shift if parts.first&.strip == ''
    parts.pop   if parts.last&.strip  == ''
    parts.map(&:strip)
  end

  esc_code = lambda do |s|
    s.gsub(/`([^`]+)`/) { "<code>#{::Regexp.last_match(1)}</code>" }
     .gsub('&', '&amp;')
     .gsub('<', '&lt;')
     .gsub('>', '&gt;')
  end

  thead_cells = to_cells.call(header)
  tbody_rows  = rows.map { |r| to_cells.call(r) }

  html = []
  html << '<table>'
  html << '  <thead>'
  html << "    <tr>#{thead_cells.map { |c| "<th>#{esc_code.call(c)}</th>" }.join}</tr>"
  html << '  </thead>'
  if tbody_rows.any?
    html << '  <tbody>'
    tbody_rows.each do |cells|
      html << "    <tr>#{cells.map { |c| "<td>#{esc_code.call(c)}</td>" }.join}</tr>"
    end
    html << '  </tbody>'
  end
  html << '</table>'
  html.join("\n")
end

.render_markdown_fallback(md_text) ⇒ Object

Kramdown が使えない場合のフォールバック実装



129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/vivlio/starter/cli/pre_process/markdown_utils.rb', line 129

def render_markdown_fallback(md_text)
  lines = md_text.to_s.split(/\r?\n/)
  html_parts = []
  in_ol = false
  buffer_p = []

  flush_p = lambda do
    unless buffer_p.empty?
      paragraph = buffer_p.join(' ').strip
      html_parts << "<p>#{paragraph}</p>" unless paragraph.empty?
      buffer_p.clear
    end
  end

  lines.each do |line|
    if line.strip.empty?
      flush_p.call
      next
    end

    # 画像
    if (m = line.match(/^\s*!\[[^\]]*\]\(([^)]+)\)\s*$/))
      flush_p.call
      src = m[1]
      html_parts << "<img src=\"#{src}\">"
      next
    end

    # 見出し相当の太字行
    if (m = line.match(/^\s*\*\*(.+?)\*\*\s*$/))
      flush_p.call
      html_parts << "<p><strong>#{m[1]}</strong></p>"
      next
    end

    # 番号リスト
    if (m = line.match(/^\s*(\d+)\.\s+(.*)$/))
      flush_p.call
      html_parts << '<ol>' unless in_ol
      in_ol = true
      html_parts << "<li>#{m[2]}</li>"
      next
    elsif in_ol
      html_parts << '</ol>'
      in_ol = false
    end

    buffer_p << line
  end

  flush_p.call
  html_parts << '</ol>' if in_ol
  html_parts.join("\n")
end

.render_markdown_to_html(md_text) ⇒ Object

簡易Markdown→HTML 変換



119
120
121
122
123
124
125
126
# File 'lib/vivlio/starter/cli/pre_process/markdown_utils.rb', line 119

def render_markdown_to_html(md_text)
  # まずはKramdownを試す
  require 'kramdown'
  Kramdown::Document.new(md_text, syntax_highlighter: nil).to_html
rescue LoadError
  # フォールバック: 最小限のMarkdownをHTMLへ
  render_markdown_fallback(md_text)
end

.restore_code_spans(text, spans) ⇒ Object

extract_code_spans で退避したコードスパンを元に戻す



98
99
100
101
102
103
104
105
# File 'lib/vivlio/starter/cli/pre_process/markdown_utils.rb', line 98

def restore_code_spans(text, spans)
  restored = text.to_s
  # gsub の置換文字列として解釈されないよう Regexp.last_match を使う
  spans.each do |placeholder, original|
    restored = restored.gsub(placeholder) { original }
  end
  restored
end