diff --git a/Readme.md b/Readme.md index 8f2777f..051f258 100644 --- a/Readme.md +++ b/Readme.md @@ -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 diff --git a/bake.rb b/bake.rb index e5110a0..d189018 100644 --- a/bake.rb +++ b/bake.rb @@ -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/" diff --git a/bin/pressa b/bin/pressa index e779045..ec72405 100755 --- a/bin/pressa +++ b/bin/pressa @@ -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!" diff --git a/lib/config/loader.rb b/lib/config/loader.rb new file mode 100644 index 0000000..b53e33f --- /dev/null +++ b/lib/config/loader.rb @@ -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 diff --git a/lib/config/simple_toml.rb b/lib/config/simple_toml.rb new file mode 100644 index 0000000..948a36f --- /dev/null +++ b/lib/config/simple_toml.rb @@ -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 diff --git a/lib/pressa.rb b/lib/pressa.rb index a300455..bd867d8 100644 --- a/lib/pressa.rb +++ b/lib/pressa.rb @@ -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 diff --git a/projects.toml b/projects.toml new file mode 100644 index 0000000..fb81377 --- /dev/null +++ b/projects.toml @@ -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" diff --git a/site.toml b/site.toml new file mode 100644 index 0000000..ff052e8 --- /dev/null +++ b/site.toml @@ -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 = [] diff --git a/spec/config/loader_spec.rb b/spec/config/loader_spec.rb new file mode 100644 index 0000000..a2523b5 --- /dev/null +++ b/spec/config/loader_spec.rb @@ -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