Skip to content

feat(code-block): highlight via the CSS Custom Highlight API, add lineNumbers - #131

Merged
tmccoy14 merged 7 commits into
mainfrom
feat/code-block-microlighter
Sep 10, 2026
Merged

feat(code-block): highlight via the CSS Custom Highlight API, add lineNumbers#131
tmccoy14 merged 7 commits into
mainfrom
feat/code-block-microlighter

Conversation

@tmccoy14

Copy link
Copy Markdown
Contributor

Context

CodeBlock highlighted through react-syntax-highlighter (Prism + refractor/all), which wraps every token in a <span> and ships its themes as JS objects applied as inline styles. That forced the component to resolve the active theme in JS — the only place in the library doing so — which picked the wrong palette when two themes were on screen and couldn't see the theme at all from inside a shadow root.

What changed

Highlighting engine

Swapped to MicroLighter, which reads TextMate grammars and paints ranges through the CSS Custom Highlight API. No token spans: ranges are registered against the existing text node, so the DOM a user copies is the source string. Grammars load on demand, so a page only pays for the languages it renders.

The palette moved wholly into the stylesheet and now resolves through the cascade, so nested themes and live theme flips are correct with no re-render.

Bundle cost, measured by bundling each library in isolation with esbuild --minify, React external:

raw gzip
react-syntax-highlighter (Prism entry) 632.3 KB 223.0 KB
microlighter initial chunk 6.6 KB 3.0 KB

The old import was static, so all 632 KB landed in the initial bundle. MicroLighter's grammars are dynamic imports — esbuild split them into 37 separate chunks, confirming the on-demand behaviour. Per-grammar cost: JSON 304 B gzip, bash 493 B, TypeScript 552 B. A page rendering JSON, TS and bash goes from 223 KB gzip to ~4.4 KB.

New lineNumbers prop

Numbers every line in a sticky gutter that stays put while long lines scroll under it. Numbering and wrapping are mutually exclusive — a wrapped line occupies more rows than its number does — so setting it switches long lines from wrapping to horizontal scrolling.

Accessibility

The scroll region is now focusable (tabIndex={0}, role="region", labelled from title) with the standard focus ring, so the overflow it owns is reachable by keyboard. This closes a pre-existing gap from max-h-64 as well as the new horizontal scrolling.

New public exports

CODE_BLOCK_ATTRIBUTE and scheduleHighlight (from lib/highlight.ts) let an application opt its own <pre> — a diff view, an editor — into the same pass and palette.

Consumer-visible changes

  • Token colours change substantially in both themes. Light: strings green → dark red, numbers magenta → green, keywords blue → purple, comments grey → green. Dark: the previous near-neon palette (cyan keywords, lime strings, gold functions) gives way to vscode-plus's muted set. Worth a heads-up to anyone with screenshots in their own docs.
  • Bold and italic tokens are gone: ::highlight() accepts only colour and decoration properties, so Markdown **bold**/_italic_ and CSS !important take a colour instead.
  • Each code block adds one tab stop to the page's keyboard order.
  • Below Baseline (June 2025 — Firefox 140 joining Chrome and Safari) no highlights register and code renders as unstyled monospace. Header, copy button and layout are unaffected; the fallback costs colour only.
  • @eqtylab/equality 3.3.1 → 3.4.0.

Docs

code-block.mdx rewritten for the new engine: the 37 shipped grammars and 13 aliases, browser support, ::highlight() styling limits, the lineNumbers section, and a section on highlighting your own markup.

Multi-line samples moved into packages/demo/src/components/demo/code-block.tsx — Prettier's MDX parser reformats template literals embedded in JSX expressions and flattens their indentation, which defeats the point of a line-numbers demo.

Also in this PR

Two unrelated cleanups picked up along the way, each in its own commit:

  • chore(lint) — the flat-config ignores: ['dist'] pattern resolves relative to the config file, so it only ever ignored a root-level /dist and pnpm lint failed on packages/ui/dist whenever the library had been built. Globbed to **/dist. Also dropped a variant prop AvatarShapeDemo never read.
  • chore(tsconfig)baseUrl is deprecated and stops functioning in TypeScript 7. Removed from both tsconfigs with every paths mapping rewritten self-relative. Nothing relied on baseUrl's bare-specifier lookup; internal imports all go through the @/, @demo/ and @eqtylab/ aliases or relative paths.

Verification

  • pnpm lint → exit 0
  • pnpm format:check → clean, whole repo
  • tsc --noEmit (ui) → exit 0; astro check (demo) → 0 errors, 0 warnings
  • Clean pnpm build from deleted dist dirs → both packages built, 67 demo pages
  • Zero stray @/ specifiers in dist/index.js and dist/index.d.ts, confirming tsup/esbuild still resolves aliases without baseUrl
  • All 36 highlight categories present in the compiled CSS
  • Palette change confirmed visually by rendering the same TSX sample through both engines in light and dark

🤖 Generated with Claude Code

tmccoy14 and others added 3 commits September 10, 2026 13:01
…eNumbers

Replace react-syntax-highlighter with MicroLighter, which reads TextMate
grammars and paints ranges through the CSS Custom Highlight API rather than
wrapping every token in a span. The palette now lives wholly in the stylesheet
and resolves through the cascade, so nested themes and live theme flips are
correct without a re-render, and no component has to resolve a theme in JS.

Bundle cost drops from 223 KB gzip, all of it static, to 3.0 KB gzip with
grammars code-split and fetched per language.

Add a lineNumbers prop: a sticky gutter that stays put while long lines scroll
under it. Numbering and wrapping are mutually exclusive, since a wrapped line
occupies more rows than its number does, so setting it switches long lines from
wrapping to horizontal scrolling.

