Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,4 +165,29 @@ RollupAndCleanupPageViewsJob.perform_now # Manually run
- Brakeman for vulnerability scanning
- Admin constraints for Sidekiq/PgHero access
- Environment-based feature toggles
- **Privacy**: No raw IP storage, only visitor_hash for uniqueness detection
- **Privacy**: No raw IP storage, only visitor_hash for uniqueness detection

### Blog Export System
- **Two formats**: HTML (with layout) and Markdown (with front-matter)
- **Export process**: BlogExportJob creates ZIP files with all blog content
- **HTML exports**: Creates `index.html` + individual `.html` files for each post/page
- **Markdown exports**: Creates `index.md` + individual `.md` files with YAML front-matter
- **Image handling**: Downloads and includes referenced images in `images/` directory
- **File structure**: Uses post slugs as filenames, preserves relative image links
- **Auto-cleanup**: Exports are automatically deleted after 7 days
- **Status tracking**: pending → in_progress → completed/failed
- **Rate limiting**: 5 exports per day per user

#### Export Testing
- Test both HTML and Markdown format creation
- Verify `display_format` method returns "HTML" or "Markdown"
- Test controller accepts format parameter correctly
- Verify both index formats are created with proper links
- Test image downloading and path rewriting
- Test failed exports can still be deleted

## Code Style Guidelines
- **String quotes**: Always use double quotes for strings, not single quotes
- **Whitespace**: Remove trailing whitespace from all lines
- **Component pattern**: Check existing implementations before creating new ones
- **Method complexity**: Keep methods simple - prefer ternary operators over case statements for 2-option logic
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ gem "premailer-rails", "~> 1.12"
gem "aws-sdk-s3"
gem "discard", "~> 1.2"
gem "pg_search"
gem "reverse_markdown"
gem "fastimage"
gem "feature_toggles"
gem "htmlentities"
Expand Down
3 changes: 3 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,8 @@ GEM
regexp_parser (2.11.2)
reline (0.6.2)
io-console (~> 0.5)
reverse_markdown (3.0.0)
nokogiri
rexml (3.4.2)
rollups (0.5.0)
activesupport (>= 7.1)
Expand Down Expand Up @@ -518,6 +520,7 @@ DEPENDENCIES
rails-controller-testing
rails_autolink
redis (>= 4.0.1)
reverse_markdown
rollups
rubocop-rails-omakase
ruby-vips
Expand Down
8 changes: 7 additions & 1 deletion app/controllers/app/settings/exports_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ def index
end

def create
@export = @blog.exports.create!
@export = @blog.exports.create!(export_params)
BlogExportJob.perform_later(@export.id)

redirect_to app_settings_exports_path, notice: "Export started"
Expand All @@ -20,4 +20,10 @@ def destroy

redirect_to app_settings_exports_path, notice: "Export deleted"
end

private

def export_params
params.fetch(:blog_export, {}).permit(:format)
end
end
42 changes: 33 additions & 9 deletions app/models/blog/export.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ class Blog::Export < ApplicationRecord
has_one_attached :file

enum :status, [ :pending, :in_progress, :completed, :failed ]
enum :format, [ :html, :markdown ]

def display_format
html? ? "HTML" : format.titleize
end

def perform
in_progress!
Expand All @@ -30,37 +35,56 @@ def perform
private

def export_posts(dir)
create_index_html(dir)
create_index(dir)

blog.all_posts.find_each do |post|
export_post_to_html(post, dir)
export_post(post, dir)
end
end

def create_index_html(dir)
template_path = Rails.root.join("app/views/blog/exports/index.html.erb")
def create_index(dir)
if markdown?
template_path = Rails.root.join("app/views/blog/exports/index.md.erb")
filename = "index.md"
else
template_path = Rails.root.join("app/views/blog/exports/index.html.erb")
filename = "index.html"
end

template = ERB.new(File.read(template_path), trim_mode: "-")

File.open(File.join(dir, "index.html"), "w") do |file|
File.open(File.join(dir, filename), "w") do |file|
file.write(template.result(binding))
end
end

def export_post_to_html(post, dir)
def export_post(post, dir)
images_dir = File.join(dir, "images")
FileUtils.mkdir_p(images_dir)

