mirror of
https://github.com/samsonjs/samhuri.net.git
synced 2026-03-25 09:05:47 +00:00
* Publish on gemini in addition to the web * Publish gemini feeds, add link from web, tweak things
440 lines
15 KiB
Ruby
440 lines
15 KiB
Ruby
require "pressa/site"
|
|
require "pressa/posts/plugin"
|
|
require "pressa/projects/plugin"
|
|
require "pressa/utils/markdown_renderer"
|
|
require "pressa/utils/gemini_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, output_format: "html")
|
|
site_config = load_toml("site.toml")
|
|
|
|
validate_required!(site_config, REQUIRED_SITE_KEYS, context: "site.toml")
|
|
validate_no_legacy_output_keys!(site_config)
|
|
|
|
normalized_output_format = normalize_output_format(output_format)
|
|
site_url = url_override || site_config["url"]
|
|
output_options = build_output_options(site_config:, output_format: normalized_output_format)
|
|
plugins = build_plugins(site_config, output_format: normalized_output_format)
|
|
|
|
Site.new(
|
|
author: site_config["author"],
|
|
email: site_config["email"],
|
|
title: site_config["title"],
|
|
description: site_config["description"],
|
|
url: site_url,
|
|
fediverse_creator: build_optional_string(
|
|
site_config["fediverse_creator"],
|
|
context: "site.toml fediverse_creator"
|
|
),
|
|
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: build_renderers(output_format: normalized_output_format),
|
|
output_format: normalized_output_format,
|
|
output_options:
|
|
)
|
|
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 validate_no_legacy_output_keys!(site_config)
|
|
if site_config.key?("output")
|
|
raise ValidationError, "Legacy key 'output' is no longer supported; use 'outputs'"
|
|
end
|
|
|
|
if site_config.key?("mastodon_url") || site_config.key?("github_url")
|
|
raise ValidationError, "Legacy keys 'mastodon_url'/'github_url' are no longer supported; use outputs.html.remote_links or outputs.gemini.home_links"
|
|
end
|
|
end
|
|
|
|
def build_plugins(site_config, output_format:)
|
|
plugin_names = parse_plugin_names(site_config["plugins"])
|
|
|
|
plugin_names.map.with_index do |plugin_name, index|
|
|
case plugin_name
|
|
when "posts"
|
|
posts_plugin_for(output_format)
|
|
when "projects"
|
|
build_projects_plugin(site_config, output_format:)
|
|
else
|
|
raise ValidationError, "Unknown plugin '#{plugin_name}' at site.toml plugins[#{index}]"
|
|
end
|
|
end
|
|
end
|
|
|
|
def build_renderers(output_format:)
|
|
case output_format
|
|
when "html"
|
|
[Utils::MarkdownRenderer.new]
|
|
when "gemini"
|
|
[Utils::GeminiMarkdownRenderer.new]
|
|
else
|
|
raise ValidationError, "Unsupported output format '#{output_format}'"
|
|
end
|
|
end
|
|
|
|
def posts_plugin_for(output_format)
|
|
case output_format
|
|
when "html"
|
|
Posts::HTMLPlugin.new
|
|
when "gemini"
|
|
Posts::GeminiPlugin.new
|
|
else
|
|
raise ValidationError, "Unsupported output format '#{output_format}'"
|
|
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, output_format:)
|
|
projects_plugin = hash_or_empty(site_config["projects_plugin"], "site.toml projects_plugin")
|
|
projects_config = load_toml("projects.toml")
|
|
projects = build_projects(projects_config)
|
|
|
|
case output_format
|
|
when "html"
|
|
Projects::HTMLPlugin.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")
|
|
)
|
|
when "gemini"
|
|
Projects::GeminiPlugin.new(projects:)
|
|
else
|
|
raise ValidationError, "Unsupported output format '#{output_format}'"
|
|
end
|
|
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_output_options(site_config:, output_format:)
|
|
outputs_config = hash_or_empty(site_config["outputs"], "site.toml outputs")
|
|
validate_allowed_keys!(
|
|
outputs_config,
|
|
allowed_keys: %w[html gemini],
|
|
context: "site.toml outputs"
|
|
)
|
|
format_config = hash_or_empty(outputs_config[output_format], "site.toml outputs.#{output_format}")
|
|
|
|
case output_format
|
|
when "html"
|
|
build_html_output_options(format_config:)
|
|
when "gemini"
|
|
build_gemini_output_options(format_config:)
|
|
else
|
|
raise ValidationError, "Unsupported output format '#{output_format}'"
|
|
end
|
|
end
|
|
|
|
def build_html_output_options(format_config:)
|
|
validate_allowed_keys!(
|
|
format_config,
|
|
allowed_keys: %w[exclude_public remote_links],
|
|
context: "site.toml outputs.html"
|
|
)
|
|
public_excludes = build_public_excludes(
|
|
format_config["exclude_public"],
|
|
context: "site.toml outputs.html.exclude_public"
|
|
)
|
|
remote_links = build_output_links(
|
|
format_config["remote_links"],
|
|
context: "site.toml outputs.html.remote_links",
|
|
allow_icon: true,
|
|
allow_label_optional: false,
|
|
allow_string_entries: false
|
|
)
|
|
|
|
HTMLOutputOptions.new(
|
|
public_excludes:,
|
|
remote_links:
|
|
)
|
|
end
|
|
|
|
def build_gemini_output_options(format_config:)
|
|
validate_allowed_keys!(
|
|
format_config,
|
|
allowed_keys: %w[exclude_public recent_posts_limit home_links],
|
|
context: "site.toml outputs.gemini"
|
|
)
|
|
public_excludes = build_public_excludes(
|
|
format_config["exclude_public"],
|
|
context: "site.toml outputs.gemini.exclude_public"
|
|
)
|
|
home_links = build_output_links(
|
|
format_config["home_links"],
|
|
context: "site.toml outputs.gemini.home_links",
|
|
allow_icon: false,
|
|
allow_label_optional: true,
|
|
allow_string_entries: true
|
|
)
|
|
recent_posts_limit = build_recent_posts_limit(
|
|
format_config["recent_posts_limit"],
|
|
context: "site.toml outputs.gemini.recent_posts_limit"
|
|
)
|
|
|
|
GeminiOutputOptions.new(
|
|
public_excludes:,
|
|
recent_posts_limit:,
|
|
home_links:
|
|
)
|
|
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
|
|
|
|
def build_public_excludes(value, context:)
|
|
entries = array_or_empty(value, context)
|
|
entries.map.with_index do |entry, index|
|
|
unless entry.is_a?(String) && !entry.strip.empty?
|
|
raise ValidationError, "Expected #{context}[#{index}] to be a non-empty String"
|
|
end
|
|
|
|
entry.strip
|
|
end
|
|
end
|
|
|
|
def build_output_links(value, context:, allow_icon:, allow_label_optional:, allow_string_entries:)
|
|
entries = array_or_empty(value, context)
|
|
entries.map.with_index do |entry, index|
|
|
if allow_string_entries && entry.is_a?(String)
|
|
href = entry
|
|
unless !href.strip.empty?
|
|
raise ValidationError, "Expected #{context}[#{index}] to be a non-empty String"
|
|
end
|
|
validate_link_href!(href.strip, context: "#{context}[#{index}]")
|
|
|
|
next OutputLink.new(label: nil, href: href.strip, icon: nil)
|
|
end
|
|
|
|
unless entry.is_a?(Hash)
|
|
raise ValidationError, "Expected #{context}[#{index}] to be a String or table"
|
|
end
|
|
|
|
allowed_keys = allow_icon ? %w[label href icon] : %w[label href]
|
|
validate_allowed_keys!(
|
|
entry,
|
|
allowed_keys:,
|
|
context: "#{context}[#{index}]"
|
|
)
|
|
|
|
href = entry["href"]
|
|
unless href.is_a?(String) && !href.strip.empty?
|
|
raise ValidationError, "Expected #{context}[#{index}].href to be a non-empty String"
|
|
end
|
|
validate_link_href!(href.strip, context: "#{context}[#{index}].href")
|
|
|
|
label = entry["label"]
|
|
if label.nil?
|
|
unless allow_label_optional
|
|
raise ValidationError, "Expected #{context}[#{index}].label to be a non-empty String"
|
|
end
|
|
else
|
|
unless label.is_a?(String) && !label.strip.empty?
|
|
raise ValidationError, "Expected #{context}[#{index}].label to be a non-empty String"
|
|
end
|
|
end
|
|
|
|
icon = entry["icon"]
|
|
unless allow_icon
|
|
if entry.key?("icon")
|
|
raise ValidationError, "Unexpected #{context}[#{index}].icon; icons are only supported for outputs.html.remote_links"
|
|
end
|
|
|
|
icon = nil
|
|
end
|
|
|
|
if allow_icon && !icon.nil? && (!icon.is_a?(String) || icon.strip.empty?)
|
|
raise ValidationError, "Expected #{context}[#{index}].icon to be a non-empty String"
|
|
end
|
|
|
|
OutputLink.new(label: label&.strip, href: href.strip, icon: icon&.strip)
|
|
end
|
|
end
|
|
|
|
def validate_link_href!(value, context:)
|
|
return if value.start_with?("/")
|
|
return if value.match?(/\A[a-z][a-z0-9+\-.]*:/i)
|
|
|
|
raise ValidationError, "Expected #{context} to start with / or include a URI scheme"
|
|
end
|
|
|
|
def build_recent_posts_limit(value, context:)
|
|
return 20 if value.nil?
|
|
return value if value.is_a?(Integer) && value.positive?
|
|
|
|
raise ValidationError, "Expected #{context} to be a positive Integer"
|
|
end
|
|
|
|
def normalize_output_format(output_format)
|
|
value = output_format.to_s.strip.downcase
|
|
return value if %w[html gemini].include?(value)
|
|
|
|
raise ValidationError, "Unsupported output format '#{output_format}'"
|
|
end
|
|
|
|
def build_optional_string(value, context:)
|
|
return nil if value.nil?
|
|
return value if value.is_a?(String) && !value.strip.empty?
|
|
|
|
raise ValidationError, "Expected #{context} to be a non-empty String"
|
|
end
|
|
|
|
def validate_allowed_keys!(hash, allowed_keys:, context:)
|
|
unknown = hash.keys - allowed_keys
|
|
return if unknown.empty?
|
|
|
|
raise ValidationError, "Unknown key(s) in #{context}: #{unknown.join(", ")}"
|
|
end
|
|
end
|
|
end
|
|
end
|