Module: VivlioStarter::CLI::PreProcessCommands::MarkdownTransformer

Defined in:
lib/vivlio_starter/cli/pre_process/markdown_transformer.rb

Overview

Markdown 特殊記法変換モジュール

Defined Under Namespace

Classes: FancyMarker

Constant Summary collapse

TERMINAL_BLOCK_PATTERN =

:::terminal … ::: の検出パターン(開始・終了とも独立行)。

/^:{3,}[ \t]*\{\.terminal\}[ \t]*\n(.*?)^:{3,}[ \t]*$\n?/m
TERMINAL_FENCE_LANG =

前処理で terminal ブロックを退避するチルダフェンスの言語名。 後処理の TerminalBlockConverter が pre.language-vs-terminal を拾う。

'vs-terminal'
TALK_BLOCK_PATTERN =

:::[オプション] … ::: の検出(開始・終了とも独立行)。 キャプチャ 1 = オプション文字列({.talk} なら空)、キャプチャ 2 = ブロック本文。

/^:{3,}[ \t]*\{\s*\.talk((?:[ \t][^}]*)?)\}[ \t]*\n(.*?)^:{3,}[ \t]*$\n?/m
TALK_SPEAKER_PATTERN =

行頭の「キー: 発話」(半角キー+半角コロン+空白)で話者を切り替える。 発話が空(キーのみ)で継続行に続けるケースも許すため、本文はオプショナル。

/\A([A-Za-z0-9][A-Za-z0-9_-]*):(?:[ \t]+(.*))?\z/
TALK_OPTION_KEYS =

ブロック単位で指定できるオプションキー。

%w[style name avatar separator].freeze
TALK_AVATAR_EXTENSIONS =

アバター画像として探す拡張子(talk.yml の指定が実体と食い違うときの変種)。

%w[.webp .png .jpg .jpeg].freeze
ROMAN_DIGIT_VALUES =

ローマ数字 1 文字の値(小文字で引く)

{ 'i' => 1, 'v' => 5, 'x' => 10, 'l' => 50, 'c' => 100, 'd' => 500, 'm' => 1000 }.freeze
FANCY_STYLE_CSS =

出力

    クラス名の様式部・区切り部(§2.1 対応表。vs-list-<様式><区切り接尾辞>)

{
  decimal: 'decimal', lower_alpha: 'lower-alpha', upper_alpha: 'upper-alpha',
  lower_roman: 'lower-roman', upper_roman: 'upper-roman'
}.freeze
FANCY_SEPARATOR_SUFFIX =
{ period: '', paren: '-paren', paren2: '-paren2' }.freeze
FANCY_TYPE_ATTR =
    へ写せる様式(decimal は HTML 既定のため付けない)
{ lower_alpha: 'a', upper_alpha: 'A', lower_roman: 'i', upper_roman: 'I' }.freeze

Class Method Summary collapse

Class Method Details

.accept_marker!(marker, stack, ol_queue, line:, source_filename:, after_blank: false) ⇒ Object

マーカー行を現在のリスト文脈で受理できるか判定し、受理時はスタック・キューを更新する。 戻り値: true(項目/新リスト)/ :split(空行を挟んだ様式変更=EOB で別リストに分ける)/ false(不受理。大文字+ピリオド 1 スペースの新規リスト等。呼び出し側が継続行または ブロック終端として扱う)。



873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 873

def accept_marker!(marker, stack, ol_queue, line:, source_filename:, after_blank: false)
  if stack.empty?
    open_fancy_list!(marker, stack, ol_queue)
    return true
  end

  # 浅いインデントへ戻ったら、そのレベルまで内側のリストを閉じる
  stack.pop while stack.size > 1 && marker.indent < stack.last[:indent]

  top = stack.last
  if marker.indent >= top[:indent] + 2 # 2 スペース以上深ければ入れ子の新規リスト
    return false unless acceptable_list_start?(marker)

    open_fancy_list!(marker, stack, ol_queue)
    return true
  end

  # 同一レベルで ul ⇔ ol が変われば新しいリストを開く(CommonMark と同じ分裂挙動)
  if marker.list_type != top[:list_type]
    return false unless acceptable_list_start?(marker)

    stack.pop
    open_fancy_list!(marker, stack, ol_queue)
    return true
  end

  # 同一レベル・同一種別: 様式一致なら次項目
  if same_list_style?(marker, top)
    top[:counter] += 1
    return true
  end
  return false unless acceptable_list_start?(marker)

  # 空行を挟んだトップレベルの様式変更は別リストの開始(Pandoc と同じ分裂挙動)。
  # ネスト中は EOB マーカーが外側のリストまで閉じてしまうため分裂させない。
  if after_blank && stack.size == 1
    stack.pop
    open_fancy_list!(marker, stack, ol_queue)
    return :split
  end

  # 空行なしの様式変更は非サポート: 警告して先頭様式のまま続行(§2.2)
  warn_fancy_style_change(top, line, source_filename)
  top[:counter] += 1
  true
end

.acceptable_list_start?(marker) ⇒ Boolean

大文字様式+ピリオドのリスト開始行は空白 2 つ以上を要求する(B. Russell 誤爆防止・§2.2)

Returns:

  • (Boolean)


797
798
799
800
801
802
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 797

def acceptable_list_start?(marker)
  return true unless marker.list_type == :ol && marker.separator == :period
  return true unless %i[upper_alpha upper_roman].include?(marker.style)

  marker.gap.length >= 2
end

.apply_talk_option(options, key, value, source_filename:) ⇒ Object

オプション 1 つを表示設定へ反映する(未知の値は 🟡 で現状維持)。



149
150
151
152
153
154
155
156
157
158
159
160
161
162
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 149

def apply_talk_option(options, key, value, source_filename:)
  case key
  when 'style'
    style = value.strip.downcase.to_sym
    unless TalkRegistry::STYLES.include?(style)
      warn_talk_unknown_style(value, source_filename)
      return options
    end
    options.with(style:)
  when 'name'      then options.with(name: Common.truthy?(value))
  when 'avatar'    then options.with(avatar: TalkRegistry.parse_avatar_mode(value))
  when 'separator' then options.with(separator: value)
  end
end

.auto_talk_avatar_tag(char, source_filename:) ⇒ Object

自動生成した簡易アバターの 。生成物はワークスペース内実体のため asset_prefix を付けない(数式 SVG・showcase と同じ規約)。



307
308
309
310
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 307

def auto_talk_avatar_tag(char, source_filename:)
  src = TalkAvatarGenerator.generate(char, source_filename:)
  src ? image_talk_avatar_tag(src) : nil
end

.build_ol_marker(indent_str, separator, token, gap, body) ⇒ Object

ol マーカーを組み立てる(トークンが様式に分類できなければ nil =マーカーでない)



765
766
767
768
769
770
771
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 765

def build_ol_marker(indent_str, separator, token, gap, body)
  style, value = classify_fancy_token(token)
  return nil unless style

  FancyMarker.new(indent: indent_str.length, list_type: :ol, style:, separator:,
                  token:, value:, gap:, body: body.to_s)
end

.classify_fancy_token(token) ⇒ Object

マーカー中身のトークンを [様式, 開始値] へ分類する(§2.2 の判定順)。 単文字の i / v / x 等はローマ数字を優先する(Pandoc 準拠。c 始まりの英字リストは書けない)。



