Skip to content

Markdown Docs: N structs ⇒ 1 file

The MarkdownDocs example is the clearest demonstration of codegen’s grouping capability. It consolidates every annotated C++ struct, regardless of which header it lives in, into a single api-reference.md. No post-processing. No shell script. The engine handles the fan-in.

Given a codebase with annotated structs across multiple headers, the rule produces one file:

generated/docs/api-reference.md
// Generated with Codegen - DO NOT EDIT!!!
# API Reference
> This file is auto-generated. Do not edit manually.
---
## `network::ConnectionOptions`
| Field | Type |
|---|---|
| `host` | `string` |
| `port` | `uint16_t` |
| `timeout_ms` | `uint32_t` |
---
## `storage::CacheConfig`
| Field | Type |
|---|---|
| `maxEntries` | `size_t` |
| `evictionPolicy` | `EvictionPolicy` |
---

The // Generated with Codegen - DO NOT EDIT!!! banner is emitted by the engine. The # API Reference header and the auto-generated notice come from the rule’s preamble, which runs once per (rule, outputFile) pair and is deduplicated by content at output assembly. Each struct contributes one section, in match order.

include/network/connection.hpp
struct [[codegen::MarkdownDocs]] ConnectionOptions {
std::string host;
uint16_t port;
uint32_t timeout_ms;
};
include/storage/cache.hpp
struct [[codegen::MarkdownDocs]] CacheConfig {
size_t maxEntries;
EvictionPolicy evictionPolicy;
};

Both headers, different directories. The engine finds both; the grouping script routes both to the same output file.

  • Directory.codegen/rules/MarkdownDocs/
    • config.yaml
    • transform.luau
    • grouping.luau
    • preamble.luau
config.yaml
version: 1
output:
language: markdown
# No outputDirectory or outputNameTemplate - grouping.luau controls all paths.

Because grouping handles all routing, the config does not set outputDirectory or outputNameTemplate.

grouping.luau
-- Routes ALL matched structs into a single consolidated output file.
-- Input file topology is irrelevant; structs from any header end up here.
return function(input)
local data = json.decode(input)
if not data then error("failed to decode input") end
local OUTPUT = "generated/docs/api-reference.md"
local result = {}
for _, ent in ipairs(data.entities) do
result[tostring(ent.registryId)] = OUTPUT
end
return json.encode(result)
end

Every entity, one output path. The path is resolved against the project root (CWD) — escapes are rejected with E005. Each entity passed to grouping has only the summary fields registryId, qualifiedName, structName, namespaces, inputFile, defaultPath.

preamble.luau
-- Emitted once per (rule, outputFile) pair. Deduplicated by content at output assembly.
return function(input)
return "# API Reference\n\n"
.. "> This file is auto-generated. Do not edit manually.\n\n"
.. "---\n\n"
end
transform.luau
-- Generates a Markdown section for each annotated struct.
-- grouping.luau consolidates all sections into a single api-reference.md.
return function(input)
local node = json.decode(input)
if not node then error("failed to decode input") end
local structName = node.identifier and node.identifier.name
if not structName or structName == "" then error("struct has no name") end
-- Build the fully-qualified name using namespace context
local namespaces = node._namespaces or {}
local nsPrefix = table.concat(namespaces, "::")
local qualifiedName = nsPrefix ~= "" and (nsPrefix .. "::" .. structName) or structName
-- Build field table rows
local rows = {}
local function typeName(tSig)
if not tSig or not tSig.identifier then return "?" end
return tSig.identifier.name or "?"
end
local function processVar(varNode)
local fname = varNode.identifier and varNode.identifier.name or varNode.name
if not fname then return end
local t = typeName(varNode.typeSignature)
fname = fname:gsub("|", "\\|")
t = t:gsub("|", "\\|")
table.insert(rows, "| `" .. fname .. "` | `" .. t .. "` |")
end
for _, varNode in ipairs(node.memberVariables or {}) do
if varNode.kind == "Variable" then
processVar(varNode)
elseif varNode.kind == "VariableGroup" then
for _, v in ipairs(varNode.variables or {}) do processVar(v) end
end
end
-- Assemble the section
local lines = { "## `" .. qualifiedName .. "`", "" }
if #rows > 0 then
table.insert(lines, "| Field | Type |")
table.insert(lines, "|---|---|")
for _, row in ipairs(rows) do table.insert(lines, row) end
else
table.insert(lines, "_No public member variables._")
end
table.insert(lines, "")
table.insert(lines, "---")
return json.encode({ source = table.concat(lines, "\n") })
end
Terminal window
codegen -i ./include -r .codegen/rules -a MarkdownDocs
  1. The engine walks ./include, finds all headers.
  2. The codex pipeline parses + analyzes each header.
  3. Each struct annotated [[codegen::MarkdownDocs]] matches; the handler runs and emits a ## ... section.
  4. grouping.luau rewrites every entity’s output path to generated/docs/api-reference.md.
  5. The engine assembles the file: banner + preamble (deduplicated) + each entity’s section in match order.
  6. The file is written via temp-and-rename.

Add descriptions. Accept a string attribute argument: [[codegen::MarkdownDocs("Optional per-entity description")]]. The handler reads it via node.attributes[i] (look for attr.ns == "codegen" and attr.name == "MarkdownDocs", then attr.arguments[1]).

Sort alphabetically. Inside grouping.luau, sort data.entities by entity.qualifiedName before assigning paths. Output assembly visits entities in the post-grouping order.

Multi-section output. Route internal entities to internal-api.md and public entities to public-api.md:

local isInternal = ent.inputFile:find("internal/") ~= nil
result[tostring(ent.registryId)] = isInternal
and "generated/internal-api.md"
or "generated/public-api.md"
Key Takeaways
  • This example demonstrates fan-in: N annotated structs from any number of headers ⇒ 1 output file.
  • The grouping.luau script is the only change needed to switch from 1:1 to fan-in routing.
  • The preamble runs once per (rule, outputFile) pair; the engine deduplicates the result by content per output file.
  • Grouping sees only summary fields (registryId, qualifiedName, structName, namespaces, inputFile, defaultPath); grouping runs before handlers, so handler output is unavailable. For AST-level routing decisions, look entities up via get_node() in the handler.
  • Paths in grouping must stay under the project root (CWD).