Skip to content

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:

  1. Editing: using the editor in admin forms, and its panels.
  2. Storage: how content is stored, as JSON in a jsonb column.
  3. 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:

slim
= form_with model: [:admin, @book] do |f|
  = f.group :content
    = f.content_editor :content
OptionDefaultEffect
nodestruethe toolbar's text buttons: bold, italic, link, clear formatting, line break
blockstruethe toolbar's block buttons: paragraph, headings 2 to 4, bullet list, quote, horizontal rule
floatingtruethe menu on an empty line, inserting an image or a prompt
autofocusfalseputs 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.

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:

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 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 :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).

Rendering on the site ​

A model with a content column includes Contentable:

slim
/ ERB or Slim
= @page.formatted_content
ruby
# Phlex
render @page.content_document.to_phlex

For 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.

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). 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 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 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 typeRole
Trek::Content::Paragraphskips empty paragraphs
Trek::Content::ListItemskips empty list items, with no content or only empty paragraphs
Trek::Content::BulletList, OrderedListskip lists with only empty items
Trek::Content::ImageBlockrenders image blocks as a <figure>: the image, its caption and credit
Trek::Content::PromptsBlockrenders 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:

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 for what each import keeps.

Released under the MIT License.