Skip to content

Build-time macros

Keep a code example beside the component that explains it. During a Vite 8 build, @ferriki/vite replaces literal code() and <Code /> macro calls with highlighted HTML and the CSS they need. React's default <Code /> presentation uses a small, framework-free module to display that prepared output in the browser.

Call the API
  1. Source codeA string, file or generated content
  2. codeToHtml()Run Ferriki in your build script or server
  3. Highlighted HTMLWrite a static page or send a server response
  4. BrowserDisplay the prepared markup
Prepare examples with Vite
  1. code() or <Code />A literal example in your module
  2. Vite build@ferriki/vite highlights the example
  3. HTML and CSS in the bundlecode(): prepared data · <Code />: shadow code element
  4. BrowserRender without running a highlighter

Install and configure

npm install @ferriki/core @ferriki/vite

Use Vite 8 and Node.js 22.13.0 or newer. Keep @ferriki/core, its optional platform package, and @ferriki/vite on the same release. The native platform requirements are listed in Compatibility. If your Vite React project does not already use the React plugin, install it with npm install -D @vitejs/plugin-react.

The first build downloads the configured grammar and theme payloads from assets.ferriki.dev, verifies them, and caches them locally. For offline builds, prepopulate the cache or configure a mirror in asset settings.

Put Ferriki before React so the macro runs before JSX is lowered:

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { ferriki } from "@ferriki/vite";

export default defineConfig({
  plugins: [ferriki({ theme: "github-dark-default" }), react()],
});

Add your first React component

Import Code from the macro entry and write its source directly in the component:

import { Code } from "@ferriki/core/react/macro";

export function Example() {
  return (
    <main>
      <h1>A code example, ready for the page</h1>
      <Code language="ts" source={`const answer: number = 42;`} />
    </main>
  );
}

Keep language and source literal. source may be a string or a template literal without ${...} interpolation; the macro cannot evaluate variables, imports, or computed strings. Render Example in your app, start Vite with its usual command such as npm run dev, and open that route to see the highlighted block.

Prepare code with code()

code() works in any JavaScript or TypeScript module:

import { code } from "@ferriki/core/macro";

export const example = code("export const answer = 42;\n", {
  language: "ts",
  meta: 'title="Answer" [TypeScript] {1}',
});

During the build, the call becomes a plain object, a PreparedCodeBlock:

FieldContent
codeThe exact source, including its final newline
languageThe language as written
htmlThe complete highlighted pre/code block
cssThe block's theme CSS and line-number and highlight rules, when it has any
metadatatitle, label, lineNumbers and highlightedLines, parsed from the options

Render html inside a container, not inside another pre. Vite already delivers the CSS: in class mode, the transformed module imports a generated stylesheet. css is there for code that moves the descriptor outside that module; do not inject it a second time.

The call takes these options:

OptionMeaning
languageRequired, a nonempty string literal such as "ts"
metaOptional string literal: title="...", the first [label], line selections such as {2,4-5}, and showLineNumbers
lineNumbersOptional boolean literal that overrides the plugin default

The source is a string literal, or a template literal without ${...} interpolations; template escapes have their normal JavaScript meaning. Variables, imported files and computed strings are not evaluated, and the options must be an object literal. Anything dynamic fails the build with the file and position.

Vite plugin options

OptionDefaultMeaning
theme"github-dark-default"One theme for every block
themesnoneNamed themes; the first key supplies the default colors
styleMode"inline""classes" emits scope classes and matching CSS
lineNumbersfalseDefault for every macro; each call can override it
transformersnoneTransformer callbacks applied to every prepared block
assetsnoneAsset source and cache settings, as for createHighlighter
includenone(id) => boolean for compiler output without a JS/TS extension

Prepare code with <Code /> in React

import { Code } from "@ferriki/core/react/macro";

export function Example() {
  return (
    <Code
      language="tsx"
      className="example-code"
      maxLines={12}
      lineNumbers
      source={`function Hello() {
  return <h1>Hello</h1>;
}`}
    />
  );
}

The element is self-closing. source is a string literal or a template literal without interpolations; JSX children are not supported. language is required, and it, meta and lineNumbers are static, with the same meaning as for code(). By default, the element becomes a <ferriki-code> Web Component with the prepared HTML in an open shadow root and a local base-style element. className applies to that host and theme selects a configured alias. The optional maxLines runtime prop must resolve to a positive safe integer; omit it for an unlimited block. These presentation props accept runtime expressions. Spreads, duplicate attributes and other attributes fail the build.

An unhighlighted JSX Code call passes its example through the source property as a string with braces and backticks. The code moves through build-time highlighting and server HTML to an isolated area in the browser. React manages the page and outer code element; the Web Component manages the text inside.

React manages the page and the outer code element. The Web Component manages the code inside. Shadow DOM is the boundary between them: page CSS selectors do not reach the inner code, and React does not manage its individual tokens. The code stays real text that people can select and copy.

For the first page load with server rendering, the server sends the actual highlighted HTML inside a template in the code element. The browser moves that template's contents into the isolated area before JavaScript runs. The template is gone, and the code is already visible. React attaches to the page and outer element while keeping the code inside in place. On later client navigation, the Web Component fills new code elements from prepared HTML in the browser modules; it does not run a highlighter.

The macro reserves space from the source line count for client-only mounts. The optional maxLines prop caps the visible block while keeping all code available for vertical scrolling; shorter blocks stay short. The reservation uses the same cap, line height, and padding as the displayed code.

SSR and styles

The server template includes a local base-style element so the code is styled before JavaScript. Register the component after document parsing so it reuses the existing code. Inline mode needs no external CSS asset or Vite base URL. Classes mode adds a link to one aggregated CSS asset, which remains after upgrade; Vite serves it during development and emits a hashed file in production. Deploy the SSR build's asset because the SSR and client build graphs can produce different filenames. Direct class-mode macro SSR requires an absolute Vite base path or URL. See the Shadow code elements guide for details. Page selectors do not reach inside the root, and ancestor theme attributes do not select its theme: pass theme={darkMode ? "dark" : "light"} explicitly. The first configured theme supplies the default colors. See the Shadow code elements guide for framework-free use, custom CSS, browser support, and CSP/Trusted Types limits.

Titles, copy buttons, and tabs can wrap the default <Code /> in ordinary React components and keep its isolated code presentation. For complete control over the result, pass a render function. This application-owned escape hatch replaces the default presentation; the example below opts into light-DOM HTML and its page CSS. It receives { code, className, theme, maxLines }, where code is the PreparedCodeBlock, and runs during normal rendering, never at build time:

import { Code } from "@ferriki/core/react/macro";
import type { CodeRenderProps } from "@ferriki/core/react/macro";

function CodeBlock({ code, className }: CodeRenderProps) {
  return (
    <figure className={className}>
      <figcaption>{code.metadata.title}</figcaption>
      <button type="button" onClick={() => navigator.clipboard.writeText(code.code)}>
        Copy code
      </button>
      <div dangerouslySetInnerHTML={{ __html: code.html }} />
    </figure>
  );
}

export function Example() {
  return (
    <Code
      language="ts"
      meta='title="Answer" {1}'
      source={`export const answer = 42;`}
      render={(props) => <CodeBlock {...props} />}
    />
  );
}

The macro import is removed from the transformed module, so neither the marker nor the build-time macro parser reaches the browser. Server rendering, hydration and client navigation use the prepared HTML. The default React path loads only the framework-free presentation module; code() alone has no presentation runtime. Each React shadow root contains its local base-style element; classes mode also links its aggregated external asset. code() and custom renderers keep ordinary Vite stylesheet delivery.

Annotate lines with Shiki's notation

