require "pressa/site" require "pressa/posts/plugin" require "pressa/projects/plugin" require "pressa/utils/markdown_renderer" require "pressa/config/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") validate_required!(site_config, REQUIRED_SITE_KEYS, context: "site.toml") site_url = url_override || site_config["url"] plugins = build_plugins(site_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:, 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 build_plugins(site_config) plugin_names = parse_plugin_names(site_config["plugins"]) plugin_names.map.with_index do |plugin_name, index| case plugin_name when "posts" Posts::Plugin.new when "projects" build_projects_plugin(site_config) else raise ValidationError, "Unknown plugin '#{plugin_name}' at site.toml plugins[#{index}]" end end end def parse_plugin_names(value) return [] if value.nil? raise ValidationError, "Expected site.toml plugins to be an array" unless value.is_a?(Array) seen = {} value.map.with_index do |plugin_name, index| unless plugin_name.is_a?(String) && !plugin_name.strip.empty? raise ValidationError, "Expected site.toml plugins[#{index}] to be a non-empty String" end normalized_plugin_name = plugin_name.strip if seen[normalized_plugin_name] raise ValidationError, "Duplicate plugin '#{normalized_plugin_name}' in site.toml plugins" end seen[normalized_plugin_name] = true normalized_plugin_name end end def build_projects_plugin(site_config) projects_plugin = hash_or_empty(site_config["projects_plugin"], "site.toml projects_plugin") projects_config = load_toml("projects.toml") projects = build_projects(projects_config) 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") ) 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 validate_asset_path!( item, context: "#{context}[#{index}]" ) 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? validate_asset_path!( src, context: "#{context}[#{index}].src" ) 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 validate_asset_path!( item, context: "#{context}[#{index}]" ) 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? validate_asset_path!( href, context: "#{context}[#{index}].href" ) 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 def validate_asset_path!(value, context:) return if value.start_with?("/", "http://", "https://") raise ValidationError, "Expected #{context} to start with / or use http(s) scheme" end end end end