Skip to content

Repository files navigation

Klenod

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.

Packages

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.

Basic Usage

Create a build context with a source directory:

context = Klenod::Build::Context.new(source_dir: "src")
entry = context.entry("pages/server")
page = entry.exports

Ruby 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
end

Use 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.

Collection And Evaluation

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.exports
  • entry.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.

Build A Bundle

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 build

The 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
]

Test An Application

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 coverage

The 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: 90

Tests 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.svg

The 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.

Entry Handles

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
end

apply_update refreshes the entry and mirrors changed assets when assets_dir: is set.

Assets

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:).

Plugins

The default build context includes these plugins:

The data format plugins use the shared DataPlugin base class.

These built-in plugins are optional:

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.

Examples

The web example is the main integration test and reference application.

The standalone example demonstrates non-web bundle use.

Development

Run the full test suite:

bundle exec rake

Run focused test suites:

bundle exec rake test:runtime
bundle exec rake test:build
bundle exec rake test:rack
bundle exec rake test:gems

Run a single test file:

bundle exec ruby gems/klenod-build/lib/klenod/build/plugins/router_plugin.test.rb

Run Standard on changed Ruby files:

RUBOCOP_CACHE_ROOT=/private/tmp/rubocop_cache bundle exec standardrb path/to/file.rb

More Documentation

About

A plugin-based module bundler for Ruby

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages