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

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 a static readonly Schema on the Tag class with .describe() on every property. This schema becomes the zodSchema on 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 (DocType in types.ts): If this is a new top-level type, register it here and update DocTypeSchema in validation.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 NAME constant matching the tag name and a static Schema (Zod object).
  • Encapsulate tree traversal (searching for children/siblings) within the class.
  • Provide clean extract() and render() methods that the processor will call.
core/tags/MyTag.ts
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.

packages/compiler/src/processors/mytag.ts
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). The ast is the full document AST root.
  • render() receives _data but it is vestigial β€” processors reconstruct the model from the AST in render().
  • Use bundlePath for leaf tags (items pushed into an array) instead of implementing merge().
  • Use promotesIdentity = true for root document tags.
  • Add zodSchema so generate-schema.ts auto-discovers the tag’s attributes for editor support.
  • Add sidebar to 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 = true required
  • extract() on a container β†’ read node.attributes only, never node.children
  • render() on a container β†’ free to traverse children; use TagIndex.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:

packages/compiler/src/passes/agent/index.ts
import '../../processors/mytag.js';
// packages/compiler/src/passes/public/index.ts
import '../../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.astro

7. 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:

packages/compiler/src/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 kindExampleCheck 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 in extract() β€” indexes are populated by the public pass pre-pass, not during the agent pass extract() call. Putting checks in extract() would cause duplicate issues.
  • Use tracker.addRefIssue(fileId, ref, message, fm.status, path) β€” never call addLinkIssue directly. addRefIssue gates severity automatically: draft β†’ warning, all other statuses β†’ error.
  • Use context.bundleCache.has(ref) for document refs (qualified "type/id" keys). Do not use context.entityLinks for this β€” entityLinks is 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 the fileId argument so issues are attributed to the correct document.
  • Use a dot-path for the path argument (e.g. tag:mytag[${data.id}].feature) so issues are easy to locate in the status page.
  • No if (context.bundleCache) guard is needed β€” bundleCache is always present on CompilerContext.
  • Issues surface in src/summary.json, the /status page, and the get_system_status MCP tool.

9. Fixture and tests

Create a fixture file:

packages/compiler/tests/fixtures/mytypes/example.mytype.mdoc

Add 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:

  1. Scope by file extension: Use the "include" property (e.g. "include": "*.feature.mdoc") to ensure snippets only appear in valid document types.
  2. Use naming standards: Prefill the id or name placeholders with the correct type prefix (e.g. req:, action:, ac:).
  3. Standalone vs Document: If it’s a new document type, create a doc:type snippet. If it’s a child tag, create a tag:tagname snippet.

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 pathHow it gets a zodSchemaExample
Full processor (implements TagProcessor)Set zodSchema on the processor classArticleProcessor in processors/article.ts
registerTagClass(TagCls, description)Derived from TagCls.Schema automaticallyregisterTagClass(Quote, '...') in processors/article.ts

Checklist:

  1. Ensure your processor has zodSchema set, or your Tag class has a static Schema and is passed to registerTagClass().
  2. Ensure the file containing the registration is imported in the pass entry points (step 5) β€” generate-schema.ts dynamically imports all files in src/processors/, so any registerTagClass() call inside a processor file is auto-discovered.
  3. Add an assertion to tests/schema-generation.test.ts to verify your tag appears.
  4. Run npx tsx scripts/generate-schema.ts from the compiler package to regenerate markdoc.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

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

15. Update documentation

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