Adding a Tag
Adding a new tag or document type to StarSpec follows a consistent registry-based pattern, using a SOLID Domain Model in core/tags/. This architecture ensures that extraction, validation, and rendering logic are encapsulated.
The checklist below covers all integration points.
1. Define the type and schema
- Interface (
types.ts): Add an interface for the extracted data. - Tag Class Schema (
core/tags/): Define astatic readonly Schemaon the Tag class with.describe()on every property. This schema becomes thezodSchemaon your processor and drives auto-generated editor support. - Extract Schema (
validation.ts): If the processor computes fields beyond raw attributes (e.g.text,body,content), add an extract schema derived from the Tag class:TagName.Schema.extend({ text: z.string().default('') }). - Document Type (
DocTypeintypes.ts): If this is a new top-level type, register it here and updateDocTypeSchemainvalidation.ts.
2. Create the Core Model (core/tags/)
For complex tags, create a dedicated class in packages/compiler/src/core/tags/.
- Inherit from
Tag(the base class is not generic β constructor takes only(node: Node)). - Add a static
NAMEconstant matching the tag name and a staticSchema(Zod object). - Encapsulate tree traversal (searching for children/siblings) within the class.
- Provide clean
extract()andrender()methods that the processor will call.
import { Tag } from '../Tag.js';import { z } from 'zod';import { TagNames } from './names.js';
export const MyTagSchema = z.object({ id: z.string().describe('Unique kebab-case identifier'), name: z.string().describe('Display name'),});
export class MyTag extends Tag { static readonly NAME = TagNames.MyTag; // or a string literal static readonly Schema = MyTagSchema;
get id(): string { return this.node.attributes?.id as string; } get name(): string { return this.node.attributes?.name as string; }
extract() { return { id: this.id, name: this.name }; }}For simple child tags whose data is parsed by their parent processor, you only need the class β no separate processor file. Register it with registerTagClass() (see step 5).
3. Create a Tag Processor (processors/)
Processors act as thin adapters between Markdoc and the Core Model. Declarative properties control bundle placement, aggregate collection, and sidebar registration.
import Markdoc from '@markdoc/markdoc';import { TagProcessor, registry } from '../registry.js';import type { CompilerContext } from '../registry.js';import type { Frontmatter } from '../types.js';import { MyTag, MyTagSchema } from '../core/tags/MyTag.js';import { TagNames } from '../core/tags/names.js';import { ContentBuilder } from '../core/ContentBuilder.js';import { findTldrNode } from '../core/rendering/document.js';import { renderBlocks } from '../core/rendering/blocks.js';
class MyTagProcessor implements TagProcessor<any> { tagName = TagNames.MyTag; zodSchema = MyTagSchema;
// --- Declarative properties --- // bundlePath = 'myitems'; // For leaf tags: pushes into bundle.myitems[] // promotesIdentity = true; // For root tags: promotes id/title to bundle root // aggregatePath = 'my-aggregate'; // Feeds the named aggregate bundle // aggregateIdKey = 'sourceId'; // Key for source doc ID in aggregate entries // isContainer = true; // Let walker descend into children // sidebar = { section: 'Specification', weight: 50 }; // Public sidebar placement
match(node: Markdoc.Node): boolean { return node.type === 'tag' && node.tag === this.tagName; }
extract(file: string, node: Markdoc.Node, ast: Markdoc.Node, context: CompilerContext) { return new MyTag(node).extract(); }
render(fm: Frontmatter, ast: Markdoc.Node, _data: unknown, context: CompilerContext): string { const tagNode = ast.children?.find(n => n.type === 'tag' && n.tag === this.tagName); if (!tagNode) return ''; return new ContentBuilder(fm.title) .tldr(findTldrNode(ast, tagNode)) .prose(renderBlocks(tagNode.children ?? [])) .build(); }}
registry.register(new MyTagProcessor());Key points:
extract()takes 4 parameters:(file, node, ast, context). Theastis the full document AST root.render()receives_databut it is vestigial β processors reconstruct the model from the AST inrender().- Use
bundlePathfor leaf tags (items pushed into an array) instead of implementingmerge(). - Use
promotesIdentity = truefor root document tags. - Add
zodSchemasogenerate-schema.tsauto-discovers the tagβs attributes for editor support. - Add
sidebarto control where this document type appears in the Starlight sidebar.
Container processors β mandatory isContainer rule
If your tag wraps child tags that have their own registered processors, you must declare isContainer = true on the processor:
class MyContainerProcessor implements TagProcessor<any> { tagName = 'mycontainer'; isContainer = true; // β required: agent walk descends into children after this fires ...}Why this matters: the agent-pass walker stops descending into a matched nodeβs children unless isContainer = true. Without it, every child tagβs processor is silently skipped β the compiler emits a warning but the data is lost.
Constraint on extract(): container processors must not read child nodes from their Core Model during extract(). Child data is the exclusive responsibility of each child processor, which runs separately when the walk descends. Calling e.g. feature.requirements inside FeatureProcessor.extract() would cause requirement data to be written into the bundle twice β once by the containerβs extract, once by RequirementProcessor via its bundlePath.
Summary:
- Tag wraps children with processors β
isContainer = truerequired extract()on a container β readnode.attributesonly, nevernode.childrenrender()on a container β free to traverse children; useTagIndex.from(node, 'public')to avoid redundant DFS calls
4. UI Foundation (DRY)
When creating a new Astro component in src/components/, always prefer the unified base components to ensure visual consistency:
DataTable.astro: For any tabular data (properties, requirements, etc.). Handles responsive layout and standard headers.ApiCard.astro: For API-like entries (actions, events, operations). Handles badges, titles, and collapsible details.
5. Register the Processor
Import your new processor in both pass entry points so it is registered before either pass runs:
import '../../processors/mytag.js';
// packages/compiler/src/passes/public/index.tsimport '../../processors/mytag.js';For child tags that only need schema visibility (no separate processor file), register them using registerTagClass() in an existing processor file (typically context.ts, logic.ts, or linking.ts):
import { registerTagClass } from '../registry.js';import { MyChildTag } from '../core/tags/MyChildTag.js';
registerTagClass(MyChildTag, 'Description of the child tag');The registerTagClass() helper derives the schema from the Tag classβs static Schema and NAME properties β no manual attribute map needed.
6. Astro component (if needed)
If the public renderer will emit a custom JSX component, create an Astro component in src/components/:
src/components/MyTagItem.astro7. Update rendering utilities (if top-level)
If you added a new top-level DocType, add its display label and directory mapping in core/rendering/utils.ts:
export const TYPE_LABELS: Partial<Record<string, string>> = { // ...existing mytype: 'My Types',};Note: passes/public/utils.ts is a re-export shim β the actual edit target is core/rendering/utils.ts.
8. Referential integrity checks
This step is mandatory. Every tag attribute that references another document or entity by id (e.g. feature="feature/auth", action="login") must have a referential integrity check in the processorβs render() method. Omitting this check means broken references are silently ignored and produce broken links with no compiler warning.
Two lookup sources are available in context, both fully populated by the time render() is called:
| Reference kind | Example | Check against |
|---|---|---|
Document ref (type/id format) | "feature/auth", "role/user" | context.bundleCache.has(ref) |
| API entity ref (bare name, no type prefix) | "login", "saved", "bookmark-not-found" | context.apiNameIndex?.has(ref) |
Pattern:
import { validate, tracker } from '../validation.js';
render(fm: Frontmatter, ast: Markdoc.Node, data: T, context: CompilerContext): string { const fileId = `${fm.type}/${fm.id}`;
// Document ref β check bundleCache (keyed as "type/id") if (data.featureRef && !context.bundleCache.has(data.featureRef)) { tracker.addRefIssue(fileId, data.featureRef, 'my-tag feature ref not found', fm.status, `tag:mytag.feature`); }
// API entity ref β check apiNameIndex if (data.actionRef && !context.apiNameIndex?.has(data.actionRef)) { tracker.addRefIssue(fileId, data.actionRef, 'my-tag action ref not found', fm.status, `tag:mytag.action`); }
// ... rest of render}Rules:
- Only check in
render(), never inextract()β indexes are populated by the public pass pre-pass, not during the agent passextract()call. Putting checks inextract()would cause duplicate issues. - Use
tracker.addRefIssue(fileId, ref, message, fm.status, path)β never calladdLinkIssuedirectly.addRefIssuegates severity automatically:draftβ warning, all other statuses β error. - Use
context.bundleCache.has(ref)for document refs (qualified"type/id"keys). Do not usecontext.entityLinksfor this βentityLinksis keyed by unqualified id and is ambiguous across document types. - Use
context.apiNameIndex?.has(name)for API entity refs β the index stores bare names with no type prefix (e.g."login", not"action:login"). - Use
${fm.type}/${fm.id}as thefileIdargument so issues are attributed to the correct document. - Use a dot-path for the
pathargument (e.g.tag:mytag[${data.id}].feature) so issues are easy to locate in the status page. - No
if (context.bundleCache)guard is needed βbundleCacheis always present onCompilerContext. - Issues surface in
src/summary.json, the/statuspage, and theget_system_statusMCP tool.
9. Fixture and tests
Create a fixture file:
packages/compiler/tests/fixtures/mytypes/example.mytype.mdocAdd tests in agent-pass.test.ts and public-pass.test.ts. Use the existing tests as a template. Because the architecture now uses a registry, your tests simply need to verify that the final bundle or rendered MDX contains the expected data.
10. Add an instruction document
Create instructions/tag/mytag.mdoc with schema, attribute table, and usage rules. Agents query it via get_instructions("starspec/tag/mytag").
If your tag introduces a new concept or term, add a row to instructions/fragments/tags/inventory.mdoc.
11. Update Code Snippets
VS Code snippets provide the best authoring experience for StarSpec. When adding a new tag or document type, add a snippet to:
.vscode/starspec.code-snippets
Follow these rules:
- Scope by file extension: Use the
"include"property (e.g."include": "*.feature.mdoc") to ensure snippets only appear in valid document types. - Use naming standards: Prefill the
idornameplaceholders with the correct type prefix (e.g.req:,action:,ac:). - Standalone vs Document: If itβs a new document type, create a
doc:typesnippet. If itβs a child tag, create atag:tagnamesnippet.
12. Schema generation
The script at packages/compiler/scripts/generate-schema.ts generates markdoc.config.mjs, which the VS Code Markdoc extension uses for inline attribute validation. It auto-discovers all registered processors and reads the zodSchema property from each one.
Every tag must appear in the generated schema β both top-level processors and child tags registered via registerTagClass(). The two registration paths both feed into schema generation:
| Registration path | How it gets a zodSchema | Example |
|---|---|---|
Full processor (implements TagProcessor) | Set zodSchema on the processor class | ArticleProcessor in processors/article.ts |
registerTagClass(TagCls, description) | Derived from TagCls.Schema automatically | registerTagClass(Quote, '...') in processors/article.ts |
Checklist:
- Ensure your processor has
zodSchemaset, or your Tag class has a staticSchemaand is passed toregisterTagClass(). - Ensure the file containing the registration is imported in the pass entry points (step 5) β
generate-schema.tsdynamically imports all files insrc/processors/, so anyregisterTagClass()call inside a processor file is auto-discovered. - Add an assertion to
tests/schema-generation.test.tsto verify your tag appears. - Run
npx tsx scripts/generate-schema.tsfrom the compiler package to regeneratemarkdoc.config.mjs.
import '../src/processors/mytag.js';
it('should define correct attributes for {% mytag %}', () => { const attrs = getAttributes('mytag'); expect(attrs).toHaveProperty('id'); expect(attrs?.id.required).toBe(true);});13. Sidebar registration
If your tag is a new top-level document type with sidebar on its processor, or a new aggregate page, add a triplet to the sidebar.entries array in starspec.config.js:
['my-id', 'Reference', { label: 'My Label' }],See starspec/sidebar-config for the full entry ID reference, available sections, and grouping options.
14. Verify
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
# From starspec/pnpm --filter @starspec/compiler build # TypeScript compile β zero errors requiredpnpm test # agent-pass, public-pass, instructions-pass testsAll tests must pass. Do not proceed if any test fails.
Step 2 β End-to-end compile
# From starspec/pnpm compileBuilds 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
# From starspec/pnpm buildConfirm:
src/summary.jsonreports zero validation issues (or only pre-existing ones known before your change)- The Starlight site builds without errors
Step 4 β Automated E2E Gallery Verification
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).
# From starspec/pnpm test:e2eThis 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:
pnpm test:e2e:uiDoβ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
15. Update documentation
Before closing any task that touches the compiler, confirm all of the following are up to date:
| Artefact | What to check |
|---|---|
instructions/tag/{tagname}.mdoc | Attribute table, example, and agent bundle shape match the processor and Tag class Schema |
instructions/architecture.mdoc | Updated if a new doc type, pass sub-module, or aggregate page was added |
instructions/adding-a-tag.mdoc | Updated if the processor pattern or TagProcessor interface itself changed |
instructions/fragments/tags/inventory.mdoc | New tag added to the inventory table |
markdoc.config.json | Regenerated via pnpm compile and contains new tag schemas |
packages/compiler/src/types.ts | JSDoc comments accurate; DocType enum current |
tests/fixtures/ | Fixture files reflect the current schema (rename/remove attributes updated) |
.vscode/starspec.code-snippets | New doc: or tag: snippet added with correct type prefixes and include file pattern scoping |
| Markdoc escaping | All {% / %} delimiters in instruction .mdoc files are escaped as \{% / %\} β in prose, inline code spans, and fenced code blocks (frontmatter is exempt) |
| Referential integrity | Every new cross-reference attribute has a tracker.addRefIssue() call in render(). pnpm compile produces zero new link issues against valid content. |
| E2E Agent | If 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:e2ewhen 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.mdocfiles - Skip referential integrity checks for new cross-reference attributes
Definition of Done
- All checklist items verified or marked not applicable