Module: VivlioStarter::CLI::Build::CatalogLoader

Defined in:
lib/vivlio_starter/cli/build/catalog_loader.rb

Overview


CatalogLoader: catalog.yml からの章構成読み込み

config/catalog.yml を読み込み、フラットな章リストを返す。 PREFACE / CHAPTERS / APPENDICES / POSTFACE のセクション、 部タイトルによるグルーピング、ショートハンド(21-25 等)に対応。

Defined Under Namespace

Classes: CatalogEntry

Constant Summary collapse

CATALOG_FILE =
'config/catalog.yml'
PREFACE_RANGE =

章番号レンジ定数(新仕様)

(0..0)
MAIN_RANGE =
(1..89)
APPX_RANGE =
(90..98)
POSTFACE_RANGE =
(99..99)
SPECIAL_PAGES =

特殊ページの内部 basename

%w[_titlepage _legalpage _colophon _indexpage _glossarypage].freeze
SECTION_KEYS =

セクションキー

%w[PREFACE CHAPTERS APPENDICES POSTFACE].freeze

Class Method Summary collapse

Class Method Details

.collect_labeled(items, label:, section:, contents_dir:) ⇒ Array<CatalogEntry>

セクション内を再帰走査し、Hash キー(部タイトル)を label として伝播させつつ ショートハンド(21-25 等)を展開する。

Returns:



116
117
118
119
120
121
122
123
124
125
# File 'lib/vivlio_starter/cli/build/catalog_loader.rb', line 116

def collect_labeled(items, label:, section:, contents_dir:)
  case items
  in nil then []
  in String | Integer
    expand_item(items, contents_dir:).map { CatalogEntry.new(basename: it, label:, section:) }
  in Array then items.flat_map { collect_labeled(it, label:, section:, contents_dir:) }
  in Hash  then items.flat_map { |k, v| collect_labeled(v, label: k.to_s, section:, contents_dir:) }
  else []
  end
end

.expand_item(item, contents_dir: Common::CONTENTS_DIR) ⇒ Array<String>

アイテム(文字列)を basename 配列に展開

Parameters:

  • item (String)

    basename またはショートハンド

  • contents_dir (String) (defaults to: Common::CONTENTS_DIR)

    ショートハンド展開時の glob 基点

Returns:

  • (Array<String>)

    basename 配列



221
222
223
224
225
226
227
228
229
230
231
232
233
# File 'lib/vivlio_starter/cli/build/catalog_loader.rb', line 221

def expand_item(item, contents_dir: Common::CONTENTS_DIR)
  normalized = item.to_s.strip

  # .md 拡張子を除去
  normalized = normalized.sub(/\.md\z/, '')

  # ショートハンド判定
  if shorthand?(normalized)
    expand_shorthand(normalized, contents_dir:)
  else
    [normalized]
  end
end

.expand_shorthand(str, contents_dir: Common::CONTENTS_DIR) ⇒ Array<String>

ショートハンドを展開して basename 配列を返す

Parameters:

  • str (String)

    "21-25" や "21-25, 38" 形式

  • contents_dir (String) (defaults to: Common::CONTENTS_DIR)

    glob 基点

Returns:

  • (Array<String>)

    basename 配列



247
248
249
250
# File 'lib/vivlio_starter/cli/build/catalog_loader.rb', line 247

def expand_shorthand(str, contents_dir: Common::CONTENTS_DIR)
  numbers = parse_shorthand_to_numbers(str)
  numbers.flat_map { |num| find_basenames_by_number(num, contents_dir:) }
end

.extract_chapter_number(basename) ⇒ Integer?

basename から章番号を抽出

Parameters:

  • basename (String)

Returns:

  • (Integer, nil)


321
322
323
324
# File 'lib/vivlio_starter/cli/build/catalog_loader.rb', line 321

def extract_chapter_number(basename)
  match = basename.to_s.match(/\A(\d{2})/)
  match ? match[1].to_i : nil
end

.find_basenames_by_number(num, contents_dir: Common::CONTENTS_DIR) ⇒ Array<String>

章番号に対応する basename を contents/ から検索 slug 付き(NN-*.md)に加え、番号のみファイル(NN.md)も拾う。番号のみファイルは TokenResolver が従来サポートする章形態であり、これを glob 対象に含めないと bare number の catalog エントリ(- 15)が脱落する(パーサ乖離の原因になっていた)。

Parameters:

  • num (Integer)

    章番号

  • contents_dir (String) (defaults to: Common::CONTENTS_DIR)

    glob 基点

Returns:

  • (Array<String>)

    basename 配列(見つからない場合は空。slug 付きを先に並べる)



282
283
284
285
286
287
288
289
# File 'lib/vivlio_starter/cli/build/catalog_loader.rb', line 282

def find_basenames_by_number(num, contents_dir: Common::CONTENTS_DIR)
  padded = num.to_s.rjust(2, '0')
  slug_files = Dir.glob(File.join(contents_dir, "#{padded}-*.md"))
  numeric_file = File.join(contents_dir, "#{padded}.md")
  slug_files << numeric_file if File.exist?(numeric_file)

  slug_files.map { |f| File.basename(f, '.md') }
end

.flatten_section(items) ⇒ Array<String>

セクションの内容をフラットな basename 配列に展開

Parameters:

  • items (Array)

    セクション内のアイテム

Returns:

  • (Array<String>)

    basename 配列



200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
# File 'lib/vivlio_starter/cli/build/catalog_loader.rb', line 200

def flatten_section(items)
  result = []

  Array(items).each do |item|
    case item
    when String, Integer
      result.concat(expand_item(item))
    when Hash
      item.each_value do |sub_items|
        result.concat(flatten_section(sub_items))
      end
    end
  end

  result
end

.load_all_basenamesArray<String>

catalog.yml を読み込み、フラットな basename 配列を返す

Returns:

  • (Array<String>)

    basename 配列(拡張子なし)



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/vivlio_starter/cli/build/catalog_loader.rb', line 49

def load_all_basenames
  catalog = load_catalog
  validate_catalog!(catalog)

  basenames = []
  SECTION_KEYS.each do |section|
    items = catalog[section]
    next if items.nil? || items.empty?

    basenames.concat(flatten_section(items))
  end

  # 重複除去・ソート
  basenames = basenames.uniq
  validate_no_duplicates!(basenames)

  basenames
end

.load_catalog(catalog_path: CATALOG_FILE) ⇒ Hash

catalog.yml を読み込み、YAML として返す

セキュリティ設計(堅牢性仕様 9-7 対応):

- `safe_load` + `permitted_classes: []` により、
Hash / Array / String / 数値 / Boolean / nil のみを許可する。
Symbol / Time / Date も含まない最も厳しい制限。
- `aliases: true` は DRY な catalog 記述のため許可するが、
Psych 5.x の Billion Laughs 対策により DoS 耐性がある。
- `!ruby/object` など許可されないクラスタグは `Psych::DisallowedClass` を
発生させ、ユーザー向けの明示的なメッセージに変換する。

Parameters:

  • catalog_path (String) (defaults to: CATALOG_FILE)

    catalog.yml のパス(テスト用に注入可能)

Returns:

  • (Hash)

    catalog データ



148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
# File 'lib/vivlio_starter/cli/build/catalog_loader.rb', line 148

def load_catalog(catalog_path: CATALOG_FILE)
  raise StandardError, "catalog.yml が見つかりません: #{catalog_path}" unless File.exist?(catalog_path)

  content = File.read(catalog_path, encoding: 'utf-8')
  catalog = YAML.safe_load(content, permitted_classes: [], aliases: true)

  raise StandardError, 'catalog.yml の形式が不正です(Hash ではありません)' unless catalog.is_a?(Hash)

  catalog
rescue Psych::SyntaxError => e
  raise StandardError, "catalog.yml のパースに失敗しました: #{e.message}"
rescue Psych::DisallowedClass => e
  raise StandardError, <<~MSG.strip
    catalog.yml に許可されていないクラス/タグが含まれています: #{e.message}
    安全性のため、!ruby/object などの Ruby オブジェクト記法や !ruby/symbol は catalog.yml では使用できません。
    標準的な YAML(文字列・数値・配列・ハッシュ・真偽値)のみを記述してください。
  MSG
end

.load_existing_basenamesArray<String>

catalog.yml を読み込み、存在するファイルのみをフィルタした basename 配列を返す

Returns:

  • (Array<String>)

    存在するファイルの basename 配列



70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/vivlio_starter/cli/build/catalog_loader.rb', line 70

def load_existing_basenames
  basenames = load_all_basenames
  existing = []
  missing = []

  basenames.each do |bn|
    path = File.join(Common::CONTENTS_DIR, "#{bn}.md")
    if File.exist?(path)
      existing << bn
    else
      missing << bn
    end
  end

  # 存在しないファイルは警告
  missing.each do |bn|
    Common.log_warn("catalog.yml に記載された章ファイルが存在しません: contents/#{bn}.md")
  end

  existing
end

.load_labeled_entries(catalog_path: CATALOG_FILE, contents_dir: Common::CONTENTS_DIR) ⇒ Array<CatalogEntry>

catalog.yml を解析し、ラベル付きの章一覧を返す(TokenResolver の下層 API)。

ファイル不在は [] を返す(TokenResolver の「カタログなしでも動く」契約を維持するため、 ビルド専用の load_all_basenames と違い raise しない)。空カタログ・重複番号の検証も 行わない(それはビルド時の関心事 = load_all_basenames 側)。 仕様: catalog-parser-unification-spec.md §3.1

Parameters:

  • catalog_path (String) (defaults to: CATALOG_FILE)

    catalog.yml のパス(テスト用に注入可能)

  • contents_dir (String) (defaults to: Common::CONTENTS_DIR)

    ショートハンド展開時の glob 基点

Returns:



102
103
104
105
106
107
108
109
110
111
# File 'lib/vivlio_starter/cli/build/catalog_loader.rb', line 102

def load_labeled_entries(catalog_path: CATALOG_FILE, contents_dir: Common::CONTENTS_DIR)
  return [] unless File.exist?(catalog_path)

  catalog = load_catalog(catalog_path:)
  warn_unknown_sections(catalog)

  SECTION_KEYS.flat_map do |section|
    collect_labeled(catalog[section], label: section, section:, contents_dir:)
  end
end

.load_part_titlesArray<Hash>

catalog.yml から部タイトル情報を抽出する CHAPTERS 配列内の Hash キーを部タイトルとして認識し、出現順に番号を付与する

Returns:

  • (Array<Hash>)

    部情報の配列 各要素: { number:, title:, first_chapter:, chapters: }



295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
# File 'lib/vivlio_starter/cli/build/catalog_loader.rb', line 295

def load_part_titles
  catalog = load_catalog
  items = catalog['CHAPTERS']
  return [] unless items.is_a?(Array)

  part_number = 0
  items.filter_map do |item|
    next unless item.is_a?(Hash)

    item.filter_map do |title, sub_items|
      chapter_basenames = flatten_section(sub_items)
      # 章が0件の部(全コメントアウト等)はスキップ
      next if chapter_basenames.empty?

      part_number += 1
      first_chapter_num = chapter_basenames.first&.then { extract_chapter_number(it) }

      { number: part_number, title: title.to_s,
        first_chapter: first_chapter_num, chapters: chapter_basenames }
    end
  end.flatten
end

.parse_shorthand_to_numbers(str) ⇒ Array<Integer>

