Module: VivlioStarter::CLI::PreProcessCommands::FrontmatterGenerator

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

Overview

フロントマター生成・CSS 更新を担当するモジュール

Constant Summary collapse

ALLOWED_COLORS =
%w[yellow orange red magenta purple indigo navy blue cyan teal green lime].freeze

Class Method Summary collapse

Class Method Details

.apply_frontmatter(content, file_type, chapter_num, path: nil) ⇒ Object

既存フロントマターを併合するか新規生成して Markdown に反映する

Parameters:

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

    警告メッセージに含めるファイルパス(省略可)



173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'lib/vivlio_starter/cli/pre_process/frontmatter_generator.rb', line 173

def apply_frontmatter(content, file_type, chapter_num, path: nil)
  text = content.dup
  if text.start_with?('---')
    # フロントマター終了の `---` を正確に検出する。
    # `/\A---\n(.*?)\n---\n/m` の最短マッチはコードブロック内の `---` で
    # 誤って止まるため、行単位で走査して最初の `---` 単独行を終端とする。
    frontmatter_end = find_frontmatter_end(text)
    unless frontmatter_end
      warn_unclosed_frontmatter(path)
      return text
    end

    frontmatter_yaml = text[4...frontmatter_end].chomp
    body_after = text[(frontmatter_end + 4)..]
    begin
      existing_frontmatter = YAML.safe_load(frontmatter_yaml, permitted_classes: [], aliases: true) || {}
      merged_frontmatter = generate_frontmatter(file_type, chapter_num, existing_frontmatter)
      new_frontmatter_yaml = YAML.dump(merged_frontmatter)
      Common.log_success('フロントマター併合')
      Common.log_success('フロントマター更新')
      "#{new_frontmatter_yaml}---\n#{body_after}"
    rescue StandardError => e
      report_frontmatter_error(e, frontmatter_yaml)
      text
    end
  else
    new_frontmatter = generate_frontmatter(file_type, chapter_num)
    new_frontmatter_yaml = YAML.dump(new_frontmatter)
    Common.log_success('フロントマター追加')
    "#{new_frontmatter_yaml}---\n\n#{text}"
  end
end

.build_base_frontmatter(chapter_css) ⇒ Object

フロントマターのベース構造を構築 link 順は [theme.css, 種別.css, book-settings.css, custom.css]。 book-settings.css(生成物・.cache/vs/ 配下)を 種別.css の後段へ置くことで book.yml 由来の設定値が既存テーマ CSS にカスケードで勝つ(P3)。 href はワークスペース内 HTML からの相対(Common.asset_prefix 前置・P4 §3.3)。



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/vivlio_starter/cli/pre_process/frontmatter_generator.rb', line 150

def build_base_frontmatter(chapter_css)
  prefix = Common.asset_prefix
  hrefs = [
    "#{prefix}stylesheets/theme.css",
    "#{prefix}stylesheets/#{chapter_css}",
    "#{prefix}#{BookSettingsCss.output_path}",
    "#{prefix}stylesheets/custom.css"
  ]
  lang = (Common::CONFIG.book.language || 'ja').to_s.strip
  lang = 'ja' if lang.empty?

  {
    'link' => hrefs.map { { 'rel' => 'stylesheet', 'href' => it } },
    'lang' => lang,
    # 原稿中の改行は常に改行として組む(日本語執筆で直感的なハード改行)。
    # 章ごとに変えたい場合は、その章のフロントマターに hardLineBreaks: false と
    # 書けばよい——merge_frontmatter は vfm について既存の記述を優先する。
    'vfm' => { 'hardLineBreaks' => true }
  }
end

.extract_error_position(error) ⇒ Object

エラーから行・列番号を抽出



258
259
260
261
262
263
264
265
266
267
# File 'lib/vivlio_starter/cli/pre_process/frontmatter_generator.rb', line 258

