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.
#Block constructs
Postext recognises seven block types. Blocks are always terminated by a blank line or by the start of another block.
| 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. Manual line breaks inside a paragraph are not preserved — use a blank line to start a new paragraph. |
| Blockquote | > quoted text | Every line of the quote must start with > (one optional space after). Consecutive quote lines merge into a single blockquote block. |
| Unordered list | - item, * item, + item | Any of the three bullet markers is accepted. Nesting uses exactly two spaces per level, 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. Rendered with the taskCheckboxChar / taskCheckedChar glyphs. |
| Display math | $$ … $$ | A LaTeX formula either on a single line ($$\int_0^1 x^2,dx$$) or fenced across multiple lines with $$ markers on their own lines. Rendered centred on the column, snapped to the baseline grid like a heading, and kept vectorial in the PDF output. |
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.
#Directives
Directives are single-line control tags written as :::name or :::name{attrs} on their own line. They produce no visible output — they drive the placement and numbering pipeline.
| Syntax | Effect |
|---|---|
:::pagebreak | Force the next block to open on a new page. |
:::pagebreak{parity="odd"} | Same, plus ensure the new page is odd (right-hand). Inserts a blank padding page when needed. |
:::pagebreak{parity="even"} | Same, but targeting an even (left-hand) page. |
:::pagebreak{parity="always-odd"} | Guarantee at least one mandatory blank separator page before landing on an odd page. The separator blank belongs to the preceding content; any further parity padding belongs to what follows. Useful when every chapter must start on a fresh spread. |
:::pagebreak{parity="always-even"} | Same, but targeting an even page. |
:::numbering{format="decimal" startAt=1} | At the next page boundary, switch the page-numbering sequence. Both attributes are optional — omit format to keep the format, omit startAt to continue the counter. |
Attribute values may be double-quoted ("…"), single-quoted ('…'), or bare (startAt=17). A bare key without = is treated as a present-but-empty flag.
Only pagebreak and numbering are recognized today — any other :::name line is parsed as a paragraph and surfaces an Unknown directive warning in the sandbox.
#:::pagebreak
The directive itself does not force parity on its own — only the next block's layout. Use it to end a preface, force a dedication onto its own page, or mark the end of a section. When both a page break and a numbering reset are wanted at the same point, compose :::pagebreak followed by :::numbering — the numbering switch applies at the fresh page the :::pagebreak just created.
The old chapter ends here.
:::pagebreak{parity="odd"}
# A new chapterParity attribute
The parity attribute accepts the same five values as headings.levels[*].breakBefore.parity:
'odd'/'even'— the new page opens on the requested side of the spread; a single blank is inserted only when the natural next page is on the wrong side.'always-odd'/'always-even'— guarantee at least one mandatory blank separator page between the previous content and the new page, then enforce parity. The separator blank belongs to the previous chapter; any further parity padding belongs to whatever follows.
Blank-page ownership
The two kinds of blank pages :::pagebreak (and breakBefore) can introduce are distinguished in the VDTPage model:
blankForParity: true— inserted to satisfy a parity constraint. In{chapterTitle}headers this page carries the upcoming chapter's title, because the blank exists only to push that chapter onto the right parity.blankForForce: true— the mandatory leading separator of an'always-*'mode. It belongs to the previous chapter — a deliberate end-of-chapter breath, not parity padding for the next chapter.
Document-start exception
When :::pagebreak is the very first construct in a document (or a heading with breakBefore would pull one in), parity enforcement is skipped while the first page is still empty. The next block lands on page 1 as written, regardless of the requested parity — no spurious leading blank.
#:::numbering
:::numbering is how you restart the page counter mid-document. The canonical book example:
---
title: "A Book With Front Matter"
---
# Preface
…
:::pagebreak{parity="odd"}
:::numbering{format="decimal" startAt=1}
# Chapter 1Preface pages are labelled i, ii, iii, …; the first chapter opens on a right-hand page labelled 1.
Format-only changes (no startAt) keep the counter flowing — useful for, say, switching from lower-alpha to upper-alpha without resetting.
#Inline formatting
Inline markup is recognised inside any text block (headings, paragraphs, blockquotes, list items).
| Markup | Syntax | Notes |
|---|---|---|
| Bold | bold or bold | Rendered with bodyText.boldFontWeight. An optional bodyText.boldColor overrides the default body color for bold spans. |
| Italic | italic or italic | Rendered with the italic variant of the current font family. An optional bodyText.italicColor overrides the default body color for italic spans. |
| Bold italic | both or both | Both flags combine. |
| Inline code | | Backticks are stripped; the span is rendered as plain text. Distinct code styling is on the roadmap. |
| Link | text | The visible text is kept in the flow; the URL is discarded by the current renderer. Link handling is on the roadmap. |
| Image | | Inline image markdown is removed from the text. Images must be declared on PostextContent.resources so the layout engine can place them according to resourcePlacement rules. |
| Inline math | $…$ | A LaTeX formula that flows with the surrounding text, e.g. $e^+1=0$. Typeset by MathJax and rendered as vector paths on every backend. Formulas whose ascent or depth would overflow the body line box are scaled down so they stay on the baseline grid. Use \$ for a literal dollar sign. |
#Mathematical formulas
Math support is a first-class part of the document format. Postext parses $…$ for inline formulas and $$…$$ for display (block) formulas, and renders them via MathJax in SVG mode. The same vector paths drive all three backends, so the canvas preview, the HTML export, and the PDF output are pixel-for-pixel consistent — and the PDF stays fully vectorial regardless of the zoom level.
- Inline:
$…$. Recognised inside any text block (paragraph, heading, blockquote, list item). Contributes a single atomic, non-breaking box to the line; Knuth-Plass treats it like a word that must not be split. If the formula's natural height would break the line box, it is scaled down uniformly so the baseline grid is preserved — very tall expressions belong in display mode. - Display:
$$…$$. Either on its own line ($$\int_0^1 x^2\,dx$$) or fenced across multiple lines with$$markers on their own lines. Rendered centred on the column and snapped to the baseline grid with configurable top and bottom margins (math.marginTop,math.marginBottom) — exactly the same correction mechanism headings use, so the paragraph after the formula lands back on the grid. - Escaping:
\$is a literal dollar sign. Unmatched$or$$delimiters produce anunclosedMathentry in the warnings panel with a click-to-focus source anchor. - Errors: TeX source that MathJax rejects (undefined macros, syntax errors) surfaces as an
invalidMathwarning. The formula is replaced by a small red placeholder so the layout geometry stays valid. - Configuration: the
mathsection of the config exposesenabled,fontSizeScale(relative to the body font size),color(inherits the body colour when unset), and the display margins.
The Euler identity $e^{i\pi}+1=0$ links the five fundamental constants.
$$
\int_0^{\infty} e^{-x^2}\,dx = \frac{\sqrt{\pi}}{2}
$$#Resources
Images, SVGs, and tables are not written inline. They are declared once as resources (managed in the sandbox's Resources panel) and then connected to your prose by id. Referencing a resource is enough to incorporate it — you mention it once with an inline :ref{id="…"}, and the engine floats the figure or table to a band at the top or bottom of the page near that reference, just as a print typesetter would. You do not place it a second time.
Both forms below are net-new syntax that does not collide with CommonMark, so a document using them still reads as plain text in any other markdown viewer.
#Inline reference (the primary form)
Refer to a resource from within prose with :ref{id="…"}. The first reference both incorporates the resource (so it gets placed on the page) and renders its computed number, prefixed by the type's short label by default:
As shown in :ref{id="lighthouse-diagram"}, the lantern room sits above the gallery.renders as: As shown in Fig. 1.7, the lantern room sits above the gallery. — and the diagram itself floats to the top (or bottom) of the page, while this sentence and the text after it flow on uninterrupted.
The running text is never broken at the reference point. Where the resource lands — top or bottom of the page, within a single column or across the full width — is governed by its placement (see Placement below), not by where you mention it.
#Block embed (optional, explicit inline placement)
Occasionally you want a resource to sit at an exact point in the flow rather than float. Opt out of floating by giving the resource placement.position: "here" and embedding it with ::resource{id="…"} on its own line:
Here is the floor plan we discussed.
::resource{id="lighthouse-diagram"}
The keeper's quarters occupy the eastern wing.For a floated resource the ::resource directive is unnecessary — the :ref already placed it, and a redundant ::resource for the same id is simply treated as another reference, not a second copy. A ::resource only renders the resource inline when its resolved placement is "here".
The id must match a resource defined in the Resources panel. The engine renders the resource (bitmap, SVG, or table) with its caption drawn underneath as a figure/table foot. The caption text is composed from the resource type's captionPrefix, the computed number, and the resource's own caption — e.g. Figure 1.7. The original lighthouse plan.
A malformed embed (missing or empty id, extra attributes) is not promoted to a resource block; it falls through to ordinary paragraph parsing and remains visible in the output, and the sandbox surfaces a warning.
Inline references are recognised inside any text block — paragraphs, headings, blockquotes, and list items — and may sit alongside bold, italic, inline code, and inline math.
Reference options
The :ref directive accepts two optional attributes:
| Syntax | Renders | Notes |
|---|---|---|
:ref{id="…"} | Fig. 1.7 | Default style: the type's shortLabel followed by the number, joined with a non-breaking space so they never wrap apart. |
:ref{id="…" style="number"} | 1.7 | The bare computed number, no label. |
:ref{id="…" style="full"} | Figure 1.7 | The type's full name followed by the number. Use at the start of a sentence or where the abbreviation reads poorly. |
:ref{id="…" text="see the plan"} | see the plan | An explicit override. The given text is used verbatim instead of any computed label — useful for prose links like "as we saw earlier". When present, text takes precedence over style. |
If a :ref (or ::resource) names an id with no matching resource, the label falls back to ? and the sandbox raises an unknown resource warning.
#First-reference numbering
A resource's number is assigned the first time it is mentioned in reading order — whether that first mention is a ::resource block embed or an inline :ref. From then on, every reference to the same id prints that same number.
This means numbers follow the order the reader meets them, not the order resources were created in the panel:
- If you
:refa figure in the introduction and only embed it (::resource) two pages later, it still takes the introduction's number — the reference came first. - Inserting a new reference earlier in the document automatically renumbers everything after it. There is no manual numbering to keep in sync.
Numbering is per resource type and respects each type's reset scope and counter format — see Configuration › Resource types for the template tokens ({h1}, {n}), resetOn, and counterFormat.
#Placement
Each resource has a placement that decides where its float lands, resolved per resource (its own placement), then its type's defaultPlacement, then the built-in default of top / column:
| Field | Values | Meaning |
|---|---|---|
position | "top" · "bottom" · "here" | Float to the top or bottom band of the page, or — for "here" — opt out of floating and embed inline at the ::resource directive. |
span | "column" · "page" | Occupy a single column, or break the column flow and span the full content width across all columns. In a single-column layout the two are identical. |
A float is placed on the next page opened after its first reference, near it in reading order. If it does not fit in that page's band, it defers to the next page (it is never shrunk or split). Floats keep first-reference order, so figures never reorder relative to where they are mentioned.
Note: the HTML viewer does not yet render resources; the canvas preview and the PDF backend do.
#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. - Fenced or indented code blocks — triple-backtick fences and 4-space indentation. Inline code works; multiline code will be rendered line-by-line as paragraphs.
- 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>. - Strikethrough —
~~text~~. The strikethrough renderer is currently reserved for completed task items. - Footnote markers in markdown —
[^1]. Footnotes ride onPostextContent.notesand are referenced by id, not by inline syntax.
This list will shrink over time. Until then, anything not explicitly listed in the supported section above should be assumed to be literal text.
#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.
- Nest lists with exactly two spaces per level. One space is parsed as a level-1 item. Three or four spaces round down to level 2 (the engine uses
floor(leading / 2) + 1, clamped to depth 5). Tab indentation is not recognised — convert tabs to spaces. - Do not indent the first list item. Level-1 items start at column 0. Leading whitespace on a bullet implicitly raises the depth.
- 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. Inlineis 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\$.
#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.