775
776
777
778
779
780
781
782
783
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 775

def classify_fancy_token(token)
  case token
  when /\A\d{1,9}\z/    then [:decimal, token.to_i]
  when /\A[ivxlcdm]+\z/ then [:lower_roman, roman_to_int(token)]
  when /\A[IVXLCDM]+\z/ then [:upper_roman, roman_to_int(token)]
  when /\A[a-z]\z/      then [:lower_alpha, token.ord - 96]
  when /\A[A-Z]\z/      then [:upper_alpha, token.ord - 64]
  end
end

.convert_book_card_inner_markdown(content) ⇒ Object

...
の内側MarkdownをHTMLへ


490
491
492
493
494
495
496
497
498
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 490

def convert_book_card_inner_markdown(content)
  content.gsub(%r{<div class="book-card">\n(.*?)\n</div>}m) do
    inner = ::Regexp.last_match(1)
    normalized = normalize_book_card_md(inner)
    html = MarkdownUtils.render_markdown_to_html(normalized)
    formatted = format_book_card_inner_html(html)
    "<div class=\"book-card\">\n#{formatted}\n</div>"
  end
end

.convert_container_blocks(content, class_name:) ⇒ Object

::: ... 記法で囲まれたコンテナを div に変換。 コードブロック内の ::: 記法は変換対象外とする。



536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 536

def convert_container_blocks(content, class_name:)
  opened_count = 0
  closed_count = 0

  # --- Phase: コードブロック退避 ---
  protected_text, spans = MarkdownUtils.extract_code_spans(content)

  pattern = /:::\s*\{\.([^}]+)\}\s*\n(.*?)\n:::\s*(?:\n|$)/m

  converted = protected_text.gsub(pattern) do
    raw_token_str = ::Regexp.last_match(1)
    inner         = ::Regexp.last_match(2)

    raw_tokens = raw_token_str.split
    first_class = raw_tokens.first
    additional_tokens = raw_tokens.drop(1)
    additional_classes = additional_tokens.select { |t| t.start_with?('.') }.map { |c| c.delete_prefix('.') }
    param_tokens = additional_tokens.reject { |t| t.start_with?('.') }

    next ::Regexp.last_match(0) unless first_class == class_name || additional_classes.include?(class_name)

    opened_count += 1
    closed_count += 1

    all_classes = [first_class] + additional_classes
    class_attr = all_classes.join(' ')

    style_parts = []
    param_tokens.each do |token|
      if (m_scale = token.match(/^scale=(.+)$/))
        raw = m_scale[1].strip
        scale_percent = raw.end_with?('%') ? raw.to_f : raw.to_f * 100.0
        scale_int = scale_percent.round
        style_parts << "--rotate-table-scale:#{scale_int}%;"
      end

      next unless (m_shift = token.match(/^shift-y=(.+)$/))

      raw = m_shift[1].strip
      shift_percent = raw.end_with?('%') ? raw.to_f : raw.to_f * 100.0
      shift_int = shift_percent.round
      sign = shift_int.negative? ? '' : '+'
      style_parts << "--rotate-table-shift-y:#{sign}#{shift_int}%;"
    end

    style_attr = style_parts.empty? ? '' : " style=\"#{style_parts.join(' ')}\""

    "<div class=\"#{class_attr}\"#{style_attr}>\n#{inner}\n</div>\n\n"
  end

  # --- Phase: コードブロック復元 ---
  converted = MarkdownUtils.restore_code_spans(converted, spans)

  [converted, opened_count, closed_count]
end

.convert_definition_lists(content) ⇒ Object

標準 Markdown(pandoc / Markdown Extra 風)の定義リスト記法を

に変換する。 用語 ←
: 説明 ←
(複数並べれば複数
) 続き行 ← 直前
の続き(半角スペース字下げ) VFM は定義リストに未対応なので、検出ブロックを Kramdown でレンダリングして

を生成する(class は索引/奥付の
と衝突させないため)。 著者は空行なしのコンパクトな形でも書け、内部でエントリ間に空行を補ってから Kramdown に渡す。インラインコード `...` 等のインライン装飾は Kramdown が処理する。 コードフェンス(``` 可変長)内は対象外。


601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 601

def convert_definition_lists(content)
  lines = content.lines
  code_lines = code_line_numbers(content)
  out = []
  i = 0
  while i < lines.size
    if code_lines.include?(i + 1)
      out << lines[i]
      i += 1
    elsif definition_list_start?(lines, i)
      j = definition_list_end(lines, i)
      out << render_definition_list(lines[i...j].join)
      i = j
    else
      out << lines[i]
      i += 1
    end
  end
  out.join
end

.convert_fancy_lists(content, source_filename: nil) ⇒ Object

Pandoc fancy_lists 互換マーカー(A. / (a) / i. 等)を含むリストブロックを Kramdown で HTML 化し、

    へ様式クラス・type・start を付与して生 HTML として インライン展開する(定義リスト convert_definition_lists と同方式)。 fancy マーカーを 1 つも含まないブロックはバイト一致で素通しし、標準リストは VFM の機能(ルビ・脚注等)を維持する。コードフェンス/インラインコード内は対象外。 行頭 \ でエスケープされた fancy マーカー行は \ を除去して地の文にする。



726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 726

def convert_fancy_lists(content, source_filename: nil)
  lines = content.lines
  code_lines = code_line_numbers(content)
  out = []
  i = 0
  while i < lines.size
    if code_lines.include?(i + 1)
      out << lines[i]
      i += 1
    elsif fancy_block_start?(lines[i])
      block_end, replacement = convert_list_block(lines, i, code_lines, source_filename:)
      out << replacement
      i = block_end
    else
      out << unescape_fancy_marker(lines[i])
      i += 1
    end
  end
  out.join
end

.convert_list_block(lines, start, code_lines, source_filename:) ⇒ Object

start 行から始まるリストブロックを走査し、[終端 index(排他的), 置換文字列] を返す。 fancy マーカーを含まないブロックは原文のまま(バイト一致)返す。



812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 812

def convert_list_block(lines, start, code_lines, source_filename:)
  # --- Phase: 走査(レベル追跡・マーカー受理・正規化行の生成)---
  stack = []      # 開いているリスト(末尾が最内)
  ol_queue = []   # <ol> 出現順(=リスト開始イベント順)の属性キュー
  original = []
  normalized = []
  fancy_found = false
  pending_blank_at = nil
  j = start

  while j < lines.size
    break if code_lines.include?(j + 1)

    line = lines[j]
    if line.strip.empty?
      break unless pending_blank_at.nil? # 空行 2 連続はブロック終端

      pending_blank_at = j
      j += 1
      next
    end

    marker = parse_list_marker(line)
    accepted = marker && accept_marker!(marker, stack, ol_queue,
                                        line:, source_filename:, after_blank: !pending_blank_at.nil?)
    if accepted
      fancy_found ||= fancy_marker?(marker)
      # :split(空行を挟んだ様式変更=別リスト)は Kramdown の EOB マーカー ^ で
      # 前のリストを閉じ、連番どうしでも 1 つのルーズリストに併合されないようにする。
      # ^ の前に空行を入れると直前のリストがルーズ化(li が <p> 包み)するため入れない。
      normalized << (accepted == :split ? "^\n\n" : "\n") unless pending_blank_at.nil?
      normalized << normalized_marker_line(marker, stack)
    elsif stack.any? && line.match?(/\A {2,}\S/)
      # 字下げ継続行(2 スペース規則で受理されなかった入れ子マーカー含む)は現在項目の続き
      normalized << "\n" unless pending_blank_at.nil?
      normalized << normalized_continuation_line(line, stack)
    else
      break
    end

    original << lines[pending_blank_at] unless pending_blank_at.nil?
    original << line
    pending_blank_at = nil
    j += 1
  end

  block_end = pending_blank_at || j
  return [block_end, original.join] unless fancy_found

  # --- Phase: Kramdown レンダリングと <ol> 属性パッチ ---
  html = MarkdownUtils.render_markdown_to_html(normalized.join).strip
  patched = patch_fancy_ol_attributes(html, ol_queue, source_filename:)
  return [block_end, original.join] if patched.nil?

  [block_end, "#{patched}\n\n"]
end

.convert_standalone_spacing(content) ⇒ Object

単独行の aki / aki2 を縦余白マクロ @vspace に置換する。 aki は本来「段落末に付けて段落へ class を与える」インライン記法のため、 それ自身だけを 1 行に書くと(付与先の本文が無いので)VFM が "aki" を そのまま文字として出力してしまう。空行用途(
のように 1 行空ける)で 書かれた単独行は @vspace:1lh(aki2 は 2lh)へ置き換え、独立段落となるよう 直後に空行を補う。段落末に付いた aki は対象外(後処理の組み込み置換ルールでクラス化)。 コードフェンス内は対象外。



1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 1043

def convert_standalone_spacing(content)
  lines = content.lines
  code_lines = code_line_numbers(content)
  out = []
  lines.each_with_index do |line, i|
    if code_lines.include?(i + 1)
      out << line
      next
    end
    m = line.strip.match(/\A\{\.(aki2?)\}\z/)
    prev_blank = out.empty? || out.last.strip.empty?
    if m && prev_blank
      out << "@vspace:#{m[1] == 'aki2' ? 2 : 1}lh\n"
      next_line = lines[i + 1]
      out << "\n" unless next_line.nil? || next_line.strip.empty?
    else
      out << line
    end
  end
  out.join
end

.convert_talk_blocks(content, registry:, source_filename:) ⇒ Object

:::talk ブロックを会話文 HTML へ変換する。 発話内のインライン Markdown(強調・コード・リンク)は生 HTML 内では VFM が処理 しないため、book-card と同じく発話部分だけ Kramdown でインライン変換して埋め込む。

記法解説フェンス(```markdown 内の :::talk)を巻き込まないようコードスパンを退避する。

Parameters:

  • registry (TalkRegistry::Registry)

    表示設定と話者定義

  • source_filename (String)

    警告に添える原稿ファイル名



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 102

def convert_talk_blocks(content, registry:, source_filename:)
  # コードスパン退避で ```markdown フェンス内の :::{.talk} 作例を巻き込まないようにする。
  # 退避にはインラインコード(`code`)も含まれるため、発話内インライン Markdown を
  # Kramdown へ渡す前に spans を使ってその発話ぶんだけ復元する(render_talk_body)。
  text, spans = MarkdownUtils.extract_code_spans(content)
  return content unless text.match?(TALK_BLOCK_PATTERN)

  # talk.yml 不在のまま .talk を使っている場合は作成を促す。
  warn_talk_missing_file(source_filename) unless registry.present?

  converted = text.gsub(TALK_BLOCK_PATTERN) do
    args = ::Regexp.last_match(1)
    body = ::Regexp.last_match(2)
    options, extra_classes = parse_talk_options(args, registry.display, source_filename:)
    render_talk_block(body, registry:, source_filename:, spans:, options:, extra_classes:)
  end
  MarkdownUtils.restore_code_spans(converted, spans)
end

.convert_terminal_blocks(content) ⇒ Object

:::terminal を独自言語名のチルダフェンスへ書き換える。

.terminal は「端末の逐語転写」であり、中身は Markdown ではない。 素の Markdown として VFM に渡すと *.png が斜体化し、`date` の バッククォートが消え、桁揃えの連続空白が HTML 生成の時点で失われ、 --- の行は <hr class="pagebreak">(改ページ)に化ける。 フェンス化してしまえば以降の前処理ステップは Masking.protect_code で 中身を退避し、VFM が HTML エスケープと空白保持を引き受ける。 専用のプレースホルダ機構を持たず既存のコード保護機構へ相乗りする形。

記法解説フェンス(```markdown 内の :::terminal)は変換しない。



59
60
61
62
63
64
65
66
67
68
69
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 59

def convert_terminal_blocks(content)
  protected_text, spans = MarkdownUtils.extract_code_spans(content)

  converted = protected_text.gsub(TERMINAL_BLOCK_PATTERN) do
    body = ::Regexp.last_match(1)
    fence = '~' * terminal_fence_length(body)
    "\n#{fence}#{TERMINAL_FENCE_LANG}\n#{body}#{fence}\n\n"
  end

  MarkdownUtils.restore_code_spans(converted, spans)
end

.definition_continuation_line?(line) ⇒ Boolean

継続行: 字下げされた非空行(直前の定義の続き)

Returns:

  • (Boolean)


640
641
642
643
644
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 640

def definition_continuation_line?(line)
  return false if line.to_s.strip.empty?

  line.to_s.start_with?(' ', "\t")
end

.definition_def_line?(line) ⇒ Boolean

定義行: 行頭が「: 」(コロン+空白)で内容が続くもの

Returns:

  • (Boolean)


635
636
637
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 635

def definition_def_line?(line)
  line.to_s.match?(/\A:[ \t]+\S/)
end

.definition_list_end(lines, idx) ⇒ Object

定義リストブロックの終端(排他的 index)を返す。 定義/継続/(定義が続く)用語/内部空行(ルーズ形式の区切り)を取り込む。



655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 655

def definition_list_end(lines, idx)
  j = idx
  while j < lines.size
    line = lines[j]
    if definition_def_line?(line) || definition_continuation_line?(line)
      j += 1
    elsif definition_term_line?(line) && definition_def_line?(lines[j + 1])
      j += 1
    elsif line.to_s.strip.empty? && definition_list_start?(lines, j + 1)
      j += 1
    else
      break
    end
  end
  j
end

.definition_list_start?(lines, idx) ⇒ Boolean

用語行の直後が定義行なら、定義リストの開始

Returns:

  • (Boolean)


647
648
649
650
651
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 647

def definition_list_start?(lines, idx)
  return false unless definition_term_line?(lines[idx])

  definition_def_line?(lines[idx + 1])
end

.definition_term_line?(line) ⇒ Boolean

用語行: 行頭から始まる非空行で、定義行(: )・継続行(字下げ)・他のブロック構文でないもの

Returns:

  • (Boolean)


623
624
625
626
627
628
629
630
631
632
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 623

def definition_term_line?(line)
  s = line.to_s.chomp
  return false if s.strip.empty?
  return false if s.start_with?(' ', "\t") # 字下げ=継続行
  return false if s.match?(/\A:[ \t]/)      # 定義行
  # 見出し / 引用 / 表 / コンテナ / フェンス / 生HTML / 箇条書き・番号リストは用語にしない
  return false if s.match?(%r{\A(\#|>|\||:::|```|<|[-*+][ \t]|\d+[.)][ \t])})

  true
end

.escape_html(str) ⇒ Object

生 HTML 属性・テキストへ差し込む文字列を最小エスケープする。



336
337
338
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 336

def escape_html(str)
  str.to_s.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;').gsub('"', '&quot;')
end

.escape_inline_code_html(line) ⇒ Object

インラインコード内の HTML 予約文字をエスケープする



1098
1099
1100
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 1098

def escape_inline_code_html(line)
  MarkdownUtils.escape_inline_code_html(line)
end

.extract_line_range(lines, start_line, end_line) ⇒ Object

content 内の各 include 記法マッチ文字列 → 行番号のマップを構築する 1 始まりの行範囲 [start_line, end_line] の行配列を返す。 終了行がファイル末尾を超える場合はファイル末尾までにクランプする(開始が有効な限り取り込む)。 開始行がファイル末尾を超える・開始 < 1・逆順(end < start)のように救済不能な場合は nil を返し、 呼び出し側は全文取り込みへフォールバックする(lines は範囲外で nil を返し nil.join で落ちるため、ここで明示的に弾く)。



1220
1221
1222
1223
1224
1225
1226
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 1220

def extract_line_range(lines, start_line, end_line)
  return nil if start_line < 1 || end_line < start_line
  return nil if start_line > lines.size

  effective_end = [end_line, lines.size].min
  lines[(start_line - 1)..(effective_end - 1)]
end

.fancy_block_start?(line) ⇒ Boolean

リストブロックの開始行か(インデント 0-3 のマーカー行)

Returns:

  • (Boolean)


805
806
807
808
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 805

def fancy_block_start?(line)
  marker = parse_list_marker(line)
  !marker.nil? && marker.indent <= 3 && acceptable_list_start?(marker)
end

.fancy_list_class(style, separator) ⇒ Object

出力

    のクラス名(§2.1 対応表のセル値)



1007
1008
1009
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 1007

def fancy_list_class(style, separator)
  "vs-list-#{FANCY_STYLE_CSS.fetch(style)}#{FANCY_SEPARATOR_SUFFIX.fetch(separator)}"
end

.fancy_marker?(marker) ⇒ Boolean

fancy マーカーか(標準=数字+ピリオド/片括弧。ul は fancy でない)

Returns:

  • (Boolean)


792
793
794
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 792

def fancy_marker?(marker)
  marker.list_type == :ol && (marker.separator == :paren2 || marker.style != :decimal)
end

.fancy_marker_literal(marker) ⇒ Object

マーカーの原文表記(警告メッセージの修正例に使う)



946
947
948
949
950
951
952
953
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 946

def fancy_marker_literal(marker)
  case marker.separator
  when :paren2 then "(#{marker.token})"
  when :paren  then "#{marker.token})"
  when :period then "#{marker.token}."
  else '-'
  end
end

.fancy_ol_attributes(entry) ⇒ Object

1 つの

    に付与する属性のリスト。標準様式(style nil)は start 以外無加工。



991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 991

def fancy_ol_attributes(entry)
  style, separator, start = entry.values_at(:style, :separator, :start)
  attrs = []
  if style
    attrs << %(class="vs-fancy-list #{fancy_list_class(style, separator)}")
    type = FANCY_TYPE_ATTR[style]
    attrs << %(type="#{type}") if type
  end
  attrs << %(start="#{start}") if start != 1
  # 括弧付き様式は CSS カウンタで自前描画するため、開始値を counter-reset のインライン指定で
  # 常に与える(start 属性からカウンタ開始値を読む標準手段が無い・§4.1-5)
  attrs << %(style="counter-reset: vs-fancy #{start - 1}") if style && separator != :period
  attrs
end

.format_book_card_inner_html(inner_html) ⇒ Object

book-card の内側を整形



501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 501

def format_book_card_inner_html(inner_html)
  html = inner_html.to_s.strip

  img_match = html.match(/<img[^>]*>/i)
  return inner_html unless img_match

  img_tag = img_match[0].gsub(%r{\s*/?>}i) { '>' }

  if html.sub!(%r{<p>\s*#{Regexp.escape(img_match[0])}\s*</p>}i, '')
    # removed wrapped <p> with img
  else
    html.sub!(img_match[0], '')
  end

  title_match = html.match(%r{<p>\s*<strong>(.*?)</strong>\s*</p>}im)
  return inner_html unless title_match

  title_text = title_match[1].strip
  html.sub!(title_match[0], '')

  description_html = html.strip

  parts = []
  parts << "  #{img_tag}"
  parts << '  <div class="book-info">'
  parts << "    <p class=\"book-title\">#{title_text}</p>"
  parts << '    <div class="book-description">'
  parts << "      #{description_html}"
  parts << '    </div>'
  parts << '  </div>'
  parts.join("\n")
end

.hard_break_line(line) ⇒ Object

非空行の末尾を Markdown のハード改行(半角スペース2つ)へ正規化する。 既存の末尾空白は一度除いてから2つに揃えるため冪等。空行はそのまま返す。



690
691
692
693
694
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 690

def hard_break_line(line)
  return line if line.strip.empty?

  "#{line.chomp.sub(/[ \t]+\z/, '')}  \n"
end

.image_talk_avatar_tag(src) ⇒ Object

著者が用意した画像の 。 src には asset_prefix を前置する。アバターは images/ 配下の著者資産であり、 ワークスペース内に実体を持つ生成物(数式 SVG・showcase・mermaid)とは違って pdf/ ミラーへコピーされないため、prefix 無しの相対では PDF 側で解決できない (ImagePathNormalizer が通常画像へ付けるのと同じ規約)。EPUB では EpubBuilder が prefix を剥がし、localize_assets! が images/ を同梱するのでそのまま解決する。



301
302
303
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 301

def image_talk_avatar_tag(src)
  "<img class=\"talk-icon\" src=\"#{escape_html(src)}\" alt=\"\" />"
end

.inject_talk_prefix(body, prefix) ⇒ Object

レンダリング済みの発話 HTML の先頭

の内側へ名前+区切りを差し込む。

で始まらない出力(リスト等)になった場合は段落を前置してフォールバックする。



266
267
268
269
270
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 266

def inject_talk_prefix(body, prefix)
  return body.sub(/\A<p([^>]*)>/) { "<p#{::Regexp.last_match(1)}>#{prefix}" } if body.match?(/\A<p[ >]/)

  "<p>#{prefix}</p>\n#{body}"
end

.normalize_book_card_md(md_text) ⇒ Object

book-card 内のMarkdownを事前整形



464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 464

def normalize_book_card_md(md_text)
  lines = md_text.to_s.split(/\r?\n/, -1)
  out = []
  lines.each_with_index do |line, i|
    next_line = lines[i + 1]
    next_nonblank = next_line && next_line.strip != ''
    is_image = line.match?(/^\s*!\[[^\]]*\]\([^)]+\)\s*$/)
    is_title = line.match?(/^\s*\*\*[^*].*\*\*\s*$/)

    if (is_image || is_title) && next_nonblank
      # 画像・タイトルの後は段落を分ける(独立した <p> にする)
      out << line
      out << ''
    elsif !line.strip.empty? && next_nonblank
      # 説明部の連続行(著者・説明など)は本書全体の hardLineBreaks に合わせて
      # 行ごとに改行させる。Kramdown のソフト改行はそのままだと半角空白へ潰れて
      # 1 行に連結されるため、Markdown のハード改行(末尾2スペース)を補って <br> 化する。
      out << "#{line.rstrip}  "
    else
      out << line
    end
  end
  out.join("\n")