stripped_html = Html::StripActionTextAttachments.new.transform(post.content.to_s)
@post_content = Blog::Export::ImageHandler.new(post, images_dir).process_images(stripped_html)

template_path = Rails.root.join("app/views/blog/exports/post.html.erb")
if markdown?
@post_content = html_to_markdown(Blog::Export::ImageHandler.new(post, images_dir).process_images(stripped_html))
template_path = Rails.root.join("app/views/blog/exports/post.md.erb")
file_extension = "md"
else
@post_content = Blog::Export::ImageHandler.new(post, images_dir).process_images(stripped_html)
template_path = Rails.root.join("app/views/blog/exports/post.html.erb")
file_extension = "html"
end

template = ERB.new(File.read(template_path), trim_mode: "-")

File.open(File.join(dir, "#{post.slug}.html"), "w") do |file|
File.open(File.join(dir, "#{post.slug}.#{file_extension}"), "w") do |file|
file.write(template.result(binding))
end
end

def html_to_markdown(html)
ReverseMarkdown.convert(html)
end

def attach_zip_file(dir)
zip_path = File.join(dir, "#{blog.subdomain.parameterize}_export_#{Time.current.to_i}.zip")

Expand Down
15 changes: 10 additions & 5 deletions app/views/app/settings/exports/_exports.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<thead>
<tr>
<th class="p-2 text-start font-bold">Date</th>
<th class="p-2 text-start font-bold">Format</th>
<th class="p-2 text-start font-bold">Status</th>
<th></th>
</tr>
Expand All @@ -13,6 +14,10 @@
<%= export.created_at.to_formatted_s(:short) %>
</td>

<td class="p-2 text-slate-600 dark:text-slate-500">
<%= export.display_format %>
</td>

<td class="p-2 text-slate-600 dark:text-slate-500">
<%= export.status.titleize %>
</td>
Expand All @@ -23,14 +28,14 @@
<%= inline_svg_tag "icons/download.svg", class: "w-4 h-4" %>
<span class="hidden sm:inline ml-2">Download</span>
<% end %>
<% end %>

<%= button_to app_settings_export_path(export), method: :delete, class: "btn-danger", data: { turbo_confirm: "Are you sure?" } do %>
<%= inline_svg_tag "icons/trash.svg", class: "w-4 h-4" %>
<span class="hidden sm:inline ml-2">Delete</span>
<% end %>
<%= button_to app_settings_export_path(export), method: :delete, class: "btn-danger", data: { turbo_confirm: "Are you sure?" } do %>
<%= inline_svg_tag "icons/trash.svg", class: "w-4 h-4" %>
<span class="hidden sm:inline ml-2">Delete</span>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</table>
33 changes: 28 additions & 5 deletions app/views/app/settings/exports/index.html.erb
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<h3 class="mb-4 font-bold text-xl">Exports</h3>

<div class="flex flex-col sm:flex-row justify-between w-full">
<div class="flex flex-col w-full">
<% if @exports.empty? %>
<div>You have no exports</div>
<div>You have no exports.</div>
<% else %>
<div>
<p>
Expand All @@ -15,9 +15,32 @@
</div>
<% end %>

<div class="mt-8 sm:mt-0 w-56 sm:text-end">
<%= button_to "💾 Generate New Export", app_settings_exports_path, class: "btn-primary" %>
</div>
<%= form_with model: Blog::Export.new, url: app_settings_exports_path, local: true, class: "space-y-4" do |form| %>
<div class="my-4 space-y-3">
<label class="block text-base font-medium">Export Format</label>
<div class="flex flex-col sm:flex-row gap-y-2 sm:gap-x-4">
<fieldset class="w-full flex border border-slate-200 dark:border-slate-700 rounded-lg items-center hover:bg-slate-100 dark:hover:bg-slate-800 cursor-pointer">
<label class="text-slate-900 dark:text-slate-300 flex items-center space-x-2 w-full p-4 cursor-pointer">
<%= form.radio_button :format, 'html',
checked: true,
class: "peer w-4 h-4 border-slate-300 checked:border-sky-600 dark:checked:border-gray-600 ring-offset-0 ring-0 text-sky-600 dark:focus:ring-gray-800 dark:ring-offset-gray-800 dark:bg-gray-700 dark:border-gray-600 cursor-pointer",
id: "format_html" %>
<span>HTML</span>
</label>
</fieldset>

