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, an editor framework on top of ProseMirror. 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 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:
- Editing: using the editor in admin forms, and its panels.
- Storage: how content is stored, as JSON in a
jsonbcolumn. - Rendering: rendering on the site, through
Trek::Content.schema, and customizing it.
Using the editor
Add it to an admin form with f.content_editor:
= 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:
= f.content_editor :intro, blocks: false, floating: falseTiptap'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 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).
How content is stored
The editor saves a ProseMirror document in the form's field, as JSON, with Tiptap's camelCase type names:
{
"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
jsonbcontentcolumn. Translatable models hold one document per locale. - Cleaning: before saving, 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 works on documents, changing only their text nodes. Add
:contentto the generatedformat_attributes Trek::TypographyFormatter, :title, :descriptionto 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_textgives the plain text, andFragment.searchshows a query in SQL (see Fragment).
Rendering on the site
A model with a content column includes Contentable:
/ ERB or Slim
= @page.formatted_content# Phlex
render @page.content_document.to_phlexFor a versioned 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), so you know which one to fix.
Trek::Content::Document.parse(book, book.intro).to_htmlWhat 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,teland 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). Saving removes them from the stored content too (cleaning), but content saved before Trek did is only cleaned when it's saved again.
- Images render as a
<figure>with adata-sizeattribute, 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 in the
prosemirrorgem'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 components with Literal properties (see the prosemirror README). To change how one renders, subclass it, and put it in the schema from an initializer. The cookbook's Render your own components shows the pattern.
Images with the app's component
For example, images at the app's own sizes:
# 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# config/initializers/trek.rb
Rails.application.config.to_prepare do
Trek::Content.schema = Trek::Content.default_schema.with(ImageBlock)
end- Registering:
withreplaces the type of the same name, and keeps the others. - Reloading: the default schema is rebuilt after code reloading, so
to_preparesees the reloaded classes in development. - Inside a node type:
- the node's attributes are instance variables (
@alt,@text); imageis the uploaded file, ornilwhen the data is missing or invalid;safe_url(url)checks a URL against the allowed schemes.
- the node's attributes are instance variables (
Prompts as the app's buttons
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
endRegister 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):
class Callout < Prosemirror::NodeType
group :block
content "block+"
prop :tone, _Union?("info", "warning")
def view_template(&) = aside(data_tone: @tone, &)
endTrek'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:
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 for what each import keeps.