end

.normalize_container_fence_spacing(content) ⇒ Object

:::class コンテナの開始行直後・終了行直前に空行を補い、開始/終了行を独立段落にする。 VFM は hardLineBreaks のため、空行が無いと開始 ":::output" 行を直後の本文行と 1 つの

に結合してしまう。その状態で後処理の置換ルールが ":::class" を

へ 置換すると

が分割され、先頭の本文(クロスリファレンスのキャプション 等)が コンテナ直下に取り残されて wrap されず、見出しが素の強調表示になる不具合が起きる。 開始直後・終了直前に空行を挟むことで、内側の各ブロックが独立段落になり正しく整形される。 コードフェンス内の ::: は対象外。既に空行がある場合は二重に入れない。



1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 1072

def normalize_container_fence_spacing(content)
  lines = content.lines
  code_lines = code_line_numbers(content)
  out = []
  lines.each_with_index do |line, i|
    if code_lines.include?(i + 1)
      out << line
      next
    end

    stripped = line.lstrip
    if stripped.match?(/\A:{3,}\s*\{/) # 開始 :::{.class}
      out << line
      nxt = lines[i + 1]
      out << "\n" if nxt && !nxt.strip.empty?
    elsif line.strip.match?(/\A:{3,}\z/) # 終了 :::
      out << "\n" unless out.empty? || out.last.strip.empty?
      out << line
    else
      out << line
    end
  end
  out.join
end

.normalized_continuation_line(line, stack) ⇒ Object

継続行を現在項目の本文開始位置(マーカー行の字下げ + 4 スペース)へ揃える



965
966
967
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 965

def normalized_continuation_line(line, stack)
  hard_break_line("#{' ' * ((4 * stack.last[:depth]) + 4)}#{line.strip}\n")
end

.normalized_marker_line(marker, stack) ⇒ Object

マーカー行を「4 スペース/レベルの字下げ+連番 1. 2. …(ul は -)」へ正規化する。 開始値はここでは反映せず、patch_fancy_ol_attributes が start 属性で与える(§4.1-3)。 hard_break_line は本書全体の hardLineBreaks: true と改行挙動を揃える措置(定義リストと同じ)。



958
959
960
961
962
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 958

def normalized_marker_line(marker, stack)
  top = stack.last
  head = top[:list_type] == :ul ? '-' : "#{top[:counter]}."
  hard_break_line("#{' ' * (4 * top[:depth])}#{head} #{marker.body}\n")
end

.open_fancy_list!(marker, stack, ol_queue) ⇒ Object

新しいリストを開く(スタックへ push・ol なら属性キューへ登録)



932
933
934
935
936
937
938
939
940
941
942
943
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 932

def open_fancy_list!(marker, stack, ol_queue)
  fancy = fancy_marker?(marker)
  entry = {
    indent: marker.indent, depth: stack.size, list_type: marker.list_type,
    style: fancy ? marker.style : nil,
    separator: fancy ? marker.separator : nil,
    start: marker.value || 1, counter: 1,
    head_literal: fancy_marker_literal(marker), warned: false
  }
  stack.push(entry)
  ol_queue << entry if marker.list_type == :ol
end

.parse_list_marker(line) ⇒ Object

行をリストマーカーとして解析する(マーカーでなければ nil)。 aa. のような複数英字は様式に該当せずマーカーにしない(§2.2-4)。



749
750
751
752
753
754
755
756
757
758
759
760
761
762
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 749

def parse_list_marker(line)
  s = line.chomp
  if (m = s.match(/\A( *)([-*+])([ \t]+)(\S.*)?\z/))
    return FancyMarker.new(indent: m[1].length, list_type: :ul, style: nil, separator: nil,
                           token: nil, value: nil, gap: m[3], body: m[4].to_s)
  end
  if (m = s.match(/\A( *)\(([0-9a-zA-Z]{1,9})\)([ \t]+)(\S.*)?\z/))
    return build_ol_marker(m[1], :paren2, m[2], m[3], m[4])
  end
  if (m = s.match(/\A( *)([0-9a-zA-Z]{1,9})([.)])([ \t]+)(\S.*)?\z/))
    return build_ol_marker(m[1], m[3] == '.' ? :period : :paren, m[2], m[4], m[5])
  end
  nil
end

.parse_talk_options(args, display, source_filename:) ⇒ Array(TalkRegistry::TalkDisplay, Array<String>)

ブロック引数(style=inline name=off .foo)を解析し、talk.yml の表示設定へ重ねる。

Returns:



123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 123

def parse_talk_options(args, display, source_filename:)
  options = display
  extra_classes = []
  avatar_requested = false

  args.to_s.split.each do |token|
    if token.start_with?('.')
      extra_classes << token.delete_prefix('.')
      next
    end

    key, value = token.split('=', 2)
    unless TALK_OPTION_KEYS.include?(key) && value
      warn_talk_unknown_option(token, source_filename)
      next
    end

    avatar_requested = true if key == 'avatar' && TalkRegistry.parse_avatar_mode(value) != :off
    options = apply_talk_option(options, key, value, source_filename:)
  end

  warn_talk_option_conflicts(options, avatar_requested, source_filename)
  [options, extra_classes]
end

.parse_talk_utterances(body, source_filename:) ⇒ Object

ブロック本文を発話(lines)の配列へ。空行は無視、字下げ行は継続、 「キー:」に一致しない非字下げ行は 🔴(話者キーなし)。



189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 189

def parse_talk_utterances(body, source_filename:)
  utterances = []
  body.each_line do |raw|
    line = raw.chomp
    next if line.strip.empty?

    if line.start_with?(' ', "\t")
      if utterances.empty?
        warn_talk_orphan_continuation(line, source_filename)
      else
        utterances.last[:lines] << line.strip
      end
    elsif (m = line.match(TALK_SPEAKER_PATTERN))
      first = m[2].to_s.strip
      utterances << { key: m[1], lines: first.empty? ? [] : [first] }
    else
      warn_talk_missing_speaker(line, source_filename)
    end
  end
  utterances
end

.patch_fancy_ol_attributes(html, ol_queue, source_filename:) ⇒ Object

生成 HTML の

    (出現順=ソース順)へ様式クラス・type・start・counter-reset を注入する。 キューと
      出現数が食い違ったら nil を返し、呼び出し側は原文のまま埋め戻す (防御・ビルドは止めない)。



972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 972

def patch_fancy_ol_attributes(html, ol_queue, source_filename:)
  occurrences = html.scan('<ol>').size
  unless occurrences == ol_queue.size
    where = source_filename ? "#{source_filename}: " : ''
    Common.log_warn(
      "#{where}fancy list の <ol> 対応付けに失敗したため、このブロックは変換せずそのまま出力します" \
      "(検出 #{ol_queue.size} 件 / 生成 #{occurrences} 件)"
    )
    return nil
  end

  queue = ol_queue.dup
  html.gsub('<ol>') do
    attrs = fancy_ol_attributes(queue.shift)
    attrs.empty? ? '<ol>' : "<ol #{attrs.join(' ')}>"
  end
