PicoDoc Tutorial

1. What is PicoDoc?

PicoDoc is a macro-based markup language that compiles to HTML. Everything in PicoDoc (headings, bold text, links, user-defined templates) is a macro call. This single abstraction keeps the language regular and predictable: once you learn the macro syntax, you know the entire language.

2. Installation - Python version

The PicoDoc Python project requires Python 3.14+ and uv.

git clone <repo-url> picodoc
cd picodoc
uv sync

Verify the installation:

uv run picodoc --help

3. Installation - C version

The C version requires a C compiler:

git clone <repo-url> picodoc-c
cd picodoc
make

4. Your First Document

Create a file called hello.pdoc:

#doc.title: Hello, PicoDoc
#-: Hello, PicoDoc

This is my first document.

Compile it to HTML (Python version):

uv run picodoc hello.pdoc -o hello.html

Or (C version):

path/picodoc hello.pdoc -o hello.html

Open hello.html in your browser. You should see a heading and a paragraph. The #doc.title macro sets the browser tab title, while #- creates a visible heading. The bare text was automatically wrapped in a <p> tag because PicoDoc treats any text not inside a macro call as an implicit paragraph.

5. Headings and Structure

PicoDoc uses #doc.title for the document's <title> (shown in the browser tab) and separate heading macros for visible headings in the body:

#doc.title: Page Title (in browser tab only)
#-: Level 1 (visible heading)
#--: Level 2 (Section)
#---: Level 3 (Subsection)
#----: Level 4
#-----: Level 5
#------: Level 6

The named forms #h1 through #h6 can also be used:

#h1: Level 1 (same as #-)
#h2: Level 2 (same as #--)
#h3: Level 3 (same as #---)
#h4: Level 4 (same as #----)
#h5: Level 5 (same as #-----)
#h6: Level 6 (same as #------)

Use #hr for a horizontal rule and #// (or #comment) for text that should not appear in the output:

#hr

#//: This is a note to myself — not rendered.

6. Paragraphs

Bare text is automatically wrapped in <p> tags. A blank line separates paragraphs:

This is the first paragraph. It can span
multiple lines.

This is the second paragraph.

You can also use #p explicitly. The paragraph body form lets you write the body on the following lines:

#p: An explicit single-line paragraph.

#p:
A multi-line paragraph using
the paragraph body form.

7. Inline Formatting

7.1. Bold and Italic

Use #** (or #b) for bold and #__ (or #i) for italic:

This is #**"bold" text.
This is #__"italic" text.

For longer spans, use the bracketed form:

