📌 Note:

Right now you are viewing the "rendered" version of the markdown document.

If you want to download the "raw" version, go here.

The ArchiveSet abstraction

{"filename": "archiveset.rb"}
<<spdx_headers>>

class ArchiveSet
<<archiveset_constructor>>

<<archiveset_lookup>>
end

The project is built around the ArchiveSet class. When instantiated, this class takes a list of archive files (usually tar, but other file-formats are accepted), uses fuse-archive to mount them to unique temporary directories (created using Dir.mktmpdir), and stores the mapping between the original archive-file and the mount-point.

Also, if one of the fuse-archive mount operations throws an error (for example, if one of the archives is corrupted or inaccessible), the error is processed silently.

Since we're using a fuse mount, the archives are not expanded on the drive, and the files stored inside the archives are made available only on demand.

{"name": "archiveset_constructor"}
  def initialize(archive_list)
    @archive_mapping = Hash.new

    archive_list.each do |archive_filepath|
      newtempdir = Dir.mktmpdir
      begin
        return_code_ok = system("fuse-archive -o nocache,notrim,quiet #{archive_filepath} #{newtempdir}")
        @archive_mapping[newtempdir] = archive_filepath if return_code_ok
      rescue
        Dir.rmdir newtempdir
      end
    end
  end

We can check this process using an irb shell:

irb(main):001> require './archiveset'
irb(main):002> inst = ArchiveSet.new(['/home/user/archive.tar'])
irb(main):003> inst.instance_variable_get :@archive_mapping
=> {"/tmp/d20260621-1889463-ckv11s" => "/home/user/archive.tar"}

Once the ArchiveSet is initialized, we can use a lookup method that iterates the set of archives and check in every one of them if a target-path is found. At the end it returns a dictionary related to the first encountered file matching that target-path, or a not_found element.

{"name": "archiveset_lookup"}
  def lookup(searched_suffix)
    @archive_mapping.each do |path, source_archive|
      temp_filepath = "#{path}/#{searched_suffix}"

      if File.exist? temp_filepath
        return {
          status: :ok,
          filepath: temp_filepath,
          archive: source_archive
        }
      end
    end

    { status: :not_found }
  end

We can continue the check using irb shell:

irb(main):004> inst.mirror_lookup('known_file')
=> {status: :ok, filepath: "/tmp/d20260621-1889463-ckv11s/known_file", archive: "/home/user/archive.tar"}
irb(main):005> inst.mirror_lookup('unknown_file')
=> {status: :not_found}

Configuring the ArchiveSets

For Yocto builds, we'll need two ArchiveSets: one for downloaded source-code and the second for sstate.

Then, to allow a server administrator to define what goes into each ArchiveSet, we set up a separate file (called settings.rb) containing a dictionary, where every record corresponds to a list of archives.

The template is the following:

{"filename": "settings.rb"}
<<spdx_headers>>

ARCHIVE_PATHS = {
  downloads: [],
  sstate: []
}

And here is an example for a fully-configured file:

{"filename": "settings.rb.example"}
<<spdx_headers>>

ARCHIVE_PATHS = {
  downloads: ['/var/archive/wrynose_downloads.tar', '/var/archive/whinlatter_downloads.tar'],
  sstate: ['/var/archive/wrynose_sstate.tar', '/var/archive/whinlatter_sstate.tar']
}

Then, in the main application file, we import the archiveset.rb and settings.rb files into the current namespace and define the mount_archivesets method. This method reads the ARCHIVE_PATHS dictionary and, for every key of the dictionary, calls the ArchiveSet constructor.

{"name": "require_settings_and_archiveset"}
require './archiveset'
require './settings'
{"name": "mount_archivesets_definition"}
def mount_archivesets
  return_mapping = Hash.new

  ARCHIVE_PATHS.each do |archive_type, archive_list|
    return_mapping[archive_type] = ArchiveSet.new archive_list
  end

  return_mapping
end

Building the basic application

One important part of the application is the configure block. It defines a key-value data-structure that gets evaluated once, at the launch of the web-application, and controls how the application runs.

{"name": "configure_block"}
configure do
<<configure_block_database_setup>>
  set :mirror_paths, mount_archivesets
  set :port, 8123
  set :bind, '0.0.0.0'
end

The bind and port keys control the address that the web-server will listen on. By default the bind address is localhost, so in order to make the mirror accessible across the network we have to replace it with 0.0.0.0, meaning "listen on all available network interfaces", on port 8123.

The mirror_paths initialization will call the mount_archivesets method once and store the returned dictionary. The result of that method can be accessed from various handlers by calling settings.mirror_paths.

The rest of the application relies on route-handlers to call the correct backend code whenever an URL is requested.

The heavy-lifting is done by a GET-handler for paths matching the template /mirror/:type/:project_id/:build_id/*. The code inside the handler can find out the string that matched the :type pattern by calling params['type']. The last part of the pattern is a wildcard, which can be accessed by calling params['splat'] (this is represented as an array).

The two results of the handler are either:

The handler will be called when requesting URL addresses like:

  • http://10.0.0.1:8123/mirror/downloads/raspberrypi_build/0001/linux.tar.gz
  • http://10.0.0.1:8123/mirror/downloads/raspberrypi_build/19700101/linux.tar.gz
  • http://10.0.0.1:8123/mirror/sstate/main_release/19700101/00/aa/00aafffff.tgz
{"filename": "app.rb"}
#!/usr/bin/env ruby
<<spdx_headers>>

<<require_sequel>>
require 'sinatra'

<<require_settings_and_archiveset>>

<<mount_archivesets_definition>>

<<make_database_connection>>

<<configure_block>>

get '/mirror/:type/:project_id/:build_id/*' do
  # The application supports only 2 types of mirrored objects: "sstate" and
  # "downloads". Convert the types to numbers, and return an error if the
  # client requests something else from the mirror.
  request_type = case params['type']
                 when 'downloads' then 0
                 when 'sstate' then 1
                 else halt 404
                 end

  suffix = params['splat'].join('/')

  lookup_result = settings.mirror_path[params['type']]
                          .lookup(suffix)

  if lookup_result[:status] == :ok
    # Log the access and send the file
<<log_cache_hit>>

    send_file lookup_result[:filepath]
  else
    # Log the error and return code 404
<<log_cache_miss>>

    halt 404
  end
end

<<index_backend>>

Dependency management

We'll store the libraries in the vendor/bundle subdirectory of the application. This is done to avoid changing the host system's libraries.

{"filename": ".bundle/config"}
---
BUNDLE_PATH: "vendor/bundle"

And then we add the libraries, one by one, in the Gemfile:

{"filename": "Gemfile"}
<<spdx_headers>>

source 'https://rubygems.org'

# Web-application prerequisites
gem 'puma'
gem 'rackup'
gem 'sinatra'

# Database support
<<database_dependencies>>

<<rubocop_dependencies>>

Building this project

wget https://personalcompute.net/assets/literate-programming/yocto-download-mirror.md
rbenv exec literate_tool.rb yocto-download-mirror.md
bundle install
bundle exec ruby app.rb

Adding a database

For this project we'll use the sequel library with an sqlite backend, to store a single table containing the log of the event. The sequel library has a handy cheasheet containing the most frequent use-cases.

This information will be useful to get analytics about the builds, which is the average cache utilisation and the cache-hit ratio.

To add the support, the first thing to do is to add the dependencies in the Gemfile:

{"name": "database_dependencies"}
gem 'sequel'
gem 'sqlite3'

Then, we need to add the proper require line at the beginning of the application source-file.

{"name": "require_sequel"}
require 'sequel'

Another thing to do is building a connection-instance, where we specify the database's backend (sqlite) and the file used for persistence (mirror.db of the current directory). If the mirror.db file doesn't exist, it will be created, and showing up as a database with no tables.

Since we want to keep the project simple, we won't be using migrations. Instead, we're defining the table (called :requests) inside an db.create_table? block

The .create_table? block will be mapped to a CREATE TABLE IF NOT EXISTS statement, so if the database was already initialized, this block does nothing.

At the end, we return a handle to the :requests table of the database.

{"name": "make_database_connection"}
def make_database_connection
  db = Sequel.connect('sqlite://mirror.db')

  db.create_table? :requests do
    primary_key :id, null: false
    String :path, null: false
    String :project_id, null: false
    String :build_id, null: false
    Integer :request_type, null: false
    String :source_archive, null: true
    DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP, null: false
  end

  db[:requests]
end

Then inside the configure block we add a field that calls the make_database_connection method at the start of the web application. This way, from any request-handler we'll be able to access the table by accessing settings.requests_table.

{"name": "configure_block_database_setup"}
  set :requests_table, make_database_connection

And now we're ready to add records into the table, whenever a cache-hit or cache-miss has been encountered.

{"name": "log_cache_hit"}
    settings.requests_table
            .insert(
              path: suffix,
              project_id: params['project_id'],
              build_id: params['build_id'],
              request_type: request_type,
              source_archive: lookup_result[:archive]
            )

As a convention, a record with source_archive = NULL represents a cache-miss.

{"name": "log_cache_miss"}
    settings.requests_table
            .insert(
              path: suffix,
              project_id: params['project_id'],
              build_id: params['build_id'],
              request_type: request_type
            )

Adding a dashboard

Now that we have informations about the past requests, we can show some analytics.

We use the erb action to render the template specified in the file views/main.erb.

{"name": "index_backend"}
get '/' do
  recent_requests = settings.requests_table
                            .order(Sequel.desc(:created_at))
                            .limit(100)

  erb :main, locals: {
    recent_requests: recent_requests
  }
end

The frontend:

{"filename": "views/main.erb"}
<%#
<<spdx_headers>>
%>
<h1>Mirror for Yocto builds</h1>

<h2>Set up your builds</h2>

<h3>Source-code archives</h3>

<pre>
SOURCE_MIRROR_URL = <%= request.scheme %>://<%= request.host %>:<%= request.port %>/mirror/downloads/my_raspberrypi_project/${DATETIME}"
INHERIT += "own-mirrors"
</pre>

<h3>SSTATE mirror</h3>

<pre>
SSTATE_MIRRORS = "file://.* <%= request.scheme %>://<%= request.host %>:<%= request.port %>/mirror/sstate/PATH"
</pre>

<h2>Recent requests</h2>
<%= erb :render_table, locals: {table: recent_requests} %>
{"filename": "views/render_table.erb"}
<%#
<<spdx_headers>>
%>
<table class="border-2 border-black">
  <tr>
    <% table.columns.each do |column_name| %>
      <th><%= column_name %></th>
    <% end %>
  </tr>
  <% table.each do |row| %>
    <tr class="odd=bg-white even:bg-gray-100 hover:bg-sky-700 hover:text-white">
      <% row.each do |cell| %> 
        <td class="p-2"><%= Rack::Utils.escape_html(cell[1]) %></td>
      <% end %>
    </tr>
  <% end %>
</table>

Security considerations

Evven if the application is simple, we should spend some time discussing what security practices are applied here.

Preventing SQL Injection Attacks

Since we're using the sequel library to handle database access, preventing SQL injection atacks is simple: as long as we don't manually build SQL queries using string-concatenation, and instead use key-value pairs, we're safe. The sequel library handles the prepared-statements for us.

Preventing HTML Injection Attacks

On the dashbboard page, we display the file-path requested by the user. To make sure that the requested file-path doesn't interfere with the page, we call the Rack::Utils.escape_html method on all displayed user-provided data, to sanitize it.

Preventing path-traversal attacks

The Sinatra framework comes with path-traversal prevention out of the box, implemented in the Rack::Protection middleware.

What's not handled - Denial-of-Service attacks

  • Consuming the bandwidth: an attacker could repeatedly request files and consume all the available network-bandwidth.
  • Increasing the database size: an attacker could send many requests and increase the size of the database-size, slowing down the database queries.

Setting up the Yocto build

SOURCE_MIRROR_URL = "http://localhost:8123/mirror/downloads/my_raspberrypi_project/${DATETIME}"
INHERIT += "own-mirrors"
SSTATE_MIRRORS = "file://.* http://localhost:8123/mirror/sstate/PATH"
BB_HASHSERVE_UPSTREAM = "hashserv.yoctoproject.org:8686"

Frontend stuff

{"filename": "views/layout.erb"}
<%#
<<spdx_headers>>
%>
<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link href="/css/output.css" rel="stylesheet">
  </head>
  <body>
    <main class="container mx-auto mt-28 mb-32">
      <%= yield %>
    </main>
  </body>
</html>

Annex - The license

This project attepts to be mostly-compliant with the REUSE Software specifications.

One requirement of the specification is to have the text of the chosen license (MIT in our case) in a file in the LICENSES directory. As such, we add it in:

{"filename": "LICENSES/MIT.txt"}
MIT License

Copyright (c) <year> <copyright holders>

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.

Another prerequisite of the specification is that every copyrightable file must include two comments using a special formatting, containing the license and the author/copyright holder.

{"name": "spdx_headers"}
# SPDX-FileCopyrightText: 2026 PersonalCompute.Net <publisher@PersonalCompute.Net>
# SPDX-License-Identifier: MIT

We can check the REUSE compliance using the lint tool:

pipx run reuse lint

Annex - Updating the CSS

Since the stylesheet doesn't change that often, the minified CSS is stored in the repo.

To regenerate it, download the standalong tool form https://github.com/tailwindlabs/tailwindcss/releases and run it in the main directory.

chmod +x ~/Downloads/tailwindcss-linux-x64                                            
~/Downloads/tailwindcss-linux-x64 -i ./public/css/input.css -o ./public/css/output.css --minify
{"filename": "public/css/input.css"}
@import "tailwindcss";
{"filename": "public/css/output.css"}
/*! tailwindcss v4.3.1 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-font-weight:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-sky-700:oklch(50% .134 242.749);--color-gray-100:oklch(96.7% .003 264.542);--color-black:#000;--color-white:#fff;--spacing:.25rem;--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-bold:700;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.static\!{position:static!important}.start-1{inset-inline-start:calc(var(--spacing) * 1)}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-28{margin-top:calc(var(--spacing) * 28)}.mb-32{margin-bottom:calc(var(--spacing) * 32)}.\!block{display:block!important}.block{display:block}.contents{display:contents}.hidden{display:none}.inline{display:inline}.table{display:table}.shrink{flex-shrink:1}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.resize{resize:both}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.rounded{border-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-black{border-color:var(--color-black)}.p-2{padding:calc(var(--spacing) * 2)}.p-10{padding:calc(var(--spacing) * 10)}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline{text-decoration-line:underline}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter\!{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)!important}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.\[rdoc-ref\:BigDecimal\@Not\+a\+Number\]{rdoc-ref:BigDecimal@Not+a+Number}.even\:bg-gray-100:nth-child(2n){background-color:var(--color-gray-100)}@media (hover:hover){.hover\:bg-sky-700:hover{background-color:var(--color-sky-700)}.hover\:text-white:hover{color:var(--color-white)}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}student@raspberrypi:~/dlmir $ 

Annex - Rubocop linting

RuboCop is a Ruby linter: it flags strange or awkward snippets of source-code, and sometimes it even can correct them.

To add RuboCop to a project, we first add it as dependency on a Gemfile, with a separate gem-group (called development). This makes RuboCop not a "strong" dependency (always installed in the workspace) but an optional one (ignored by default, available on demand).

{"name": "rubocop_dependencies"}
# Rubocop linting
group :development, optional: true do
  gem 'rubocop'
  gem 'rubocop-performance'
  gem 'rubocop-sequel'
end

To install it, we must first "unlock" the development group of dependencies, install it, and then run it.

# Enable and install the "development" group of dependencies
bundle config set with 'development'
bundle install
# Look for formatting issues
bundle exec rubocop
# Auto-fix formatting issues
bundle exec rubocop -A

Although the enforced coding style is quite good, some rules are not fitting the project, and we can disable them. Every project can configure which rules are applied, by configuring the tool.

{"filename": ".rubocop.yml"}
AllCops:
  NewCops: enable

plugins:
  - rubocop-sequel
  - rubocop-performance

# Having complex code is not a problem. Sometimes we solve complex problems.
Metrics/BlockLength:
  Enabled: false
Metrics/MethodLength:
  Enabled: false

# Don't add extra noise
Style/Documentation:
  Enabled: false
Style/RescueStandardError:
  Enabled: false

# Calling the Hash.new constructor is ok.
Style/EmptyLiteral:
  Enabled: false

# Ignore frozen-string warnings.
Style/FrozenStringLiteralComment:
  Enabled: false
Style/MutableConstant:
  Enabled: false

# This project's Gemfile lists dependencies in the logical order.
# Ordering them alphabetically would make no sense.
Bundler/OrderedGems:
  Enabled: false

# Tech limitation.
Lint/ScriptPermission:
  Enabled: false