Klenod is an experimental module bundler for Ruby, inspired by JavaScript bundlers.
Klenod reads files from a source directory, applies plugins, records a dependency graph, and writes a runtime bundle. The runtime bundle can run without build plugins.
Klenod was created because I needed a better module system for my web framework Mayu.
The project is still early in development. Contributions are welcome.
The repository contains seven gems:
klenod: provides the build and runtime packages together.klenod-runtime: loads bundles, evaluates modules, reads source maps, and rewrites backtraces.klenod-build: builds graphs, runs plugins, watches files, writes bundles, and provides the CLI.klenod-test: runs application tests, watches their dependency graph, and reports source-mapped coverage without choosing a test framework.klenod-rack: provides Rack helpers for serving bundled assets.klenod-plugin-css: adds CSS assets and CSS Modules support.klenod-plugin-javascript: adds JavaScript and TypeScript assets.
Production applications that only load a prebuilt bundle usually only need klenod-runtime.
Create a build context with a source directory:
context = Klenod::Build::Context.new(source_dir: "src")
entry = context.entry("pages/server")
page = entry.exportsRuby modules import other modules with literal import("...") calls:
Shared = import("../shared")
Styles = import("./styles/home.css")
Hero = import("./hero.png?width=320,640&format=png")Relative imports resolve from the importing file. Bare imports like import("Card") are also relative. Leading-slash imports resolve from the current scheme root; for normal app files that is the configured source directory.
Card = import("./Card")
Layout = import("/layouts/App")
Router = import("virtual:router")Internally, source files use canonical module ids such as app:/layouts/App.rb, while virtual modules use ids such as virtual:/router.rb. Plugins can own other schemes, for example gem://some-gem/components/Button.rb.
Use lazy_import("...") to record a dependency and defer loading its value:
Details = lazy_import("./details")
def self.render_details
Details.call::Default.new.render
endUse import_glob("...") for a deterministic hash of matched files:
Gallery = import_glob("./gallery/*.{jpg,png}?width=320,640&format=webp")
Pages = import_glob("./pages/*.rb", eager: false)The hash keys are the matched import specifiers without query strings.
Klenod separates graph collection from module evaluation.
Collection reads source, transforms it, records dependencies, emits assets, and stores a module record.
Evaluation instantiates a Klenod::Runtime::Mod and runs the module Ruby code inside it.
These APIs collect without evaluating application code:
context.entry(...)context.collect(...)
These APIs evaluate on demand:
entry.exportsentry.call(...)context.exports(...)context.evaluate(...)
Build mode collects and serializes modules. It does not evaluate application top-level code.
Read Graph And Plugin Phases for the detailed lifecycle.
Use the Ruby API to build a bundle:
bundle = context.build(
entrypoints: ["pages/server"],
output: "dist/klenod.bundle",
assets_dir: "public"
)You can also use the CLI from a directory with klenod.config.rb:
bundle exec klenod buildThe CLI finds the nearest klenod.config.rb. Then it changes into that directory before it builds.
A configuration file is Ruby:
source_dir "src"
entrypoint "pages/server"
output "dist/klenod.bundle"
assets_dir "public"
mode :development
plugins [
Klenod::Build::Plugins::RubyPlugin.new
]The klenod meta-gem includes klenod-test. Add Klenod::Test::Plugin to the
application's plugins, then run:
bundle exec klenod test --run
bundle exec klenod test --watch
bundle exec klenod coverageThe command finds the nearest klenod.test.rb. This file provides a fresh build
context and the callback that runs selected test modules with Minitest, RSpec, or
another testing library:
context do
path = File.expand_path("klenod.config.rb", __dir__)
Klenod::Build::ConfigLoader.load(path).context
end
execute do |context, test_paths|
# Run the selected test modules and return an integer exit status.
end
coverage report: :brief, minimum: 90Tests run once in CI and watch by default otherwise. A changed test or one of its
dependencies reruns only the affected test files in a fresh worker process.
The coverage command runs the full suite once. Use --report and --minimum to
override the coverage settings for one run.
Load a runtime bundle without build plugins:
require "klenod/runtime"
bundle = Klenod::Runtime.load_bundle("dist/klenod.bundle")
page = bundle.exports("pages/server")You can inspect a built bundle as Graphviz DOT:
bundle exec klenod graph dist/klenod.bundle > graph.dot
dot -Tsvg graph.dot > graph.svgThe graph hides Klenod's internal virtual modules by default. Pass
--internal-virtual-modules to include them. Application-facing virtual modules,
such as an imported router module, remain visible.
Frameworks usually keep an entry handle:
entry = context.entry("pages/server")
status, headers, body = entry.call(request, context)
page = entry.exports
stylesheets = entry.assets(type: :css)The handle stays valid after development updates. Klenod resolves it through the current graph state when code asks for exports, calls, or assets.
entry.assets returns reachable assets by default. Pass recursive: false to get only assets that the entry emits directly.
Watch-mode consumers can apply updates and keep the same handle:
context.on_update do |event|
update = context.apply_update(event, entry: entry, assets_dir: "public")
if update.success?
status, headers, body = update.entry.call(nil, context)
css_assets = update.entry.assets(type: :css)
else
update.error_messages.each { |message| warn message }
end
endapply_update refreshes the entry and mirrors changed assets when assets_dir: is set.
Plugins emit assets through Klenod::Build::Asset.
Assets have two stable names:
logical_name: the source-root-relative path without import query parameters.output_path: the public content-hashed path for browsers.
Example:
logical_name # "images/hero.png"
output_path # "/assets/hero.320w.abc123.png"The graph and runtime bundle expose the same lookup shape:
context.asset("/assets/home.abc123.css")
context.assets_for("styles/home.css")
context.assets_for_module("pages/server.rb", type: :css)
bundle.asset("/assets/home.abc123.css")
bundle.assets_for("styles/home.css")
bundle.assets_for_module("pages/server.rb", type: :css)Import query parameters configure one import. They do not change the logical name.
LargeHero = import("images/hero.png?width=640&format=png")
ResponsiveHero = import("images/hero.png?width=320,640&format=png")Klenod reuses overlapping generated variants across imports. In this example, it generates the 640 variant once.
When Context#build receives assets_dir:, Klenod writes emitted assets under that directory.
In development, frameworks can serve context.asset(path).bytes or context.asset_bytes(path, assets_dir:).
The default build context includes these plugins:
RubyPlugin: collects Ruby imports and prepares them for the runtime.IntlPlugin: loads companion translation files for Haml.HamlPlugin: transforms Haml into Ruby component classes.MarkdownPlugin: transforms Markdown into component factory calls.GemImportPlugin: resolves modules from exposed paths in installed gems.SvgPlugin: emits SVG assets and image metadata.ImagePlugin: emits raster images and responsive variants.JsonPlugin: imports JSON data.YamlPlugin: imports YAML data.TomlPlugin: imports TOML data.TextPlugin: imports text files.
The data format plugins use the shared DataPlugin base class.
These built-in plugins are optional:
GoogleFontsPlugin: downloads Google Fonts and emits local assets.RouterPlugin: generates a virtual router from a route tree.
Testing, CSS, and JavaScript support are separate gems:
Klenod::Test::Plugin: discovers test entrypoints and keeps test files out of application imports.CSSPlugin: scopes CSS Modules and emits CSS assets.JavaScriptPlugin: collects JavaScript dependencies and emits JavaScript assets.
The web example is the main integration test and reference application.
The standalone example demonstrates non-web bundle use.
Run the full test suite:
bundle exec rakeRun focused test suites:
bundle exec rake test:runtime
bundle exec rake test:build
bundle exec rake test:rack
bundle exec rake test:gemsRun a single test file:
bundle exec ruby gems/klenod-build/lib/klenod/build/plugins/router_plugin.test.rbRun Standard on changed Ruby files:
RUBOCOP_CACHE_ROOT=/private/tmp/rubocop_cache bundle exec standardrb path/to/file.rb