Skip to content
Transtyle v0.1.0-alpha.5
ok: build 10:17 UTC31 pages main · 7ed21c5

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.

Before npm publication

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 part picks a member of a composite slot (fontSize, fontWeight, lineHeight, fontFamily of a typography role; color, width of a border); a format writes a colour as oklch (default), hex or hsl-triplet.
  • Modes: a CSS file adds blocks, one selector per mode value. A color-scheme block 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 TST1014 and a slot that doesn’t exist (primary.sold) is TST1015 with 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:

  • ctx gives 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.targetProfile says which version you’re building for. It is one of the ranges in your manifest’s targets, chosen from the project’s version (shadcn reads it to pick its Tailwind v3 or v4 output). An exporter with one profile can ignore it.
  • ctx.units converts dimensions. ctx.units.toPx("0.5rem") and toRem("8px") use the config’s units.remBase, return undefined for anything that is not a px or rem dimension, and ctx.units.remBase is 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, record unsupported and carry on. Never throw because a design system didn’t author something.
  • Don’t name a file report.json or transtyle-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’s no-reserved-file-names check 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/ir exports the two encodings the official exporters use for a dimension beyond color-scheme (brand, contrast, motion, density): modeBlocks(ir, { dims, render, darkSelector }) plans the attribute, media and combination blocks a CSS target appends after its own :root and dark blocks, from a render(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 the dropped rows 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/ir is the test the official exporters share: authored or aliased, or an alias(<slot>) default of a slot the design system authored, so a ring.width left to border-width.medium counts once the border scale is authored. Report the unauthored case as a dropped row 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, with slot set 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)), add slots: [...] with the paths; when the variable just follows another of your variables, add via: ['<that variable>']. The conformance kit checks every slots entry 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 your emit: a token a catalog slot aliases reaches your target when one of your rows names that slot (in slot or slots), and every other one gets a dropped row. 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), set openVocabulary: true, write each token of ctx.customTokens with a native row, and declare a customTokens: "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’s reads in report.json, and transtyle check --matrix lists 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 to files and coverage (shadcn does this when its radius offsets collapse a small radius.md to 0, TST2104). Core prints them with your target’s name in front. Only info and warning: 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 $description is entry.description and its $deprecated is entry.deprecated (true or the reason). If your files take comments, write them above the declaration: entryNotes(entry) from @transtyle/ir gives the lines, blockComment() / lineComment() make them safe for /* */ and //. You don’t add them to coverage rows, or list deprecated tokens in usage.md: core does both for every target.
  • Say what an unsupported row is missing. When the target themes something the catalog has no word for, add a meaning to 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 emit sits at the end of
  • css-variables: the reference implementation, deliberately trivial, and short enough to read start to finish