query-stream 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ================================================================
4
+ # File: lib/query_stream/query_stream_parser.rb
5
+ # ================================================================
6
+ # 責務:
7
+ # QueryStream 記法(= books | tags=ruby | -title | 5 | :full)を
8
+ # パースし、構造化されたクエリハッシュを返す。
9
+ #
10
+ # パイプライン:
11
+ # 1. Source - データ名(必須)
12
+ # 2. Filter - 抽出条件(field=value, 比較演算子, 範囲指定)
13
+ # 3. Sort - ソート条件(-field / +field)
14
+ # 4. Limit - 件数制限(正の整数)
15
+ # 5. Style - スタイル名(:stylename または :stylename.ext)
16
+ #
17
+ # トークンの自動判別:
18
+ # 各トークンは形式で一意に判別される(省略・順序入れ替えに対応)
19
+ # ================================================================
20
+
21
+ module QueryStream
22
+ # QueryStream 記法のパーサー
23
+ module QueryStreamParser
24
+ # 主キー候補フィールド(優先順位順)
25
+ PRIMARY_KEY_FIELDS = %i[id no code slug name title].freeze
26
+
27
+ # AND 条件の区切りパターン
28
+ AND_PATTERN = /\s+(?:AND|and|&&)\s+/
29
+
30
+ module_function
31
+
32
+ # QueryStream 記法をパースして構造化ハッシュを返す
33
+ #
34
+ # @param line [String] QueryStream 行(例: "= books | tags=ruby | :full")
35
+ # @return [Hash] パース結果
36
+ # - :source [String] データ名(複数形のまま)
37
+ # - :filters [Array<Hash>] フィルタ条件
38
+ # - :sort [Hash, nil] ソート条件 { field:, direction: }
39
+ # - :limit [Integer, nil] 件数制限
40
+ # - :style [String, nil] スタイル名
41
+ # - :format [String, nil] 出力形式(スタイルに拡張子がある場合)
42
+ # - :single_lookup [Boolean] 主キーによる一件検索か
43
+ def parse(line)
44
+ # "= books | tags=ruby | :full" → ["books", "tags=ruby", ":full"]
45
+ # "=books | :table" も許容
46
+ raw = line.sub(/\A=\s*/, '').strip
47
+ segments = raw.split('|').map { it.strip }
48
+
49
+ source = segments.shift
50
+ return build_result(source:) if segments.empty?
51
+
52
+ # 源泉名が単数形かどうかを判定
53
+ # 単数形の場合、最初の非修飾セグメントは主キー検索として解釈する
54
+ singular_source = (Singularize.call(source) == source)
55
+
56
+ filters = []
57
+ sort = nil
58
+ limit = nil
59
+ style = nil
60
+ format = nil
61
+ single_lookup = false
62
+
63
+ segments.each_with_index do |segment, idx|
64
+ next if segment.empty?
65
+
66
+ classified = classify_tokens(segment, primary_context: singular_source && idx == 0)
67
+ classified.each do |token|
68
+ case token
69
+ in { type: :style, value:, format: fmt }
70
+ style = value
71
+ format = fmt
72
+ in { type: :style, value: }
73
+ style = value
74
+ in { type: :limit, value: }
75
+ limit = value
76
+ in { type: :sort, value: }
77
+ sort = value
78
+ in { type: :filter, value: }
79
+ filters.concat(value)
80
+ in { type: :primary_lookup, value: }
81
+ filters.concat(value)
82
+ single_lookup = true
83
+ end
84
+ end
85
+ end
86
+
87
+ build_result(source:, filters:, sort:, limit:, style:, format:, single_lookup:)
88
+ end
89
+
90
+ # セグメント内のトークンを分類する
91
+ # AND で分割された複合条件、スタイル、ソート、件数を判別
92
+ # @param segment [String] パイプで区切られた1セグメント
93
+ # @param primary_context [Boolean] 主キー検索コンテキスト(単数形源泉の最初のセグメント)
94
+ # @return [Array<Hash>] 分類済みトークン
95
+ def classify_tokens(segment, primary_context: false)
96
+ tokens = []
97
+
98
+ # スタイル(:stylename または :stylename.ext)
99
+ if segment.match?(/\A:[a-zA-Z0-9_.-]+\z/)
100
+ style_str = segment.delete_prefix(':')
101
+ # 拡張子の検出(:table.html → style=table, format=html)
102
+ if style_str.match(/\A([a-zA-Z0-9_-]+)\.([a-zA-Z0-9]+)\z/)
103
+ tokens << { type: :style, value: $1, format: $2 }
104
+ else
105
+ tokens << { type: :style, value: style_str }
106
+ end
107
+ return tokens
108
+ end
109
+
110
+ # ソート(-field / +field)
111
+ if segment.match?(/\A[+-][a-zA-Z_]/)
112
+ tokens << { type: :sort, value: parse_sort(segment) }
113
+ return tokens
114
+ end
115
+
116
+ # フィルタ条件(field=value, 比較演算子, AND/OR 複合条件)
117
+ if segment.match?(/[=!<>]/) || segment.match?(AND_PATTERN)
118
+ tokens << { type: :filter, value: parse_filter_expression(segment) }
119
+ return tokens
120
+ end
121
+
122
+ # 主キー検索コンテキスト(単数形源泉の最初のセグメント)では
123
+ # 数値も主キー検索値として扱う(code=13 のような検索に対応)
124
+ if primary_context
125
+ tokens << { type: :primary_lookup, value: build_primary_lookup(segment) }
126
+ return tokens
127
+ end
128
+
129
+ # 件数(正の整数のみ)
130
+ if segment.match?(/\A\d+\z/)
131
+ tokens << { type: :limit, value: segment.to_i }
132
+ return tokens
133
+ end
134
+
135
+ # 上記いずれにも該当しない場合は主キー検索
136
+ tokens << { type: :primary_lookup, value: build_primary_lookup(segment) }
137
+ tokens
138
+ end
139
+
140
+ # ソート指定をパースする
141
+ # @param token [String] "-title" / "+title" / "title"
142
+ # @return [Hash] { field:, direction: }
143
+ def parse_sort(token)
144
+ case token
145
+ in /\A-(.+)\z/
146
+ { field: $1.to_sym, direction: :desc }
147
+ in /\A\+?(.+)\z/
148
+ { field: $1.to_sym, direction: :asc }
149
+ end
150
+ end
151
+
152
+ # 直前フィールドを引き継いで値のみの条件をパースする
153
+ # @param field [Symbol, String] 直前フィールド
154
+ # @param value_str [String] "ruby" / "ruby, beginner"
155
+ # @return [Array<Hash>] フィルタ条件
156
+ def parse_value_only_condition(field, value_str)
157
+ parse_eq_condition(field, value_str)
158
+ end
159
+
160
+ # AND で接続されたフィルタ式をパースする
161
+ # @param expression [String] "tags=ruby && category=web"
162
+ # @return [Array<Hash>] フィルタ条件のリスト
163
+ def parse_filter_expression(expression)
164
+ # AND で分割
165
+ clauses = expression.split(AND_PATTERN)
166
+ previous_field = nil
167
+ previous_filter = nil
168
+
169
+ clauses.flat_map do |clause|
170
+ clause = clause.strip
171
+ next [] if clause.empty?
172
+
173
+ parsed = parse_single_condition(clause)
174
+
175
+ if parsed.empty? && previous_field
176
+ if previous_filter && previous_filter[:field] == previous_field && previous_filter[:op] == :eq
177
+ additional = parse_values(clause)
178
+ previous_filter[:value] = Array(previous_filter[:value]) + additional
179
+ next []
180
+ else
181
+ parsed = parse_value_only_condition(previous_field, clause)
182
+ end
183
+ end
184
+
185
+ last_filter = parsed.last
186
+ previous_field = last_filter&.[](:field)
187
+ previous_filter = last_filter if last_filter&.[](:op) == :eq
188
+
189
+ parsed
190
+ end
191
+ end
192
+
193
+ # 単一条件をパースする(OR はカンマ区切りで表現)
194
+ # @param condition [String] "tags=ruby, javascript" / "temp>=20"
195
+ # @return [Array<Hash>] フィルタ条件(1つまたは複数)
196
+ def parse_single_condition(condition)
197
+ # 比較演算子の検出(!=, >=, <=, >, <, ==, = の順で試行)
198
+ case condition.strip
199
+ in /\A([a-zA-Z_]+)\s*!=\s*(.+)\z/
200
+ [{ field: $1.to_sym, op: :neq, value: parse_values($2) }]
201
+ in /\A([a-zA-Z_]+)\s*>=\s*(.+)\z/
202
+ [{ field: $1.to_sym, op: :gte, value: parse_numeric($2.strip) }]
203
+ in /\A([a-zA-Z_]+)\s*<=\s*(.+)\z/
204
+ [{ field: $1.to_sym, op: :lte, value: parse_numeric($2.strip) }]
205
+ in /\A([a-zA-Z_]+)\s*>\s*(.+)\z/
206
+ [{ field: $1.to_sym, op: :gt, value: parse_numeric($2.strip) }]
207
+ in /\A([a-zA-Z_]+)\s*<\s*(.+)\z/
208
+ [{ field: $1.to_sym, op: :lt, value: parse_numeric($2.strip) }]
209
+ in /\A([a-zA-Z_]+)\s*={1,2}\s*(.+)\z/
210
+ parse_eq_condition($1.strip, $2.strip)
211
+ else
212
+ # パースできない場合は空で返す
213
+ []
214
+ end
215
+ end
216
+
217
+ # 等値/範囲条件をパースする
218
+ # @param field [String] フィールド名
219
+ # @param value_str [String] "ruby, javascript" / "20..25" / "20...25"
220
+ # @return [Array<Hash>] フィルタ条件
221
+ def parse_eq_condition(field, value_str)
222
+ field_sym = field.to_sym
223
+
224
+ # 範囲指定の検出
225
+ # 開始なし(...N / ..N)を先にチェックし、次に両端あり(N...M / N..M)をチェック
226
+ if (m = value_str.match(/\A\.\.\.(\S+)\z/))
227
+ # 上限のみ・排他的(field=...25 → 25未満)
228
+ [{ field: field_sym, op: :lt, value: parse_numeric(m[1]) }]
229
+ elsif (m = value_str.match(/\A\.\.(\S+)\z/))
230
+ # 上限のみ(field=..25)
231
+ [{ field: field_sym, op: :lte, value: parse_numeric(m[1]) }]
232
+ elsif (m = value_str.match(/\A([^.]\S*)\.\.\.(\S+)\z/))
233
+ # 排他的範囲(終端除く): 20...25
234
+ [{ field: field_sym, op: :range, value: parse_numeric(m[1])...parse_numeric(m[2]) }]
235
+ elsif (m = value_str.match(/\A([^.]\S*)\.\.(\S+)\z/))
236
+ # 包括的範囲: 20..25
237
+ [{ field: field_sym, op: :range, value: parse_numeric(m[1])..parse_numeric(m[2]) }]
238
+ elsif (m = value_str.match(/\A([^.]\S*)\.\.\z/))
239
+ # 下限のみ(field=20..)
240
+ [{ field: field_sym, op: :gte, value: parse_numeric(m[1]) }]
241
+ else
242
+ # 通常の等値条件(カンマ区切りでOR)
243
+ [{ field: field_sym, op: :eq, value: parse_values(value_str) }]
244
+ end
245
+ end
246
+
247
+ # カンマ区切りの値をリストとしてパースする
248
+ # @param str [String] "ruby, javascript"
249
+ # @return [Array<String>] ["ruby", "javascript"]
250
+ def parse_values(str)
251
+ str.split(',').map { it.strip }
252
+ end
253
+
254
+ # 数値文字列を適切な型に変換する
255
+ # @param str [String] "20" / "3.14" / "東京"
256
+ # @return [Integer, Float, String] 変換後の値
257
+ def parse_numeric(str)
258
+ s = str.strip
259
+ case s
260
+ when /\A-?\d+\z/ then s.to_i
261
+ when /\A-?\d+\.\d+\z/ then s.to_f
262
+ else s
263
+ end
264
+ end
265
+
266
+ # 主キー候補フィールドによる一件検索フィルタを構築する
267
+ # すべての主キー候補に対して OR 的に検索する
268
+ # @param value [String] 検索値
269
+ # @return [Array<Hash>] フィルタ条件(特殊な primary_key_lookup)
270
+ def build_primary_lookup(value)
271
+ parsed = parse_numeric(value)
272
+ [{ field: :_primary_key, op: :eq, value: parsed }]
273
+ end
274
+
275
+ # パース結果のハッシュを構築する
276
+ def build_result(source:, filters: [], sort: nil, limit: nil, style: nil, format: nil, single_lookup: false)
277
+ { source:, filters:, sort:, limit:, style:, format:, single_lookup: }
278
+ end
279
+ end
280
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ================================================================
4
+ # File: lib/query_stream/singularize.rb
5
+ # ================================================================
6
+ # 責務:
7
+ # 英単語の複数形を単数形に変換する軽量ヘルパー。
8
+ # ActiveSupport::Inflector に依存せず、パターンマッチングで実装する。
9
+ #
10
+ # 例:
11
+ # books → book
12
+ # categories → category
13
+ # branches → branch
14
+ # shelves → shelf
15
+ # elements → element
16
+ # data → data(不変)
17
+ # ================================================================
18
+
19
+ module QueryStream
20
+ # 英単語の複数形→単数形変換モジュール
21
+ module Singularize
22
+ module_function
23
+
24
+ # 複数形の英単語を単数形に変換する
25
+ # @param word [String] 変換対象の単語
26
+ # @return [String] 単数形の単語
27
+ def call(word)
28
+ str = word.to_s
29
+
30
+ # アンダースコア付きの複合名は、末尾の s で終わるセグメントを単数化する
31
+ # physics_books → physics_book, books_nested → book_nested, books2 → book2
32
+ if str.include?('_')
33
+ segments = str.split('_')
34
+ # people は不規則変化なので優先的に単数化
35
+ people_idx = segments.index { it.match?(/\Apeople\z/i) }
36
+ if people_idx
37
+ segments[people_idx] = singularize_simple(segments[people_idx])
38
+ return segments.join('_')
39
+ end
40
+ # 末尾から探して最初に見つかった複数形セグメントを単数化
41
+ idx = segments.rindex { singularize_simple(it) != it }
42
+ if idx
43
+ segments[idx] = singularize_simple(segments[idx])
44
+ return segments.join('_')
45
+ end
46
+ end
47
+
48
+ singularize_simple(str)
49
+ end
50
+
51
+ # 単一単語の単数化
52
+ def singularize_simple(word)
53
+ case word.to_s
54
+ in /\Apeople(.*)\z/i then "person#{$1}"
55
+ in /\A(.+)ies\z/ then "#{$1}y"
56
+ in /\A(.+)([sxz]|ch|sh)es\z/ then "#{$1}#{$2}"
57
+ in /\A(.+)ves\z/ then "#{$1}f"
58
+ in /\A(.+?)s([0-9].*)\z/ then "#{$1}#{$2}"
59
+ in /\A(.+)s\z/ then $1
60
+ else word
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,353 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ================================================================
4
+ # File: lib/query_stream/template_compiler.rb
5
+ # ================================================================
6
+ # 責務:
7
+ # テンプレートファイル(_book.md 等)にデータを流し込み、
8
+ # テキストを生成するコンパイラ。
9
+ #
10
+ # 変換ルール:
11
+ # - `= key` のみの行 → key の値を展開(nil/空文字なら行スキップ)
12
+ # - `prefix = key` → prefix を残してkey の値を展開
13
+ # - `![](key)` → 変数展開(拡張子なし → 変数、拡張子あり → リテラル)
14
+ # - `= key` を含まない行 → リテラル出力(ヘッダー等は一度だけ出力)
15
+ # - 空行 → 改行出力
16
+ #
17
+ # テーブル記法対応:
18
+ # `= key` を含む行のみ反復し、含まない行(ヘッダー・区切り行)は
19
+ # 一度だけ出力する。
20
+ #
21
+ # VFM フェンス記法対応:
22
+ # :::{.book-card} 〜 ::: のようなフェンスが動的行を囲んでいる場合、
23
+ # フェンス行も repeating 範囲に含めて各レコードごとに反復出力する。
24
+ # {.person-card} 等の任意の VFM クラス名に汎用的に対応する。
25
+ # ================================================================
26
+
27
+ module QueryStream
28
+ # テンプレートコンパイラモジュール
29
+ module TemplateCompiler
30
+ # 変数展開パターン: = key または =key(行内に出現、ドット記法対応)
31
+ # (?<![=\w]) で width=40 や align=right 内の = を除外
32
+ VARIABLE_PATTERN = /(?<![=\w])=\s*([a-zA-Z_][a-zA-Z0-9_.]*)/
33
+
34
+ # 画像記法内の変数展開パターン: ![](key) / ![](= key)
35
+ # 名前付きキャプチャで gsub ブロック内の $N 上書き問題を回避
36
+ IMAGE_VAR_PATTERN = /!\[(?<alt>[^\]]*)\]\((?:=\s*)?(?<src>[^)]+)\)(?<attr>\{[^}]*\})?/
37
+
38
+ # 画像の拡張子(リテラル判定用)
39
+ IMAGE_EXTENSIONS = %w[png jpg jpeg webp gif svg].freeze
40
+
41
+ # VFM フェンス開始行: :::{.class-name} 形式
42
+ FENCE_OPEN_PATTERN = /\A:::\s*\{\.[\w-].*\}\s*\z/
43
+
44
+ # VFM フェンス終了行: 単独の :::
45
+ FENCE_CLOSE_PATTERN = /\A:::\s*\z/
46
+
47
+ module_function
48
+
49
+ # テンプレートにレコード群を流し込んでテキストを生成する
50
+ # @param template [String] テンプレートの内容
51
+ # @param records [Array<Hash>] データレコード群
52
+ # @param source_filename [String, nil] エラー報告用ファイル名
53
+ # @param line_number [Integer, nil] エラー報告用行番号
54
+ # @return [String] 展開後のテキスト
55
+ def render(template, records, source_filename: nil, line_number: nil)
56
+ lines = template.lines
57
+ validate_template_keys!(lines, records.first, source_filename:, line_number:) if records.any?
58
+
59
+ parts = classify_lines(lines)
60
+ return '' if records.empty?
61
+
62
+ # 最初と最後の動的行を特定し、leading / repeating / trailing に三分割する
63
+ first_dyn = parts.index { it[:type] == :dynamic }
64
+
65
+ # 動的行がないテンプレートは全行をそのまま一度だけ出力
66
+ unless first_dyn
67
+ return parts.map { |p|
68
+ p[:type] == :blank ? "\n" : p[:content]
69
+ }.compact.join
70
+ end
71
+
72
+ last_dyn = parts.rindex { it[:type] == :dynamic }
73
+
74
+ # --- Phase: フェンス行をrepeating範囲に取り込む ---
75
+ # 動的行の直前にフェンス開始行がある場合、repeating 範囲を前方に拡張する
76
+ # 動的行の直後にフェンス終了行がある場合、repeating 範囲を後方に拡張する
77
+ # これにより :::{.book-card} 〜 ::: が各レコードごとに反復される
78
+ first_dyn, last_dyn = expand_fence_range(parts, first_dyn, last_dyn)
79
+
80
+ leading = parts[0...first_dyn]
81
+ repeating = parts[first_dyn..last_dyn]
82
+ trailing = last_dyn + 1 < parts.size ? parts[(last_dyn + 1)..] : []
83
+
84
+ # テーブルテンプレート判定(区切り行 |---|…| の有無、またはデータ行が | で始まる)
85
+ table_mode = leading.any? { it[:type] == :static && it[:content]&.match?(/^\|[-|:\s]+\|/) } ||
86
+ repeating.any? { it[:type] == :dynamic && it[:content]&.match?(/^\s*\|/) }
87
+
88
+ output = []
89
+
90
+ # leading: 静的ヘッダーを一度だけ出力
91
+ leading.each do |part|
92
+ case part
93
+ in { type: :static, content: } then output << content
94
+ in { type: :blank } then output << "\n"
95
+ end
96
+ end
97
+
98
+ # repeating: レコードごとに動的行を展開
99
+ records.each_with_index do |record, idx|
100
+ output << "\n" if idx > 0 && !table_mode
101
+
102
+ repeating.each do |part|
103
+ case part
104
+ in { type: :dynamic, content: }
105
+ expanded = expand_line(content, record)
106
+ output << expanded if expanded
107
+ in { type: :fence_open, content: } then output << content
108
+ in { type: :fence_close, content: } then output << content
109
+ in { type: :static, content: }
110
+ output << content
111
+ in { type: :blank }
112
+ output << "\n" unless table_mode
113
+ end
114
+ end
115
+ end
116
+
117
+ # trailing: 末尾の静的行を一度だけ出力
118
+ trailing.each do |part|
119
+ case part
120
+ in { type: :static, content: } then output << content
121
+ in { type: :blank } then output << "\n"
122
+ end
123
+ end
124
+
125
+ output.join
126
+ end
127
+
128
+ # テンプレート行を分類する
129
+ # @param lines [Array<String>] テンプレートの行リスト
130
+ # @return [Array<Hash>] 分類済み行リスト
131
+ def classify_lines(lines)
132
+ in_code_block = false
133
+ lines.map do |line|
134
+ stripped = line.strip
135
+
136
+ # コードブロックのフェンス行(``` で始まる行)を検出してトグル
137
+ if stripped.start_with?('```')
138
+ in_code_block = !in_code_block
139
+ next { type: :static, content: line }
140
+ end
141
+
142
+ # コードブロック内は変数展開せず static として扱う
143
+ if in_code_block
144
+ next { type: :static, content: line }
145
+ end
146
+
147
+ if stripped.empty?
148
+ { type: :blank }
149
+ elsif stripped.match?(FENCE_OPEN_PATTERN)
150
+ { type: :fence_open, content: line }
151
+ elsif stripped.match?(FENCE_CLOSE_PATTERN)
152
+ { type: :fence_close, content: line }
153
+ elsif contains_variable?(line)
154
+ { type: :dynamic, content: line }
155
+ else
156
+ { type: :static, content: line }
157
+ end
158
+ end
159
+ end
160
+
161
+ # VFM フェンス開始行かを判定する
162
+ # @param line [String] テンプレート行
163
+ # @return [Boolean]
164
+ def fence_open?(line) = line.strip.match?(FENCE_OPEN_PATTERN)
165
+
166
+ # VFM フェンス終了行かを判定する
167
+ # @param line [String] テンプレート行
168
+ # @return [Boolean]
169
+ def fence_close?(line) = line.strip.match?(FENCE_CLOSE_PATTERN)
170
+
171
+ # 動的行の前後にあるフェンス行を repeating 範囲に取り込む
172
+ # フェンス開始→(空行)→動的行 のパターンや
173
+ # 動的行→(空行)→フェンス終了 のパターンも考慮する
174
+ # @param parts [Array<Hash>] 分類済み行リスト
175
+ # @param first_dyn [Integer] 最初の動的行インデックス
176
+ # @param last_dyn [Integer] 最後の動的行インデックス
177
+ # @return [Array(Integer, Integer)] 拡張後の [first_dyn, last_dyn]
178
+ def expand_fence_range(parts, first_dyn, last_dyn)
179
+ # 前方拡張: フェンス開始行を取り込む(間に空行があっても可)
180
+ idx = first_dyn - 1
181
+ idx -= 1 if idx >= 0 && parts[idx][:type] == :blank
182
+ first_dyn = idx if idx >= 0 && parts[idx][:type] == :fence_open
183
+
184
+ # 後方拡張: フェンス終了行を取り込む(間に空行があっても可)
185
+ idx = last_dyn + 1
186
+ idx += 1 if idx < parts.size && parts[idx][:type] == :blank
187
+ last_dyn = idx if idx < parts.size && parts[idx][:type] == :fence_close
188
+
189
+ [first_dyn, last_dyn]
190
+ end
191
+
192
+ # 行に変数参照(= key)が含まれるかを判定する
193
+ # @param line [String] テンプレート行
194
+ # @return [Boolean]
195
+ def contains_variable?(line)
196
+ return true if line.match?(VARIABLE_PATTERN)
197
+ return true if line.match?(IMAGE_VAR_PATTERN) && image_has_variable?(line)
198
+
199
+ false
200
+ end
201
+
202
+ # 画像記法内に変数参照があるかを判定する
203
+ # @param line [String] テンプレート行
204
+ # @return [Boolean]
205
+ def image_has_variable?(line)
206
+ line.scan(IMAGE_VAR_PATTERN).any? do |(_, src, _)|
207
+ src = src.sub(/\A=\s*/, '').strip
208
+ !literal_image?(src)
209
+ end
210
+ end
211
+
212
+ # 画像パスがリテラル(拡張子あり)かを判定する
213
+ # @param src [String] 画像パス文字列
214
+ # @return [Boolean]
215
+ def literal_image?(src)
216
+ ext = File.extname(src).delete_prefix('.').downcase
217
+ IMAGE_EXTENSIONS.include?(ext)
218
+ end
219
+
220
+ # テンプレート行をレコードデータで展開する
221
+ # nil/空文字のキーがあれば行ごとスキップ(nil を返す)
222
+ # @param line [String] テンプレート行
223
+ # @param record [Hash] データレコード
224
+ # @return [String, nil] 展開後の行、またはスキップ時 nil
225
+ def expand_line(line, record)
226
+ result = line.dup
227
+
228
+ # 画像記法の展開(先に処理)
229
+ result = expand_images(result, record)
230
+ return nil unless result
231
+
232
+ # = key パターンの展開
233
+ result = expand_variables(result, record)
234
+ return nil unless result
235
+
236
+ result
237
+ end
238
+
239
+ # 画像記法内の変数を展開する
240
+ # @param line [String] テンプレート行
241
+ # @param record [Hash] データレコード
242
+ # @return [String, nil] 展開後の行、またはスキップ時 nil
243
+ def expand_images(line, record)
244
+ result = line.dup
245
+ skip = false
246
+
247
+ result.gsub!(IMAGE_VAR_PATTERN) do |match|
248
+ md = Regexp.last_match
249
+ alt = md[:alt]
250
+ src = md[:src].sub(/\A=\s*/, '').strip
251
+ attr = md[:attr] || ''
252
+
253
+ if literal_image?(src)
254
+ # 拡張子ありはリテラルとしてそのまま出力
255
+ match
256
+ else
257
+ # 変数として展開(ドット記法対応)
258
+ value = resolve_nested_value(record, src)
259
+ if value.nil? || value.to_s.strip.empty?
260
+ skip = true
261
+ match # gsub のブロックからは文字列を返す必要がある
262
+ else
263
+ "![#{alt}](#{value})#{attr}"
264
+ end
265
+ end
266
+ end
267
+
268
+ skip ? nil : result
269
+ end
270
+
271
+ # = key パターンの変数を展開する(ドット記法対応)
272
+ # @param line [String] テンプレート行
273
+ # @param record [Hash] データレコード
274
+ # @return [String, nil] 展開後の行、またはスキップ時 nil
275
+ def expand_variables(line, record)
276
+ result = line.dup
277
+
278
+ result.gsub!(VARIABLE_PATTERN) do |_match|
279
+ key_path = $1
280
+ value = resolve_nested_value(record, key_path)
281
+ if value.nil? || value.to_s.strip.empty?
282
+ return nil # 行ごとスキップ
283
+ end
284
+ value.to_s
285
+ end
286
+
287
+ result
288
+ end
289
+
290
+ # ドット記法のキーパスをたどってネストされた値を取得する
291
+ # @param record [Hash] データレコード
292
+ # @param key_path [String] キーパス(例: "author.name")
293
+ # @return [Object, nil] 値
294
+ def resolve_nested_value(record, key_path)
295
+ keys = key_path.split('.')
296
+ value = record
297
+ keys.each do |k|
298
+ return nil unless value.is_a?(Hash)
299
+ value = value[k.to_sym] || value[k.to_s]
300
+ end
301
+ value
302
+ end
303
+
304
+ # テンプレート内のキーがデータに存在するかを検証する
305
+ # @param lines [Array<String>] テンプレートの行リスト
306
+ # @param sample_record [Hash] サンプルレコード(最初の1件)
307
+ # @param source_filename [String, nil] エラー報告用ファイル名
308
+ # @param line_number [Integer, nil] エラー報告用行番号
309
+ def validate_template_keys!(lines, sample_record, source_filename: nil, line_number: nil)
310
+ return unless sample_record
311
+
312
+ location = source_filename ? "#{source_filename}:#{line_number}" : ''
313
+ available_keys = sample_record.keys
314
+ in_code_block = false
315
+
316
+ lines.each do |line|
317
+ stripped = line.strip
318
+
319
+ # コードブロック内はキー検証をスキップ
320
+ if stripped.start_with?('```')
321
+ in_code_block = !in_code_block
322
+ next
323
+ end
324
+ next if in_code_block
325
+
326
+ # = key パターン(ドット記法の場合はルートキーのみ検証)
327
+ line.scan(VARIABLE_PATTERN).each do |(key_path)|
328
+ root_key = key_path.split('.').first.to_sym
329
+ unless available_keys.include?(root_key)
330
+ msg = "テンプレートに存在しないキーが記述されています: #{key_path}"
331
+ QueryStream.logger.error("#{msg}(#{location})")
332
+ QueryStream.logger.error(" 利用可能なキー: #{available_keys.join(', ')}")
333
+ raise UnknownKeyError, msg
334
+ end
335
+ end
336
+
337
+ # 画像記法内の変数
338
+ line.scan(IMAGE_VAR_PATTERN).each do |(_, src, _)|
339
+ src = src.sub(/\A=\s*/, '').strip
340
+ next if literal_image?(src)
341
+
342
+ root_key = src.split('.').first.to_sym
343
+ unless available_keys.include?(root_key)
344
+ msg = "テンプレートに存在しないキーが記述されています: #{src}"
345
+ QueryStream.logger.error("#{msg}(#{location})")
346
+ QueryStream.logger.error(" 利用可能なキー: #{available_keys.join(', ')}")
347
+ raise UnknownKeyError, msg
348
+ end
349
+ end
350
+ end
351
+ end
352
+ end
353
+ end