Make the scroll region focusable (tabIndex, role=region, labelled from title) so
the overflow it owns is reachable by keyboard. This closes a pre-existing gap
from max-h-64 as well as the new horizontal scrolling.

Export CODE_BLOCK_ATTRIBUTE and scheduleHighlight from lib/ so an application
can opt its own <pre> into the same pass and palette.

Token colours change in both themes: strings, numbers and keywords all shift in
light, and dark moves from a near-neon palette to vscode-plus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The flat-config `ignores: ['dist']` pattern resolves relative to the config
file, so it only ever ignored a root-level /dist and `pnpm lint` failed on
packages/ui/dist whenever the library had been built. Glob it instead.

AvatarShapeDemo took a `variant` prop it never read and no caller passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
baseUrl is deprecated and stops functioning in TypeScript 7. Without it, paths
resolve relative to the tsconfig that declares them, so every mapping is now
written that way. Nothing relied on baseUrl's bare-specifier lookup — internal
imports all go through the @/, @demo/ and @eqtylab/ aliases or relative paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The file exported bare sample strings, the only one in src/components/demo that
didn't export *Demo components. Export three demo components instead, with each
sample inline at its point of use, and call them from the MDX the way every
other docs page calls its demos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The docs contain copy/paste issues and the wrapping behavior for long unbroken tokens appears inconsistent with the intended wrap-vs-scroll UX.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR updates the CodeBlock component in the Equality design system to use MicroLighter + the CSS Custom Highlight API for token coloring (removing Prism token DOM spans and JS-resolved themes), adds optional sticky line numbers, and includes related demo/docs updates plus small lint/tsconfig maintenance.

Changes:

  • Replace react-syntax-highlighter with microlighter, introducing a shared scheduleHighlight() pass and CSS-driven syntax palettes.
  • Add a lineNumbers?: boolean prop to CodeBlock with a sticky gutter and horizontal scrolling when enabled.
  • Update demo documentation and samples; adjust ESLint ignore globs and remove deprecated baseUrl usage from tsconfigs.
File summaries
File Description
pnpm-lock.yaml Removes react-syntax-highlighter deps and adds microlighter to the lockfile.
packages/ui/package.json Bumps @eqtylab/equality to 3.4.0 and swaps highlighting dependency to microlighter.
packages/ui/tsconfig.json Removes baseUrl and rewrites paths mappings to be self-relative.
packages/demo/tsconfig.json Removes baseUrl and rewrites paths mappings to be self-relative.
packages/ui/src/types/microlighter.d.ts Adds a local module declaration for microlighter to type highlightAll.
packages/ui/src/lib/highlight.ts Introduces shared CODE_BLOCK_ATTRIBUTE and scheduleHighlight() for coalesced rescans.
packages/ui/src/index.ts Exports CODE_BLOCK_ATTRIBUTE and scheduleHighlight as new public API.
packages/ui/src/components/code-block/code-block.tsx Rebuilds CodeBlock markup for text-node-based highlighting, adds focusable scroll region + line numbers.
packages/ui/src/components/code-block/code-block.module.css Moves token palette to CSS ::highlight() rules and adds layout styles for numbered gutter.
packages/demo/src/content/components/code-block.mdx Rewrites docs for new engine, browser support, line numbers, and custom markup highlighting.
packages/demo/src/components/demo/code-block.tsx Adds multi-line sample sources for docs demos (esp. line numbers).
packages/demo/src/components/demo/avatar.tsx Removes an unused variant prop from AvatarShapeDemo.
eslint.config.mjs Fixes ignore patterns to match dist and node_modules in any package directory.
Review details

Files not reviewed (1)

  • pnpm-lock.yaml: Generated file
  • Files reviewed: 11/13 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/ui/src/components/code-block/code-block.tsx
Comment thread packages/demo/src/content/components/code-block.mdx Outdated
Comment thread packages/demo/src/content/components/code-block.mdx
tmccoy14 and others added 3 commits September 10, 2026 13:52
`highlightAll` collects its elements before awaiting their grammars, then
clears every registered range once they land. Two passes in flight therefore
let the slower one finish last and wipe blocks it was too early to see: a
`rust` block awaiting its grammar would drop a `json` block that mounted a
frame later and resolved from cache first. Queue passes behind one another
instead of clearing the coalescing slot before the pass has read the DOM.

Rework the props table into the `| Name | Description | Type | Default |
Required |` shape the create-documentation skill mandates, which 52 of the 59
component docs already use, and drop the `className` row the skill says to
leave out. `code` is now marked required in its own column rather than by
putting "Required" in the Default cell.

Also correct "four color variants" to five, which is what the bullets beneath
it and the Color Variants section both list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Group the scroll container rather than making it a region: `region` is a
landmark, so a docs page of code blocks filled the landmark menu with eight of
them. The accessible name and the tab stop that reaches the overflow both stay.

Share one `--code-line-height` between the gutter and the code instead of
repeating 1.5 in each with a comment warning they must match.

Drop a single-argument `cn()`, a bracket lookup for a plain key, and a
tsconfig include already covered by `src`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The snippet already opens with an import, so the bare hook read as an
oversight rather than as the elision it is elsewhere in the docs. Matches
dialog and date-range-picker, which import their hooks from react.

Drop the demo file's preamble comment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tmccoy14
tmccoy14 merged commit 65c2222 into main Sep 10, 2026
1 check passed
@tmccoy14
tmccoy14 deleted the feat/code-block-microlighter branch September 10, 2026 17:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants