Refactor and enhance the Twelve-Factor app

- Updated dependencies in Gemfile.lock for kramdown, parallel, parser, rexml, rubocop-ast, solargraph, and tilt.
- Introduced new classes for handling blog posts and authors, including TOML parsing for author data.
- Added a new factors configuration file and refactored the factors loading mechanism.
- Updated the home view to utilize the new factors structure.
- Removed unused dependencies from web.rb and improved helper methods for rendering content.
This commit is contained in:
Yehuda Katz 2025-04-18 17:34:56 -07:00
parent 506d1681dc
commit 6f78f556d2
9 changed files with 338 additions and 160 deletions

View File

@ -1,3 +1,4 @@
{
"markdown.validate.enabled": true
"markdown.validate.enabled": true,
"solargraph.commandPath": "./bin/solargraph"
}

View File

@ -16,8 +16,8 @@ GEM
concurrent-ruby (~> 1.0)
jaro_winkler (1.6.0)
json (2.10.2)
kramdown (2.4.0)
rexml
kramdown (2.5.1)
rexml (>= 3.3.9)
kramdown-parser-gfm (1.1.0)
kramdown (~> 2.0)
language_server-protocol (3.17.0.4)
@ -30,8 +30,8 @@ GEM
racc (~> 1.4)
observer (0.1.2)
ostruct (0.6.1)
parallel (1.26.3)
parser (3.3.7.4)
parallel (1.27.0)
parser (3.3.8.0)
ast (~> 2.4.1)
racc
prism (1.4.0)
@ -49,8 +49,7 @@ GEM
regexp_parser (2.10.0)
reverse_markdown (3.0.0)
nokogiri
rexml (3.3.2)
strscan
rexml (3.4.1)
rubocop (1.75.2)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
@ -62,7 +61,7 @@ GEM
rubocop-ast (>= 1.44.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.44.0)
rubocop-ast (1.44.1)
parser (>= 3.3.7.2)
prism (~> 1.4)
ruby-progressbar (1.13.0)
@ -74,7 +73,7 @@ GEM
tilt (~> 2.0)
sinatra-partial (1.0.1)
sinatra (>= 1.4)
solargraph (0.53.4)
solargraph (0.54.0)
backport (~> 1.2)
benchmark
bundler (~> 2.0)
@ -93,13 +92,12 @@ GEM
tilt (~> 2.0)
yard (~> 0.9, >= 0.9.24)
yard-solargraph (~> 0.1)
strscan (3.1.0)
thin (1.8.2)
daemons (~> 1.0, >= 1.0.9)
eventmachine (~> 1.0, >= 1.0.4)
rack (>= 1, < 3)
thor (1.3.2)
tilt (2.4.0)
tilt (2.6.0)
toml-rb (4.0.0)
citrus (~> 3.0, > 3.0)
racc (~> 1.7)

14
content/factors.toml Normal file
View File

@ -0,0 +1,14 @@
factors = [
"codebase",
"dependencies",
"config",
"backing-services",
"build-release-run",
"processes",
"port-binding",
"concurrency",
"disposability",
"dev-prod-parity",
"logs",
"admin-processes",
]

146
lib/blog.rb Normal file
View File

@ -0,0 +1,146 @@
require 'toml-rb'
require 'front_matter_parser'
module TwelveFactor
class Author
attr_reader :id
attr_reader :name
attr_reader :github_username
def initialize(id, toml)
@id = id
@toml = toml
@name = toml['name']
@github_username = toml['github_username']
end
def image_path
"/images/bios/#{@id}.jpg"
end
def maintainer?
@toml['maintainer']
end
def description
@toml['description'] ? MARKDOWN.render(@toml['description']) : ""
end
end
class Authors
def initialize
@authors = TomlRB.load_file("#{ROOT}/blog/authors.toml")
end
def get(id)
author = @authors.fetch(id)
Author.new(id, author)
end
end
AUTHORS = Authors.new
class BlogPost
# A file is a blog post if it has front-matter (i.e. it starts with `---`)
def self.is(file)
File.read(file, :encoding => 'utf-8').strip.start_with?('---')
end
def self.excerpt(content)
# Skip front matter
content_without_frontmatter = if content.start_with?('---')
content.split('---', 3)[2].to_s.strip
else
content
end
# Get excerpt (content until the END_EXCERPT marker)
if content_without_frontmatter.include?('<!-- END_EXCERPT -->')
content_without_frontmatter.split('<!-- END_EXCERPT -->').first.strip
else
# Fallback to first paragraph if no marker is found
content_without_frontmatter.split(/\n\n/).first.to_s.strip
end
end
attr_reader :content
attr_reader :front_matter
attr_reader :file_path
attr_reader :excerpt
attr_reader :slug
def initialize(file)
@file_path = file
@slug = File.basename(file, '.md')
file_content = File.read(file, :encoding => 'utf-8')
# Configure the parser to allow Date objects
parser = FrontMatterParser::Parser.new(:md, loader: FrontMatterParser::Loader::Yaml.new(allowlist_classes: [Date]))
@front_matter = parser.call(file_content)
@content = file_content.split('---', 3)[2].to_s.strip
@excerpt = BlogPost.excerpt(file_content)
end
def image_path
nil
end
def render
MARKDOWN.render(@content)
end
def author
AUTHORS.get(@front_matter['author'])
end
def date
@front_matter['date'].strftime('%d %b, %Y')
end
def title
@front_matter['title']
end
def categories?
@front_matter['categories']
end
def categories
@front_matter['categories'] || []
end
def featured?
@front_matter['featured']
end
def url
"/blog/#{@slug}"
end
end
class Blog
def initialize
post_files = Dir["#{ROOT}/blog/*.md"]
@posts = post_files.filter_map do |file|
BlogPost.new(file) if BlogPost.is(file)
end
@posts.sort_by! { |post| post.date }
end
def all
@posts
end
def featured
@posts.select(&:featured?)
end
def find(slug)
@posts.find { |post| post.slug == slug }
end
end
BLOG = Blog.new
end

