Skip to content
Extend StarSpec
AutoXXS (320px)XS (375px)SM (640px)MD (768px)LG (1024px)XL (1280px)XXL (1536px)
SketchMaterialiOSTamagui
DataInjectionKeyPatternsServiceTransactionProcessResearchProductQualityPerformanceSpecDomainFunctionTechnologyArchitectureConfigMiddlewareDataDatabaseDrizzleMigrationModelop-sqliteSchemaSQLState ManagementDraftKeystoneMergePatchPatchesPersistenceReactiveRedoStoreUndoTestingDeviceFactoryIsolationTypeScriptZodTopicsCommunicationBidsNVCDesignDesign ImplicationsEducationPedagogyFoundationsPsychologyAttachmentFloodingRelatingAuthentic RelatingUIEditorReact Native

Extend StarSpec

Use this loop when a user asks you to add a new tag, document type, or aggregated page to StarSpec. You are modifying the compiler itself β€” not authoring spec content. Read starspec/architecture and starspec/adding-a-tag before starting if you have not already.

Dependency check

Before starting, verify you have access to the StarSpec source:

  • packages/compiler/src/types.ts
  • packages/compiler/src/core/Tag.ts and core/tags/
  • packages/compiler/src/core/AstWalker.ts
  • packages/compiler/src/core/AggregateProcessor.ts
  • packages/compiler/src/passes/agent/index.ts
  • packages/compiler/src/passes/public/index.ts
  • packages/compiler/src/passes/public/phases/
  • packages/compiler/tests/agent-pass.test.ts
  • packages/compiler/tests/public-pass.test.ts

If these files are not accessible, stop and ask the user to open the StarSpec repository.

Phase 1 β€” Concept

Ask:

  1. What real-world spec artefact does this tag model? (e.g. β€œa business rule”, β€œa persona”, β€œa data contract”)
  2. Where does it belong? Inside an existing tag (child), or as a new top-level document type (standalone *.{type}.mdoc file)?
  3. Who consumes it β€” agents, human readers, or both?

Do not proceed until you have clear answers to all three.

Phase 2 β€” Schema design

Design the tag’s Markdoc surface:

\{% newtag attribute="value" scope="public agent" %\}
Prose body (if any).
\{% /newtag %\}

Document each attribute:

AttributeTypeRequiredNotes
…………

Rules to confirm:

  • Does scope propagate from a parent or must it be declared explicitly?
  • Does the tag have a prose body, or is it attribute-only (self-closing /%\})?
  • Can it appear multiple times as a sibling? Does order matter?
  • Are there sibling-tag blank-line constraints (Markdoc parses blank lines as block boundaries β€” contiguous siblings must not have blank lines between them)?

Phase 3 β€” Agent bundle shape

Decide what structured data the agent pass extracts:

{
"fieldOne": "string value",
"fieldTwo": ["array", "of", "strings"]
}
  • Does it add a top-level field to SpecBundle, or does it nest inside an existing field?
  • What gets skipped (scope-filtered, empty values)?
  • What bundlePath should the processor declare? (e.g. 'requirements', 'api.actions')
  • Should this tag feed an aggregate bundle? If so, what aggregatePath and aggregateIdKey?

Bundle placement decision: Decide whether your processor owns the entire bundle shape (use merge()) or produces individual array items (use bundlePath). See the full rules:

The TagProcessor<T> interface

TagProcessor<T> generic interface definition
compilerinterfacetypescript tagprocessor-interface
packages/compiler/src/registry.ts
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:

PropertyPurpose
bundlePathDot-path where extract() data is pushed in the agent bundle (e.g. 'api.actions', 'requirements'). Replaces manual merge() for leaf processors.
promotesIdentityWhen true, the tag’s id and title are promoted to the bundle root. Used by root document tags (feature, domain, role, etc.).
aggregatePathName of the aggregate bundle this tag feeds (e.g. 'glossary', 'policies'). Items are collected automatically from all documents.
aggregateIdKeyKey name for the source document ID in aggregate entries. Defaults to 'sourceId'.
zodSchemaTag class’s static Schema (e.g. Rule.Schema). Used by generate-schema.ts to auto-generate markdoc.config.mjs β€” no manual schema registration needed.
isContainerWhen true, the agent-pass walker descends into children after this processor fires. Without it, child tags with processors are silently skipped.
sidebarDeclares 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.

Core Model adapter delegation pattern
compilerpatterncore-model processor-core-model-delegation
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
ParameterDescription
fileRelative content file path
nodeThe matched Markdoc tag node
astFull document AST root β€” use for cross-node lookups within the same file
contextCompiler context β€” tracker, cross-document index maps, entity links

