# 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](https://phlex.fun) 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 currently using this to create https://taintedcoders.com - 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, -x VALUE # Twitter handle, default: "" --help, -h # Print this help ``` ### Plugins The router and resources use the plugin [pattern](https://janko.io/the-plugin-system-of-sequel-and-roda/) found in [Sequel](https://github.com/jeremyevans/sequel) and [Roda](https://github.com/jeremyevans/roda). This means you can easily extend each of them with plugins to fit the specific content of your site. ```ruby 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: ```ruby class SomeResource < Staticky::Resource plugin MyResourcePlugin end ``` Or, if we register the plugin with `register_plugin` we can just use our shorter symbol: ```ruby 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): ```ruby 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: ```ruby 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`: ```ruby 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. ```ruby match "404", to: Errors::NotFound, as: Resource ``` #### Root Using `match` you can define a root path like: ```ruby match "/", to: Pages::Home ``` For convenience you can shorten this using `root`: ```ruby 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: ```ruby 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: ```ruby 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: ```ruby Staticky.router.define do match "foo", to: Component match "bar", to: Component end ``` Then we could view our resources: ``` (ruby) Staticky.resources [#, @destination=#, @uri=#, @url="foo">, #, @destination=#, @uri=#, @url="bar">] ``` The `prelude` plugin provides the following methods: |Method|Description| |------|-----------| |`build_path`|`Pathname` of where the component's output will be written to| |`read`|Read the output of the resource from the file system| |`filepath`|The file path (e.g. `about/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: ```ruby 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](https://github.com/inhouse-work/protos) component, but you can use plain old Phlex components if you like. ```ruby module Posts class Show < ApplicationComponent 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@example.com" 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`): ```ruby link_to("Some link", Pages::Home) ``` Otherwise you can link to the path itself: ```ruby 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:3000](http://localhost:3000). 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: ```ruby 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`. ## Live reloading The development server has been hooked up with some live reloading using server-side events. A javascript script is inserted into the `` tag during development which will poll the `_staticky/live_reloading` endpoint. If files have changed then a reload is triggered with `Turbo` if available, and just plain `window.location.reload()` if not. You can toggle this off by setting `live_reloading` to false inside the config. ## Configuration We can override the configuration according to the settings defined on the main module: ```ruby 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) config.live_reloading = false end ``` ### Environment You can define the environment of Staticky through its config. ```ruby Staticky.configure do |config| config.env = :test end ``` This lets you write environment specific code: ```ruby 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`: ```ruby 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: ```ruby 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: ```ruby 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](https://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](https://opensource.org/licenses/MIT).