def extract_error_position(error)
  line = error.respond_to?(:line) && error.line ? error.line.to_i : error.message[/line (\d+)/i, 1]&.to_i
  column = if error.respond_to?(:column) && error.column
             error.column.to_i
           else
             error.message[/column (\d+)/i,
                           1]&.to_i
           end
  [line, column]
end

古いテーマリンクを除外



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

def filter_legacy_theme_links(links)
  links.reject do |lnk|
    href = (lnk && lnk['href']).to_s
    href.match(%r{stylesheets/(theme-(yellow|blue|red|accent)\.css|theme-overrides\.css)})
  end
end

.find_frontmatter_end(text) ⇒ Integer?

フロントマター終了位置を行単位で検出する。 ファイル先頭の ---\n の直後から走査し、 コードフェンス(```)に入る前に --- 単独行が現れた位置を返す。

Parameters:

  • text (String)

    ファイル全体のテキスト

Returns:

  • (Integer, nil)

    終了 ---\n の開始インデックス、見つからなければ nil



211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
# File 'lib/vivlio_starter/cli/pre_process/frontmatter_generator.rb', line 211

def find_frontmatter_end(text)
  # 先頭の `---\n` をスキップした本文領域について、コードフェンス内の `---` を
  # 終端と誤認しないよう、フェンス行の判定は Masking(唯一の実装)へ委ねる。
  # 可変長フェンス・入れ子・~~~ に一貫して追従する。
  code_lines = frontmatter_body_code_lines(text[4..].to_s)

  pos = 4
  lineno = 0
  while pos < text.length
    line_end = text.index("\n", pos)
    break unless line_end

    lineno += 1
    line = text[pos...line_end]
    return pos if line == '---' && !code_lines.include?(lineno)

    pos = line_end + 1
  end
  nil
end

.frontmatter_body_code_lines(body) ⇒ Object

先頭 ---\n を除いた本文領域について、コードとみなす行番号(1 始まり)を返す。



233
234
235
236
237
238
# File 'lib/vivlio_starter/cli/pre_process/frontmatter_generator.rb', line 233

def frontmatter_body_code_lines(body)
  prose = Set.new
  Masking.each_prose_line(body) { |_line, lineno| prose << lineno }
  total = body.each_line.count
  (1..total).reject { prose.include?(it) }.to_set
end

.generate_frontmatter(file_type, _chapter_num = nil, existing_frontmatter = {}) ⇒ Object

フロントマターを生成 CSS 設定の反映は BookSettingsCss 生成器('prepare theme images' ステップ)が 一括で担うため、章ごとの CSS 書換はここでは行わない(P3・調査報告 §7.3-3)。



130
131
132
133
134
# File 'lib/vivlio_starter/cli/pre_process/frontmatter_generator.rb', line 130

def generate_frontmatter(file_type, _chapter_num = nil, existing_frontmatter = {})
  chapter_css = resolve_chapter_css(file_type, existing_frontmatter)
  new_frontmatter = build_base_frontmatter(chapter_css)
  merge_frontmatter(existing_frontmatter, new_frontmatter)
end

.log_detailed_snippet(fm_lines, line, column) ⇒ Object

詳細なスニペットをログ出力



292
293
294
295
296
297
298
299
300
301
302
# File 'lib/vivlio_starter/cli/pre_process/frontmatter_generator.rb', line 292

def log_detailed_snippet(fm_lines, line, column)
  idx = line - 1
  start_idx = [idx - 2, 0].max
  finish_idx = [idx + 2, fm_lines.length - 1].min
  snippet = fm_lines[start_idx..finish_idx].each_with_index.map do |l, i|
    "#{start_idx + i + 1}: #{l.chomp}"
  end.join("\n")
  err_line_text = fm_lines[idx].to_s.chomp
  caret_line = column&.positive? ? "#{' ' * (column - 1)}^" : ''
  Common.log_info("問題のフロントマター(抜粋):\n---\n#{snippet}\n---\n該当行:\n#{err_line_text}\n#{caret_line}")
end

.log_frontmatter_error_message(line, column) ⇒ Object

エラーメッセージをログ出力



270
271
272
273
274
275
276
277
# File 'lib/vivlio_starter/cli/pre_process/frontmatter_generator.rb', line 270

def log_frontmatter_error_message(line, column)
  if line&.positive?
    col_str = column&.positive? ? column : '?'
    Common.log_warn("フロントマター(--- ~ ---)の記述に誤りがあります(位置: 行#{line}#{col_str})。内容を見直してください。")
  else
    Common.log_warn('フロントマター(--- ~ ---)の記述に誤りがあります。内容を見直してください。')
  end
end

.log_frontmatter_snippet(frontmatter_yaml, line, column) ⇒ Object

フロントマターの該当箇所をログ出力



280
281
282
283
284
285
286
287
288
289
# File 'lib/vivlio_starter/cli/pre_process/frontmatter_generator.rb', line 280

def log_frontmatter_snippet(frontmatter_yaml, line, column)
  fm_lines = frontmatter_yaml.to_s.lines
  if line&.positive? && line <= fm_lines.length
    log_detailed_snippet(fm_lines, line, column)
  else
    Common.log_info("問題のフロントマター(抜粋):\n---\n#{frontmatter_yaml}\n---")
  end
rescue StandardError
  Common.log_info("問題のフロントマター(抜粋):\n---\n#{frontmatter_yaml}\n---")
end

.merge_frontmatter(existing_frontmatter, new_frontmatter) ⇒ Object

フロントマターをマージ link は重複を除外して結合、vfm は著者の章別指定(既存側)を book.yml 由来の値より優先



363
364
365
366
367
368
369
370
371
372
373
374
375
376
# File 'lib/vivlio_starter/cli/pre_process/frontmatter_generator.rb', line 363

def merge_frontmatter(existing_frontmatter, new_frontmatter)
  merged = existing_frontmatter.dup
  merged.delete('stylesheet')
  merged['link'] = filter_legacy_theme_links(merged['link']) if merged['link'].is_a?(Array)

  new_frontmatter.each do |key, value|
    merged[key] = case [key, merged[key]]
                  in ['link', Array => existing] then merge_links(existing, value)
                  in ['vfm', Hash => existing] then value.merge(existing)
                  else value
                  end
  end
  merged
end

リンク配列をマージ(重複を除外)



398
399
400
401
402
# File 'lib/vivlio_starter/cli/pre_process/frontmatter_generator.rb', line 398

def merge_links(existing_links, new_links)
  existing_links + new_links.reject do |new_link|
    existing_links.any? { |existing| existing['href'] == new_link['href'] }
  end
end

.normalize_char_count(value, label:) ⇒ Integer?

「1 行の文字数」を正の整数へ正規化する。未設定・空欄・解釈不能は nil (呼び出し側が theme.css の既定を生かす。P3 の「書かない条件では宣言しない」)。

Returns:

  • (Integer, nil)


112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/vivlio_starter/cli/pre_process/frontmatter_generator.rb', line 112

def normalize_char_count(value, label:)
  return nil if value.nil?

  v = value.to_s.strip
  return nil if v.empty?

  count = v.to_i
  if count.positive? && v =~ /\A\d+\z/
    count
  else
    Common.log_warn("#{label} は 1 行の文字数(正の整数)で指定してください (#{v})。既定値を使います。")
    nil
  end
end

.normalize_css_length(value, label:, default: nil, fallback_unit: 'mm') ⇒ Object

CSS長さ値を正規化



342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
# File 'lib/vivlio_starter/cli/pre_process/frontmatter_generator.rb', line 342

def normalize_css_length(value, label:, default: nil, fallback_unit: 'mm')
  return default if value.nil?

  v = value.to_s.strip
  return default if v.empty?

  if v =~ /^-?\d+(?:\.\d+)?$/
    "#{v}#{fallback_unit}"
  elsif v =~ /^-?\d+(?:\.\d+)?(?:mm|cm|in|px|pt|pc|em|rem|vw|vh|vmin|vmax|%)$/i
    v
  else
    Common.log_warn("#{label} の形式が想定外です (#{v})。#{fallback_unit}単位として扱います。")
    numeric = v.gsub(/[^0-9.-]/, '')
    return default if numeric.empty?

    "#{numeric}#{fallback_unit}"
  end
end

.parse_frontispiece_config(frontispiece_raw) ⇒ Object

frontispiece 設定を解析(Data オブジェクト前提) 寸法は mm ではなく「1 行の文字数」で受ける(heading-metrics-spec §1-2)—— 文字サイズが判型に追従するため、文字数なら A4 でも A5 でも同じ意味になる。



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/vivlio_starter/cli/pre_process/frontmatter_generator.rb', line 77

def parse_frontispiece_config(frontispiece_raw)
  # String の場合はそのまま image 名として使用
  source = frontispiece_raw.is_a?(String) ? frontispiece_raw : frontispiece_raw&.dig(:image)
  path = ThemeImageResolver.resolve_frontispiece_path(source, allow_generation: true)
  cfg = frontispiece_raw.is_a?(String) ? nil : frontispiece_raw

  {
    path: path,
    edge_inset: normalize_css_length(cfg&.dig(:edge_inset),
                                     label: 'theme.frontispiece.edge_inset', default: '0mm'),
    heading_offset: normalize_css_length(cfg&.dig(:heading_offset),
                                         label: 'theme.frontispiece.heading_offset'),
    heading_chars: normalize_char_count(cfg&.dig(:heading_chars),
                                        label: 'theme.frontispiece.heading_chars'),
    lead_chars: normalize_char_count(cfg&.dig(:lead_chars), label: 'theme.frontispiece.lead_chars')
  }
end

.parse_ornament_config(ornament_raw) ⇒ Object

ornament 設定を解析。frontispiece と同じく「スカラー=画像名だけの短縮形」と 「マッピング=画像名+寸法」の両方を受ける(heading-metrics-spec §1-2)。 resolve_ornament_path は raw.to_s を見るため、マッピングは先に image を取り出す。



98
99
100
101
102
103
104
105
106
107
# File 'lib/vivlio_starter/cli/pre_process/frontmatter_generator.rb', line 98

def parse_ornament_config(ornament_raw)
  scalar = ornament_raw.nil? || ornament_raw.is_a?(String) || ornament_raw.is_a?(Symbol)
  source = scalar ? ornament_raw : ornament_raw&.dig(:image)
  cfg = scalar ? nil : ornament_raw

  {
    path: ThemeImageResolver.resolve_ornament_path(source, allow_generation: true),
    heading_chars: normalize_char_count(cfg&.dig(:heading_chars), label: 'theme.ornament.heading_chars')
  }
end

.parse_theme_color(raw_color) ⇒ Object

テーマカラーをパース



305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
# File 'lib/vivlio_starter/cli/pre_process/frontmatter_generator.rb', line 305

def parse_theme_color(raw_color)
  s = raw_color.to_s.strip
  t = s.downcase

  hex_ok      = t.match(/^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i)
  hex_bare_ok = t.match(/^(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i)
  hex_0x_ok   = t.match(/^0x(?:[0-9a-f]{6}|[0-9a-f]{8})$/i)

  if t.empty?
    ['yellow', 'var(--accent-yellow)']
  elsif hex_ok
    [t, t]
  elsif hex_bare_ok
    normalized = "##{t}"
    [normalized, normalized]
  elsif hex_0x_ok
    normalized = "##{t.sub(/^0x/i, '')}"
    [normalized, normalized]
  elsif ALLOWED_COLORS.include?(t)
    [t, "var(--accent-#{t})"]
  else
    # 無効な色名は既定色(yellow)へフォールバックしてビルドを継続する。
    # 著者向けの警告は ThemeValidator が Step 2 で一度だけ表示する
    # (ここは章ごとに呼ばれるため、ログを出すと重複してしまう)。
    ['yellow', 'var(--accent-yellow)']
  end
end

.parse_theme_settings(cfg = nil) ⇒ Object

テーマ設定を解析して構造化データを返す



44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/vivlio_starter/cli/pre_process/frontmatter_generator.rb', line 44

def parse_theme_settings(cfg = nil)
  cfg ||= Common::CONFIG
  theme_cfg = cfg.theme

  theme_color = theme_cfg.color
  theme_style_raw = theme_cfg.style
  frontispiece_raw = theme_cfg.frontispiece
  ornament_raw = theme_cfg.ornament

  theme_name, theme_accent_value = parse_theme_color(theme_color)
  theme_style = parse_theme_style(theme_style_raw)

  frontispiece_cfg = parse_frontispiece_config(frontispiece_raw)
  ornament_cfg = parse_ornament_config(ornament_raw)

  {
    theme_name: theme_name,
    theme_accent_value: theme_accent_value,
    theme_style: theme_style,
    theme_cfg: theme_cfg,
    frontispiece_path: frontispiece_cfg[:path],
    edge_inset_value: frontispiece_cfg[:edge_inset],
    heading_offset_value: frontispiece_cfg[:heading_offset],
    heading_chars_value: frontispiece_cfg[:heading_chars],
    lead_chars_value: frontispiece_cfg[:lead_chars],
    ornament_path: ornament_cfg[:path],
    ornament_heading_chars_value: ornament_cfg[:heading_chars]
  }
end

.parse_theme_style(raw_style) ⇒ Object

テーマスタイルをパース



334
335
336
337
338
339
# File 'lib/vivlio_starter/cli/pre_process/frontmatter_generator.rb', line 334

def parse_theme_style(raw_style)
  s = (raw_style || 'image').to_s.strip.downcase
  %w[simple image].include?(s) ? s : 'image'
rescue StandardError
  'image'
end

.report_frontmatter_error(error, frontmatter_yaml) ⇒ Object

フロントマター解析時のエラー内容を詳細ログへ出力する



251
252
253
254
255
# File 'lib/vivlio_starter/cli/pre_process/frontmatter_generator.rb', line 251

def report_frontmatter_error(error, frontmatter_yaml)
  line, column = extract_error_position(error)
  log_frontmatter_error_message(line, column)
  log_frontmatter_snippet(frontmatter_yaml, line, column)
end

.resolve_chapter_css(file_type, existing_frontmatter) ⇒ Object

ファイルタイプに応じたCSS名を解決



137
138
139
140
141
142
143
# File 'lib/vivlio_starter/cli/pre_process/frontmatter_generator.rb', line 137

def resolve_chapter_css(file_type, existing_frontmatter)
  return existing_frontmatter['stylesheet'] if existing_frontmatter['stylesheet']
  return 'chapter.css' if file_type == 'chapter'
  return 'part-title.css' if file_type == 'part_title'

  "#{file_type}.css"
end

.safe_config_hash(obj) ⇒ Object



378
379
380
381
382
383
384
385
386
387
# File 'lib/vivlio_starter/cli/pre_process/frontmatter_generator.rb', line 378

def safe_config_hash(obj)
  case obj
  when Hash
    obj.dup
  when nil
    {}
  else
    obj.respond_to?(:to_h) ? obj.to_h : {}
  end
end

.warn_unclosed_frontmatter(path) ⇒ Object

フロントマター開始の --- に対応する閉じ --- が見つからない場合に警告を出す。 著者が誤って --- を書き忘れた/閉じ忘れたケースを検知し、 ビルド結果が意図せず本文扱いになる前に気付かせる。

Parameters:

  • path (String, nil)

    ファイルパス(警告メッセージ用)



244
245
246
247
248
# File 'lib/vivlio_starter/cli/pre_process/frontmatter_generator.rb', line 244

def warn_unclosed_frontmatter(path)
  location = path || '(unknown file)'
  warn "[frontmatter] 警告: #{location} のフロントマター開始 `---` に対応する閉じ `---` が" \
       'コードフェンス外に見つかりません。フロントマターは適用されず、本文として扱われます。'
end