Skip to content

Instantly share code, notes, and snippets.

@rgarner
Last active February 7, 2024 16:27
Show Gist options
  • Save rgarner/95c484d3534badf269219881013650a4 to your computer and use it in GitHub Desktop.
Save rgarner/95c484d3534badf269219881013650a4 to your computer and use it in GitHub Desktop.
Assets in a post-jsbundling Rails 7 world

Assets

We build assets via custom file tasks in assets.rake. Our build system is based on esbuild. This is because esbuild is the fastest of the bundlers.

We used to use jsbundling-rails and cssbundling-rails with the esbuild opinion. We no longer do this. This is mostly because both those gems now indiscriminately enhance the spec:prepare rake task such that CSS and JS are built even when running a single spec.

We avoid this by using the aforementioned file rake tasks to symbolic application.js and application.css files that have FileList dependencies on the entire app/javascript and app/assets/stylesheets directories respectively. CSS and JS will only build if any of the files in those lists have timestamps newer than their symbolic file.

Rationale

  • jsbundling and cssbundling only provide starter opinions via install templates for common frameworks ("I can get you started with esbuild for js" or "I can get you started with Tailwind for CSS", for example) which both only use a package.json script as an entry point
  • both gems imply a rake interface of javascript:build or css:build and an associated clobber task which delegate to those package.json entry points. The build tasks are what assets:precompile uses in production, and the clobber tasks are largely dev-only.
  • both gems enhance spec:prepare, which implies that we must build both CSS and JS on every single spec run if we accept the *bundling deal
  • It is easier simply never to enhance those tasks incorrectly (by removing the gems) than it is to remove and re-enhance them
  • we have "symbolic" application.js and application.css entry points which use rake file tasks such that they aren't built on every run. We enhance spec:prepare ourselves with those, and move the build and clobber tasks into our own lib/tasks/javascript and lib/tasks/css.