<fieldset class="w-full flex border border-slate-200 dark:border-slate-700 rounded-lg items-center hover:bg-slate-100 dark:hover:bg-slate-800 cursor-pointer">
<label class="text-slate-900 dark:text-slate-300 flex items-center space-x-2 w-full p-4 cursor-pointer">
<%= form.radio_button :format, 'markdown',
class: "peer w-4 h-4 border-slate-300 checked:border-sky-600 dark:checked:border-gray-600 ring-offset-0 ring-0 text-sky-600 dark:focus:ring-gray-800 dark:ring-offset-gray-800 dark:bg-gray-700 dark:border-gray-600 cursor-pointer",
id: "format_markdown" %>
<span>Markdown</span>
</label>
</fieldset>
</div>
</div>
<%= form.submit "💾 Generate New Export", class: "btn-primary" %>
<% end %>
</div>

<%= render "exports" unless @exports.empty? %>
30 changes: 30 additions & 0 deletions app/views/blog/exports/index.md.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# <%= blog.display_name %>

<% if blog.bio -%>
<%= blog.bio %>

<% end -%>
<% navigation_pages = blog.pages.navigation_pages.visible.order(:title) %>
<% if navigation_pages.any? %>
## Pages

<% navigation_pages.each do |page| %>
- [<%= page.title %>](<%= page.slug %>.md)
<% end %>

<% end %>
## Posts

<% current_year = nil -%>
<% blog.posts.visible.order(published_at: :desc).each do |post| -%>
<% year = post.published_at.year -%>
<% if year != current_year -%>
<% if current_year -%>

<% end -%>
### <%= year %>

<% current_year = year -%>
<% end -%>
- <%= post.published_at.strftime("%d %b") %> — [<%= post.title.presence || post.summary %>](<%= post.slug %>.md)
<% end -%>
2 changes: 1 addition & 1 deletion app/views/blog/exports/post.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<html>
<head>
<meta charset="utf-8">
<title><%= post.title.presence || post.summary %></title>
<title><%= post.display_title %></title>
</head>
<body>
<% if post.title.present? -%>
Expand Down
16 changes: 16 additions & 0 deletions app/views/blog/exports/post.md.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
title: "<%= post.display_title %>"
published: <%= post.published? %>
<% if post.published_at.present? -%>
published_at: <%= post.published_at.iso8601 %>
<% end -%>
<% if post.tag_list.present? -%>
tags: [<%= post.tag_list.map { |tag| "\"#{tag}\"" }.join(", ") %>]
<% end -%>
---

<% if post.title.present? -%>
# <%= post.title %>

<% end -%>
<%= @post_content %>
5 changes: 5 additions & 0 deletions db/migrate/20250901130412_add_format_to_blog_exports.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddFormatToBlogExports < ActiveRecord::Migration[8.1]
def change
add_column :blog_exports, :format, :integer, default: 0, null: false
end
end
3 changes: 2 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 29 additions & 1 deletion test/controllers/app/settings/exports_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,41 @@ class App::Settings::ExportsControllerTest < ActionDispatch::IntegrationTest
login_as @user
end

test "should create export" do
test "should create export with default html format" do
assert_difference -> { Blog::Export.count } do
assert_enqueued_with(job: BlogExportJob) do
post app_settings_exports_path
end
end

export = Blog::Export.last
assert export.html?
assert_redirected_to app_settings_exports_path
assert_equal "Export started", flash[:notice]
end

test "should create export with markdown format" do
assert_difference -> { Blog::Export.count } do
assert_enqueued_with(job: BlogExportJob) do
post app_settings_exports_path, params: { blog_export: { format: "markdown" } }
end
end

export = Blog::Export.last
assert export.markdown?
assert_redirected_to app_settings_exports_path
assert_equal "Export started", flash[:notice]
end

test "should create export with html format explicitly" do
assert_difference -> { Blog::Export.count } do
assert_enqueued_with(job: BlogExportJob) do
post app_settings_exports_path
end
end

export = Blog::Export.last
assert export.html?
assert_redirected_to app_settings_exports_path
assert_equal "Export started", flash[:notice]
end
Expand Down
Loading