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 =

画像記法内の変数展開パターン: ![](key) / ![](= key) 名前付きキャプチャで 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

Class Method Details

.classify_lines(lines) ⇒ Array<Hash>

テンプレート行を分類する

Parameters:

  • lines (Array<String>)

    テンプレートの行リスト

Returns:

  • (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)が含まれるかを判定する

Parameters:

  • line (String)

    テンプレート行

Returns:

  • (Boolean)


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 範囲に取り込むフェンス開始→(空行)→動的行 のパターンや動的行→(空行)→フェンス終了 のパターンも考慮する

Parameters:

  • parts (Array<Hash>)

    分類済み行リスト

  • first_dyn (Integer)

    最初の動的行インデックス

  • last_dyn (Integer)

    最後の動的行インデックス

Returns:

  • (Array(Integer, Integer))

    拡張後の [first_dyn, last_dyn]



178
179
180
181
182
183
184
185
186
187
188
189
190
# File 'lib/query_stream/template_compiler.rb', line 178

def expand_fence_range(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?

画像記法内の変数を展開する

Parameters:

  • line (String)

    テンプレート行

  • record (Hash)

    データレコード

Returns:

  • (String, nil)

    展開後の行、またはスキップ時 nil



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 expand_images(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
        "![#{alt}](#{value})#{attr}"
      end
    end
  end

  skip ? nil : result
end

.expand_line(line, record) ⇒ String?

テンプレート行をレコードデータで展開するnil/空文字のキーがあれば行ごとスキップ(nil を返す)

Parameters:

  • line (String)

    テンプレート行

  • record (Hash)

    データレコード

Returns:

  • (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 expand_line(line, record)
  result = line.dup

  # 画像記法の展開(先に処理)
  result = expand_images(result, record)
  return nil unless result

  # = key パターンの展開
  result = expand_variables(result, record)
  return nil unless result

  result
end

.expand_variables(line, record) ⇒ String?

key パターンの変数を展開する(ドット記法対応)

Parameters:

  • line (String)

    テンプレート行

  • record (Hash)

    データレコード

Returns:

  • (String, nil)

    展開後の行、またはスキップ時 nil



275
276
277
278
279
280
281
282
283
284
285
286
287
288
# File 'lib/query_stream/template_compiler.rb', line 275

def expand_variables(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 フェンス終了行かを判定する

Parameters:

  • line (String)

    テンプレート行

Returns:

  • (Boolean)


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 フェンス開始行かを判定する

Parameters:

  • line (String)

    テンプレート行

Returns:

  • (Boolean)


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

画像記法内に変数参照があるかを判定する

Parameters:

  • line (String)

    テンプレート行

Returns:

  • (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

画像パスがリテラル(拡張子あり)かを判定する

Parameters:

  • src (String)

    画像パス文字列

Returns:

  • (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

テンプレートにレコード群を流し込んでテキストを生成する

Parameters:

  • template (String)

    テンプレートの内容

  • records (Array<Hash>)

    データレコード群

  • source_filename (String, nil) (defaults to: nil)

    エラー報告用ファイル名

  • line_number (Integer, nil) (defaults to: nil)

    エラー報告用行番号

Returns:

  • (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 = expand_fence_range(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: }
        expanded = expand_line(content, record)
        output << expanded if expanded
      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?

ドット記法のキーパスをたどってネストされた値を取得する

Parameters:

  • record (Hash)

    データレコード

  • key_path (String)

    キーパス(例: “author.name”)

Returns:

  • (Object, nil)



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

テンプレート内のキーがデータに存在するかを検証する

Parameters:

  • lines (Array<String>)

    テンプレートの行リスト

  • sample_record (Hash)

    サンプルレコード(最初の1件)

  • source_filename (String, nil) (defaults to: nil)

    エラー報告用ファイル名

  • line_number (Integer, nil) (defaults to: nil)

    エラー報告用行番号



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