Module: QueryStream::TemplateCompiler
- Defined in:
- lib/query_stream/template_compiler.rb
Overview
テンプレートコンパイラモジュール
Constant Summary collapse
- VARIABLE_PATTERN =
変数展開パターン: = key または =key(行内に出現、ドット記法対応)(?<!) で width=40 や align=right 内の = を除外
/(?<![=\w])=\s*([a-zA-Z_][a-zA-Z0-9_.]*)/- IMAGE_VAR_PATTERN =
画像記法内の変数展開パターン:  /  名前付きキャプチャで gsub ブロック内の $N 上書き問題を回避
/!\[(?<alt>[^\]]*)\]\((?:=\s*)?(?<src>[^)]+)\)(?<attr>\{[^}]*\})?/- IMAGE_EXTENSIONS =
画像の拡張子(リテラル判定用)
%w[png jpg jpeg webp gif svg].freeze
- FENCE_OPEN_PATTERN =
VFM フェンス開始行: :::class-name 形式
/\A:::\s*\{\.[\w-].*\}\s*\z/- FENCE_CLOSE_PATTERN =
- VFM フェンス終了行: 単独の :
/\A:::\s*\z/
Class Method Summary collapse
-
.classify_lines(lines) ⇒ Array<Hash>
テンプレート行を分類する.
-
.contains_variable?(line) ⇒ Boolean
行に変数参照(= key)が含まれるかを判定する.
-
.expand_fence_range(parts, first_dyn, last_dyn) ⇒ Array(Integer, Integer)
動的行の前後にあるフェンス行を repeating 範囲に取り込む フェンス開始→(空行)→動的行 のパターンや 動的行→(空行)→フェンス終了 のパターンも考慮する.
-
.expand_images(line, record) ⇒ String?
画像記法内の変数を展開する.
-
.expand_line(line, record) ⇒ String?
テンプレート行をレコードデータで展開する nil/空文字のキーがあれば行ごとスキップ(nil を返す).
-
.expand_variables(line, record) ⇒ String?
key パターンの変数を展開する(ドット記法対応).
-
.fence_close?(line) ⇒ Boolean
VFM フェンス終了行かを判定する.
-
.fence_open?(line) ⇒ Boolean
VFM フェンス開始行かを判定する.
-
.image_has_variable?(line) ⇒ Boolean
画像記法内に変数参照があるかを判定する.
-
.literal_image?(src) ⇒ Boolean
画像パスがリテラル(拡張子あり)かを判定する.
-
.render(template, records, source_filename: nil, line_number: nil) ⇒ String
テンプレートにレコード群を流し込んでテキストを生成する.
-
.resolve_nested_value(record, key_path) ⇒ Object?
ドット記法のキーパスをたどってネストされた値を取得する.
-
.validate_template_keys!(lines, sample_record, source_filename: nil, line_number: nil) ⇒ Object
テンプレート内のキーがデータに存在するかを検証する.
Class Method Details
.classify_lines(lines) ⇒ Array<Hash>
テンプレート行を分類する
131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 |
# File 'lib/query_stream/template_compiler.rb', line 131 def classify_lines(lines) in_code_block = false lines.map do |line| stripped = line.strip # コードブロックのフェンス行(``` で始まる行)を検出してトグル if stripped.start_with?('```') in_code_block = !in_code_block next { type: :static, content: line } end # コードブロック内は変数展開せず static として扱う if in_code_block next { type: :static, content: line } end if stripped.empty? { type: :blank } elsif stripped.match?(FENCE_OPEN_PATTERN) { type: :fence_open, content: line } elsif stripped.match?(FENCE_CLOSE_PATTERN) { type: :fence_close, content: line } elsif contains_variable?(line) { type: :dynamic, content: line } else { type: :static, content: line } end end end |
.contains_variable?(line) ⇒ Boolean
行に変数参照(= key)が含まれるかを判定する
195 196 197 198 199 200 |
# File 'lib/query_stream/template_compiler.rb', line 195 def contains_variable?(line) return true if line.match?(VARIABLE_PATTERN) return true if line.match?(IMAGE_VAR_PATTERN) && image_has_variable?(line) false end |
.expand_fence_range(parts, first_dyn, last_dyn) ⇒ Array(Integer, Integer)
動的行の前後にあるフェンス行を repeating 範囲に取り込むフェンス開始→(空行)→動的行 のパターンや動的行→(空行)→フェンス終了 のパターンも考慮する
178 179 180 181 182 183 184 185 186 187 188 189 190 |
# File 'lib/query_stream/template_compiler.rb', line 178 def (parts, first_dyn, last_dyn) # 前方拡張: フェンス開始行を取り込む(間に空行があっても可) idx = first_dyn - 1 idx -= 1 if idx >= 0 && parts[idx][:type] == :blank first_dyn = idx if idx >= 0 && parts[idx][:type] == :fence_open # 後方拡張: フェンス終了行を取り込む(間に空行があっても可) idx = last_dyn + 1 idx += 1 if idx < parts.size && parts[idx][:type] == :blank last_dyn = idx if idx < parts.size && parts[idx][:type] == :fence_close [first_dyn, last_dyn] end |
.expand_images(line, record) ⇒ String?
画像記法内の変数を展開する
243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 |
# File 'lib/query_stream/template_compiler.rb', line 243 def (line, record) result = line.dup skip = false result.gsub!(IMAGE_VAR_PATTERN) do |match| md = Regexp.last_match alt = md[:alt] src = md[:src].sub(/\A=\s*/, '').strip attr = md[:attr] || '' if literal_image?(src) # 拡張子ありはリテラルとしてそのまま出力 match else # 変数として展開(ドット記法対応) value = resolve_nested_value(record, src) if value.nil? || value.to_s.strip.empty? skip = true match # gsub のブロックからは文字列を返す必要がある else "#{attr}" end end end skip ? nil : result end |
.expand_line(line, record) ⇒ String?
テンプレート行をレコードデータで展開するnil/空文字のキーがあれば行ごとスキップ(nil を返す)
225 226 227 228 229 230 231 232 233 234 235 236 237 |
# File 'lib/query_stream/template_compiler.rb', line 225 def (line, record) result = line.dup # 画像記法の展開(先に処理) result = (result, record) return nil unless result # = key パターンの展開 result = (result, record) return nil unless result result end |
.expand_variables(line, record) ⇒ String?
key パターンの変数を展開する(ドット記法対応)
275 276 277 278 279 280 281 282 283 284 285 286 287 288 |
# File 'lib/query_stream/template_compiler.rb', line 275 def (line, record) result = line.dup result.gsub!(VARIABLE_PATTERN) do |_match| key_path = $1 value = resolve_nested_value(record, key_path) if value.nil? || value.to_s.strip.empty? return nil # 行ごとスキップ end value.to_s end result end |
.fence_close?(line) ⇒ Boolean
VFM フェンス終了行かを判定する
169 |
# File 'lib/query_stream/template_compiler.rb', line 169 def fence_close?(line) = line.strip.match?(FENCE_CLOSE_PATTERN) |
.fence_open?(line) ⇒ Boolean
VFM フェンス開始行かを判定する
164 |
# File 'lib/query_stream/template_compiler.rb', line 164 def fence_open?(line) = line.strip.match?(FENCE_OPEN_PATTERN) |
.image_has_variable?(line) ⇒ Boolean
画像記法内に変数参照があるかを判定する
205 206 207 208 209 210 |
# File 'lib/query_stream/template_compiler.rb', line 205 def image_has_variable?(line) line.scan(IMAGE_VAR_PATTERN).any? do |(_, src, _)| src = src.sub(/\A=\s*/, '').strip !literal_image?(src) end end |
.literal_image?(src) ⇒ Boolean
画像パスがリテラル(拡張子あり)かを判定する
215 216 217 218 |
# File 'lib/query_stream/template_compiler.rb', line 215 def literal_image?(src) ext = File.extname(src).delete_prefix('.').downcase IMAGE_EXTENSIONS.include?(ext) end |
.render(template, records, source_filename: nil, line_number: nil) ⇒ String
テンプレートにレコード群を流し込んでテキストを生成する
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 |
# File 'lib/query_stream/template_compiler.rb', line 55 def render(template, records, source_filename: nil, line_number: nil) lines = template.lines validate_template_keys!(lines, records.first, source_filename:, line_number:) if records.any? parts = classify_lines(lines) return '' if records.empty? # 最初と最後の動的行を特定し、leading / repeating / trailing に三分割する first_dyn = parts.index { it[:type] == :dynamic } # 動的行がないテンプレートは全行をそのまま一度だけ出力 unless first_dyn return parts.map { |p| p[:type] == :blank ? "\n" : p[:content] }.compact.join end last_dyn = parts.rindex { it[:type] == :dynamic } # --- Phase: フェンス行をrepeating範囲に取り込む --- # 動的行の直前にフェンス開始行がある場合、repeating 範囲を前方に拡張する # 動的行の直後にフェンス終了行がある場合、repeating 範囲を後方に拡張する # これにより :::{.book-card} 〜 ::: が各レコードごとに反復される first_dyn, last_dyn = (parts, first_dyn, last_dyn) leading = parts[0...first_dyn] repeating = parts[first_dyn..last_dyn] trailing = last_dyn + 1 < parts.size ? parts[(last_dyn + 1)..] : [] # テーブルテンプレート判定(区切り行 |---|…| の有無、またはデータ行が | で始まる) table_mode = leading.any? { it[:type] == :static && it[:content]&.match?(/^\|[-|:\s]+\|/) } || repeating.any? { it[:type] == :dynamic && it[:content]&.match?(/^\s*\|/) } output = [] # leading: 静的ヘッダーを一度だけ出力 leading.each do |part| case part in { type: :static, content: } then output << content in { type: :blank } then output << "\n" end end # repeating: レコードごとに動的行を展開 records.each_with_index do |record, idx| output << "\n" if idx > 0 && !table_mode repeating.each do |part| case part in { type: :dynamic, content: } = (content, record) output << if in { type: :fence_open, content: } then output << content in { type: :fence_close, content: } then output << content in { type: :static, content: } output << content in { type: :blank } output << "\n" unless table_mode end end end # trailing: 末尾の静的行を一度だけ出力 trailing.each do |part| case part in { type: :static, content: } then output << content in { type: :blank } then output << "\n" end end output.join end |
.resolve_nested_value(record, key_path) ⇒ Object?
ドット記法のキーパスをたどってネストされた値を取得する
294 295 296 297 298 299 300 301 302 |
# File 'lib/query_stream/template_compiler.rb', line 294 def resolve_nested_value(record, key_path) keys = key_path.split('.') value = record keys.each do |k| return nil unless value.is_a?(Hash) value = value[k.to_sym] || value[k.to_s] end value end |
.validate_template_keys!(lines, sample_record, source_filename: nil, line_number: nil) ⇒ Object
テンプレート内のキーがデータに存在するかを検証する
309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 |
# File 'lib/query_stream/template_compiler.rb', line 309 def validate_template_keys!(lines, sample_record, source_filename: nil, line_number: nil) return unless sample_record location = source_filename ? "#{source_filename}:#{line_number}" : '' available_keys = sample_record.keys in_code_block = false lines.each do |line| stripped = line.strip # コードブロック内はキー検証をスキップ if stripped.start_with?('```') in_code_block = !in_code_block next end next if in_code_block # = key パターン(ドット記法の場合はルートキーのみ検証) line.scan(VARIABLE_PATTERN).each do |(key_path)| root_key = key_path.split('.').first.to_sym unless available_keys.include?(root_key) msg = "テンプレートに存在しないキーが記述されています: #{key_path}" QueryStream.logger.error("#{msg}(#{location})") QueryStream.logger.error(" 利用可能なキー: #{available_keys.join(', ')}") raise UnknownKeyError, msg end end # 画像記法内の変数 line.scan(IMAGE_VAR_PATTERN).each do |(_, src, _)| src = src.sub(/\A=\s*/, '').strip next if literal_image?(src) root_key = src.split('.').first.to_sym unless available_keys.include?(root_key) msg = "テンプレートに存在しないキーが記述されています: #{src}" QueryStream.logger.error("#{msg}(#{location})") QueryStream.logger.error(" 利用可能なキー: #{available_keys.join(', ')}") raise UnknownKeyError, msg end end end end |