end

.process_code_include(content, source_filename: nil, source_path: nil) ⇒ Object

include:path[:start-end] を検出し、codes/ または絶対パスから読込。 マークダウンのコードブロックおよびインラインコード内に記述された include 記法は記法の説明例であるためスキップする。

Parameters:

  • content (String)

    処理対象の Markdown テキスト

  • source_filename (String, nil) (defaults to: nil)

    エラーメッセージに表示するソースファイル名

  • source_path (String, nil) (defaults to: nil)

    元ファイルのパス(行番号補正用)



1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 1108

def process_code_include(content, source_filename: nil, source_path: nil)
  matches_found = 0
  line_number_map = build_line_number_map(content)
  occurrence = Hash.new(0)
  skippable_lines = lines_inside_code_blocks(content)
  inline_code_lines = lines_with_inline_code_include(content)
  source_line_map = build_source_include_line_map(source_path)

  content.gsub!(/```include:([^:`\s]+)(?::(\d+)-(\d+))?\s*```/) do |match|
    # 同一 include 文字列が複数回現れても行番号を取り違えないよう、出現順に
    # 対応する行番号を消費する(例: 記法説明フェンス内の例文と、:::{.output} 等に
    # 置いた本物の include が同一文字列のとき、前者の行番号で後者まで誤スキップするのを防ぐ)。
    # ln=前処理後コンテンツ内の行(スキップ判定用)、report_ln=著者向けに原稿ファイルの行を優先。
    idx = occurrence[match]
    occurrence[match] += 1
    ln = line_number_map[match][idx]
    report_ln = source_line_map[match][idx] || ln

    # コードブロック内の include 記法はスキップ(記法説明用の例文)
    next match if ln && skippable_lines.include?(ln)

    # インラインコード内の include 記法はスキップ
    # `` ```include:file.rb``` `` のようにバッククォートで囲まれた場合
    next match if ln && inline_code_lines.include?(ln)

    matches_found += 1
    original_path = ::Regexp.last_match(1)
    start_line = ::Regexp.last_match(2)&.to_i
    end_line = ::Regexp.last_match(3)&.to_i

    Common.log_action("マッチ発見: #{match.strip}")
    Common.log_info("元のパス: #{original_path}")

    file_path = if original_path.start_with?('/')
                  original_path
                else
                  File.join(Common::CODES_DIR, original_path)
                end
    Common.log_info("解決されたパス: #{file_path}")

    if File.exist?(file_path)
      source_content = File.read(file_path)
      lines = source_content.lines

      # 範囲 include が成立したときだけ #L<開始>-L<実効終了> マーカーをフェンス情報文字列へ
      # 付与し、post_process(prism_lines)が元ファイルの行番号で採番できるようにする。
      # 全文取り込み(範囲指定なし・救済不能フォールバック)ではマーカーを付けない。
      line_marker = ''
      code_content =
        if start_line && end_line
          selected_lines = extract_line_range(lines, start_line, end_line)
          warn_prefix = "#{source_filename || '(不明)'}#{report_ln ? ":#{report_ln}" : ''} - "
          if selected_lines.nil?
            # 開始行がファイル末尾を超える/逆順など、救済不能な不正指定。全文取り込みへフォールバック。
            Common.log_warn(
              "#{warn_prefix}include の範囲指定が不正です" \
              "#{original_path}#{lines.size} 行、指定は #{start_line}-#{end_line})。全文を取り込みます。"
            )
            "#{source_content}\n"
          else
            # 開始は有効で終了だけがファイル末尾を超える場合は、末尾までにクランプして取り込む。
            if end_line > lines.size
              Common.log_warn(
                "#{warn_prefix}include の終了行がファイル末尾を超えています" \
                "#{original_path}#{lines.size} 行、指定は #{start_line}-#{end_line})。" \
                "#{start_line}-#{lines.size} 行を取り込みます。"
              )
            end
            # 終了行は extract_line_range のクランプと同値の実効値を書く(GitHub パーマリンク風)。
            line_marker = "#L#{start_line}-L#{[end_line, lines.size].min}"
            selected_lines.join
          end
        else
          "#{source_content}\n"
        end

      language = MarkdownUtils.detect_language(file_path)
      replacement = "```#{language}:#{original_path}#{line_marker}\n#{code_content}```"
      Common.log_success("置換完了: #{original_path} (#{language})")

      replacement
    else
      code_name = File.basename(original_path)
      # 元ファイルの行番号があればそちらを使う
      source_ln = report_ln
      if source_filename && source_ln
        Common.log_error(
          "#{source_filename}:#{source_ln} - ソースコード '#{code_name}' が見つかりません",
          detail: "コードの場所: #{file_path}"
        )
        LinkImageValidator.record_code_include_error(source_filename, source_ln, code_name)
      else
        Common.log_error(
          "ソースコード '#{code_name}' が見つかりません",
          detail: "コードの場所: #{file_path}"
        )
        LinkImageValidator.record_code_include_error(source_filename || '(不明)', 0, code_name)
      end
      match
    end
  end

  Common.log_info("#{matches_found}個のinclude記法を処理") if matches_found.positive?
  content
end

.render_definition_list(block) ⇒ Object

定義リストブロックを Kramdown で

化する。 Kramdown はエントリ間に空行を要求するため、用語行の前へ空行を補ってから渡す。 また本書全体の hardLineBreaks: true(改行=
)に揃えるため、説明(dd)内の 各行末へ Markdown のハード改行(半角スペース2つ)を補い、複数行の説明が
で改行されるようにする(空行=エントリ区切りはそのまま残す)。



677
678
679
680
681
682
683
684
685
686
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 677

def render_definition_list(block)
  normalized = []
  block.lines.each do |line|
    normalized << "\n" if definition_term_line?(line) && !normalized.empty? && !normalized.last.strip.empty?
    normalized << hard_break_line(line)
  end
  html = MarkdownUtils.render_markdown_to_html(normalized.join).strip
  html = html.sub(/\A<dl>/, '<dl class="def-list">')
  "#{html}\n\n"
end

.render_talk_block(body, registry:, source_filename:, spans:, options:, extra_classes:) ⇒ Object

ブロック 1 つ分を

へ。発話が 1 つも無ければ空文字。 区切り文字は data-talk-sep として容器に持たせる——Kindle 劣化(EpubBuilder)が talk.yml を読み直さずに inline 形式へ組み替えられるようにするため(§2.3)。



175
176
177
178
179
180
181
182
183
184
185
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 175

def render_talk_block(body, registry:, source_filename:, spans:, options:, extra_classes:)
  utterances = parse_talk_utterances(body, source_filename:)
  return '' if utterances.empty?

  items = utterances.map do
    render_talk_item(it, registry:, source_filename:, spans:, options:)
  end
  classes = ['talk', "talk-style-#{options.style}", *extra_classes].join(' ')
  "\n\n<div class=\"#{classes}\" data-talk-sep=\"#{escape_html(options.separator)}\">\n" \
    "#{items.join("\n")}\n</div>\n\n"
end