50
lib/document.rb Normal file
View File

@ -0,0 +1,50 @@
require 'redcarpet'
module TwelveFactor
MARKDOWN = Redcarpet::Markdown.new(Redcarpet::Render::HTML, autolink: true, tables: true)
class Document
def self.parse(file, excerpt: false)
file_content = File.read(file, :encoding => 'utf-8')
# Configure the parser to allow Date objects
parser = FrontMatterParser::Parser.new(:md, loader: FrontMatterParser::Loader::Yaml.new(allowlist_classes: [Date]))
parsed = parser.call(file_content)
content = parsed.content.strip
excerpt = excerpt ? Document.excerpt(content) : nil
Document.new(parsed.front_matter, content, excerpt)
end
def self.excerpt(content)
# Skip front matter
content_without_frontmatter = if content.start_with?('---')
content.split('---', 3)[2].to_s.strip
else
content
end
# Get excerpt (content until the END_EXCERPT marker)
if content_without_frontmatter.include?('<!-- END_EXCERPT -->')
content_without_frontmatter.split('<!-- END_EXCERPT -->').first.strip
else
# Fallback to first paragraph if no marker is found
content_without_frontmatter.split(/\n\n/).first.to_s.strip
end
end
attr_reader :front_matter
attr_reader :content
attr_reader :excerpt
def initialize(front_matter, content, excerpt)
@front_matter = front_matter
@content = content
@excerpt = excerpt
end
def render
MARKDOWN.render(@content)
end
end
end

95
lib/factors.rb Normal file
View File

@ -0,0 +1,95 @@
require "toml-rb"
module TwelveFactor
FACTOR_ROOT = "#{ROOT}/content"
class Factors
# The factors are loaded from the `content` directory. In that directory,
# the factors are stored in subdirectories named after a locale (e.g. `en`,
# `fr`, `es`, etc.). Each locale has a localized `toc.md` file and separate
# files for each factor.
#
# The `factors.toml` file contains the list of factors in order.
#
# This class is used to load the factors and return them as a list of
# localized factors.
def self.load
factors = TomlRB.load_file("#{ROOT}/content/factors.toml")['factors']
locales = Dir.glob("#{FACTOR_ROOT}/*").map { |dir| File.basename(dir) }
Factors.new(factors, locales)
end
attr_reader :factor_names
def initialize(factor_names, locales)
@factor_names = factor_names
@locales = locales
end
def current
self[I18n.locale]
end
def [](locale)
LocalizedFactors.new(locale)
end
def locales
@locales
end
end
FACTORS = Factors.load
class LocalizedFactors
FRAGMENTS = [:intro, :who, :background, :toc]
attr_reader :locale
def initialize(locale)
@locale = locale
@root = "#{FACTOR_ROOT}/#{locale}"
@documents = {}
end
FRAGMENTS.each do |fragment|
define_method(fragment) do
@documents[fragment] ||= load_document(fragment)
end
end
def [](id)
return nil unless FACTORS.factor_names.include?(id)
file = "#{@root}/#{id}.md"
TwelveFactor::Document.parse(file)
end
# Returns a list of `LocalizedFactor` objects for each factor in
# `FACTORS.factor_names`.
def factors
FACTORS.factor_names.map do |id|
self[id]
end
end
private
def load_document(file)
@documents[file] ||= TwelveFactor::Document.parse("#{@root}/#{file}.md")
end
end
class LocalizedFactor
def initialize(id, file)
@id = id
@file = file
end
def document
TwelveFactor::Document.parse(@file)
end
end
end

View File

@ -1,146 +1,10 @@
require 'toml-rb'
require 'front_matter_parser'
module TwelveFactor
ROOT = File.expand_path("..", __dir__)
MARKDOWN = Redcarpet::Markdown.new(Redcarpet::Render::HTML, autolink: true, tables: true)
class Author
attr_reader :id
attr_reader :name
attr_reader :github_username
def initialize(id, toml)
@id = id
@toml = toml
@name = toml['name']
@github_username = toml['github_username']
end
def image_path
"/images/bios/#{@id}.jpg"
end
def maintainer?
@toml['maintainer']
end
def description
@toml['description'] ? MARKDOWN.render(@toml['description']) : ""
end
end
class Authors
def initialize
@authors = TomlRB.load_file("#{ROOT}/blog/authors.toml")
end
def get(id)
author = @authors.fetch(id)
Author.new(id, author)
end
end
AUTHORS = Authors.new
class BlogPost
# A file is a blog post if it has front-matter (i.e. it starts with `---`)
def self.is(file)
File.read(file, :encoding => 'utf-8').strip.start_with?('---')
end
def self.excerpt(content)
# Skip front matter
content_without_frontmatter = if content.start_with?('---')
content.split('---', 3)[2].to_s.strip
else
content
end
# Get excerpt (content until the END_EXCERPT marker)
if content_without_frontmatter.include?('<!-- END_EXCERPT -->')
content_without_frontmatter.split('<!-- END_EXCERPT -->').first.strip
else
# Fallback to first paragraph if no marker is found
content_without_frontmatter.split(/\n\n/).first.to_s.strip
end
end
attr_reader :content
attr_reader :front_matter
attr_reader :file_path
attr_reader :excerpt
attr_reader :slug
def initialize(file)
@file_path = file
@slug = File.basename(file, '.md')
file_content = File.read(file, :encoding => 'utf-8')
# Configure the parser to allow Date objects
parser = FrontMatterParser::Parser.new(:md, loader: FrontMatterParser::Loader::Yaml.new(allowlist_classes: [Date]))
@front_matter = parser.call(file_content)
@content = file_content.split('---', 3)[2].to_s.strip
@excerpt = BlogPost.excerpt(file_content)
end
def image_path
nil
end
def render
MARKDOWN.render(@content)
end
def author
AUTHORS.get(@front_matter['author'])
end
def date
@front_matter['date'].strftime('%d %b, %Y')
end
def title
@front_matter['title']
end
def categories?
@front_matter['categories']
end
def categories
@front_matter['categories'] || []
end
def featured?
@front_matter['featured']
end
def url
"/blog/#{@slug}"
end
end
class Blog
def initialize
post_files = Dir["#{ROOT}/blog/*.md"]
@posts = post_files.filter_map do |file|
BlogPost.new(file) if BlogPost.is(file)
end
@posts.sort_by! { |post| post.date }
end
def all
@posts
end
def featured
@posts.select(&:featured?)
end
def find(slug)
@posts.find { |post| post.slug == slug }
end
end
BLOG = Blog.new
end
require_relative "document"
require_relative "factors"
require_relative "blog"

View File

@ -1,9 +1,12 @@
<section class="abstract">
<article><%= render_markdown('intro') %></article>
<article><%= render_markdown('background') %></article>
<article><%= render_markdown('who') %></article>
<article><%= @home.intro.render %></article>
<article><%= @home.background.render %></article>
<article><%= @home.who.render %></article>
</section>
<section class="concrete">
<article><%= render_markdown('toc') %></article>
<article>
<%= @home.toc.render %>
</article>
</section>

13
web.rb
View File

@ -2,9 +2,6 @@ require 'sinatra'
require 'maruku'
require 'i18n'
require 'rack/ssl-enforcer'
require 'toml-rb'
require 'redcarpet'
require 'front_matter_parser'
require 'sinatra/partial'
require_relative './lib/twelve_factor'
@ -66,6 +63,7 @@ class App < Sinatra::Base
end
get '/' do
@home = TwelveFactor::FACTORS.current
erb :home
end
@ -142,7 +140,16 @@ class App < Sinatra::Base
erb :factor
end
helpers do
def h(text)
Rack::Utils.escape_html(text)
end
def hattr(text)
Rack::Utils.escape_path(text)
end
def alternate_links
links = [
"<link rel=\"alternate\" hreflang=\"x-default\" href=\"#{default_url}\">"