klenod-build 0.0.1

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.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +29 -0
  3. data/lib/klenod/build/asset.rb +235 -0
  4. data/lib/klenod/build/asset_compression.rb +58 -0
  5. data/lib/klenod/build/asset_generation_queue.rb +50 -0
  6. data/lib/klenod/build/cli/application.rb +108 -0
  7. data/lib/klenod/build/cli.rb +3 -0
  8. data/lib/klenod/build/config.rb +142 -0
  9. data/lib/klenod/build/context.rb +370 -0
  10. data/lib/klenod/build/dependency.rb +21 -0
  11. data/lib/klenod/build/errors.rb +175 -0
  12. data/lib/klenod/build/filesystem_resolver.rb +147 -0
  13. data/lib/klenod/build/graph/invalidator.rb +246 -0
  14. data/lib/klenod/build/graph.rb +864 -0
  15. data/lib/klenod/build/graphviz.rb +282 -0
  16. data/lib/klenod/build/hashing.rb +21 -0
  17. data/lib/klenod/build/invalidation_result.rb +55 -0
  18. data/lib/klenod/build/load_result.rb +7 -0
  19. data/lib/klenod/build/loaded_module.rb +38 -0
  20. data/lib/klenod/build/module_id.rb +100 -0
  21. data/lib/klenod/build/module_record.rb +22 -0
  22. data/lib/klenod/build/plugin.rb +35 -0
  23. data/lib/klenod/build/plugins/class_names_runtime.rb +125 -0
  24. data/lib/klenod/build/plugins/component_defaults.rb +12 -0
  25. data/lib/klenod/build/plugins/data_plugin.rb +117 -0
  26. data/lib/klenod/build/plugins/gem_import_plugin.rb +125 -0
  27. data/lib/klenod/build/plugins/google_fonts_plugin/font_metrics.json +17687 -0
  28. data/lib/klenod/build/plugins/google_fonts_plugin/font_metrics.rb +105 -0
  29. data/lib/klenod/build/plugins/google_fonts_plugin/font_metrics.txt +31 -0
  30. data/lib/klenod/build/plugins/google_fonts_plugin.rb +387 -0
  31. data/lib/klenod/build/plugins/haml_plugin/companions.rb +40 -0
  32. data/lib/klenod/build/plugins/haml_plugin/errors.rb +114 -0
  33. data/lib/klenod/build/plugins/haml_plugin/helper_source.rb +123 -0
  34. data/lib/klenod/build/plugins/haml_plugin/parser.rb +85 -0
  35. data/lib/klenod/build/plugins/haml_plugin/transformer/ruby_builder.rb +1039 -0
  36. data/lib/klenod/build/plugins/haml_plugin/transformer.rb +766 -0
  37. data/lib/klenod/build/plugins/haml_plugin.rb +369 -0
  38. data/lib/klenod/build/plugins/image_plugin.rb +401 -0
  39. data/lib/klenod/build/plugins/intl_plugin.rb +34 -0
  40. data/lib/klenod/build/plugins/markdown_compiler.rb +180 -0
  41. data/lib/klenod/build/plugins/markdown_plugin.rb +161 -0
  42. data/lib/klenod/build/plugins/router_plugin.rb +942 -0
  43. data/lib/klenod/build/plugins/ruby_plugin.rb +34 -0
  44. data/lib/klenod/build/plugins/svg_plugin.rb +197 -0
  45. data/lib/klenod/build/plugins.rb +22 -0
  46. data/lib/klenod/build/profiler.rb +79 -0
  47. data/lib/klenod/build/resolution_error_formatter.rb +42 -0
  48. data/lib/klenod/build/resolver.rb +95 -0
  49. data/lib/klenod/build/ruby_import_rewriter.rb +436 -0
  50. data/lib/klenod/build/source_map/editor.rb +110 -0
  51. data/lib/klenod/build/source_map/map.rb +168 -0
  52. data/lib/klenod/build/source_map/vlq.rb +68 -0
  53. data/lib/klenod/build/source_map.rb +12 -0
  54. data/lib/klenod/build/transform_result.rb +12 -0
  55. data/lib/klenod/build/version.rb +7 -0
  56. data/lib/klenod/build/watched_pattern.rb +11 -0
  57. data/lib/klenod/build/watcher.rb +141 -0
  58. data/lib/klenod/build.rb +15 -0
  59. metadata +310 -0
