sdxc

[ Interface ]

@sdxc/highlight

Syntax highlighting as tokens, with a markdown walk visitor and a stylesheet

npm add @sdxc/highlight
pnpm add @sdxc/highlight
yarn add @sdxc/highlight
bun add @sdxc/highlight
Installs with
@sdxc/markdown
Used by
uptime, blog, books

Syntax highlighting as tokens, with a markdown walk visitor and a stylesheet.

A highlighter's output is a sequence of runs, each labelled with what it is. This package returns those runs, so a caller renders them as components, as markup or as inline styles. A grammar is a plain value a module exports: no global registry, no import order to get right.

Installation

npm add @sdxc/highlight

The root entry has no dependencies. The ./markdown entry walks documents from @sdxc/markdown, whose walk answers a Result from @sdxc/result.

Usage

Tokenize source

import { tokenize } from "@sdxc/highlight";

let tokens = tokenize("let x = 1;", "ts");
// [
//   { type: "keyword", value: "let" },
//   { type: "plain", value: " x " },
//   { type: "operator", value: "=" },
//   { type: "plain", value: " " },
//   { type: "number", value: "1" },
//   { type: "punctuation", value: ";" },
// ]

The language is taken as an author writes it on a fence: ts, sh, yml or TSX resolve to their grammar, and a language with no grammar comes back as one plain token.

Render tokens as components

import { tokenize } from "@sdxc/highlight";
import type { Handle } from "remix/component";
import "@sdxc/highlight/styles.css";

function CodeBlock(handle: Handle<{ code: string; language: string }>) {
	return () => {
		let { code, language } = handle.props;
		return (
			<pre>
				<code className={`language-${language}`}>
					{tokenize(code, language).map((token, index) =>
						token.type === "plain" ? (
							token.value
						) : (
							<span key={index} className={`token ${token.type}`}>
								{token.value}
							</span>
						),
					)}
				</code>
			</pre>
		);
	};
}

The stylesheet colours .token.<type> and the text of any language-* block.

Render a string

import { highlight } from "@sdxc/highlight";

let markup = highlight('let name = "x";', "ts");
// '<span class="token keyword">let</span> name <span class="token operator">=</span> …'

Every value is escaped, so the result is safe inside a <pre><code>.

Paint a parsed document

import { highlight } from "@sdxc/highlight/markdown";
import { Markdown } from "@sdxc/markdown";
import { isFailure } from "@sdxc/result";

let painted = Markdown.walk(document, highlight);
if (isFailure(painted)) throw painted.error;

for (let node of painted.data.children) {
	if (node.type === "code") node.tokens; // Token[]
}

The walk is synchronous, so painting inside a render path needs no await.

API

tokenize(code: string, language: string): Token[]

Tokenizes source as a language, resolving aliases first. The tokens arrive in source order and cover the input exactly once; a language with no grammar yields one plain token, and empty source an empty list.

highlight(code: string, language: string): string

Highlights source into <span class="token …"> markup, escaping every value it writes. plain runs are written as escaped text without a span.

normalizeLanguage(language: string): string

Lowercases a fence's language and follows an alias when one applies; an unknown name comes back lowercased, still usable as a class name.

normalizeLanguage("SH"); // "bash"
normalizeLanguage("txt"); // "plain"
normalizeLanguage("hcl"); // "hcl"

Aliases: js, ts, py, rb, sh, shell, yml, gql, md, mdx, rest (http), jsonc (json), xml, svg, rss, atom, mathml, erb (html), and text, txt, plaintext, env, dotenv (plain).

languages: Record<string, Grammar>

Every bundled grammar by name: bash, css, diff, graphql, html, http, javascript, json, jsx, markdown, plain, python, ruby, sql, tsx, typescript, yaml.

scan(code: string, grammar: Grammar): Token[]

Scans source with a grammar directly, skipping the registry and aliases. Characters no rule claims accumulate into plain runs, and adjacent runs of the same type arrive merged.

compose(...parts: Array<Record<string, Rule[]>>): Grammar

Merges grammars mode by mode, earlier parts' rules tried first. A part without main merges the same way, so a set of modes joins a grammar.

highlight from @sdxc/highlight/markdown

A Markdown.Visitor with one code handler: it resolves the block's language (plain when it names none), tokenizes the body and returns a copy of the node with the normalized language and its tokens. content, attributes and position are kept, and it spreads into a visitor alongside other handlers.

@sdxc/highlight/styles.css

One rule per token type, each reading a --highlight-<type> custom property, plus --highlight-text for the block. It sets colour only and ships light and dark defaults.

Types

Token

interface Token {
	type: Token.Type;
	value: string;
}

Token.Type

The closed set of twenty run kinds: attr-name, attr-value, boolean, builtin, class-name, comment, constant, deleted, function, inserted, keyword, number, operator, plain, property, punctuation, regex, string, tag, variable.

builtin covers names the language provides, class-name types and classes, property keys of any kind, tag markup tag names and CSS selectors, variable sigil-marked names, and inserted/deleted the sides of a diff.

Rule

interface Rule {
	type: Token.Type;
	match: RegExp;
	push?: string;
	pop?: true;
}

match carries the sticky flag and matches at least one character. push enters a mode and pop leaves the current one; a rule uses one or the other.

Grammar

interface Grammar {
	main: Rule[];
	[mode: string]: Rule[];
}

Scanning starts in main; every other entry is a mode a rule pushes.

Markdown.Code["tokens"]

The ./markdown entry augments @sdxc/markdown's code node with tokens?: Token[]. It is derived data: serializing the document writes content and drops tokens.

Pattern: Server-Rendered Code Blocks

Load the stylesheet once and emit markup for each block, with the language-* class the stylesheet reads for the block's text colour.

import { highlight, normalizeLanguage } from "@sdxc/highlight";
import "@sdxc/highlight/styles.css";

function codeBlock(code: string, language: string) {
	let name = normalizeLanguage(language);
	return `<pre><code class="language-${name}">${highlight(code, name)}</code></pre>`;
}

Pattern: Paint And Rewrite In One Walk

Visitors merge, so the highlighter spreads into a visitor that also rewrites other nodes, and a renderer reads tokens off each code node.

import { highlight } from "@sdxc/highlight/markdown";
import { Markdown } from "@sdxc/markdown";
import { isFailure } from "@sdxc/result";

let result = Markdown.walk(document, {
	...highlight,
	link(node) {
		return { ...node, href: new URL(node.href, "https://example.com").href };
	},
});
if (isFailure(result)) throw result.error;

for (let node of result.data.children) {
	if (node.type === "code") renderTokens(node.tokens ?? [], node.language);
}

Pattern: Inline Colours Without A Stylesheet

An email client loads no stylesheet, so colours go inline. A palette keyed by Token.Type paints the types it names and leaves the rest as text; make it a full Record to have the compiler demand all twenty.

import type { Token } from "@sdxc/highlight";
import { tokenize } from "@sdxc/highlight";

let PALETTE: Partial<Record<Token.Type, string>> = {
	comment: "#6a737d",
	keyword: "#d73a49",
	string: "#032f62",
	number: "#005cc5",
	function: "#6f42c1",
};

function paint(code: string, language: string) {
	return tokenize(code, language).map((token, index) => {
		let color = PALETTE[token.type];
		if (!color) return token.value;
		return (
			<span key={index} style={{ color }}>
				{token.value}
			</span>
		);
	});
}

Pattern: Theming The Stylesheet

Declare the properties for the roles you want to change after loading the stylesheet; the rest keep their defaults. Weight, face, padding and borders go in your own rules on the same classes.

@import "@sdxc/highlight/styles.css";

:root {
	--highlight-comment: #8b949e;
	--highlight-keyword: #ff7b72;
	--highlight-string: #a5d6ff;
}

.token.comment {
	font-style: italic;
}

Pattern: Writing A Grammar

A grammar is a record of modes, each a list of rules tried in order at the cursor. Anything no rule claims becomes plain, so a grammar works from its first rule. Put a language the registry lacks in front of tokenize:

import type { Grammar } from "@sdxc/highlight";
import { normalizeLanguage, scan, tokenize } from "@sdxc/highlight";

let ini: Grammar = {
	main: [
		{ type: "comment", match: /[#;][^\n]*/y },
		{ type: "tag", match: /\[[^\]\n]*\]/y },
		{ type: "property", match: /[A-Za-z_][\w.]*(?=\s*=)/y },
		{ type: "operator", match: /=/y },
		{ type: "string", match: /"(?:\\[\s\S]|[^"\\\n])*"?/y },
	],
};

function highlightAny(code: string, language: string) {
	return normalizeLanguage(language) === "ini" ? scan(code, ini) : tokenize(code, language);
}

Rule order is priority, so a comment rule goes above an operator rule that would claim its first character. Keep patterns linear, with no nested quantifiers over the same characters, so highlighting costs what the block is long.

Pattern: Nested Modes And Composed Grammars

A region is a mode: a rule with push enters it and one with pop returns to the mode before. A construct that nests inside itself pushes its own mode again, so the matching brace closes it. compose layers these modes over an existing grammar, its rules tried first.

import type { Rule } from "@sdxc/highlight";
import { compose, scan } from "@sdxc/highlight";

import { ini } from "./ini.js";

let expression: Rule[] = [
	{ type: "number", match: /\d+/y },
	{ type: "variable", match: /[A-Za-z_]\w*/y },
	{ type: "operator", match: /[+\-*\/]/y },
];

let templated = compose(
	{
		main: [{ type: "punctuation", match: /\$\{/y, push: "interpolation" }],
		interpolation: [
			{ type: "punctuation", match: /\}/y, pop: true },
			{ type: "punctuation", match: /\{/y, push: "interpolation" },
			...expression,
		],
	},
	ini,
);

let tokens = scan("port = ${base + 80}\n", templated);

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.