ショートハンド文字列を章番号配列に変換

Parameters:

  • str (String)

Returns:

  • (Array<Integer>)


255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
# File 'lib/vivlio_starter/cli/build/catalog_loader.rb', line 255

def parse_shorthand_to_numbers(str)
  parts = str.split(/[,\s]+/).map(&:strip).reject(&:empty?)
  numbers = []

  parts.each do |part|
    if part.match?(/\A\d+-\d+\z/)
      # 範囲指定
      match = part.match(/\A(\d+)-(\d+)\z/)
      start_num = match[1].to_i
      end_num = match[2].to_i
      numbers.concat((start_num..end_num).to_a) if start_num <= end_num
    elsif part.match?(/\A\d+\z/)
      # 単一番号
      numbers << part.to_i
    end
  end

  numbers.uniq.sort
end

.section_for_chapter_number(num) ⇒ String

章番号からセクションを決定

Parameters:

  • num (Integer)

    章番号

Returns:

  • (String)

    セクションキー



37
38
39
40
41
42
43
44
45
# File 'lib/vivlio_starter/cli/build/catalog_loader.rb', line 37

def section_for_chapter_number(num)
  case num
  when PREFACE_RANGE  then 'PREFACE'
  when MAIN_RANGE     then 'CHAPTERS'
  when APPX_RANGE     then 'APPENDICES'
  when POSTFACE_RANGE then 'POSTFACE'
  else 'CHAPTERS' # デフォルト
  end
end

.shorthand?(str) ⇒ Boolean

ショートハンド(番号・範囲指定)かどうか判定

Parameters:

  • str (String)

Returns:

  • (Boolean)


238
239
240
241
# File 'lib/vivlio_starter/cli/build/catalog_loader.rb', line 238

def shorthand?(str)
  # "21" や "21-25" や "21-25, 38" の形式
  str.match?(/\A[\d\s,-]+\z/) && !str.match?(/\A\d+-[a-zA-Z]/)
end

.validate_catalog!(catalog) ⇒ Object

catalog のバリデーション

Raises:

  • (StandardError)


168
169
170
171
172
173
174
# File 'lib/vivlio_starter/cli/build/catalog_loader.rb', line 168

def validate_catalog!(catalog)
  # 全セクションが空の場合はエラー
  total = SECTION_KEYS.sum { |key| Array(catalog[key]).size }
  return unless total.zero?

  raise StandardError, 'catalog.yml にビルド対象の章がありません'
end

.validate_no_duplicates!(basenames) ⇒ Object

章番号の重複チェック

Raises:

  • (StandardError)


177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
# File 'lib/vivlio_starter/cli/build/catalog_loader.rb', line 177

def validate_no_duplicates!(basenames)
  number_to_basenames = Hash.new { |h, k| h[k] = [] }

  basenames.each do |bn|
    num = extract_chapter_number(bn)
    next unless num

    number_to_basenames[num] << bn
  end

  duplicates = number_to_basenames.select { |_num, list| list.size > 1 }
  return if duplicates.empty?

  error_msg = "同一章番号で複数のファイルが存在します:\n"
  duplicates.each do |num, list|
    error_msg += "  章番号 #{num}: #{list.join(', ')}\n"
  end
  raise StandardError, error_msg
end

.warn_unknown_sections(catalog) ⇒ Object

catalog.yml の未知トップレベルセクションを警告する(タイプミス検出)。 有効セクションは catalog_spec が定める 4 種のみ。黙って落とすと調査困難なため知らせる。



129
130
131
132
133
# File 'lib/vivlio_starter/cli/build/catalog_loader.rb', line 129

def warn_unknown_sections(catalog)
  (catalog.keys - SECTION_KEYS).each do |key|
    Common.log_warn("catalog.yml に未知のセクション '#{key}' があります(有効: #{SECTION_KEYS.join(' / ')}")
  end
end