Configuring ActiveStorage for DigitalOcean Spaces

    Joy of Rails uses ActiveStorage to manage image attachments, such as the screenshots for the snippets. Images are stored in the S3-compatible DigitalOcean Spaces service. Configuration is as simple as providing credentials in config/storage.yml as shown here.

    config/storage.yml
    digitalocean:
      service: S3
      access_key_id: <%= Rails.application.credentials.dig(:digitalocean, :access_key) %>
      secret_access_key: <%= Rails.application.credentials.dig(:digitalocean, :secret) %>
      bucket: <%= Rails.application.credentials.dig(:digitalocean, :bucket) %>
      endpoint: https://nyc3.digitaloceanspaces.com
      region: us-east-1

    Phlex is fun

    I love Ruby so much that I write my HTML in Ruby. I’ve got nothing against ERb, but after a year of writing Phlex components, I’m not looking back.

    render CodeBlock::Body.new(data: {controller: "snippet-editor"}) do
      div(class: "grid-stack") do
        render CodeBlock::Code.new(source, language: language, data: {snippet_editor_target: "source"}) do
          label(class: "sr-only", for: "snippet[source]") { "Source" }
          div(class: "code-editor autogrow-wrapper") do
            textarea(
              name: "snippet[source]",
              data: {snippet_editor_target: "textarea"}
             ) { source }
          end
        end
      end
    end

    Ruby’s backslash line continuation

    It’s such a simple thing, but I love that Ruby treats the backslash character as a line continuation, similar to a shell.

    I often use it to omit parens and group keyword arguments on new line.

    app/models/page/resource.rb
    def self.from(sitepress_resource)
      Resource.new \
        request_path: sitepress_resource.request_path,
        body: sitepress_resource.body,
        title: sitepress_resource.data.title,
        description: sitepress_resource.data.description,
        image: sitepress_resource.data.image,
        # ...

    Browsers are really good at parsing

    I like organize my Tailwind classes by function with line breaks and tabs. I find it easier to read for excessively long class names.

    But I find ERB formatting support in VS Code a bit lacking at the moment.

    question.html.erb
    <div 
      id="<%= dom_id(question, :body) %>"
      class="
        question__body
        flex items-start flex-col
        space-col-4 grid-cols-12
        md:items-center md:flex-row md:space-row-4
      "><!-- -->

    ActiveRecord::Relation #with and #excluding

    The related pages feature on Joy of Rails queries on the pages and page_embeddings tables in the database. The query is backed two relatively new features in ActiveRecord: ActiveRecord::Relation#excluding and the yet-to-be-documented (as far as I know) ActiveRecord::Relation#with for representing Common Table Expressions (CTE).

    app/models/page.rb
    select("pages.*", "similar_embeddings.distance")
      .with(similar_embeddings: PageEmbedding.similar_to(page))
      .excluding(page)
      .order(distance: :asc)

    A snippet() snippet

    snippet() is a SQLite function that returns a snippet of text containing the search term. It’s used to highlight search results. This example shows an ActiveRecord scope that enables selection additional fields title_snippet and body_snippet from an FTS5 virtual table (created separately).

    https://www.sqlite.org/fts5.html#the_snippet_function

    app/models/page.rb
    scope :with_snippets do
      select("pages.*")
        .select("snippet(pages_search_index, 0, '<mark>', '</mark>', '…', 32) AS title_snippet")
        .select("snippet(pages_search_index, 1, '<mark>', '</mark>', '…', 32) AS body_snippet")
    end

    app/models/markdown.rb

    This example demonstrates how you could parse a Commonmarker markdown document into an abstract syntax tree to customize the output. Each node has a type to switch on behavior and an each method for iterating over its direct children.

    ruby
    require "commonmarker"
    
    def visit(node)
      return if node.nil?
    
      case node.type
      in :document
        visit_children(node)
      in :heading
        header(node.header_level) { visit_children(node) }
      in :paragraph
        p { visit_children(node) }
    
        # ...
      end
    end
    
    def visit_children(node)
      node.each { |c| visit(c) }
    end
    
    visit(Commonmarker.parse("# Hello World\n\nHappy Friday!"))

    config/initializers/vapid.rb

    The X-Factor of Rails configuration

    Use the Rails config.x object to add organize custom configuration into namespaces.

    config/initializers/vapid.rb
    Rails.application.configure do
      config.x.vapid.public_key = credentials.dig(:vapid, :public_key)
      config.x.vapid.private_key = credentials.dig(:vapid, :private_key)
    end

    footer.css

    CSS :has() Example

    Hey Tailwind enthusiasts, don‘t sleep on CSS. It‘s getting really good. Modern browsers now support features like custom variables, new pseudo selectors (e.g. :has(), :is()), and CSS nesting. You can do some sweet styling without need of a preprocessor like SASS. Here‘s a snippet from the footer nav on https://www.joyofrails.com.

    footer.css
    nav li:has(+ li) {
      &:after {
        margin: 0 var(--space-xs);
        content: ' / ';
      }
    }

    Markdown ASTs with Commonmarker

    For converting Markdown, Rubyists may default to Redcarpet or Kramdown. I suggest taking a look at Commonmarker, a Ruby gem built on top of comrak, a Rust of implementation the GitHub-flavored version of the CommonMark spec.

    Why? One reason is you can parse Markdown into an abstract syntax tree (AST) which enables powerful customization of what you can do with the markdown source.

    require 'commonmarker'
    Commonmarker.to_html('"Hi *there*"', options: {
        parse: { smart: true }
    })
    # => <p>“Hi <em>there</em>”</p>\n
    
    doc = Commonmarker.parse("*Hello* world", options: {
        parse: { smart: true }
    })
    doc.walk do |node|
      puts node.type # => [:document, :paragraph, :emph, :text, :text]
    end

    app/views/users/newsletter_subscriptions/_banner.html.erb

    Phlex and ERB play nice together!

    If you’re curious about Phlex but unsure of whether you want to go "all in", you can start small. You can render a Phlex component in ERB and you can yield back to the ERB template from Phlex. You can adopt Phlex gradually.

    This code snippet displays an ERB template that renders a Phlex component called Users::NewsletterSubscriptions::Banner. The first render call yields to a block that in turns renders another ERB partial called "users/newsletter_subscriptions/form". These features help demonstrate Phlex’s compatibility with ERB.

    app/views/users/newsletter_subscriptions/_banner.html.erb
    <%= render Users::NewsletterSubscriptions::Banner.new do %>
      <%= render "users/newsletter_subscriptions/form" %>
    <% end %>

    Vite on Rails

    An open secret about Rails and JavaScript: lots of apps are using Vite to bundle frontend assets via the excellent Rails integration provided by Vite Ruby. You may want to give it a try. #rails #vite

    bundle add vite_rails
    bundle exec vite install

    config/database.yml

    I‘ve mentioned previously that Joy of Rails uses SQLite along with Rails support for multiple databases. Here's a snapshot of what the Joy of Rails config/database.yml file looks like in development. I’ve separated the database for each of the Rails "backends" running on SQLite, including ActiveRecord (primary), Rails cache, Solid Queue job queue, and Action Cable. Each simply maps to a different file on disk.

    config/database.yml
    default: &default
      adapter: sqlite3
      pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 3 } %>
      timeout: 5000
    
    development:
      primary:
        <<: *default
        database: storage/development/data.sqlite3
      cache:
        <<: *default
        database: storage/development/cache.sqlite3
        migrations_paths: db/migrate_cache
      queue:
        <<: *default
        database: storage/development/queue.sqlite3
        migrations_paths: db/migrate_queue
      cable:
        <<: *default
        database: storage/development/cable.sqlite3
        migrations_paths: db/migrate_cable

    ApplicationController.render(
      inline: article.body,
      type: :"mdrb-atom",
      layout: false,
      content_type: "application/atom+xml",
      assigns: {
        format: :atom
      }
    )

    app/javascript/controllers/screenshot.js

    app/javascript/controllers/screenshot.js
    import { Controller } from '@hotwired/stimulus';
    import * as htmlToImage from 'html-to-image';
    
    export default class extends Controller {
      connect() {
        this.element.addEventListener(
          'turbo:before-fetch-request',
          this.prepareScreenshot,
        );
      }
    
      prepareScreenshot = async (event) => {
        event.preventDefault();
        if (event.detail.fetchOptions.body instanceof URLSearchParams) {
          const data = await htmlToImage.toPng(this.snippetTarget);
          event.detail.fetchOptions.body.append('screenshot', data);
        }
        event.detail.resume();
      };
    }