Skip to content

Document Format

Ignacio Ferro Picón edited this page Oct 16, 2026 · 10 revisions

Document Format

The Markdown subset Postext parses: frontmatter, headings, lists and code blocks, what it ignores, and the rules for authoring source documents

Generated from docs/document-format-en.mdx (updated 2026-10-16). Read it on postext.dev, where it is also available in Spanish, Catalan, Portuguese, French, German, Italian, Chinese, Japanese and Arabic. To change this page, edit the MDX and run pnpm wiki in apps/web.

In short

This page explains how to write a text that Postext can lay out. You write in Markdown, a simple way of writing where a few symbols mark headings, lists, bold words and links. This page covers the lines at the top of a file, headings, lists, code and the marks Postext ignores. Four more pages cover blocks such as boxes and columns, marks inside a line, notes and references, and pictures and tables. The page ends with advice on writing text that is easy to read.

Postext reads a deliberately small markdown dialect.

The parser ships as a hand-written tokenizer — not a full CommonMark implementation — so the source format is narrow and predictable. The intent is twofold: keep the engine small and fast, and make documents trivially portable between Postext and any other CommonMark reader (Obsidian, Pandoc, VS Code…). Anything not listed on this page is either treated as plain text or removed from the inline stream.

If you are building a document programmatically, the parseMarkdown function (see Configuration › Parsing) gives you the exact block structure the layout engine consumes.

Frontmatter

A document may begin with an optional YAML frontmatter block fenced by --- markers:

---
title: Chapter One
author: Jane Doe
publishDate: 2026-04-15
---

# Chapter One

The story begins here…

Call extractFrontmatter(source) to split the frontmatter from the body. The parsed metadata object is returned alongside the remaining markdown and the character offset where the body begins — useful if you need to map errors or cursor positions back to the original source.

Frontmatter is parsed with gray-matter, so any shape of YAML is accepted. Postext itself only looks at title, subtitle, author, and publishDate; additional keys are preserved on PostextContent.metadata and are yours to use.

A block that is not valid YAML — an unclosed quote, text after a quoted value — does not stop the layout: extractFrontmatter returns no metadata, the text after the closing --- as the body, and the parser's reason in error. The document is set without its metadata, and the build lists an invalidFrontmatter entry in contentWarnings (the sandbox shows it as Invalid front matter).

YAML gives its values types: 1984 is a number, 2026-04-15 a date, [Ana Gil, Luis Paz] a list. Postext prints each of those four fields as text whatever its type:

  • a number in its plain decimal form, and a boolean as true or false (title: 1984 prints 1984). YAML reads a number as a value, not digit by digit: 1.50 prints 1.5, 017 (octal) 15, 1:30 (base 60) 90;
  • a date as its calendar day, written out in the document's language — the locale setting, else the hyphenation locale: publishDate: 2026-04-15 prints April 15, 2026 in English, 15 de abril de 2026 in Spanish, 15. April 2026 in German. Arabic dates use the Gregorian calendar unless the tag names another (ar-u-ca-islamic prints the Hijri date), with the digits the tag implies (ar-EG: ١٥ أبريل ٢٠٢٦; ar-u-nu-latn: 15 أبريل 2026). A date with a time of day prints the day of its UTC time and drops the time: 2026-09-24T23:30:00-05:00 is 04:30 UTC on the 25th and prints September 25, 2026;
  • a list as its items joined by commas (author: [Ana Gil, Luis Paz] prints Ana Gil, Luis Paz).

Quote a value to print it exactly as written — a number that must keep its digits, a date that must keep its day: publishDate: "15/04/2026", title: "1984". The text reaches doc.metadata, the placeholders ({title}, {publishDate}…) and the PDF title and author. One of the four fields with no text form — a nested map, an empty title: — is left out of doc.metadata. extractFrontmatter still returns the values as YAML typed them; metadataText(value, locale) gives the text Postext prints for one.

Block constructs

Postext recognises seven prose block types and code listings, plus the directives and resource embeds covered later on this page. Blocks are always terminated by a blank line or by the start of another block.

Figure: Prose block constructs at a glance The seven prose block types Postext recognises: heading, paragraph, blockquote, unordered list, ordered list, task list, and display math, each with its markdown syntax. Directives and resource embeds are covered separately.

Every prose block type and its markdown entry point.

Construct Syntax Notes
Heading # Title … ###### H6 One to six # characters followed by a space and the heading text. Levels 1–6 map directly to the headings.levels config.
Paragraph Plain text over one or more lines Consecutive non-blank, non-special lines are joined with a single space and emitted as one paragraph. Between two Chinese or Japanese characters (ideographs, kana, fullwidth punctuation, and curly quotes, dashes or an ellipsis beside them) the line end is dropped instead, as CSS does, so a Chinese paragraph may be wrapped anywhere in the source. Between two such marks (“你好”⏎“再见”, 他说……⏎“好”) the characters past them decide: the line end goes when one of them is Chinese or Japanese, and “hello”⏎“bye” keeps its space. Korean keeps its space. A line break typed in the source is not kept, unless the line ends with a backslash (a forced line break, see Line breaks); a blank line starts a new paragraph. A tab typed in the text is a word space too, unless the paragraph's style sets tab stops; :tab is always a tab (see Tabs and tab stops). A poem, whose lines must stay as written, goes in a :::verse block.
Blockquote > quoted text Every line of the quote must start with > (one optional space after). Consecutive quote lines merge into a single blockquote block, joined like paragraph lines. It is set as bodyText.blockquote says: grey italics with the body's first-line indent unless you change them (see Configuration › Blockquotes).
Unordered list - item, * item, + item Any of the three bullet markers is accepted. An item nests under the item above when its marker is indented at least two columns past that item's marker: two spaces under -, two or three under 1. (the column of its text, as in CommonMark), up to a maximum depth of 5.
Ordered list 1. item, 2) item Digits followed by . or ). The start number is preserved (so a list can begin at 5, or at 0). The separator rendered in the output comes from orderedLists.separator, not from the source.
Task list (GFM) - [ ] todo, - [x] done An unordered item with a bracketed checkbox. Accepts lowercase x or uppercase X. The engine draws its box, with a tick when the item is done; a character named in taskCheckboxChar / taskCheckedChar is set as text in its place.
Display math $$ … $$ A LaTeX formula set as its own block. Rendered centred on the column, snapped to the baseline grid like a heading, and kept vectorial in the PDF output. The single-line and fenced multi-line forms are described under Mathematical formulas.
Code block �0�, ~~~ … ~~~ A listing, kept as written: every space, tab and line break, nothing read as Markdown. Set line by line in the code face, in a box. See Code blocks.

A single blank line between two list items is tolerated — the list stays together. Two or more blank lines terminate the list.

Lists of mixed kinds at the same depth are accepted (you can switch from unordered to ordered mid-run), but the engine treats the runs as separate for numbering purposes. In practice, keep one kind per depth unless you have a reason to mix them.

Figure: List nesting depth A list item nests under the item above when it is indented past that item’s marker: two spaces under a bullet, up to a maximum depth of five. Each level indents further and may use different bullet styling.

Two spaces per level under a bullet. Max depth: five.

Heading attributes

A heading line may end with an attribute block in braces — the same key="value" syntax directives use:

# The Long Road {author="I. Zango Martín" year=1998}

The braces and their content are removed from the heading text (the title above renders as The Long Road) and stored on the heading as attrs. They are exposed to design slots as {attr.<key>} placeholders: in the heading's own advanced-design slot, and in page headers and footers, where they resolve from the current chapter's H1. Only a balanced, brace-free block at the very end of the line is recognised, after a space or, since Chinese titles are written without one, right after a Chinese or Japanese character (# 回目{style="x"}). The block is taken only when the attribute syntax below reads all of it: {x, y}, {紅樓|hóng lóu} and a lone {} stay in the title, and so do flags alone glued to the title (# 第一回{draft}). Attributes are separated by spaces, so a block with commas ({a="1", b="2"}) and Pandoc's {.class} stay in the title as well. Pandoc's identifier {#id} is read: it is the same as id="…" and names the heading for cross-references. A key written in another script is read and dropped with a warning (see below), so # 回目{style="x" 作者=曹雪芹} still takes style. See Configuration → Headers & footers.

A value printed by a design text may span several lines: the two characters \n start a new line there, whatever the element's overflow (to="Firma X\nStrasse 1\n10115 Berlin" prints a three-line address). With inlineMarks on the element, inline marks in the value apply too: authors="Ana Ruiz^1^, Luis Gil^2^" sets the affiliation numbers as superscripts. The escape belongs to attribute values (and to the design's own templates): the characters \n in a heading's text, say in a code span, print as written.

Two attributes have a meaning of their own. style="<id>" applies a named heading style to the heading — a preface or an authors list with its own opener design, running heads, page geometry and body typography, and no chapter number when the style says numbered: false. toc="false" (or "true") overrides whether the heading is listed by :::toc:

# Preface {style="front-matter"}

# Contents {style="front-matter" toc="false"}

