Markdown Editor
elements man recipes/render-markdown Read as markdownAn 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.parseruns 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 pipingtext.valuethrough asetTimeout-drivenpreview.valueand readingpreview.valuein theraw(...)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. Seeelements man recipes markdown-blogfor that shape. - Sanitization. Default
markedescapes 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, butmarkedsupports raw HTML), run the output throughDOMPurifybeforeraw(...). - 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@rpcthat writestext.valueto a database row, and call it from a "save" button or on blur. - Extending the parser.
markedsupports custom renderers, code highlighting integration, and extensions for non-standard syntax (callouts, math, mermaid). All configured throughmarked.use(...)once on app startup; the recipe's binding stays the same.