diff --git a/pressa/Gemfile b/pressa/Gemfile index 8bcdf50..2a0c9c4 100644 --- a/pressa/Gemfile +++ b/pressa/Gemfile @@ -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' diff --git a/pressa/Gemfile.lock b/pressa/Gemfile.lock index 2846ec6..5631ad3 100644 --- a/pressa/Gemfile.lock +++ b/pressa/Gemfile.lock @@ -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) diff --git a/pressa/bake.rb b/pressa/bake.rb index ce4a3a0..edd8e77 100644 --- a/pressa/bake.rb +++ b/pressa/bake.rb @@ -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 diff --git a/pressa/bin/convert-all-posts b/pressa/bin/convert-all-posts new file mode 100755 index 0000000..4d71a5b --- /dev/null +++ b/pressa/bin/convert-all-posts @@ -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) diff --git a/pressa/bin/validate-output b/pressa/bin/validate-output index fb8c913..cb39e3c 100755 --- a/pressa/bin/validate-output +++ b/pressa/bin/validate-output @@ -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 diff --git a/pressa/lib/posts/json_feed.rb b/pressa/lib/posts/json_feed.rb index 303b3d2..920c49e 100644 --- a/pressa/lib/posts/json_feed.rb +++ b/pressa/lib/posts/json_feed.rb @@ -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 diff --git a/pressa/lib/posts/metadata.rb b/pressa/lib/posts/metadata.rb index 373bd58..5c972cd 100644 --- a/pressa/lib/posts/metadata.rb +++ b/pressa/lib/posts/metadata.rb @@ -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 diff --git a/pressa/lib/posts/repo.rb b/pressa/lib/posts/repo.rb index a1cf4c3..4ecf543 100644 --- a/pressa/lib/posts/repo.rb +++ b/pressa/lib/posts/repo.rb @@ -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) diff --git a/pressa/lib/posts/rss_feed.rb b/pressa/lib/posts/rss_feed.rb index c723903..32f3dad 100644 --- a/pressa/lib/posts/rss_feed.rb +++ b/pressa/lib/posts/rss_feed.rb @@ -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 diff --git a/pressa/lib/posts/writer.rb b/pressa/lib/posts/writer.rb index 800887c..70c9f92 100644 --- a/pressa/lib/posts/writer.rb +++ b/pressa/lib/posts/writer.rb @@ -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 diff --git a/pressa/lib/pressa.rb b/pressa/lib/pressa.rb index 39d9a12..c1fcdd8 100644 --- a/pressa/lib/pressa.rb +++ b/pressa/lib/pressa.rb @@ -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 diff --git a/pressa/lib/projects/plugin.rb b/pressa/lib/projects/plugin.rb index 3f487bc..3c1650d 100644 --- a/pressa/lib/projects/plugin.rb +++ b/pressa/lib/projects/plugin.rb @@ -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 diff --git a/pressa/lib/site_generator.rb b/pressa/lib/site_generator.rb index 309c2dc..babc92d 100644 --- a/pressa/lib/site_generator.rb +++ b/pressa/lib/site_generator.rb @@ -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:) diff --git a/pressa/lib/utils/file_writer.rb b/pressa/lib/utils/file_writer.rb index 1c2809b..4bd5444 100644 --- a/pressa/lib/utils/file_writer.rb +++ b/pressa/lib/utils/file_writer.rb @@ -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 diff --git a/pressa/lib/utils/frontmatter_converter.rb b/pressa/lib/utils/frontmatter_converter.rb index 65cad2b..2c1c766 100644 --- a/pressa/lib/utils/frontmatter_converter.rb +++ b/pressa/lib/utils/frontmatter_converter.rb @@ -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 diff --git a/pressa/lib/utils/html_formatter.rb b/pressa/lib/utils/html_formatter.rb new file mode 100644 index 0000000..af03d73 --- /dev/null +++ b/pressa/lib/utils/html_formatter.rb @@ -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>/m, + /
.*?<\/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?('') || line.include?('/]+)}, 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 diff --git a/pressa/lib/utils/markdown_renderer.rb b/pressa/lib/utils/markdown_renderer.rb index 3ac7295..6897a56 100644 --- a/pressa/lib/utils/markdown_renderer.rb +++ b/pressa/lib/utils/markdown_renderer.rb @@ -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 diff --git a/pressa/lib/views/archive_view.rb b/pressa/lib/views/archive_view.rb index c9cdf17..6413a8a 100644 --- a/pressa/lib/views/archive_view.rb +++ b/pressa/lib/views/archive_view.rb @@ -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 diff --git a/pressa/lib/views/feed_post_view.rb b/pressa/lib/views/feed_post_view.rb new file mode 100644 index 0000000..57ba351 --- /dev/null +++ b/pressa/lib/views/feed_post_view.rb @@ -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 diff --git a/pressa/lib/views/layout.rb b/pressa/lib/views/layout.rb index 725db4e..ffffeba 100644 --- a/pressa/lib/views/layout.rb +++ b/pressa/lib/views/layout.rb @@ -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 diff --git a/pressa/lib/views/month_posts_view.rb b/pressa/lib/views/month_posts_view.rb index 621f7b5..b07f7a7 100644 --- a/pressa/lib/views/month_posts_view.rb +++ b/pressa/lib/views/month_posts_view.rb @@ -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 diff --git a/pressa/lib/views/post_view.rb b/pressa/lib/views/post_view.rb index 45b36aa..120d23f 100644 --- a/pressa/lib/views/post_view.rb +++ b/pressa/lib/views/post_view.rb @@ -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 diff --git a/pressa/lib/views/project_view.rb b/pressa/lib/views/project_view.rb index 2a45684..30f9929 100644 --- a/pressa/lib/views/project_view.rb +++ b/pressa/lib/views/project_view.rb @@ -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 diff --git a/pressa/lib/views/projects_view.rb b/pressa/lib/views/projects_view.rb index 19c216c..791ad19 100644 --- a/pressa/lib/views/projects_view.rb +++ b/pressa/lib/views/projects_view.rb @@ -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 diff --git a/pressa/lib/views/recent_posts_view.rb b/pressa/lib/views/recent_posts_view.rb index 4e59886..09d6586 100644 --- a/pressa/lib/views/recent_posts_view.rb +++ b/pressa/lib/views/recent_posts_view.rb @@ -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 diff --git a/pressa/lib/views/year_posts_view.rb b/pressa/lib/views/year_posts_view.rb index 88a7c3a..1a9a543 100644 --- a/pressa/lib/views/year_posts_view.rb +++ b/pressa/lib/views/year_posts_view.rb @@ -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 diff --git a/pressa/spec/examples.txt b/pressa/spec/examples.txt index 0e06a65..a9f0bc0 100644 --- a/pressa/spec/examples.txt +++ b/pressa/spec/examples.txt @@ -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 | diff --git a/pressa/spec/utils/frontmatter_converter_spec.rb b/pressa/spec/utils/frontmatter_converter_spec.rb index 7f6ab42..a33211a 100644 --- a/pressa/spec/utils/frontmatter_converter_spec.rb +++ b/pressa/spec/utils/frontmatter_converter_spec.rb @@ -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("

Zelda

") 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