WIP: close to original output

This commit is contained in:
Sami Samhuri 2025-11-14 11:18:39 -08:00
parent 6e9988fd29
commit cf3dc9afb6
No known key found for this signature in database
28 changed files with 893 additions and 314 deletions

View file

@ -9,6 +9,7 @@ gem 'rouge', '~> 4.6'
gem 'dry-struct', '~> 1.8'
gem 'builder', '~> 3.3'
gem 'bake', '~> 0.20'
gem 'nokogiri', '~> 1.18'
group :development, :test do
gem 'rspec', '~> 3.13'

View file

@ -85,7 +85,27 @@ GEM
lumberjack (1.4.2)
mapping (1.1.3)
method_source (1.1.0)
mini_portile2 (2.8.9)
nenv (0.3.0)
nokogiri (1.18.10)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.18.10-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.10-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.18.10-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-musl)
racc (~> 1.4)
notiffany (0.1.3)
nenv (~> 0.1)
shellany (~> 0.0)
@ -185,6 +205,7 @@ DEPENDENCIES
guard-rspec (~> 4.7)
kramdown (~> 2.5)
kramdown-parser-gfm (~> 1.1)
nokogiri (~> 1.18)
phlex (~> 2.3)
rouge (~> 4.6)
rspec (~> 3.13)

View file

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

66
pressa/bin/convert-all-posts Executable file
View file

@ -0,0 +1,66 @@
#!/usr/bin/env ruby
require_relative '../lib/utils/frontmatter_converter'
source_dir = ARGV[0] || '../posts'
dry_run = ARGV.include?('--dry-run')
unless Dir.exist?(source_dir)
puts "ERROR: Directory not found: #{source_dir}"
exit 1
end
posts = Dir.glob(File.join(source_dir, '**', '*.md')).sort
puts "Found #{posts.length} posts to convert"
puts "Mode: #{dry_run ? 'DRY RUN (no changes will be made)' : 'CONVERTING FILES'}"
puts ""
converted = 0
errors = []
posts.each do |post_path|
relative_path = post_path.sub("#{source_dir}/", '')
begin
if dry_run
content = File.read(post_path)
Pressa::Utils::FrontmatterConverter.convert_content(content)
puts "✓ Would convert: #{relative_path}"
else
Pressa::Utils::FrontmatterConverter.convert_file(post_path)
puts "✓ Converted: #{relative_path}"
end
converted += 1
rescue => e
errors << {path: relative_path, error: e.message}
puts "✗ Error: #{relative_path}"
puts " #{e.message}"
end
end
puts ""
puts "=" * 60
puts "CONVERSION SUMMARY"
puts "=" * 60
puts ""
puts "Total posts: #{posts.length}"
puts "Converted: #{converted}"
puts "Errors: #{errors.length}"
puts ""
if errors.any?
puts "Posts with errors:"
errors.each do |err|
puts " #{err[:path]}"
puts " #{err[:error]}"
end
puts ""
end
if dry_run
puts "This was a dry run. Run without --dry-run to actually convert files."
puts ""
end
exit(errors.empty? ? 0 : 1)

View file

