Load site and projects from TOML files

This commit is contained in:
Sami Samhuri 2026-02-07 16:20:27 -08:00
parent a3dc9b55f8
commit 848054f941
No known key found for this signature in database
9 changed files with 551 additions and 56 deletions

View file

@ -10,6 +10,7 @@ This repository is now a single integrated Ruby project. The legacy Swift genera
- Build tasks: `bake.rb`
- CLI and utilities: `bin/`
- Tests: `spec/`
- Config: `site.toml` and `projects.toml`
- Content: `posts/` and `public/`
- Output: `www/`
@ -39,6 +40,15 @@ rbenv exec bundle exec bake debug # build for http://localhost:8000
rbenv exec bundle exec bake serve # serve www/ locally
```
## Configuration
Site metadata and project data are configured with TOML files at the repository root:
- `site.toml`: site identity, default scripts/styles, and `projects_plugin` assets.
- `projects.toml`: project listing entries using `[[projects]]`.
`Pressa.create_site` loads both files from the provided `source_path` and still supports URL overrides for `debug`, `beta`, and `release` builds.
Other targets:
```bash

View file

@ -85,7 +85,7 @@ def build(url)
require_relative 'lib/pressa'
puts "Building site for #{url}..."
site = Pressa.create_site(url_override: url)
site = Pressa.create_site(source_path: '.', url_override: url)
generator = Pressa::SiteGenerator.new(site:)
generator.generate(source_path: '.', target_path: 'www')
puts "Site built successfully in www/"

View file

@ -17,7 +17,7 @@ target_path = ARGV[1]
site_url = ARGV[2]
begin
site = Pressa.create_site(url_override: site_url)
site = Pressa.create_site(source_path:, url_override: site_url)
generator = Pressa::SiteGenerator.new(site:)
generator.generate(source_path:, target_path:)
puts "Site generated successfully!"

157
lib/config/loader.rb Normal file
View file

@ -0,0 +1,157 @@
require_relative '../site'
require_relative '../posts/plugin'
require_relative '../projects/plugin'
require_relative '../utils/markdown_renderer'
require_relative 'simple_toml'
module Pressa
module Config
class ValidationError < StandardError; end
class Loader
REQUIRED_SITE_KEYS = %w[author email title description url].freeze
REQUIRED_PROJECT_KEYS = %w[name title description url].freeze
def initialize(source_path:)
@source_path = source_path
end
def build_site(url_override: nil)
site_config = load_toml('site.toml')
projects_config = load_toml('projects.toml')
validate_required!(site_config, REQUIRED_SITE_KEYS, context: 'site.toml')
site_url = url_override || site_config['url']
projects_plugin = hash_or_empty(site_config['projects_plugin'], 'site.toml projects_plugin')
projects = build_projects(projects_config)
Site.new(
author: site_config['author'],
email: site_config['email'],
title: site_config['title'],
description: site_config['description'],
url: site_url,
image_url: normalize_image_url(site_config['image_url'], site_url),
scripts: build_scripts(site_config['scripts'], context: 'site.toml scripts'),
styles: build_styles(site_config['styles'], context: 'site.toml styles'),
plugins: [
Posts::Plugin.new,
Projects::Plugin.new(
projects:,
scripts: build_scripts(projects_plugin['scripts'], context: 'site.toml projects_plugin.scripts'),
styles: build_styles(projects_plugin['styles'], context: 'site.toml projects_plugin.styles')
)
],
renderers: [
Utils::MarkdownRenderer.new
]
)
end
private
def load_toml(filename)
path = File.join(@source_path, filename)
SimpleToml.load_file(path)
rescue ParseError => e
raise ValidationError, e.message
end
def build_projects(projects_config)
projects = projects_config['projects']
raise ValidationError, "Missing required top-level array 'projects' in projects.toml" unless projects
raise ValidationError, "Expected 'projects' to be an array in projects.toml" unless projects.is_a?(Array)
projects.map.with_index do |project, index|
unless project.is_a?(Hash)
raise ValidationError, "Project entry #{index + 1} must be a table in projects.toml"
end
validate_required!(project, REQUIRED_PROJECT_KEYS, context: "projects.toml project ##{index + 1}")
Projects::Project.new(
name: project['name'],
title: project['title'],
description: project['description'],
url: project['url']
)
end
end
def validate_required!(hash, keys, context:)
missing = keys.reject do |key|
hash[key].is_a?(String) && !hash[key].strip.empty?
end
return if missing.empty?
raise ValidationError, "Missing required #{context} keys: #{missing.join(', ')}"
end
def hash_or_empty(value, context)
return {} if value.nil?
return value if value.is_a?(Hash)
raise ValidationError, "Expected #{context} to be a table"
end
def build_scripts(value, context:)
entries = array_or_empty(value, context)
entries.map.with_index do |item, index|
case item
when String
Script.new(src: item, defer: true)
when Hash
src = item['src']
raise ValidationError, "Expected #{context}[#{index}].src to be a String" unless src.is_a?(String) && !src.empty?
defer = item.key?('defer') ? item['defer'] : true
unless [true, false].include?(defer)
raise ValidationError, "Expected #{context}[#{index}].defer to be a Boolean"
end
Script.new(src:, defer:)
else
raise ValidationError, "Expected #{context}[#{index}] to be a String or table"
end
end
end
def build_styles(value, context:)
entries = array_or_empty(value, context)
entries.map.with_index do |item, index|
case item
when String
Stylesheet.new(href: item)
when Hash
href = item['href']
raise ValidationError, "Expected #{context}[#{index}].href to be a String" unless href.is_a?(String) && !href.empty?
Stylesheet.new(href:)
else
raise ValidationError, "Expected #{context}[#{index}] to be a String or table"
end
end
end
def array_or_empty(value, context)
return [] if value.nil?
return value if value.is_a?(Array)
raise ValidationError, "Expected #{context} to be an array"
end
def normalize_image_url(value, site_url)
return nil if value.nil?
return value if value.start_with?('http://', 'https://')
normalized = value.start_with?('/') ? value : "/#{value}"
"#{site_url}#{normalized}"
end
end
end
end

224
lib/config/simple_toml.rb Normal file
View file

@ -0,0 +1,224 @@
require 'json'
module Pressa
module Config
class ParseError < StandardError; end
class SimpleToml
def self.load_file(path)
new.parse(File.read(path))
rescue Errno::ENOENT
raise ParseError, "Config file not found: #{path}"
end
def parse(content)
root = {}
current_table = root
lines = content.each_line.to_a
line_index = 0
while line_index < lines.length
line = lines[line_index]
line_number = line_index + 1
source = strip_comments(line).strip
if source.empty?
line_index += 1
next
end
if source =~ /\A\[\[(.+)\]\]\z/
current_table = start_array_table(root, Regexp.last_match(1), line_number)
line_index += 1
next
end
if source =~ /\A\[(.+)\]\z/
current_table = start_table(root, Regexp.last_match(1), line_number)
line_index += 1
next
end
key, raw_value = parse_assignment(source, line_number)
while needs_continuation?(raw_value)
line_index += 1
raise ParseError, "Unterminated value for key '#{key}' at line #{line_number}" if line_index >= lines.length
continuation = strip_comments(lines[line_index]).strip
next if continuation.empty?
raw_value = "#{raw_value} #{continuation}"
end
if current_table.key?(key)
raise ParseError, "Duplicate key '#{key}' at line #{line_number}"
end
current_table[key] = parse_value(raw_value, line_number)
line_index += 1
end
root
end
private
def start_array_table(root, raw_path, line_number)
keys = parse_path(raw_path, line_number)
parent = ensure_path(root, keys[0..-2], line_number)
table_name = keys.last
parent[table_name] ||= []
array = parent[table_name]
unless array.is_a?(Array)
raise ParseError, "Expected array for '[[#{raw_path}]]' at line #{line_number}"
end
table = {}
array << table
table
end
def start_table(root, raw_path, line_number)
keys = parse_path(raw_path, line_number)
ensure_path(root, keys, line_number)
end
def ensure_path(root, keys, line_number)
cursor = root
keys.each do |key|
cursor[key] ||= {}
unless cursor[key].is_a?(Hash)
raise ParseError, "Expected table path '#{keys.join('.')}' at line #{line_number}"
end
cursor = cursor[key]
end
cursor
end
def parse_path(raw_path, line_number)
keys = raw_path.split('.').map(&:strip)
if keys.empty? || keys.any? { |part| part.empty? || part !~ /\A[A-Za-z0-9_]+\z/ }
raise ParseError, "Invalid table path '#{raw_path}' at line #{line_number}"
end
keys
end
def parse_assignment(source, line_number)
separator = index_of_unquoted(source, '=')
raise ParseError, "Invalid assignment at line #{line_number}" unless separator
key = source[0...separator].strip
value = source[(separator + 1)..].strip
if key.empty? || key !~ /\A[A-Za-z0-9_]+\z/
raise ParseError, "Invalid key '#{key}' at line #{line_number}"
end
raise ParseError, "Missing value for key '#{key}' at line #{line_number}" if value.empty?
[key, value]
end
def parse_value(raw_value, line_number)
JSON.parse(raw_value)
rescue JSON::ParserError
raise ParseError, "Unsupported TOML value '#{raw_value}' at line #{line_number}"
end
def needs_continuation?(source)
in_string = false
escaped = false
depth = 0
source.each_char do |char|
if in_string
if escaped
escaped = false
elsif char == '\\'
escaped = true
elsif char == '"'
in_string = false
end
next
end
case char
when '"'
in_string = true
when '[', '{'
depth += 1
when ']', '}'
depth -= 1
end
end
in_string || depth.positive?
end
def strip_comments(line)
output = +''
in_string = false
escaped = false
line.each_char do |char|
if in_string
output << char
if escaped
escaped = false
elsif char == '\\'
escaped = true
elsif char == '"'
in_string = false
end
next
end
case char
when '"'
in_string = true
output << char
when '#'
break
else
output << char
end
end
output
end
def index_of_unquoted(source, target)
in_string = false
escaped = false
source.each_char.with_index do |char, index|
if in_string
if escaped
escaped = false
elsif char == '\\'
escaped = true
elsif char == '"'
in_string = false
end
next
end
if char == '"'
in_string = true
next
end
return index if char == target
end
nil
end
end
end
end

View file

@ -4,61 +4,11 @@ require_relative 'plugin'
require_relative 'posts/plugin'
require_relative 'projects/plugin'
require_relative 'utils/markdown_renderer'
require_relative 'config/loader'
module Pressa
def self.create_site(url_override: nil)
url = url_override || 'https://samhuri.net'
build_project = lambda do |name, title, description|
Projects::Project.new(
name:,
title:,
description:,
url: "https://github.com/samsonjs/#{title}"
)
end
projects = [
build_project.call('samhuri.net', 'samhuri.net', 'this site'),
build_project.call('bin', 'bin', 'my collection of scripts in ~/bin'),
build_project.call('config', 'config', 'important dot files (zsh, emacs, vim, screen)'),
build_project.call('compiler', 'compiler', 'a compiler targeting x86 in Ruby'),
build_project.call('lake', 'lake', 'a simple implementation of Scheme in C'),
build_project.call('AsyncMonitor', 'AsyncMonitor', 'easily monitor async sequences using Swift concurrency'),
build_project.call('NotificationSmuggler', 'NotificationSmuggler', 'embed strongly-typed values in notifications on Apple platforms'),
build_project.call('strftime', 'strftime', 'strftime for JavaScript'),
build_project.call('format', 'format', 'printf for JavaScript'),
build_project.call('gitter', 'gitter', 'a GitHub client for Node (v3 API)'),
build_project.call('cheat.el', 'cheat.el', 'cheat from emacs')
]
project_scripts = [
Script.new(src: 'https://ajax.googleapis.com/ajax/libs/prototype/1.6.1.0/prototype.js', defer: true),
Script.new(src: 'js/gitter.js', defer: true),
Script.new(src: 'js/store.js', defer: true),
Script.new(src: 'js/projects.js', defer: true)
]
Site.new(
author: 'Sami Samhuri',
email: 'sami@samhuri.net',
title: 'samhuri.net',
description: "Sami Samhuri's blog about programming, mainly about iOS and Ruby and Rails these days.",
url:,
image_url: "#{url}/images/me.jpg",
scripts: [],
styles: [
Stylesheet.new(href: 'css/normalize.css'),
Stylesheet.new(href: 'css/style.css'),
Stylesheet.new(href: 'css/syntax.css')
],
plugins: [
Posts::Plugin.new,
Projects::Plugin.new(projects:, scripts: project_scripts, styles: [])
],
renderers: [
Utils::MarkdownRenderer.new
]
)
def self.create_site(source_path: '.', url_override: nil)
loader = Config::Loader.new(source_path:)
loader.build_site(url_override:)
end
end

65
projects.toml Normal file
View file