The transformers option applies transformer callbacks to every prepared block, including the notation helpers from @shikijs/transformers (verified with v4.4.3, Ferriki's Shiki baseline):

// vite.config.js
import { ferriki } from "@ferriki/vite";
import {
  transformerNotationDiff,
  transformerNotationFocus,
  transformerNotationHighlight,
  transformerNotationWordHighlight,
} from "@shikijs/transformers";

export default {
  plugins: [
    ferriki({
      transformers: [
        transformerNotationHighlight(),
        transformerNotationFocus(),
        transformerNotationDiff(),
        transformerNotationWordHighlight(),
      ],
    }),
  ],
};

Write // [!code highlight], // [!code focus], // [!code ++], // [!code --] or // [!code word:answer] inside the macro's source. The comments are removed from the highlighted HTML; the descriptor's code keeps the source as written. In a TypeScript config, the helpers may need a type assertion at this boundary: Ferriki's transformer context is not promised to be type-interchangeable with Shiki's.

Which modules are transformed

The adapter is import-driven. Vite and Rolldown call its transform only for JavaScript and TypeScript modules, and for IDs accepted by include, whose source contains the text @ferriki/core/macro or @ferriki/core/react/macro. Every other module is never parsed or changed. A module that passes is parsed once with Vite's own parser, and the parse decides: a mention in a comment or a string, or a type-only import, leaves the module unchanged.

Write the import specifier literally. A specifier spelled with escape sequences, such as "@ferriki/core/\u006dacro", does not pass the text check: that module is not transformed, and the marker throws its configuration error when it runs.

The imported name is reserved

The local name a macro import binds, code, Code or an alias, is reserved in its module. A declaration with that name fails the build: a variable, function, class, parameter, destructured binding, catch binding, import, or a TypeScript type alias, interface, enum or namespace, at the top level or in a nested scope. Assigning the marker, passing it elsewhere or re-exporting it fails as well, so every runtime use of the name refers to the macro.

A module that imports both macros and destructures ({ code }) in a renderer therefore imports the function macro under another name:

import { code as prepareCode } from "@ferriki/core/macro";
import { Code } from "@ferriki/core/react/macro";

Errors

A macro error fails the build as a FerrikiMacroError with the code FERRIKI_MACRO (Rolldown builds report it as pluginCode), the module ID, the line and column, and a code frame that the dev server overlay and the build output show. A syntax error in a module that imports a macro is reported at the parser's position.

An unknown language warns once per module and produces escaped plain code. Missing assets and native failures fail the build. A marker that runs without the transform throws a configuration error that names the missing Vite setup.

Themes and CSS

Inline output already carries its colors in the highlighted HTML. With styleMode: "classes", resolved token and theme rules live in generated CSS. For code() and custom renderers, each transformed module imports a content-addressed stylesheet. Set data-ferriki-theme="dark" on the prepared block or an ancestor to switch themes in ordinary HTML.

Each <Code /> root has its own compact local style block for base presentation and inline theme switching. In classes mode, roots in a build link one aggregated external CSS asset. SSR and client builds can have different graphs and asset hashes, so deploy the SSR build's CSS with its markup; direct class-mode macro SSR requires an absolute Vite base. The runtime theme prop selects the host's theme; ancestor theme selectors do not cross that boundary. Public semantic color variables can still inherit from :root, a theme container, or the host. The surrounding presentation, such as titles and copy buttons, stays yours. See Class-based output for the class contract and the Shadow code elements guide for the component's semantic color variables.

MDX and other compilers

The adapter has to see a compiler's output while that output still contains the macro's import declaration and call. Use the compiler's JSX-output mode, run it earlier in Vite's pre plugin tier than Ferriki, and pass include(id) for its module IDs without a JavaScript or TypeScript extension. Such a module is parsed as TSX; when the adapter emits JSX into it, the result declares the tsx module type, so Rolldown lowers that JSX. A compiler that turns Markdown into JavaScript within a single hook hides the macro from every other plugin.

Fenced code blocks in Markdown are not macros; your Markdown pipeline highlights them.

Rendering HTML in React

codeToHtml() returns an HTML string; code() prepares a descriptor with that string in its html field. React escapes a string in {html}, so it displays the tags as text. Use dangerouslySetInnerHTML to insert the prepared markup in ordinary HTML. <Code /> supplies a shadow host by default. The browser still parses prepared HTML; React manages only the opaque host and does not create elements for each highlighted token.

Literal HTML cannot simply be pasted into JSX: attributes such as class and style have different representations, and escaped source text must be preserved. The current adapter keeps Ferriki's HTML intact instead of converting each highlighted span into a React element. The resulting page still contains ordinary pre, code and span elements.

Ferriki escapes the original source. Custom transformers control their own markup; only insert HTML from your highlighting pipeline.

The direct JSX experiment from issue #221 was removed in favor of an isolated Web Component. The Node HTML API and code() descriptor remain unchanged.