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
-
.check_shorthand_overlap(basenames) ⇒ Array<String>
ショートハンドと basename の重複をチェック.
-
.expand_item(item) ⇒ Array<String>
アイテム(文字列)を basename 配列に展開.
-
.expand_shorthand(str) ⇒ Array<String>
ショートハンドを展開して basename 配列を返す.
-
.extract_chapter_number(basename) ⇒ Integer?
basename から章番号を抽出.
-
.find_basenames_by_number(num) ⇒ Array<String>
章番号に対応する basename を contents/ から検索.
-
.flatten_section(items) ⇒ Array<String>
セクションの内容をフラットな basename 配列に展開.
-
.load_all_basenames ⇒ Array<String>
catalog.yml を読み込み、フラットな basename 配列を返す.
-
.load_catalog ⇒ Hash
catalog.yml を読み込み、YAML として返す.
-
.load_existing_basenames ⇒ Array<String>
catalog.yml を読み込み、存在するファイルのみをフィルタした basename 配列を返す.
-
.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
ショートハンド(番号・範囲指定)かどうか判定.
-
.special_page?(basename) ⇒ Boolean
特殊ページ(システム生成)かどうかを判定する _titlepage, _colophon 等の固定ページに加え、_partN も対象.
-
.validate_catalog!(catalog) ⇒ Object
catalog のバリデーション.
-
.validate_no_duplicates!(basenames) ⇒ Object
章番号の重複チェック.
Class Method Details
.check_shorthand_overlap(basenames) ⇒ 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 配列に展開
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 (item) normalized = item.to_s.strip # .md 拡張子を除去 normalized = normalized.sub(/\.md\z/, '') # ショートハンド判定 if shorthand?(normalized) (normalized) else [normalized] end end |
.expand_shorthand(str) ⇒ Array<String>
ショートハンドを展開して basename 配列を返す
198 199 200 201 |
# File 'lib/vivlio/starter/cli/build/catalog_loader.rb', line 198 def (str) numbers = parse_shorthand_to_numbers(str) numbers.flat_map { |num| find_basenames_by_number(num) } end |
.extract_chapter_number(basename) ⇒ Integer?
basename から章番号を抽出
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/ から検索
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 配列に展開
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((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 配列を返す
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_catalog ⇒ 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` を
発生させ、ユーザー向けの明示的なメッセージに変換する。
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.}" 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 配列を返す
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_titles ⇒ Array<Hash>
catalog.yml から部タイトル情報を抽出するCHAPTERS 配列内の Hash キーを部タイトルとして認識し、出現順に番号を付与する
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>
ショートハンド文字列を章番号配列に変換
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
章番号からセクションを決定
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
ショートハンド(番号・範囲指定)かどうか判定
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 も対象
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 のバリデーション
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
章番号の重複チェック
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 |