lilac-router 0.2.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7a6abd5e56ff36094f0bb8069844b33877b2e3dd58ef62739a247a66733e2fe7
4
+ data.tar.gz: af8c214eaafea581124411ebe3fbd395e52eddad3c6be657200ba8e0ad364e07
5
+ SHA512:
6
+ metadata.gz: 69df2c585a1ffebd1444faa6727cd7d452b0d0d752d84ee3db16536c5e08270a41543b2b7c2b6c4fe219c0a1bed5d379e394372e8b62dc41280324beb014aac6
7
+ data.tar.gz: c24df74f2d0721903f9942219c88b4476a38379dfa7b412538f3dd59f69421ebf95082c056ff1a36672e5cf01660d0f883c3127491effd2ee64153328f68de8c
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "lilac-router"
5
+ spec.version = "0.2.0"
6
+ spec.authors = ["takahashim"]
7
+
8
+ spec.summary = "Lilac package: signal-based URL routing (`Lilac::Router`)"
9
+ spec.description = "Provides `Lilac::Router`, a signal-backed driver for " \
10
+ "SPA-style navigation against `window.location`. " \
11
+ "Designed for the lilac-compiled wasm variant; " \
12
+ "lilac-full already links the router gem in. Picked up " \
13
+ "automatically by `lilac build` via Bundler when the " \
14
+ "gem appears in the project's Gemfile."
15
+ spec.license = "MIT"
16
+ spec.required_ruby_version = ">= 3.2.0"
17
+
18
+ # See lilac-extras.gemspec for the mrblib source distribution rationale.
19
+ spec.files = Dir["mrblib/**/*.rb", "*.gemspec"]
20
+
21
+ spec.metadata = {
22
+ "lilac_package" => "true",
23
+ "source_code_uri" => "https://github.com/takahashim/lilac",
24
+ "rubygems_mfa_required" => "true",
25
+ }
26
+ end
@@ -0,0 +1,683 @@
1
+ # Lilac Router — signal-based URL routing for `mruby-lilac`.
2
+ #
3
+ # 2-layer API:
4
+ #
5
+ # * Low-level: `router.location` (Signal), `path`/`query`/`hash` (sugar),
6
+ # `match(pattern)` (Computed), `navigate(path, replace:)`,
7
+ # `intercept_link(event)`. SPA-style URL state as a reactive signal.
8
+ #
9
+ # * High-level DSL: `router.draw outlet:` block + `page :name, "/path",
10
+ # template:` declarations. Generates `*_path` / `*_match` helpers via
11
+ # `define_singleton_method`, plus `params` / `current` computeds. Active
12
+ # route's `<template id="...">` is cloned into the outlet on every
13
+ # location change (lazy mount via existing
14
+ # MutationObserver-driven component lifecycle).
15
+ #
16
+ # This gem is opt-in: `mruby-lilac` core does not depend on it. MPA
17
+ # constructions (each route is its own HTML file) should not load this
18
+ # gem at all — Router is for SPA navigation only.
19
+ module Lilac
20
+ module Router
21
+ # Bookkeeper for `*_path` / `*_match` singleton methods generated by
22
+ # the `page` DSL. Encapsulates the lifecycle (define, remember,
23
+ # remove) so Router proper can stay focused on URL state.
24
+ class GeneratedRouteMethods
25
+ def initialize(target)
26
+ @target = target
27
+ @names = []
28
+ end
29
+
30
+ def define_path(name, compiled)
31
+ required = compiled[:params]
32
+ segs = compiled[:segs]
33
+ method_name = "#{name}_path"
34
+ @target.define_singleton_method(method_name) do |**params|
35
+ missing = required - params.keys
36
+ raise ArgumentError, "missing keys: #{missing.inspect}" unless missing.empty?
37
+ extra = params.keys - required
38
+ raise ArgumentError, "unknown keys: #{extra.inspect}" unless extra.empty?
39
+ segs.map { |seg|
40
+ seg.start_with?(":") ? encode(params[seg[1..].to_sym]) : seg
41
+ }.join("/")
42
+ end
43
+ remember(method_name)
44
+ end
45
+
46
+ def define_match(name, compiled)
47
+ sym = name.to_sym
48
+ method_name = "#{name}_match"
49
+ @target.define_singleton_method(method_name) do
50
+ ensure_signal!
51
+ @named_match_computeds[sym] ||= begin
52
+ loc = @location_signal
53
+ Lilac::Computed.new { match_path(compiled, loc.value[:path]) }
54
+ end
55
+ end
56
+ remember(method_name)
57
+ end
58
+
59
+ # Remove every helper this bookkeeper has installed. Idempotent.
60
+ def clear_all
61
+ eigen = (class << @target; self; end)
62
+ @names.each do |meth|
63
+ begin
64
+ eigen.__send__(:remove_method, meth)
65
+ rescue NameError
66
+ # Already absent; tolerated for re-entrant draw / reset.
67
+ end
68
+ end
69
+ @names.clear
70
+ end
71
+
72
+ private
73
+
74
+ def remember(method_name)
75
+ sym = method_name.to_sym
76
+ @names << sym unless @names.include?(sym)
77
+ end
78
+ end
79
+
80
+ # Stateful router object. `Lilac::Router` exposes the default context as
81
+ # a compatibility facade, while components receive a context-shaped `router`
82
+ # object that can later be replaced through provider injection.
83
+ class Context
84
+ def initialize
85
+ @started = false
86
+ @routes = []
87
+ @fallback_template = nil
88
+ @outlet_selector = nil
89
+ @mode = :hash
90
+ @base = "/"
91
+ @location_signal = nil
92
+ @match_computed_cache = {}
93
+ @named_match_computeds = {}
94
+ @params_computed = nil
95
+ @current_computed = nil
96
+ @route_methods = GeneratedRouteMethods.new(self)
97
+ @hashchange_cb = nil
98
+ @popstate_cb = nil
99
+ @render_effect = nil
100
+ @rendering = false
101
+ end
102
+
103
+ # ---------- Bootstrap ----------
104
+
105
+ # Install listeners and seed the location signal. Idempotent: a
106
+ # second call after the first start is a no-op. Safe to call
107
+ # *after* components that already accessed `path` / `match` / etc.
108
+ # — those triggered `ensure_signal!` lazily, and we reuse the same
109
+ # signal here so their subscriptions stay connected.
110
+ def start(mode: nil, base: nil)
111
+ return if @started
112
+ @mode = mode if mode
113
+ @base = base if base
114
+ ensure_signal!
115
+ # Force-refresh the signal so subscribers (computeds that read
116
+ # `current` / `params` before draw) re-evaluate now that the
117
+ # route table is populated. Hash-valued signals always notify
118
+ # via `value=` (no equal_for_skip? short-circuit on Hash), so
119
+ # this is unconditional.
120
+ @location_signal.value = read_location
121
+ install_listeners
122
+ # Lazy-mount render effect, only if a draw with outlet has been
123
+ # configured. Subscribes to @location_signal and re-clones the
124
+ # active template on change.
125
+ @render_effect = Lilac::Effect.new(label: "router-render") { render_active_route } if @outlet_selector
126
+ @started = true
127
+ nil
128
+ end
129
+
130
+ # ---------- DSL ----------
131
+
132
+ # Declare routes. Required for lazy-mount usage; not needed for
133
+ # low-level only (just call `start`). `outlet:` is a CSS selector
134
+ # passed to `document.querySelector` for the single mount target.
135
+ def draw(outlet:, &block)
136
+ raise ArgumentError, "block required" unless block
137
+ @outlet_selector = outlet
138
+ @route_methods.clear_all
139
+ @routes.clear
140
+ @fallback_template = nil
141
+ @named_match_computeds.clear
142
+ instance_eval(&block)
143
+ # If start has already run (test re-init flow), set up the render
144
+ # effect now that we have an outlet.
145
+ if @started && @render_effect.nil?
146
+ @render_effect = Lilac::Effect.new(label: "router-render") { render_active_route }
147
+ elsif @started
148
+ render_active_route
149
+ end
150
+ nil
151
+ end
152
+
153
+ # Declare a named route. `template:` defaults to "page-#{name}"
154
+ # (convention). Auto-generates `Lilac::Router.{name}_path` and
155
+ # `Lilac::Router.{name}_match` via `define_singleton_method`
156
+ # (requires mruby-metaprog in the build config). Method lifecycle
157
+ # is owned by `@route_methods` (see `GeneratedRouteMethods`).
158
+ def page(name, pattern, template: nil)
159
+ compiled = compile(pattern)
160
+ template_id = template || "page-#{name}"
161
+ @routes << { name: name.to_sym, pattern: pattern, compiled: compiled, template: template_id }
162
+ @route_methods.define_path(name, compiled)
163
+ @route_methods.define_match(name, compiled)
164
+ end
165
+
166
+ # Last-resort route shown when no `page` matches. Takes only a
167
+ # template (no path, no helpers).
168
+ def fallback(template:)
169
+ @fallback_template = template
170
+ end
171
+
172
+ # ---------- Reactive accessors ----------
173
+
174
+ def location
175
+ ensure_signal!
176
+ @location_signal
177
+ end
178
+
179
+ def path
180
+ ensure_signal!
181
+ @location_signal.value[:path]
182
+ end
183
+
184
+ def query
185
+ ensure_signal!
186
+ @location_signal.value[:query]
187
+ end
188
+
189
+ def hash
190
+ ensure_signal!
191
+ @location_signal.value[:hash]
192
+ end
193
+
194
+ # Match computed for an arbitrary pattern. Cached per pattern string so
195
+ # two components matching the same pattern share one computed (avoids
196
+ # graph blowup on hot paths).
197
+ def match(pattern)
198
+ ensure_signal!
199
+ @match_computed_cache[pattern] ||= begin
200
+ compiled = compile(pattern)
201
+ loc = @location_signal
202
+ Lilac::Computed.new { match_path(compiled, loc.value[:path]) }
203
+ end
204
+ end
205
+
206
+ # Hash of params for the currently-active named route, or `{}` if
207
+ # no route matches. Only meaningful with the DSL.
208
+ #
209
+ # Accessing `params` inside a reactive scope (computed / effect / bind)
210
+ # registers a dependency on the location signal automatically (via
211
+ # the internal `@location_signal.value` read), so `params[:id]`
212
+ # in a `computed { ... }` will re-evaluate on URL change.
213
+ def params
214
+ ensure_signal!
215
+ current_route_params
216
+ end
217
+
218
+ # Active route name (`:home` / `:user` / `:fallback` / nil). Only
219
+ # meaningful with the DSL. Auto-tracks location like `params`.
220
+ def current
221
+ ensure_signal!
222
+ current_route_name
223
+ end
224
+
225
+ # ---------- Link helpers ----------
226
+
227
+ # Convert an app-route path into an actual href suitable for `<a>`.
228
+ # In hash mode this returns "#/path"; in history mode it includes
229
+ # `base:` as a pathname prefix.
230
+ def href(path)
231
+ raw = path.to_s
232
+ return raw if external_url?(raw)
233
+
234
+ build_navigate_url(resolve(raw))
235
+ end
236
+
237
+ # Resolve a relative path against the current route path.
238
+ # Uses the browser URL implementation instead of hand-rolling URL
239
+ # dot-segment / query / fragment behavior.
240
+ def resolve(path, from: nil)
241
+ ensure_signal!
242
+ raw = path.to_s
243
+ return raw if external_url?(raw)
244
+
245
+ origin = JS.global[:location][:origin].to_s
246
+ base_path = from || @location_signal.value[:path]
247
+ base_url = "#{origin}#{base_path}"
248
+ url = JS.global[:URL].new(raw, base_url)
249
+ "#{url[:pathname]}#{url[:search]}#{url[:hash]}"
250
+ rescue StandardError
251
+ raw.start_with?("/") ? raw : "/#{raw}"
252
+ end
253
+
254
+ # True when the current route matches `target`.
255
+ #
256
+ # `target` may be:
257
+ # - Symbol route name (`:home`)
258
+ # - Array of route names (`[:users, :user]`)
259
+ # - Path string (`"/users"`), with descendant matching by default
260
+ def active?(target, exact: false)
261
+ ensure_signal!
262
+ case target
263
+ when Array
264
+ target.include?(current_route_name)
265
+ when Symbol
266
+ current_route_name == target
267
+ else
268
+ target_path = route_path_only(resolve(target))
269
+ current_path = route_path_only(@location_signal.value[:path])
270
+ return current_path == target_path if exact || target_path == "/"
271
+
272
+ prefix = target_path.end_with?("/") ? target_path : "#{target_path}/"
273
+ current_path == target_path || current_path.start_with?(prefix)
274
+ end
275
+ end
276
+
277
+ # Bind one anchor as a router-aware link: writes `href` and toggles
278
+ # an active CSS class reactively.
279
+ #
280
+ # Args:
281
+ # el — RefElement (preferred) or raw JS element
282
+ # href: — String / Symbol / Signal / Computed / Proc → route path
283
+ # match: — what makes the link "active" (Symbol route name,
284
+ # Array of names, or path string with prefix matching).
285
+ # When omitted, falls back to `href`'s path
286
+ # (descendant matches highlight too — `/users` is
287
+ # active at `/users/42`).
288
+ # active_class: — CSS class to toggle on match (default `"active"`)
289
+ # inactive_class: — CSS class to toggle on miss (default nil)
290
+ # exact: — disable prefix matching for path-string `match`
291
+ # owner_component: — Component that owns the lifetime of the binding.
292
+ # Internal: the ComponentMixin passes itself so the
293
+ # effect is auto-disposed on unmount. When nil,
294
+ # a free-standing Effect lives until VM teardown.
295
+ #
296
+ # Returns nil. Effect lifecycle:
297
+ # - `component.bind_link(...)` (mixin form) → tracked on component, auto-cleanup
298
+ # - `router.bind_link(raw_el, ...)` → leaks until VM exits
299
+ def bind_link(el, href:, match: nil, active_class: "active", inactive_class: nil, exact: false, owner_component: nil)
300
+ runner = proc do
301
+ route = source_value(href)
302
+ set_anchor_href(el, self.href(route))
303
+ active_target = match.nil? ? route : source_value(match)
304
+ is_active = active?(active_target, exact: exact)
305
+ toggle_anchor_class(el, active_class, is_active) if active_class
306
+ toggle_anchor_class(el, inactive_class, !is_active) if inactive_class
307
+ end
308
+
309
+ if owner_component
310
+ owner_component.effect(label: "router-link") { runner.call }
311
+ else
312
+ Lilac::Effect.new(label: "router-link") { runner.call }
313
+ end
314
+ nil
315
+ end
316
+
317
+ # ---------- Mutation ----------
318
+
319
+ def navigate(path, replace: false)
320
+ ensure_signal!
321
+ new_url = build_navigate_url(path)
322
+ history = JS.global[:history]
323
+ method = replace ? :replaceState : :pushState
324
+ # 1st arg: state; 2nd: title (deprecated, "" is conventional);
325
+ # 3rd: URL. We pass the existing state through to keep popstate
326
+ # restoration consistent.
327
+ history.call(method, history[:state], "", new_url)
328
+ # pushState/replaceState don't fire popstate, so refresh the
329
+ # location signal manually.
330
+ update_location
331
+ nil
332
+ end
333
+
334
+ # `<a>` click → SPA navigate. Skips modifier-key clicks, middle/right
335
+ # mouse, target=_blank, and external origins so the user retains
336
+ # browser-default behaviour for those cases.
337
+ def intercept_link(event)
338
+ return if event[:metaKey].js_bool || event[:ctrlKey].js_bool ||
339
+ event[:shiftKey].js_bool || event[:altKey].js_bool
340
+ return if event[:button].to_i != 0
341
+
342
+ target = event[:target].call(:closest, "a[href]")
343
+ return if target.js_null?
344
+
345
+ target_attr = target.call(:getAttribute, "target")
346
+ return if !target_attr.js_null? && target_attr.to_s == "_blank"
347
+
348
+ href = target[:href].to_s
349
+ route_path = route_path_from_href(href)
350
+ return unless route_path
351
+
352
+ event.call(:preventDefault)
353
+ navigate(route_path)
354
+ end
355
+
356
+ # ---------- Internal: testing / lifecycle ----------
357
+
358
+ # For tests: forget every piece of router state and reinstall. Not
359
+ # part of the public API.
360
+ def __reset_for_tests__
361
+ @started = false
362
+ @routes = []
363
+ @fallback_template = nil
364
+ @outlet_selector = nil
365
+ @match_computed_cache.clear
366
+ @named_match_computeds.clear
367
+ @params_computed = nil
368
+ @current_computed = nil
369
+ @route_methods.clear_all
370
+ @mode = :hash
371
+ @base = "/"
372
+ @location_signal = nil
373
+ @render_effect&.dispose
374
+ @render_effect = nil
375
+ # Listeners stay registered (one per VM lifetime); they no-op
376
+ # against a nil signal. Re-installing is conditional in
377
+ # install_listeners.
378
+ nil
379
+ end
380
+
381
+ private
382
+
383
+ # ---------- Path compilation ----------
384
+
385
+ def compile(pattern)
386
+ segs = pattern.split("/", -1)
387
+ params = []
388
+ segs.each_with_index do |seg, i|
389
+ params << seg[1..].to_sym if seg.start_with?(":")
390
+ end
391
+ { pattern: pattern, segs: segs, params: params }
392
+ end
393
+
394
+ def match_path(compiled, path)
395
+ segs = path.split("/", -1)
396
+ return nil unless segs.length == compiled[:segs].length
397
+ out = {}
398
+ compiled[:segs].each_with_index do |seg, i|
399
+ if seg.start_with?(":")
400
+ out[seg[1..].to_sym] = decode(segs[i])
401
+ elsif seg != segs[i]
402
+ return nil
403
+ end
404
+ end
405
+ out
406
+ end
407
+
408
+ # ---------- Listeners ----------
409
+
410
+ def install_listeners
411
+ win = JS.global[:window]
412
+ if @hashchange_cb.nil?
413
+ @hashchange_cb = JS.callback { |_e| update_location }
414
+ win.call(:addEventListener, "hashchange", @hashchange_cb)
415
+ end
416
+ if @popstate_cb.nil?
417
+ @popstate_cb = JS.callback { |_e| update_location }
418
+ win.call(:addEventListener, "popstate", @popstate_cb)
419
+ end
420
+ end
421
+
422
+ def update_location
423
+ return if @location_signal.nil?
424
+ @location_signal.value = read_location
425
+ end
426
+
427
+ # ---------- URL parsing ----------
428
+
429
+ def read_location
430
+ loc = JS.global[:location]
431
+ if @mode == :hash
432
+ hash_str = loc[:hash].to_s
433
+ # hash starts with "#" (or empty); the part after "#" is our path+query
434
+ raw = hash_str.start_with?("#") ? hash_str[1..] : hash_str
435
+ raw = strip_base(raw)
436
+ raw = "/" if raw.empty?
437
+ path_part, query_str = split_query(raw)
438
+ { path: path_part, query: parse_query(query_str), hash: "" }
439
+ else
440
+ path_part = loc[:pathname].to_s
441
+ path_part = strip_base(path_part)
442
+ path_part = "/" if path_part.empty?
443
+ search = loc[:search].to_s
444
+ search = search[1..] if search.start_with?("?")
445
+ h = loc[:hash].to_s
446
+ h = h[1..] if h.start_with?("#")
447
+ { path: path_part, query: parse_query(search), hash: h.to_s }
448
+ end
449
+ end
450
+
451
+ def strip_base(path)
452
+ return path if @base == "/" || @base.empty?
453
+ prefix = @base.chomp("/")
454
+ if path == prefix
455
+ return "/"
456
+ elsif path.start_with?("#{prefix}/")
457
+ rest = path[prefix.length..]
458
+ return "/" if rest.nil? || rest.empty?
459
+ return rest
460
+ end
461
+ path
462
+ end
463
+
464
+ def split_query(s)
465
+ if s.include?("?")
466
+ s.split("?", 2)
467
+ else
468
+ [s, ""]
469
+ end
470
+ end
471
+
472
+ def parse_query(s)
473
+ return {} if s.nil? || s.empty?
474
+ out = {}
475
+ s.split("&").each do |pair|
476
+ next if pair.empty?
477
+ k, v = pair.split("=", 2)
478
+ out[decode(k)] = decode(v || "")
479
+ end
480
+ out
481
+ end
482
+
483
+ def decode(s)
484
+ JS.decode_uri_component(s)
485
+ rescue StandardError
486
+ s.to_s
487
+ end
488
+
489
+ def encode(s)
490
+ JS.encode_uri_component(s)
491
+ rescue StandardError
492
+ s.to_s
493
+ end
494
+
495
+ def build_navigate_url(path)
496
+ prefix = @base == "/" ? "" : @base.chomp("/")
497
+ if @mode == :hash
498
+ "##{prefix}#{path}"
499
+ else
500
+ "#{prefix}#{path}"
501
+ end
502
+ end
503
+
504
+ def external_url?(s)
505
+ s.include?("://") || s.start_with?("//")
506
+ end
507
+
508
+ def route_path_only(s)
509
+ path_part, _query = split_query(s.to_s)
510
+ if path_part.include?("#")
511
+ path_part.split("#", 2)[0]
512
+ else
513
+ path_part
514
+ end
515
+ end
516
+
517
+ def source_value(source)
518
+ if source.is_a?(Lilac::Signal) || source.is_a?(Lilac::Computed)
519
+ source.value
520
+ elsif source.is_a?(Proc)
521
+ source.call
522
+ else
523
+ source
524
+ end
525
+ end
526
+
527
+ def set_anchor_href(el, value)
528
+ if ref_element?(el)
529
+ el.attr("href", value)
530
+ else
531
+ el.call(:setAttribute, "href", value.to_s)
532
+ end
533
+ end
534
+
535
+ def toggle_anchor_class(el, class_name, enabled)
536
+ if ref_element?(el)
537
+ el.toggle_class(class_name, enabled)
538
+ else
539
+ el[:classList].call(:toggle, class_name.to_s, !!enabled)
540
+ end
541
+ end
542
+
543
+ def ref_element?(el)
544
+ el.class == Lilac::RefElement
545
+ rescue StandardError
546
+ false
547
+ end
548
+
549
+ def route_path_from_href(href)
550
+ origin = JS.global[:location][:origin].to_s
551
+ url = JS.global[:URL].new(href.to_s, origin)
552
+ return nil unless url[:origin].to_s == origin
553
+
554
+ if @mode == :hash
555
+ hash_str = url[:hash].to_s
556
+ return nil if !hash_str.empty? && !hash_str.start_with?("#/")
557
+
558
+ if hash_str.start_with?("#/")
559
+ raw = strip_base(hash_str[1..])
560
+ raw = "/" if raw.empty?
561
+ return raw
562
+ end
563
+ end
564
+
565
+ path_part = strip_base(url[:pathname].to_s)
566
+ path_part = "/" if path_part.empty?
567
+ "#{path_part}#{url[:search]}#{url[:hash]}"
568
+ rescue StandardError
569
+ nil
570
+ end
571
+
572
+ # ---------- Active route resolution ----------
573
+
574
+ def find_active_route
575
+ return nil if @location_signal.nil?
576
+ path = @location_signal.value[:path]
577
+ @routes.find { |r| match_path(r[:compiled], path) }
578
+ end
579
+
580
+ def current_route_name
581
+ active = find_active_route
582
+ return active[:name] if active
583
+ return :fallback if @fallback_template
584
+ nil
585
+ end
586
+
587
+ def current_route_params
588
+ active = find_active_route
589
+ return {} unless active
590
+ match_path(active[:compiled], @location_signal.value[:path]) || {}
591
+ end
592
+
593
+ # ---------- Render (lazy mount) ----------
594
+
595
+ def render_active_route
596
+ return unless @outlet_selector
597
+ return if @rendering # guard against re-entry during template clone
598
+ outlet = JS.global[:document].call(:querySelector, @outlet_selector)
599
+ return if outlet.js_null?
600
+
601
+ active = find_active_route
602
+ template_id = active ? active[:template] : @fallback_template
603
+
604
+ @rendering = true
605
+ begin
606
+ # Always clear: this triggers our MutationObserver to unmount
607
+ # any components mounted in the previous route, regardless of
608
+ # whether a new template will replace them.
609
+ outlet[:innerHTML] = ""
610
+ if template_id
611
+ mount_template_into_outlet(template_id, outlet)
612
+ end
613
+ ensure
614
+ @rendering = false
615
+ end
616
+ end
617
+
618
+ def mount_template_into_outlet(template_id, outlet)
619
+ template_el = JS.global[:document].call(:getElementById, template_id)
620
+ if template_el.js_null?
621
+ Lilac.logger.warn("router: template '#{template_id}' not found")
622
+ return
623
+ end
624
+ cloned = template_el[:content].call(:cloneNode, true)
625
+ outlet.call(:appendChild, cloned)
626
+ end
627
+
628
+ def ensure_signal!
629
+ return unless @location_signal.nil?
630
+ # Allow signal-only access before `start` is called by initialising
631
+ # with current URL. Listeners get registered on the first `start`.
632
+ @location_signal = Lilac::Signal.new(read_location)
633
+ end
634
+ end
635
+
636
+ class << self
637
+ def default_context
638
+ @default_context ||= Context.new
639
+ end
640
+
641
+ def new_context
642
+ Context.new
643
+ end
644
+
645
+ # Lifecycle shortcuts on the default context. Limited to the two
646
+ # "called once at boot" operations: `draw` declares the route
647
+ # table, `start` installs listeners + first render. Per-call
648
+ # operations (`path`, `navigate`, `params`, ...) are intentionally
649
+ # NOT exposed here — those should be reached via Component#router
650
+ # (auto-tracked through `lookup(:router)`) or
651
+ # `Lilac::Router.default_context` directly, so sub-contexts
652
+ # remain a clean override path.
653
+ def draw(outlet:, &block)
654
+ default_context.draw(outlet: outlet, &block)
655
+ end
656
+
657
+ def start(mode: nil, base: nil)
658
+ default_context.start(mode: mode, base: base)
659
+ end
660
+ end
661
+
662
+ # Mixed into `Lilac::Component` so users can write `bind_link refs.x,
663
+ # href: ..., match: ...` directly inside `setup`, alongside the other
664
+ # binding helpers (`bind`, `bind_list`, `bind_input`). Same pattern as the
665
+ # Form Builder's `Lilac::FormBuilder` mixin — keeps the Router gem
666
+ # opt-in: Component gains `bind_link` only when this gem is required.
667
+ module ComponentMixin
668
+ def router
669
+ lookup(:router) { Lilac::Router.default_context }
670
+ end
671
+
672
+ def bind_link(el, href:, match: nil, active_class: "active", inactive_class: nil, exact: false)
673
+ router.bind_link(
674
+ el,
675
+ href: href, match: match,
676
+ active_class: active_class, inactive_class: inactive_class,
677
+ exact: exact, owner_component: self)
678
+ end
679
+ end
680
+ end
681
+ end
682
+
683
+ Lilac::Component.include(Lilac::Router::ComponentMixin)
metadata ADDED
@@ -0,0 +1,45 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lilac-router
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - takahashim
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Provides `Lilac::Router`, a signal-backed driver for SPA-style navigation
13
+ against `window.location`. Designed for the lilac-compiled wasm variant; lilac-full
14
+ already links the router gem in. Picked up automatically by `lilac build` via Bundler
15
+ when the gem appears in the project's Gemfile.
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - lilac-router.gemspec
21
+ - mrblib/lilac_router.rb
22
+ licenses:
23
+ - MIT
24
+ metadata:
25
+ lilac_package: 'true'
26
+ source_code_uri: https://github.com/takahashim/lilac
27
+ rubygems_mfa_required: 'true'
28
+ rdoc_options: []
29
+ require_paths:
30
+ - lib
31
+ required_ruby_version: !ruby/object:Gem::Requirement
32
+ requirements:
33
+ - - ">="
34
+ - !ruby/object:Gem::Version
35
+ version: 3.2.0
36
+ required_rubygems_version: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ requirements: []
42
+ rubygems_version: 3.6.9
43
+ specification_version: 4
44
+ summary: 'Lilac package: signal-based URL routing (`Lilac::Router`)'
45
+ test_files: []