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.
- checksums.yaml +7 -0
- data/Gemfile +5 -0
- data/LICENSE +41 -0
- data/README.md +115 -0
- data/bin/query-stream +7 -0
- data/lib/query_stream/command.rb +40 -0
- data/lib/query_stream/configuration.rb +35 -0
- data/lib/query_stream/data_resolver.rb +73 -0
- data/lib/query_stream/errors.rb +25 -0
- data/lib/query_stream/filter_engine.rb +136 -0
- data/lib/query_stream/query_stream_parser.rb +280 -0
- data/lib/query_stream/singularize.rb +39 -0
- data/lib/query_stream/template_compiler.rb +330 -0
- data/lib/query_stream/version.rb +5 -0
- data/lib/query_stream.rb +225 -0
- data/query-stream.gemspec +33 -0
- metadata +128 -0
|
@@ -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
|
+
# - `` → 変数展開(拡張子なし → 変数、拡張子あり → リテラル)
|
|
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
|
+
# 画像記法内の変数展開パターン:  / 
|
|
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
|
+
"#{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
|