Alpha — breaking changes ship without a deprecation cycle. What is built vs. planned
Write an exporter
The core knows nothing about any target. Every official exporter (Bootstrap, shadcn/ui, PrimeNG) uses the same public API you’re about to use, and nothing in this guide touches core.
We’ll build a real one: compile a design system into an Alacritty terminal colour scheme. It’s small enough to read in one sitting and awkward enough to be a real test. A terminal has 16 colours and no concept of radius, spacing, or type, so you’ll have to say what doesn’t fit.
Transtyle isn’t on npm yet, so the install steps below use a local path to a clone. Once packages are published, the same steps work with npm install @transtyle/plugin-kit. Nothing else in this guide changes.
The whole API#
One function. It receives the fully resolved design system and returns files plus an account of how well they map:
{
name: string,
emit(ir, ctx) → { files: [{ path, contents, kind }], coverage: [...], diagnostics?: [...] }
}
That’s it: no build step, framework or registration. The conformance kit enforces three rules. emit must be deterministic, it must not mutate the IR, and it must never touch the filesystem. You return file descriptions, and core writes them.
If your target is just a list of variables, you may not need that function at all: start with a mapping table.
Start declarative: a mapping table#
Many targets are a file of variables with their own names: our-internal-lib.css, a Sass partial, a JSON theme. For those, write a mapping: rows of variable → catalog slot, and Transtyle’s declarative runtime is the exporter. No code, nothing to publish, and nothing that runs at build time.
{
"$schema": "https://transtyle.dev/schemas/mapping/v0.json",
"name": "ourlib",
"files": [
{
"path": "ourlib.css",
"template": "css-custom-properties",
"header": ["/* ourlib theme for {projectName}: generated by transtyle, do not edit */"],
"blocks": [{ "mode": { "color-scheme": "dark" }, "selector": ".ourlib-dark" }],
"rows": [
{ "variable": "--ourlib-brand", "slot": "semantic.color.primary.solid" },
{ "variable": "--ourlib-brand-hover", "slot": "semantic.color.primary.solid-hover" },
{ "variable": "--ourlib-on-brand", "slot": "semantic.color.primary.on-solid" },
{ "variable": "--ourlib-surface", "slot": "semantic.color.elevation.0.surface" },
{ "variable": "--ourlib-text", "slot": "semantic.color.text.base" },
{ "variable": "--ourlib-radius", "slot": "semantic.radius.md" },
{ "variable": "--ourlib-font", "slot": "semantic.type.role.body.md", "part": "fontFamily" }
]
},
{
"path": "_ourlib.scss",
"template": "scss-variables",
"rows": [
{ "variable": "$ourlib-brand", "slot": "semantic.color.primary.solid", "format": "hex" }
]
}
]
}
Save it next to your config and point a target at it:
"targets": {
"ourlib": { "exporter": "./ourlib.mapping.json", "output": "dist/ourlib" }
}
npx transtyle build ourlib writes ourlib.css (a :root block, then .ourlib-dark with every colour row in dark mode) and _ourlib.scss. It also writes a report.json whose coverage comes straight from the rows: native unless a row says derived or approximated, dropped (with the row’s note) when the design system has no value for the slot, approximated when a hex colour had to be clamped into sRGB. What a mapping can say:
- Four templates:
css-custom-properties,scss-variables,less-variables,json. Variable names carry their sigil (--x,$x,@x). - A
partpicks a member of a composite slot (fontSize,fontWeight,lineHeight,fontFamilyof a typography role;color,widthof a border); aformatwrites a colour asoklch(default),hexorhsl-triplet. - Modes: a CSS file adds
blocks, one selector per mode value. Acolor-schemeblock repeats every colour row; a block for another dimension (density) holds only what changes. Sass, Less and JSON files read one mode each ("mode": { "color-scheme": "dark" }on a second file). - Checked before it runs: a malformed table is
TST1014and a slot that doesn’t exist (primary.sold) isTST1015with the nearest name, each pointing at the row. The schema gives your editor autocomplete.
To share it, publish a package with no code: a package.json whose transtyle manifest (the one below) adds "declarative": "ourlib.mapping.json". Users install it and name it in exporter like any exporter; the CLI reads the mapping from the package and never imports it. The kit tests a mapping like any plugin: conformance(createDeclarativeExporter(mapping), { manifest }), with createDeclarativeExporter from @transtyle/core.
When to graduate to code. A mapping has no conditions, no arithmetic, no naming patterns: each row is one variable from one slot. When your target needs a value computed from two slots, a name built from the role, a contrast pick, or a file format that isn’t one of the four (a TOML terminal theme), write emit, as the rest of this guide does. The format’s full reference is the declarative mapping spec.
1. Set up the package#
mkdir transtyle-exporter-alacritty && cd transtyle-exporter-alacritty
npm init -y
npm install @transtyle/plugin-kit # while unpublished: npm install /path/to/transtyle/packages/plugin-kit
Your package.json carries a transtyle manifest: static metadata that tools can read without executing your code. That’s what makes transtyle add and any future registry safe:
{
"name": "transtyle-exporter-alacritty",
"type": "module",
"exports": { ".": "./src/index.js" },
"keywords": ["transtyle-exporter"],
"transtyle": {
"kind": "exporter",
"name": "alacritty",
"irSpec": "v0-draft",
"pluginApi": "0",
"targets": { "alacritty": ["*"] },
"modes": ["color-scheme"],
"capabilities": ["build"]
}
}
The transtyle-exporter keyword is how community exporters are discoverable.
irSpec and pluginApi say which core your exporter is built for, and the CLI checks them before it runs your code. pluginApi is a semver range that must accept the plugin API version core implements: "0" (or "^0") accepts the current 0.x line. irSpec must be exactly "v0-draft", the IR spec core produces until the freeze gives it a version number. A value core doesn’t accept stops the build with TST1309, naming your package and its version; a package with no manifest still loads, with a TST1310 warning. The conformance kit’s manifest-compatible check catches a wrong value in your own CI first.
targets lists the versions of your framework you support, as semver ranges, one per profile ("bootstrap": [">=5.3 <6"], oldest first). A project can ask for its version on the target ("version": "5.3.8"); the compiler picks the range covering it and hands it to you as ctx.targetProfile (with the version itself in ctx.targetVersion), or stops with TST1313 when none does. Key any per-version table on the range string, and never parse a version yourself. Without a request, ctx.targetProfile is your last range. A terminal theme has no versions, so Alacritty declares ["*"]. The kit’s manifest-targets-ranges check rejects an entry the compiler can’t match, such as an era name.
2. Write the mapping table#
Keep the mapping as data, separate from the code that renders it. Yours is the only code that reads it, so the shape is up to you. A flat table is what makes an exporter reviewable by someone who knows the target and not the compiler.
Alacritty wants eight “normal” and eight “bright” ANSI colours. The role grid supplies both from the same roles: solid for normal, solid-hover for bright. That is exactly what the grid’s state axis is for.
const ANSI = [
{
name: 'black',
slot: 'neutral.solid',
class: 'approximated',
note: 'ANSI black is a palette slot, not a surface',
},
{ name: 'red', slot: 'danger.solid', class: 'native' },
{ name: 'green', slot: 'success.solid', class: 'native' },
{ name: 'yellow', slot: 'warning.solid', class: 'native' },
{ name: 'blue', slot: 'primary.solid', class: 'native' },
{
name: 'magenta',
slot: 'accent.solid',
class: 'approximated',
note: 'no magenta concept in the catalog; accent is the closest intent',
},
{ name: 'cyan', slot: 'info.solid', class: 'native' },
{ name: 'white', slot: 'text.base', class: 'approximated', note: 'ANSI white is a foreground' },
];
Note the class on each row. You decide how exact each mapping is, because only you know the target: native where it really means what we mean, approximated where you bent a meaning to fit. Don’t be shy about approximated. The coverage report is the product’s credibility, and an amber row with a reason beats a wall of green nobody believes.
3. Write emit#
const S = 'semantic.color.';
export default {
name: 'alacritty',
// Core validates the user's `options` against this before calling you.
optionsSchema: {
type: 'object',
additionalProperties: false,
properties: { mode: { type: 'string' } },
},
emit(ir, ctx) {
// Terminals have no light/dark switch — you compile one mode.
const mode = ctx.targetConfig.options?.mode ?? ir.defaultMode;
const map = ir.modes[mode] ?? ir.modes[ir.defaultMode];
const coverage = [];
// ctx carries the colour helpers. Never convert colours yourself.
const hex = (slot) => {
const entry = map.get(S + slot);
return entry ? ctx.formatHex(entry.value).text : null;
};
const row = (variable, slot, cls, note) => {
const value = hex(slot);
if (value === null) {
coverage.push({
variable,
slot: S + slot,
class: 'unsupported',
note: 'slot missing from the IR',
});
return null;
}
coverage.push({
variable,
slot: S + slot,
class: cls,
provenance: map.get(S + slot).provenance.kind,
...(note && { note }),
});
return value;
};
const fg = row('colors.primary.foreground', 'text.base', 'native');
const bg = row('colors.primary.background', 'elevation.0.surface', 'native');
const normal = ANSI.map((a) => [
a.name,
row(`colors.normal.${a.name}`, a.slot, a.class, a.note),
]);
const bright = ANSI.map((a) => [
a.name,
row(
`colors.bright.${a.name}`,
`${a.slot}-hover`,
a.class,
'bright variant from the grid’s hover state',
) ?? hex(a.slot),
]);
// Say plainly what this target cannot express.
for (const missing of ['radius.*', 'space.*', 'type.*', 'elevation.*.shadow']) {
coverage.push({
variable: `(${missing})`,
slot: '—',
class: 'dropped',
note: 'Alacritty themes carry colour only',
});
}
// …including the modes: one file holds one mode.
for (const dim of ir.dimensionNames) {
coverage.push({
variable: `(mode:${dim})`,
slot: '—',
class: 'dropped',
note: `an Alacritty theme holds one mode (${mode})`,
});
}
const toml = [
`# Generated by transtyle from ${ctx.projectName} (${mode} mode) — do not edit.`,
'',
'[colors.primary]',
`background = "${bg}"`,
`foreground = "${fg}"`,
'',
'[colors.normal]',
...normal.map(([k, v]) => `${k} = "${v}"`),
'',
'[colors.bright]',
...bright.map(([k, v]) => `${k} = "${v}"`),
'',
].join('\n');
return {
files: [{ path: 'alacritty.transtyle.toml', contents: toml, kind: 'config' }],
coverage,
};
},
};
Some things to point out:
ctxgives you the colour helpers:formatHex,formatColor,formatHslTriplet,contrastRatio,mix. Use them. They’re the same functions the derivation engine uses, so your output agrees with everyone else’s by construction.ctx.targetProfilesays which version you’re building for. It is one of the ranges in your manifest’stargets, chosen from the project’sversion(shadcn reads it to pick its Tailwind v3 or v4 output). An exporter with one profile can ignore it.ctx.unitsconverts dimensions.ctx.units.toPx("0.5rem")andtoRem("8px")use the config’sunits.remBase, returnundefinedfor anything that is not apxorremdimension, andctx.units.remBaseis the base itself, for your coverage note. Don’t hard-code 16.- A missing slot is data, not a crash. If the IR has no
accent.solid, recordunsupportedand carry on. Never throw because a design system didn’t author something. - Don’t name a file
report.jsonortranstyle-manifest.json. Core writes both at the root of every target’s output directory after your files (the build report and the manifest of what was generated). A file of yours with either name would be silently overwritten. The kit’sno-reserved-file-namescheck fails it. - The dropped rows are the point. A terminal can’t express radius or type, and one file can’t hold light and dark or two densities. Saying so, in the report, is the difference between a translation and a guess; a mode dimension you ignore without a
(mode:<dimension>)row fails the kit. - More modes than one file can hold have helpers.
@transtyle/irexports the two encodings the official exporters use for a dimension beyondcolor-scheme(brand,contrast,motion,density):modeBlocks(ir, { dims, render, darkSelector })plans the attribute, media and combination blocks a CSS target appends after its own:rootand dark blocks, from arender(map, { dark })that returns your declarations.emitPerValue(ir, 'brand', (view) => emitOne(view, ctx))runs your emit once per value and names the files<file>.<value>.<ext>.droppedDimensions(names, expressed, reasons)writes thedroppedrows with a reason per dimension. - Some slots are written only when authored. A target that has its own good default for a slot (PrimeNG’s, Chakra’s and MUI’s focus rings, Bootstrap’s halo) keeps it unless the design system authored the slot.
isAuthored(map, 'semantic.ring.width')from@transtyle/iris the test the official exporters share:authoredoraliased, or analias(<slot>)default of a slot the design system authored, so aring.widthleft toborder-width.mediumcounts once the border scale is authored. Report the unauthored case as adroppedrow that names the slot and the target’s own value, so the reader knows what to author. - Make each row findable.
transtyle explain --variable <name> --target <yours>reads your coverage rows to go from a variable back to its slot. One variable per row, withslotset to the exact IR path you read, is all it needs. When the label says more than a path (semantic.duration.fast + easing.standard,… (fontSize)), addslots: [...]with the paths; when the variable just follows another of your variables, addvia: ['<that variable>']. The conformance kit checks everyslotsentry exists in the IR (coverage report fields). - Your rows decide which custom tokens reach you. A design system’s own
semantic.*tokens outside the catalog are accounted for by core after youremit: a token a catalog slot aliases reaches your target when one of your rows names that slot (inslotorslots), and every other one gets adroppedrow. A row labelled in prose hides the slots behind it, which is one more reason to name them. If your target takes any variable name (like css-variables or daisyUI), setopenVocabulary: true, write each token ofctx.customTokenswith anativerow, and declare acustomTokens: "emit" | "omit"option; the kit checks the three (open-vocabulary targets). - Read only what you use. The compiler records every slot an exporter looks up in the mode maps (
get,has, or an entry opened while iterating). It’s your target’sreadsinreport.json, andtranstyle check --matrixlists your target as a reader of each. Looking a slot up “just in case” makes your target show up as depending on it. Listing the keys to filter them, as css-variables does, is not a read. - Diagnostics are optional, and for your target’s quirks. When your target’s own conventions turn an authored value into something the author didn’t ask for, return
diagnostics: [{ severity: 'info', code, message, hint }]next tofilesandcoverage(shadcn does this when its radius offsets collapse a smallradius.mdto 0,TST2104). Core prints them with your target’s name in front. Onlyinfoandwarning: to stop a build, the design system has to be wrong, and core already checks that. - Descriptions are on the entry. An authored token’s
$descriptionisentry.descriptionand its$deprecatedisentry.deprecated(trueor the reason). If your files take comments, write them above the declaration:entryNotes(entry)from@transtyle/irgives the lines,blockComment()/lineComment()make them safe for/* */and//. You don’t add them to coverage rows, or list deprecated tokens inusage.md: core does both for every target. - Say what an
unsupportedrow is missing. When the target themes something the catalog has no word for, add ameaningto the row (meaning: 'icon.size'), reusing a key from the registry when another exporter already reports the same concept. Rows that share a meaning across exporters are the evidence the catalog grows on.
4. Prove it#
The conformance kit is the real specification: prose drifts, executable fixtures don’t. Point it at your plugin:
// conformance.test.mjs
import { conformance } from '@transtyle/plugin-kit';
import { readFileSync } from 'node:fs';
import plugin from './src/index.js';
const manifest = JSON.parse(readFileSync('./package.json', 'utf8')).transtyle;
const { pass, checks } = await conformance(plugin, { manifest });
for (const c of checks.filter((c) => !c.pass))
console.log(`✖ ${c.fixture ? `${c.fixture}: ` : ''}${c.name} — ${c.detail}`);
console.log(`${checks.filter((c) => c.pass).length}/${checks.length} checks pass`);
if (!pass) process.exit(1);
node conformance.test.mjs
106/106 checks pass
The kit runs your plugin against ten small fixture design systems, each twice: the canonical one, then the shapes that broke exporters before the kit could see them (one token, three tokens, color-scheme × density, light only, an authored component tier, a custom role, custom semantic tokens, authored composites, DTCG object forms). On each it checks the contract: that you didn’t mutate the IR, that two runs are byte-identical, and that no undefined, NaN or [object Object] reached a file. It also checks that no native row names a slot the design system never filled, and that a density mode you can’t express has its dropped row. Each failure names the fixture, so one-token: emit-runs means your plugin crashed on a design system with nothing but a brand color. Open node_modules/@transtyle/plugin-kit/fixtures/<name>/ to see exactly what it received. With { manifest }, manifest-valid, manifest-compatible and manifest-targets-ranges check the manifest once. A plugin that returns diagnostics also gets emit-diagnostics-valid on each. Passing it is what “official” means, and community exporters can advertise it.
5. Use it in a real project#
Install it into a project and name it in the config. The exporter field selects the package; the key is the instance name:
npm install transtyle-exporter-alacritty
"targets": {
"alacritty": {
"exporter": "transtyle-exporter-alacritty",
"output": "dist/alacritty"
}
}
npx transtyle build alacritty
alacritty 55% native · 23% approximated · 18% dropped · 5% unsupported
↳ dist/alacritty/alacritty.transtyle.toml
↳ dist/alacritty/report.json
↳ dist/alacritty/transtyle-manifest.json
That coverage line is your exporter reporting its limits in public. And dist/alacritty/alacritty.transtyle.toml is a terminal theme built from the same tokens as your buttons.
Where next#
- The Transtyle language: every catalog slot you can map from, and which exporters consume each
- Internals: the pipeline your
emitsits at the end of - css-variables: the reference implementation, deliberately trivial, and short enough to read start to finish