Module: Vivlio::Starter::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 等)に対応。


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

.check_shorthand_overlap(basenames) ⇒ Array<String>

ショートハンドと basename の重複をチェック

Parameters:

  • basenames (Array<String>)

Returns:

  • (Array<String>)

    重複している basename



282
283
284
285
286
287
288
289
290
291
292
293
294
295
# File 'lib/vivlio/starter/cli/build/catalog_loader.rb', line 282

def check_shorthand_overlap(basenames)
  seen = {}
  duplicates = []

  basenames.each do |bn|
    if seen[bn]
      duplicates << bn
    else
      seen[bn] = true
    end
  end

  duplicates
end

.expand_item(item) ⇒ Array<String>

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

Parameters:

  • item (String)

    basename またはショートハンド

Returns:

  • (Array<String>)

    basename 配列



173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/vivlio/starter/cli/build/catalog_loader.rb', line 173

def expand_item(item)
  normalized = item.to_s.strip

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

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

.expand_shorthand(str) ⇒ Array<String>

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

Parameters:

  • str (String)

    “21-25” や “21-25, 38” 形式

Returns:

  • (Array<String>)

    basename 配列



198
199
200
201
# File 'lib/vivlio/starter/cli/build/catalog_loader.rb', line 198

def expand_shorthand(str)
  numbers = parse_shorthand_to_numbers(str)
  numbers.flat_map { |num| find_basenames_by_number(num) }
end

.extract_chapter_number(basename) ⇒ Integer?

basename から章番号を抽出

Parameters:

  • basename (String)

Returns:

  • (Integer, nil)


274
275
276
277
# File 'lib/vivlio/starter/cli/build/catalog_loader.rb', line 274

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) ⇒ Array<String>

章番号に対応する basename を contents/ から検索

Parameters:

  • num (Integer)

    章番号

Returns:

  • (Array<String>)

    basename 配列(見つからない場合は空)



229
230
231
232
233
234
# File 'lib/vivlio/starter/cli/build/catalog_loader.rb', line 229

def find_basenames_by_number(num)
  pattern = File.join(Common::CONTENTS_DIR, "#{num.to_s.rjust(2, '0')}-*.md")
  files = Dir.glob(pattern)

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

.flatten_section(items) ⇒ Array<String>

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

Parameters:

  • items (Array)

    セクション内のアイテム

Returns:

  • (Array<String>)

    basename 配列



153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/vivlio/starter/cli/build/catalog_loader.rb', line 153

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 配列(拡張子なし)



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/vivlio/starter/cli/build/catalog_loader.rb', line 46

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_catalogHash

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` を
  発生させ、ユーザー向けの明示的なメッセージに変換する。

Returns:

  • (Hash)

    catalog データ



101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/vivlio/starter/cli/build/catalog_loader.rb', line 101

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

  content = File.read(CATALOG_FILE, 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 配列



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/vivlio/starter/cli/build/catalog_loader.rb', line 67

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_part_titlesArray<Hash>

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

Returns:

  • (Array<Hash>)

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



240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
# File 'lib/vivlio/starter/cli/build/catalog_loader.rb', line 240

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>)


206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
# File 'lib/vivlio/starter/cli/build/catalog_loader.rb', line 206

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)

    セクションキー



34
35
36
37
38
39
40
41
42
# File 'lib/vivlio/starter/cli/build/catalog_loader.rb', line 34

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)


190
191
192
193
# File 'lib/vivlio/starter/cli/build/catalog_loader.rb', line 190

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

.special_page?(basename) ⇒ Boolean

特殊ページ(システム生成)かどうかを判定する_titlepage, _colophon 等の固定ページに加え、_partN も対象

Parameters:

  • basename (String)

Returns:

  • (Boolean)


267
268
269
# File 'lib/vivlio/starter/cli/build/catalog_loader.rb', line 267

def special_page?(basename)
  SPECIAL_PAGES.include?(basename) || basename.match?(/\A_part\d+\z/)
end

.validate_catalog!(catalog) ⇒ Object

catalog のバリデーション

Raises:

  • (StandardError)


121
122
123
124
125
126
127
# File 'lib/vivlio/starter/cli/build/catalog_loader.rb', line 121

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)


130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/vivlio/starter/cli/build/catalog_loader.rb', line 130

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