@@ -0,0 +1,401 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "image_size"
4
+ require "json"
5
+ require "rmagick"
6
+ require "uri"
7
+
8
+ require_relative "../asset"
9
+ require_relative "../dependency"
10
+ require_relative "../hashing"
11
+ require_relative "../load_result"
12
+ require_relative "../plugin"
13
+ require_relative "../transform_result"
14
+
15
+ module Klenod
16
+ module Build
17
+ module Plugins
18
+ module ImagePlugin
19
+ def self.new(...)
20
+ Plugin.new(...)
21
+ end
22
+
23
+ class Plugin < Klenod::Build::Plugin
24
+ EXTENSIONS = [".avif", ".gif", ".jpeg", ".jpg", ".png", ".webp"].freeze
25
+ IMAGE_RUNTIME_SPECIFIER = "virtual:klenod/image"
26
+ IMAGE_RUNTIME_MODULE_ID = ModuleId.new("#{IMAGE_RUNTIME_SPECIFIER}.rb", nil)
27
+
28
+ ImageDefaultKey = Data.define(:source_path, :source_hash, :format, :quality)
29
+ ImageVariantKey = Data.define(:source_path, :source_hash, :width, :format, :quality)
30
+
31
+ def initialize(widths: [], formats: nil)
32
+ @widths = widths
33
+ @formats = formats
34
+ @default_asset_cache = {}
35
+ @variant_cache = {}
36
+ end
37
+
38
+ def resolve(dependency, _context)
39
+ return nil unless dependency.specifier == IMAGE_RUNTIME_SPECIFIER
40
+
41
+ ResolvedDependency.new(dependency, IMAGE_RUNTIME_MODULE_ID, {virtual: true})
42
+ end
43
+
44
+ def load(module_id, context)
45
+ return image_runtime_source if module_id.scheme == :virtual && module_id == IMAGE_RUNTIME_MODULE_ID
46
+ return nil unless EXTENSIONS.include?(module_id.extname)
47
+
48
+ source_path = context.absolute_path(module_id)
49
+ source_hash = Hashing.file_hexdigest(source_path)
50
+ dimensions = image_dimensions(source_path)
51
+ image_options = image_options_for(module_id)
52
+ asset = default_image_asset(module_id, source_path, source_hash, dimensions, image_options, context.asset_generation_queue)
53
+ variant_assets = generate_variant_assets(module_id, source_path, source_hash, dimensions, image_options, context.asset_generation_queue)
54
+ javascript_asset = javascript_image_asset(module_id, asset, variant_assets)
55
+ assets = [asset, *variant_assets, javascript_asset]
56
+ image_runtime_dependency =
57
+ Dependency
58
+ .create(
59
+ specifier: IMAGE_RUNTIME_SPECIFIER,
60
+ importer_id: module_id,
61
+ kind: :image_runtime
62
+ )
63
+ .with(id: "#{module_id}:image_runtime")
64
+
65
+ transform =
66
+ TransformResult.new(
67
+ image_module_source(module_id, assets, image_runtime_dependency),
68
+ [image_runtime_dependency],
69
+ nil,
70
+ assets,
71
+ [],
72
+ {image_javascript_asset_path: javascript_asset.output_path}
73
+ )
74
+ LoadResult.new(
75
+ image_source(module_id),
76
+ source_hash,
77
+ transform
78
+ )
79
+ end
80
+
81
+ def import_value(_resolved_dependency, record, context)
82
+ return nil unless EXTENSIONS.include?(record.id.extname)
83
+
84
+ context.mods.fetch(record.id).const_get(:Exports)::Default
85
+ end
86
+
87
+ def runtime_import_value(_resolved_dependency, record, _context)
88
+ return Runtime::DefaultImport.new(:Default) if EXTENSIONS.include?(record.id.extname)
89
+
90
+ super
91
+ end
92
+
93
+ private
94
+
95
+ Dimensions = Data.define(:width, :height, :format)
96
+
97
+ def image_dimensions(path)
98
+ size = ImageSize.path(path)
99
+ Dimensions.new(size.width, size.height, size.format)
100
+ rescue ImageSize::FormatError
101
+ Dimensions.new(nil, nil, nil)
102
+ end
103
+
104
+ def default_image_asset(module_id, source_path, source_hash, dimensions, image_options, queue)
105
+ return static_image_asset(module_id, source_path, source_hash, dimensions) unless generated_default_image?(image_options)
106
+
107
+ format = default_image_format(module_id, image_options)
108
+ key = ImageDefaultKey.new(module_id.path, source_hash, format, image_options.quality)
109
+ @default_asset_cache[key] ||= generated_default_image_asset(module_id, source_path, source_hash, dimensions, format, image_options.quality, queue)
110
+ end
111
+
112
+ def generated_default_image?(image_options)
113
+ return true if image_options.explicit_formats && image_options.formats.any?
114
+
115
+ !image_options.quality.nil?
116
+ end
117
+
118
+ def default_image_format(module_id, image_options)
119
+ return image_options.formats.fetch(0) if image_options.explicit_formats && image_options.formats.any?
120
+
121
+ module_id.extname.delete_prefix(".")
122
+ end
123
+
124
+ def static_image_asset(module_id, source_path, source_hash, dimensions)
125
+ hash = source_hash[0, 16]
126
+ Asset.new(
127
+ module_id.path,
128
+ hash,
129
+ "/assets/#{asset_name(module_id)}.#{hash}#{module_id.extname}",
130
+ source_path,
131
+ nil,
132
+ content_type(module_id.extname),
133
+ {
134
+ type: :image,
135
+ width: dimensions.width,
136
+ height: dimensions.height,
137
+ format: dimensions.format
138
+ }
139
+ )
140
+ end
141
+
142
+ def generated_default_image_asset(module_id, source_path, source_hash, dimensions, format, quality, queue)
143
+ format = format.downcase
144
+ extname = ".#{format}"
145
+ hash = Hashing.short("#{source_hash}:default:#{format}:#{quality}")
146
+ metadata = {
147
+ type: :image,
148
+ width: dimensions.width,
149
+ height: dimensions.height,
150
+ format: format.to_sym
151
+ }
152
+ metadata[:quality] = quality if quality
153
+
154
+ Asset.generated(
155
+ module_id.path,
156
+ hash,
157
+ "/assets/#{asset_name(module_id)}.#{hash}#{extname}",
158
+ source_path,
159
+ content_type(extname),
160
+ metadata,
161
+ writer: ->(io) { write_image_bytes(source_path, format, quality, io) },
162
+ queue: queue,
163
+ queue_kind: :cpu
164
+ ) do
165
+ generate_image_bytes(source_path, format, quality:)
166
+ end
167
+ end
168
+
169
+ def generate_variant_assets(module_id, source_path, source_hash, dimensions, image_options, queue)
170
+ return [] if dimensions.width.nil? || dimensions.height.nil?
171
+
172
+ return [] if image_options.widths.empty?
173
+
174
+ image_options.formats.flat_map do |format|
175
+ image_options.widths.filter_map do |width|
176
+ next if width <= 0
177
+
178
+ key = ImageVariantKey.new(module_id.path, source_hash, width, format.downcase, image_options.quality)
179
+ @variant_cache[key] ||= variant_asset(module_id, source_path, dimensions, source_hash, width, format, image_options.quality, queue)
180
+ end
181
+ end
182
+ end
183
+
184
+ ImageOptions = Data.define(:widths, :formats, :explicit_formats, :quality)
185
+
186
+ def image_runtime_source
187
+ <<~RUBY
188
+ Image =
189
+ Data.define(:src, :width, :height, :content_type, :variants) do
190
+ def srcset
191
+ return nil if variants.empty?
192
+
193
+ variants.map { "\#{it.src} \#{it.descriptor}" }.join(", ")
194
+ end
195
+
196
+ def sizes
197
+ display_width = variants.filter_map(&:width).max || width
198
+ return nil unless display_width
199
+
200
+ "(max-width: \#{display_width}px) 100vw, \#{display_width}px"
201
+ end
202
+ end
203
+
204
+ ImageVariant = Data.define(:src, :width, :height, :content_type, :format, :descriptor, :metadata)
205
+ RUBY
206
+ end
207
+
208
+ def image_source(module_id)
209
+ "# image asset: #{module_id}\n"
210
+ end
211
+
212
+ def image_module_source(module_id, assets, image_runtime_dependency)
213
+ asset = image_asset(assets)
214
+ variants = image_variant_assets(assets).map { |variant_asset| image_variant_source(variant_asset) }
215
+
216
+ <<~RUBY
217
+ ImageRuntime = __klenod_import__(#{image_runtime_dependency.id.inspect})
218
+ Default =
219
+ ImageRuntime::Image.new(
220
+ src: #{asset.output_path.inspect},
221
+ width: #{asset.metadata[:width].inspect},
222
+ height: #{asset.metadata[:height].inspect},
223
+ content_type: #{asset.content_type.inspect},
224
+ variants: [
225
+ #{variants.join(",\n ")}
226
+ ]
227
+ )
228
+
229
+ RUBY
230
+ end
231
+
232
+ def image_variant_source(asset)
233
+ <<~RUBY.chomp
234
+ ImageRuntime::ImageVariant.new(
235
+ src: #{asset.output_path.inspect},
236
+ width: #{asset.metadata[:width].inspect},
237
+ height: #{asset.metadata[:height].inspect},
238
+ content_type: #{asset.content_type.inspect},
239
+ format: #{asset.metadata[:format].inspect},
240
+ descriptor: #{asset.metadata[:descriptor].inspect},
241
+ metadata: #{asset.metadata.inspect}
242
+ )
243
+ RUBY
244
+ end
245
+
246
+ def javascript_image_asset(module_id, asset, variant_assets)
247
+ code = javascript_image_module_source(asset, variant_assets)
248
+ hash = Hashing.short(code)
249
+ Asset.new(
250
+ module_id.path,
251
+ hash,
252
+ "#{asset.output_path}.js",
253
+ nil,
254
+ code,
255
+ "application/javascript",
256
+ {type: :image_javascript_metadata, image_metadata: true, image_asset_path: asset.output_path}
257
+ )
258
+ end
259
+
260
+ def javascript_image_module_source(asset, variant_assets)
261
+ variants = variant_assets.map { |variant_asset| javascript_image_variant(variant_asset) }
262
+ srcset = variant_assets.empty? ? nil : variant_assets.map { "#{it.output_path} #{it.metadata[:descriptor]}" }.join(", ")
263
+ display_width = variant_assets.filter_map { it.metadata[:width] }.max || asset.metadata[:width]
264
+ sizes = display_width ? "(max-width: #{display_width}px) 100vw, #{display_width}px" : nil
265
+ metadata = {
266
+ src: asset.output_path,
267
+ width: asset.metadata[:width],
268
+ height: asset.metadata[:height],
269
+ contentType: asset.content_type,
270
+ variants: variants,
271
+ srcset: srcset,
272
+ sizes: sizes
273
+ }
274
+
275
+ "export default #{JSON.generate(metadata)};\n"
276
+ end
277
+
278
+ def javascript_image_variant(asset)
279
+ {
280
+ src: asset.output_path,
281
+ width: asset.metadata[:width],
282
+ height: asset.metadata[:height],
283
+ contentType: asset.content_type,
284
+ format: asset.metadata[:format],
285
+ descriptor: asset.metadata[:descriptor],
286
+ metadata: asset.metadata
287
+ }
288
+ end
289
+
290
+ def image_asset(assets)
291
+ assets.find { it.metadata[:type] == :image } || assets.fetch(0)
292
+ end
293
+
294
+ def image_variant_assets(assets)
295
+ assets.select { it.metadata[:type] == :image_variant }
296
+ end
297
+
298
+ def image_options_for(module_id)
299
+ query = URI.decode_www_form(module_id.query || "").to_h
300
+ widths =
301
+ if query["width"]
302
+ query["width"].split(",").filter_map { |value| Integer(value, exception: false) }
303
+ else
304
+ @widths
305
+ end
306
+ explicit_formats = query.key?("format")
307
+ formats =
308
+ if explicit_formats
309
+ query["format"].split(",")
310
+ else
311
+ @formats || [module_id.extname.delete_prefix(".")]
312
+ end
313
+
314
+ ImageOptions.new(widths.uniq, formats.map(&:downcase).uniq, explicit_formats, image_quality(query["quality"]))
315
+ end
316
+
317
+ def image_quality(value)
318
+ return nil if value.nil?
319
+
320
+ quality = Integer(value, exception: false)
321
+ return nil unless quality&.between?(1, 100)
322
+
323
+ quality
324
+ end
325
+
326
+ def variant_asset(module_id, source_path, dimensions, source_hash, width, format, quality, queue)
327
+ format = format.downcase
328
+ extname = ".#{format}"
329
+ descriptor = "#{width}w"
330
+ hash = Hashing.short("#{source_hash}:#{width}:#{format}:#{quality}")
331
+ output_path = "/assets/#{asset_name(module_id)}.#{width}w.#{hash}#{extname}"
332
+ metadata = {
333
+ type: :image_variant,
334
+ width: width,
335
+ height: scaled_height(dimensions, width),
336
+ format: format.to_sym,
337
+ descriptor: descriptor,
338
+ source_width: width
339
+ }
340
+ metadata[:quality] = quality if quality
341
+
342
+ Asset.generated(
343
+ module_id.path,
344
+ hash,
345
+ output_path,
346
+ source_path,
347
+ content_type(extname),
348
+ metadata,
349
+ writer: ->(io) { write_variant_bytes(source_path, width, format, quality, io) },
350
+ queue: queue,
351
+ queue_kind: :cpu
352
+ ) do
353
+ generate_image_bytes(source_path, format, width: width, quality:)
354
+ end
355
+ end
356
+
357
+ def generate_image_bytes(source_path, format, width: nil, quality: nil)
358
+ image = Magick::Image.read(source_path.to_s).first
359
+ output_image = width ? image.resize_to_fit(width) : image
360
+ output_image.to_blob do |info|
361
+ info.format = format.upcase
362
+ info.quality = quality if quality
363
+ end
364
+ ensure
365
+ output_image&.destroy! if output_image && output_image != image
366
+ image&.destroy!
367
+ end
368
+
369
+ def write_variant_bytes(source_path, width, format, quality, io)
370
+ io.write(generate_image_bytes(source_path, format, width: width, quality:))
371
+ end
372
+
373
+ def write_image_bytes(source_path, format, quality, io)
374
+ io.write(generate_image_bytes(source_path, format, quality:))
375
+ end
376
+
377
+ def scaled_height(dimensions, width)
378
+ return nil if dimensions.width.nil? || dimensions.height.nil? || dimensions.width.zero?
379
+
380
+ (dimensions.height.to_f * width / dimensions.width).round
381
+ end
382
+
383
+ def asset_name(module_id)
384
+ File.basename(module_id.path, module_id.extname).gsub(/[^A-Za-z0-9]+/, "_")
385
+ end
386
+
387
+ def content_type(extname)
388
+ case extname
389
+ when ".avif" then "image/avif"
390
+ when ".gif" then "image/gif"
391
+ when ".jpeg", ".jpg" then "image/jpeg"
392
+ when ".png" then "image/png"
393
+ when ".webp" then "image/webp"
394
+ else "application/octet-stream"
395
+ end
396
+ end
397
+ end
398
+ end
399
+ end
400
+ end
401
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "toml-rb"
4
+
5
+ require_relative "../plugin"
6
+
7
+ module Klenod
8
+ module Build
9
+ module Plugins
10
+ module IntlPlugin
11
+ def self.new(...)
12
+ Plugin.new(...)
13
+ end
14
+
15
+ class Plugin < Klenod::Build::Plugin
16
+ INTL_FILE_RE = /\.intl\.(?<locale>[^\/]+)\.toml\z/
17
+
18
+ def translations_for(context, module_id)
19
+ base = module_id.path.delete_suffix(module_id.extname)
20
+ pattern = context.absolute_path(ModuleId.new("#{base}.intl.*.toml", nil)).to_s
21
+
22
+ Dir
23
+ .glob(pattern)
24
+ .sort
25
+ .to_h do |path|
26
+ locale = File.basename(path).match(INTL_FILE_RE)[:locale]
27
+ [locale, TomlRB.load_file(path)]
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ previous_verbose = $VERBOSE
4
+ begin
5
+ $VERBOSE = nil
6
+ require "kramdown"
7
+ require "kramdown-parser-gfm"
8
+ ensure
9
+ $VERBOSE = previous_verbose
10
+ end
11
+
12
+ module Klenod
13
+ module Build
14
+ module Plugins
15
+ class MarkdownCompiler
16
+ ESCAPED_INTERPOLATION_SENTINEL = "\uE000klenod_escaped_interpolation\uE000"
17
+
18
+ def initialize(factory:, components_source: "{}")
19
+ @factory = factory
20
+ @components_source = components_source
21
+ end
22
+
23
+ def compile(source, interpolate: false)
24
+ source = protect_escaped_interpolation(source) if interpolate
25
+ document = Kramdown::Document.new(source, input: "GFM")
26
+ compile_children(document.root.children, interpolate: interpolate)
27
+ end
28
+
29
+ private
30
+
31
+ def compile_children(children, parent: nil, interpolate: false)
32
+ expressions = compile_child_expressions(children, parent: parent, interpolate: interpolate)
33
+ return "nil" if expressions.empty?
34
+ return expressions.fetch(0) if expressions.length == 1
35
+
36
+ "[#{expressions.join(", ")}]"
37
+ end
38
+
39
+ def compile_child_expressions(children, parent:, interpolate:, table_section: nil)
40
+ children.flat_map { |child| compile_node(child, parent: parent, table_section: table_section, interpolate: interpolate) }.compact
41
+ end
42
+
43
+ def compile_node(node, parent: nil, table_section: nil, interpolate: false)
44
+ case node.type
45
+ when :root
46
+ [compile_children(node.children, parent: node, interpolate: interpolate)]
47
+ when :blank
48
+ nil
49
+ when :text
50
+ text_expressions(node.value, interpolate: interpolate)
51
+ when :p
52
+ return compile_child_expressions(node.children, parent: node, interpolate: interpolate) if node.options[:transparent]
53
+
54
+ [factory_call(:p, node.children, attrs: node.attr, parent: node, interpolate: interpolate)]
55
+ when :header
56
+ [factory_call(:"h#{node.options.fetch(:level)}", node.children, attrs: node.attr, parent: node, interpolate: interpolate)]
57
+ when :em, :strong, :a, :blockquote, :ul, :ol, :li, :table
58
+ [factory_call(node.type, node.children, attrs: node.attr, parent: node, interpolate: interpolate)]
59
+ when :thead, :tbody
60
+ [factory_call(node.type, node.children, attrs: node.attr, parent: node, table_section: node.type, interpolate: interpolate)]
61
+ when :tr
62
+ [factory_call(node.type, node.children, attrs: node.attr, parent: node, table_section: table_section, interpolate: interpolate)]
63
+ when :td
64
+ [factory_call((table_section == :thead) ? :th : :td, node.children, attrs: node.attr, parent: node, table_section: table_section, interpolate: interpolate)]
65
+ when :img, :hr, :br
66
+ [factory_call(node.type, [], attrs: node.attr, parent: node, interpolate: interpolate)]
67
+ when :codespan
68
+ [factory_call(:code, [node], raw_text: node.value, attrs: node.attr, parent: node, interpolate: interpolate)]
69
+ when :codeblock
70
+ code = factory_call(:code, [node], raw_text: node.value, attrs: node.attr, parent: node, interpolate: interpolate)
71
+ [factory_call(:pre, [], child_sources: [code], parent: node, interpolate: interpolate)]
72
+ when :html_element
73
+ [factory_call(node.value.to_sym, node.children, attrs: node.attr, parent: node, interpolate: interpolate)]
74
+ when :raw
75
+ [restore_escaped_interpolation(node.value.to_s).inspect]
76
+ else
77
+ [compile_children(node.children, parent: node, interpolate: interpolate)]
78
+ end
79
+ end
80
+
81
+ def factory_call(tag, children, parent:, attrs: {}, raw_text: nil, child_sources: nil, table_section: nil, interpolate: false)
82
+ child_sources ||=
83
+ if raw_text.nil?
84
+ compile_child_expressions(children, parent: parent, table_section: table_section, interpolate: interpolate)
85
+ else
86
+ [restore_escaped_interpolation(raw_text).inspect]
87
+ end
88
+ tag_source = tag_source(tag)
89
+ parts = [tag_source, *child_sources]
90
+ parts << "**#{attrs_source(attrs)}" unless attrs.empty?
91
+
92
+ "#{@factory}[#{parts.join(", ")}]"
93
+ end
94
+
95
+ def tag_source(tag)
96
+ "#{@components_source}.fetch(#{tag.inspect}, #{tag.inspect})"
97
+ end
98
+
99
+ def attrs_source(attrs)
100
+ attrs.transform_keys(&:to_sym).inspect
101
+ end
102
+
103
+ def text_expressions(text, interpolate:)
104
+ return [restore_escaped_interpolation(text).inspect] unless interpolate && text.include?("\#{")
105
+
106
+ expressions = []
107
+ buffer = +""
108
+ index = 0
109
+
110
+ while index < text.length
111
+ if text[index, 2] == "\#{"
112
+ expression, next_index = read_interpolation(text, index + 2)
113
+ if expression
114
+ expressions << restore_escaped_interpolation(buffer).inspect unless buffer.empty?
115
+ buffer.clear
116
+ expressions << "(#{expression})"
117
+ index = next_index
118
+ else
119
+ buffer << text[index]
120
+ index += 1
121
+ end
122
+ else
123
+ buffer << text[index]
124
+ index += 1
125
+ end
126
+ end
127
+
128
+ expressions << restore_escaped_interpolation(buffer).inspect unless buffer.empty?
129
+ expressions
130
+ end
131
+
132
+ def read_interpolation(text, index)
133
+ expression = +""
134
+ depth = 1
135
+ quote = nil
136
+ escaped = false
137
+
138
+ while index < text.length
139
+ char = text[index]
140
+
141
+ if quote
142
+ expression << char
143
+ if escaped
144
+ escaped = false
145
+ elsif char == "\\"
146
+ escaped = true
147
+ elsif char == quote
148
+ quote = nil
149
+ end
150
+ else
151
+ case char
152
+ when "\"", "'", "`"
153
+ quote = char
154
+ when "{"
155
+ depth += 1
156
+ when "}"
157
+ depth -= 1
158
+ return [expression.strip, index + 1] if depth.zero?
159
+ end
160
+
161
+ expression << char
162
+ end
163
+
164
+ index += 1
165
+ end
166
+
167
+ nil
168
+ end
169
+
170
+ def protect_escaped_interpolation(source)
171
+ source.gsub("\\\#{", ESCAPED_INTERPOLATION_SENTINEL)
172
+ end
173
+
174
+ def restore_escaped_interpolation(text)
175
+ text.gsub(ESCAPED_INTERPOLATION_SENTINEL, "\#{")
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end