Two more change what a heading prints and how it counts. hidden="true" (or "false") overrides the hidden of the heading's level or style: a hidden heading prints nothing and takes no room — in the flow and inside a :::callout box alike — but still opens its page, counts, and is listed in the contents, the {chapterTitle} running heads and the PDF bookmarks. jidori=N spaces a one-line heading evenly to N of its own characters (字取り: # 序章 {jidori=3} prints 序 章), and jidori=0 turns off the even spacing its level or style sets (see Configuration › Per-level overrides). indent=N lowers one heading N characters of the body text from the line start (字下げ: ## 一 {indent=5}), over the indent of its level or style; a bare number counts body ems, 0 sets the heading at the line start, and it works the same in vertical and horizontal text. firstLineIndent=N indents the first line of one heading alone, over the firstLineIndent of its level or style, its other lines starting where indent puts them (the heads of GB/T 9704: ## 关于加强管理的通知 {firstLineIndent=2}); it reads lengths as indent does, and 0 takes the level's off. startAt=N, a positive integer, sets the heading's level counter to N instead of advancing it; the headings after it count on from there, in later chapters too, and the levels below restart under it as usual. With a heading style that letters appendices (numberingTemplate: 'Appendix {1:A}'), the first appendix restarts the count so that it reads Appendix A rather than continuing from the chapters:

# To my mother {hidden="true" toc="false"}

# Survey instrument {style="appendix" startAt=1}

# Raw data {style="appendix"}

Drop caps

A paragraph opens with a drop cap when its heading level or style, or the paragraph style of its :::paragraphs group, sets one (see Configuration › Drop caps): the first letter is set large beside the first lines, which are shortened around it. There is no markup in the paragraph itself; the text is written as usual, and the letter stays part of its word for copying, search and the source map. A dropcap attribute switches it for one chapter or one group:

# Lost {dropcap=false}

# The mines {dropcap=2}

:::paragraphs{style="entry" dropcap}
The lamp of the first entry opens with an initial.
:::

