elek.io stores rich body content as a structured mdast öffnet in neuem Tab tree (markdown abstract syntax tree), not as a markdown source string. This document explains why and how to use it.
For the field reference, see fields.md.
The data shape
A markdown field on a Collection or Component produces an MdAstValue on the Entry. Its content is keyed per language:
entry.values.body.content.en === { type: 'root', children: [ { type: 'heading', depth: 2, children: [{ type: 'text', value: 'Hello' }], }, { type: 'paragraph', children: [ { type: 'text', value: 'See ' }, { type: 'entryReference', collectionId: '...', entryId: '...', children: [{ type: 'text', value: 'tutorial' }], }, { type: 'text', value: '.' }, ], }, ], };Every node has a type field. Nested content lives in children. Empty markdown values are canonically null per language - not an empty tree.
Why a tree and not a markdown string?
- References are first-class:
entryReferenceandassetReferencenodes carry UUIDs (andcollectionIdfor Entry refs), not opaque URLs. Consumers can resolve them to whatever URL structure or component they want at render time. - Per-field validation works directly on the tree: the
featuresallowlist on each markdown field controls which node types are accepted at write time. Disallowed nodes are rejected by Core’s schema layer - they never reach disk.
Rendering markdown content in Astro
Core ships mdastRender from @elek-io/core/astro. It walks the mdast tree, dispatches each node type to a renderer, and returns an Astro JSX element ready to interpolate.
Three things to know before writing a renderer:
- Astro’s
render()does not apply to an elek.io Entry. It hands back the rendered HTML an entry carries with it, and the loaders store none, because a finished HTML string cannot keep theentryReferenceandassetReferencenodes a page needs the UUIDs from.- Calling it anyway is not an error and produces nothing:
<Content />renders empty,headingsis[]andremarkPluginFrontmatteris{}, with no warning anywhere. Body content arrives onentry.dataas a tree instead, andmdastRenderis what turns it into markup.
- Calling it anyway is not an error and produces nothing:
- Three node types require an explicit override,
html,assetReferenceandentryReference. The other 21 have semantic HTML defaults that render every field their node carries, except the five in what a default leaves to you.- The type system surfaces the required keys because content editors can flip
features.rawHtml,features.assetReferencesandfeatures.entryReferences, or extendofCollections, on any field at any time. - Forcing a documented decision per key stops the renderer from silently no-opping when a new node type appears. Choosing
() => nullis a valid decision and documents “render nothing if this ever appears.”
- The type system surfaces the required keys because content editors can flip
- JSX belongs in the template, not in the frontmatter. That is a property of Astro rather than of
mdastRender. The---fence of an.astrofile is TypeScript, not TSX, so a handler written as(node) => <em>{node.alt}</em>up there is a parse error.- Write the renderers object inline at the
mdastRendercall in the template, as below. WhatmdastRenderreturns is an ordinary Astro template result, so it renders wherever you put it, including inside a component you pass it to.
- Write the renderers object inline at the
---import { getCollection } from 'astro:content';import { Image } from 'astro:assets';import { elekSlugPaths, mdastRender } from '@elek-io/core/astro';import DOMPurify from 'isomorphic-dompurify';
export async function getStaticPaths() { const posts = await getCollection('posts'); return elekSlugPaths(posts, { slugField: 'slug', language: 'en' });}
// Every path carries the language it was built forconst { entry: post, language } = Astro.props;
// Sync lookup maps. mdastRender's handlers run synchronously, so build// them once per page from the already-awaited collections.const assets = await getCollection('assets');const posts = await getCollection('posts');const assetById = new Map(assets.map((a) => [a.id, a.data]));// References carry UUIDs, the page URLs are slugs, so the renderer// needs the same mapping the routes were built fromconst slugById = new Map(posts.map((p) => [p.id, p.data.slug[language]]));
// An optional field is null in a language nobody filled inconst body = post.data.body[language];---<article> {body !== null && mdastRender(body, { // Required - sign off on each, even if your fields don't currently use them. html: (node) => <Fragment set:html={DOMPurify.sanitize(node.value)} />, assetReference: (node) => { const asset = assetById.get(node.assetId); // src is Astro's own image for image Assets, href the public URL // for every other Asset, and each is null for the other kind return asset?.src ? ( <Image src={asset.src} alt={node.alt} /> ) : ( <a href={asset?.href ?? '#'}>{node.alt}</a> ); }, entryReference: (node, children) => { const slug = slugById.get(node.entryId); // No slug means the referenced Entry has no page in this language return slug ? ( <a href={`/posts/${slug}`}>{children}</a> ) : ( <Fragment>{children}</Fragment> ); }, })}</article>The object is checked against MdastAstroRenderers by the call itself, so a missing required key or a mistyped node property is an error without annotating it.
entryReference nodes carry the referenced Entry’s UUID, which is also its store id, so getEntry('posts', node.entryId) resolves a reference from anywhere without a map. The map above exists only to turn that UUID into the same URL the routes were built from.
A Collection without a slug field routes by UUID instead, with a getStaticPaths that returns:
posts.map((post) => ({ params: { id: post.id }, props: { post } }));The three required handlers are ~20 lines for the typical case. Without the helper the equivalent hand-rolled switch (see the fallback recipe below) takes ~90, and it grows with every node type Core adds.
Defaults and what you’ll commonly override
The defaults emit plain semantic HTML - no class names, no rel/target, no slug anchors, no syntax highlighting.
A handler’s shape is (node, children, context) => ... for a parent and (node, context) => ... for a leaf, and one that does not need the context leaves the parameter out. Each snippet below goes into the renderers object at the mdastRender call in the template, next to the three required handlers. Common override patterns:
Slug-id anchors on headings (table of contents). extractText is exported from @elek-io/core and returns the concatenated plain text of an mdast node.
heading: (node, children) => { const id = slugify(extractText(node)); const Tag = `h${node.depth}` as const; return <Tag id={id}>{children}</Tag>;},Syntax highlighting on code blocks. Most highlighters (Prism / Shiki / Highlight.js) look for class="language-${lang}" on the <code> element. The default ships without any class - override to wire the format your highlighter expects:
code: (node) => ( <pre> <code class={node.lang ? `language-${node.lang}` : undefined}> {node.value} </code> </pre>),External-link policy on link. The default emits a plain <a> because the same tree mixes internal site URLs (/path, #section, ./sibling) with external https:// ones - a single auto policy would be wrong for half. Override when you know your tree’s URL mix:
link: (node, children) => { const isExternal = /^https?:\/\//.test(node.url); return ( <a href={node.url} title={node.title ?? undefined} rel={isExternal ? 'noopener noreferrer' : undefined} target={isExternal ? '_blank' : undefined} > {children} </a> );},Optimized <Image> for the image node. The default emits a plain <img> because Astro’s <Image> for remote URLs needs image.remotePatterns config the consumer may not have, and dimensions the mdast image node doesn’t carry. If you control the external image hosts, override to opt into optimization:
image: (node) => ( <Image src={node.url} alt={node.alt} inferSize />),Collected footnotes section at the bottom. The default renders each footnoteDefinition inline in tree order, matching mdast structure. Most articles want a single collected footnotes section at the end.
Declare the accumulator in the frontmatter, override the handler to fill it and return null, then render your section after the article. Astro evaluates template expressions in order, so the list is complete by the time the second one is reached:
---import { mdastRender } from '@elek-io/core/astro';
const footnotes: astroHTML.JSX.Element[] = [];---<article> {body !== null && mdastRender(body, { // ... footnoteDefinition: (node, children) => { footnotes.push(<li id={`fn-${node.identifier}`}>{children}</li>); return null; }, })}</article>{footnotes.length > 0 && ( <section class="footnotes"> <ol>{footnotes}</ol> </section>)}An override replaces the default whole, so the way back to the reference is yours to re-add here, as is the fn- id if you want another scheme.
Custom top-level wrap via root. The default wraps the rendered blocks in a Fragment so <article>{rendered}</article> works at the call site. You can override root to do the wrapping at the renderer level instead - useful when the renderer is responsible for the whole article shell:
root: (_, children) => <article class="prose">{children}</article>,What a default leaves to you
Every default renders every field of its node except these five. Nothing else of a node is dropped, so an ordered list keeps the number it counts from and a task item keeps its checked state without you writing a handler.
| Field | What the default does with it | Override to get |
|---|---|---|
code.lang | nothing, <pre><code> carries no class | the class name your highlighter reads, see code above |
code.meta | nothing, it is the option string of a highlighter Core does not ship | whatever your highlighter makes of it |
list.spread | nothing, see the tight list below | a tight item, from a paragraph override that reads it off its context |
listItem.spread | the same | the same |
footnoteDefinition.label | nothing, the authored spelling of the identifier shows on the reference instead | it on the note as well |
Where a default cannot reach
A parent’s default is handed its children already rendered, so it cannot reach inside them, and one astroDefaults object serves every render. Five things follow, and the defaults ship them visibly rather than working around them.
- A task item’s checkbox precedes its text instead of opening the item’s first paragraph, which is where remark puts it. The paragraph arrives already rendered, so it takes a
paragraphoverride to put the box inside it.li > p { display: inline }is the CSS that puts box and text on one line, and it keeps the list marker, which the default leaves in place by adding no class name. - A task item’s checkbox is named with the item’s own text. A
<label>cannot wrap blocks that arrive already rendered, so the name goes on anaria-labelinstead and is read twice, once as the box’s name and once as the text. A list directly inside the item is left out, whether it holds tasks or plain items, and an item whose text is an image or only whitespace gets no attribute. - A note’s way back follows its blocks instead of ending its last paragraph, for the same reason.
- A tight list item keeps its paragraph, so
<li><p>text</p></li>rather than<li>text</li>. It is valid and semantic, and it is one<p>of margin away from what other renderers emit. - Footnote ids are built from the identifier alone. Two trees on one page claim the same
fn-andfnref-ids, and an anchor then lands on whichever rendered first. A note referenced twice repeats its reference id, so its way back lands on the first reference. A default cannot take a prefix, because oneastroDefaultsobject serves every render. Give both handlers one of your own:
footnoteReference: (node) => ( <sup id={`${prefix}fnref-${node.identifier}`}> <a href={`#${prefix}fn-${node.identifier}`}> {node.label ?? node.identifier} </a> </sup>),footnoteDefinition: (node, children) => ( <div id={`${prefix}fn-${node.identifier}`}> {children} <a href={`#${prefix}fnref-${node.identifier}`} aria-label="Back to content"> ↩ </a> </div>),The back-link’s aria-label is English, so a site in another language overrides footnoteDefinition for that too.
Where a node sits
An mdast node does not carry its own position, and some of what a handler has to decide is only knowable from it. A table cell is the clear case: nothing on the cell says whether it heads a column, and the alignment of its column sits on the table.
So every handler receives an MdastNodeContext as its last argument, exported from @elek-io/core:
| Field | Holds |
|---|---|
index | the node’s position among its parent’s children, 0 for the root |
ancestors | every ancestor, the root first and the direct parent last, so ancestors.at(-1) is the parent |
Two things follow from that shape:
- There is no
parentfield, becauseancestors.at(-1)already is one. - The
ancestorsarray is shared by a node’s siblings. Read it, never write to it.
A handler that does not need it declares fewer parameters, which is why every renderers object written before the context existed still compiles.
Tables
A table’s header is its first row, which is all mdast records. The defaults turn that into the markup a screen reader needs:
<table> <thead><tr><th scope="col">Field</th><th scope="col">Type</th></tr></thead> <tbody><tr><td>slug</td><td>string</td></tr></tbody></table>- The first row’s cells are
<th scope="col">, every row below holds<td>, and an empty header cell stays a header cell. <thead>and<tbody>wrap those rows, and neither is emitted empty. A table of one row is a header with no body.- A column’s alignment arrives as
style="text-align:left|center|right"on every cell of that column, header and body alike. - A column the markdown left unaligned gets no style at all, instead of a hard-coded left, so its body cells keep the start edge of the reading direction. Its header cell is centered, because that is what a user agent does with a
<th>of its own accord, and only your CSS can change that.
table.align sits on the table and the cell is what renders it, so two helpers exported from @elek-io/core answer a cell’s two questions from its context. Both are what the defaults use:
import { isTableHeaderCell, tableCellAlign } from '@elek-io/core';
tableCell: (_, children, context) => { const align = tableCellAlign(context); // 'left' | 'center' | 'right' | null const style = align ? { textAlign: align } : undefined; return isTableHeaderCell(context) ? ( <th scope="col" style={style}>{children}</th> ) : ( <td style={style}>{children}</td> );},Both answer from a cell’s context, so isTableHeaderCell is false for a row. A tableRow override that asks whether it is the header row reads context.index === 0.
A table has no caption, because mdast has no caption node, so there is nothing for Core to render one from. That matters when you put a wide table in a horizontally scrolling box: the box is a scrollable region, and a region needs a name and keyboard reach of its own. Name it from whatever your page knows, the heading above the table for example, rather than expecting one in the tree.
Reusable per-site renderer
For sites that render markdown in multiple places, write the overrides once in a small Astro component. The per-site rendering policy then lives in one file, and every page passes a tree to it:
---import { mdastRender } from '@elek-io/core/astro';import type { MdAstRoot } from '@elek-io/core';
interface Props { root: MdAstRoot | null;}
const { root } = Astro.props;---{root !== null && mdastRender(root, { html: (node) => /* ... */, assetReference: (node) => /* ... */, entryReference: (node, children) => /* ... */, // any standard-node overrides your site applies everywhere })}Use it from any page:
<article><MdastContent root={post.data.body.en} /></article>The overrides stay in the component’s template, for the same reason as everywhere else: the frontmatter above them is TypeScript. A page that needs one more override for one article can always call mdastRender itself instead.
Core does not ship this component itself - it would be a thin one-file wrapper, project-specific (your overrides, your styling), and shipping a generic default would freeze opinions you should own.
Fallback recipe for frameworks without an official wrapper
If you’re targeting a framework Core doesn’t ship a wrapper for yet (React, Vue, Solid, SvelteKit and so on), use the framework-agnostic primitive directly. mdastRender and MdastRenderersBase<T> are exported from @elek-io/core.
The primitive is the same typed fold the Astro binding wraps. T is your framework’s element type, and you supply one handler per node type. Parents receive their already-rendered children, leaves receive just the node, and both receive the context last.
The example below uses React (T = ReactNode). For Vue bind T = VNode, for Solid T = JSX.Element. Only the element factory changes, the structure stays identical.
import { extractText, isTableHeaderCell, mdastRender, tableCellAlign, type MdAstListItem, type MdastRenderersBase,} from '@elek-io/core';import type { ReactNode } from 'react';
// A task item's name is the text of its own blocks, a list directly inside// the item left out, whether its items are tasks naming a checkbox of their// own or plain items. Trimmed, so an item whose text is only whitespace gets// no name rather than a name of spaces.function taskItemName(node: MdAstListItem): string | undefined { const ownBlocks = { ...node, children: node.children.filter((child) => child.type !== 'list'), }; return extractText(ownBlocks).trim() || undefined;}
const renderers: MdastRenderersBase<ReactNode> = { root: (_, children) => <>{children}</>, paragraph: (_, children) => <p>{children}</p>, heading: (node, children) => { const Tag = `h${node.depth}` as const; return <Tag>{children}</Tag>; }, blockquote: (_, children) => <blockquote>{children}</blockquote>, list: (node, children) => node.ordered ? ( // A start of 1 is what an <ol> counts from anyway <ol start={node.start === 1 ? undefined : (node.start ?? undefined)}> {children} </ol> ) : ( <ul>{children}</ul> ), listItem: (node, children) => // A checked of null is an ordinary item, true and false are a task node.checked === null ? ( <li>{children}</li> ) : ( <li> <input type="checkbox" aria-label={taskItemName(node)} checked={node.checked} disabled readOnly /> {children} </li> ), code: (node) => ( <pre> <code>{node.value}</code> </pre> ), thematicBreak: () => <hr />, table: (_, children) => ( <table> {children.length > 0 && <thead>{children[0]}</thead>} {children.length > 1 && <tbody>{children.slice(1)}</tbody>} </table> ), tableRow: (_, children) => <tr>{children}</tr>, tableCell: (_, children, context) => { const align = tableCellAlign(context); const style = align ? { textAlign: align } : undefined; return isTableHeaderCell(context) ? ( <th scope="col" style={style}> {children} </th> ) : ( <td style={style}>{children}</td> ); }, footnoteDefinition: (node, children) => ( <div id={`fn-${node.identifier}`}> {children} <a href={`#fnref-${node.identifier}`} aria-label="Back to content"> ↩ </a> </div> ), text: (node) => node.value, inlineCode: (node) => <code>{node.value}</code>, emphasis: (_, children) => <em>{children}</em>, strong: (_, children) => <strong>{children}</strong>, delete: (_, children) => <del>{children}</del>, link: (node, children) => ( <a href={node.url} title={node.title ?? undefined}> {children} </a> ), image: (node) => ( <img src={node.url} alt={node.alt} title={node.title ?? undefined} /> ), break: () => <br />, footnoteReference: (node) => ( <sup id={`fnref-${node.identifier}`}> <a href={`#fn-${node.identifier}`}>{node.label ?? node.identifier}</a> </sup> ), // You decide these three. See the security note on rawHtml below. html: (node) => ( <span dangerouslySetInnerHTML={{ __html: sanitize(node.value) }} /> ), assetReference: (node) => ( <img src={`/assets/${node.assetId}`} alt={node.alt} /> ), entryReference: (node, children) => ( <a href={`/${node.collectionId}/${node.entryId}`}>{children}</a> ),};
const root = entry.values.body.content.en;const rendered = root !== null ? mdastRender(root, renderers) : null;MdastRenderersBase<T> requires a handler for every node type, so a forgotten one is a compile error rather than a silent gap. The walk switch is exhaustiveness-checked the same way, so adding a node type in a future Core release surfaces as a type error in your renderers until you handle it.
If you’re building a reusable binding (what @elek-io/core/astro is) rather than a one-off, layer defaults on top so your consumers override only what they want. Mirror astroDefaults:
- Build a
Pick<MdastRenderersBase<T>, DefaultedRendererKey>of safe defaults. - Expose
FrameworkRenderers<T>as the consumer-facing override shape. - Merge
{ ...defaults, ...overrides }before callingmdastRender.
REQUIRED_RENDERER_KEYS names the three keys (html, assetReference, entryReference) that stay required because no default is safe for them. All four are exported from @elek-io/core.
When you need plain text (no markdown)
If you only need plain text (e.g. for fulltext search, excerpts or heading slugs), Core ships extractText from @elek-io/core. It walks any mdast node and concatenates text, inlineCode and code values in document order. Raw html and image/reference alt text are skipped. Going through markdown serialization is overkill for that case.
Block-level siblings (paragraphs, list items, table rows and cells) are joined with a separator that defaults to a single space, so a heading followed by a paragraph reads as Title Body rather than TitleBody. Inline content keeps its own spacing, so a sentence is never broken up.
Pass a second argument to change the separator, for example '\n' when producing a plain-text document that should keep block breaks.
import { extractText } from '@elek-io/core';
const plain = body !== null ? extractText(body) : '';const asLines = body !== null ? extractText(body, '\n') : '';Security note: rawHtml
If a markdown field’s features.rawHtml: true, authors can embed arbitrary HTML in entries - including <script> tags. Core does not sanitize. Your renderer MUST sanitize at render time:
import DOMPurify from 'isomorphic-dompurify';
case 'html': return <Fragment set:html={DOMPurify.sanitize(node.value)} />;This is non-optional when rawHtml is enabled. Partial sanitization in Core would create false confidence - consumers might stop sanitizing themselves, and any bypass becomes a production XSS incident. Putting the responsibility entirely on the rendering layer (where the output context is known: HTML doc? JSX? plain text?) is the correct layering.
Security note: the URL a link or image may hold
Core’s schema rejects exotic URL schemes on link and image nodes. javascript:, data:, file:, vbscript: and protocol-relative URLs (//host) cannot be persisted.
link.urlacceptshttp,https,mailtoandtel, plus site-relative (/path), sibling and parent-relative (./,../) and fragment-only (#section) forms.image.urlaccepts only absolutehttpandhttps. UseassetReferencefor internal images.
A relative link.url is resolved before it is accepted, and has to keep the origin it is relative to. /\evil.com reads as a path and resolves to https://evil.com/, and so does the same shape holding a tab, a line feed or a carriage return.
Renderers should still apply their own per-context policy on top of this. Examples: a closed-corpus site might want to allow only same-origin links. An email-rendering pipeline might want to strip tel: links. The schema check eliminates the most dangerous classes. The rendering layer owns the rest.
Out of scope in Core
- HTML rendering / sanitization: not provided. Consumers walk the tree and own any sanitization (especially for
rawHtml-enabled fields). - Markdown serialization itself: not provided.
assetReferenceandentryReferenceare custom mdast nodes with no standard markdown representation, and Core does not know where referenced files end up in the output.- Every consumer resolves elek.io-specific reference nodes into standard mdast how they see fit, then composes with
mdast-util-to-markdown(and any extensions they want) for the string conversion.
- Every consumer resolves elek.io-specific reference nodes into standard mdast how they see fit, then composes with
- Markdown-to-mdast parsing: not provided. Same reasoning as above.
- Reference integrity on delete: deleting an Asset, Entry or Collection that is still referenced is blocked with a
Conflict, and create / update reject a reference to content that does not exist. Projects are managed only through Core or elek.io Desktop, so these gates cover every supported change.- See
references.mdfor the full model and the one case, a sync that merges concurrent changes, they cannot fully prevent.
- See
- Tree depth limit: enforced at 100 levels of nesting (matches
markdown-it’smaxNestingdefault). Trees deeper than that are rejected at write time. Renderers don’t need their own bound, but it remains good hygiene for any consumer that processes trees from outside Core.
See also
fields.md- themarkdownfield definition and itsfeaturesallowlistreferences.md- howassetReference/entryReferencefit the wider reference model and its integrity rulesasset-management.md- the Assets thatassetReferencenodes point tousage.md- the Astro integration in contextconcepts.md- Values and the overall data model