query-stream 1.0.0

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,39 @@
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
+ case word.to_s
29
+ in /\Apeople(.*)\z/ then "person#{$1}" # people → person(不規則)
30
+ in /\A(.+)ies\z/ then "#{$1}y" # categories → category
31
+ in /\A(.+)([sxz]|ch|sh)es\z/ then "#{$1}#{$2}" # branches → branch
32
+ in /\A(.+)ves\z/ then "#{$1}f" # shelves → shelf
33
+ in /\A(.+?)s([0-9_].*)\z/ then "#{$1}#{$2}" # books2 → book2
34
+ in /\A(.+)s\z/ then $1 # elements → element
35
+ else word # data, sheep(不変)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,330 @@
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
+ lines.map do |line|
133
+ stripped = line.strip
134
+ if stripped.empty?
135
+ { type: :blank }
136
+ elsif stripped.match?(FENCE_OPEN_PATTERN)
137
+ { type: :fence_open, content: line }
138
+ elsif stripped.match?(FENCE_CLOSE_PATTERN)
139
+ { type: :fence_close, content: line }
140
+ elsif contains_variable?(line)
141
+ { type: :dynamic, content: line }
142
+ else
143
+ { type: :static, content: line }
144
+ end
145
+ end
146
+ end
147
+
148
+ # VFM フェンス開始行かを判定する
149
+ # @param line [String] テンプレート行
150
+ # @return [Boolean]
151
+ def fence_open?(line) = line.strip.match?(FENCE_OPEN_PATTERN)
152
+
153
+ # VFM フェンス終了行かを判定する
154
+ # @param line [String] テンプレート行
155
+ # @return [Boolean]
156
+ def fence_close?(line) = line.strip.match?(FENCE_CLOSE_PATTERN)
157
+
158
+ # 動的行の前後にあるフェンス行を repeating 範囲に取り込む
159
+ # フェンス開始→(空行)→動的行 のパターンや
160
+ # 動的行→(空行)→フェンス終了 のパターンも考慮する
161
+ # @param parts [Array<Hash>] 分類済み行リスト
162
+ # @param first_dyn [Integer] 最初の動的行インデックス
163
+ # @param last_dyn [Integer] 最後の動的行インデックス
164
+ # @return [Array(Integer, Integer)] 拡張後の [first_dyn, last_dyn]
165
+ def expand_fence_range(parts, first_dyn, last_dyn)
166
+ # 前方拡張: フェンス開始行を取り込む(間に空行があっても可)
167
+ idx = first_dyn - 1
168
+ idx -= 1 if idx >= 0 && parts[idx][:type] == :blank
169
+ first_dyn = idx if idx >= 0 && parts[idx][:type] == :fence_open
170
+
171
+ # 後方拡張: フェンス終了行を取り込む(間に空行があっても可)
172
+ idx = last_dyn + 1
173
+ idx += 1 if idx < parts.size && parts[idx][:type] == :blank
174
+ last_dyn = idx if idx < parts.size && parts[idx][:type] == :fence_close
175
+
176
+ [first_dyn, last_dyn]
177
+ end
178
+
179
+ # 行に変数参照(= key)が含まれるかを判定する
180
+ # @param line [String] テンプレート行
181
+ # @return [Boolean]
182
+ def contains_variable?(line)
183
+ return true if line.match?(VARIABLE_PATTERN)
184
+ return true if line.match?(IMAGE_VAR_PATTERN) && image_has_variable?(line)
185
+
186
+ false
187
+ end
188
+
189
+ # 画像記法内に変数参照があるかを判定する
190
+ # @param line [String] テンプレート行
191
+ # @return [Boolean]
192
+ def image_has_variable?(line)
193
+ line.scan(IMAGE_VAR_PATTERN).any? do |(_, src, _)|
194
+ src = src.sub(/\A=\s*/, '').strip
195
+ !literal_image?(src)
196
+ end
197
+ end
198
+
199
+ # 画像パスがリテラル(拡張子あり)かを判定する
200
+ # @param src [String] 画像パス文字列
201
+ # @return [Boolean]
202
+ def literal_image?(src)
203
+ ext = File.extname(src).delete_prefix('.').downcase
204
+ IMAGE_EXTENSIONS.include?(ext)
205
+ end
206
+
207
+ # テンプレート行をレコードデータで展開する
208
+ # nil/空文字のキーがあれば行ごとスキップ(nil を返す)
209
+ # @param line [String] テンプレート行
210
+ # @param record [Hash] データレコード
211
+ # @return [String, nil] 展開後の行、またはスキップ時 nil
212
+ def expand_line(line, record)
213
+ result = line.dup
214
+
215
+ # 画像記法の展開(先に処理)
216
+ result = expand_images(result, record)
217
+ return nil unless result
218
+
219
+ # = key パターンの展開
220
+ result = expand_variables(result, record)
221
+ return nil unless result
222
+
223
+ result
224
+ end
225
+
226
+ # 画像記法内の変数を展開する
227
+ # @param line [String] テンプレート行
228
+ # @param record [Hash] データレコード
229
+ # @return [String, nil] 展開後の行、またはスキップ時 nil
230
+ def expand_images(line, record)
231
+ result = line.dup
232
+ skip = false
233
+
234
+ result.gsub!(IMAGE_VAR_PATTERN) do |match|
235
+ md = Regexp.last_match
236
+ alt = md[:alt]
237
+ src = md[:src].sub(/\A=\s*/, '').strip
238
+ attr = md[:attr] || ''
239
+
240
+ if literal_image?(src)
241
+ # 拡張子ありはリテラルとしてそのまま出力
242
+ match
243
+ else
244
+ # 変数として展開(ドット記法対応)
245
+ value = resolve_nested_value(record, src)
246
+ if value.nil? || value.to_s.strip.empty?
247
+ skip = true
248
+ match # gsub のブロックからは文字列を返す必要がある
249
+ else
250
+ "![#{alt}](#{value})#{attr}"
251
+ end
252
+ end
253
+ end
254
+
255
+ skip ? nil : result
256
+ end
257
+
258
+ # = key パターンの変数を展開する(ドット記法対応)
259
+ # @param line [String] テンプレート行
260
+ # @param record [Hash] データレコード
261
+ # @return [String, nil] 展開後の行、またはスキップ時 nil
262
+ def expand_variables(line, record)
263
+ result = line.dup
264
+
265
+ result.gsub!(VARIABLE_PATTERN) do |_match|
266
+ key_path = $1
267
+ value = resolve_nested_value(record, key_path)
268
+ if value.nil? || value.to_s.strip.empty?
269
+ return nil # 行ごとスキップ
270
+ end
271
+ value.to_s
272
+ end
273
+
274
+ result
275
+ end
276
+
277
+ # ドット記法のキーパスをたどってネストされた値を取得する
278
+ # @param record [Hash] データレコード
279
+ # @param key_path [String] キーパス(例: "author.name")
280
+ # @return [Object, nil] 値
281
+ def resolve_nested_value(record, key_path)
282
+ keys = key_path.split('.')
283
+ value = record
284
+ keys.each do |k|
285
+ return nil unless value.is_a?(Hash)
286
+ value = value[k.to_sym] || value[k.to_s]
287
+ end
288
+ value
289
+ end
290
+
291
+ # テンプレート内のキーがデータに存在するかを検証する
292
+ # @param lines [Array<String>] テンプレートの行リスト
293
+ # @param sample_record [Hash] サンプルレコード(最初の1件)
294
+ # @param source_filename [String, nil] エラー報告用ファイル名
295
+ # @param line_number [Integer, nil] エラー報告用行番号
296
+ def validate_template_keys!(lines, sample_record, source_filename: nil, line_number: nil)
297
+ return unless sample_record
298
+
299
+ location = source_filename ? "#{source_filename}:#{line_number}" : ''
300
+ available_keys = sample_record.keys
301
+
302
+ lines.each do |line|
303
+ # = key パターン(ドット記法の場合はルートキーのみ検証)
304
+ line.scan(VARIABLE_PATTERN).each do |(key_path)|
305
+ root_key = key_path.split('.').first.to_sym
306
+ unless available_keys.include?(root_key)
307
+ msg = "テンプレートに存在しないキーが記述されています: #{key_path}"
308
+ QueryStream.logger.error("#{msg}(#{location})")
309
+ QueryStream.logger.error(" 利用可能なキー: #{available_keys.join(', ')}")
310
+ raise UnknownKeyError, msg
311
+ end
312
+ end
313
+
314
+ # 画像記法内の変数
315
+ line.scan(IMAGE_VAR_PATTERN).each do |(_, src, _)|
316
+ src = src.sub(/\A=\s*/, '').strip
317
+ next if literal_image?(src)
318
+
319
+ root_key = src.split('.').first.to_sym
320
+ unless available_keys.include?(root_key)
321
+ msg = "テンプレートに存在しないキーが記述されています: #{src}"
322
+ QueryStream.logger.error("#{msg}(#{location})")
323
+ QueryStream.logger.error(" 利用可能なキー: #{available_keys.join(', ')}")
324
+ raise UnknownKeyError, msg
325
+ end
326
+ end
327
+ end
328
+ end
329
+ end
330
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueryStream
4
+ VERSION = '1.0.0'
5
+ end