Skip to content

Commit e9fe003

Browse files
committed
Add support for import maps.
1 parent c4928b1 commit e9fe003

File tree

4 files changed

+800
-0
lines changed

4 files changed

+800
-0
lines changed

lib/utopia.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
require_relative "utopia/version"
77

8+
require_relative "utopia/import_map"
89
require_relative "utopia/content"
910
require_relative "utopia/controller"
1011
require_relative "utopia/exceptions"

lib/utopia/import_map.rb

Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2025, by Samuel Williams.
5+
6+
require "json"
7+
require "xrb"
8+
require "protocol/url"
9+
10+
module Utopia
11+
# Represents an import map for JavaScript modules with support for URI and relative path resolution.
12+
# Import maps allow you to control how JavaScript imports are resolved, supporting both absolute
13+
# URLs and relative paths with proper context-aware resolution.
14+
#
15+
# The builder pattern supports nested base URIs that are properly resolved relative to parent bases.
16+
# All URL resolution follows RFC 3986 via the `protocol-url` gem.
17+
#
18+
# @example Basic usage with absolute URLs.
19+
# import_map = Utopia::ImportMap.build do |map|
20+
# map.import("react", "https://esm.sh/react@18")
21+
# map.import("@myapp/utils", "./js/utils.js", integrity: "sha384-...")
22+
# end
23+
#
24+
# puts import_map.to_html
25+
#
26+
# @example Using nested base URIs for different CDNs.
27+
# import_map = Utopia::ImportMap.build do |map|
28+
# # Imports without base
29+
# map.import("app", "/app.js")
30+
#
31+
# # CDN imports - base is set to jsdelivr
32+
# map.with(base: "https://cdn.jsdelivr.net/npm/") do |m|
33+
# m.import "lit", "[email protected]/index.js"
34+
# m.import "lit/decorators.js", "[email protected]/decorators.js"
35+
# end
36+
#
37+
# # Nested base combines with parent: "https://cdn.jsdelivr.net/npm/mermaid@10/"
38+
# map.with(base: "https://cdn.jsdelivr.net/npm/") do |m|
39+
# m.with(base: "mermaid@10/") do |nested|
40+
# nested.import "mermaid", "dist/mermaid.esm.min.mjs"
41+
# end
42+
# end
43+
# end
44+
#
45+
# @example Creating page-specific import maps with relative paths.
46+
# # Global import map with base: "/_components/"
47+
# global_map = Utopia::ImportMap.build(base: "/_components/") do |map|
48+
# map.import("button", "./button.js")
49+
# end
50+
#
51+
# # For a page at /foo/bar/, create a context-specific import map
52+
# page_map = global_map.relative_to("/foo/bar/")
53+
# # Base becomes: "../../_components/"
54+
# # button import resolves to: "../../_components/button.js"
55+
#
56+
# puts page_map.to_html
57+
class ImportMap
58+
# Builder class for constructing import maps with scoped base URIs.
59+
#
60+
# The builder supports nested `with(base:)` blocks where each base is resolved
61+
# relative to its parent base, following RFC 3986 URL resolution rules.
62+
#
63+
# @example Nested base resolution.
64+
# ImportMap.build do |map|
65+
# # No base - imports as-is
66+
# map.import("app", "/app.js")
67+
#
68+
# # Base: "https://cdn.example.com/"
69+
# map.with(base: "https://cdn.example.com/") do |cdn|
70+
# cdn.import("lib", "lib.js") # => "https://cdn.example.com/lib.js"
71+
#
72+
# # Nested base: "https://cdn.example.com/" + "v2/" = "https://cdn.example.com/v2/"
73+
# cdn.with(base: "v2/") do |v2|
74+
# v2.import("new-lib", "lib.js") # => "https://cdn.example.com/v2/lib.js"
75+
# end
76+
# end
77+
# end
78+
class Builder
79+
def self.build(import_map, **options, &block)
80+
builder = self.new(import_map, **options)
81+
82+
if block.arity == 1
83+
yield(builder)
84+
else
85+
builder.instance_eval(&block)
86+
end
87+
88+
return builder
89+
end
90+
91+
def initialize(import_map, base: nil)
92+
@import_map = import_map
93+
@base = Protocol::URL[base]
94+
end
95+
96+
# Add an import mapping with the current base URI.
97+
#
98+
# If a base is set, the value is resolved relative to that base following RFC 3986.
99+
# Absolute URLs (scheme://...) are preserved as-is when used as values.
100+
#
101+
# @parameter specifier [String] The module specifier (e.g., "react", "@myapp/utils").
102+
# @parameter value [String] The URL or path to resolve to.
103+
# @parameter integrity [String, nil] Optional subresource integrity hash.
104+
# @returns [Builder] Self for method chaining.
105+
#
106+
# @example With base URL.
107+
# builder = Builder.new(map, base: "https://cdn.com/")
108+
# builder.import("lib", "lib.js") # Resolves to: "https://cdn.com/lib.js"
109+
# builder.import("ext", "https://other.com/ext.js") # Keeps: "https://other.com/ext.js"
110+
def import(specifier, value, integrity: nil)
111+
resolved_value = if @base
112+
value_url = Protocol::URL[value]
113+
114+
# Combine base with value
115+
(@base + value_url).to_s
116+
else
117+
value
118+
end
119+
120+
@import_map.import(specifier, resolved_value, integrity: integrity)
121+
122+
self
123+
end
124+
125+
# Create a nested scope with a different base URI.
126+
#
127+
# The new base is resolved relative to the current base. This allows for
128+
# hierarchical organization of imports from different sources.
129+
#
130+
# @parameter base [String] The new base URI, resolved relative to current base.
131+
# @yields [Builder] A new builder with the resolved base.
132+
# @returns [Builder] The builder instance.
133+
#
134+
# @example Nested CDN paths.
135+
# builder.with(base: "https://cdn.com/") do |cdn|
136+
# cdn.with(base: "libs/v2/") do |v2|
137+
# # Base is now: "https://cdn.com/libs/v2/"
138+
# v2.import("util", "util.js") # => "https://cdn.com/libs/v2/util.js"
139+
# end
140+
# end
141+
def with(base:, &block)
142+
# Resolve the new base relative to the current base
143+
resolved_base = if @base
144+
@base + Protocol::URL[base]
145+
else
146+
base
147+
end
148+
149+
self.class.build(@import_map, base: resolved_base, &block)
150+
end
151+
152+
# Add a scope mapping.
153+
#
154+
# Scopes allow different import resolutions for different parts of your application.
155+
#
156+
# @parameter scope_prefix [String] The scope prefix (e.g., "/pages/").
157+
# @parameter imports [Hash] Import mappings specific to this scope.
158+
# @returns [Builder] Self for method chaining.
159+
#
160+
# @example Scope-specific imports.
161+
# builder.scope("/admin/", {"utils" => "/admin/utils.js"})
162+
def scope(scope_prefix, imports)
163+
@import_map.scope(scope_prefix, imports)
164+
self
165+
end
166+
end
167+
168+
# Create an import map using a builder pattern.
169+
#
170+
# The builder supports both block parameter and instance_eval styles.
171+
# The returned import map is frozen to prevent accidental mutation.
172+
#
173+
# @parameter base [String, nil] The base URI for resolving relative paths.
174+
# @yields {|builder| ...} If a block is given.
175+
# @parameter builder [Builder] The import map builder, if the block takes an argument.
176+
# @returns [ImportMap] A frozen import map instance.
177+
#
178+
# @example Block parameter style.
179+
# import_map = ImportMap.build do |map|
180+
# map.import("react", "https://esm.sh/react")
181+
# end
182+
#
183+
# @example Instance eval style.
184+
# import_map = ImportMap.build do
185+
# import "react", "https://esm.sh/react"
186+
# end
187+
def self.build(base: nil, &block)
188+
instance = self.new(base: base)
189+
190+
builder = Builder.build(instance, &block)
191+
192+
return instance.freeze
193+
end
194+
195+
# Initialize a new import map.
196+
#
197+
# Typically you should use {build} instead of calling this directly.
198+
#
199+
# @parameter imports [Hash] The imports mapping.
200+
# @parameter integrity [Hash] Integrity hashes for imports.
201+
# @parameter scopes [Hash] Scoped import mappings.
202+
# @parameter base [String, Protocol::URL, nil] The base URI for resolving relative paths.
203+
def initialize(imports = {}, integrity = {}, scopes = {}, base: nil)
204+
@imports = imports
205+
@integrity = integrity
206+
@scopes = scopes
207+
@base = Protocol::URL[base]
208+
end
209+
210+
# @attribute [Hash(String, String)] The imports mapping.
211+
attr :imports
212+
213+
# @attribute [Hash(String, String)] Subresource integrity hashes for imports.
214+
attr :integrity
215+
216+
# @attribute [Hash(String, Hash)] Scoped import mappings.
217+
attr :scopes
218+
219+
# @attribute [Protocol::URL::Absolute | Protocol::URL::Relative | nil] The parsed base URL for efficient resolution.
220+
attr :base
221+
222+
# Add an import mapping.
223+
#
224+
# @parameter specifier [String] The import specifier (e.g., "react", "@myapp/utils").
225+
# @parameter value [String] The URL or path to resolve to.
226+
# @parameter integrity [String, nil] Optional subresource integrity hash for the resource.
227+
# @returns [ImportMap] Self for method chaining.
228+
def import(specifier, value, integrity: nil)
229+
@imports[specifier] = value
230+
@integrity[specifier] = integrity if integrity
231+
232+
self
233+
end
234+
235+
# Add a scope mapping.
236+
#
237+
# Scopes allow different import resolutions based on the referrer URL.
238+
# See https://github.com/WICG/import-maps#scoping-examples for details.
239+
#
240+
# @parameter scope_prefix [String] The scope prefix (e.g., "/pages/").
241+
# @parameter imports [Hash] Import mappings specific to this scope.
242+
# @returns [ImportMap] Self for method chaining.
243+
def scope(scope_prefix, imports)
244+
@scopes[scope_prefix] = imports
245+
246+
self
247+
end
248+
249+
# Create a new import map with paths relative to the given page path.
250+
# This is useful for creating page-specific import maps from a global one.
251+
#
252+
# @parameter path [String] The absolute page path to make imports relative to.
253+
# @returns [ImportMap] A new import map with a relative base.
254+
#
255+
# @example Creating page-specific import maps.
256+
# # Global import map with base: "/_components/"
257+
# import_map = ImportMap.build(base: "/_components/") { ... }
258+
#
259+
# # For a page at /foo/bar/, calculate relative path to components
260+
# page_map = import_map.relative_to("/foo/bar/")
261+
# # Base becomes: "../../_components/"
262+
def relative_to(path)
263+
if @base
264+
# Calculate the relative path from the page to the base
265+
relative_base = Protocol::URL::Path.relative(@base.path, path)
266+
resolved_base = Protocol::URL[relative_base]
267+
else
268+
resolved_base = nil
269+
end
270+
271+
instance = self.class.new(@imports.dup, @integrity.dup, @scopes.dup, base: resolved_base)
272+
273+
return instance.freeze
274+
end
275+
276+
# Resolve a single import value considering base context.
277+
#
278+
# @parameter value [String] The import URL or path value.
279+
# @parameter base [Protocol::URL, nil] The base URL context for resolving relative paths.
280+
# @returns [Protocol::URL, String] The resolved URL object or original string.
281+
private def resolve_value(value, base)
282+
if base
283+
base + Protocol::URL[value]
284+
else
285+
value
286+
end
287+
end
288+
289+
# Resolve a hash of imports with the given base.
290+
#
291+
# @parameter imports [Hash] The imports hash to resolve.
292+
# @parameter base [Protocol::URL, nil] The base URL context.
293+
# @returns [Hash] The resolved imports with string values.
294+
private def resolve_imports(imports, base)
295+
result = {}
296+
297+
imports.each do |specifier, value|
298+
result[specifier] = resolve_value(value, base).to_s
299+
end
300+
301+
result
302+
end
303+
304+
# Build the import map as a Hash with resolved paths.
305+
#
306+
# All relative paths are resolved against the base URL if present.
307+
# Absolute URLs and protocol-relative URLs are preserved as-is.
308+
# This method is compatible with the JSON gem's `as_json` convention.
309+
#
310+
# @returns [Hash] The resolved import map data structure ready for JSON serialization.
311+
def as_json(...)
312+
result = {}
313+
314+
# Add imports
315+
if @imports.any?
316+
result["imports"] = resolve_imports(@imports, @base)
317+
end
318+
319+
# Add scopes
320+
if @scopes.any?
321+
result["scopes"] = {}
322+
@scopes.each do |scope_prefix, scope_imports|
323+
# Resolve the scope prefix itself with base
324+
scope_url = Protocol::URL[scope_prefix]
325+
resolved_prefix = if @base && !scope_url.is_a?(Protocol::URL::Absolute)
326+
(@base + scope_url).to_s
327+
else
328+
scope_prefix
329+
end
330+
331+
result["scopes"][resolved_prefix] = resolve_imports(scope_imports, @base)
332+
end
333+
end
334+
335+
# Add integrity
336+
if @integrity.any?
337+
result["integrity"] = @integrity.dup
338+
end
339+
340+
return result
341+
end
342+
343+
# Convert the import map to JSON.
344+
#
345+
# @returns [String] The JSON representation of the import map.
346+
def to_json(...)
347+
as_json.to_json(...)
348+
end
349+
350+
# Generate the import map as an XRB fragment suitable for embedding in HTML.
351+
#
352+
# Creates a `<script type="importmap">` tag containing the JSON representation.
353+
#
354+
# @returns [XRB::Builder::Fragment] The generated HTML fragment.
355+
def to_html
356+
json_data = to_json
357+
358+
XRB::Builder.fragment do |builder|
359+
builder.inline("script", type: "importmap") do
360+
builder.text(json_data)
361+
end
362+
end
363+
end
364+
365+
# Convenience method for rendering the import map as an HTML string.
366+
#
367+
# Equivalent to `to_html.to_s`.
368+
#
369+
# @returns [String] The generated HTML containing the import map script tag.
370+
def to_s
371+
to_html.to_s
372+
end
373+
end
374+
end

0 commit comments

Comments
 (0)