@ -0,0 +1,65 @@
[[projects]]
name = "samhuri.net"
title = "samhuri.net"
description = "this site"
url = "https://github.com/samsonjs/samhuri.net"
[[projects]]
name = "bin"
title = "bin"
description = "my collection of scripts in ~/bin"
url = "https://github.com/samsonjs/bin"
[[projects]]
name = "config"
title = "config"
description = "important dot files (zsh, emacs, vim, screen)"
url = "https://github.com/samsonjs/config"
[[projects]]
name = "compiler"
title = "compiler"
description = "a compiler targeting x86 in Ruby"
url = "https://github.com/samsonjs/compiler"
[[projects]]
name = "lake"
title = "lake"
description = "a simple implementation of Scheme in C"
url = "https://github.com/samsonjs/lake"
[[projects]]
name = "AsyncMonitor"
title = "AsyncMonitor"
description = "easily monitor async sequences using Swift concurrency"
url = "https://github.com/samsonjs/AsyncMonitor"
[[projects]]
name = "NotificationSmuggler"
title = "NotificationSmuggler"
description = "embed strongly-typed values in notifications on Apple platforms"
url = "https://github.com/samsonjs/NotificationSmuggler"
[[projects]]
name = "strftime"
title = "strftime"
description = "strftime for JavaScript"
url = "https://github.com/samsonjs/strftime"
[[projects]]
name = "format"
title = "format"
description = "printf for JavaScript"
url = "https://github.com/samsonjs/format"
[[projects]]
name = "gitter"
title = "gitter"
description = "a GitHub client for Node (v3 API)"
url = "https://github.com/samsonjs/gitter"
[[projects]]
name = "cheat.el"
title = "cheat.el"
description = "cheat from emacs"
url = "https://github.com/samsonjs/cheat.el"

17
site.toml Normal file
View file

@ -0,0 +1,17 @@
author = "Sami Samhuri"
email = "sami@samhuri.net"
title = "samhuri.net"
description = "Sami Samhuri's blog about programming, mainly about iOS and Ruby and Rails these days."
url = "https://samhuri.net"
image_url = "/images/me.jpg"
scripts = []
styles = ["css/normalize.css", "css/style.css", "css/syntax.css"]
[projects_plugin]
scripts = [
"https://ajax.googleapis.com/ajax/libs/prototype/1.6.1.0/prototype.js",
"js/gitter.js",
"js/store.js",
"js/projects.js"
]
styles = []

View file

@ -0,0 +1,72 @@
require 'spec_helper'
require 'fileutils'
require 'tmpdir'
RSpec.describe Pressa::Config::Loader do
describe '#build_site' do
it 'builds a site from site.toml and projects.toml' do
with_temp_config do |dir|
loader = described_class.new(source_path: dir)
site = loader.build_site
expect(site.author).to eq('Sami Samhuri')
expect(site.url).to eq('https://samhuri.net')
expect(site.image_url).to eq('https://samhuri.net/images/me.jpg')
expect(site.styles.map(&:href)).to eq(['css/style.css'])
projects_plugin = site.plugins.find { |plugin| plugin.is_a?(Pressa::Projects::Plugin) }
expect(projects_plugin).not_to be_nil
expect(projects_plugin.scripts.map(&:src)).to eq(['js/projects.js'])
end
end
it 'applies url_override and rewrites relative image_url with override host' do
with_temp_config do |dir|
loader = described_class.new(source_path: dir)
site = loader.build_site(url_override: 'https://beta.samhuri.net')
expect(site.url).to eq('https://beta.samhuri.net')
expect(site.image_url).to eq('https://beta.samhuri.net/images/me.jpg')
end
end
it 'raises a validation error for missing required site keys' do
Dir.mktmpdir do |dir|
File.write(File.join(dir, 'site.toml'), "title = \"x\"\n")
File.write(File.join(dir, 'projects.toml'), '')
loader = described_class.new(source_path: dir)
expect { loader.build_site }.to raise_error(Pressa::Config::ValidationError, /Missing required site\.toml keys/)
end
end
end
def with_temp_config
Dir.mktmpdir do |dir|
File.write(File.join(dir, 'site.toml'), <<~TOML)
author = "Sami Samhuri"
email = "sami@samhuri.net"
title = "samhuri.net"
description = "blog"
url = "https://samhuri.net"
image_url = "/images/me.jpg"
scripts = []
styles = ["css/style.css"]
[projects_plugin]
scripts = ["js/projects.js"]
styles = []
TOML
File.write(File.join(dir, 'projects.toml'), <<~TOML)
[[projects]]
name = "demo"
title = "demo"
description = "demo project"
url = "https://github.com/samsonjs/demo"
TOML
yield dir
end
end
end