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

  1. Update the version constraint in the host application's Gemfile when necessary.
  2. Run bundle update journeybook and commit the resulting Gemfile.lock.
  3. If the release includes database migrations, run bin/rails journeybook:install and commit the newly copied migrations.
  4. Run bin/rails db:migrate locally and in the deployment release step.
  5. 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.

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.