Skip to content

[diffs] Integrate @pierre/highlights highlighter - #1141

Open
ije wants to merge 19 commits into
mainfrom
ije/highlights-integration
Open

ije wants to merge 19 commits into
mainfrom
ije/highlights-integration

Conversation

@ije

@ije ije commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Integrates @pierre/highlights as a highlighter for File, DiffFile, Editor, streams, SSR, and worker pools. Main change:

  • Backend abstraction: DiffsHighlighter replaces the raw Shiki interface. shiki-js, shiki-wasm, and highlights load lazily and have separate cached instances. Shiki JS remains the default.
    /** Tokenization and theme resolution owned by one highlighter backend. */
    export interface DiffsHighlighter {
      readonly name: HighlighterTypes;
      readonly themeResolver: ThemeResolver<DiffsTheme>;
      /**
       * A resolved theme by name. Themes an instance has already used stay
       * available after the shared resolver cache is cleared.
       */
      getTheme(name: string): DiffsTheme;
      codeToHtml(code: string, options: CodeToHtmlOptions): string;
      codeToTokens(code: string, options: CodeToTokensOptions): TokensResult;
      createStreamTokenizer(options: CodeToTokensOptions): DiffsStreamTokenizer;
      createLiveTokenizer(options: DiffsLiveTokenizerOptions): DiffsLiveTokenizer;
      /** Load TextMate grammars when supported by the backend. */
      loadLanguages?(languages: readonly string[]): Promise<void>;
      /** Whether all requested TextMate grammars are loaded. */
      hasLoadedLanguages?(languages: readonly string[]): boolean;
      /** Attach resolved TextMate grammars when supported by the backend. */
      attachLanguages?(languages: readonly ResolvedLanguage[]): void;
      /** Release backend resources. The instance is unusable afterward. */
      dispose(): void;
    }
  • Shared rendering: all backends produce tokens, then use the same renderer for line markup, diff decorations, and HTML. SSR therefore doesn’t use Highlights’ faster standalone WASM-to-HTML path.
  • Streaming and editing: backend-specific tokenizers handle incremental input. Streaming can replace provisional tokens on unfinished lines; cleanup now disposes the tokenizer.
  • Themes, workers, and switching: backend-specific theme resolution, Zed theme registration, worker support, and cache invalidation make switching engines work throughout the playground.

Usage

To use @pierre/highlights as the highlighter, set preferredHighlighter to 'highlights'(default is shiki-js):

// Create the instance with highligts
const instance = new FileDiff({
  theme: { dark: 'pierre-dark', light: 'pierre-light' },
  diffStyle: 'split',
  preferredHighlighter: 'highlights',
});

👉 Playground

Benchmark

Scenario Main: Shiki WASM Current: Highlights Speedup
DOM first paint: first 100 rows(Virtualizer) 949 ms 32.2 ms 29.5×
DOM first paint: first 100 rows(Virtualizer), pool attached 1,000 ms 32.3 ms 31.0×
DOM first paint: full file, 10,825 lines 1,313 ms 355 ms 3.7×
SSR: full file 1,018 ms 21.5 ms 47.4×
SSR: full diff 2,071 ms 56.7 ms 36.6×
Streaming: 4 KiB chunks 462 ms 20.4 ms 22.6×
Worker pool: 8 files, 1 worker 7,866 ms 430 ms 18.3×
Worker pool: 8 files, 4 workers 2,114 ms 263 ms 8.1×

Benchmarked on an Apple M4 Pro: Bun 1.4.0 for SSR/tokenizer streaming; Chrome 151 for browser rendering. Below uses a 529 KB TypeScript file, 10,825 lines, one dark theme, fully expanded split diffs, and no workers or virtualization. Values are median milliseconds; lower is better.

API Changes

  • Backend selection: HighlighterTypes adds 'highlights'. Set preferredHighlighter on components, SSR options, or worker pool initialization. The default remains 'shiki-js'; a worker pool’s choice overrides component options.
  • Highlighter instances: getSharedHighlighter() returns the backend-neutral DiffsHighlighter. New createHighlighter({ preferredHighlighter }) creates an independently disposable instance. getHighlighterIfLoaded() accepts preferredHighlighter, with optional theme and lang checks. isHighlighterLoaded(), isHighlighterLoading(), and isHighlighterNull() accept a backend name and default to 'shiki-js'. disposeHighlighter() disposes all cached backends and clears the shared theme and language caches; instances from createHighlighter() are left alone and keep the themes they have already used.
  • Direct Shiki access — breaking: DiffsHighlighter no longer exposes Shiki methods such as codeToHast(), setTheme(), or loadLanguage(). Use codeToTokens(), codeToHtml(), themeResolver, and optional loadLanguages() / hasLoadedLanguages() / attachLanguages(). attachResolvedLanguages() delegates to the backend’s attachLanguages() and is a no-op for Highlights.
  • Themes — breaking: Theme resolution and getTheme() return DiffsTheme, containing shared colors and optional textmate / zed palettes. Theme resolution helpers accept an optional backend argument, defaulting to 'shiki-js'.
  • Custom themes: registerCustomTheme(name, loader, type = 'textmate') accepts loaders returning TextMate themes, Zed Theme / ThemeFamily objects, or portable DiffsTheme objects. The optional third argument selects 'textmate' for Shiki or 'zed' for Highlights, preserving existing two-argument TextMate calls. Both formats can share a theme name. createCSSVariablesTheme() now returns a portable DiffsTheme instead of re-exporting Shiki’s createCssVariablesTheme, with a default variable prefix of --diffs- (Shiki’s default was --shiki-). Pass variablePrefix: '--shiki-' to keep stylesheets written for the old default, or import createCssVariablesTheme from shiki when a raw Shiki registration is needed.
  • Tokenizers — breaking: Create tokenizers through highlighter.createStreamTokenizer() or highlighter.createLiveTokenizer(). The public ShikiStreamTokenizer and its option/result types are removed. CodeToTokenTransformStream now requires a DiffsHighlighter and disposes its tokenizer on completion, cancellation, or failure. Stream tokenizers emit each line-break character as its own token ('\r' then '\n' for CRLF) so token text round-trips the source; FileStream renders a '\r' token as an empty span.
  • Exports — breaking: The standalone codeToHtml, createTransformerWithState, and AttachedThemes exports are removed. Use highlighter.codeToHtml() and backend theme checks. Shiki-specific type re-exports—including BundledLanguage, CodeToHastOptions, LanguageRegistration, ShikiTransformer, ThemeRegistration, and ThemeRegistrationResolved—must now be imported from shiki.
  • Custom languages: registerCustomLanguage() continues to support Shiki grammars. Highlights uses bundled lexers and renders unsupported languages as plain text.

@vercel

vercel Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
pierre-diffshub Ready Ready Preview Oct 1, 2026 9:20am UTC
pierre-docs-diffs Ready Ready Preview Oct 1, 2026 9:20am UTC
pierre-docs-trees Ready Ready Preview Oct 1, 2026 9:20am UTC
pierrejs-diff-demo Ready Ready Preview Oct 1, 2026 9:20am UTC

Request Review

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f1bb768378

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/diffs/test/e2e/worker-portable.pw.ts Outdated
@vercel
vercel Bot temporarily deployed to Preview – pierrejs-diff-demo September 19, 2026 17:12 Inactive
@ije

ije commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: 6f4a7586ec

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/diffs/src/highlighter/types.ts Outdated
}

/** Tokenization and theme resolution owned by one highlighter backend. */
export interface DiffsHighlighter {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why not make this a class to make future updates easier and reduce verbosity downstream?

I also think it makes for a better developer experience when exploring code, because you can jump to the implementation easier, see how stuff works, etc.

By doing that, we can still keep entrypoints separate by using type imports.

return true;
const names = Array.isArray(languages) ? languages : [languages];
if (highlighter != null)
return highlighter.hasLoadedLanguages?.(names) ?? true;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think going back to the class version of highlighter, we could also make this function not optional, and just have the highlights variant return true, which cleans up code where it's used.

private options: CodeViewOptions<LAnnotation, Caret>;
private workerManager: WorkerPoolManager | undefined;
private isReadySubscription: (() => void) | undefined;
private pendingHighlighterType: HighlighterTypes | undefined;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Would it help simplify things downstream (both here and elsewhere in the code) that once you've tried to load a highlighter, you can never load another highlighter type for a given session?

I only say that because i think it can help make our code stronger, but also help identify misconfiguration errors that people might have.

I.E. maybe a good example might be -- if shiki is the default, but someone wants to use highlights, but maybe there's some race condition or something in their code that forces shiki to load first, and then their code to set highlights comes next and now they've loaded 2 highlighters.

Comment thread packages/diffs/package.json Outdated
"dist/components/web-components.js",
"dist/worker/worker.js",
"dist/worker/worker-portable.js",
"src/worker/worker.ts"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

are we sure src/worker/worker.ts should be here?

Also, i think something I would maybe like to do after this PR, is rework the workers a bit. There's actually no need for portable anymore, and I think what I'd want to do is basically make 3 separate workers that each load their highlighter synchronously.

}

/** Incremental tokenization and bracket ranges independent of a grammar engine. */
export interface DiffsLiveTokenizer {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Kinda feel like this too should be a class

decorations = [],
lineOffsets = [],
}: RenderTokenLinesOptions
): ElementContent[] {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Also, some general codex thoughts on performance improvements by codex:

  1. Walk tokens and decoration boundaries together.
    In appendDecoratedTokens(), every token scans every decoration on its line. Then every emitted fragment scans all those decorations again to find its wrappers.

    With T tokens, D decorations, and F resulting fragments, that’s roughly T × D + F × D range checks, plus sorting a fresh boundary array for every token.

    Instead, sort decoration boundaries once and advance through them as token positions increase. Finished decorations can be discarded; future decorations don’t need checking yet. Our generated diff spans are already ordered and non-overlapping, so that case can use a straightforward two-cursor walk. Nested/custom decorations need more careful handling.

  2. Avoid building an intermediate token array for ordinary lines.
    The normalization loop (renderTokenLines.ts:52) allocates an array for every line, sometimes creates replacement token objects, then another loop immediately turns those tokens into HAST.

    For lines without decorations, whitespace handling could emit HAST directly. That removes an intermediate array and a second token traversal from the common path. A smaller first step: reuse the original tokens when normalization makes no changes.

  3. Skip the editor’s fragment-wrapping pass on undecorated lines.
    wrapTokenFragments() runs for every editor line. It recursively walks the newly created tree and builds replacement child arrays.

    Without decorations, we already emitted one span per nonempty normalized token with its data-char position. There are no decoration-created fragments to reunite. That looks like a useful place to skip the pass while preserving the empty-line <br> behavior.

  4. Precompute each decoration’s bounds for the current line.
    The inner loops repeatedly evaluate whether a decoration starts/ends on this line and derive its numeric bounds. Compute { from, to, decoration } once per line. Likewise, accumulate line length during normalization instead of doing another reduce(), and allocate the empty-marker Set only when empty decorations exist.

I had codex apply these changes (and these tests):
https://gist.github.com/amadeus/78577f378b37bec94866bd10edd626ae

and it came up with these benched improvements:

Synthetic benchmark: 200 lines × 100 input tokens, warmed median timings on Bun 1.4.0.

Scenario Before After Speedup
Plain 0.603 ms 0.467 ms 1.29×
Plain with whitespace 0.629 ms 0.607 ms 1.04×
Editor with whitespace 1.730 ms 1.129 ms 1.53×
One decoration per line 1.139 ms 0.599 ms 1.90×
Sparse decorations 0.726 ms 0.585 ms 1.24×
50 decorations per line 8.811 ms 2.691 ms 3.27×
Nested decorations 1.989 ms 0.866 ms 2.30×
Empty markers 8.833 ms 2.403 ms 3.68×

These measure token-to-HAST conversion only, excluding tokenization and DOM work.

@@ -0,0 +1,141 @@
import type { Element, Nodes, Properties, RootContent } from 'hast';
import { toHtml } from 'hast-util-to-html';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Codex came up with some more performance optimizations for this, just in case: https://gist.github.com/amadeus/4759cd218da454c88918fd6c7b4e75c8

Input Before After Less time
Large highlighted file 2.23 ms 1.80 ms 19%
File with data-char 4.06 ms 3.87 ms 5%
Split diff 1.04 ms 0.88 ms 16%
Unified diff 0.73 ms 0.62 ms 15%
Synthetic escape-heavy spans 18.29 ms 6.18 ms 66%

return styles;
}

function getThemeColors(theme: DiffsTheme, highlighter: DiffsHighlighter) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Maybe a quick comment on why this is needed?

What happens if it's highlights type but cssVariables is false/undefined? What does that mean?

* `WorkerPoolManager.highlightsOnMainThread`), leaves highlighting to the
* surface's local highlighter, which still uses the pool's render options.
*/
export function highlightsInWorkers(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Based on my previous comment, this should ultimately go away when we move workerpool back to working as it did before.

return this.asyncHighlight(diff)
.then((fresh) => this.applyRefreshedResult(diff, fresh))
.then((fresh) => {
if (preferredHighlighter !== this.options.preferredHighlighter) return;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think a ton of stuff in this renderer and the file renderer will be reverted if don't need to support multiple highlighters simultaneously.

@amadeus

amadeus commented Sep 30, 2026

Copy link
Copy Markdown
Member

Great start on this! I think there's some high level stuff that was can simplify with this architecture, and the top level feedback here is:

  • I only think we should allow one type of highlighter at a time, and we should throw if someone attempts to instantiate another if one is already instantiated.
    • This will simplify a ton of stuff downstream and I think help devs/AI realize when they fucked up something
  • I think we need to create proper classes for some of these types instead of using interfaces (i documented where in the review)
    • I think it helps when jumping around code (you can easily go right to the implementation)
    • We can still ensure modules are separate by using type imports most of the time
    • It's a lot easier to maintain things going forward because you can just change the source of truth without needing to update multiple places
  • I'm wondering if we can lean into some of the areas with highlights and how it likes to return data (strings) instead of using HAST... but maybe that's too complicated at the moment

Also as a follow up to this PR, I would love to maybe re-work the worker stuff a bit, and maybe make 3 different entry points for the different types, and not async load the highlighters in each of those workers, so they load up maybe a bit faster and are better self contained.

@ije

ije commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-01T09:40:11.745402Z 6dacbda Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: 6dacbda06f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

This branch was successfully deployed

4 active deployments
Preview – pierre-docs-trees — 6dacbda0 Deployed Oct 1, 2026 by vercel[bot]
Preview – pierre-docs-diffs — 6dacbda0 Deployed Oct 1, 2026 by vercel[bot]
Preview – pierre-diffshub — 6dacbda0 Deployed Oct 1, 2026 by vercel[bot]
Preview – pierrejs-diff-demo — 6dacbda0 Deployed Oct 1, 2026 by vercel[bot]
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