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 +7 -0
- data/lilac-router.gemspec +26 -0
- data/mrblib/lilac_router.rb +683 -0
- metadata +45 -0
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: []
|