On a heading line, dropcap=false leaves the paragraph after that heading without the drop cap its level or style sets, dropcap=N sets it over N lines, and a bare dropcap gives that paragraph one (three lines, the paragraph's face) where the level sets none. On a :::paragraphs fence the attribute does the same for the group's first paragraph, or for every paragraph when the style says each: true. The paragraph after a heading is found past fences, directives, boxes that left the flow and floated figures; a paragraph inside a box, a list item, a quotation or a poem never takes a drop cap. Since postext 1.23.

Code blocks

A listing goes between two fences of three or more backticks (or three or more tildes), each on a line of its own, up to three spaces in. Everything between them is kept as written: leading spaces and runs of spaces, tabs, blank lines, and lines that start with #, -, 1., >, $ or :::. Nothing inside is read as Markdown: no emphasis, no :ref, no maths, no chips, no directives, no headings. The closing fence uses the opening one's character and is at least as long, so a listing that shows a fence goes in a longer one or in tildes:

```bash {title="backup.sh" lineNumbers start=12 highlight="3,5-7"}
#!/usr/bin/env bash
# Copy the photos to the backup disk
rsync -a --delete ~/Photos/ /Volumes/Backup/Photos/
echo "done: $(date)"
```

The info string after the opening fence names the language (its first word: js, ts, python, bash, console, json…), and the rest is read as attributes in the attribute syntax, in braces or bare (title="app.js" lineNumbers). Anything else after the language is the title: ```console Terminal and ```python normalize.py print Terminal and normalize.py over the listing.

Attribute Effect
title="…" A title over the listing, in a title row (or in a label tab on the box's edge when codeStyle.label is set).
label="…" The text of the box's label tab, as a :::callout's (with codeStyle.label).
lineNumbers, lineNumbers=false Numbers the lines of this listing (or not), whatever codeStyle.lineNumbers says. The numbers stand in a gutter before the code and are not copied with it.
start=N The first line's number (numbers the listing when nothing else does).
highlight="3,5-7" Tints the lines named (counted from the listing's first line, 1-based): single lines and ranges, separated by commas.
span=page On a page of several columns, the listing spans them all.

A fence ends the paragraph above it, blank line or not, and the text after the closing fence starts a new paragraph. A fence left open runs to the end of the text and is reported (unclosedCodeBlock). A fence inside a :::callout is a box nested in it; inside :::paragraphs the listing keeps the code style, not the paragraph style. Postext has no list-item continuation, so a fence after a list item closes the list. Index marks and anchors written inside a listing stay as written.

Indented code (four spaces or a tab, after a blank line) is read as a listing only when codeStyle.indentedCode is on: Postext text often indents with spaces, and nested list items read leading spaces.

How a listing looks (its face, box, tab width, what happens to a line wider than the box, line numbers, the colours of its tokens) is set by codeStyle; see Configuration › Code listings. Inline code (`x`) stays in the text's face unless codeStyle.inline gives it one. A book stored before postext 1.23 with fences in its text reads them as it did then, as Markdown (codeStyle.blocks: false). Since postext 1.23.

What is NOT supported

Postext does not recognise the following CommonMark features. They are either treated as plain text (and therefore will appear literally in the output) or silently dropped:

  • Setext-style headings — the === / --- underline form. Use ATX (#) headings.
  • HTML passthrough — raw <tags> are not interpreted. MDX-style tags are not supported either; Postext source is pure markdown.
  • Horizontal rules — ---, ***, ___.
  • Tables — pipe tables are not parsed. Tables are modeled as structured resources on PostextContent.resources.
  • Reference-style links — [text][id] plus a definition block.
  • Autolinks — <https://example.com>.
  • Hard line breaks made with two trailing spaces — the spaces are trimmed. End the line with a backslash instead (see Line breaks).
  • Strikethrough — ~~text~~. The strikethrough renderer is currently reserved for completed task items.
  • Margin notes — not implemented, and PostextContent.notes, which the types accept, is ignored by the engine. Footnotes and chapter-end notes are written with [^id] (see Footnotes); up to postext 1.5 they had to be set as text.

This list will shrink over time. Until then, anything not explicitly listed in the supported section above should be assumed to be literal text.

Beyond the syntax, Arabic and the other right-to-left scripts are set right to left, with the bidirectional algorithm and right binding (see Arabic layout). Chinese is set across the page and down it, with the line breaking, punctuation widths, justification between characters, marks, ruby and warichu its region expects (see Chinese layout). Japanese is set with its own rules, kinsoku, yakumono spacing, furigana, emphasis marks, kanbun marks and notes (see Japanese layout); Korean goes through the same composer with the mainland Chinese rules. Hyphenation patterns ship for eight languages, and built-in labels for those eight, Chinese, Japanese and Arabic. See Languages and scripts.

Authoring conventions

A few conventions make the difference between a document that parses cleanly and one that surprises you:

  • Leave a blank line between blocks. Two paragraphs separated by a blank line are two paragraphs. Two paragraphs on consecutive lines become one — every line collapses into the preceding paragraph.
  • Extra blank lines add no space. Three blank lines separate two blocks exactly like one. When you want more room between them, write a :::space line.
  • Indent a nested item to the text of the item above. Two spaces under a bullet (- ), three under 1. , four under 10. , as CommonMark nests lists; two spaces under 1. nest too. An item nests under the nearest open item whose marker sits at least two columns to its left, so one space nests nothing and a level is never skipped, whatever the step (up to postext 1.15 the depth was floor(spaces / 2) + 1, and a 1. under 1. came out at level 4). A tab reaches the next multiple of four columns. Depth is capped at 5.
  • Do not indent the first list item. Level-1 items start at column 0. A list's first item opens level 1 however far it is indented (up to postext 1.15 its leading spaces raised the depth), so nesting needs an item above to nest under.
  • Task markers must be in square brackets with a single space. [ ], [x], [X] — no variations. [*] or [-] are not task markers; they render as literal text.
  • Blockquotes inside lists are not supported. Start the blockquote at column 0, outside the list.
  • Images and tables live in resources. Inline ![alt](src) is stripped precisely because inline images break column-aware placement. Declare each image as a resource and reference it by id — the engine then decides whether it floats, breaks the column, or moves to the top of the next page.
  • Escape dollar signs with \$ when you do not mean math. Postext interprets $…$ as inline LaTeX, so a raw $ in prose will start a formula. Prices, shell prompts, and anything else with a bare dollar sign should be written as \$.
  • Type the markup in ASCII, also in Chinese and Japanese text. A Chinese or Japanese input method gives full-width forms: ::: for a fence, # for a heading, [^1] for a footnote marker, {…} for attributes, ** for bold. They print as text, and the build reports the line as a fullwidthMarkup warning that names the ASCII form to type. Attribute values may be in any script and quoted with “…” or 「…」; keys stay ASCII.

Worked example

A short document that exercises every supported construct:

---
title: The Typesetter's Craft
author: Anon
---

# Opening

A good book reads itself. The **reader** should never notice the
typesetter's work — only the author's voice.

## What makes text readable

Three properties matter most:

1. Line measure — 40 to 75 characters per line.
2. Leading — 1.3 to 1.5 times the font size.
   a. Tighter at short measures.
   b. Looser at long measures.
3. Contrast between body and headings.

Common failure modes include:

- Lines that stretch across the whole page.
- Headings that float without a following paragraph.
- Orphans and widows at column boundaries.

> Typography is the craft of endowing human language with a durable
> visual form.
> — Robert Bringhurst

### Review checklist

- [x] Column width under 75 characters
- [x] Leading set to 1.5
- [ ] Orphan and widow pass
- [ ] Final proofread

### A note on formulas

Inline math such as $a^2 + b^2 = c^2$ flows with the surrounding text, and
display math sits centred on the baseline grid:

$$
\int_0^1 x^2\,dx = \tfrac{1}{3}
$$

The same document, rendered through the layout engine, produces a structured VDTDocument whose pages carry each of these blocks as typed entries — see the Architecture page for how blocks become geometry.

Clone this wiki locally