Staticky
Staticky is a static site builder for Ruby maximalists. I built this library because I wanted something more scriptable than Bridgetown and Jekyll that had first-class support for Phlex components.
Phlex makes building component based frontends fun and I wanted to extend the developer experience of something like Rails but focused on static sites.
I am using this at https://taintedcoders.com. (soon)
- Hot reloading in development with Roda serving static files
- Docker deployment with NGINX
You can find a working setup in site_template
folder.
Installation
Install the gem and add to the application's Gemfile by executing:
$ bundle add staticky
If bundler is not being used to manage dependencies, install the gem by executing:
$ gem install staticky
Usage
First you can use the CLI to generate a new template:
staticky new my_blog --url "https://example.com"
This will generate a new site at ./my_blog
, install your dependencies and run
rspec
just to make sure everything got set up correctly.
You can append --help
to any commands to see info:
staticky new --help
Which outputs:
Command:
staticky new
Usage:
staticky new PATH
Description:
Create new site
Arguments:
PATH # REQUIRED Relative path where the site will be generated
Options:
--url=VALUE, -u VALUE # Site URL, default: "https://example.com"
--title=VALUE, -t VALUE # Site title, default: "Example"
--description=VALUE, -d VALUE # Site description, default: "Example site"
--twitter=VALUE, -t VALUE # Twitter handle, default: ""
--help, -h # Print this help
Plugins
The router and resources use the plugin pattern found in Sequel and Roda.
This means you can easily extend each of them with plugins to fit the specific content of your site.
module MyResourcePlugin
module InstanceMethods
def component=(component)
@component = component
end
def component
return @component if defined?(@component)
raise ArgumentError, "component is required"
end
end
end
In our own classes we can now reference our new plugin:
class SomeResource < Staticky::Resource
plugin MyResourcePlugin
end
Or, if we register the plugin with register_plugin
we can just use our
shorter symbol:
Staticky::Resources::Plugins.register_plugin(:something, MyResourcePlugin)
class SomeResource < Staticky::Resource
plugin :something
end
This system lets you define your own specific resources by subclassing and extending with your own plugins.
Here is an example of hooking into the output of the component (a string of HTML):
module MinifyHTML
module InstanceMethods
# Calling super works because the base class has no methods, everything is
# a plugin including the core behavior of a resource.
def build
SomehowMinifyTheHTML.call(super)
end
end
end
Staticky::Resources::Plugins.register_plugin(:minify_html, MinifyHTML)
class ApplicationResource < Staticky::Resource
plugin :minify_html
end
Now when an ApplicationResource
gets rendered, its final output (a string of
HTML) will be minified.
Each plugin can define modules for:
Name | Description |
---|---|
InstanceMethods | Get added as instance methods of the Resource |
ClassMethods | Get added as the class methods of the Resource |
In addition you have methods you can define that let you hook into the resource that adds your plugins:
Name | Description |
---|---|
load_dependencies(plugin, ...) | Hook to load any other plugins required by this one |
configure(plugin, ...) | Hook for additional setup required on the class |
Routing
Your router is a plugin system that by default only has one plugin:
plugin :prelude
This gives you the match
and root
methods in your router. You can override
or extend these methods yourself by redefining them (and optionally calling
super
) inside your own plugin or class that inherits from the router.
Once your site is generated you can use the router to define how your content
maps to routes in config/routes.rb
:
Staticky.router.define do
root to: Pages::Home
# We can pass in a phlex class
match "404", to: Errors::NotFound
# Or an instance
match "500", to: Errors::ServiceError.new
# We can specify the resource type
match "about",
to: Markdown.new("content/posts/about.md"),
as: Resources::Markdown
# Write your own logic to parse your data into components
Site.posts.each_value do |model|
match model.relative_url, to: Posts::Show.new(model)
end
end
Each route takes a Phlex component (or any object that outputs a string from
#call
). We can either pass the class for a default initialization (we just
call .new
) or initialize it ourselves.
The resource will be initialized with a component
and a url
. It is used as
the view context for your phlex components.
Match
This works in a similar way to your Rails routes. Match takes a path and a component (either a class or an instance) that it will route to.
match "404", to: Errors::NotFound, as: Resource
Root
Using match
you can define a root path like:
match "/", to: Pages::Home
For convenience you can shorten this using root
:
root to: Pages::Home
Resources
They initialize the same way ActiveModel
objects do. That is they take their
keywords and call the setter according to the keys:
def new(**env)
super().tap do |resource|
env.each do |key, value|
resource.send(:"#{key}=", value)
end
end
end
The base resource has two core plugins it includes by default:
plugin :prelude
plugin :phlex
Routes define your resources, which in the end are just data objects that
contain all the information required to produce the static file that eventually
outputs to your Staticky.build_path
.
Lets say we had a router defined like:
Staticky.router.define do
match "foo", to: Component
match "bar", to: Component
end
Then we could view our resources:
(ruby) Staticky.resources
[#<Staticky::Resource:0x0000711525d82c18
@component=#<Component:0x0000711525d74848>,
@destination=#<Pathname:/your-site-folder/build>,
@uri=#<URI::Generic /foo>,
@url="foo">,
#<Staticky::Resource:0x0000711525d82a88
@component=#<Component:0x0000711525d74208>,
@destination=#<Pathname:/your-site-folder/build>,
@uri=#<URI::Generic /bar>,
@url="bar">]
The prelude
plugin provides the following methods:
Method | Description |
---|---|
filepath |
Pathname of where the component's output will be written to |
read |
Read the output of the resource from the file system |
basename |
The file basename (e.g. index.html ) for the resource |
root? |
Whether or not the resource is the root path |
While the phlex
plugin provides:
Method | Description |
---|---|
build |
Call the component and output its result as a string |
These resources are used by your site builder to output the files that end up in
the Staticky.build_path
.
Each resource needs to have a #build
method that creates a file in your build
folder.
The phlex
plugin will call your components with a ViewContext
just like
ActionView
in Rails. But this context is tailored towards your static site.
This view context is a SimpleDelegator
to your resource with a few extra
methods:
Method | Description |
---|---|
root? |
Whether or not this resource is for the root page |
current_path |
The path of the current resource being rendered |
These are useful for creating pages that hide or show content depending on which path of the site we are building.
Linking to your routes
First you need to include the view helpers somewhere in your component hierarchy:
class Component < Phlex::HTML
include Staticky::Phlex::ViewHelpers
end
This will add link_to
to all your components which uses the router to resolve
any URLs via their path.
Here is an example of what the Posts::Show
component might look like. We are
using a protos component, but you can
use plain old Phlex components if you like.
module Posts
class Show < Component
param :post, reader: false
def around_template(&)
render Layouts::Post.new(class: css[:layout], &)
end
def view_template
# Links can be resolved to component classes if they are unique:
link_to "Home", Pages::Home
# They can also resolve via their url:
link_to "Posts", "/posts"
# Absolute links are resolved as is:
link_to "Email", "mailto:[email protected]"
render Posts::Header.new(@post)
render Posts::Outline.new(@post, class: css[:outline])
render Posts::Markdown.new(@post, class: css[:post])
render Posts::Footer.new(@post)
end
private
def theme
{
layout: "bg-background",
outline: "border",
post: "max-w-prose mx-auto"
}
end
end
end
The advantage of using link_to
over plain old a
tags is that changes to your
routes will raise errors on invalidated links instead of silently
linking to invalid pages.
If your component is unique then you can link directly to them (if its not
unique then it will link to the last defined match
):
link_to("Some link", Pages::Home)
Otherwise you can link to the path itself:
link_to("Some link", "/")
Building your site
When you are developing your site you run bin/dev
to start your development
server on http://localhost:9292.
This will automatically reload after a short period when you make changes.
Assets are handled by Vite by default, but you can have whatever build process
you like just by tweaking Procfile.dev
and your Rakefile
. You will also need
to create your own view helpers for linking your assets.
By default, to build your site you run the builder, usually inside a Rakefile:
require "vite_ruby"
ViteRuby.install_tasks
desc "Precompile assets"
task :environment do
require "./config/boot"
end
namespace :site do
desc "Precompile assets"
task build: :environment do
Rake::Task["vite:build"].invoke
Staticky.builder.call
end
end
This will output your site to ./build
by default.
During building, each definition in the router is compiled and handed a special
view context which holds information about the resource being rendered such as
the current_path
.
These are available in your Phlex components under helpers
(if you are using
the site template). This matches what you might expect when using Phlex in
Rails with phlex-rails
.
Configuration
We can override the configuration according to the settings defined on the main module:
Staticky.configure do |config|
config.env = :test
config.build_path = Pathname.new("dist")
config.root_path = Pathname(__dir__)
config.logger = Logger.new($stdout)
config.server_logger = Logger.new($stdout)
end
Environment
You can define the environment of Staticky through its config.
Staticky.configure do |config|
config.env = :test
end
This lets you write environment specific code:
if Staticky.env.test?
# Do something test specific
end
Testing
We can setup a separate testing environment by putting the following
into your spec/spec_helper.rb
:
Staticky.configure do |config|
config.root_path = Pathname.new(__dir__).join("fixtures")
config.build_path = Pathname.new(__dir__).join("fixtures/build")
config.env = :test
end
This sets up our build path to something different than our development builds.
Staticky uses Dry::System
to manage its dependencies which means you can stub
them out if you want:
require "dry/system/stubs"
Staticky.application.enable_stubs!
RSpec.configure do |config|
config.before do
Staticky.application.stub(:files, Staticky::Filesystem.test)
end
end
This lets you test your builds using dry-files
(actually staticky-files
, but
the interface is the same with additional capabilities for file folders).
The advantage of this is that we can perform our builds on a temporary in memory file system rather than actually writing to our disk.
The plugins themselves can also be stubbed:
require "dry/system/stubs"
Staticky::Resources::Plugins.enable_stubs!
Staticky::Routing::Plugins.enable_stubs!
RSpec.configure do |config|
config.before do
Staticky::Resources::Plugins.stub(:prelude, MyOwnResourcePlugin)
Staticky::Routing::Plugins.stub(:prelude, MyOwnRoutingPlugin)
end
end
Development
After checking out the repo, run bin/setup
to install dependencies. Then, run
bin/rspec
to run the tests. You can also run bin/console
for an interactive
prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To
release a new version, update the version number in version.rb
, and then run
bundle exec rake release
, which will create a git tag for the version, push
git commits and the created tag, and push the .gem
file to
rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/nolantait/staticky.
License
The gem is available as open source under the terms of the MIT License.