From 6f78f556d288d4134a4070a00a20441dd96c97c5 Mon Sep 17 00:00:00 2001 From: Yehuda Katz Date: Fri, 18 Apr 2025 17:34:56 -0700 Subject: [PATCH] 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. --- .vscode/settings.json | 3 +- Gemfile.lock | 18 +++-- content/factors.toml | 14 ++++ lib/blog.rb | 146 +++++++++++++++++++++++++++++++++++++++++ lib/document.rb | 50 ++++++++++++++ lib/factors.rb | 95 +++++++++++++++++++++++++++ lib/twelve_factor.rb | 148 ++---------------------------------------- views/home.erb | 11 ++-- web.rb | 13 +++- 9 files changed, 338 insertions(+), 160 deletions(-) create mode 100644 content/factors.toml create mode 100644 lib/blog.rb create mode 100644 lib/document.rb create mode 100644 lib/factors.rb diff --git a/.vscode/settings.json b/.vscode/settings.json index c0f4aaf..fc004c4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "markdown.validate.enabled": true + "markdown.validate.enabled": true, + "solargraph.commandPath": "./bin/solargraph" } \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index 7277274..c408f97 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/content/factors.toml b/content/factors.toml new file mode 100644 index 0000000..33ed783 --- /dev/null +++ b/content/factors.toml @@ -0,0 +1,14 @@ +factors = [ + "codebase", + "dependencies", + "config", + "backing-services", + "build-release-run", + "processes", + "port-binding", + "concurrency", + "disposability", + "dev-prod-parity", + "logs", + "admin-processes", +] diff --git a/lib/blog.rb b/lib/blog.rb new file mode 100644 index 0000000..cc01305 --- /dev/null +++ b/lib/blog.rb @@ -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?('') + content_without_frontmatter.split('').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 diff --git a/lib/document.rb b/lib/document.rb new file mode 100644 index 0000000..2d625eb --- /dev/null +++ b/lib/document.rb @@ -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?('') + content_without_frontmatter.split('').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 diff --git a/lib/factors.rb b/lib/factors.rb new file mode 100644 index 0000000..1204abb --- /dev/null +++ b/lib/factors.rb @@ -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 diff --git a/lib/twelve_factor.rb b/lib/twelve_factor.rb index 34d08ad..d0526fb 100644 --- a/lib/twelve_factor.rb +++ b/lib/twelve_factor.rb @@ -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?('') - content_without_frontmatter.split('').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" \ No newline at end of file diff --git a/views/home.erb b/views/home.erb index 14c1d5f..4fe9ad7 100644 --- a/views/home.erb +++ b/views/home.erb @@ -1,9 +1,12 @@
-
<%= render_markdown('intro') %>
-
<%= render_markdown('background') %>
-
<%= render_markdown('who') %>
+
<%= @home.intro.render %>
+
<%= @home.background.render %>
+
<%= @home.who.render %>
-
<%= render_markdown('toc') %>
+ +
+ <%= @home.toc.render %> +
diff --git a/web.rb b/web.rb index f3a86c9..89ff8ec 100644 --- a/web.rb +++ b/web.rb @@ -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 = [ ""