Journeybook
Journeybook is a mountable Rails 8 engine for Markdown-first CMS content. It keeps layouts, routing, and design in the host application while providing reusable page models, component registration, and a rendering pipeline.
Principles
- Content is written in Markdown.
- Components are defined in Ruby code, not stored in the database.
- Public routing belongs to the host application.
- No JavaScript dependency is required beyond Rails defaults such as Turbo.
- The engine exposes small, Zeitwerk-compatible Rails services.
Installation
Install the released gem from RubyGems:
gem "journeybook", "~> 0.1"
Then run:
bundle install
For local engine development only, a host application can temporarily use a relative path: dependency. Do not deploy a path: dependency.
Initial host installation
Run these steps once for each Rails host application:
bundle install
bin/rails journeybook:install
bin/rails db:migrate
Commit the files generated by journeybook:install, including the copied migrations, initializer, controller, view, and routes. The installer copies Journeybook migrations and a reload-safe initializer, then adds a minimal public page controller, default page view, and host-owned routes:
root "pages#show"
constraints Journeybook::PublicPageConstraint.new do
get "*path" => "pages#show", as: :journeybook_page
end
The generated controller includes Journeybook::PublicPages, which resolves published pages by request host and path, renders them through Journeybook::PageRenderer, and leaves layout/design in the host app.
The admin routes are mounted by the installer:
mount Journeybook::Engine => "/journeybook"
Open /journeybook in the host app to manage sites, pages, components, the image library, previews, and AI-generated Markdown drafts.
Updating Journeybook
- Update the version constraint in the host application's Gemfile when necessary.
- Run
bundle update journeybookand commit the resultingGemfile.lock. - If the release includes database migrations, run
bin/rails journeybook:installand commit the newly copied migrations. - Run
bin/rails db:migratelocally and in the deployment release step. - Deploy the host application's commit.
Do not rerun the installer for a release without new migrations or generated setup changes. Do not edit migrations that have already been deployed; ship a new engine migration instead.
Coolify deployment
For a Rails application deployed from main with Coolify, RubyGems requires no additional GitHub SSH key. Coolify runs bundle install as part of the normal build and resolves the version locked in Gemfile.lock.
Configure the production release command to run bin/rails db:migrate. Store Journeybook admin credentials, S3 credentials, and optional Postmark/OpenAI credentials as production secrets. Use persistent S3-compatible storage for the Journeybook image library; do not rely on the container filesystem for uploaded media.
Releasing
RubyGems releases are immutable. Use semantic versioning and update both lib/journeybook/version.rb and CHANGELOG.md before releasing.
bin/rails db:test:prepare && bin/rails test
bin/rubocop -f github
bundle exec rake build
gem check pkg/journeybook-*.gem
Run the Release workflow manually from GitHub Actions after the version bump and changelog commit are merged. It runs tests and linting, then rubygems/release-gem creates the matching Git tag and publishes the gem.
For RubyGems Trusted Publishing, configure the journeybook gem with this GitHub Actions publisher after the first release:
Owner: talent-journey
Repository: journeybook
Workflow: .github/workflows/release.yml
Trusted Publishing uses GitHub OIDC, so it needs no RUBYGEMS_API_KEY and no one-time MFA code. The first release must be published manually with an MFA code before a gem-specific publisher can be configured.
Credentials
Journeybook expects secrets below one journeybook namespace in the host app credentials. Non-secret form settings such as sender and recipient addresses can stay in the initializer.
journeybook:
admin:
username: ...
password: ...
openai:
api_key: ...
model: gpt-5.4-mini
postmark:
api_key: ...
s3:
default:
region: eu-central-1
development:
bucket: journeybook-assets-development
access_key_id: ...
secret_access_key: ...
prefix: journeybook/development
staging:
bucket: journeybook-assets-staging
access_key_id: ...
secret_access_key: ...
prefix: journeybook/staging
production:
bucket: journeybook-assets-production
access_key_id: ...
secret_access_key: ...
prefix: journeybook/production
Journeybook denies admin access by default. Configure admin_authenticator in the host application. For a small, single-admin installation, HTTP Basic authentication with the credentials above is sufficient; production applications with their own account system should delegate to that system instead.
Journeybook.configure do |config|
config.admin_basic_auth username: journeybook_credentials.dig(:admin, :username), password: journeybook_credentials.dig(:admin, :password)
end
Configure non-secret form delivery settings in the host initializer:
Journeybook.configure do |config|
config.form_from = "noreply@example.com"
config.form_to = "team@example.com"
config.form_delivery_method = :postmark
end
The generated initializer reads journeybook.s3.default and merges it with the current Rails.env block. Use separate buckets per environment for production safety. If you intentionally use one bucket, restrict each environment's IAM credentials to its own prefix.
Journeybook builds public S3 URLs from bucket and region automatically. Set public_base_url only when images should be served through CloudFront, a CDN, or another custom public host:
journeybook:
s3:
production:
public_base_url: https://cdn.example.com
Image Library
Journeybook includes a global public image library for component props and AI-generated drafts. Uploaded images are stored in S3 and metadata is stored in Journeybook. Deleting an image from the library also deletes the S3 object. The admin UI accepts PNG, JPEG, WebP, GIF, and SVG files.
By default Journeybook stores objects below an environment-specific prefix such as journeybook/development or journeybook/production. Production should use a separate bucket or IAM credentials restricted to the production prefix, so local development cannot delete production objects.
AI Page Builder
Journeybook can generate editable Markdown drafts with OpenAI. Store the API key under journeybook.openai.api_key in Rails credentials:
journeybook:
openai:
api_key: ...
model: gpt-5.4-mini
You can also use OPENAI_API_KEY as a local development fallback:
OPENAI_API_KEY=... bin/dev
The AI builder sends the user's prompt plus the registered component schema to OpenAI and returns one Markdown document. It uses gpt-5.4-mini by default. Selecting a page template restricts generation to that template's component recipe and preserves its order. Journeybook validates the generated components before review; the generated Markdown is never published directly, and users must review and save it as a draft through the normal page form.
Configuration
journeybook_credentials = Rails.application.credentials.fetch(:journeybook, {})
journeybook_s3_default = journeybook_credentials.dig(:s3, :default) || {}
journeybook_s3_env = journeybook_credentials.dig(:s3, Rails.env.to_sym) || {}
journeybook_s3 = journeybook_s3_default.merge(journeybook_s3_env)
Journeybook.configure do |config|
config.default_layout = "application"
config.openai_api_key = journeybook_credentials.dig(:openai, :api_key)
config.openai_model = journeybook_credentials.dig(:openai, :model) || config.openai_model
config.postmark_api_key = journeybook_credentials.dig(:postmark, :api_key)
config.form_from = "noreply@example.com"
config.form_to = "team@example.com"
config.form_delivery_method = Rails.env.development? ? :letter_opener : :postmark
config.s3_bucket = journeybook_s3[:bucket]
config.s3_region = journeybook_s3[:region]
config.s3_access_key_id = journeybook_s3[:access_key_id]
config.s3_secret_access_key = journeybook_s3[:secret_access_key]
config.s3_prefix = journeybook_s3[:prefix] || "journeybook/#{Rails.env}"
# Optional: use CloudFront, a CDN, or another custom public host instead of the default S3 URL.
config.s3_public_base_url = journeybook_s3[:public_base_url]
end
Page templates
Page templates give non-technical editors an approved component recipe instead of a blank canvas. Register them in the host application:
Journeybook.register_template :blog_post,
name: "Blog post",
description: "Article header and longform content.",
page_type: "blog_post",
path_prefix: "/blog",
path_hint: "/blog/your-post-title",
sites: %i[marketing],
markdown: <<~MARKDOWN
{{ article_hero }}
{{ text text="Start writing your article here." }}
MARKDOWN
Editors choose a template when creating a page. The template supplies its component Markdown and page type. If path_prefix is present and the editor leaves the path empty, Journeybook derives a path from the title, such as /blog/your-post-title.
Use page_type: "kb_article" with an optional parent_id to build a Help Center hierarchy. Use page_type: "glossary_term" and topic: "HR" (or another topic) for glossary content and topic-specific index components.
Components
Register components in the host application:
Journeybook.configure do |config|
config.component :hero, Website::HeroComponent
end
Then use them in Markdown:
# Hello
{{ hero title="Journeybook" subtitle="Markdown-first CMS" }}
Registered component classes can expose either .render(**props) or instances with #render.
Repeatable items
Use a collection prop for a component with a configurable number of homogeneous items. Define the item schema and an explicit visual limit:
Journeybook.configure do |config|
config.component :feature_grid, Website::FeatureGridComponent,
props: {
items: {
type: :collection,
min: 1,
max: 4,
item: {
title: "Feature title",
description: { type: :long_text },
href: { type: :url, required: false },
color: { type: :select, options: %w[sunset forest stone] }
}
}
}
end
Collection data is JSON wrapped in single quotes, which keeps Markdown readable and gives the AI a deterministic format:
{{ feature_grid items='[{"title":"Onboarding","description":"New hires start with confidence.","href":"/onboarding"}]' }}
The page editor renders collection props as a repeater with add, remove, and drag-to-reorder controls. Nested arbitrary components are intentionally not supported.
Navigation
Journeybook stores site-scoped navigation menus and ordered items. An item links to either a published Journeybook page or an external URL/path. The host application keeps ownership of the markup and renders menus by key:
<% (journeybook_menu("footer_product")&.visible_items || []).each do |item| %>
<a href="<%= item.href %>"><%= item.label %></a>
<% end %>
Create and edit menus in the admin under Navigation. Seed menu keys such as header_learn or footer_product from the host application; their meaning is defined by that host's layout.
Form Emails
Journeybook includes a public form endpoint for components that need to send notification emails through Postmark. Store the Postmark API key under journeybook.postmark.api_key, then configure the non-secret form addresses in the host app initializer:
Journeybook.configure do |config|
config.postmark_api_key = Rails.application.credentials.dig(:journeybook, :postmark, :api_key)
config.form_from = "noreply@example.com"
config.form_to = "team@example.com"
config.form_delivery_method = :postmark
end
For local development, add letter_opener to the host app's development group and switch the delivery method there. Journeybook does not include letter_opener as a runtime dependency because it should not be installed in production apps unless the host app explicitly wants it.
# Gemfile
group :development do
gem "letter_opener"
end
# config/initializers/journeybook.rb
Journeybook.configure do |config|
config.form_delivery_method = Rails.env.development? ? :letter_opener : :postmark
end
Components can render forms that post to the configured action:
class ContactFormComponent
def self.render
<<~HTML
<form action="#{Journeybook.configuration.form_action}" method="post">
<input name="name" required>
<input name="email" type="email" required>
<textarea name="message" required></textarea>
<button type="submit">Send</button>
</form>
HTML
end
end
The recipient address is configuration-only. Submitted email is used as the Postmark ReplyTo address when present. If you mount Journeybook somewhere other than /journeybook, set config.form_action to the matching form endpoint path.
Contributing
Bug reports and pull requests are welcome at https://github.com/talent-journey/journeybook.
License
The gem is available as open source under the terms of the MIT License.