Module: QueryStream::TemplateCompiler

Defined in:
lib/query_stream/template_compiler.rb

Overview

テンプレートコンパイラモジュール

Constant Summary collapse

VARIABLE_PATTERN =

変数展開パターン: = key または =key(行内に出現、ドット記法対応)(?<![=\w]) で 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
# File 'lib/query_stream/template_compiler.rb', line 131

def classify_lines(lines)
  lines.map do |line|
    stripped = line.strip
    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)


182
183
184
185
186
187
# File 'lib/query_stream/template_compiler.rb', line 182

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]



165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/query_stream/template_compiler.rb', line 165

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



230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
# File 'lib/query_stream/template_compiler.rb', line 230

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



212
213
214
215
216
217
218
219
220
221
222
223
224
# File 'lib/query_stream/template_compiler.rb', line 212

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



262
263
264
265
266
267
268
269
270
271
272
273
274
275
# File 'lib/query_stream/template_compiler.rb', line 262

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)


156
# File 'lib/query_stream/template_compiler.rb', line 156

def fence_close?(line) = line.strip.match?(FENCE_CLOSE_PATTERN)

.fence_open?(line) ⇒ Boolean

VFM フェンス開始行かを判定する

Parameters:

  • line (String)

    テンプレート行

Returns:

  • (Boolean)


151
# File 'lib/query_stream/template_compiler.rb', line 151

def fence_open?(line) = line.strip.match?(FENCE_OPEN_PATTERN)

.image_has_variable?(line) ⇒ Boolean

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

Parameters:

  • line (String)

    テンプレート行

Returns:

  • (Boolean)


192
193
194
195
196
197
# File 'lib/query_stream/template_compiler.rb', line 192

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)


202
203
204
205
# File 'lib/query_stream/template_compiler.rb', line 202

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)



281
282
283
284
285
286
287
288
289
# File 'lib/query_stream/template_compiler.rb', line 281

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)

    エラー報告用行番号



296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
# File 'lib/query_stream/template_compiler.rb', line 296

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

  lines.each do |line|
    # = 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