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
-
.collect_labeled(items, label:, section:, contents_dir:) ⇒ Array<CatalogEntry>
セクション内を再帰走査し、Hash キー(部タイトル)を label として伝播させつつ ショートハンド(21-25 等)を展開する。.
-
.expand_item(item, contents_dir: Common::CONTENTS_DIR) ⇒ Array<String>
アイテム(文字列)を basename 配列に展開.
-
.expand_shorthand(str, contents_dir: Common::CONTENTS_DIR) ⇒ Array<String>
ショートハンドを展開して basename 配列を返す.
-
.extract_chapter_number(basename) ⇒ Integer?
basename から章番号を抽出.
-
.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)が脱落する(パーサ乖離の原因になっていた)。. -
.flatten_section(items) ⇒ Array<String>
セクションの内容をフラットな basename 配列に展開.
-
.load_all_basenames ⇒ Array<String>
catalog.yml を読み込み、フラットな basename 配列を返す.
-
.load_catalog(catalog_path: CATALOG_FILE) ⇒ Hash
catalog.yml を読み込み、YAML として返す.
-
.load_existing_basenames ⇒ Array<String>
catalog.yml を読み込み、存在するファイルのみをフィルタした basename 配列を返す.
-
.load_labeled_entries(catalog_path: CATALOG_FILE, contents_dir: Common::CONTENTS_DIR) ⇒ Array<CatalogEntry>
catalog.yml を解析し、ラベル付きの章一覧を返す(TokenResolver の下層 API)。.
-
.load_part_titles ⇒ Array<Hash>
catalog.yml から部タイトル情報を抽出する CHAPTERS 配列内の Hash キーを部タイトルとして認識し、出現順に番号を付与する.
-
.parse_shorthand_to_numbers(str) ⇒ Array<Integer>
ショートハンド文字列を章番号配列に変換.
-
.section_for_chapter_number(num) ⇒ String
章番号からセクションを決定.
-
.shorthand?(str) ⇒ Boolean
ショートハンド(番号・範囲指定)かどうか判定.
-
.validate_catalog!(catalog) ⇒ Object
catalog のバリデーション.
-
.validate_no_duplicates!(basenames) ⇒ Object
章番号の重複チェック.
-
.warn_unknown_sections(catalog) ⇒ Object
catalog.yml の未知トップレベルセクションを警告する(タイプミス検出)。 有効セクションは catalog_spec が定める 4 種のみ。黙って落とすと調査困難なため知らせる。.
Class Method Details
.collect_labeled(items, label:, section:, contents_dir:) ⇒ Array<CatalogEntry>
セクション内を再帰走査し、Hash キー(部タイトル)を label として伝播させつつ ショートハンド(21-25 等)を展開する。
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 (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 配列に展開
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 (item, contents_dir: Common::CONTENTS_DIR) normalized = item.to_s.strip # .md 拡張子を除去 normalized = normalized.sub(/\.md\z/, '') # ショートハンド判定 if shorthand?(normalized) (normalized, contents_dir:) else [normalized] end end |
.expand_shorthand(str, contents_dir: Common::CONTENTS_DIR) ⇒ Array<String>
ショートハンドを展開して basename 配列を返す
247 248 249 250 |
# File 'lib/vivlio_starter/cli/build/catalog_loader.rb', line 247 def (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 から章番号を抽出
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)が脱落する(パーサ乖離の原因になっていた)。
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 配列に展開
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((item)) when Hash item.each_value do |sub_items| result.concat(flatten_section(sub_items)) end end end result end |
.load_all_basenames ⇒ Array<String>
catalog.yml を読み込み、フラットな 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` を
発生させ、ユーザー向けの明示的なメッセージに変換する。
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.}" rescue Psych::DisallowedClass => e raise StandardError, <<~MSG.strip catalog.yml に許可されていないクラス/タグが含まれています: #{e.} 安全性のため、!ruby/object などの Ruby オブジェクト記法や !ruby/symbol は catalog.yml では使用できません。 標準的な YAML(文字列・数値・配列・ハッシュ・真偽値)のみを記述してください。 MSG end |
.load_existing_basenames ⇒ Array<String>
catalog.yml を読み込み、存在するファイルのみをフィルタした 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
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_titles ⇒ Array<Hash>
catalog.yml から部タイトル情報を抽出する CHAPTERS 配列内の Hash キーを部タイトルとして認識し、出現順に番号を付与する
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>
ショートハンド文字列を章番号配列に変換
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
章番号からセクションを決定
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
ショートハンド(番号・範囲指定)かどうか判定
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 のバリデーション
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
章番号の重複チェック
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 |