The render() signature

render(fm: Frontmatter, ast: Markdoc.Node, data: T, context: CompilerContext): string
ParameterDescription
fmParsed frontmatter of the current document
astFull document AST root β€” use ast.children?.find(...) to locate nodes
dataThe value returned by extract() for this document
contextCompiler 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:

  1. merge() β€” if the processor implements merge(), it is called directly. Use for top-level processors that own the entire bundle shape (feature, domain, role, story, milestone, manifest).
  2. bundlePath β€” if set, data is pushed into the bundle at the specified dot-path via ObjectPath.push(bundle, bundlePath, data). Use for leaf processors that produce array items (requirement, criterion, policy, action, event, term).
  3. promotesIdentity β€” if true, the tag’s id and title are promoted to the bundle root (already handled before merge/bundlePath).
  4. 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 class
registerTagClass(MyChildTag, 'Description of the child tag');
// Legacy β€” manual attribute map
registry.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 via tracker.addRuntimeIssue() instead of throwing
  • Aggregate-building utilities β€” sort comparators, map/filter helpers, and any post-processing over collected bundles must guard against undefined fields (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');
ParameterValues
fileRelative content file path
messageHuman-readable description
severity'error' or 'warning'
pathDot-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.

Phase 4 β€” Public render design

Describe the Starlight page or page section:

  • If child tag: what Markdown section does it render inside the parent page? (heading level, table vs prose vs list)
  • If new document type: what is the full page structure? (frontmatter, metadata line, sections)
  • If aggregated page: create an AggregateProcessor in processors/aggregates/. Implement collect(), render(), and optionally sidebarEntry(). Register it in processors/aggregates/index.ts. Then add a sidebar triplet in starspec.config.js β€” see starspec/sidebar-config for the full entry ID reference and configuration syntax.

Sketch the expected Markdown output before writing any code.

Phase 5 β€” Scope filtering checklist

How scope propagates

Before each pass, the compiler walks the full AST and propagates scope top-down:

Scope propagation top-down function
compilerscopetypescript scope-propagation-walkthrough
function propagateScope(node, inherited) {
const resolved = node.attributes?.scope ?? inherited;
node.attributes.scope = resolved;
for (const child of node.children ?? []) {
propagateScope(child, resolved);
}
}

A child tag inherits its parent’s scope unless it explicitly declares its own. Multi-scope is space-separated: scope="public agent". Scope functions live in core/scope.ts (re-exported via utils/markdoc.ts):

  • isScoped(node, targetScope) β€” generic check used by AstWalker visitors
  • isPublicScoped(node) β€” shorthand for isScoped(node, 'public')
  • isAgentScoped(node) β€” shorthand for isScoped(node, 'agent')

Scope values

ValueVisible to
publicRendered Starlight documentation site
agentAI agent TOON bundles
internalNeither β€” compile-time only notes

A node may carry multiple scopes: scope="public agent" makes content appear in both outputs.

Scope filtering checklist for processors

When writing render() or extract():

  • Top-level gate: return '' (render) or null (extract) if the primary tag node is missing or not scoped to the current pass
  • Child tag lists: filter with isPublicScoped(n) before iterating β€” do not render agent-only child tags in the public pass
  • collect​Text calls: pass true as the second argument to skip note and property prose from polluting descriptions
  • Silent early return is correct for an expected absence (e.g. an optional tag not present)
  • tracker.addRuntimeIssue is correct for an unexpected absence (e.g. a required tag missing from a doc type that guarantees it)

Phase 6 β€” Implementation order

Follow this order exactly. Do not write production code before the fixture and expected output are agreed:

  1. types.ts β€” add interface and extend DocType if needed
  2. validation.ts β€” add extract schema derived from Tag class Schema (if the processor computes fields beyond raw attributes); extend DocTypeSchema if new doc type
  3. core/tags/{TagName}.ts β€” create Core Model class extending Tag with static NAME, Schema, and extract() method
  4. core/tags/names.ts β€” add the tag name to the TagNames const
  5. Fixture β€” packages/compiler/tests/fixtures/{type}/example.{type}.mdoc Include at least one agent-scoped and one public-scoped instance to exercise filtering
  6. processors/{tagname}.ts β€” implement TagProcessor with match, extract (4 params: file, node, ast, context), render, and declarative properties (bundlePath for leaf tags, promotesIdentity for root tags, zodSchema for editor support). For simple child tags, use registerTagClass() instead.
  7. Register β€” import the new processor in both passes/agent/index.ts and passes/public/index.ts
  8. core/rendering/utils.ts β€” add to TYPE_LABELS and typeToDir if new top-level type
  9. Tests β€” add describe blocks to agent-pass.test.ts and public-pass.test.ts
  10. Instruction β€” add instructions/tag/{tagname}.mdoc with schema, attributes table, rules, and rendered output description
  11. Aggregate (if needed) β€” create processors/aggregates/{name}.aggregate.ts implementing AggregateProcessor, register in processors/aggregates/index.ts

Phase 7 β€” Verification

Every compiler change must pass all three checks before it is considered done. Run them in order β€” each builds on the previous.

Step 1 β€” Type check and unit tests

Terminal window
# From starspec/
pnpm --filter @starspec/compiler build # TypeScript compile β€” zero errors required
pnpm test # agent-pass, public-pass, instructions-pass tests

All tests must pass. Do not proceed if any test fails.

Step 2 β€” End-to-end compile

Terminal window
# From starspec/
pnpm compile

Builds the compiler then runs it against content/. Inspect at least one generated file for the affected type:

  • Agent bundle: dist/bundles/{type}/{id}.toon β€” verify the new or changed field is present and correct
  • Public page: src/content/docs/{type}/{id}.md β€” verify the rendered section looks right
  • Aggregate page (if applicable): src/content/docs/{aggregate-name}.md

Step 3 β€” Regression check

Terminal window
# From starspec/
pnpm build

Confirm:

  • src/summary.json reports zero validation issues (or only pre-existing ones known before your change)
  • The Starlight site builds without errors

For changes affecting the Component Gallery or Screenshot Export, run the automated E2E agent. (These tests use the *.e2e.ts naming convention and are excluded from standard unit tests).

Terminal window
# From starspec/
pnpm test:e2e

This agent verifies:

  • All download buttons have deterministic IDs
  • Every component variant in the gallery can be successfully captured
  • Grouped component tabs are correctly handled
  • Both β€œChrome” and β€œClean” export modes function as expected

For interactive debugging of visual regressions:

Terminal window
pnpm test:e2e:ui

Do’s and Don’ts

Do:

  • Run all steps in order β€” each builds on the previous
  • Inspect at least one generated file for the affected type after end-to-end compile
  • Run the E2E agent when changes affect the Component Gallery or Screenshot Export

Don’t:

  • Skip a step or proceed if any test fails
  • Treat a passing TypeScript compile as sufficient without running end-to-end compile and site build

Definition of Done

  • TypeScript compile and unit tests pass
  • End-to-end compile produces correct output
  • Starlight site builds without errors

If the MCP server is running (pnpm serve), call get_document on the new bundle type to verify it is queryable.

Phase 8 β€” Draft & confirm

Before writing any files, present:

  1. The tag schema (Phase 2)
  2. The agent bundle shape + merge() decision (Phase 3)
  3. A sketch of the rendered Markdown output (Phase 4)
  4. The list of files that will be created or modified

Wait for user confirmation before proceeding to Phase 6.

Documentation update checklist

Before closing any task that touches the compiler, confirm all of the following are up to date:

ArtefactWhat to check
instructions/tag/{tagname}.mdocAttribute table, example, and agent bundle shape match the processor and Tag class Schema
instructions/architecture.mdocUpdated if a new doc type, pass sub-module, or aggregate page was added
instructions/adding-a-tag.mdocUpdated if the processor pattern or TagProcessor interface itself changed
instructions/fragments/tags/inventory.mdocNew tag added to the inventory table
markdoc.config.jsonRegenerated via pnpm compile and contains new tag schemas
packages/compiler/src/types.tsJSDoc comments accurate; DocType enum current
tests/fixtures/Fixture files reflect the current schema (rename/remove attributes updated)
.vscode/starspec.code-snippetsNew doc: or tag: snippet added with correct type prefixes and include file pattern scoping
Markdoc escapingAll {% / %} delimiters in instruction .mdoc files are escaped as \{% / %\} β€” in prose, inline code spans, and fenced code blocks (frontmatter is exempt)
Referential integrityEvery new cross-reference attribute has a tracker.addRefIssue() call in render(). pnpm compile produces zero new link issues against valid content.
E2E AgentIf the Component Gallery or Export changed, pnpm test:e2e passes

The rule: an instruction doc out of sync with its processor is a bug. Agents and developers will act on the wrong schema.


Do’s and Don’ts

Do:

  • Verify every item in the checklist before closing a task
  • Ensure all Markdoc delimiters are escaped in instruction files
  • Run pnpm test:e2e when Component Gallery or Export changed

Don’t:

  • Close a task with instruction docs out of sync with their processors
  • Leave un-escaped {% / %} delimiters in instruction .mdoc files
  • Skip referential integrity checks for new cross-reference attributes

Definition of Done

  • All checklist items verified or marked not applicable