@ -1,16 +1,20 @@
#!/usr/bin/env ruby
require 'optparse'
require 'fileutils'
require 'digest'
class OutputValidator
def initialize(swift_dir:, ruby_dir:)
def initialize(swift_dir:, ruby_dir:, verbose:, ignore_patterns:, show_details:)
@swift_dir = swift_dir
@ruby_dir = ruby_dir
@differences = []
@missing_in_ruby = []
@missing_in_swift = []
@identical_count = 0
@verbose = verbose
@ignore_patterns = ignore_patterns
@show_details = show_details
end
def validate
@ -22,8 +26,8 @@ class OutputValidator
swift_files = find_html_files(@swift_dir)
ruby_files = find_html_files(@ruby_dir)
puts "Found #{swift_files.length} Swift HTML files"
puts "Found #{ruby_files.length} Ruby HTML files"
puts "Found #{swift_files.length} Swift output files"
puts "Found #{ruby_files.length} Ruby output files"
puts ""
compare_files(swift_files, ruby_files)
@ -33,7 +37,7 @@ class OutputValidator
private
def find_html_files(dir)
Dir.glob(File.join(dir, '**', '*.{html,xml,json}'))
Dir.glob(File.join(dir, '**', '*.{html,xml,json,css,js,png,jpg,jpeg,gif,svg,ico,woff,woff2,ttf,eot}'))
.map { |f| f.sub("#{dir}/", '') }
.sort
end
@ -42,6 +46,8 @@ class OutputValidator
all_files = (swift_files + ruby_files).uniq.sort
all_files.each do |relative_path|
next if ignored?(relative_path)
swift_path = File.join(@swift_dir, relative_path)
ruby_path = File.join(@ruby_dir, relative_path)
@ -56,12 +62,17 @@ class OutputValidator
end
def compare_file_contents(relative_path, swift_path, ruby_path)
swift_content = normalize_html(File.read(swift_path))
ruby_content = normalize_html(File.read(ruby_path))
if binary_file?(relative_path)
swift_content = File.binread(swift_path)
ruby_content = File.binread(ruby_path)
else
swift_content = normalize_html(File.read(swift_path))
ruby_content = normalize_html(File.read(ruby_path))
end
if swift_content == ruby_content
@identical_count += 1
puts "✓ #{relative_path}"
puts "✓ #{relative_path}" if @verbose
else
@differences << {
path: relative_path,
@ -74,6 +85,17 @@ class OutputValidator
end
end
def ignored?(path)
return false if @ignore_patterns.empty?
@ignore_patterns.any? { |pattern| File.fnmatch?(pattern, path) }
end
def binary_file?(path)
ext = File.extname(path).downcase
%w[.png .jpg .jpeg .gif .svg .ico .woff .woff2 .ttf .eot].include?(ext)
end
def normalize_html(content)
content
.gsub(/\s+/, ' ')
@ -96,6 +118,8 @@ class OutputValidator
if @differences.any?
puts "Files with differences:"
return unless @show_details
@differences.each do |diff|
puts " #{diff[:path]}"
puts " Swift: #{diff[:swift_size]} bytes (#{diff[:swift_hash][0..7]}...)"
@ -124,13 +148,32 @@ class OutputValidator
end
end
options = {
verbose: false,
ignore_patterns: [],
show_details: false
}
parser = OptionParser.new do |opts|
opts.banner = "Usage: validate-output [options] SWIFT_DIR RUBY_DIR"
opts.on('-v', '--verbose', 'Print a ✓ line for every identical file') do
options[:verbose] = true
end
opts.on('-iPATTERN', '--ignore=PATTERN', 'Ignore files matching the glob (may be repeated)') do |pattern|
options[:ignore_patterns] << pattern
end
opts.on('--details', 'Include byte counts and hashes for differing files') do
options[:show_details] = true
end
end
parser.parse!
if ARGV.length != 2
puts "Usage: validate-output SWIFT_DIR RUBY_DIR"
puts ""
puts "Compares HTML/XML/JSON output from Swift and Ruby generators."
puts ""
puts "Example:"
puts " validate-output www-swift www-ruby"
puts parser
exit 1
end
@ -147,5 +190,11 @@ unless Dir.exist?(ruby_dir)
exit 1
end
validator = OutputValidator.new(swift_dir: swift_dir, ruby_dir: ruby_dir)
validator = OutputValidator.new(
swift_dir: swift_dir,
ruby_dir: ruby_dir,
verbose: options[:verbose],
ignore_patterns: options[:ignore_patterns],
show_details: options[:show_details]
)
validator.validate

View file

@ -1,5 +1,6 @@
require 'json'
require_relative '../utils/file_writer'
require_relative '../views/feed_post_view'
module Pressa
module Posts
@ -14,20 +15,7 @@ module Pressa
def write_feed(target_path:, limit: 30)
recent = @posts_by_year.recent_posts(limit)
feed = {
version: FEED_VERSION,
title: @site.title,
home_page_url: @site.url,
feed_url: @site.url_for('/feed.json'),
description: @site.description,
authors: [
{
name: @site.author,
url: @site.url
}
],
items: recent.map { |post| feed_item(post) }
}
feed = build_feed(recent)
json = JSON.pretty_generate(feed)
file_path = File.join(target_path, 'feed.json')
@ -36,22 +24,49 @@ module Pressa
private
def feed_item(post)
item = {
id: @site.url_for(post.path),
url: post.link_post? ? post.link : @site.url_for(post.path),
title: post.link_post? ? "#{post.title}" : post.title,
content_html: post.body,
summary: post.excerpt,
date_published: post.date.iso8601,
authors: [
{
name: post.author
}
]
def build_feed(posts)
author = {
name: @site.author,
url: @site.url,
avatar: @site.image_url
}
items = posts.map { |post| feed_item(post) }
{
icon: icon_url,
favicon: favicon_url,
items: items,
home_page_url: @site.url,
author:,
version: FEED_VERSION,
authors: [author],
feed_url: @site.url_for('/feed.json'),
language: 'en-CA',
title: @site.title
}
end
def icon_url
@site.url_for('/images/apple-touch-icon-300.png')
end
def favicon_url
@site.url_for('/images/apple-touch-icon-80.png')
end
def feed_item(post)
content_html = Views::FeedPostView.new(post:, site: @site).call
permalink = @site.url_for(post.path)
item = {}
item[:url] = post.link_post? ? post.link : permalink
item[:tags] = post.tags unless post.tags.empty?
item[:content_html] = content_html
item[:title] = post.link_post? ? "#{post.title}" : post.title
item[:author] = { name: post.author }
item[:date_published] = post.date.iso8601
item[:id] = permalink
item
end

View file

@ -38,11 +38,16 @@ module Pressa
@date = timestamp.is_a?(String) ? DateTime.parse(timestamp) : timestamp.to_datetime
@formatted_date = @raw['Date']
@link = @raw['Link']
@tags = parse_comma_separated(@raw['Tags'])
@tags = parse_tags(@raw['Tags'])
@scripts = parse_scripts(@raw['Scripts'])
@styles = parse_styles(@raw['Styles'])
end
def parse_tags(value)
return [] if value.nil?
value.is_a?(Array) ? value : value.split(',').map(&:strip)
end
def parse_comma_separated(value)
return [] if value.nil? || value.empty?
value.split(',').map(&:strip)
@ -50,12 +55,25 @@ module Pressa
def parse_scripts(value)
return [] if value.nil?
parse_comma_separated(value).map { |src| Script.new(src:, defer: true) }
parse_comma_separated(value).map do |src|
Script.new(src: normalize_asset_path(src, 'js'), defer: true)
end
end
def parse_styles(value)
return [] if value.nil?
parse_comma_separated(value).map { |href| Stylesheet.new(href:) }
parse_comma_separated(value).map do |href|
Stylesheet.new(href: normalize_asset_path(href, 'css'))
end
end
def normalize_asset_path(path, default_dir)
return path if path.start_with?('http://', 'https://', '/')
return path if path.include?('/')
"#{default_dir}/#{path}"
end
end
end

View file

@ -76,20 +76,22 @@ module Pressa
def generate_excerpt(markdown)
text = markdown.dup
text.gsub!(/!\[.*?\]\([^)]+\)/, '')
text.gsub!(/!\[[^\]]*\]\([^)]+\)/, '')
text.gsub!(/!\[[^\]]*\]\[[^\]]+\]/, '')
text.gsub!(/\[(.*?)\]\([^)]+\)/, '\1')
text.gsub!(/\[([^\]]+)\]\([^)]+\)/, '\1')
text.gsub!(/\[([^\]]+)\]\[[^\]]+\]/, '\1')
text.gsub!(/(?m)^\[[^\]]+\]:\s*\S.*$/, '')
text.gsub!(/<[^>]+>/, '')
text.gsub!(/\s+/, ' ')
text.strip!
if text.length > EXCERPT_LENGTH
"#{text[0...EXCERPT_LENGTH]}..."
else
text
end
return '...' if text.empty?
"#{text[0...EXCERPT_LENGTH]}..."
end
def add_post_to_hierarchy(post)

View file

@ -1,5 +1,6 @@
require 'builder'
require_relative '../utils/file_writer'
require_relative '../views/feed_post_view'
module Pressa
module Posts
@ -15,29 +16,26 @@ module Pressa
xml = Builder::XmlMarkup.new(indent: 2)
xml.instruct! :xml, version: "1.0", encoding: "UTF-8"
xml.rss version: "2.0", "xmlns:atom" => "http://www.w3.org/2005/Atom" do
xml.rss version: "2.0",
"xmlns:atom" => "http://www.w3.org/2005/Atom",
"xmlns:content" => "http://purl.org/rss/1.0/modules/content/" do
xml.channel do
xml.title @site.title
xml.link @site.url
xml.description @site.description
xml.language "en-us"
xml.pubDate recent.first.date.rfc822 if recent.any?
xml.lastBuildDate Time.now.rfc822
xml.tag! "atom:link", href: @site.url_for('/feed.xml'), rel: "self", type: "application/rss+xml"
recent.each do |post|
xml.item do
title = post.link_post? ? "#{post.title}" : post.title
permalink = @site.url_for(post.path)
xml.title title
xml.link post.link_post? ? post.link : @site.url_for(post.path)
xml.guid @site.url_for(post.path), isPermaLink: "true"
xml.description { xml.cdata! post.body }
xml.link permalink
xml.guid permalink, isPermaLink: "true"
xml.pubDate post.date.rfc822
xml.author "#{@site.email} (#{@site.author})"
post.tags.each do |tag|
xml.category tag
end
xml.author post.author
xml.tag!('content:encoded') { xml.cdata!(render_feed_post(post)) }
end
end
end
@ -46,6 +44,10 @@ module Pressa
file_path = File.join(target_path, 'feed.xml')
Utils::FileWriter.write(path: file_path, content: xml.target!)
end
def render_feed_post(post)
Views::FeedPostView.new(post:, site: @site).call
end
end
end
end

View file

