Skip to content

Instantly share code, notes, and snippets.

@robacarp
Created April 3, 2024 17:10
Show Gist options
  • Save robacarp/406db80234952f683346854a1a9e9ffc to your computer and use it in GitHub Desktop.
Save robacarp/406db80234952f683346854a1a9e9ffc to your computer and use it in GitHub Desktop.
Asset Renderer and Manifest Builder in Crystal

In config/assets.cr:

require "../src/lib/asset_renderer"
require "../src/lib/import_map"

AssetRenderer.configure do |c|
  c.source_path = Path.new("src")
  c.output_path = Path.new("public/h/")
  c.rendered_path = Path.new("/h/")
end

# local paths are relative to ./src
ImportMap.config do
  import_remote("stimulus", path: "https://cdn.jsdelivr.net/npm/stimulus@3.2.2/+esm", preload: true)
  import_local("application", path: "javascript/application.js", preload: true)

  # TODO: find a way to render this once and for all in deployments
  import_glob("src/javascript", "controllers/**/*.js")
end
require "digest"
require "file_utils"
class AssetRenderer
Log = ::Log.for self
def self.instance : self
@@instance ||= new
end
def self.configure
yield instance
end
property source_path : Path
property output_path : Path
property rendered_path : Path
def initialize
@source_path = Path.new("./src")
@output_path = Path.new("public/h/")
@rendered_path = Path.new("/h/")
end
# Calculates a hash and copies the file to the output path.
# Returns the rendered path.
#
# Assumes that source_path, output_path, and rendered_path are all going to be
# mirrored structurally.
#
# For example, given these initial paths:
# source_path: "src"
# output_path: "public/assets"
# rendered_path: "assets"
#
# Calling #hash_and_copy_file("javascript/application.js") will:
# calculate a hash of src/javascript/application.js
# copy the file to: public/assets/javascript/application-<hash>.js
# return the rendered path: assets/javascript/application-<hash>.js
def hash_and_copy_file(path : Path) : Path
relative_path = path.parent
source_file_path = @source_path.join(path)
digest = Digest::SHA256.new.file(source_file_path).hexfinal
new_filename = "#{source_file_path.stem}-#{digest}#{source_file_path.extension}"
destination_folder = @output_path.join(path).parent
destination_file_path = destination_folder.join new_filename
rendered_path = @rendered_path.join relative_path, new_filename
# If the file already exists, don't copy it again.
if File.exists? destination_file_path
return rendered_path
end
# Ensure the destination folder exists.
FileUtils.mkdir_p destination_folder
FileUtils.cp source_file_path, destination_file_path
Log.info { "Copied #{source_file_path} to #{destination_file_path}" }
rendered_path
end
# :ditto:
def hash_and_copy_file(path : String) : Path
hash_and_copy_file(Path.new(path))
end
module HTMLHelpers
private def asset_renderer : AssetRenderer
AssetRenderer.instance.dup
end
def script_tag_for(path : String, module is_module : Bool = true) : String
rendered_path = asset_renderer.hash_and_copy_file(path)
String.build do |s|
s << "<script "
s << %|type="module" | if is_module
s << %|src="#{rendered_path}">|
s << "</script>"
end
end
def stylesheet_tag_for(path : String) : String
rendered_path = asset_renderer.hash_and_copy_file(path)
%|<link href="#{rendered_path}" rel="stylesheet">|
end
end
end
class ImportMap
module HTMLHelpers
private def map : ImportMap
ImportMap.instance
end
def render_script_tags : String
tags = [] of String
asset_renderer = AssetRenderer.instance
# constant imports, array of [name, path]
rendered_imports = map.imports
.map { |import| [import.name, import.path] }
# render the dynamic imports
# todo move this logic into ImportGlob
map.paths.each do |path|
puts "Globbing #{path.glob_path}"
Dir.glob(path.glob_path).each do |file|
path_ = Path[file].relative_to(path.root)
file_ = asset_renderer.hash_and_copy_file(
Path[file].relative_to(asset_renderer.source_path)
).to_s
rendered_imports << [path_.parent.join(path_.stem).to_s, file_]
end
end
# render the import map script tag
tags << String.build do |s|
s << %|<script type="importmap">|
s << { "imports": rendered_imports.to_h }.to_json
s << %|</script>|
end
# render the preload tags
map.imports.select { |import| import.preload }.each do |import|
tags << %|<script type="modulepreload" src="#{import.path}"></script>|
end
tags.join("\n")
end
end
struct Import
getter name : String
getter preload : Bool
getter? local : Bool
def initialize(@name : String, @path : String, @preload : Bool, @local : Bool = false)
end
def path : String
if local?
AssetRenderer.instance.hash_and_copy_file(@path).to_s
else
@path
end
end
end
struct ImportGlob
getter root : String
getter glob : String
def initialize(@root : String, @glob : String)
end
def glob_path : Path
Path[@root] / @glob
end
end
def self.instance : self
@@instance ||= new
end
def self.config : Nil
with instance yield
end
def self.render_script_tags : String
instance.render_script_tags
end
getter imports = [] of Import
getter paths = [] of ImportGlob
def initialize
end
# Adds a remote file to the import map. Useful for packages from jsdeliver.net, unpkg, etc.
#
# Eg.
# ```crystal
# ImportMap.config do
# import_remote("stimulus", path: "https://cdn.jsdelivr.net/npm/stimulus@3.2.2/+esm", preload: true)
# end
# ```
#
def import_remote(name : String, path : String, preload : Bool = false)
@imports << Import.new name, path, preload
end
# Adds a static local file to the import map.
#
# Eg:
#
# ```crystal
# ImportMap.config do
# import_local "application", "assets/application.js"
# end
# ```
def import_local(name : String, path : String, preload : Bool = false)
@imports << Import.new name, path, preload, local: true
end
# Adds a dynamic file glob to the import map.
# Each time the import map is rendered, the files matching the pattern
# will be added to the map. Files are hashed and the stem is used as the
# import name.
#
# Eg:
#
# ```crystal
# ImportMap.config do
# import_glob(root: "src/javascript", glob: "controllers/**/*.js")
# end
# ```
def import_glob(root : String, glob : String) : Nil
@paths << ImportGlob.new(root, glob)
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment