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,942 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../dependency"
4
+ require_relative "../errors"
5
+ require_relative "../module_id"
6
+ require_relative "../plugin"
7
+ require "klenod/runtime/source_map"
8
+ require_relative "../transform_result"
9
+ require_relative "../watched_pattern"
10
+
11
+ module Klenod
12
+ module Build
13
+ module Plugins
14
+ module RouterPlugin
15
+ def self.new(...)
16
+ Plugin.new(...)
17
+ end
18
+
19
+ class Plugin < Klenod::Build::Plugin
20
+ RouteSegment = Data.define(:name, :kind, :param_name, :path_part) do
21
+ def self.parse(name)
22
+ case name
23
+ when /\A\[\[\.\.\.(?<param_name>[A-Za-z_]\w*)\]\]\z/
24
+ new(name, :optional_catch_all, $~[:param_name], nil)
25
+ when /\A\[\.\.\.(?<param_name>[A-Za-z_]\w*)\]\z/
26
+ new(name, :catch_all, $~[:param_name], "*#{$~[:param_name]}")
27
+ when /\A\[(?<param_name>[A-Za-z_]\w*)\]\z/
28
+ new(name, :dynamic, $~[:param_name], ":#{$~[:param_name]}")
29
+ when /\A\(\.\)(?<path_part>.+)\z/
30
+ new(name, :intercept_current, nil, $~[:path_part])
31
+ when /\A\(\.\.\)(?<path_part>.+)\z/
32
+ new(name, :intercept_parent, nil, $~[:path_part])
33
+ when /\A\(\.\.\.\)(?<path_part>.+)\z/
34
+ new(name, :intercept_root, nil, $~[:path_part])
35
+ when /\A\((?<group_name>.+)\)\z/
36
+ new(name, :group, nil, nil)
37
+ when /\A@(?<slot_name>.+)\z/
38
+ new(name, :parallel, $~[:slot_name], nil)
39
+ else
40
+ new(name, :static, nil, name)
41
+ end
42
+ end
43
+ end
44
+
45
+ RouteParam = Data.define(:name, :kind)
46
+ PARAM_SEGMENT_KINDS = [:dynamic, :catch_all, :optional_catch_all].freeze
47
+ ROUTE_FILE_BASENAMES = ["+page", "+layout", "+route", "+error", "+not-found"].freeze
48
+
49
+ PageRoute = Data.define(:path, :page_module_id, :handler_module_id, :segments, :layout_module_ids, :slot_layout_module_id) do
50
+ def params
51
+ segments
52
+ .select { |segment| segment.param_name && PARAM_SEGMENT_KINDS.include?(segment.kind) }
53
+ .map { |segment| RouteParam.new(segment.param_name, segment.kind) }
54
+ end
55
+
56
+ def module_id
57
+ page_module_id || handler_module_id
58
+ end
59
+
60
+ def kind
61
+ return :page_and_handler if page_module_id && handler_module_id
62
+ return :page if page_module_id
63
+
64
+ :handler
65
+ end
66
+ end
67
+
68
+ SpecialView = Data.define(:kind, :path, :view_module_id, :segments, :layout_module_ids, :status) do
69
+ def module_id
70
+ view_module_id
71
+ end
72
+ end
73
+
74
+ RouteManifest = Data.define(:routes, :special_views) do
75
+ def entrypoints
76
+ [
77
+ *routes.flat_map { |route| [route.page_module_id, route.handler_module_id] },
78
+ *special_views.map(&:view_module_id)
79
+ ].compact.map(&:path)
80
+ end
81
+
82
+ def [](path)
83
+ routes_by_path.fetch(path)
84
+ end
85
+
86
+ def fetch(path, *fallback, &block)
87
+ routes_by_path.fetch(path, *fallback, &block)
88
+ end
89
+
90
+ def each_route(&block)
91
+ return enum_for(:each_route) unless block
92
+
93
+ routes.each(&block)
94
+ end
95
+
96
+ private
97
+
98
+ def routes_by_path
99
+ routes.to_h { |route| [route.path, route] }
100
+ end
101
+ end
102
+
103
+ def initialize(specifier: "virtual:router", pages_dir: "pages", extensions: [".rb", ".haml"], route_base_class: nil)
104
+ @specifier = specifier
105
+ @pages_dir = pages_dir
106
+ @extensions = extensions
107
+ @route_base_class = route_base_class
108
+ @module_id = ModuleId.new("#{specifier}.rb", nil)
109
+ end
110
+
111
+ attr_reader :specifier, :pages_dir, :extensions, :route_base_class
112
+
113
+ def resolve(dependency, _context)
114
+ return nil unless dependency.specifier == specifier
115
+
116
+ ResolvedDependency.new(dependency, @module_id, {virtual: true})
117
+ end
118
+
119
+ def load(module_id, context)
120
+ return nil unless module_id.scheme == :virtual && module_id == @module_id
121
+
122
+ generate_router_source(discover(source_dir: context.source_dir))
123
+ end
124
+
125
+ def transform(module_id, code, context)
126
+ if module_id.scheme == :virtual && module_id == @module_id
127
+ return TransformResult.new(
128
+ code,
129
+ [],
130
+ nil,
131
+ [],
132
+ watched_patterns(module_id),
133
+ {}
134
+ )
135
+ end
136
+
137
+ return TransformResult.identity(code) unless route_handler_module_id?(module_id, context)
138
+
139
+ wrapped = route_handler_source(code)
140
+ TransformResult.new(
141
+ wrapped,
142
+ [],
143
+ Klenod::Runtime::SourceMap::SourceMap.parse(code, wrapped),
144
+ [],
145
+ [],
146
+ {router_route_handler: true}
147
+ )
148
+ end
149
+
150
+ def import_value(_resolved_dependency, record, context)
151
+ return nil unless record.metadata[:router_route_handler]
152
+
153
+ context.mods.fetch(record.id).const_get(:Exports)::Default
154
+ end
155
+
156
+ def runtime_import_value(_resolved_dependency, record, _context)
157
+ return nil unless record.metadata[:router_route_handler]
158
+
159
+ Runtime::DefaultImport.new(:Default)
160
+ end
161
+
162
+ def discover(source_dir:)
163
+ root = Pathname.new(source_dir).expand_path
164
+ validate_plus_files(root)
165
+ RouteManifest.new(routes(source_dir: root), special_views(source_dir: root))
166
+ end
167
+
168
+ private
169
+
170
+ def validate_plus_files(source_dir)
171
+ root = source_dir.join(pages_dir)
172
+ return unless root.directory?
173
+
174
+ unsupported =
175
+ root
176
+ .glob("**/+*")
177
+ .select(&:file?)
178
+ .reject { |path| supported_plus_file?(path) }
179
+ return if unsupported.empty?
180
+
181
+ matches = unsupported.map { |path| relative_path_for(source_dir, path) }.sort.join(", ")
182
+ raise ResolveError, "Unsupported router file #{matches}. Supported router files are +page, +layout, +route, +error, and +not-found."
183
+ end
184
+
185
+ def supported_plus_file?(path)
186
+ basename = path.basename.to_s
187
+ return true if basename == "+route.rb"
188
+
189
+ ROUTE_FILE_BASENAMES.any? do |route_basename|
190
+ next false if route_basename == "+route"
191
+
192
+ extensions.any? { |extension| basename == "#{route_basename}#{extension}" } ||
193
+ basename == "#{route_basename}.css" ||
194
+ basename.match?(/\A#{Regexp.escape(route_basename)}\.intl\.[^\/]+\.toml\z/)
195
+ end
196
+ end
197
+
198
+ def routes(source_dir:)
199
+ route_files(source_dir)
200
+ .group_by { |path| route_key_for(route_segments_for(source_dir, path)) }
201
+ .map { |_route_key, paths| route_for(source_dir, paths) }
202
+ .sort_by(&:path)
203
+ end
204
+
205
+ def route_files(source_dir)
206
+ root = source_dir.join(pages_dir)
207
+ return [] unless root.directory?
208
+
209
+ [
210
+ *extensions.flat_map { |extension| root.glob("**/+page#{extension}") },
211
+ *root.glob("**/+route.rb")
212
+ ].select(&:file?)
213
+ end
214
+
215
+ def special_views(source_dir:)
216
+ special_view_files(source_dir)
217
+ .group_by { |path| [special_view_kind_for(path), route_key_for(route_segments_for(source_dir, path))] }
218
+ .map { |(_kind, _route_key), paths| special_view_for(source_dir, paths) }
219
+ .sort_by { |view| [view.kind.to_s, view.path] }
220
+ end
221
+
222
+ def special_view_files(source_dir)
223
+ root = source_dir.join(pages_dir)
224
+ return [] unless root.directory?
225
+
226
+ extensions.flat_map do |extension|
227
+ [
228
+ *root.glob("**/+not-found#{extension}"),
229
+ *root.glob("**/+error#{extension}")
230
+ ]
231
+ end.select(&:file?).reject { |path| route_segments_for(source_dir, path).any? { |segment| segment.kind == :parallel } }
232
+ end
233
+
234
+ def special_view_for(source_dir, paths)
235
+ path = paths.fetch(0)
236
+ kind = special_view_kind_for(path)
237
+ if paths.length > 1
238
+ matches = paths.map { |match| relative_path_for(source_dir, match) }.sort.join(", ")
239
+ raise ResolveError, "Ambiguous #{kind.to_s.tr("_", "-")} route #{route_path_for(route_segments_for(source_dir, path))}; matched #{matches}. Use only one special view file per directory."
240
+ end
241
+
242
+ segments = route_segments_for(source_dir, path)
243
+ SpecialView.new(
244
+ kind,
245
+ route_path_for(segments),
246
+ ModuleId.new(relative_path_for(source_dir, path), nil),
247
+ segments,
248
+ layout_module_ids_for(source_dir, path),
249
+ (kind == :not_found) ? 404 : 500
250
+ )
251
+ end
252
+
253
+ def special_view_kind_for(path)
254
+ name = path.basename(path.extname).to_s
255
+ return :not_found if name == "+not-found"
256
+ return :error if name == "+error"
257
+
258
+ raise ResolveError, "Unsupported special view #{path}"
259
+ end
260
+
261
+ def watched_patterns(module_id)
262
+ patterns = extensions.flat_map do |extension|
263
+ [
264
+ WatchedPattern.new(module_id, "#{pages_dir}/**/+page#{extension}", :router_page, {}),
265
+ WatchedPattern.new(module_id, "#{pages_dir}/**/+layout#{extension}", :router_layout, {}),
266
+ WatchedPattern.new(module_id, "#{pages_dir}/**/+not-found#{extension}", :router_not_found, {}),
267
+ WatchedPattern.new(module_id, "#{pages_dir}/**/+error#{extension}", :router_error, {})
268
+ ]
269
+ end
270
+ patterns + [WatchedPattern.new(module_id, "#{pages_dir}/**/+route.rb", :router_route, {})]
271
+ end
272
+
273
+ def route_for(source_dir, paths)
274
+ segments = route_segments_for(source_dir, paths.fetch(0))
275
+ route_path = route_path_for(segments)
276
+ page_paths = paths.reject { |path| route_handler_path?(path) }
277
+ handler_path = paths.find { |path| route_handler_path?(path) }
278
+ if page_paths.length > 1
279
+ matches = page_paths.map { |path| relative_path_for(source_dir, path) }.sort.join(", ")
280
+ raise ResolveError, "Ambiguous route #{route_path}; matched #{matches}. Use only one page file per directory."
281
+ end
282
+
283
+ path = page_paths.fetch(0, handler_path)
284
+ PageRoute.new(
285
+ route_path,
286
+ page_paths.fetch(0, nil)&.then { |page_path| ModuleId.new(relative_path_for(source_dir, page_path), nil) },
287
+ handler_path&.then { |route_path| ModuleId.new(relative_path_for(source_dir, route_path), nil) },
288
+ segments,
289
+ page_paths.empty? ? [] : layout_module_ids_for(source_dir, path),
290
+ page_paths.empty? ? nil : slot_layout_module_id_for(source_dir, path, segments)
291
+ )
292
+ end
293
+
294
+ def route_segments_for(source_dir, path)
295
+ relative_dir = path.dirname.relative_path_from(source_dir.join(pages_dir)).to_s
296
+ return [] if relative_dir == "."
297
+
298
+ relative_dir.split("/").map { |name| RouteSegment.parse(name) }
299
+ end
300
+
301
+ def route_path_for(segments)
302
+ path_parts = route_path_parts_for(segments)
303
+
304
+ return "/" if path_parts.empty?
305
+ "/#{path_parts.join("/")}"
306
+ end
307
+
308
+ def route_path_parts_for(segments)
309
+ segments.each_with_object([]) do |segment, path_parts|
310
+ case segment.kind
311
+ when :intercept_current
312
+ path_parts << segment.path_part
313
+ when :intercept_parent
314
+ path_parts.pop
315
+ path_parts << segment.path_part
316
+ when :intercept_root
317
+ path_parts.clear
318
+ path_parts << segment.path_part
319
+ else
320
+ path_parts << segment.path_part if segment.path_part
321
+ end
322
+ end
323
+ end
324
+
325
+ def route_key_for(segments)
326
+ segments.map { |segment| [segment.kind, segment.path_part, segment.param_name] }
327
+ end
328
+
329
+ def layout_module_ids_for(source_dir, path)
330
+ page_root = source_dir.join(pages_dir)
331
+ relative_dir = path.dirname.relative_path_from(page_root).to_s
332
+ dirs =
333
+ if relative_dir == "."
334
+ [page_root]
335
+ else
336
+ parts = relative_dir.split("/")
337
+ [page_root] + parts.each_index.map { |index| page_root.join(*parts[0..index]) }
338
+ end
339
+
340
+ dirs.filter_map do |dir|
341
+ layout_path = layout_path_for(dir)
342
+ ModuleId.new(relative_path_for(source_dir, layout_path), nil) if layout_path
343
+ end
344
+ end
345
+
346
+ def slot_layout_module_id_for(source_dir, path, segments)
347
+ parallel_index = segments.index { |segment| segment.kind == :parallel }
348
+ return nil unless parallel_index
349
+
350
+ page_root = source_dir.join(pages_dir)
351
+ relative_dir = path.dirname.relative_path_from(page_root).to_s
352
+ owner_parts = (relative_dir == ".") ? [] : relative_dir.split("/").first(parallel_index)
353
+ owner_dir = owner_parts.empty? ? page_root : page_root.join(*owner_parts)
354
+ layout_path = layout_path_for(owner_dir)
355
+
356
+ ModuleId.new(relative_path_for(source_dir, layout_path), nil) if layout_path
357
+ end
358
+
359
+ def layout_path_for(dir)
360
+ matches = extensions.filter_map do |extension|
361
+ path = dir.join("+layout#{extension}")
362
+ path if path.file?
363
+ end
364
+ return nil if matches.empty?
365
+ return matches.fetch(0) if matches.length <= 1
366
+
367
+ relative = matches.map(&:to_s).sort.join(", ")
368
+ raise ResolveError, "Ambiguous layout route; matched #{relative}. Use only one layout file per directory."
369
+ end
370
+
371
+ def relative_path_for(source_dir, path)
372
+ path.relative_path_from(source_dir).to_s
373
+ end
374
+
375
+ def generate_router_source(manifest)
376
+ imports = import_refs(manifest)
377
+ <<~RUBY
378
+ Segment = Data.define(:name, :kind, :param_name, :path_part)
379
+ Param = Data.define(:name, :kind)
380
+ #{import_definitions(imports)}
381
+
382
+ module Default
383
+ Route = Data.define(:path, :page_module_id, :handler_module_id, :segments, :match_parts, :layout_module_ids, :slot_layout_module_id, :page_ref, :handler_ref, :layout_refs) do
384
+ def params
385
+ segments
386
+ .select { |segment| segment.param_name && [:dynamic, :catch_all, :optional_catch_all].include?(segment.kind) }
387
+ .map { |segment| Param.new(segment.param_name, segment.kind) }
388
+ end
389
+
390
+ def module_id
391
+ page_module_id || handler_module_id
392
+ end
393
+
394
+ def kind
395
+ return :page_and_handler if page_module_id && handler_module_id
396
+ return :page if page_module_id
397
+
398
+ :handler
399
+ end
400
+
401
+ def page
402
+ return nil unless page_ref
403
+
404
+ Default.resolve_import(page_ref)
405
+ end
406
+
407
+ def handler
408
+ return nil unless handler_ref
409
+
410
+ Default.resolve_import(handler_ref)
411
+ end
412
+
413
+ def layouts
414
+ layout_refs.map { |layout_ref| Default.resolve_import(layout_ref) }
415
+ end
416
+ end
417
+
418
+ SpecialView = Data.define(:kind, :path, :status, :module_id, :segments, :match_parts, :layout_module_ids, :page_ref, :layout_refs) do
419
+ def page
420
+ Default.resolve_import(page_ref)
421
+ end
422
+
423
+ def layouts
424
+ layout_refs.map { |layout_ref| Default.resolve_import(layout_ref) }
425
+ end
426
+ end
427
+
428
+ class RouteNode
429
+ attr_reader :segment, :path, :children, :slots
430
+ attr_accessor :route
431
+
432
+ def initialize(segment:, path:, route: nil)
433
+ @segment = segment
434
+ @path = path
435
+ @route = route
436
+ @children = []
437
+ @slots = {}
438
+ end
439
+
440
+ def root?
441
+ segment.nil?
442
+ end
443
+
444
+ def leaf?
445
+ !route.nil?
446
+ end
447
+
448
+ def child_for(segment)
449
+ children.find { |child| Default.same_segment?(child.segment, segment) }
450
+ end
451
+
452
+ def add_child(segment, path)
453
+ child_for(segment) || children.tap { _1 << RouteNode.new(segment: segment, path: path) }.last
454
+ end
455
+
456
+ def add_slot(segment, path)
457
+ slots[segment.param_name.to_sym] ||= add_child(segment, path)
458
+ end
459
+ end
460
+
461
+ SlotMatch = Data.define(:route, :params, :layout_module_id) do
462
+ def page
463
+ route.page
464
+ end
465
+
466
+ def layouts
467
+ route.layouts
468
+ end
469
+ end
470
+
471
+ Match = Data.define(:route, :params, :slots) do
472
+ def page
473
+ route.page
474
+ end
475
+
476
+ def handler
477
+ route.handler
478
+ end
479
+
480
+ def layouts
481
+ route.layouts
482
+ end
483
+ end
484
+
485
+ SpecialMatch = Data.define(:route, :params) do
486
+ def page
487
+ route.page
488
+ end
489
+
490
+ def layouts
491
+ route.layouts
492
+ end
493
+
494
+ def slots
495
+ {}
496
+ end
497
+
498
+ def status
499
+ route.status
500
+ end
501
+ end
502
+
503
+ ROUTES = [
504
+ #{route_entries(matching_routes(manifest), imports: imports).join(",\n")}
505
+ ].freeze
506
+ NOT_FOUND_VIEWS = [
507
+ #{special_view_entries(special_views_for(manifest, :not_found), imports: imports).join(",\n")}
508
+ ].freeze
509
+ ERROR_VIEWS = [
510
+ #{special_view_entries(special_views_for(manifest, :error), imports: imports).join(",\n")}
511
+ ].freeze
512
+
513
+ def self.routes
514
+ ROUTES
515
+ end
516
+
517
+ def self.not_found_views
518
+ NOT_FOUND_VIEWS
519
+ end
520
+
521
+ def self.error_views
522
+ ERROR_VIEWS
523
+ end
524
+
525
+ def self.tree
526
+ TREE
527
+ end
528
+
529
+ def self.match(path)
530
+ parts = normalize_path(path)
531
+ route = main_route_for(parts)
532
+ return nil unless route
533
+
534
+ Match.new(route, params_for(route, parts), slot_matches_for(route, parts))
535
+ end
536
+
537
+ def self.not_found(path)
538
+ special_match(NOT_FOUND_VIEWS, path)
539
+ end
540
+
541
+ def self.error(path)
542
+ special_match(ERROR_VIEWS, path)
543
+ end
544
+
545
+ def self.resolve_import(value)
546
+ value.respond_to?(:call) && value.class.name == "Klenod::Runtime::LazyImport" ? value.call : value
547
+ end
548
+
549
+ def self.normalize_path(path)
550
+ normalized = path.to_s.split("?", 2).first
551
+ normalized = "/\#{normalized}" unless normalized.start_with?("/")
552
+ normalized.split("/").reject(&:empty?)
553
+ end
554
+
555
+ def self.build_tree(routes)
556
+ root = RouteNode.new(segment: nil, path: "/")
557
+
558
+ routes.each do |route|
559
+ cursor = root
560
+ path_parts = []
561
+
562
+ route.segments.each do |segment|
563
+ update_path_parts(path_parts, segment)
564
+ path = path_parts.empty? ? "/" : "/\#{path_parts.join("/")}"
565
+ cursor = segment.kind == :parallel ? cursor.add_slot(segment, path) : cursor.add_child(segment, path)
566
+ end
567
+
568
+ cursor.route = route
569
+ end
570
+
571
+ root
572
+ end
573
+
574
+ def self.update_path_parts(path_parts, segment)
575
+ case segment.kind
576
+ when :intercept_current
577
+ path_parts << segment.path_part
578
+ when :intercept_parent
579
+ path_parts.pop
580
+ path_parts << segment.path_part
581
+ when :intercept_root
582
+ path_parts.clear
583
+ path_parts << segment.path_part
584
+ else
585
+ path_parts << segment.path_part if segment.path_part
586
+ end
587
+ end
588
+
589
+ def self.same_segment?(left, right)
590
+ left&.name == right&.name &&
591
+ left&.kind == right&.kind &&
592
+ left&.param_name == right&.param_name &&
593
+ left&.path_part == right&.path_part
594
+ end
595
+
596
+ def self.route_matches?(route, parts)
597
+ cursor = 0
598
+ route.match_parts.each do |match_part|
599
+ case match_part[0]
600
+ when :static
601
+ return false unless parts[cursor] == match_part[1]
602
+ cursor += 1
603
+ when :dynamic
604
+ return false unless parts[cursor]
605
+ cursor += 1
606
+ when :catch_all
607
+ return false if parts[cursor..].empty?
608
+ cursor = parts.length
609
+ when :optional_catch_all
610
+ cursor = parts.length
611
+ end
612
+ end
613
+ cursor == parts.length
614
+ end
615
+
616
+ def self.route_prefix_matches?(route, parts)
617
+ cursor = 0
618
+ route.match_parts.each do |match_part|
619
+ case match_part[0]
620
+ when :static
621
+ return false unless parts[cursor] == match_part[1]
622
+ cursor += 1
623
+ when :dynamic
624
+ return false unless parts[cursor]
625
+ cursor += 1
626
+ when :catch_all, :optional_catch_all
627
+ cursor = parts.length
628
+ end
629
+ end
630
+ true
631
+ end
632
+
633
+ def self.special_match(views, path)
634
+ parts = normalize_path(path)
635
+ view = special_view_for(views, parts)
636
+ return nil unless view
637
+
638
+ SpecialMatch.new(view, params_for(view, parts))
639
+ end
640
+
641
+ def self.special_view_for(views, parts)
642
+ views
643
+ .select { |view| special_view_matches?(view, parts) }
644
+ .max_by { |view| [fallback_depth(view), -route_score_for(view)] }
645
+ end
646
+
647
+ def self.special_view_matches?(view, parts)
648
+ cursor = 0
649
+ view.match_parts.each do |match_part|
650
+ case match_part[0]
651
+ when :static
652
+ return false unless parts[cursor] == match_part[1]
653
+ cursor += 1
654
+ when :dynamic
655
+ return false unless parts[cursor]
656
+ cursor += 1
657
+ when :catch_all, :optional_catch_all
658
+ cursor = parts.length
659
+ end
660
+ end
661
+ cursor <= parts.length
662
+ end
663
+
664
+ def self.fallback_depth(view)
665
+ view.match_parts.count { |match_part| [:static, :dynamic, :catch_all, :optional_catch_all].include?(match_part[0]) }
666
+ end
667
+
668
+ def self.route_score_for(route)
669
+ route.match_parts.sum do |match_part|
670
+ case match_part[0]
671
+ when :static
672
+ 0
673
+ when :dynamic
674
+ 10
675
+ when :catch_all
676
+ 100
677
+ when :optional_catch_all
678
+ 1_000
679
+ else
680
+ 0
681
+ end
682
+ end
683
+ end
684
+
685
+ def self.main_route_for(parts)
686
+ MAIN_ROUTES.find { |candidate| route_matches?(candidate, parts) }
687
+ end
688
+
689
+ def self.slot_matches_for(main_route, parts)
690
+ SLOT_ROUTES.each_with_object({}) do |route, slots|
691
+ next unless route_matches?(route, parts)
692
+ next unless route_prefix_matches?(main_route, normalize_path(route.path))
693
+
694
+ slots[slot_name_for(route)] = SlotMatch.new(route, params_for(route, parts), route.slot_layout_module_id)
695
+ end
696
+ end
697
+
698
+ def self.parallel_route?(route)
699
+ route.segments.any? { |segment| segment.kind == :parallel }
700
+ end
701
+
702
+ def self.slot_name_for(route)
703
+ route.segments.find { |segment| segment.kind == :parallel }.param_name.to_sym
704
+ end
705
+
706
+ def self.params_for(route, parts)
707
+ cursor = 0
708
+ params = {}
709
+ route.match_parts.each do |match_part|
710
+ case match_part[0]
711
+ when :static
712
+ cursor += 1
713
+ when :dynamic
714
+ params[match_part[2].to_sym] = parts[cursor]
715
+ cursor += 1
716
+ when :catch_all
717
+ params[match_part[2].to_sym] = parts[cursor..]
718
+ cursor = parts.length
719
+ when :optional_catch_all
720
+ params[match_part[2].to_sym] = parts[cursor..]
721
+ cursor = parts.length
722
+ end
723
+ end
724
+ params
725
+ end
726
+
727
+ MAIN_ROUTES = ROUTES.reject { |route| parallel_route?(route) }.freeze
728
+ SLOT_ROUTES = ROUTES.select { |route| parallel_route?(route) }.freeze
729
+ TREE = build_tree(ROUTES)
730
+ end
731
+ RUBY
732
+ end
733
+
734
+ def route_entries(routes, imports:)
735
+ routes.map do |route|
736
+ layouts = route.layout_module_ids.map { |module_id| imports.fetch(module_id.to_s) }
737
+ [
738
+ " Route.new(",
739
+ " #{route.path.inspect},",
740
+ " #{route.page_module_id&.path.inspect},",
741
+ " #{route.handler_module_id&.path.inspect},",
742
+ " #{segments_source(route.segments)},",
743
+ " #{match_parts_source(route.segments)},",
744
+ " #{route.layout_module_ids.map(&:path).inspect},",
745
+ " #{route.slot_layout_module_id&.path.inspect},",
746
+ " #{route.page_module_id ? imports.fetch(route.page_module_id.to_s) : "nil"},",
747
+ " #{route.handler_module_id ? imports.fetch(route.handler_module_id.to_s) : "nil"},",
748
+ " #{layouts_source(layouts)}",
749
+ " )"
750
+ ].join("\n")
751
+ end
752
+ end
753
+
754
+ def special_view_entries(views, imports:)
755
+ views.map do |view|
756
+ layouts = view.layout_module_ids.map { |module_id| imports.fetch(module_id.to_s) }
757
+ [
758
+ " SpecialView.new(",
759
+ " #{view.kind.inspect},",
760
+ " #{view.path.inspect},",
761
+ " #{view.status.inspect},",
762
+ " #{view.view_module_id.path.inspect},",
763
+ " #{segments_source(view.segments)},",
764
+ " #{match_parts_source(view.segments)},",
765
+ " #{view.layout_module_ids.map(&:path).inspect},",
766
+ " #{imports.fetch(view.view_module_id.to_s)},",
767
+ " #{layouts_source(layouts)}",
768
+ " )"
769
+ ].join("\n")
770
+ end
771
+ end
772
+
773
+ def special_views_for(manifest, kind)
774
+ manifest.special_views.select { |view| view.kind == kind }
775
+ end
776
+
777
+ def matching_routes(manifest)
778
+ manifest.routes.sort_by { |route| route_priority(route) }
779
+ end
780
+
781
+ def route_priority(route)
782
+ [
783
+ route_score(route),
784
+ -visible_segments(route).length,
785
+ route.path
786
+ ]
787
+ end
788
+
789
+ def route_score(route)
790
+ visible_segments(route).sum { |segment| segment_score(segment) }
791
+ end
792
+
793
+ def visible_segments(route)
794
+ route.segments.reject { |segment| [:group, :parallel].include?(segment.kind) }
795
+ end
796
+
797
+ def segment_score(segment)
798
+ case segment.kind
799
+ when :static, :intercept_current, :intercept_parent, :intercept_root
800
+ 0
801
+ when :dynamic
802
+ 10
803
+ when :catch_all
804
+ 100
805
+ when :optional_catch_all
806
+ 1_000
807
+ else
808
+ 0
809
+ end
810
+ end
811
+
812
+ def import_refs(manifest)
813
+ specifiers =
814
+ [
815
+ *manifest.routes.flat_map do |route|
816
+ [
817
+ route.page_module_id&.to_s,
818
+ route.handler_module_id&.to_s,
819
+ *route.layout_module_ids.map(&:to_s)
820
+ ]
821
+ end,
822
+ *manifest.special_views.flat_map do |view|
823
+ [
824
+ view.view_module_id.to_s,
825
+ *view.layout_module_ids.map(&:to_s)
826
+ ]
827
+ end
828
+ ].uniq
829
+ specifiers.compact!
830
+
831
+ specifiers.each_with_index.to_h do |specifier, index|
832
+ [specifier, "ROUTER_IMPORT_#{index}"]
833
+ end
834
+ end
835
+
836
+ def import_definitions(imports)
837
+ imports
838
+ .map { |specifier, const_name| "#{const_name} = lazy_import(#{specifier.inspect})" }
839
+ .join("\n")
840
+ end
841
+
842
+ def segments_source(segments)
843
+ "[#{segments.map { |segment| segment_source(segment) }.join(", ")}]"
844
+ end
845
+
846
+ def match_parts_source(segments)
847
+ route_match_parts_for(segments).inspect
848
+ end
849
+
850
+ def segment_source(segment)
851
+ "Segment.new(#{segment.name.inspect}, #{segment.kind.inspect}, #{segment.param_name.inspect}, #{segment.path_part.inspect})"
852
+ end
853
+
854
+ def route_match_parts_for(segments)
855
+ route_segments_for_matching(segments).filter_map do |segment|
856
+ case segment.kind
857
+ when :static, :intercept_current, :intercept_parent, :intercept_root
858
+ [:static, segment.path_part, nil]
859
+ when :dynamic, :catch_all, :optional_catch_all
860
+ [segment.kind, segment.path_part, segment.param_name]
861
+ end
862
+ end
863
+ end
864
+
865
+ def route_segments_for_matching(segments)
866
+ segments.each_with_object([]) do |segment, match_segments|
867
+ case segment.kind
868
+ when :group, :parallel
869
+ next
870
+ when :intercept_current
871
+ match_segments << segment
872
+ when :intercept_parent
873
+ match_segments.pop
874
+ match_segments << segment
875
+ when :intercept_root
876
+ match_segments.clear
877
+ match_segments << segment
878
+ else
879
+ match_segments << segment if segment.path_part || [:dynamic, :catch_all, :optional_catch_all].include?(segment.kind)
880
+ end
881
+ end
882
+ end
883
+
884
+ def layouts_source(layouts)
885
+ "[#{layouts.join(", ")}]"
886
+ end
887
+
888
+ def route_handler_module_id?(module_id, context)
889
+ return false unless module_id.path.start_with?("#{pages_dir}/")
890
+ return false unless module_id.path.end_with?("/+route.rb")
891
+
892
+ route_handler_module_ids(context.source_dir).include?(module_id)
893
+ end
894
+
895
+ def route_handler_module_ids(source_dir)
896
+ discover(source_dir: source_dir).routes.filter_map(&:handler_module_id)
897
+ end
898
+
899
+ def route_handler_path?(path)
900
+ path.basename.to_s == "+route.rb"
901
+ end
902
+
903
+ def route_handler_source(source)
904
+ superclass = route_base_class ? " < #{route_base_class}" : ""
905
+ <<~RUBY
906
+ # frozen_string_literal: true
907
+ KlenodImport = method(:__klenod_import__)
908
+ class Route#{superclass}
909
+ def self.__klenod_import__(dependency_id)
910
+ KlenodImport.call(dependency_id)
911
+ end
912
+
913
+ def __klenod_import__(dependency_id)
914
+ self.class.__klenod_import__(dependency_id)
915
+ end
916
+
917
+ #{marked_route_handler_body(source)}
918
+ end
919
+ Default = Route
920
+ RUBY
921
+ end
922
+
923
+ def marked_route_handler_body(source)
924
+ source.each_line.with_index(1).map do |line, line_no|
925
+ if ruby_magic_comment?(line)
926
+ ""
927
+ elsif line.strip.empty?
928
+ line
929
+ else
930
+ " # #{Klenod::Runtime::SourceMap::Mark.new(line_no)}\n #{line}"
931
+ end
932
+ end.join
933
+ end
934
+
935
+ def ruby_magic_comment?(line)
936
+ line.match?(/\A#\s*(?:frozen_string_literal|encoding):/)
937
+ end
938
+ end
939
+ end
940
+ end
941
+ end
942
+ end