@ -25,9 +25,11 @@ module Pressa
content_view = Views::RecentPostsView.new(posts: recent, site: @site)
html = render_layout(
page_title: @site.title,
page_subtitle: nil,
canonical_url: @site.url,
content: content_view
content: content_view,
page_description: 'Recent posts',
page_type: 'article'
)
file_path = File.join(target_path, 'index.html')
@ -38,9 +40,10 @@ module Pressa
content_view = Views::ArchiveView.new(posts_by_year: @posts_by_year, site: @site)
html = render_layout(
page_title: "Archive #{@site.title}",
page_subtitle: 'Archive',
canonical_url: @site.url_for('/posts/'),
content: content_view
content: content_view,
page_description: 'Archive of all posts'
)
file_path = File.join(target_path, 'posts', 'index.html')
@ -65,17 +68,16 @@ module Pressa
private
def write_post(post:, target_path:)
content_view = Views::PostView.new(post:, site: @site)
all_scripts = @site.scripts + post.scripts
all_styles = @site.styles + post.styles
content_view = Views::PostView.new(post:, site: @site, article_class: 'container')
html = render_layout(
page_title: "#{post.title} #{@site.title}",
page_subtitle: post.title,
canonical_url: @site.url_for(post.path),
content: content_view,
page_scripts: post.scripts,
page_styles: post.styles
page_styles: post.styles,
page_description: post.excerpt,
page_type: 'article'
)
file_path = File.join(target_path, post.path.sub(/^\//, ''), 'index.html')
@ -86,9 +88,11 @@ module Pressa
content_view = Views::YearPostsView.new(year:, year_posts:, site: @site)
html = render_layout(
page_title: "#{year} #{@site.title}",
page_subtitle: year.to_s,
canonical_url: @site.url_for("/posts/#{year}/"),
content: content_view
content: content_view,
page_description: "Archive of all posts from #{year}",
page_type: 'article'
)
file_path = File.join(target_path, 'posts', year.to_s, 'index.html')
@ -101,22 +105,34 @@ module Pressa
title = "#{month.name} #{year}"
html = render_layout(
page_title: "#{title} #{@site.title}",
page_subtitle: title,
canonical_url: @site.url_for("/posts/#{year}/#{month.padded}/"),
content: content_view
content: content_view,
page_description: "Archive of all posts from #{title}",
page_type: 'article'
)
file_path = File.join(target_path, 'posts', year.to_s, month.padded, 'index.html')
Utils::FileWriter.write(path: file_path, content: html)
end
def render_layout(page_title:, canonical_url:, content:, page_scripts: [], page_styles: [])
def render_layout(
page_subtitle:,
canonical_url:,
content:,
page_scripts: [],
page_styles: [],
page_description: nil,
page_type: 'website'
)
layout = Views::Layout.new(
site: @site,
page_title:,
page_subtitle:,
canonical_url:,
page_scripts:,
page_styles:
page_styles:,
page_description:,
page_type:
)
layout.call do

View file

@ -9,47 +9,57 @@ module Pressa
def self.create_site(url_override: nil)
url = url_override || 'https://samhuri.net'
projects = [
build_project = lambda do |name, title, description|
Projects::Project.new(
name: 'bin',
title: 'bin',
description: 'my collection of scripts in ~/bin',
url: 'https://github.com/samsonjs/bin'
),
Projects::Project.new(
name: 'config',
title: 'config',
description: 'important dot files',
url: 'https://github.com/samsonjs/config'
),
Projects::Project.new(
name: 'unix-node',
title: 'unix-node',
description: 'Node.js CommonJS module that exports useful Unix commands',
url: 'https://github.com/samsonjs/unix-node'
),
Projects::Project.new(
name: 'strftime',
title: 'strftime',
description: 'strftime for JavaScript',
url: 'https://github.com/samsonjs/strftime'
name:,
title:,
description:,
url: "https://github.com/samsonjs/#{title}"
)
end
projects = [
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('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('mojo.el', 'mojo.el', 'turn emacs into a sweet mojo editor'),
build_project.call('ThePusher', 'ThePusher', 'Github post-receive hook router'),
build_project.call('NorthWatcher', 'NorthWatcher', 'cron for filesystem changes'),
build_project.call('repl-edit', 'repl-edit', 'edit Node repl commands with your text editor'),
build_project.call('cheat.el', 'cheat.el', 'cheat from emacs'),
build_project.call('batteries', 'batteries', 'a general purpose node library'),
build_project.call('samhuri.net', 'samhuri.net', 'this site')
]
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: 'The personal blog of Sami Samhuri',
description: "Sami Samhuri's blog about programming, mainly about iOS and Ruby and Rails these days.",
url:,
image_url: "#{url}/images",
image_url: "#{url}/images/me.jpg",
scripts: [],
styles: [
Stylesheet.new(href: 'css/style.css')
Stylesheet.new(href: 'css/normalize.css'),
Stylesheet.new(href: 'css/style.css'),
Stylesheet.new(href: 'css/fontawesome.min.css'),
Stylesheet.new(href: 'css/brands.min.css'),
Stylesheet.new(href: 'css/solid.min.css')
],
plugins: [
Posts::Plugin.new,
Projects::Plugin.new(projects:)
Projects::Plugin.new(projects:, scripts: project_scripts, styles: [])
],
renderers: [
Utils::MarkdownRenderer.new

View file

@ -8,8 +8,12 @@ require_relative 'models'
module Pressa
module Projects
class Plugin < Pressa::Plugin
def initialize(projects: [])
attr_reader :scripts, :styles
def initialize(projects: [], scripts: [], styles: [])
@projects = projects
@scripts = scripts
@styles = styles
end
def setup(site:, source_path:)
@ -30,7 +34,7 @@ module Pressa
html = render_layout(
site:,
page_title: "Projects #{site.title}",
page_subtitle: 'Projects',
canonical_url: site.url_for('/projects/'),
content: content_view
)
@ -44,20 +48,34 @@ module Pressa
html = render_layout(
site:,
page_title: "#{project.title} #{site.title}",
page_subtitle: project.title,
canonical_url: site.url_for(project.path),
content: content_view
content: content_view,
page_scripts: @scripts,
page_styles: @styles,
page_description: project.description
)
file_path = File.join(target_path, 'projects', project.name, 'index.html')
Utils::FileWriter.write(path: file_path, content: html)
end
def render_layout(site:, page_title:, canonical_url:, content:)
def render_layout(
site:,
page_subtitle:,
canonical_url:,
content:,
page_scripts: [],
page_styles: [],
page_description: nil
)
layout = Views::Layout.new(
site:,
page_title:,
canonical_url:
page_subtitle:,
canonical_url:,
page_scripts:,
page_styles:,
page_description:
)
layout.call do

View file

@ -31,6 +31,13 @@ module Pressa
next if File.directory?(source_file)
next if File.basename(source_file) == '.' || File.basename(source_file) == '..'
filename = File.basename(source_file)
ext = File.extname(source_file)[1..]
if can_render?(filename, ext)
next
end
relative_path = source_file.sub("#{public_dir}/", '')
target_file = File.join(target_path, relative_path)
@ -39,6 +46,10 @@ module Pressa
end
end
def can_render?(filename, ext)
site.renderers.any? { |renderer| renderer.can_render_file?(filename:, extension: ext) }
end
def process_public_directory(source_path, target_path)
public_dir = File.join(source_path, 'public')
return unless Dir.exist?(public_dir)
@ -51,7 +62,12 @@ module Pressa
ext = File.extname(source_file)[1..]
if renderer.can_render_file?(filename:, extension: ext)
relative_path = File.dirname(source_file).sub("#{public_dir}/", '')
dir_name = File.dirname(source_file)
relative_path = if dir_name == public_dir
''
else
dir_name.sub("#{public_dir}/", '')
end
target_dir = File.join(target_path, relative_path)
renderer.render(site:, file_path: source_file, target_dir:)

View file

@ -1,4 +1,5 @@
require 'fileutils'
require_relative 'html_formatter'
module Pressa
module Utils
@ -6,7 +7,13 @@ module Pressa
def self.write(path:, content:, permissions: 0o644)
FileUtils.mkdir_p(File.dirname(path))
File.write(path, content, mode: 'w')
formatted_content = if path.end_with?('.html')
HtmlFormatter.format(content)
else
content
end
File.write(path, formatted_content, mode: 'w')
File.chmod(permissions, path)
end

View file

@ -1,7 +1,7 @@
module Pressa
module Utils
class FrontmatterConverter
FIELD_PATTERN = /^([A-Z][a-z]+):\s*(.+)$/
FIELD_PATTERN = /^([A-Z][A-Za-z\s]+):\s*(.+)$/
def self.convert_file(input_path, output_path = nil)
content = File.read(input_path)
@ -40,7 +40,7 @@ module Pressa
next if line.empty?
if line =~ FIELD_PATTERN
field_name = $1
field_name = $1.strip
field_value = $2.strip
fields[field_name] = field_value
@ -56,12 +56,27 @@ module Pressa
end
private_class_method def self.format_yaml_field(name, value)
needs_quoting = (value.include?(':') && !value.start_with?('http')) ||
return "#{name}: #{value}" if name == 'Timestamp'
if name == 'Tags'
tags = value.split(',').map(&:strip)
return "#{name}: [#{tags.join(', ')}]"
end
if name == 'Title'
escaped_value = value.gsub('\\', '\\\\\\\\').gsub('"', '\\"')
return "#{name}: \"#{escaped_value}\""
end
has_special_chars = value.include?('\\') || value.include?('"')
needs_quoting = has_special_chars ||
(value.include?(':') && !value.start_with?('http')) ||
value.include?(',') ||
name == 'Date'
if needs_quoting
"#{name}: \"#{value}\""
escaped_value = value.gsub('\\', '\\\\\\\\').gsub('"', '\\"')
"#{name}: \"#{escaped_value}\""
else
"#{name}: #{value}"
end

View file

@ -0,0 +1,91 @@
module Pressa
module Utils
module HtmlFormatter
INDENT = 2
VOID_TAGS = %w[
area base br col embed hr img input link meta param source track wbr
].freeze
PLACEHOLDER_PREFIX = '%%PRESSA_PRESERVE_'
PRESERVE_PATTERNS = [
/<div class="typocode">.*?<\/div>/m,
/<div class="pressa">.*?<\/div>/m
].freeze
def self.format(html)
html_with_placeholders, preserved = preserve_sections(html)
formatted = format_with_indentation(html_with_placeholders)
restore_sections(formatted, preserved)
end
def self.format_with_indentation(html)
indent_level = 0
formatted_lines = split_lines(html).map do |line|
stripped = line.strip
next if stripped.empty?
decrease_indent = closing_tag?(stripped)
indent_level = [indent_level - INDENT, 0].max if decrease_indent
content = tag_line?(stripped) ? stripped : line
current_line = (' ' * indent_level) + content
if tag_line?(stripped) && !decrease_indent && !void_tag?(stripped) && !self_closing?(stripped)
indent_level += INDENT
end
current_line
end
formatted_lines.compact.join("\n")
end
def self.split_lines(html)
html.gsub(/>\s*</, ">\n<").split("\n")
end
def self.tag_line?(line)
line.start_with?('<') && !line.start_with?('<!')
end
def self.closing_tag?(line)
line.start_with?('</')
end
def self.self_closing?(line)
line.end_with?('/>') || line.include?('</')
end
def self.tag_name(line)
line[%r{\A</?([^\s>/]+)}, 1]&.downcase
end
def self.void_tag?(line)
VOID_TAGS.include?(tag_name(line))
end
def self.preserve_sections(html)
preserved = {}
index = 0
PRESERVE_PATTERNS.each do |pattern|
html = html.gsub(pattern) do |match|
placeholder = "#{PLACEHOLDER_PREFIX}#{index}%%"
preserved[placeholder] = match
index += 1
placeholder
end
end
[html, preserved]
end
def self.restore_sections(html, preserved)
preserved.reduce(html) do |content, (placeholder, original)|
content.gsub(placeholder, original)
end
end
end
end
end

View file

@ -1,6 +1,7 @@
require 'kramdown'
require 'yaml'
require_relative 'file_writer'
require_relative '../site'
require_relative '../views/layout'
class String
@ -10,6 +11,8 @@ end
module Pressa
module Utils
class MarkdownRenderer
EXCERPT_LENGTH = 300
def can_render_file?(filename:, extension:)
extension == 'md'
end
@ -20,20 +23,35 @@ module Pressa
html_body = render_markdown(body_markdown)
page_title = metadata['Title'] || File.basename(file_path, '.md').capitalize
show_extension = metadata['Show extension'] == 'true'
page_title = presence(metadata['Title']) || File.basename(file_path, '.md').capitalize
page_type = presence(metadata['Page type']) || 'website'
page_description = presence(metadata['Description']) || generate_excerpt(body_markdown)
show_extension = ['true', 'yes', true].include?(metadata['Show extension'])
slug = File.basename(file_path, '.md')
relative_dir = File.dirname(file_path).sub(/^.*?\/public\/?/, '')
relative_dir = '' if relative_dir == '.'
canonical_path = if show_extension
"/#{relative_dir}/#{slug}.html".squeeze('/')
else
"/#{relative_dir}/#{slug}/".squeeze('/')
end
html = render_layout(
site:,
page_title:,
canonical_url: site.url,
body: html_body
page_subtitle: page_title,
canonical_url: site.url_for(canonical_path),
body: html_body,
page_description:,
page_type:
)
output_filename = if show_extension
File.basename(file_path, '.md') + '.html'
"#{slug}.html"
else
File.join(File.basename(file_path, '.md'), 'index.html')
File.join(slug, 'index.html')
end
output_path = File.join(target_dir, output_filename)
@ -65,21 +83,70 @@ module Pressa
).to_html
end
def render_layout(site:, page_title:, canonical_url:, body:)
def render_layout(site:, page_subtitle:, canonical_url:, body:, page_description:, page_type:)
layout = Views::Layout.new(
site:,
page_title: "#{page_title} #{site.title}",
canonical_url:
page_subtitle:,
canonical_url:,
page_description:,
page_type:
)
content_view = PageView.new(page_title: page_subtitle, body:)
layout.call do
article(class: "page") do
h1 { page_title }
raw(body)
div(class: "fin") { "" }
content_view.call
end
end
class PageView < Phlex::HTML
def initialize(page_title:, body:)
@page_title = page_title
@body = body
end
def view_template
article(class: 'container') do
h1 { @page_title }
raw(@body)
end
div(class: 'row clearfix') do
p(class: 'fin') do
i(class: 'fa fa-code')
end
end
end
end
def generate_excerpt(markdown)
text = markdown.dup
# Drop inline and reference-style images before links are simplified.
text.gsub!(/!\[[^\]]*\]\([^)]+\)/, '')
text.gsub!(/!\[[^\]]*\]\[[^\]]+\]/, '')
# Replace inline and reference links with just their text.
text.gsub!(/\[([^\]]+)\]\([^)]+\)/, '\1')
text.gsub!(/\[([^\]]+)\]\[[^\]]+\]/, '\1')
# Remove link reference definitions such as: [foo]: http://example.com
text.gsub!(/(?m)^\[[^\]]+\]:\s*\S.*$/, '')
text.gsub!(/<[^>]+>/, '')
text.gsub!(/\s+/, ' ')
text.strip!
return nil if text.empty?
"#{text[0...EXCERPT_LENGTH]}..."
end
def presence(value)
return value unless value.respond_to?(:strip)
stripped = value.strip
stripped.empty? ? nil : stripped
end
end
end
end