.render_talk_body(lines, spans:) ⇒ Object

発話本文(継続行を含む)を単一段落 HTML へ。継続行は本書の hardLineBreaks に揃え
化する。 インラインコード等は退避済みのため、Kramdown へ渡す前に spans でこの発話ぶんを復元する。



327
328
329
330
331
332
333
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 327

def render_talk_body(lines, spans:)
  return '<p></p>' if lines.empty?

  md = MarkdownUtils.restore_code_spans(lines.join("  \n"), spans)
  html = MarkdownUtils.render_markdown_to_html(md).strip
  html.empty? ? '<p></p>' : html
end

.render_talk_item(utterance, registry:, source_filename:, spans:, options:) ⇒ Object

発話 1 つを .talk-item へ。未定義キーは 🔴 で追記例を提示し、 表示名=キー・色なしのフォールバックで組む(ビルドは止めない)。



213
214
215
216
217
218
219
220
221
222
223
224
225
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 213

def render_talk_item(utterance, registry:, source_filename:, spans:, options:)
  key = utterance[:key]
  char = registry[key]
  warn_talk_undefined_key(key, source_filename) unless char

  name = char&.name || key
  body = render_talk_body(utterance[:lines], spans:)
  if options.style == :inline
    render_talk_item_inline(key, name, body, options:)
  else
    render_talk_item_chat(key, char, name, body, options:, source_filename:)
  end
end

.render_talk_item_chat(key, char, name, body, options:, source_filename:) ⇒ Object

chat: 話者の見せ方はアバターの有無で変わる。 アバターあり … 吹き出しの外側に話者列を立て、アバターの下へ名前を積む アバターなし … 名前だけの列は幅を持て余すため、吹き出し上部のラベルにする name=off でも .talk-name は出力し talk-name-off で隠す——Kindle 劣化が この要素を段落内へ移して話者名を見せるため、DOM から消してはならない(§2.3)。



232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 232

def render_talk_item_chat(key, char, name, body, options:, source_filename:)
  side = char&.side || 'left'
  avatar = talk_avatar_tag(char, options:, source_filename:)
  name_span = "<span class=\"#{talk_name_classes(options)}\">#{escape_html(name)}</span>"

  parts = ["<div class=\"talk-item talk-#{side} talk-c-#{key}\">"]
  if avatar
    parts << '<div class="talk-speaker">'
    parts << avatar
    parts << name_span
    parts << '</div>'
  end
  parts << '<div class="talk-body">'
  parts << name_span unless avatar
  parts << body
  parts << '</div>'
  parts << '</div>'
  parts.join("\n")
end

.render_talk_item_inline(key, name, body, options:) ⇒ Object

inline: 「名前+区切り+発話」を 1 段落に収める。アバターと左右振り分けは行わない。



253
254
255
256
257
258
259
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 253

def render_talk_item_inline(key, name, body, options:)
  klass = talk_name_classes(options)
  prefix = "<span class=\"#{klass}\">#{escape_html(name)}</span>" \
           "<span class=\"talk-sep#{options.name ? '' : ' talk-name-off'}\">" \
           "#{escape_html(options.separator)}</span>"
  "<div class=\"talk-item talk-c-#{key}\">\n#{inject_talk_prefix(body, prefix)}\n</div>"
end

.resolve_talk_avatar(avatar) ⇒ Object

avatar 指定から images/characters/ 配下の実在ファイル名を解決する(無ければ nil)。 著者が書いた拡張子をまず尊重し、次に .webp/.png/.jpg/.jpeg の変種を探す。 ImagePathNormalizer.image_exists_for? は「正規化済み .webp パス」を前提に .webp だけを 剥がすため、avatar: a.png を渡すと a.png.webp 等を探して実在する a.png を取りこぼし、 逆に avatar: a.webp(実体は a.png)では実在しない .webp を src に出してしまう。 ここは著者の生の指定を受けるので、常に「実在するファイル」を src にする。



318
319
320
321
322
323
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 318

def resolve_talk_avatar(avatar)
  base = avatar.sub(/\.[A-Za-z0-9]+\z/, '')
  [avatar, *TALK_AVATAR_EXTENSIONS.map { "#{base}#{it}" }]
    .uniq
    .find { File.exist?(File.join(Common::IMAGES_DIR, 'characters', it)) }
end

.roman_to_int(token) ⇒ Object

ローマ数字文字列を整数へ(iv → 4)。減算則: 次の桁より小さい桁は引く。



786
787
788
789
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 786

def roman_to_int(token)
  digits = token.downcase.chars.map { ROMAN_DIGIT_VALUES.fetch(it) }
  digits.each_cons(2).sum { |a, b| a < b ? -a : a } + digits.last
end

.same_list_style?(marker, top) ⇒ Boolean

マーカーが現在のリストの様式(先頭項目で確定)と一致するか

Returns:

  • (Boolean)


921
922
923
924
925
926
927
928
929
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 921

def same_list_style?(marker, top)
  return true if marker.list_type == :ul

  if fancy_marker?(marker)
    marker.style == top[:style] && marker.separator == top[:separator]
  else
    top[:style].nil?
  end
end

.talk_avatar_tag(char, options:, source_filename:) ⇒ Object

アバター (吹き出し外側の丸抜き)。出せない場合は nil を返す(ビルドは止めない)。 解決順は talk-auto-avatar-spec.md §1.3:

1. avatar=off なら何もしない
2. 話者が画像を指定 → その画像。見つからなければ 🟡(auto なら自動生成へ落とす)
3. 話者が `auto`、または avatar=auto かつ話者の指定なし → 簡易アバターを自動生成


280
281
282
283
284
285
286
287
288
289
290
291
292
293
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 280

def talk_avatar_tag(char, options:, source_filename:)
  return nil if char.nil? || options.avatar == :off

  spec = char.avatar.to_s.strip
  auto = options.avatar == :auto
  return auto_talk_avatar_tag(char, source_filename:) if spec.casecmp?(TalkRegistry::AVATAR_AUTO)
  return auto ? auto_talk_avatar_tag(char, source_filename:) : nil if spec.empty?

  resolved = resolve_talk_avatar(spec)
  return image_talk_avatar_tag("#{Common.asset_prefix}images/characters/#{resolved}") if resolved

  warn_talk_missing_avatar(char.key, spec, source_filename)
  auto ? auto_talk_avatar_tag(char, source_filename:) : nil
end

.talk_name_classes(options) ⇒ Object

話者名 span のクラス(name=off は隠す。Kindle 劣化時にこのクラスを外す)。



262
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 262

def talk_name_classes(options) = options.name ? 'talk-name' : 'talk-name talk-name-off'

.terminal_fence_length(body) ⇒ Object

フェンスの長さ。本文に ~~~ が現れても閉じ位置がずれないよう、 本文中の最長のチルダ連続 + 1 と 3 の大きい方を採る。



73
74
75
76
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 73

def terminal_fence_length(body)
  longest = body.to_s.scan(/~+/).map(&:length).max.to_i
  [3, longest + 1].max
end

Markdown内のリンク記法を脚注化



419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 419

