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.
What it produces
Section titled “What it produces”Given a codebase with annotated structs across multiple headers, the rule produces one file:
// 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.
Annotating your structs
Section titled “Annotating your structs”struct [[codegen::MarkdownDocs]] ConnectionOptions { std::string host; uint16_t port; uint32_t timeout_ms;};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.
The rule files
Section titled “The rule files”Directory.codegen/rules/MarkdownDocs/
- config.yaml
- transform.luau
- grouping.luau
- preamble.luau
config.yaml
Section titled “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
Section titled “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)endEvery 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
Section titled “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"endtransform.luau
Section titled “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") })endRunning the rule
Section titled “Running the rule”codegen -i ./include -r .codegen/rules -a MarkdownDocs- The engine walks
./include, finds all headers. - The codex pipeline parses + analyzes each header.
- Each struct annotated
[[codegen::MarkdownDocs]]matches; the handler runs and emits a## ...section. grouping.luaurewrites every entity’s output path togenerated/docs/api-reference.md.- The engine assembles the file: banner + preamble (deduplicated) + each entity’s section in match order.
- The file is written via temp-and-rename.
Extending this example
Section titled “Extending this example”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/") ~= nilresult[tostring(ent.registryId)] = isInternal and "generated/internal-api.md" or "generated/public-api.md"- This example demonstrates fan-in: N annotated structs from any number of headers ⇒ 1 output file.
- The
grouping.luauscript 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 viaget_node()in the handler. - Paths in grouping must stay under the project root (CWD).