View file

@ -1,4 +1,5 @@
require 'phlex'
require_relative 'year_posts_view'
module Pressa
module Views
@ -9,52 +10,13 @@ module Pressa
end
def view_template
article(class: "archive") do
h1 { "Archive" }
@posts_by_year.sorted_years.each do |year|
year_posts = @posts_by_year.by_year[year]
render_year(year, year_posts)
end
div(class: 'container') do
h1 { 'Archive' }
end
end
private
def render_year(year, year_posts)
section(class: "year") do
h2 do
a(href: @site.url_for("/posts/#{year}/")) { year.to_s }
end
year_posts.sorted_months.each do |month_posts|
render_month(year, month_posts)
end
end
end
def render_month(year, month_posts)
month = month_posts.month
section(class: "month") do
h3 do
a(href: @site.url_for("/posts/#{year}/#{month.padded}/")) do
"#{month.name} #{year}"
end
end
ul do
month_posts.sorted_posts.each do |post|
li do
if post.link_post?
a(href: post.link) { "#{post.title}" }
else
a(href: @site.url_for(post.path)) { post.title }
end
plain " #{post.formatted_date}"
end
end
end
@posts_by_year.sorted_years.each do |year|
year_posts = @posts_by_year.by_year[year]
render Views::YearPostsView.new(year:, year_posts:, site: @site)
end
end
end

View file

@ -0,0 +1,24 @@
require 'phlex'
module Pressa
module Views
class FeedPostView < Phlex::HTML
def initialize(post:, site:)
@post = post
@site = site
end
def view_template
div do
p(class: 'time') { @post.formatted_date }
raw(@post.body)
p do
a(class: 'permalink', href: @site.url_for(@post.path)) { '∞' }
end
end
end
private
end
end
end

View file

