Import & Export
Background-processed import and export for posts, enabling bulk content migration with YAML frontmatter support.
Quick Reference
| Feature | Import | Export |
|---|---|---|
| Route | /admin/imports/posts |
/admin/exports/posts |
| Formats | .md, .markdown, .txt, .zip |
.zip (markdown files) |
| Processing | Background job | Background job |
| Images | Attaches from zip or URL | Exports to images/ folder |
CMS Content Transfer
Transfer CMS content (groups, elements, and images) between environments via ZIP files. Designed for promoting content created in development to production.
| Feature | Export | Import |
|---|---|---|
| Route | POST /admin/cms_transfer/export |
POST /admin/cms_transfer/import |
| Format | .zip (JSON manifest + images) |
.zip (same format) |
| Processing | Synchronous (inline) | Synchronous (inline) |
| Scope | Active groups + elements only | Creates, updates, or restores |
| Images | Included in images/ directory |
Attached via Active Storage |
ZIP Format
cms_content_20260207_143022.zip
├── content.json
└── images/
├── headers/
│ └── hero-image.png
└── footers/
└── footer-logo.jpg
JSON Manifest
The content.json file contains a versioned manifest of all groups and elements:
{
"version": 1,
"exported_at": "2026-02-07T14:30:22-05:00",
"source": "RailsPress CMS",
"groups": [
{
"name": "Headers",
"description": "Site header content elements",
"elements": [
{
"name": "Homepage H1",
"content_type": "text",
"position": 1,
"text_content": "Welcome to Our Site"
},
{
"name": "Hero Image",
"content_type": "image",
"position": 2,
"text_content": null,
"image_path": "images/headers/hero-image.png"
}
]
}
]
}
Groups and elements are matched by name on import. The author_id field is intentionally excluded since it is meaningless across environments.
Programmatic API
result = Railspress::ContentExportService.new.call
result.zip_data # => binary ZIP data
result.filename # => "cms_content_20260207_143022.zip"
result.group_count # => 2
result.element_count # => 5
result = Railspress::ContentImportService.new(zip_file).call
result.created # => 2
result.updated # => 3
result.restored # => 1
result.errors # => ["Group 'X': Name can't be blank"]
result.success? # => true (when errors is empty)
Import Behavior
| Record State | Action |
|---|---|
| Not found | Create new |
| Found (active) | Update attributes |
| Found (soft-deleted) | Restore and update |
Imports are idempotent -- re-importing the same ZIP produces no duplicates or unnecessary changes. Individual record errors are collected and processing continues for remaining items. The CMS template cache is cleared after import so changes take effect immediately.
Security
- 50 MB maximum ZIP file size
- 500 maximum ZIP entries
- Path traversal protection -- entries containing
..or starting with/are rejected - macOS artifacts (
__MACOSX, dotfiles) are skipped - Supported image types:
.jpg,.jpeg,.png,.gif,.webp
CMS Transfer Routes
namespace :admin do
resource :cms_transfer, only: [:show] do
post :export, on: :member
post :import, on: :member
end
end
| Route Helper | Method | Path |
|---|---|---|
admin_cms_transfer_path |
GET | /admin/cms_transfer |
export_admin_cms_transfer_path |
POST | /admin/cms_transfer/export |
import_admin_cms_transfer_path |
POST | /admin/cms_transfer/import |
enable_cms in your RailsPress configuration. The admin UI is accessible from the "CMS Transfer" link in the admin sidebar.
Import
Supported File Types
- Markdown (
.md,.markdown): Parsed with YAML frontmatter - Plain text (
.txt): Title derived from filename - Zip archives (
.zip): Processes all markdown/text files recursively
Frontmatter Fields
---
title: My Blog Post # Required (or extracted from H1/filename)
slug: my-blog-post # Optional, auto-generated if blank
status: published # draft (default) or published
published_at: 2024-01-15 # Date, defaults to today
author: John Doe # Case-insensitive match to existing author
category: Technology # Case-insensitive match to existing category
tags: ruby, rails, tutorial # Comma-separated or YAML array
header_image: images/hero.jpg # Relative path in zip or full URL
meta_title: SEO Title # Optional SEO title
meta_description: SEO desc # Optional SEO description
---
Title Resolution
Title is resolved in order:
titlefield in frontmatter- First
# Headingin content - Filename with dashes/underscores converted to spaces
Header Images
Header images can be specified as:
Relative path in zip:
header_image: images/hero.jpg
The processor looks for the file relative to the markdown file, then relative to the zip root.
URL:
header_image: https://example.com/image.jpg
Downloads and attaches the image automatically.
Zip Structure
my-posts.zip
├── post-one.md
├── post-two.md
├── images/
│ ├── post-one-hero.jpg
│ └── post-two-hero.png
└── drafts/
└── work-in-progress.md
All markdown and text files are processed regardless of directory depth.
Obsidian Compatibility
The import processor strips Obsidian-specific metadata:
- Hashtag lines (e.g.,
#tag #another) - Task/checkbox lines (e.g.,
- [x] Done) - Priority and date markers
- Project/category prefixes
Processing Flow
- User uploads files via drag-and-drop or file picker
- Controller saves files to
tmp/uploads/import_{id}/ ImportPostsJobenqueued with file pathsPostImportProcessorprocesses each file:- Extracts zip to
tmp/imports/{id}_{timestamp}/ - Parses frontmatter and content
- Converts markdown to HTML via Redcarpet
- Creates post with associations
- Attaches header image
- Extracts zip to
- Cleans up temp files
- Updates import status (completed/failed)
Error Handling
- Individual file errors are recorded, processing continues
- Import marked "completed" if any posts succeed
- Import marked "failed" only if all posts fail
- Errors visible in "Recent Imports" table
Export
Output Format
Each post exports as a markdown file with YAML frontmatter:
---
title: My Blog Post
slug: my-blog-post
status: published
published_at: "2024-01-15"
category: Technology
tags: ruby, rails, tutorial
author: John Doe
header_image: images/my-blog-post.png
meta_title: SEO Title
meta_description: SEO description
---
<p>Post content as HTML...</p>
Content Format
Content is exported as HTML (the format stored by ActionText). Markdown parsers handle inline HTML, so the exported files remain valid markdown.
Note: Redcarpet converts Markdown to HTML on import but cannot reverse the conversion. Content that originated as markdown will export as the rendered HTML.
Zip Structure
posts_export_20241223_143022.zip
├── post-one.md
├── post-two.md
├── another-post.md
└── images/
├── post-one.png
└── another-post.jpg
Processing Flow
- User clicks "Export N posts" button
- Controller creates Export record with status "pending"
ExportPostsJobenqueuedPostExportProcessorprocesses all posts:- Creates
tmp/exports/{id}_{timestamp}/directory - Generates markdown file for each post
- Copies header images to
images/subfolder - Creates zip archive
- Attaches zip to export record via ActiveStorage
- Creates
- Cleans up temp directory
- Updates export status
Downloading
Completed exports show a "Download" button in the Recent Exports table. The download streams directly from ActiveStorage via send_data.
Database Schema
Imports Table
create_table :railspress_imports do |t|
t.string :import_type, null: false
t.string :filename
t.string :content_type
t.string :status, default: "pending"
t.integer :total_count, default: 0
t.integer :success_count, default: 0
t.integer :error_count, default: 0
t.text :error_messages
t.bigint :user_id
t.timestamps
end
Exports Table
create_table :railspress_exports do |t|
t.string :export_type, null: false
t.string :filename
t.string :status, default: "pending"
t.integer :total_count, default: 0
t.integer :success_count, default: 0
t.integer :error_count, default: 0
t.text :error_messages
t.bigint :user_id
t.timestamps
end
Exports also use ActiveStorage attachment:
has_one_attached :file
Dependencies
- rubyzip: Zip file handling
- redcarpet: Markdown to HTML conversion (import only)
Both gems are included in the railspress gemspec.
Routes
namespace :admin do
resources :imports, only: [:create] do
collection do
get ":type", action: :show, as: :typed
end
end
resources :exports, only: [:create] do
collection do
get ":type", action: :show, as: :typed
end
member do
get :download
end
end
end
| Route Helper | Path |
|---|---|
typed_admin_imports_path(type: "posts") |
/admin/imports/posts |
admin_imports_path |
POST /admin/imports |
typed_admin_exports_path(type: "posts") |
/admin/exports/posts |
admin_exports_path |
POST /admin/exports |
download_admin_export_path(export) |
/admin/exports/:id/download |
Configuration
Import/export respects these Railspress configuration options:
Railspress.configure do |config|
config.enable_post_images # Include post images in import/export
config.enable_authors # Include author in frontmatter
config.author_class_name = "User" # Model for author lookup
config.author_display_method = :name # Field to match/display author
end
Extending
Adding New Export Types
- Add type to
Export::EXPORT_TYPES - Create processor class (e.g.,
PageExportProcessor) - Update
ExportPostsJobor create dedicated job - Add route and controller handling
Custom Frontmatter Fields
Extend PostImportProcessor#create_post and PostExportProcessor#build_frontmatter to handle additional fields.