TagProcessor Interface
The TagProcessor<T> interface
export interface TagProcessor<T = unknown> { tagName: string; /** Bundle property path where extracted data should be stored (e.g., 'api.actions', 'requirements') */ bundlePath?: string; /** If true, promote tag's id/title to bundle root (default: false) */ promotesIdentity?: boolean; /** If set, this tag's data will be collected into the named aggregate bundle */ aggregatePath?: string; /** Key name for the source bundle ID in the aggregate entry (e.g., 'domainId', 'featureId'). Defaults to 'sourceId' */ aggregateIdKey?: string; /** Authoritative Zod schema for this tag's attributes (set to TagClass.Schema). Used by generate-schema.ts to emit markdoc.config.mjs. */ zodSchema?: z.ZodObject<any>; /** Schema definition for editor support (attributes, descriptions, types) */ schema?: { attributes?: Record<string, SchemaAttribute>; description?: string; }; /** If true, the agent-pass walk() continues into children after this processor fires. */ isContainer?: boolean; /** * Declares where documents of this type appear in the public sidebar. * `section` is the top-level section; `group` is the optional sub-group label. * `weight` controls ordering within the section (lower = earlier). */ sidebar?: { section: 'Product' | 'Specification' | 'Implementation' | 'Journal'; group?: string; weight: number; collapsed?: boolean; groupByDomain?: boolean; }; match(node: Markdoc.Node): boolean; extract(file: string, node: Markdoc.Node, ast: Markdoc.Node, context: CompilerContext): T; render?(fm: Frontmatter, ast: Markdoc.Node, data: T, context: CompilerContext): string; merge?(bundle: Record<string, unknown>, data: T): void;}Declarative properties
Most wiring is handled automatically through declarative properties on the processor:
| Property | Purpose |
|---|---|
bundlePath | Dot-path where extract() data is pushed in the agent bundle (e.g. 'api.actions', 'requirements'). Replaces manual merge() for leaf processors. |
promotesIdentity | When true, the tagβs id and title are promoted to the bundle root. Used by root document tags (feature, domain, role, etc.). |
aggregatePath | Name of the aggregate bundle this tag feeds (e.g. 'glossary', 'policies'). Items are collected automatically from all documents. |
aggregateIdKey | Key name for the source document ID in aggregate entries. Defaults to 'sourceId'. |
zodSchema | Tag classβs static Schema (e.g. Rule.Schema). Used by generate-schema.ts to auto-generate markdoc.config.mjs β no manual schema registration needed. |
isContainer | When true, the agent-pass walker descends into children after this processor fires. Without it, child tags with processors are silently skipped. |
sidebar | Declares where this document type appears in the public Starlight sidebar (section, group, weight). |
Recommendation: For complex tags, the processor should act as a thin adapter that delegates to a Core Model (see core/tags/). This encapsulates validation, tree traversal, and rendering logic in a testable class.
extract(file: string, node: Node, ast: Node, context: CompilerContext) { return new MyTagModel(node).extract();}The extract() signature
extract(file: string, node: Markdoc.Node, ast: Markdoc.Node, context: CompilerContext): T| Parameter | Description |
|---|---|
file | Relative content file path |
node | The matched Markdoc tag node |
ast | Full document AST root β use for cross-node lookups within the same file |
context | Compiler context β tracker, cross-document index maps, entity links |
The render() signature
render(fm: Frontmatter, ast: Markdoc.Node, data: T, context: CompilerContext): string| Parameter | Description |
|---|---|
fm | Parsed frontmatter of the current document |
ast | Full document AST root β use ast.children?.find(...) to locate nodes |
data | The value returned by extract() for this document |
context | Compiler context β tracker, cross-document index maps, entity links |
ast is the full document root, not the matched node. Processors navigate back to their node inside render via ast.children?.find(n => n.tag === this.tagName).
Bundle placement β how extract() data reaches the agent bundle
The agent pass determines where extract() data goes based on the processorβs declarative properties, checked in this order:
merge()β if the processor implementsmerge(), it is called directly. Use for top-level processors that own the entire bundle shape (feature, domain, role, story, milestone, manifest).bundlePathβ if set, data is pushed into the bundle at the specified dot-path viaObjectPath.push(bundle, bundlePath, data). Use for leaf processors that produce array items (requirement, criterion, policy, action, event, term).promotesIdentityβ if true, the tagβsidandtitleare promoted to the bundle root (already handled before merge/bundlePath).- Fallback β data fields are spread directly onto the bundle (legacy path).
Rule of thumb: if extract() returns an object to be spread into the top-level bundle, implement merge. If it returns a single item to append to an array, set bundlePath to the target array path.
Registering child tag processors
For child tags whose data is parsed by their parent processor and only need schema visibility in the editor, use the shorthand helpers instead of writing a full processor:
// Preferred β derives schema from the Tag domain classregisterTagClass(MyChildTag, 'Description of the child tag');
// Legacy β manual attribute mapregistry.register(schemaOnly('childtag', { name: { type: 'string', required: true } }, 'Description'));Graceful Validation β Compiler Pass Invariant
The compiler pass must never throw or crash due to validation errors. All validation β schema checks, required attributes, naming conventions, cross-document references β is graceful. Issues are collected by the ValidationTracker singleton (tracker) and reported after the pass completes. The pass always finishes processing all files.
This invariant applies to:
extract()implementations in processors β wrap suspicious attribute reads defensively and emit viatracker.addRuntimeIssue()instead of throwing- Aggregate-building utilities β sort comparators, map/filter helpers, and any post-processing over collected bundles must guard against
undefinedfields (e.g.(a.term ?? '').localeCompare(b.term ?? '')) passes/agent/index.tsβ the agent pass sorts, filters, and maps all bundles; never assume a field is present
How to record an issue without crashing:
tracker.addRuntimeIssue(file, 'Descriptive message', 'error', 'tag:tagname.attribute');| Parameter | Values |
|---|---|
file | Relative content file path |
message | Human-readable description |
severity | 'error' or 'warning' |
path | Dot-path of the failing attribute (used for deduplication in the lint workflow) |
Never use throw inside a processor or pass for validation failures β always use tracker.addRuntimeIssue.