@ -3,52 +3,97 @@ require 'phlex'
module Pressa
module Views
class Layout < Phlex::HTML
attr_reader :site, :page_title, :canonical_url, :page_scripts, :page_styles
START_YEAR = 2006
def initialize(site:, page_title:, canonical_url:, page_scripts: [], page_styles: [])
attr_reader :site,
:page_subtitle,
:page_description,
:page_type,
:canonical_url,
:page_scripts,
:page_styles
def initialize(
site:,
page_subtitle: nil,
canonical_url:,
page_description: nil,
page_type: 'website',
page_scripts: [],
page_styles: []
)
@site = site
@page_title = page_title
@page_subtitle = page_subtitle
@page_description = page_description
@page_type = page_type
@canonical_url = canonical_url
@page_scripts = page_scripts
@page_styles = page_styles
end
def format_output?
true
end
def view_template(&block)
doctype
html(lang: "en") do
html(lang: 'en') do
comment { 'meow' }
head do
meta(charset: "utf-8")
meta(name: "viewport", content: "width=device-width, initial-scale=1")
title { page_title }
meta(name: "description", content: site.description)
meta(name: "author", content: site.author)
meta(charset: 'UTF-8')
title { full_title }
meta(name: 'twitter:title', content: full_title)
meta(property: 'og:title', content: full_title)
meta(name: 'description', content: description)
meta(name: 'twitter:description', content: description)
meta(property: 'og:description', content: description)
meta(property: 'og:site_name', content: site.title)
link(rel: "canonical", href: canonical_url)
link(rel: 'canonical', href: canonical_url)
meta(name: 'twitter:url', content: canonical_url)
meta(property: 'og:url', content: canonical_url)
meta(property: 'og:image', content: og_image_url) if og_image_url
meta(property: 'og:type', content: page_type)
meta(property: 'article:author', content: site.author)
meta(name: 'twitter:card', content: 'summary')
meta(property: "og:title", content: page_title)
meta(property: "og:description", content: site.description)
meta(property: "og:url", content: canonical_url)
meta(property: "og:type", content: "website")
if site.image_url
meta(property: "og:image", content: site.image_url)
end
link(
rel: 'alternate',
href: site.url_for('/feed.xml'),
type: 'application/rss+xml',
title: site.title
)
link(
rel: 'alternate',
href: site.url_for('/feed.json'),
type: 'application/json',
title: site.title
)
meta(name: "twitter:card", content: "summary")
meta(name: "twitter:title", content: page_title)
meta(name: "twitter:description", content: site.description)
link(rel: "alternate", type: "application/rss+xml", title: "RSS Feed", href: site.url_for('/feed.xml'))
link(rel: "alternate", type: "application/json", title: "JSON Feed", href: site.url_for('/feed.json'))
meta(name: 'fediverse:creator', content: '@sjs@techhub.social')
link(rel: 'author', type: 'text/plain', href: site.url_for('/humans.txt'))
link(rel: 'icon', type: 'image/png', href: site.url_for('/images/favicon-32x32.png'))
link(rel: 'shortcut icon', href: site.url_for('/images/favicon.icon'))
link(rel: 'apple-touch-icon', href: site.url_for('/images/apple-touch-icon.png'))
link(rel: 'mask-icon', color: '#aa0000', href: site.url_for('/images/safari-pinned-tab.svg'))
link(rel: 'manifest', href: site.url_for('/images/manifest.json'))
meta(name: 'msapplication-config', content: site.url_for('/images/browserconfig.xml'))
meta(name: 'theme-color', content: '#121212')
meta(name: 'viewport', content: 'width=device-width, initial-scale=1.0, viewport-fit=cover')
link(rel: 'dns-prefetch', href: 'https://use.typekit.net')
link(rel: 'dns-prefetch', href: 'https://netdna.bootstrapcdn.com')
link(rel: 'dns-prefetch', href: 'https://gist.github.com')
all_styles.each do |style|
link(rel: "stylesheet", href: site.url_for("/#{style.href}"))
link(rel: 'stylesheet', type: 'text/css', href: absolute_asset(style.href))
end
end
body do
render_header
main(&block)
instance_exec(&block) if block
render_footer
render_scripts
end
@ -57,6 +102,20 @@ module Pressa
private
def description
page_description || site.description
end
def full_title
return site.title unless page_subtitle
"#{site.title}: #{page_subtitle}"
end
def og_image_url
site.image_url
end
def all_styles
site.styles + page_styles
end
@ -66,37 +125,77 @@ module Pressa
end
def render_header
header do
h1 { a(href: "/") { site.title } }
nav do
ul do
li { a(href: "/") { "Home" } }
li { a(href: "/posts/") { "Archive" } }
li { a(href: "/projects/") { "Projects" } }
li { a(href: "/about/") { "About" } }
header(class: 'primary') do
div(class: 'title') do
h1 do
a(href: site.url) { site.title }
end
br
h4 do
plain 'By '
a(href: site.url_for('/about')) { site.author }
end
end
nav(class: 'remote') do
ul do
li(class: 'mastodon') do
a(rel: 'me', href: 'https://techhub.social/@sjs') do
i(class: 'fab fa-mastodon')
end
end
li(class: 'github') do
a(href: 'https://github.com/samsonjs') do
i(class: 'fab fa-github')
end
end
li(class: 'rss') do
a(href: site.url_for('/feed.xml')) do
i(class: 'fa fa-rss')
end
end
end
end
nav(class: 'local') do
ul do
li { a(href: site.url_for('/about')) { 'About' } }
li { a(href: site.url_for('/posts')) { 'Archive' } }
li { a(href: site.url_for('/projects')) { 'Projects' } }
end
end
div(class: 'clearfix')
end
end
def render_footer
footer do
p { "© #{Time.now.year} #{site.author}" }
p do
plain "Email: "
a(href: "mailto:#{site.email}") { site.email }
end
footer(class: 'container') do
plain "© #{START_YEAR} - #{Time.now.year} "
a(href: site.url_for('/about')) { site.author }
end
end
def render_scripts
all_scripts.each do |script|
if script.defer
script_(src: site.url_for("/#{script.src}"), defer: true)
else
script_(src: site.url_for("/#{script.src}"))
end
all_scripts.each do |scr|
attrs = { src: script_src(scr.src) }
attrs[:defer] = true if scr.defer
script(**attrs)
end
script(src: 'https://use.typekit.net/tcm1whv.js', crossorigin: 'anonymous')
script { plain 'try{Typekit.load({ async: true });}catch(e){}' }
end
def script_src(src)
return src if src.start_with?('http://', 'https://')
absolute_asset(src)
end
def absolute_asset(path)
normalized = path.start_with?('/') ? path : "/#{path}"
site.url_for(normalized)
end
end
end

View file

@ -11,10 +11,12 @@ module Pressa
end
def view_template
article(class: "month-posts") do
div(class: 'container') do
h1 { "#{@month_posts.month.name} #{@year}" }
end
@month_posts.sorted_posts.each do |post|
@month_posts.sorted_posts.each do |post|
div(class: 'container') do
render PostView.new(post:, site: @site)
end
end

View file

@ -7,38 +7,43 @@ end
module Pressa
module Views
class PostView < Phlex::HTML
def initialize(post:, site:)
def initialize(post:, site:, article_class: nil)
@post = post
@site = site
@article_class = article_class
end
def view_template
article(class: "post") do
article(**article_attributes) do
header do
if @post.link_post?
h1 do
h2 do
if @post.link_post?
a(href: @post.link) { "#{@post.title}" }
else
a(href: @post.path) { @post.title }
end
else
h1 { @post.title }
end
div(class: "post-meta") do
time(datetime: @post.date.iso8601) { @post.formatted_date }
plain " · "
a(href: @site.url_for(@post.path), class: "permalink") { "Permalink" }
end
time { @post.formatted_date }
a(href: @post.path, class: 'permalink') { '∞' }
end
div(class: "post-content") do
raw(@post.body)
end
raw(@post.body)
end
footer(class: "post-footer") do
div(class: "fin") { "" }
div(class: 'row clearfix') do
p(class: 'fin') do
i(class: 'fa fa-code')
end
end
end
private
def article_attributes
return {} unless @article_class
{ class: @article_class }
end
end
end
end

View file

@ -9,36 +9,54 @@ module Pressa
end
def view_template
article(class: "project", data_title: @project.github_path) do
header do
h1 { @project.title }
p(class: "description") { @project.description }
article(class: 'container project') do
h1(id: 'project', data: { title: @project.title }) { @project.title }
h4 { @project.description }
div(class: 'project-stats') do
p do
a(href: @project.url) { "View on GitHub →" }
a(href: @project.url) { 'GitHub' }
plain ' • '
a(id: 'nstar', href: stargazers_url)
plain ' • '
a(id: 'nfork', href: network_url)
end
p do
plain 'Last updated on '
span(id: 'updated')
end
end
section(class: "project-stats") do
h2 { "Statistics" }
div(id: "stats") do
p { "Loading..." }
div(class: 'project-info row clearfix') do
div(class: 'column half') do
h3 { 'Contributors' }
div(id: 'contributors')
end
end
section(class: "project-contributors") do
h2 { "Contributors" }
div(id: "contributors") do
p { "Loading..." }
end
end
section(class: "project-languages") do
h2 { "Languages" }
div(id: "languages") do
p { "Loading..." }
div(class: 'column half') do
h3 { 'Languages' }
div(id: 'langs')
end
end
end
div(class: 'row clearfix') do
p(class: 'fin') do
i(class: 'fa fa-code')
end
end
end
private
def stargazers_url
"#{@project.url}/stargazers"
end
def network_url
"#{@project.url}/network/members"
end
end
end

View file

@ -9,20 +9,24 @@ module Pressa
end
def view_template
article(class: "projects") do
h1 { "Projects" }
article(class: 'container') do
h1 { 'Projects' }
p { "Open source projects I've created or contributed to." }
ul(class: "projects-list") do
@projects.each do |project|
li do
@projects.each do |project|
div(class: 'project-listing') do
h4 do
a(href: @site.url_for(project.path)) { project.title }
plain " #{project.description}"
end
p(class: 'description') { project.description }
end
end
end
div(class: 'row clearfix') do
p(class: 'fin') do
i(class: 'fa fa-code')
end
end
end
end
end

View file

@ -10,7 +10,7 @@ module Pressa
end
def view_template
div(class: "recent-posts") do
div(class: 'container') do
@posts.each do |post|
render PostView.new(post:, site: @site)
end

View file

@ -10,8 +10,10 @@ module Pressa
end
def view_template
article(class: "year-posts") do
h1 { @year.to_s }
div(class: 'container') do
h2(class: 'year') do
a(href: year_path) { @year.to_s }
end
@year_posts.sorted_months.each do |month_posts|
render_month(month_posts)
@ -21,30 +23,36 @@ module Pressa
private
def year_path
@site.url_for("/posts/#{@year}/")
end
def render_month(month_posts)
month = month_posts.month
section(class: "month") do
h2 do
a(href: @site.url_for("/posts/#{@year}/#{month.padded}/")) do
month.name
end
h3(class: 'month') do
a(href: @site.url_for("/posts/#{@year}/#{month.padded}/")) do
month.name
end
end
ul do
month_posts.sorted_posts.each do |post|
li do
if post.link_post?
a(href: post.link) { "#{post.title}" }
else
a(href: @site.url_for(post.path)) { post.title }
end
plain " #{post.formatted_date}"
end
ul(class: 'archive') do
month_posts.sorted_posts.each do |post|
li do
a(href: post_link(post)) { post.title }
time { short_date(post.date) }
end
end
end
end
def post_link(post)
post.link_post? ? post.link : @site.url_for(post.path)
end
def short_date(date)
date.strftime('%-d %b')
end
end
end
end

View file

@ -1,17 +1,20 @@
example_id | status | run_time |
------------------------------------------------- | ------ | --------------- |
./spec/posts/metadata_spec.rb[1:1:1] | passed | 0.00009 seconds |
./spec/posts/metadata_spec.rb[1:1:2] | passed | 0.00032 seconds |
./spec/posts/metadata_spec.rb[1:1:3] | passed | 0.00228 seconds |
./spec/posts/repo_spec.rb[1:1:1] | passed | 0.00078 seconds |
./spec/posts/repo_spec.rb[1:1:2] | passed | 0.01536 seconds |
./spec/utils/frontmatter_converter_spec.rb[1:1:1] | passed | 0.00024 seconds |
./spec/utils/frontmatter_converter_spec.rb[1:1:2] | passed | 0.00003 seconds |
./spec/utils/frontmatter_converter_spec.rb[1:1:3] | passed | 0.00006 seconds |
./spec/utils/frontmatter_converter_spec.rb[1:1:4] | passed | 0.00004 seconds |
./spec/utils/frontmatter_converter_spec.rb[1:1:5] | passed | 0.00004 seconds |
./spec/utils/frontmatter_converter_spec.rb[1:1:6] | passed | 0.00003 seconds |
./spec/utils/frontmatter_converter_spec.rb[1:1:7] | passed | 0.00042 seconds |
./spec/utils/frontmatter_converter_spec.rb[1:1:8] | passed | 0.00013 seconds |
./spec/utils/frontmatter_converter_spec.rb[1:2:1] | passed | 0.00059 seconds |
./spec/utils/frontmatter_converter_spec.rb[1:2:2] | passed | 0.00004 seconds |
./spec/posts/metadata_spec.rb[1:1:1] | passed | 0.00025 seconds |
./spec/posts/metadata_spec.rb[1:1:2] | passed | 0.00089 seconds |
./spec/posts/metadata_spec.rb[1:1:3] | passed | 0.00068 seconds |
./spec/posts/repo_spec.rb[1:1:1] | passed | 0.0172 seconds |
./spec/posts/repo_spec.rb[1:1:2] | passed | 0.0009 seconds |
./spec/utils/frontmatter_converter_spec.rb[1:1:1] | passed | 0.0002 seconds |
./spec/utils/frontmatter_converter_spec.rb[1:1:2] | passed | 0.00004 seconds |
./spec/utils/frontmatter_converter_spec.rb[1:1:3] | passed | 0.00003 seconds |
./spec/utils/frontmatter_converter_spec.rb[1:1:4] | passed | 0.00056 seconds |
./spec/utils/frontmatter_converter_spec.rb[1:1:5] | passed | 0.00003 seconds |
./spec/utils/frontmatter_converter_spec.rb[1:1:6] | passed | 0.00032 seconds |
./spec/utils/frontmatter_converter_spec.rb[1:1:7] | passed | 0.00003 seconds |
./spec/utils/frontmatter_converter_spec.rb[1:1:8] | passed | 0.00004 seconds |
./spec/utils/frontmatter_converter_spec.rb[1:2:1] | passed | 0.00004 seconds |
./spec/utils/frontmatter_converter_spec.rb[1:2:2] | passed | 0.00003 seconds |
./spec/utils/frontmatter_converter_spec.rb[1:2:3] | passed | 0.00002 seconds |
./spec/utils/frontmatter_converter_spec.rb[1:2:4] | passed | 0.00003 seconds |
./spec/utils/frontmatter_converter_spec.rb[1:2:5] | passed | 0.00002 seconds |

View file

@ -18,10 +18,10 @@ RSpec.describe Pressa::Utils::FrontmatterConverter do
output = described_class.convert_content(input)
expect(output).to start_with("---\n")
expect(output).to include("Title: Test Post")
expect(output).to include("Title: \"Test Post\"")
expect(output).to include("Author: Sami Samhuri")
expect(output).to include("Date: \"11th November, 2025\"")
expect(output).to include("Timestamp: \"2025-11-11T14:00:00-08:00\"")
expect(output).to include("Timestamp: 2025-11-11T14:00:00-08:00")
expect(output).to end_with("---\n\nThis is the post body.\n")
end
@ -45,8 +45,8 @@ RSpec.describe Pressa::Utils::FrontmatterConverter do
output = described_class.convert_content(input)
expect(output).to include("Title: Zelda Tones for iOS")
expect(output).to include("Tags: \"zelda, nintendo, pacman, ringtones, tones, ios\"")
expect(output).to include("Title: \"Zelda Tones for iOS\"")
expect(output).to include("Tags:\n - zelda\n - nintendo\n - pacman\n - ringtones\n - tones\n - ios")
expect(output).to include("<h2>Zelda</h2>")
end
@ -67,7 +67,7 @@ RSpec.describe Pressa::Utils::FrontmatterConverter do
output = described_class.convert_content(input)
expect(output).to include("Link: http://en.wikipedia.org/wiki/Buffalo_buffalo_buffalo_buffalo_buffalo_buffalo_buffalo_buffalo")
expect(output).to include("Tags: \"amusement, buffalo\"")
expect(output).to include("Tags:\n - amusement\n - buffalo")
end
it 'converts front-matter with Scripts and Styles' do
@ -105,7 +105,7 @@ RSpec.describe Pressa::Utils::FrontmatterConverter do
output = described_class.convert_content(input)
expect(output).to include("Date: \"1st January, 2025\"")
expect(output).to include("Timestamp: \"2025-01-01T12:00:00-08:00\"")
expect(output).to include("Timestamp: 2025-01-01T12:00:00-08:00")
end
it 'raises error if no front-matter delimiter' do
@ -165,11 +165,11 @@ RSpec.describe Pressa::Utils::FrontmatterConverter do
yaml = described_class.convert_frontmatter_to_yaml(input)
expect(yaml).to include("Title: Test Post")
expect(yaml).to include("Title: \"Test Post\"")
expect(yaml).to include("Author: Sami Samhuri")
expect(yaml).to include("Date: \"11th November, 2025\"")
expect(yaml).to include("Timestamp: \"2025-11-11T14:00:00-08:00\"")
expect(yaml).to include("Tags: \"Ruby, Testing\"")
expect(yaml).to include("Timestamp: 2025-11-11T14:00:00-08:00")
expect(yaml).to include("Tags:\n - Ruby\n - Testing")
expect(yaml).to include("Link: https://example.net")
expect(yaml).to include("Scripts: app.js")
expect(yaml).to include("Styles: style.css")
@ -187,8 +187,48 @@ RSpec.describe Pressa::Utils::FrontmatterConverter do
yaml = described_class.convert_frontmatter_to_yaml(input)
expect(yaml).to include("Title: Test")
expect(yaml).to include("Title: \"Test\"")
expect(yaml).to include("Author: Sami Samhuri")
end
it 'handles multi-word field names' do
input = <<~FRONTMATTER
Title: About Page
Page type: page
Show extension: false
Date: 1st January, 2025
Timestamp: 2025-01-01T12:00:00-08:00
FRONTMATTER
yaml = described_class.convert_frontmatter_to_yaml(input)
expect(yaml).to include("Page type: page")
expect(yaml).to include("Show extension: false")
end
it 'escapes embedded quotes in values' do
input = <<~FRONTMATTER
Title: The "Swift" Programming Language
Date: 1st January, 2025
Timestamp: 2025-01-01T12:00:00-08:00
FRONTMATTER
yaml = described_class.convert_frontmatter_to_yaml(input)
expect(yaml).to include('Title: "The \"Swift\" Programming Language"')
expect(yaml).not_to include('Title: "The "Swift" Programming Language"')
end
it 'escapes backslashes in values' do
input = <<~FRONTMATTER
Title: Paths\\and\\backslashes, oh my
Date: 1st January, 2025
Timestamp: 2025-01-01T12:00:00-08:00
FRONTMATTER
yaml = described_class.convert_frontmatter_to_yaml(input)
expect(yaml).to include('Title: "Paths\\\\and\\\\backslashes, oh my"')
end
end
end