This is [#b : bold text that spans several words].
This is [#i : italic text in brackets].

Bold and italic can be nested:

This is [#b : bold with [#i : italic inside]].

For combined bold+italic, use #*_ (strong wrapping em) or #_* (em wrapping strong):

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

Use #> (or #link) with a to argument and optional body for the link text:

Visit [#> to="https://example.com" : Example Site] today.
[#link to="https://example.com" : Using the alternate form]

If no body is provided, the to value is used as the link text. If to contains no :// and no /, it is treated as a fragment reference (e.g. to=section1 produces href="#section1").

7.3. Mixed Inline Example

All inline formatting can appear together in a paragraph:

A paragraph with #**"bold", #__"italic", and
[#link to="https://example.com" : a link] all in one line.

8. Lists

8.1. Unordered Lists

Wrap list items in #ul. Each item uses #* (or #li):

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

8.2. Ordered Lists

Use #ol instead of #ul:

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

8.3. Items with Formatting

List items can contain any inline macro:

[#ul :
  #*: A #**"bold" item
  #*: An #__"italic" item
  #*: A [#link to="https://example.com" : linked] item
]

8.4. Nested Lists

Use the bracketed form of #* and nest a list inside:

[#ul :
  #*: Top-level item
  [#* : Item with sublist
    [#ul :
      #*: Nested A
      #*: Nested B
    ]
  ]
  #*: Another top-level item
]

9. Tables

The simplest way to make a table is with pipe-delimited rows. The first row becomes headers:

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

Cells can contain inline macros:

#table:
  Feature | Supported
  Bold | [#**"Yes"]
  Links | [#link to="https://example.com" : Yes]

For full control (colspan, custom structure), use the explicit form with #tr, #th, and #td:

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

10. Code

For inline code, use #~:

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

For code blocks, use #code with a raw string body and an optional language parameter:

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

The indentation of the closing """ delimiter determines how much leading whitespace is stripped from every line.

You can also use a paragraph body (colon followed by a newline). Body content is automatically dedented — the common leading whitespace is stripped:

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

To show PicoDoc syntax itself without it being interpreted, use #literal:

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

11. User-Defined Macros

11.1. Simple Variables

Define a variable with #set. A macro with no parameters is just a named value:

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

11.2. Macros with Arguments

Add parameters to create reusable templates. Mark required parameters with ? and provide defaults with =value:

[#set name=greeting target=? : Hello, #target!]
[#greeting target=World]
[#greeting target=Alice]
[#set name=box style=default body=? : [#p : (#style) #body]]
[#box : Content with default style.]
[#box style=fancy : Content with fancy style.]

11.3. Macros with Body

If your macro accepts a body, declare it as body=? (required) and it must be the last parameter:

[#set name=greeting target=? body=? : Dear #target, #body Kind regards.]
[#greeting target=World : thank you for your support.]

11.4. Out-of-Order Use

Macros can be used before they are defined. PicoDoc collects all definitions first, then expands:

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

12. Overriding Builtin Macros

You can redefine render-time builtins like #p, #b, #code, and #link to customise how standard elements are rendered. Use the #builtin.name prefix to call the original builtin from inside your override:

[#set name=p body=? : [#builtin.p : [#b : #body]]]
This paragraph will be bold.

Here the user-defined #p wraps every paragraph body in bold, delegating to the real #p via #builtin.p. This works for any render-time builtin:

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

Expansion-time builtins (#set, #ifeq, #ifne, #ifset, #include, #comment, #table) cannot be overridden.

The builtin.* namespace is reserved. You cannot define a macro whose name starts with builtin..

13. Content Wrappers

PicoDoc provides wrapper macros (#div, #section, #span, #nav, #header, #footer, #main, #article, #aside) for grouping content in HTML container elements. They accept optional class and id parameters:

[#div class=card :
  #--: Card Title
  #p: Card body text here.
]

For page-level layout, use #doc.content to automatically wrap loose content (anything not inside an explicit wrapper) in a container element:

#doc.content type=main class=content

[#header : #-: My Site]
#p: This paragraph gets wrapped in <main class="content">.
[#footer : #p: Copyright]

The header and footer remain outside the <main> element, while loose paragraphs and headings are collected inside it.

14. String Literals

14.1. Interpreted Strings

Delimited by single double quotes. Escape sequences are processed:

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

To embed macro calls inside a string, use \[...\] (code mode):

[#set name=version : 1.0]
#p"PicoDoc version \[#version] is ready."

14.2. Raw Strings

Delimited by three or more double quotes. Nothing inside is processed:

"""This is raw: \n is literal, #name is not expanded."""

14.3. Whitespace Stripping

When a string starts on the next line after the delimiter and the closing delimiter is on its own line, leading whitespace matching the closing delimiter's indentation is stripped from all lines:

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

The four spaces before each line are stripped, producing clean output.

The same kind of dedenting applies to colon body content (paragraph and bracketed forms). The longest common leading whitespace across all non-blank lines is stripped automatically, so you can indent body content to match its macro without the indentation appearing in the output.

15. Conditionals

PicoDoc provides three conditional macros:

The #ifeq macro tests string equality:

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

The #ifne macro tests string inequality:

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

The #ifset macro tests if a macro is defined:

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

A practical pattern is a draft watermark controlled by an environment variable:

[#ifeq lhs=[#env.mode] rhs=draft :
  #p: DRAFT — not for distribution.
]

Then compile with uv run picodoc -e mode=draft doc.pdoc -o doc.html.

16. Including Other Files

The #include macro inserts another file at the call site. The filename is provided as the body:

[#include : header.pdoc]

#--: Main Content

#p: The body of the document.

[#include : footer.pdoc]

This is the standard pattern for shared headers and footers across a set of documents.

To include a file as raw text without parsing, use the literal parameter:

[#include literal=true : code.js]

17. Document Metadata

Use the doc.* namespace for HTML head elements:

#doc.lang: en
#doc.body class=dark-theme
#doc.meta name=viewport content="width=device-width, initial-scale=1"
#doc.link rel=stylesheet href="style.css"
#doc.script src="app.js"
#doc.author: Jane Doe

Use #doc.body to set class and id attributes on the <body> element.

18. Table of Contents

Use #doc.toc to generate a table of contents from your document's headings. It renders a <ul> tree, so wrap it in a container macro for placement control:

[#nav id=toc :
  #doc.toc
]

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

By default, headings up to level 3 are included. Use the level parameter to control depth:

[#nav id=toc :
  #doc.toc level=2
]

All headings automatically receive id attributes based on their text, enabling deep linking even without a TOC.

19. Environment Variables

The env.* namespace provides global values. They can be set from three sources:

CLI:

uv run picodoc -e mode=draft -e author="Alice" doc.pdoc -o doc.html

Config file (picodoc.toml):

[env]
mode = "draft"
author = "Alice"

Document-level #set:

[#set name=env.mode : draft]

Document-level definitions have the highest precedence, then CLI, then config.

Access environment values as zero-argument macros:

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

20. CLI Quick Reference

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
--watchWatch for changes and recompile
--debugDump the AST to stderr

Watch mode recompiles automatically whenever the input file changes:

uv run picodoc --watch doc.pdoc -o doc.html

Exit codes:

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

21. Configuration File

Place a picodoc.toml next to your input file. CLI flags override config values.

[env]
author = "Jane Doe"

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

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

22. Next Steps

You now know enough to write real documents in PicoDoc. For a complete and systematic description of every feature, see the Language Reference in the file reference.pdoc.

Additional topics covered in the reference: