PicoDoc Language Reference

1. Overview

PicoDoc is a macro-based markup language that compiles to HTML. Every construct in the language (headings, bold text, links, user-defined templates) is a macro call. This regularity means there is only one syntax to learn.

Design principles:

The file extension is .pdoc.

2. Lexical Structure

2.1. Identifiers

An identifier consists of one or more letters, digits, dots, or special characters:

! $ % & * + - / < > @ ^ _ ~ |

Dots allow namespacing (e.g. env.mode, doc.lang). The identifier terminates at the first character that is not in the set above, such as whitespace, colons, quotes, brackets, equals signs, hash, or backslash.

2.2. Escape Sequences

Backslash is the escape prefix. Backslash followed by any character not on the valid list for the current context is a syntax error.

Prose escapes (in body text and barewords):

SequenceProduces
\\Literal backslash
\#Literal #
\[Literal [
\]Literal ]
\"Literal "
\xHHUnicode codepoint U+0000 to U+00FF
\UHHHHHHHHUnicode codepoint (8 hex digits)

Double quotes are literal in prose — they are only parsed as string delimiters in value positions (after =, after a colon, or directly after a macro name). Use \" when the body of a macro must begin with a literal double quote character.

String escapes (inside interpreted string literals):

SequenceProduces
\\Literal backslash
\"Literal double quote
\[Enter code mode (macro expansion)
\nNewline
\tTab
\xHHUnicode codepoint U+0000 to U+00FF
\UHHHHHHHHUnicode codepoint (8 hex digits)

Note that \[ has different meaning in the two contexts: in prose it produces a literal [, while in strings it enters code mode.

2.3. Comments

The #// macro (also #comment) removes its body from the output entirely. Three forms are available:

  1. Inline (colon body to end of line): #//: This text will not appear.
  2. Bracketed (multi-line): [#// : Multi-line comment here.]
  3. Paragraph body (colon + following paragraph):
#//: This text will not appear.

[#// : Multi-line comments
  use the bracketed form.]

#//:
This entire paragraph
is a comment.

3. Macro Calls

There are two forms of macro call: unbracketed and bracketed. The parser does not need to consult macro definitions to parse either form.

3.1. Unbracketed Form

A macro call begins with # followed immediately by the macro identifier. After the identifier, the parser examines the next token:

  1. If the token matches identifier= (no whitespace before the equals sign), it enters argument mode and consumes name=value pairs.
  2. If the token is a colon, it enters body mode.
  3. If the token is a string literal opening, it enters body mode with the string as body.
  4. Otherwise the call is complete with no arguments and no body. Remaining text on the line is prose.
#doc.title: Document Title
#-: Visible Heading
#hr
#p"Explicit paragraph."

In argument mode, the parser consumes name=value pairs. If the next token is not a name=value pair, a colon, or a string literal, the call is complete and the remaining text on the line is prose. After a colon body (to end of line) or a string literal body, the call is also complete and any remaining text is prose.

#code language=python : print("hello")
#doc.meta name=viewport content="width=device-width"

3.2. Bracketed Form

A macro call enclosed in square brackets: [ ...]. Named arguments may appear as name=value pairs separated by whitespace. A colon or string literal introduces body content. Everything until the matching closing bracket is body, which may span multiple lines and paragraphs.

[#b : bold text]
[#link to="https://example.com" : Click here]
[#code language=python : """
    def hello():
        print("world")
    """]

Bare text inside brackets that is not a name=value pair and not preceded by a colon or string literal is a syntax error.

3.3. Named Arguments

Named arguments use the syntax name=value with no whitespace before the equals sign. Whitespace after the equals sign is permitted.

Argument values can be:

3.4. The Body Argument

Body content is introduced by a colon or by a string literal:

Colon, inline body: text follows the colon on the same line.

#-: Document Heading

Colon, paragraph body:

nothing follows the colon on the line. The next paragraph (contiguous non-blank lines) becomes the body.

#p:
This paragraph spans
multiple lines.

String literal body:

a string literal serves as body. A colon before the string is optional.

#p"A string literal paragraph."
#**"bold text"
[#code language=python """
    def hello():
        pass
    """]

Bracketed body:

in bracketed calls, body extends to the closing bracket and may span multiple lines and paragraphs.

[#ul :
  #*: First item
  #*: Second item
  #*: Third item
]

If no colon and no string literal is present, the macro call has no body.

3.5. Body Whitespace Stripping

Multiline body content (both paragraph and bracketed forms) is dedented: the longest common leading whitespace shared by all non-blank lines is stripped. This lets you indent body content to match the surrounding markup without that indentation leaking into the output.

#code:
    def hello():
        print("hi")

The four spaces before def and print are stripped because they are the common leading whitespace. The relative indentation (four extra spaces on print) is preserved.

Blank lines inside the body do not affect the common prefix calculation. Inline bodies (content on the same line as the colon) have no newlines and are unaffected.

4. String Literals

4.1. Interpreted Strings

String literals are only recognised in value positions: after = (argument value), directly after a macro name with no whitespace (inline body), or after : (colon body). A double quote in any other position is literal prose text.

Delimited by a single double quote on each end. Escape sequences are processed but macro calls are NOT automatically scanned for.

"A simple string."
"Contains a tab:\there."
"Contains a newline:\nSecond line."
"A literal quote: \" inside."

To embed macro calls within an interpreted string, use \[ to enter code mode. Code mode ends at the matching closing ]. Normal bracketed macro call syntax applies inside \[...\].

"Hello, \[#name]!"
"Dear \[#target], welcome to \[#place]."

4.2. Raw Strings

Delimited by three or more double quote characters. The closing delimiter must have exactly the same number of quotes as the opening. Contents are completely opaque (no escapes, no macro processing).

"""This is raw: \n is literal and #name is not expanded."""
""""Contains """ three quotes inside.""""

4.3. Empty String

The empty string is always "" (two double quotes). An empty raw string is not possible because the opening quotes run into the closing.

4.4. Whitespace Stripping

Both interpreted and raw strings apply the same whitespace rules:

  1. If the remainder of the opening delimiter's line is blank (whitespace only), that remainder is discarded.
  2. If the beginning of the closing delimiter's line is blank (whitespace only), that whitespace is discarded.
  3. If the closing delimiter's leading whitespace appears identically on ALL other lines of the string, that common prefix is stripped from every line (indentation stripping).

This allows indenting string content to match the surrounding markup without affecting the result:

[#code language=python : """
    def hello():
        print("world")
    """]

The four spaces before def and print are stripped because they match the indentation of the closing delimiter.

Colon body content (paragraph and bracketed) applies analogous whitespace stripping: the longest common whitespace prefix across all non-blank content lines is removed. The difference from strings is that strings use the closing delimiter's indentation as the reference prefix, while bodies compute the minimum common prefix directly.

4.5. Adjacent String Restriction

After a string literal closes, the next character must not be a double quote. At least one non-quote character must separate consecutive string literals. This prevents a mismatched quote from silently cascading through the file.

5. Builtin Macros

PicoDoc has 44 builtin macros in 8 categories, plus 11 alternate forms.

5.1. Structural Macros

5.1.1. #-

Also: #h1. Top-level heading. Renders as <h1> in the document body. Takes body, no parameters.

#-: Main Heading
#h1: Alternate form

5.1.2. #--

Also: #h2. Section heading. Renders as <h2>. Takes body, no parameters.

5.1.3. #---

Also: #h3. Subsection heading. Renders as <h3>. Takes body, no parameters.

5.1.4. #----

Also: #h4. Lower-level heading. Renders as <h4>. Takes body, no parameters.

5.1.5. #-----

Also: #h5. Lower-level heading. Renders as <h5>. Takes body, no parameters.

5.1.6. #------

Also: #h6. Lower-level heading. Renders as <h6>. Takes body, no parameters.

5.1.7. #p

Paragraph. Renders as <p>. Takes body, no parameters. Bare text paragraphs (not inside a macro call) are implicitly wrapped in #p.

#p: An explicit paragraph.

This bare text is also a paragraph.

5.1.8. #hr

Horizontal rule. Renders as <hr>. No parameters, no body.

5.2. Inline Macros

5.2.1. #**

Also: #b. Bold text. Renders as <strong>. Takes body, no parameters.

This is #**"bold" text.
This is [#** : also bold] text.

5.2.2. #__

Also: #i. Italic text. Renders as <em>. Takes body, no parameters.

This is #__"italic" text.
This is [#__ : also italic] text.

5.2.3. #*_

Bold italic. Renders as <strong><em> (strong wrapping em). Takes body, no parameters.

This is [#*_: bold italic] text.
This is #*_"also bold italic" text.

5.2.4. #_*

Italic bold. Renders as <em><strong> (em wrapping strong). Takes body, no parameters.

This is [#_*: italic bold] text.
This is #_*"also italic bold" text.

5.2.5. #>

Also: #link. Hyperlink. Renders as <a>. Parameters:

ParameterRequiredDescription
toNoLink target (URL, path, or fragment name)

Takes optional body (the link text). At least one of to or body must be present. When to is omitted, the body text is used as the link target. For external links, if no body is provided the to value is used as the link text.

Fragment reference behavior: if the link target does not contain :// and does not contain /, it is treated as a fragment reference and # is prepended (e.g. to=section1 produces href="#section1").

For fragment references, two additional rules apply:

[#> to="https://example.com" : Click here]
[#link to="https://example.com" : Alternate form]
[#> to=section1 : Jump to section]
[#> to=section1]
[#> to=page/about : About page]
[#> : https://example.com]
[#> : section1]

5.3. Code and Literal Macros

5.3.1. #code

Block code. Renders as <pre><code>. Parameters:

ParameterRequiredDescription
languageNoProgramming language for syntax class

Takes body. Always renders as a block <pre><code> element regardless of body type. For inline code, use #~ instead.

[#code language=python : """
    def hello():
        print("world")
    """]

With a paragraph body, body dedenting strips the common indentation automatically:

#code language=python:
    def hello():
        print("world")

5.3.2. #~

Inline code. Renders as <code>. Parameters:

ParameterRequiredDescription
languageNoProgramming language for syntax class

Takes body. Always renders as an inline <code> element regardless of body type. For block code, use #code instead.

Use the [#~ : print()] function to output text.
The [#~ language=python : def] keyword starts a function.

5.3.3. #literal

Prevents re-expansion of its body. Any macro calls or escapes within the body are passed through as literal text. Useful for documenting PicoDoc syntax or for output from external filters. Takes body, no parameters.

#literal"""
The #b macro makes text bold.
Use [#link to="..." : text] for links.
"""

5.4. List Macros

5.4.1. #ul

Unordered list. Renders as <ul>. Takes body, no parameters. Body must contain only #* (list item) elements.

5.4.2. #ol

Ordered list. Renders as <ol>. Takes body, no parameters. Body must contain only #* (list item) elements.

5.4.3. #*

Also: #li. List item. Renders as <li>. Takes body, no parameters. Must appear inside #ul or #ol.

[#ul :
  #*: First item
  #*: Second with #**"bold"
  [#* : Third with a sublist
    [#ul :
      #*: Nested A
      #*: Nested B
    ]
  ]
]

[#ol :
  #*: Step one
  #*: Step two
]

5.5. Table Macros

5.5.1. #table

Table container. Renders as <table>. Takes body. Parameters:

ParameterRequiredDescription
colsNoColumn widths and alignment spec

The cols parameter specifies relative column widths and optional alignment, using a space-separated list of integers. Prefix a column with > for right-alignment or < for left-alignment (default). Example: cols="1 >2 1" means 3 columns where the middle column is twice as wide and right-aligned.

When cols is present, a <colgroup> element is emitted with percentage-based widths (integer division: width*100/total). The column count must match every row's cell count or rendering fails with an error.

[#table cols="1 >2 1" :
  Name | Score | Status
  Alice | 95 | Active
  Bob | 87 | Active
]

This produces:

<colgroup>
<col style="width: 25%">
<col style="width: 50%; text-align: right">
<col style="width: 25%">
</colgroup>

Supports two forms:

Pipe-delimited form: the body is parsed as pipe-separated rows.

The first row becomes headers (#th), subsequent rows become data (#td).

#table:
  Name | Age | Status
  Alice | 30 | Active
  Bob | 25 | Inactive

Explicit form: the body contains #tr, #th, and #td calls

directly.

[#table :
  [#tr : [#th: Name] [#th: Age]]
  [#tr : [#td: Alice] [#td: 30]]
]

5.5.2. #tr

Table row. Renders as <tr>. Takes body, no parameters.

5.5.3. #td

Table data cell. Renders as <td>. Parameters:

ParameterRequiredDescription
spanNoColumn span (colspan attribute)

Takes body.

5.5.4. #th

Table header cell. Renders as <th>. Same parameters as #td. Takes body.

[#table :
  [#tr : [#th: Name] [#th: Age]]
  [#tr : [#td: Alice] [#td: 30]]
  [#tr : [#td span=2 : Total: 1 person]]
]

5.6. Wrapper / Container Macros

PicoDoc provides 9 wrapper macros for grouping content in HTML container elements. All wrapper macros accept optional class and id parameters and take a body.

MacroHTML elementDisplay
#div<div>Block
#section<section>Block
#nav<nav>Block
#header<header>Block
#footer<footer>Block
#main<main>Block
#article<article>Block
#aside<aside>Block
#span<span>Inline

Block wrappers render their body children on separate lines within the opening and closing tags. The #span macro is inline and renders its body without added newlines.

[#div class=container :
  #-: Welcome
  #p: This content is inside a div.
]

#p: Text with [#span class=highlight : highlighted words] inside.

[#section class=sidebar id=nav :
  [#nav :
    [#ul :
      #*: [#link to=page1 : Page 1]
      #*: [#link to=page2 : Page 2]
    ]
  ]
]

5.7. Document Macros (doc.* Namespace)

All document-level macros live under the doc.* namespace. They produce elements in the HTML <head> section, not in <body>.

5.7.1. #doc.title

Sets the document <title> in the HTML <head> section. Does not produce any output in <body>. Takes body, no parameters.

#doc.title: My Document

5.7.2. #doc.lang

Sets the lang attribute on the HTML <html> element. Takes body (the language code), no parameters.

#doc.lang: en

5.7.3. #doc.meta

Adds a <meta> tag to the document head. Parameters:

ParameterRequiredDescription
nameNoMeta name attribute
propertyNoMeta property attribute (e.g. Open Graph)
contentYesMeta content value

No body.

#doc.meta name=viewport content="width=device-width, initial-scale=1"
#doc.meta property="og:title" content="PicoDoc Reference"

Adds a <link> tag to the document head. Parameters:

ParameterRequiredDescription
relYesRelationship (e.g. stylesheet)
hrefYesURL of the linked resource
typeNoMIME type (e.g. image/png)
sizesNoIcon sizes (e.g. 32x32)

No body. The optional type and sizes attributes are only included in the output when non-empty.

#doc.link rel=stylesheet href="style.css"
#doc.link rel=icon href="icon.png" type="image/png" sizes="32x32"

5.7.5. #doc.script

Adds a <script> element. Parameters:

ParameterRequiredDescription
srcNoURL of external script
typeNoScript type (e.g. text/javascript)

Takes optional body (inline script content). Use either the src parameter or body, not both.

#doc.script src="app.js"
#doc.script type=text/javascript src="app.js"
[#doc.script : """
    console.log("Hello from PicoDoc");
    """]

5.7.6. #doc.author

Convenience macro. Renders as <meta name="author" content="...">. Takes body (the author name), no parameters.

#doc.author: Jane Doe

5.7.7. #doc.version

Convenience macro. Renders as <meta name="version" content="...">. Takes body (the version string), no parameters.

#doc.version: 2.0

5.7.8. #doc.datecreated

Convenience macro. Renders as <meta name="datecreated" content="...">. Takes body (the date string), no parameters.

#doc.datecreated: 2025-01-01

5.7.9. #doc.datemodified

Convenience macro. Renders as <meta name="datemodified" content="...">. Takes body (the date string), no parameters.

#doc.datemodified: 2025-06-15

5.7.10. #doc.body

Sets attributes on the <body> element. Parameters:

ParameterRequiredDescription
classNoCSS class for the body element
idNoHTML id for the body element

No body. This is a document-level directive; it does not render as content.

#doc.body class=dark-theme id=app

This produces <body class="dark-theme" id="app">.

5.7.11. #doc.content

Wraps all "loose" top-level content (anything not inside an explicit wrapper macro) in a specified HTML container element. This is a document-level directive that affects the rendering of body items. Parameters:

ParameterRequiredDescription
typeYesWrapper tag (must be one of the 9 wrapper tags)
classNoCSS class for the wrapper element
idNoHTML id for the wrapper element

No body. Only one #doc.content may appear per document. The type value must be one of: article, aside, div, footer, header, main, nav, section, span.

Behavior: during rendering, top-level items whose resolved name is one of the 9 wrapper tags remain outside the content wrapper. All other items (paragraphs, headings, lists, tables, etc.) are collected and wrapped in a single element of the specified type. The wrapper is placed at the position of the first loose item, preserving source order.

#doc.content type=main class=content

[#header : #-: My Site]

#p: This paragraph is loose and will be wrapped.

#p: So will this one.

[#footer : #p: Copyright 2025]

This produces header, then <main class="content"> containing the two paragraphs, then footer.

5.7.12. #doc.toc

Generates a table of contents from document headings. Unlike other doc.* macros, #doc.toc renders in <body> (not <head>). Place it wherever the TOC should appear. Parameters:

ParameterRequiredDescription
levelNoMaximum heading depth to include (default 3)

No body. Renders as a <ul> tree with nested <a> links pointing to heading IDs. Wrap it in a container macro for placement control:

[#nav id=toc class=sidebar-toc :
  #doc.toc level=3
]

All headings (#- through #------) automatically receive an id attribute based on their text content. The slug algorithm lowercases the text, replaces non-alphanumeric characters with hyphens, collapses consecutive hyphens, and strips leading/trailing hyphens. Duplicate heading texts produce unique IDs by appending -2, -3, etc. Heading IDs are always generated, even without #doc.toc, enabling deep linking.

#-: Introduction
#--: Getting Started
#--: Installation

The TOC for these headings produces:

<ul>
<li><a href="#introduction">Introduction</a>
<ul>
<li><a href="#getting-started">Getting Started</a>
</li>
<li><a href="#installation">Installation</a>
</li>
</ul>
</li>
</ul>

Composed with a wrapper:

[#nav id=toc class=sidebar-toc :
  #doc.toc level=3
]

This produces:

<nav id="toc" class="sidebar-toc">
<ul>
...
</ul>
</nav>

5.7.13. #doc.heading.number

Document-level directive that prepends section numbers to headings. Numbers track section coordinates (1., 1.1., etc.) starting at h2. Heading level 1 is always skipped from numbering (HTML recommends a single h1 as the document title). Deeper counters reset when a higher-level heading increments. Headings beyond the configured level render without numbers. Parameters:

ParameterRequiredDescription
levelNoMaximum heading depth to number (default 3)

No body.

#doc.heading.number level=3

#-: Introduction
#--: Background
#---: Details
#--: Motivation
#-: Methods

This produces:

<h1 id="introduction">Introduction</h1>
<h2 id="background">1. Background</h2>
<h3 id="details">1.1. Details</h3>
<h2 id="motivation">2. Motivation</h2>
<h1 id="methods">Methods</h1>

5.7.14. #doc.heading.anchor

Document-level directive that wraps heading content in an anchor link for deep-linking. The heading text is highlighted on hover (styled by CSS). Only headings up to the configured level receive anchors. Parameters:

ParameterRequiredDescription
levelNoMaximum heading depth to add anchors (default 3)

No body.

#doc.heading.anchor level=3
#doc.heading.number

#-: Introduction
#--: Background

This produces:

<h1 id="introduction"><a href="#introduction">Introduction</a></h1>
<h2 id="background"><a href="#background">1. Background</a></h2>

The anchor wraps the full heading content, including the section number when #doc.heading.number is also active.

5.8. Expansion-Time Macros

These macros are fully resolved during evaluation and do not appear in the final AST.

The #// macro (also #comment) removes its body from the output. Takes body, no parameters.

The #set macro defines a user macro. See the User-Defined Macros section below.

5.8.1. #ifeq

String equality test. Returns body if lhs equals rhs, empty string otherwise. Parameters:

ParameterRequiredDescription
lhsYesLeft-hand side value
rhsYesRight-hand side value

Takes body.

5.8.2. #ifne

String inequality test. Returns body if lhs does not equal rhs, empty string otherwise. Same parameters as #ifeq. Takes body.

5.8.3. #ifset

Tests whether a macro is defined. Returns body if the named macro exists, empty string otherwise. Parameters:

ParameterRequiredDescription
nameYesMacro name to test

Takes body.

5.8.4. #include

Inserts the contents of another file. The filename is provided as the body of the macro call. The included file is parsed and expanded as if its contents appeared at the call site. Parameters:

ParameterRequiredDescription
literalNoIf "true", include as raw text without parsing

Takes body (the filename). Forward slashes are used as path separators on all platforms. Backslashes in the filename are normalized to forward slashes.

[#include : header.pdoc]
#include: footer.pdoc
[#include literal=true : code.js]

When literal=true is set, the file contents are included as raw text without parsing or macro expansion. This is useful for including source code or other non-PicoDoc content.

5.9. Alternate Forms

All 11 alternate forms mapped to their canonical commands:

AlternateCanonical
#h1#-
#h2#--
#h3#---
#h4#----
#h5#-----
#h6#------
#comment#//
#link#>
#b#**
#i#__
#li#*

6. User-Defined Macros

6.1. Defining Macros with #set

The #set macro defines a new macro. It must always appear in bracketed form and at the top level (not inside macro bodies).

[#set name=version : 1.0]
[#set name=greeting target=? body=? : Dear #target, #body Kind regards.]
[#set name=box style=default body=? : [#p : (#style) #body]]

The name argument is required and specifies the macro name. Additional arguments become the macro's parameters.

6.2. Parameters

Parameters can be:

If the macro uses a body parameter, it must be declared as body=? (required) or body=default and must be the last parameter.

Default values are evaluated during the resolution pass after all definitions are collected. A default value may be a macro call, which will be expanded during resolution.

6.3. Calling User Macros

User macros are called exactly like builtin macros:

#version
[#greeting target=World : thank you for your support.]
[#box style=fancy : Content here.]

A macro with no arguments is essentially a variable:

[#set name=version : 1.0]
The version is #version.

6.4. Out-of-Order Definitions

Macros may be used before they are defined. The compiler collects all #set definitions in a first pass before resolving and expanding:

#p: The project is #project-name.
[#set name=project-name : PicoDoc]

6.5. Duplicate Detection

Duplicate macro definitions at the same scope are an immediate error.

6.6. Scope and Shadowing

Macro invocation creates a new scope at the call site. Local argument names shadow any definitions outside the macro call with the same name.

6.7. Overriding Builtin Macros

User-defined macros can shadow render-time builtins such as #p, #b, #code, #link, headings, lists, wrappers, and table cells. This lets you customise how standard elements are rendered without losing access to the original behaviour.

Expansion-time builtins (#set, #ifeq, #ifne, #ifset, #include, #//, #table) cannot be overridden. Attempting to define a macro with the same name as an expansion-time builtin is an error.

[#set name=p body=? : [#builtin.p : [#b : #body]]]
#p: Every paragraph is now bold.

6.7.1. The #builtin.* Prefix

Inside a user macro definition (or anywhere else), use #builtin.name to call the original builtin, bypassing the user-defined override. Alternate form resolution applies after the prefix, so #builtin.b resolves to the builtin #**.

[#set name=code body=? :
  [#div class=code-wrapper :
    [#builtin.code : #body]
  ]
]
[#code language=python : """
    print("hello")
    """]

The #builtin.* namespace is reserved. Defining a macro whose name starts with builtin. is an error:

[#set name=builtin.foo : bar]
[#//: Error: reserved namespace: cannot define 'builtin.foo']

If the name after builtin. does not match any builtin macro (after alternate form resolution), an evaluation error is raised.

6.8. Macro References as Argument Values

A zero-argument macro (variable) can be passed as an argument value using the #identifier form:

[#set name=site-url : https://example.com]
[#link to=#site-url : Our site]

7. Conditionals

PicoDoc has no expression language. Conditions are limited to simple comparisons via specialised macros.

The #ifeq macro returns body if lhs equals rhs:

[#ifeq lhs=#mode rhs=draft : This document is a draft.]

The #ifne macro returns body if lhs does not equal rhs:

[#ifne lhs=#mode rhs=production : Not yet published.]

The #ifset macro returns body if the named macro is defined:

[#ifset name=env.author : Written by [#env.author].]

Conditionals can be nested:

[#ifeq lhs=#mode rhs=draft :
  [#ifset name=env.author : Draft by [#env.author].]
]

8. Includes

The #include macro inserts the contents of another file at the call site. The filename is provided as the body. The included file is parsed and expanded as if it appeared inline.

[#include : header.pdoc]

#h2: Main Content

#p: The body of the document.

[#include : footer.pdoc]

A common pattern is to use includes for shared headers and footers across multiple documents.

To include a file as raw text without parsing (e.g. source code), use the literal parameter:

[#include literal=true : code.js]

9. Global Environment (env.*)

The env.* namespace provides global values accessible to all macros, including external filters.

9.1. Defining Environment Values

Values can be set from three sources (listed in ascending precedence):

  1. Config file : under the [env] section in picodoc.toml
  2. CLI arguments : using -e name=value (repeatable)
  3. Document-level #set : for example [#set name=env.mode : draft]

Document-level definitions have the highest precedence. The document author has final say.

9.2. Accessing Environment Values

Environment values are accessed as zero-argument macros:

#env.mode
[#env.author]

They are inherited through nested macro calls. A macro body can reference #env.mode and it resolves from the global environment.

9.3. Immutability

Environment values are global and immutable once set. They cannot be overridden locally within macro bodies. This prevents confusing action-at-a-distance where a macro silently changes global state.

10. External Filters

External filters allow users to define macros as command-line programs in any language.

10.1. Protocol

The filter receives a JSON object on stdin containing all named arguments (including body if present) and all env.* values:

{"arg1": "value1", "body": "the body text", "env": {"mode": "draft"}}

The filter returns PicoDoc markup on stdout. The multi-pass evaluator expands any macro calls in the output on subsequent passes.

A filter that wants to return final HTML (preventing re-expansion) wraps its output in #literal.

10.2. Discovery

The converter searches for filter executables in this order:

  1. A filters/ directory alongside the document
  2. Directories specified via --filter-path or config
  3. The system $PATH

The executable name maps to the macro name.

10.3. Timeout

A configurable timeout (default 5 seconds, set via --filter-timeout or config) kills long-running filters. Non-zero exit codes are treated as expansion errors with the filter's stderr included in the error message.

11. Configuration File

Place a picodoc.toml next to your input file (or point to one with the --config flag). CLI flags override config values.

[env]
title = "My Document"
author = "Jane Doe"

[css]
files = ["style.css", "code.css"]

[js]
files = ["main.js"]

[meta]
viewport = "width=device-width, initial-scale=1"

[filters]
paths = ["./filters"]
timeout = 10.0

12. Command-Line Interface

Usage:

uv run picodoc [OPTIONS] INPUT

12.1. Options

FlagDescription
-o FILEWrite output to FILE instead of stdout
-e NAME=VALUESet an environment variable (repeatable)
--css FILEInject a CSS file into the head (repeatable)
--js FILEInject a JS file into the head (repeatable)
--meta NAME=VALUEAdd a meta tag (repeatable)
--filter-path DIRExtra filter search directory (repeatable)
--filter-timeout SECSFilter execution timeout (default 5.0)
--config FILEConfig file path (auto-discovers picodoc.toml)
--watchWatch for changes and recompile on save
--debugDump the AST to stderr

12.2. Exit Codes

CodeMeaning
0Success
1Syntax error (lex or parse failure)
2Evaluation error or invalid arguments

13. Expansion Model

PicoDoc processes a document through a pipeline of stages:

  1. Lexing : source text is tokenized into a stream of tokens.
  2. Parsing : tokens are assembled into an AST (abstract syntax tree). The parser is definition-independent; it does not need to know which macros are defined.
  3. Collection : all #set definitions are gathered from the AST without evaluating them.
  4. Resolution : definitions are resolved and default values are evaluated, which may themselves require expansion passes.
  5. Expansion : the evaluator walks the AST expanding macro nodes, marks fully-expanded nodes as complete, and re-walks until convergence. Expansion-time builtins (#set, #ifeq, #include, #comment, #table pipe form) are resolved and removed. Only text nodes and render-time builtins remain.
  6. Rendering : the renderer maps the expanded AST to HTML, validates nesting, handles HTML escaping, and wraps the output in a proper document structure (doctype, head, body).

13.1. Multi-Pass Expansion

Expansion is convergence-based. The evaluator keeps walking the AST until no unexpanded macro calls remain. This allows macros to produce output containing other macro calls that are expanded on subsequent passes (e.g. #table pipe form emits #tr/#th/#td calls).

13.2. Call Stack Depth

A global maximum call stack depth (configurable, default 64) catches infinite or excessive recursion. Exceeding this limit is an evaluation error.

13.3. Preventing Re-Expansion

The #literal macro prevents its body from being re-expanded. This is useful for: