Manual Recipes Markdown Editor

Markdown Editor

elements man recipes/render-markdown Read as markdown

An in-page markdown editor with a live preview. As the user types in a <textarea>, the preview re-renders on every keystroke through the template's reactive binding. The parser is marked, installed via elements install marked; the recipe is the wiring around it, not the parser itself.

Install

elements install marked

elements install adds the dependency to package.json and the project server rebuilds against it on the next save.

Page setup

elements create page markdown

The page is a single-template editor: textarea on the left, rendered preview on the right. No rpc, no database. marked.parse runs in the browser on each keystroke.

app/pages/markdown/index.ts:

import markdown from "./template";

export default function route(req, res) {
  return new markdown();
}

The route does nothing but render. All the interactivity is in the template.

app/pages/markdown/template.html:

import "./style.css";
import { raw } from "@elements/app";
import { marked } from "marked";

const INITIAL = `# hello, markdown

write *some* **bold** text. add a [link](https://elements.dev) or two.

- bullet one
- bullet two
- bullet three

\`\`\`
let x = 42;
console.log(x);
\`\`\`
`;

<html class="markdown"
      (private text: { value: string } = { value: INITIAL })>
  <h1>markdown editor</h1>

  <div class="split">
    <textarea class="source" value={text.value} rows="20"/>
    <div class="preview">{raw(marked.parse(text.value) as string)}</div>
  </div>
</html>

text.value is the textarea's bound value. Each keystroke updates text.value, the binding re-runs, marked.parse(...) renders the new markdown, and {raw(...)} writes the resulting HTML into the preview pane. marked.parse is sync by default and returns string; the cast pins the return type for TypeScript.

{raw(html)} is the elements helper for rendering an HTML string without escaping. The trust boundary is marked: by default it escapes HTML in the input, so its output is safe to pass to raw(). If you configure marked to allow raw HTML pass-through in the source, sanitize the output with DOMPurify (or equivalent) before raw().

app/pages/markdown/style.css:

@import "#app/shared/styles/page.css";

.markdown .split {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-6);
  align-items: stretch;
}

.markdown .source {
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  padding: var(--space-4);
}

.markdown .preview {
  padding: var(--space-4);
  border: 1px solid var(--rule);
  border-radius: var(--radius-md);
  background: var(--bg);
  overflow-y: auto;
}

.markdown .preview h1,
.markdown .preview h2,
.markdown .preview h3 {
  margin-top: var(--space-6);
}

.markdown .preview h1:first-child,
.markdown .preview h2:first-child {
  margin-top: 0;
}

.markdown .preview pre {
  background: var(--bg-soft);
  padding: var(--space-3);
  border-radius: var(--radius-md);
  overflow-x: auto;
}

.markdown .preview code {
  font-family: var(--font-mono);
  font-size: var(--text-sm);
}

The preview is a side-by-side panel matching the textarea height. Styles pick up the design-system tokens (--bg, --bg-soft, --rule, --space-*) so the preview matches the rest of the app's look.

Routes

Register the page in index.ts:

import markdown from "#app/pages/markdown";

// ...
app.route("/markdown", markdown);

Notes

  • Browser-side rendering. marked.parse runs on every keystroke. For typical document sizes (under ~50 KB) it finishes inside a frame; the preview keeps up with typing. For very large documents, debounce the binding by piping text.value through a setTimeout-driven preview.value and reading preview.value in the raw(...) call.
  • Server-side rendering. For static content where the user doesn't edit (a blog post, a docs page), render in the route handler and pass the HTML string to the template: let html = marked.parse(body) as string; return new page({ html });. The first paint shows the rendered output and the parser never runs on the browser. See elements man recipes markdown-blog for that shape.
  • Sanitization. Default marked escapes HTML in the input, so a user typing <script> into the textarea ends up as literal text in the preview, not as executed JavaScript. If you turn that off (rare, but marked supports raw HTML), run the output through DOMPurify before raw(...).
  • Saving the document. The recipe is a pure editor; the markdown is held in template state and lost on navigation. Persist by adding a save @rpc that writes text.value to a database row, and call it from a "save" button or on blur.
  • Extending the parser. marked supports custom renderers, code highlighting integration, and extensions for non-standard syntax (callouts, math, mermaid). All configured through marked.use(...) once on app startup; the recipe's binding stays the same.