---
url: /content-editor.md
---
# Content editor

Trek's content editor is where the admin writes the content of pages, page versions and fragments: headings, lists, quotes, links, images and calls to action, laid out as they will read.

It's built on [Tiptap](https://tiptap.dev), an editor framework on top of [ProseMirror](https://prosemirror.net). That's why the editor doesn't save HTML. It saves a structured document: a tree of typed nodes (paragraphs, headings, images…) and marks (bold, links…), each with typed attributes. The site turns that document into HTML when it shows it, with the [`prosemirror`](https://github.com/etaminstudio/prosemirror) gem.

Keeping structure rather than markup lets Trek:

* **render the content safely:** the HTML is built from known types, so content can't inject markup;
* **change how a block looks** on the site without touching stored content, for example images rendered by the app's own component;
* **keep links to pages working** when they move: a link stores the page, not its URL;
* **read the content as data:** extract its text, search it, check it.

This guide follows content from end to end:

1. **Editing:** [using the editor](#using-the-editor) in admin forms, and its panels.
2. **Storage:** [how content is stored](#how-content-is-stored), as JSON in a `jsonb` column.
3. **Rendering:** [rendering on the site](#rendering-on-the-site), through `Trek::Content.schema`, and [customizing it](#customizing-the-rendering).

## Using the editor

Add it to an admin form with `f.content_editor`:

```slim
= form_with model: [:admin, @book] do |f|
  = f.group :content
    = f.content_editor :content
```

| Option | Default | Effect |
| --- | --- | --- |
| `nodes` | `true` | the toolbar's text buttons: bold, italic, link, clear formatting, line break |
| `blocks` | `true` | the toolbar's block buttons: paragraph, headings 2 to 4, bullet list, quote, horizontal rule |
| `floating` | `true` | the menu on an empty line, inserting an image or a prompt |
| `autofocus` | `false` | puts the cursor at the end of the content on load |

Undo and redo are always there. For a short field, such as an introduction, turn the rest off:

```slim
= f.content_editor :intro, blocks: false, floating: false
```

Tiptap's Markdown shortcuts work too: typing `1. ` starts an ordered list, and `~~text~~` strikes text through, even though the toolbar has no button for them. The site renders them like the rest.

### Links

The link button opens a panel to pick a page of the site, or type any URL. A link to a page is stored as its GlobalID (`gid://app/Page/12`) rather than its URL. It's turned into a URL when the content is rendered, so the link survives the page moving or changing its slug (see [Links to records with GlobalID](https://github.com/etaminstudio/prosemirror/blob/main/COOKBOOK.md#links-to-records-with-globalid) in the `prosemirror` gem's cookbook).

### Images

The image entry of the floating menu opens a panel to upload an image (with Uppy, stored by Shrine's `ModelImageUploader`). The panel also sets its alternative text, caption, credit and size (`default` or `larger`). The node keeps the uploaded file's data, so the image belongs to the content, not to a record.

### Prompts

The prompt entry inserts a call to action: a text, a link (a page or a URL, as for links), where it opens, and a title.

### Panels

The link, image and prompt panels are the `Admin::Panels::*` controllers that the installer generates (see [Controllers](/reference/controllers)).

## How content is stored

The editor saves a ProseMirror document in the form's field, as JSON, with Tiptap's camelCase type names:

```json
{
  "type": "doc",
  "content": [
    { "type": "heading", "attrs": { "level": 2 }, "content": [{ "type": "text", "text": "Opening hours" }] },
    {
      "type": "paragraph",
      "content": [
        { "type": "text", "text": "See the " },
        { "type": "text", "text": "map", "marks": [{ "type": "link", "attrs": { "href": "gid://app/Page/12" } }] }
      ]
    },
    { "type": "imageBlock", "attrs": { "file_data": "{\"id\":\"…\",\"storage\":\"store\"}", "alt": "The entrance", "text": "Main entrance", "credit": "Photo: A. Smith", "size": "larger" } }
  ]
}
```

* **Column:** Trek's models keep it in a `jsonb` `content` column. [Translatable](/reference/concerns/translatable) models hold one document per locale.
* **Cleaning:** before saving, [Contentable](/reference/concerns/contentable) removes what rendering skips: empty paragraphs, empty list items, and lists left empty. An empty document keeps one paragraph, and whatever the schema doesn't know is kept. The editor submits the content as a JSON string, and it's stored as a document. Empty paragraphs used for spacing are removed for good.
* **Typography:** [Formattable](/reference/concerns/formattable) works on documents, changing only their text nodes. Add `:content` to the generated `format_attributes Trek::TypographyFormatter, :title, :description` to apply French typography to it.
* **Search:** to find content by its words, search the text nodes rather than the JSON, or every document matches `paragraph`. `content_text` gives the plain text, and `Fragment.search` shows a query in SQL (see [Fragment](/reference/models/fragment)).

## Rendering on the site

A model with a `content` column includes [Contentable](/reference/concerns/contentable):

```slim
/ ERB or Slim
= @page.formatted_content
```

```ruby
# Phlex
render @page.content_document.to_phlex
```

For a [versioned](/reference/concerns/versionable) page, render its live version's content, `current_content`, which comes from a `PageVersion`: `Trek::Content::Document.parse(@page.current_version || @page, @page.current_content).to_html`.

`formatted_content` only reads the `content` column. To render another column, such as a book's `intro`, parse it with `Trek::Content::Document`. Its first argument is the record holding the content: when that content is invalid, the error reports name the record (`trek_content: "Book#3"`, see [Invalid content](#invalid-content)), so you know which one to fix.

```ruby
Trek::Content::Document.parse(book, book.intro).to_html
```

### What the HTML contains

* **Escaping:** HTML is escaped as it is built. Nothing a user types can open a tag.
* **Attributes** are typed: a value that doesn't fit, such as a heading level of 9, is replaced by its default, or removed.
* **URLs:** links and images only allow `http`, `https`, `mailto`, `tel` and relative URLs.
* **Links to pages** become the page's URL, through `url_for`. The records a document links to load together, with one query per model. A link to a deleted record is removed, and its text kept.
* **Empty nodes:** empty paragraphs, empty list items, and lists with only empty items render nothing ([Skip empty paragraphs](https://github.com/etaminstudio/prosemirror/blob/main/COOKBOOK.md#skip-empty-paragraphs)). Saving removes them from the stored content too ([cleaning](#how-content-is-stored)), but content saved before Trek did is only cleaned when it's saved again.
* **Images** render as a `<figure>` with a `data-size` attribute, with the caption and the credit in its `<figcaption>`.
* **Prompts** render as a link.
* **Unknown attributes,** such as heading ids added by the editor, are kept in the JSON but never rendered. To give headings anchors, see [Heading anchors](https://github.com/etaminstudio/prosemirror/blob/main/COOKBOOK.md#heading-anchors) in the `prosemirror` gem's cookbook.

### Invalid content

A document that doesn't fit the schema is fixed as it's read, and each problem is reported to `Rails.error` (`handled: true`):

* an attribute of the wrong type is converted when the meaning is clear, or replaced by its default, or its node or mark is removed (a link keeps its text);
* a node of an unknown type renders only its content;
* links to deleted records, reported as `Prosemirror::UnresolvedURL`.

The report's context names the record holding the content (`trek_content: "Page#12"`), which AppSignal shows as a tag, so a problem leads back to the page to fix.

## Customizing the rendering

`Trek::Content.schema` is Tiptap's schema (`Prosemirror::Schema.tiptap(case: :camel)`), with Trek's own node types:

| Node type | Role |
| --- | --- |
| `Trek::Content::Paragraph` | skips empty paragraphs |
| `Trek::Content::ListItem` | skips empty list items, with no content or only empty paragraphs |
| `Trek::Content::BulletList`, `OrderedList` | skip lists with only empty items |
| `Trek::Content::ImageBlock` | renders image blocks as a `<figure>`: the image, its caption and credit |
| `Trek::Content::PromptsBlock` | renders prompt blocks as a link |

Node and mark types are [Phlex](https://www.phlex.fun) components with [Literal](https://literal.fun) properties (see the [`prosemirror` README](https://github.com/etaminstudio/prosemirror#custom-nodes-and-marks)). To change how one renders, subclass it, and put it in the schema from an initializer. The cookbook's [Render your own components](https://github.com/etaminstudio/prosemirror/blob/main/COOKBOOK.md#render-your-own-components) shows the pattern.

### Images with the app's component

For example, images at the app's own sizes:

```ruby
# app/content/image_block.rb
class ImageBlock < Trek::Content::ImageBlock
  def view_template
    return unless image

    render Components::Figure.new(
      src: image.derivation_url(:thumbnail, 930, 1200),
      alt: @alt,
      caption: @text,
      credit: @credit
    )
  end
end
```

```ruby
# config/initializers/trek.rb
Rails.application.config.to_prepare do
  Trek::Content.schema = Trek::Content.default_schema.with(ImageBlock)
end
```

* **Registering:** `with` replaces the type of the same name, and keeps the others.
* **Reloading:** the default schema is rebuilt after code reloading, so `to_prepare` sees the reloaded classes in development.
* **Inside a node type:**
  * the node's attributes are instance variables (`@alt`, `@text`);
  * `image` is the uploaded file, or `nil` when the data is missing or invalid;
  * `safe_url(url)` checks a URL against the allowed schemes.

### Prompts as the app's buttons

```ruby
class PromptsBlock < Trek::Content::PromptsBlock
  def view_template
    href = @href.present? && safe_url(@href)
    return if !href || @text.blank?

    render Components::Button.new(href:, target: @target, title: @title) { @text }
  end
end
```

Register it the same way: `Trek::Content.default_schema.with(ImageBlock, PromptsBlock)`.

### Node types of the app's own

A node of a type the schema doesn't know renders only its content, and is reported. To render it, add a class of that name to the schema (`Callout` for `callout`):

```ruby
class Callout < Prosemirror::NodeType
  group :block
  content "block+"

  prop :tone, _Union?("info", "warning")

  def view_template(&) = aside(data_tone: @tone, &)
end
```

Trek's editor has a fixed set of Tiptap extensions, so it can't insert such a node yet. It renders nodes that come from elsewhere: imported content, or JSON written by a script.

## Importing content

The `prosemirror` gem builds documents from HTML (with the `herb` gem), Markdown (with `commonmarker`) or plain text, using the schema's parse rules. To fill pages from an older site:

```ruby
document = Prosemirror.from_html(old_html, schema: Trek::Content.schema)
page.update!(content: document.to_h)
```

`to_h` gives the JSON the editor reads, with invalid parts fixed. See the [`prosemirror` README](https://github.com/etaminstudio/prosemirror#html-import) for what each import keeps.