def transform_links_to_footnotes(md_text)
  original = md_text.to_s
  text, code_spans = MarkdownUtils.extract_code_spans(original)

  max_n = 0
  text.scan(/\[\^url(\d+)\]:/).each do |m|
    n = m[0].to_i
    max_n = n if n > max_n
  end

  url_id = {}
  replacements = []

  replaced = text.gsub(/(?<!!)\[(.+?)\]\((https?:[^\s)]+)\)(?!\s*\[^url\d+\])/) do |_match|
    label = ::Regexp.last_match(1)
    url   = ::Regexp.last_match(2)
    id = (url_id[url] ||= begin
      max_n += 1
      "url#{max_n}"
    end)
    replacements << [id, url]
    "[#{label}](#{url}) [^#{id}]"
  end

  existing_defs = {}
  text.scan(/\[\^(url\d+)\]:\s*(\S+)/) { |id, u| existing_defs[id] = u }

  new_defs = url_id.filter_map do |u, id|
    next nil if existing_defs.key?(id)

    "[^#{id}]: #{u}"
  end

  result = if new_defs.empty?
             replaced
           elsif replaced.strip.end_with?("\n")
             "#{replaced}\n#{new_defs.join("\n")}\n"
           else
             "#{replaced}\n\n#{new_defs.join("\n")}\n"
           end

  MarkdownUtils.restore_code_spans(result, code_spans)
end

.unescape_fancy_marker(line) ⇒ Object

行頭マーカーの \ エスケープ((1) 等)を解除して地の文にする(§2.2)。 fancy マーカーの形をした行だけが対象(標準リスト・ul の \ は VFM に委ねる)。



1027
1028
1029
1030
1031
1032
1033
1034
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 1027

def unescape_fancy_marker(line)
  m = line.match(/\A( *)\\(\S.*)\z/m)
  return line unless m

  candidate = "#{m[1]}#{m[2]}"
  marker = parse_list_marker(candidate)
  marker && fancy_marker?(marker) ? candidate : line
end

.warn_fancy_style_change(top, line, source_filename) ⇒ Object

同一リスト途中の様式変更を警告する(リストごとに 1 回・先頭様式で続行)



1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 1012

def warn_fancy_style_change(top, line, source_filename)
  return if top[:warned]

  top[:warned] = true
  where = source_filename ? "#{source_filename}: " : ''
  Common.log_warn(
    "#{where}リストの途中でマーカー様式が変わっています。" \
    "先頭項目の様式(#{top[:head_literal]})のまま採番を続行します",
    detail: "該当行: #{line.strip} → 修正例: マーカーを #{top[:head_literal]} と同じ様式に揃えるか、" \
            '空行を挟んで別のリストに分けてください'
  )
end

.warn_talk_inline_avatar(source_filename) ⇒ Object



365
366
367
368
369
370
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 365

def warn_talk_inline_avatar(source_filename)
  Common.log_warn(
    "#{source_filename}: style=inline ではアバターを表示しません(avatar=on の指定は無視されます)",
    detail: '→ アバターを見せたい場合は style=chat にしてください'
  )
end

.warn_talk_inline_nameless(source_filename) ⇒ Object

inline かつ name=off はアバター・左右・話者色のいずれも出ないため、 発話が地の文と区別できず誰の台詞か判別不能になる(§1.6)。



374
375
376
377
378
379
380
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 374

def warn_talk_inline_nameless(source_filename)
  Common.log_warn(
    "#{source_filename}: style=inline かつ name=off では話者を判別できません",
    detail: "→ name=on に戻すか、style=chat にしてください\n" \
            '→ inline は話者名だけが手がかりのため、名前を消すと地の文と区別が付きません'
  )
end

.warn_talk_missing_avatar(key, avatar, source_filename) ⇒ Object



410
411
412
413
414
415
416
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 410

def warn_talk_missing_avatar(key, avatar, source_filename)
  Common.log_warn(
    "#{source_filename}: 話者 '#{key}' のアバター images/characters/#{avatar} が見つかりません。" \
    'アバターなしで表示します',
    detail: '→ images/characters/ に画像を置くか、talk.yml の avatar 指定を見直してください'
  )
end

.warn_talk_missing_file(source_filename) ⇒ Object

--- 会話文の警告(§1.2・warning-messages-actionable: 修正例+出現位置を添える)---



342
343
344
345
346
347
348
349
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 342

def warn_talk_missing_file(source_filename)
  Common.log_error(
    "#{source_filename}: 会話文(:::{.talk})が使われていますが config/talk.yml がありません",
    detail: "→ config/talk.yml を作成し、話者を定義してください(例):\n" \
            "    sensei: indigo        # 色だけの簡易形\n" \
            '    hanako: teal'
  )
end

.warn_talk_missing_speaker(line, source_filename) ⇒ Object



382
383
384
385
386
387
388
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 382

def warn_talk_missing_speaker(line, source_filename)
  Common.log_error(
    %(#{source_filename}: 会話文に話者キーがありません: "#{line}"),
    detail: "→ 各行は「キー: 発話」で書きます(例: sensei: こんにちは)\n" \
            '→ 前の発話の続きなら行頭を空白で字下げしてください'
  )
end

.warn_talk_option_conflicts(options, avatar_requested, source_filename) ⇒ Object

組み合わせとして無意味・危険な指定を知らせる(§1.6)。



165
166
167
168
169
170
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 165

def warn_talk_option_conflicts(options, avatar_requested, source_filename)
  return unless options.style == :inline

  warn_talk_inline_avatar(source_filename) if avatar_requested
  warn_talk_inline_nameless(source_filename) unless options.name
end

.warn_talk_orphan_continuation(line, source_filename) ⇒ Object



390
391
392
393
394
395
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 390

def warn_talk_orphan_continuation(line, source_filename)
  Common.log_error(
    %(#{source_filename}: 継続行の前に発話がありません: "#{line.strip}"),
    detail: '→ 継続行(字下げ行)の前に「キー: 発話」の行が必要です'
  )
end

.warn_talk_undefined_key(key, source_filename) ⇒ Object



397
398
399
400
401
402
403
404
405
406
407
408
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 397

def warn_talk_undefined_key(key, source_filename)
  Common.log_error(
    "#{source_filename}: 未定義の話者キー '#{key}' が使われています",
    detail: "→ config/talk.yml に追記してください:\n" \
            "    #{key}: indigo        # 色だけの簡易形\n" \
            "  または詳細形:\n" \
            "    #{key}:\n" \
            "      name: 表示名\n" \
            "      color: indigo\n" \
            "      avatar: #{key}.webp"
  )
end

.warn_talk_unknown_option(token, source_filename) ⇒ Object



351
352
353
354
355
356
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 351

def warn_talk_unknown_option(token, source_filename)
  Common.log_warn(
    %(#{source_filename}: 会話文の不明な指定です: "#{token}"),
    detail: "→ 指定できるのは #{TALK_OPTION_KEYS.join(' / ')} です(例: :::{.talk style=inline name=off})"
  )
end

.warn_talk_unknown_style(value, source_filename) ⇒ Object



358
359
360
361
362
363
# File 'lib/vivlio_starter/cli/pre_process/markdown_transformer.rb', line 358

def warn_talk_unknown_style(value, source_filename)
  Common.log_warn(
    "#{source_filename}: 会話文の style '#{value}' は不明な表示形式です。既定のまま続行します",
    detail: "→ 指定できるのは #{TalkRegistry::STYLES.join(' / ')} です"
  )
end