#
# Hook up to jsbundling-rails and cssbundling-rails by making spec:prepare dependent
# on app/assets/builds/application.js and app/assets/builds/application.css, file-based equivalents to
# `yarn build` and `yarn css:build` respectively. Will only rebuild when *any* JS or CSS files have changed.
# It is not sophisticated. javascript:build and css:build default is to always rebuild, they don't reference this.
module Assets
JS_SOURCE_DIR = 'app/javascript'.freeze
CSS_SOURCE_DIR = 'app/assets/stylesheets'.freeze
LOGOS_SOURCE_DIR = 'app/assets/images/logos'.freeze
BUILDS_DIR = 'app/assets/builds'.freeze
JS_SOURCE = FileList["#{JS_SOURCE_DIR}/**/*"]
CSS_SOURCE = FileList["#{CSS_SOURCE_DIR}/**/*"]
LOGO_WIDTH = 350
def self.tenant_keys = File.read(Rails.root.join('TENANTS')).split("\n")
LOGOS = tenant_keys.map { |key| "#{LOGOS_SOURCE_DIR}/#{key}.svg" }
end
#
# application.js is symbolic for "all the app JS and sourcemaps, timestamped".
# It will only rebuild if any file found in the JS_SOURCE FileList is newer.
file "#{Assets::BUILDS_DIR}/application.js" => Assets::JS_SOURCE do
unless system 'yarn install && yarn build'
raise 'assets.rake: JS build failed, ensure yarn is installed and `yarn build` runs without errors'
end
end
#
# application.css is symbolic for "all the app CSS, timestamped".
# It will only rebuild if any file found in the CSS_SOURCE FileList is newer.
file "#{Assets::BUILDS_DIR}/application.css" => Assets::CSS_SOURCE do |t|
puts "Creating #{t.name}"
raise 'assets.rake: build:css failed' unless system 'yarn install && yarn build:css'
`echo '/* dummy CSS file used in rake build only */' > #{t.name}`
end
namespace :assets do
logo_png_file_tasks = Assets::LOGOS.map do |svg|
file svg.sub('.svg', '.png') => svg do |t|
cmd = "rsvg-convert -w #{Assets::LOGO_WIDTH} #{t.prerequisites[0]} -o #{t.name}"
system(cmd, exception: true)
end
end
task :require_librsvg do
raise 'rsvg-convert required. Install with `brew install librsvg`' unless system('which rsvg-convert > /dev/null')
end
desc "Build PNG versions of SVG logos at #{Assets::LOGO_WIDTH}px width"
task logos: [:require_librsvg].concat(logo_png_file_tasks)
namespace :logos do
desc 'Clean logos'
task(:clean) { logo_png_file_tasks.each { |t| FileUtils.rm_f(t.name) } }
end
end
if Rake::Task.task_defined?('spec:prepare')
Rake::Task['spec:prepare'].enhance(["#{Assets::BUILDS_DIR}/application.js", "#{Assets::BUILDS_DIR}/application.css"])
end
// Default to development, or `yarn build` won't build a sourceMap
process.env.NODE_ENV = process.env.NODE_ENV || "development"
import dotenv from 'dotenv'
import path from 'path'
import pkg from '@sprout2000/esbuild-copy-plugin';
const { copyPlugin } = pkg;
import esbuild from 'esbuild'
// read env vars from `./.env`. Note that when this process is invoked from Ruby via `system('yarn build')`,
// dotenv-rails will already have forwarded env vars from whatever env it was called from, so we need
// to specify { override: true } to get it to prefer values in `./.env`. This ensures that running a spec
// doesn't overwrite our real GETADDRESS_API_KEY with DUMMY. See commit message for more.
dotenv.config({ override: true })
const define = {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
'process.env.GETADDRESS_API_KEY': JSON.stringify(process.env.GETADDRESS_API_KEY),
'process.env.POSTCODER_API_KEY': JSON.stringify(process.env.POSTCODER_API_KEY),
'process.env.ENABLE_LIVE_RELOADING': JSON.stringify(process.env.ENABLE_LIVE_RELOADING || '0'),
'global': 'window'
}
const SRCDIR = "app/javascript";
const OUTDIR = "app/assets/builds";
const PUBLIC_ASSETS = "public/assets"
const config = {
logLevel: "info",
entryPoints: ["application.js", "flows.js", "back_office.js"],
bundle: true,
minify: process.env.NODE_ENV === "production",
sourcemap: true,
outdir: path.join(process.cwd(), OUTDIR),
absWorkingDir: path.join(process.cwd(), SRCDIR),
plugins: [
copyPlugin({
src: "./node_modules/@hpcc-js/wasm/dist/graphvizlib.wasm",
dest: path.join(PUBLIC_ASSETS, 'graphvizlib.wasm'),
recursive: true
}),
],
define,
}
if(process.argv.includes("--watch")) {
const context = await esbuild.context(config)
await context.watch()
await context.serve({ host: 'localhost', port: 8075 })
} else {
await esbuild.build(config)
}
process.env.NODE_ENV = process.env.NODE_ENV || "development"
import path from 'path'
import fs from 'fs'
import esbuild from 'esbuild'
import { sassPlugin } from 'esbuild-sass-plugin'
const markServedExternal = {
name: 'mark-served-external',
setup(build) {
build.onResolve({filter: /\.(ttf|woff2)$/}, (args)=> ({path: args.path, external: true}))
build.onResolve({filter: /\.(png|svg)$/}, (args)=> ({path: args.path, external: true}))
},
};
const tenantKeys = fs.readFileSync('TENANTS').toString().trim().split("\n")
const cssEntryPoints = tenantKeys.map((tenantKey) => `${tenantKey}.scss`)
const mailerCssEntryPoints = tenantKeys.map((tenantKey) => `mailers/${tenantKey}.scss`)
const jtEntryPoint = ['jt.scss']
const SRCDIR = "app/assets/stylesheets";
const OUTDIR = "app/assets/builds";
const config = {
logLevel: "info",
entryPoints: cssEntryPoints.concat(mailerCssEntryPoints).concat(jtEntryPoint),
bundle: true,
minify: process.env.NODE_ENV === "production",
sourcemap: true,
outdir: path.join(process.cwd(), OUTDIR),
absWorkingDir: path.join(process.cwd(), SRCDIR),
plugins: [
markServedExternal,
sassPlugin({
loadPaths: [SRCDIR, 'node_modules'],
quietDeps: true,
})
],
}
if(process.argv.includes("--watch")) {
const context = await esbuild.context(config)
await context.watch()
await context.serve({ host: 'localhost', port: 8655 })
} else {
await esbuild.build(config)
}
{
"scripts": {
"build": "node esbuild.config.js",
"test": "jest",
"build:css": "node esbuild.css.js"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment