Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
!/log/.keep
!/tmp/.keep

# Generator test scratch directory (Rails::Generators::TestCase destination).
/test/tmp/

# Ignore pidfiles, but keep the directory.
/tmp/pids/*
!/tmp/pids/
Expand Down
1 change: 1 addition & 0 deletions Procfile.dev
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
web: bin/rails server
css: bin/rails tailwindcss:watch
themes: bin/rails themes:tailwind:watch
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Minimal blog using Rails 8, designed to be easily [self-hosted on AWS](https://g
* Markdown and Code Highlighting
* [Link Blog](https://capotej.com/links)
* Drag and Drop image uploads for Pages and Posts
* Themable (default minimal look + drop-in community themes — see [Themes](#themes))

# Getting Started

Expand Down Expand Up @@ -56,6 +57,66 @@ This will scan the given path for files ending in `.markdown` and create a seed

**Note: This will delete everything in the local database and re-seed using `db/seeds/*`.**

# Themes

Abbey ships with a drop-in theme system designed for community contribution. A
theme is **one self-contained folder** under `app/themes/<name>/`:

```
app/themes/aurora/
theme.rb # manifest (Abbey::Theme.register)
assets/tailwind.css # per-theme Tailwind build
views/ # ERB overrides (any subset, optional)
README.md
```

Dropping a folder in and setting `ABBEY_THEME=<name>` is the entire install —
zero edits to any central file. The default Abbey bundle stays byte-for-byte
unchanged no matter how many themes the project ships.

## Built-in themes

| Theme | Description |
|------------|-------------|
| `default` | Original minimal Abbey look (no behavioural change). |
| `retro` | Memphis-style / 8-bit / 80s computer chrome — neo-brutalist cards, CRT scanlines, terminal code blocks, pixel-display headings. |
| `grimoire` | Retro hacker dark fantasy — Matrix-minimal monospace, parchment + void palette with phosphor/ember/gold accents, tome cards, wax-seal tags, an animated summoning circle, and a Konami-code easter egg. |
| `midnight` | Sample drop-in theme. ~80 lines total demonstrating the "30-second recolor" pattern. Deep slate palette with warm amber accents, Inter + JetBrains Mono. |

## Switching themes

Set the `ABBEY_THEME` environment variable before booting the app:

$ ABBEY_THEME=retro bin/dev

Or hardcode it in `config/initializers/themes.rb`:

```ruby
Rails.application.config.theme = "retro"
```

When the active theme is `default`, the app behaves identically to before —
no extra assets are loaded, the default Tailwind build is unchanged, and the
existing markdown renderer is used.

## Authoring a new theme

```sh
bin/rails g abbey:theme aurora # full scaffold
bin/rails g abbey:theme aurora --minimal # pure recolor (theme.rb + tailwind.css + 3-line layout)
bin/rails g abbey:theme aurora --from=retro # clone retro as starting point
```

Then edit `app/themes/aurora/theme.rb` (display name, colors, fonts) and
`app/themes/aurora/assets/tailwind.css` (your `@theme` tokens). Boot with
`ABBEY_THEME=aurora bin/dev`.

For a guided walkthrough — concepts, manifest reference, common patterns,
gotchas — see [**docs/THEMES.md**](docs/THEMES.md). For the exhaustive
manifest field reference and registry API, see
[**docs/THEMES_API.md**](docs/THEMES_API.md). For a minimal working
example to fork, look at [`app/themes/midnight/`](app/themes/midnight/).

# Deploying to AWS

## Assumptions
Expand Down
21 changes: 9 additions & 12 deletions app/assets/tailwind/application.css
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
@import 'tailwindcss';

@config '../../../config/tailwind.config.js';

/*
/* Exclude every drop-in theme from the default Tailwind scan. Each theme
(under app/themes/<name>/) compiles its own self-contained Tailwind
bundle from its own views, so the default Abbey bundle stays
byte-for-byte unchanged regardless of how many themes the project
ships. */
@source not "../../themes";

@layer components {
.btn-primary {
@apply py-2 px-4 bg-blue-200;
}
}
@config '../../../config/tailwind.config.js';

*/
@plugin "@tailwindcss/forms";
@plugin "@tailwindcss/typography";

/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
Expand All @@ -29,6 +29,3 @@
border-color: var(--color-gray-200, currentcolor);
}
}

@plugin "@tailwindcss/forms";
@plugin "@tailwindcss/typography";
1 change: 1 addition & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
class ApplicationController < ActionController::Base
include Authentication
include Theming
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern
end
41 changes: 41 additions & 0 deletions app/controllers/concerns/theming.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# frozen_string_literal: true

# Prepends the active theme's view directory to Rails' lookup path so that
# `app/themes/<theme>/views/<scope>/<name>.html.erb` overrides
# `app/views/<scope>/<name>.html.erb` (including layouts) while the theme
# is active. The default theme is a no-op.
module Theming
extend ActiveSupport::Concern

included do
before_action :prepend_theme_view_path
helper_method :current_theme, :theme_active?, :active_theme
end

private

# Returns the active `Abbey::Theme` instance (or its DefaultTheme sentinel
# when no named theme is configured). Always non-nil.
def active_theme
Abbey::Theme.active
end

# Backward-compat string accessor used by older view helpers.
def current_theme
active_theme.name.to_s
end

def theme_active?
!active_theme.default?
end

def prepend_theme_view_path
theme = active_theme
return if theme.default?

views = theme.views_path
return unless views&.directory?

prepend_view_path views.to_s
end
end
78 changes: 78 additions & 0 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
@@ -1,2 +1,80 @@
module ApplicationHelper
# Override Propshaft's `:app` bulk inclusion so the per-theme assets
# (tailwind-<name>.css and every file from app/themes/<name>/assets/)
# are NOT auto-included with the rest of the app bundle. They belong
# to optional themes and are loaded separately via #theme_stylesheets
# only when their theme is active. This keeps the default theme bundle
# byte-for-byte unchanged regardless of how many themes ship.
def app_stylesheets_paths
excluded = theme_excluded_logical_paths
super.reject do |path|
slug = path.to_s.delete_suffix(".css")
slug.start_with?("themes/") || excluded.include?(slug)
end
end

# Returns the logical names of every stylesheet the active theme ships,
# suitable for `stylesheet_link_tag`. Always includes the per-theme
# Tailwind bundle first (`tailwind-<name>`), then any extra CSS files
# the theme contributes from its `assets/` folder. The default theme
# contributes nothing extra.
#
# Resolution order:
# 1. `tailwind-<active>` (compiled by `themes:tailwind:build`) so
# theme tokens land before component CSS that references them.
# 2. Manifest-declared stylesheets (filesystem scan of assets/, with
# tailwind.css excluded since it's the compiler input, not output).
# 3. Legacy `themes/<name>` / `themes/<name>-highlight` fallback for
# code paths that haven't moved to the consolidated folder.
def theme_stylesheets
theme = Abbey::Theme.active
return [] if theme.default?

sheets = []
sheets << "tailwind-#{theme.name}" if theme_stylesheet_exists?("tailwind-#{theme.name}")

candidates = theme.stylesheets
candidates = [
"themes/#{theme.name}",
"themes/#{theme.name}-highlight"
] if candidates.empty?

sheets.concat(candidates.select { |name| theme_stylesheet_exists?(name) })
end

# Whether dark mode is currently active on the request. The chrome
# partial uses this to pick between the active theme's dark_html_class
# and light_html_class.
def dark_mode?
cookies[:dark_mode] == "true"
end

# Build the `class="..."` value for the chrome partial's <html> element
# by combining the active theme's always-on classes with its
# dark/light variants based on the request's dark mode state.
def chrome_html_class(theme = Abbey::Theme.active)
parts = [
theme.html_class,
dark_mode? ? theme.dark_html_class : theme.light_html_class
]
parts.compact.reject(&:blank?).join(" ").presence
end

private

def theme_stylesheet_exists?(logical_name)
Rails.application.assets&.load_path&.find("#{logical_name}.css").present? ||
Rails.root.join("app/assets/stylesheets/#{logical_name}.css").exist?
rescue StandardError
Rails.root.join("app/assets/stylesheets/#{logical_name}.css").exist?
end

# Every logical asset path contributed by any registered theme. Used
# by `app_stylesheets_paths` to exclude theme assets from the default
# bundle without hard-coding any theme name.
def theme_excluded_logical_paths
@_theme_excluded_logical_paths ||= Abbey::Theme.registry.flat_map do |name, theme|
["tailwind-#{name}"] + theme.stylesheets
end.to_set
end
end
20 changes: 19 additions & 1 deletion app/models/concerns/rendering.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require "markdown_render"
require "minimal_markdown_render"

module Rendering
extend ActiveSupport::Concern
Expand All @@ -7,7 +8,7 @@ module Rendering

included do
def render(text)
processed_markdown = Redcarpet::Markdown.new(MarkdownRender, fenced_code_blocks: true).render(text)
processed_markdown = Redcarpet::Markdown.new(self.class.markdown_renderer, fenced_code_blocks: true).render(text)

# Replace signed IDs with img tags, handling both href and src attributes
processed_markdown.gsub!(/(href|src)="(.*?)"/) do |match|
Expand All @@ -32,4 +33,21 @@ def render(text)
processed_markdown
end
end

class_methods do
# Resolve the Redcarpet renderer class to use for this request.
#
# Themes declare their renderer in their manifest:
#
# Abbey::Theme.register(:retro) do |t|
# t.markdown_renderer = :minimal # or :default, or a custom class
# end
#
# The default theme (no manifest) keeps `MarkdownRender` for backward
# compatibility with existing imported posts that depend on its inline
# Tailwind class output.
def markdown_renderer
Abbey::Theme.active.markdown_renderer
end
end
end
Loading