feat(coding-agent): simplify lazy syntax grammar loading

This commit is contained in:
Armin Ronacher 2026-08-08 09:59:03 +02:00
parent 2149e32489
commit 704be487af
9 changed files with 458 additions and 880 deletions

View file

@ -2882,12 +2882,6 @@ const typescriptLines = highlightCode("const x = 1;", "typescript");
// Detect language from a file path
const lang = getLanguageFromPath("/path/to/file.rs"); // "rust"
const fileLines = highlightCode(code, lang);
// Uncommon grammars load lazily. In a tool renderer, request another render
// after the grammar and its dependencies are ready.
const lazyLines = highlightCode(code, lang, {
onLanguageReady: context.invalidate,
});
```
## Error Handling

View file

@ -393,7 +393,6 @@ export {
getMarkdownTheme,
getSelectListTheme,
getSettingsListTheme,
type HighlightCodeOptions,
highlightCode,
initTheme,
Theme,

View file

@ -26,8 +26,6 @@ export class InteractiveThemeController {
private autoSyncEnabled = false;
private terminalColorSchemeUnsubscribe: (() => void) | undefined;
private highlightLanguageLoadUnsubscribe: (() => void) | undefined;
private highlightLanguageRefreshTimer: ReturnType<typeof setTimeout> | undefined;
private highlightLanguageGlobalRefreshPending = false;
constructor(ui: TUI, settingsManager: SettingsManager, showError: (message: string) => void, onChanged: () => void) {
this.ui = ui;
@ -42,14 +40,8 @@ export class InteractiveThemeController {
this.showError(`Failed to load syntax highlighting language "${event.language}": ${message}`);
return;
}
this.highlightLanguageGlobalRefreshPending ||= event.requiresGlobalRefresh;
if (this.highlightLanguageRefreshTimer !== undefined) return;
this.highlightLanguageRefreshTimer = setTimeout(() => {
this.highlightLanguageRefreshTimer = undefined;
if (this.highlightLanguageGlobalRefreshPending) this.ui.invalidate();
this.highlightLanguageGlobalRefreshPending = false;
this.ui.requestRender();
}, 0);
this.ui.invalidate();
this.ui.requestRender();
});
this.bindTerminalColorSchemeListener();
}
@ -117,9 +109,6 @@ export class InteractiveThemeController {
this.terminalColorSchemeUnsubscribe = undefined;
this.highlightLanguageLoadUnsubscribe?.();
this.highlightLanguageLoadUnsubscribe = undefined;
if (this.highlightLanguageRefreshTimer !== undefined) clearTimeout(this.highlightLanguageRefreshTimer);
this.highlightLanguageRefreshTimer = undefined;
this.highlightLanguageGlobalRefreshPending = false;
}
getTerminalTheme(): TerminalTheme {

View file

@ -17,8 +17,6 @@ import type { SourceInfo } from "../../../core/source-info.ts";
import { closeWatcher, watchWithErrorHandler } from "../../../utils/fs-watch.ts";
import { highlight, requestHighlightLanguage } from "../../../utils/syntax-highlight.ts";
export { getLanguageFromPath } from "../../../utils/syntax-highlight.ts";
// ============================================================================
// Types & Schema
// ============================================================================
@ -1155,18 +1153,14 @@ function getCliHighlightTheme(t: Theme): CliHighlightTheme {
return cachedCliHighlightTheme;
}
export interface HighlightCodeOptions {
onLanguageReady?: () => void;
}
/**
* Highlight code with syntax coloring based on file extension or language.
* Returns array of highlighted lines.
*/
export function highlightCode(code: string, lang?: string, options: HighlightCodeOptions = {}): string[] {
export function highlightCode(code: string, lang?: string): string[] {
// Validate language before highlighting to avoid stderr spam from highlight.js.
// Uncommon languages start loading in the background and render plainly once.
const validLang = lang ? requestHighlightLanguage(lang, options.onLanguageReady) : undefined;
const validLang = lang ? requestHighlightLanguage(lang) : undefined;
// Skip highlighting when no valid language is specified. highlight.js's
// auto-detection is unreliable and can misidentify prose as AppleScript,
// LiveCodeServer, etc., coloring random English words as keywords.
@ -1185,6 +1179,77 @@ export function highlightCode(code: string, lang?: string, options: HighlightCod
}
}
/**
* Get language identifier from file path extension.
*/
export function getLanguageFromPath(filePath: string): string | undefined {
const ext = filePath.split(".").pop()?.toLowerCase();
if (!ext) return undefined;
const extToLang: Record<string, string> = {
ts: "typescript",
tsx: "typescript",
js: "javascript",
jsx: "javascript",
mjs: "javascript",
cjs: "javascript",
py: "python",
rb: "ruby",
rs: "rust",
go: "go",
java: "java",
kt: "kotlin",
swift: "swift",
c: "c",
h: "c",
cpp: "cpp",
cc: "cpp",
cxx: "cpp",
hpp: "cpp",
cs: "csharp",
php: "php",
sh: "bash",
bash: "bash",
zsh: "bash",
fish: "fish",
ps1: "powershell",
sql: "sql",
html: "html",
htm: "html",
css: "css",
scss: "scss",
sass: "sass",
less: "less",
json: "json",
yaml: "yaml",
yml: "yaml",
toml: "toml",
xml: "xml",
md: "markdown",
markdown: "markdown",
dockerfile: "dockerfile",
makefile: "makefile",
cmake: "cmake",
lua: "lua",
perl: "perl",
r: "r",
scala: "scala",
clj: "clojure",
ex: "elixir",
exs: "elixir",
erl: "erlang",
hs: "haskell",
ml: "ocaml",
vim: "vim",
graphql: "graphql",
proto: "protobuf",
tf: "hcl",
hcl: "hcl",
};
return extToLang[ext];
}
export function getMarkdownTheme(): MarkdownTheme {
return {
heading: (text: string) => theme.fg("mdHeading", text),
@ -1201,7 +1266,7 @@ export function getMarkdownTheme(): MarkdownTheme {
italic: (text: string) => theme.italic(text),
underline: (text: string) => theme.underline(text),
strikethrough: (text: string) => chalk.strikethrough(text),
highlightCode: (code, lang, onLanguageReady) => highlightCode(code, lang, { onLanguageReady }),
highlightCode,
};
}

View file

@ -1,7 +1,7 @@
import type { LanguageDefinition } from "highlight.js/lib/core.js";
import bash from "highlight.js/lib/languages/bash.js";
import css from "highlight.js/lib/languages/css.js";
import diff from "highlight.js/lib/languages/diff.js";
import diffLanguage from "highlight.js/lib/languages/diff.js";
import javascript from "highlight.js/lib/languages/javascript.js";
import json from "highlight.js/lib/languages/json.js";
import markdown from "highlight.js/lib/languages/markdown.js";
@ -12,545 +12,253 @@ import xml from "highlight.js/lib/languages/xml.js";
import yaml from "highlight.js/lib/languages/yaml.js";
type LanguageModule = { default: LanguageDefinition };
type LanguageLoader = () => Promise<LanguageModule>;
export interface HighlightLanguage {
name: string;
extensions?: readonly string[];
aliases?: readonly string[];
dependencies?: readonly string[];
definition?: LanguageDefinition;
load?: () => Promise<LanguageModule>;
}
export const HIGHLIGHT_LANGUAGE_DEFINITIONS: Record<string, LanguageDefinition> = {
xml: xml,
bash: bash,
css: css,
markdown: markdown,
diff: diffLanguage,
ruby: ruby,
javascript: javascript,
json: json,
python: python,
yaml: yaml,
typescript: typescript,
};
// Registration, aliases, dependencies, lazy importers, and file-extension
// detection live in one registry.
//
// Intentional exception to the no-dynamic-import rule: literal dynamic imports
// let Bun embed every grammar in the standalone binary while keeping evaluation
// lazy, and let Node load only the requested grammar.
export const HIGHLIGHT_LANGUAGES: readonly HighlightLanguage[] = [
{ name: "1c", load: () => import("highlight.js/lib/languages/1c.js") },
{ name: "abnf", load: () => import("highlight.js/lib/languages/abnf.js") },
{ name: "accesslog", load: () => import("highlight.js/lib/languages/accesslog.js") },
{
name: "actionscript",
aliases: ["as"],
load: () => import("highlight.js/lib/languages/actionscript.js"),
},
{ name: "ada", load: () => import("highlight.js/lib/languages/ada.js") },
{
name: "angelscript",
aliases: ["asc"],
load: () => import("highlight.js/lib/languages/angelscript.js"),
},
{
name: "apache",
aliases: ["apacheconf"],
load: () => import("highlight.js/lib/languages/apache.js"),
},
{
name: "applescript",
aliases: ["osascript"],
load: () => import("highlight.js/lib/languages/applescript.js"),
},
{ name: "arcade", load: () => import("highlight.js/lib/languages/arcade.js") },
{ name: "arduino", aliases: ["ino"], load: () => import("highlight.js/lib/languages/arduino.js") },
{ name: "armasm", aliases: ["arm"], load: () => import("highlight.js/lib/languages/armasm.js") },
{
name: "xml",
extensions: ["xml"],
aliases: ["html", "xhtml", "rss", "atom", "xjb", "xsd", "xsl", "plist", "wsf", "svg"],
dependencies: ["css", "javascript"],
definition: xml,
},
{ name: "html", extensions: ["html", "htm"], dependencies: ["xml"] },
{
name: "asciidoc",
aliases: ["adoc"],
dependencies: ["xml"],
load: () => import("highlight.js/lib/languages/asciidoc.js"),
},
{ name: "aspectj", load: () => import("highlight.js/lib/languages/aspectj.js") },
{
name: "autohotkey",
aliases: ["ahk"],
load: () => import("highlight.js/lib/languages/autohotkey.js"),
},
{ name: "autoit", load: () => import("highlight.js/lib/languages/autoit.js") },
{ name: "avrasm", load: () => import("highlight.js/lib/languages/avrasm.js") },
{ name: "awk", load: () => import("highlight.js/lib/languages/awk.js") },
{ name: "axapta", aliases: ["x++"], load: () => import("highlight.js/lib/languages/axapta.js") },
{ name: "bash", extensions: ["sh", "bash", "zsh"], aliases: ["sh", "zsh"], definition: bash },
{ name: "basic", load: () => import("highlight.js/lib/languages/basic.js") },
{ name: "bnf", load: () => import("highlight.js/lib/languages/bnf.js") },
{
name: "brainfuck",
aliases: ["bf"],
load: () => import("highlight.js/lib/languages/brainfuck.js"),
},
{
name: "c-like",
aliases: ["c", "h", "cc", "c++", "h++", "hpp", "hh", "hxx", "cxx"],
load: () => import("highlight.js/lib/languages/c-like.js"),
},
{ name: "c", extensions: ["c", "h"], aliases: ["h"], load: () => import("highlight.js/lib/languages/c.js") },
{ name: "cal", load: () => import("highlight.js/lib/languages/cal.js") },
{
name: "capnproto",
aliases: ["capnp"],
load: () => import("highlight.js/lib/languages/capnproto.js"),
},
{ name: "ceylon", load: () => import("highlight.js/lib/languages/ceylon.js") },
{
name: "clean",
aliases: ["icl", "dcl"],
load: () => import("highlight.js/lib/languages/clean.js"),
},
{
name: "clojure",
extensions: ["clj"],
aliases: ["clj"],
load: () => import("highlight.js/lib/languages/clojure.js"),
},
{
name: "clojure-repl",
dependencies: ["clojure"],
load: () => import("highlight.js/lib/languages/clojure-repl.js"),
},
{
name: "cmake",
extensions: ["cmake"],
aliases: ["cmake.in"],
load: () => import("highlight.js/lib/languages/cmake.js"),
},
{
name: "coffeescript",
aliases: ["coffee", "cson", "iced"],
dependencies: ["javascript"],
load: () => import("highlight.js/lib/languages/coffeescript.js"),
},
{ name: "coq", load: () => import("highlight.js/lib/languages/coq.js") },
{
name: "cos",
aliases: ["cls"],
dependencies: ["javascript", "sql", "xml"],
load: () => import("highlight.js/lib/languages/cos.js"),
},
{
name: "cpp",
extensions: ["cpp", "cc", "cxx", "hpp"],
aliases: ["cc", "c++", "h++", "hpp", "hh", "hxx", "cxx"],
load: () => import("highlight.js/lib/languages/cpp.js"),
},
{
name: "crmsh",
aliases: ["crm", "pcmk"],
load: () => import("highlight.js/lib/languages/crmsh.js"),
},
{ name: "crystal", aliases: ["cr"], load: () => import("highlight.js/lib/languages/crystal.js") },
{
name: "csharp",
extensions: ["cs"],
aliases: ["cs", "c#"],
load: () => import("highlight.js/lib/languages/csharp.js"),
},
{ name: "csp", load: () => import("highlight.js/lib/languages/csp.js") },
{ name: "css", extensions: ["css"], definition: css },
{ name: "d", load: () => import("highlight.js/lib/languages/d.js") },
{
name: "markdown",
extensions: ["md", "markdown"],
aliases: ["md", "mkdown", "mkd"],
dependencies: ["xml"],
definition: markdown,
},
{
name: "dart",
dependencies: ["markdown"],
load: () => import("highlight.js/lib/languages/dart.js"),
},
{
name: "delphi",
aliases: ["dpr", "dfm", "pas", "pascal", "freepascal", "lazarus", "lpr", "lfm"],
load: () => import("highlight.js/lib/languages/delphi.js"),
},
{ name: "diff", aliases: ["patch"], definition: diff },
{
name: "django",
aliases: ["jinja"],
dependencies: ["xml"],
load: () => import("highlight.js/lib/languages/django.js"),
},
{ name: "dns", aliases: ["bind", "zone"], load: () => import("highlight.js/lib/languages/dns.js") },
{
name: "dockerfile",
extensions: ["dockerfile"],
aliases: ["docker"],
dependencies: ["bash"],
load: () => import("highlight.js/lib/languages/dockerfile.js"),
},
{ name: "dos", aliases: ["bat", "cmd"], load: () => import("highlight.js/lib/languages/dos.js") },
{ name: "dsconfig", load: () => import("highlight.js/lib/languages/dsconfig.js") },
{ name: "dts", load: () => import("highlight.js/lib/languages/dts.js") },
{
name: "dust",
aliases: ["dst"],
dependencies: ["xml"],
load: () => import("highlight.js/lib/languages/dust.js"),
},
{ name: "ebnf", load: () => import("highlight.js/lib/languages/ebnf.js") },
{ name: "elixir", extensions: ["ex", "exs"], load: () => import("highlight.js/lib/languages/elixir.js") },
{ name: "elm", load: () => import("highlight.js/lib/languages/elm.js") },
{
name: "ruby",
extensions: ["rb"],
aliases: ["rb", "gemspec", "podspec", "thor", "irb"],
definition: ruby,
},
{
name: "erb",
dependencies: ["ruby", "xml"],
load: () => import("highlight.js/lib/languages/erb.js"),
},
{ name: "erlang-repl", load: () => import("highlight.js/lib/languages/erlang-repl.js") },
{
name: "erlang",
extensions: ["erl"],
aliases: ["erl"],
load: () => import("highlight.js/lib/languages/erlang.js"),
},
{
name: "excel",
aliases: ["xlsx", "xls"],
load: () => import("highlight.js/lib/languages/excel.js"),
},
{ name: "fix", load: () => import("highlight.js/lib/languages/fix.js") },
{ name: "fish", extensions: ["fish"] },
{ name: "flix", load: () => import("highlight.js/lib/languages/flix.js") },
{
name: "fortran",
aliases: ["f90", "f95"],
load: () => import("highlight.js/lib/languages/fortran.js"),
},
{ name: "fsharp", aliases: ["fs"], load: () => import("highlight.js/lib/languages/fsharp.js") },
{ name: "gams", aliases: ["gms"], load: () => import("highlight.js/lib/languages/gams.js") },
{ name: "gauss", aliases: ["gss"], load: () => import("highlight.js/lib/languages/gauss.js") },
{ name: "gcode", aliases: ["nc"], load: () => import("highlight.js/lib/languages/gcode.js") },
{
name: "gherkin",
aliases: ["feature"],
load: () => import("highlight.js/lib/languages/gherkin.js"),
},
{ name: "glsl", load: () => import("highlight.js/lib/languages/glsl.js") },
{ name: "gml", load: () => import("highlight.js/lib/languages/gml.js") },
{ name: "go", extensions: ["go"], aliases: ["golang"], load: () => import("highlight.js/lib/languages/go.js") },
{ name: "golo", load: () => import("highlight.js/lib/languages/golo.js") },
{ name: "gradle", load: () => import("highlight.js/lib/languages/gradle.js") },
{ name: "graphql", extensions: ["graphql"] },
{ name: "groovy", load: () => import("highlight.js/lib/languages/groovy.js") },
{
name: "haml",
dependencies: ["ruby"],
load: () => import("highlight.js/lib/languages/haml.js"),
},
{
name: "handlebars",
aliases: ["hbs", "html.hbs", "html.handlebars", "htmlbars"],
dependencies: ["xml"],
load: () => import("highlight.js/lib/languages/handlebars.js"),
},
{
name: "haskell",
extensions: ["hs"],
aliases: ["hs"],
load: () => import("highlight.js/lib/languages/haskell.js"),
},
{ name: "hcl", extensions: ["tf", "hcl"] },
{ name: "haxe", aliases: ["hx"], load: () => import("highlight.js/lib/languages/haxe.js") },
{ name: "hsp", load: () => import("highlight.js/lib/languages/hsp.js") },
{
name: "htmlbars",
aliases: ["hbs", "html.hbs", "html.handlebars", "htmlbars"],
dependencies: ["xml"],
load: () => import("highlight.js/lib/languages/htmlbars.js"),
},
{ name: "http", aliases: ["https"], load: () => import("highlight.js/lib/languages/http.js") },
{ name: "hy", aliases: ["hylang"], load: () => import("highlight.js/lib/languages/hy.js") },
{ name: "inform7", aliases: ["i7"], load: () => import("highlight.js/lib/languages/inform7.js") },
{ name: "ini", aliases: ["toml"], load: () => import("highlight.js/lib/languages/ini.js") },
{ name: "irpf90", load: () => import("highlight.js/lib/languages/irpf90.js") },
{ name: "isbl", load: () => import("highlight.js/lib/languages/isbl.js") },
{ name: "java", extensions: ["java"], aliases: ["jsp"], load: () => import("highlight.js/lib/languages/java.js") },
{
name: "javascript",
extensions: ["js", "jsx", "mjs", "cjs"],
aliases: ["js", "jsx", "mjs", "cjs"],
dependencies: ["css", "xml"],
definition: javascript,
},
{
name: "jboss-cli",
aliases: ["wildfly-cli"],
load: () => import("highlight.js/lib/languages/jboss-cli.js"),
},
{ name: "json", extensions: ["json"], definition: json },
{ name: "julia", load: () => import("highlight.js/lib/languages/julia.js") },
{
name: "julia-repl",
dependencies: ["julia"],
load: () => import("highlight.js/lib/languages/julia-repl.js"),
},
{
name: "kotlin",
extensions: ["kt"],
aliases: ["kt", "kts"],
load: () => import("highlight.js/lib/languages/kotlin.js"),
},
{
name: "lasso",
aliases: ["ls", "lassoscript"],
load: () => import("highlight.js/lib/languages/lasso.js"),
},
{ name: "latex", aliases: ["tex"], load: () => import("highlight.js/lib/languages/latex.js") },
{ name: "ldif", load: () => import("highlight.js/lib/languages/ldif.js") },
{ name: "leaf", load: () => import("highlight.js/lib/languages/leaf.js") },
{ name: "less", extensions: ["less"], load: () => import("highlight.js/lib/languages/less.js") },
{ name: "lisp", load: () => import("highlight.js/lib/languages/lisp.js") },
{ name: "livecodeserver", load: () => import("highlight.js/lib/languages/livecodeserver.js") },
{
name: "livescript",
aliases: ["ls"],
dependencies: ["javascript"],
load: () => import("highlight.js/lib/languages/livescript.js"),
},
{ name: "llvm", load: () => import("highlight.js/lib/languages/llvm.js") },
{ name: "lsl", load: () => import("highlight.js/lib/languages/lsl.js") },
{ name: "lua", extensions: ["lua"], load: () => import("highlight.js/lib/languages/lua.js") },
{
name: "makefile",
extensions: ["makefile"],
aliases: ["mk", "mak", "make"],
load: () => import("highlight.js/lib/languages/makefile.js"),
},
{
name: "mathematica",
aliases: ["mma", "wl"],
load: () => import("highlight.js/lib/languages/mathematica.js"),
},
{ name: "matlab", load: () => import("highlight.js/lib/languages/matlab.js") },
{ name: "maxima", load: () => import("highlight.js/lib/languages/maxima.js") },
{ name: "mel", load: () => import("highlight.js/lib/languages/mel.js") },
{
name: "mercury",
aliases: ["m", "moo"],
load: () => import("highlight.js/lib/languages/mercury.js"),
},
{ name: "mipsasm", aliases: ["mips"], load: () => import("highlight.js/lib/languages/mipsasm.js") },
{ name: "mizar", load: () => import("highlight.js/lib/languages/mizar.js") },
{
name: "perl",
extensions: ["perl"],
aliases: ["pl", "pm"],
dependencies: ["mojolicious"],
load: () => import("highlight.js/lib/languages/perl.js"),
},
{
name: "mojolicious",
dependencies: ["perl", "xml"],
load: () => import("highlight.js/lib/languages/mojolicious.js"),
},
{ name: "monkey", load: () => import("highlight.js/lib/languages/monkey.js") },
{
name: "moonscript",
aliases: ["moon"],
load: () => import("highlight.js/lib/languages/moonscript.js"),
},
{ name: "n1ql", load: () => import("highlight.js/lib/languages/n1ql.js") },
{ name: "nginx", aliases: ["nginxconf"], load: () => import("highlight.js/lib/languages/nginx.js") },
{ name: "nim", load: () => import("highlight.js/lib/languages/nim.js") },
{ name: "nix", aliases: ["nixos"], load: () => import("highlight.js/lib/languages/nix.js") },
{
name: "node-repl",
dependencies: ["javascript"],
load: () => import("highlight.js/lib/languages/node-repl.js"),
},
{ name: "nsis", load: () => import("highlight.js/lib/languages/nsis.js") },
{
name: "objectivec",
aliases: ["mm", "objc", "obj-c", "obj-c++", "objective-c++"],
load: () => import("highlight.js/lib/languages/objectivec.js"),
},
{ name: "ocaml", extensions: ["ml"], aliases: ["ml"], load: () => import("highlight.js/lib/languages/ocaml.js") },
{
name: "openscad",
aliases: ["scad"],
load: () => import("highlight.js/lib/languages/openscad.js"),
},
{ name: "oxygene", load: () => import("highlight.js/lib/languages/oxygene.js") },
{
name: "parser3",
dependencies: ["xml"],
load: () => import("highlight.js/lib/languages/parser3.js"),
},
{ name: "pf", aliases: ["pf.conf"], load: () => import("highlight.js/lib/languages/pf.js") },
{
name: "pgsql",
aliases: ["postgres", "postgresql"],
dependencies: ["bash", "java", "json", "lua", "perl", "php", "python", "r", "ruby", "scheme", "tcl", "xml"],
load: () => import("highlight.js/lib/languages/pgsql.js"),
},
{
name: "php",
extensions: ["php"],
aliases: ["php3", "php4", "php5", "php6", "php7", "php8"],
load: () => import("highlight.js/lib/languages/php.js"),
},
{
name: "php-template",
dependencies: ["php", "xml"],
load: () => import("highlight.js/lib/languages/php-template.js"),
},
{
name: "plaintext",
aliases: ["text", "txt"],
load: () => import("highlight.js/lib/languages/plaintext.js"),
},
{ name: "pony", load: () => import("highlight.js/lib/languages/pony.js") },
{
name: "powershell",
extensions: ["ps1"],
aliases: ["ps", "ps1"],
load: () => import("highlight.js/lib/languages/powershell.js"),
},
{ name: "processing", load: () => import("highlight.js/lib/languages/processing.js") },
{ name: "profile", load: () => import("highlight.js/lib/languages/profile.js") },
{ name: "prolog", load: () => import("highlight.js/lib/languages/prolog.js") },
{ name: "properties", load: () => import("highlight.js/lib/languages/properties.js") },
{ name: "protobuf", extensions: ["proto"], load: () => import("highlight.js/lib/languages/protobuf.js") },
{ name: "puppet", aliases: ["pp"], load: () => import("highlight.js/lib/languages/puppet.js") },
{
name: "purebasic",
aliases: ["pb", "pbi"],
load: () => import("highlight.js/lib/languages/purebasic.js"),
},
{ name: "python", extensions: ["py"], aliases: ["py", "gyp", "ipython"], definition: python },
{
name: "python-repl",
aliases: ["pycon"],
dependencies: ["python"],
load: () => import("highlight.js/lib/languages/python-repl.js"),
},
{ name: "q", aliases: ["k", "kdb"], load: () => import("highlight.js/lib/languages/q.js") },
{
name: "qml",
aliases: ["qt"],
dependencies: ["xml"],
load: () => import("highlight.js/lib/languages/qml.js"),
},
{ name: "r", extensions: ["r"], load: () => import("highlight.js/lib/languages/r.js") },
{ name: "reasonml", aliases: ["re"], load: () => import("highlight.js/lib/languages/reasonml.js") },
{ name: "rib", load: () => import("highlight.js/lib/languages/rib.js") },
{
name: "roboconf",
aliases: ["graph", "instances"],
load: () => import("highlight.js/lib/languages/roboconf.js"),
},
{
name: "routeros",
aliases: ["mikrotik"],
load: () => import("highlight.js/lib/languages/routeros.js"),
},
{ name: "rsl", load: () => import("highlight.js/lib/languages/rsl.js") },
{ name: "ruleslanguage", load: () => import("highlight.js/lib/languages/ruleslanguage.js") },
{ name: "rust", extensions: ["rs"], aliases: ["rs"], load: () => import("highlight.js/lib/languages/rust.js") },
{ name: "sas", load: () => import("highlight.js/lib/languages/sas.js") },
{ name: "sass", extensions: ["sass"] },
{ name: "scala", extensions: ["scala"], load: () => import("highlight.js/lib/languages/scala.js") },
{ name: "scheme", load: () => import("highlight.js/lib/languages/scheme.js") },
{ name: "scilab", aliases: ["sci"], load: () => import("highlight.js/lib/languages/scilab.js") },
{ name: "scss", extensions: ["scss"], load: () => import("highlight.js/lib/languages/scss.js") },
{
name: "shell",
aliases: ["console"],
dependencies: ["bash"],
load: () => import("highlight.js/lib/languages/shell.js"),
},
{ name: "smali", load: () => import("highlight.js/lib/languages/smali.js") },
{
name: "smalltalk",
aliases: ["st"],
load: () => import("highlight.js/lib/languages/smalltalk.js"),
},
{ name: "sml", aliases: ["ml"], load: () => import("highlight.js/lib/languages/sml.js") },
{ name: "sqf", load: () => import("highlight.js/lib/languages/sqf.js") },
{
name: "sql_more",
aliases: ["mysql", "oracle"],
load: () => import("highlight.js/lib/languages/sql_more.js"),
},
{ name: "sql", extensions: ["sql"], load: () => import("highlight.js/lib/languages/sql.js") },
{ name: "stan", aliases: ["stanfuncs"], load: () => import("highlight.js/lib/languages/stan.js") },
{ name: "stata", aliases: ["do", "ado"], load: () => import("highlight.js/lib/languages/stata.js") },
{
name: "step21",
aliases: ["p21", "step", "stp"],
load: () => import("highlight.js/lib/languages/step21.js"),
},
{ name: "stylus", aliases: ["styl"], load: () => import("highlight.js/lib/languages/stylus.js") },
{ name: "subunit", load: () => import("highlight.js/lib/languages/subunit.js") },
{ name: "swift", extensions: ["swift"], load: () => import("highlight.js/lib/languages/swift.js") },
{ name: "taggerscript", load: () => import("highlight.js/lib/languages/taggerscript.js") },
{
name: "yaml",
extensions: ["yaml", "yml"],
aliases: ["yml"],
dependencies: ["ruby"],
definition: yaml,
},
{
name: "tap",
dependencies: ["yaml"],
load: () => import("highlight.js/lib/languages/tap.js"),
},
{ name: "tcl", aliases: ["tk"], load: () => import("highlight.js/lib/languages/tcl.js") },
{ name: "thrift", load: () => import("highlight.js/lib/languages/thrift.js") },
{ name: "toml", extensions: ["toml"], dependencies: ["ini"] },
{ name: "tp", load: () => import("highlight.js/lib/languages/tp.js") },
{
name: "twig",
aliases: ["craftcms"],
dependencies: ["xml"],
load: () => import("highlight.js/lib/languages/twig.js"),
},
{
name: "typescript",
extensions: ["ts", "tsx"],
aliases: ["ts", "tsx"],
dependencies: ["css", "xml"],
definition: typescript,
},
{ name: "vala", load: () => import("highlight.js/lib/languages/vala.js") },
{ name: "vbnet", aliases: ["vb"], load: () => import("highlight.js/lib/languages/vbnet.js") },
{ name: "vbscript", aliases: ["vbs"], load: () => import("highlight.js/lib/languages/vbscript.js") },
{
name: "vbscript-html",
dependencies: ["vbscript", "xml"],
load: () => import("highlight.js/lib/languages/vbscript-html.js"),
},
{
name: "verilog",
aliases: ["v", "sv", "svh"],
load: () => import("highlight.js/lib/languages/verilog.js"),
},
{ name: "vhdl", load: () => import("highlight.js/lib/languages/vhdl.js") },
{ name: "vim", extensions: ["vim"], load: () => import("highlight.js/lib/languages/vim.js") },
{ name: "x86asm", load: () => import("highlight.js/lib/languages/x86asm.js") },
{ name: "xl", aliases: ["tao"], load: () => import("highlight.js/lib/languages/xl.js") },
{
name: "xquery",
aliases: ["xpath", "xq"],
dependencies: ["xml"],
load: () => import("highlight.js/lib/languages/xquery.js"),
},
{ name: "zephir", aliases: ["zep"], load: () => import("highlight.js/lib/languages/zephir.js") },
];
export const HIGHLIGHT_LANGUAGE_LOADERS: Record<string, LanguageLoader> = {
"1c": () => import("highlight.js/lib/languages/1c.js"),
abnf: () => import("highlight.js/lib/languages/abnf.js"),
accesslog: () => import("highlight.js/lib/languages/accesslog.js"),
actionscript: () => import("highlight.js/lib/languages/actionscript.js"),
ada: () => import("highlight.js/lib/languages/ada.js"),
angelscript: () => import("highlight.js/lib/languages/angelscript.js"),
apache: () => import("highlight.js/lib/languages/apache.js"),
applescript: () => import("highlight.js/lib/languages/applescript.js"),
arcade: () => import("highlight.js/lib/languages/arcade.js"),
arduino: () => import("highlight.js/lib/languages/arduino.js"),
armasm: () => import("highlight.js/lib/languages/armasm.js"),
asciidoc: () => import("highlight.js/lib/languages/asciidoc.js"),
aspectj: () => import("highlight.js/lib/languages/aspectj.js"),
autohotkey: () => import("highlight.js/lib/languages/autohotkey.js"),
autoit: () => import("highlight.js/lib/languages/autoit.js"),
avrasm: () => import("highlight.js/lib/languages/avrasm.js"),
awk: () => import("highlight.js/lib/languages/awk.js"),
axapta: () => import("highlight.js/lib/languages/axapta.js"),
basic: () => import("highlight.js/lib/languages/basic.js"),
bnf: () => import("highlight.js/lib/languages/bnf.js"),
brainfuck: () => import("highlight.js/lib/languages/brainfuck.js"),
"c-like": () => import("highlight.js/lib/languages/c-like.js"),
c: () => import("highlight.js/lib/languages/c.js"),
cal: () => import("highlight.js/lib/languages/cal.js"),
capnproto: () => import("highlight.js/lib/languages/capnproto.js"),
ceylon: () => import("highlight.js/lib/languages/ceylon.js"),
clean: () => import("highlight.js/lib/languages/clean.js"),
clojure: () => import("highlight.js/lib/languages/clojure.js"),
"clojure-repl": () => import("highlight.js/lib/languages/clojure-repl.js"),
cmake: () => import("highlight.js/lib/languages/cmake.js"),
coffeescript: () => import("highlight.js/lib/languages/coffeescript.js"),
coq: () => import("highlight.js/lib/languages/coq.js"),
cos: () => import("highlight.js/lib/languages/cos.js"),
cpp: () => import("highlight.js/lib/languages/cpp.js"),
crmsh: () => import("highlight.js/lib/languages/crmsh.js"),
crystal: () => import("highlight.js/lib/languages/crystal.js"),
csharp: () => import("highlight.js/lib/languages/csharp.js"),
csp: () => import("highlight.js/lib/languages/csp.js"),
d: () => import("highlight.js/lib/languages/d.js"),
dart: () => import("highlight.js/lib/languages/dart.js"),
delphi: () => import("highlight.js/lib/languages/delphi.js"),
django: () => import("highlight.js/lib/languages/django.js"),
dns: () => import("highlight.js/lib/languages/dns.js"),
dockerfile: () => import("highlight.js/lib/languages/dockerfile.js"),
dos: () => import("highlight.js/lib/languages/dos.js"),
dsconfig: () => import("highlight.js/lib/languages/dsconfig.js"),
dts: () => import("highlight.js/lib/languages/dts.js"),
dust: () => import("highlight.js/lib/languages/dust.js"),
ebnf: () => import("highlight.js/lib/languages/ebnf.js"),
elixir: () => import("highlight.js/lib/languages/elixir.js"),
elm: () => import("highlight.js/lib/languages/elm.js"),
erb: () => import("highlight.js/lib/languages/erb.js"),
"erlang-repl": () => import("highlight.js/lib/languages/erlang-repl.js"),
erlang: () => import("highlight.js/lib/languages/erlang.js"),
excel: () => import("highlight.js/lib/languages/excel.js"),
fix: () => import("highlight.js/lib/languages/fix.js"),
flix: () => import("highlight.js/lib/languages/flix.js"),
fortran: () => import("highlight.js/lib/languages/fortran.js"),
fsharp: () => import("highlight.js/lib/languages/fsharp.js"),
gams: () => import("highlight.js/lib/languages/gams.js"),
gauss: () => import("highlight.js/lib/languages/gauss.js"),
gcode: () => import("highlight.js/lib/languages/gcode.js"),
gherkin: () => import("highlight.js/lib/languages/gherkin.js"),
glsl: () => import("highlight.js/lib/languages/glsl.js"),
gml: () => import("highlight.js/lib/languages/gml.js"),
go: () => import("highlight.js/lib/languages/go.js"),
golo: () => import("highlight.js/lib/languages/golo.js"),
gradle: () => import("highlight.js/lib/languages/gradle.js"),
groovy: () => import("highlight.js/lib/languages/groovy.js"),
haml: () => import("highlight.js/lib/languages/haml.js"),
handlebars: () => import("highlight.js/lib/languages/handlebars.js"),
haskell: () => import("highlight.js/lib/languages/haskell.js"),
haxe: () => import("highlight.js/lib/languages/haxe.js"),
hsp: () => import("highlight.js/lib/languages/hsp.js"),
htmlbars: () => import("highlight.js/lib/languages/htmlbars.js"),
http: () => import("highlight.js/lib/languages/http.js"),
hy: () => import("highlight.js/lib/languages/hy.js"),
inform7: () => import("highlight.js/lib/languages/inform7.js"),
ini: () => import("highlight.js/lib/languages/ini.js"),
irpf90: () => import("highlight.js/lib/languages/irpf90.js"),
isbl: () => import("highlight.js/lib/languages/isbl.js"),
java: () => import("highlight.js/lib/languages/java.js"),
"jboss-cli": () => import("highlight.js/lib/languages/jboss-cli.js"),
julia: () => import("highlight.js/lib/languages/julia.js"),
"julia-repl": () => import("highlight.js/lib/languages/julia-repl.js"),
kotlin: () => import("highlight.js/lib/languages/kotlin.js"),
lasso: () => import("highlight.js/lib/languages/lasso.js"),
latex: () => import("highlight.js/lib/languages/latex.js"),
ldif: () => import("highlight.js/lib/languages/ldif.js"),
leaf: () => import("highlight.js/lib/languages/leaf.js"),
less: () => import("highlight.js/lib/languages/less.js"),
lisp: () => import("highlight.js/lib/languages/lisp.js"),
livecodeserver: () => import("highlight.js/lib/languages/livecodeserver.js"),
livescript: () => import("highlight.js/lib/languages/livescript.js"),
llvm: () => import("highlight.js/lib/languages/llvm.js"),
lsl: () => import("highlight.js/lib/languages/lsl.js"),
lua: () => import("highlight.js/lib/languages/lua.js"),
makefile: () => import("highlight.js/lib/languages/makefile.js"),
mathematica: () => import("highlight.js/lib/languages/mathematica.js"),
matlab: () => import("highlight.js/lib/languages/matlab.js"),
maxima: () => import("highlight.js/lib/languages/maxima.js"),
mel: () => import("highlight.js/lib/languages/mel.js"),
mercury: () => import("highlight.js/lib/languages/mercury.js"),
mipsasm: () => import("highlight.js/lib/languages/mipsasm.js"),
mizar: () => import("highlight.js/lib/languages/mizar.js"),
perl: () => import("highlight.js/lib/languages/perl.js"),
mojolicious: () => import("highlight.js/lib/languages/mojolicious.js"),
monkey: () => import("highlight.js/lib/languages/monkey.js"),
moonscript: () => import("highlight.js/lib/languages/moonscript.js"),
n1ql: () => import("highlight.js/lib/languages/n1ql.js"),
nginx: () => import("highlight.js/lib/languages/nginx.js"),
nim: () => import("highlight.js/lib/languages/nim.js"),
nix: () => import("highlight.js/lib/languages/nix.js"),
"node-repl": () => import("highlight.js/lib/languages/node-repl.js"),
nsis: () => import("highlight.js/lib/languages/nsis.js"),
objectivec: () => import("highlight.js/lib/languages/objectivec.js"),
ocaml: () => import("highlight.js/lib/languages/ocaml.js"),
openscad: () => import("highlight.js/lib/languages/openscad.js"),
oxygene: () => import("highlight.js/lib/languages/oxygene.js"),
parser3: () => import("highlight.js/lib/languages/parser3.js"),
pf: () => import("highlight.js/lib/languages/pf.js"),
pgsql: () => import("highlight.js/lib/languages/pgsql.js"),
php: () => import("highlight.js/lib/languages/php.js"),
"php-template": () => import("highlight.js/lib/languages/php-template.js"),
plaintext: () => import("highlight.js/lib/languages/plaintext.js"),
pony: () => import("highlight.js/lib/languages/pony.js"),
powershell: () => import("highlight.js/lib/languages/powershell.js"),
processing: () => import("highlight.js/lib/languages/processing.js"),
profile: () => import("highlight.js/lib/languages/profile.js"),
prolog: () => import("highlight.js/lib/languages/prolog.js"),
properties: () => import("highlight.js/lib/languages/properties.js"),
protobuf: () => import("highlight.js/lib/languages/protobuf.js"),
puppet: () => import("highlight.js/lib/languages/puppet.js"),
purebasic: () => import("highlight.js/lib/languages/purebasic.js"),
"python-repl": () => import("highlight.js/lib/languages/python-repl.js"),
q: () => import("highlight.js/lib/languages/q.js"),
qml: () => import("highlight.js/lib/languages/qml.js"),
r: () => import("highlight.js/lib/languages/r.js"),
reasonml: () => import("highlight.js/lib/languages/reasonml.js"),
rib: () => import("highlight.js/lib/languages/rib.js"),
roboconf: () => import("highlight.js/lib/languages/roboconf.js"),
routeros: () => import("highlight.js/lib/languages/routeros.js"),
rsl: () => import("highlight.js/lib/languages/rsl.js"),
ruleslanguage: () => import("highlight.js/lib/languages/ruleslanguage.js"),
rust: () => import("highlight.js/lib/languages/rust.js"),
sas: () => import("highlight.js/lib/languages/sas.js"),
scala: () => import("highlight.js/lib/languages/scala.js"),
scheme: () => import("highlight.js/lib/languages/scheme.js"),
scilab: () => import("highlight.js/lib/languages/scilab.js"),
scss: () => import("highlight.js/lib/languages/scss.js"),
shell: () => import("highlight.js/lib/languages/shell.js"),
smali: () => import("highlight.js/lib/languages/smali.js"),
smalltalk: () => import("highlight.js/lib/languages/smalltalk.js"),
sml: () => import("highlight.js/lib/languages/sml.js"),
sqf: () => import("highlight.js/lib/languages/sqf.js"),
sql_more: () => import("highlight.js/lib/languages/sql_more.js"),
sql: () => import("highlight.js/lib/languages/sql.js"),
stan: () => import("highlight.js/lib/languages/stan.js"),
stata: () => import("highlight.js/lib/languages/stata.js"),
step21: () => import("highlight.js/lib/languages/step21.js"),
stylus: () => import("highlight.js/lib/languages/stylus.js"),
subunit: () => import("highlight.js/lib/languages/subunit.js"),
swift: () => import("highlight.js/lib/languages/swift.js"),
taggerscript: () => import("highlight.js/lib/languages/taggerscript.js"),
tap: () => import("highlight.js/lib/languages/tap.js"),
tcl: () => import("highlight.js/lib/languages/tcl.js"),
thrift: () => import("highlight.js/lib/languages/thrift.js"),
tp: () => import("highlight.js/lib/languages/tp.js"),
twig: () => import("highlight.js/lib/languages/twig.js"),
vala: () => import("highlight.js/lib/languages/vala.js"),
vbnet: () => import("highlight.js/lib/languages/vbnet.js"),
vbscript: () => import("highlight.js/lib/languages/vbscript.js"),
"vbscript-html": () => import("highlight.js/lib/languages/vbscript-html.js"),
verilog: () => import("highlight.js/lib/languages/verilog.js"),
vhdl: () => import("highlight.js/lib/languages/vhdl.js"),
vim: () => import("highlight.js/lib/languages/vim.js"),
x86asm: () => import("highlight.js/lib/languages/x86asm.js"),
xl: () => import("highlight.js/lib/languages/xl.js"),
xquery: () => import("highlight.js/lib/languages/xquery.js"),
zephir: () => import("highlight.js/lib/languages/zephir.js"),
};
const aliasPairs = `
as=actionscript asc=angelscript apacheconf=apache osascript=applescript ino=arduino arm=armasm html=xml
xhtml=xml rss=xml atom=xml xjb=xml xsd=xml xsl=xml plist=xml wsf=xml svg=xml adoc=asciidoc ahk=autohotkey
x++=axapta sh=bash zsh=bash bf=brainfuck c=c-like h=c cc=cpp c++=cpp h++=cpp hpp=cpp hh=cpp hxx=cpp cxx=cpp
capnp=capnproto icl=clean dcl=clean clj=clojure cmake.in=cmake coffee=coffeescript cson=coffeescript
iced=coffeescript cls=cos crm=crmsh pcmk=crmsh cr=crystal cs=csharp c#=csharp md=markdown mkdown=markdown
mkd=markdown dpr=delphi dfm=delphi pas=delphi pascal=delphi freepascal=delphi lazarus=delphi lpr=delphi
lfm=delphi patch=diff jinja=django bind=dns zone=dns docker=dockerfile bat=dos cmd=dos dst=dust rb=ruby
gemspec=ruby podspec=ruby thor=ruby irb=ruby erl=erlang xlsx=excel xls=excel f90=fortran f95=fortran fs=fsharp
gms=gams gss=gauss nc=gcode feature=gherkin golang=go hbs=htmlbars html.hbs=htmlbars html.handlebars=htmlbars
htmlbars=htmlbars hs=haskell hx=haxe https=http hylang=hy i7=inform7 toml=ini jsp=java js=javascript
jsx=javascript mjs=javascript cjs=javascript wildfly-cli=jboss-cli kt=kotlin kts=kotlin ls=livescript
lassoscript=lasso tex=latex mk=makefile mak=makefile make=makefile mma=mathematica wl=mathematica m=mercury
moo=mercury mips=mipsasm pl=perl pm=perl moon=moonscript nginxconf=nginx nixos=nix mm=objectivec
objc=objectivec obj-c=objectivec obj-c++=objectivec objective-c++=objectivec ml=sml scad=openscad pf.conf=pf
postgres=pgsql postgresql=pgsql php3=php php4=php php5=php php6=php php7=php php8=php text=plaintext
txt=plaintext ps=powershell ps1=powershell pp=puppet pb=purebasic pbi=purebasic py=python gyp=python
ipython=python pycon=python-repl k=q kdb=q qt=qml re=reasonml graph=roboconf instances=roboconf
mikrotik=routeros rs=rust sci=scilab console=shell st=smalltalk mysql=sql_more oracle=sql_more stanfuncs=stan
do=stata ado=stata p21=step21 step=step21 stp=step21 styl=stylus yml=yaml tk=tcl craftcms=twig ts=typescript
tsx=typescript vb=vbnet vbs=vbscript v=verilog sv=verilog svh=verilog tao=xl xpath=xquery xq=xquery zep=zephir
`
.trim()
.split(/\s+/);
export const HIGHLIGHT_LANGUAGE_ALIASES = new Map(
aliasPairs.map((pair) => {
const separator = pair.indexOf("=");
return [pair.slice(0, separator), pair.slice(separator + 1)] as const;
}),
);
const dependencyPairs = `
xml=css,javascript html=xml asciidoc=xml clojure-repl=clojure coffeescript=javascript cos=javascript,sql,xml
markdown=xml dart=markdown django=xml dockerfile=bash dust=xml erb=ruby,xml haml=ruby handlebars=xml htmlbars=xml
javascript=css,xml julia-repl=julia livescript=javascript perl=mojolicious mojolicious=perl,xml node-repl=javascript
parser3=xml pgsql=bash,java,json,lua,perl,php,python,r,ruby,scheme,tcl,xml php-template=php,xml python-repl=python
qml=xml shell=bash yaml=ruby tap=yaml toml=ini twig=xml typescript=css,xml vbscript-html=vbscript,xml xquery=xml
`
.trim()
.split(/\s+/);
export const HIGHLIGHT_LANGUAGE_DEPENDENCIES = new Map(
dependencyPairs.map((pair) => {
const separator = pair.indexOf("=");
return [pair.slice(0, separator), pair.slice(separator + 1).split(",")] as const;
}),
);

View file

@ -1,5 +1,10 @@
import hljs, { type LanguageDefinition } from "highlight.js/lib/core.js";
import { HIGHLIGHT_LANGUAGES, type HighlightLanguage } from "./highlight-languages.ts";
import {
HIGHLIGHT_LANGUAGE_ALIASES,
HIGHLIGHT_LANGUAGE_DEFINITIONS,
HIGHLIGHT_LANGUAGE_DEPENDENCIES,
HIGHLIGHT_LANGUAGE_LOADERS,
} from "./highlight-languages.ts";
import { decodeHtmlEntityAt } from "./html.ts";
export type HighlightFormatter = (text: string) => string;
@ -12,64 +17,45 @@ export interface HighlightOptions {
theme?: HighlightTheme;
}
const LANGUAGE_BY_NAME = new Map<string, HighlightLanguage>();
for (const language of HIGHLIGHT_LANGUAGES) {
LANGUAGE_BY_NAME.set(language.name, language);
for (const alias of language.aliases ?? []) LANGUAGE_BY_NAME.set(alias.toLowerCase(), language);
}
const LANGUAGE_BY_EXTENSION = new Map(
HIGHLIGHT_LANGUAGES.flatMap(({ name, extensions = [] }) =>
extensions.map((extension) => [extension, name] as const),
),
);
const registeredLanguages = new Set<string>();
function registerLanguage(language: HighlightLanguage, definition: LanguageDefinition): void {
if (registeredLanguages.has(language.name)) return;
hljs.registerLanguage(language.name, definition);
registeredLanguages.add(language.name);
}
for (const language of HIGHLIGHT_LANGUAGES) {
if (language.definition) registerLanguage(language, language.definition);
interface HighlightLanguage {
name: string;
definition: LanguageDefinition;
}
type HighlightLanguageLoadEvent =
| { language: string; requiresGlobalRefresh: boolean }
| { language: string; languages: HighlightLanguage[] }
| { language: string; error: unknown };
type HighlightLanguageLoadListener = (event: HighlightLanguageLoadEvent) => void;
interface LanguageLoadState {
definition?: LanguageDefinition;
promise: Promise<LanguageDefinition>;
}
interface LanguageRequestState {
promise: Promise<boolean>;
callbacks: Set<() => void>;
requiresGlobalRefresh: boolean;
status: "pending" | "ready" | "failed";
}
interface LanguageRegistryState {
loads: Map<string, LanguageLoadState>;
definitions: Map<string, Promise<LanguageDefinition>>;
requests: Map<string, Promise<HighlightLanguage[]>>;
listeners: Set<HighlightLanguageLoadListener>;
requests?: Map<string, LanguageRequestState>;
}
// jiti can evaluate this module more than once. Sharing imported definitions
// and listeners lets extension-triggered loads refresh the host TUI.
const LANGUAGE_REGISTRY_KEY = Symbol.for("@earendil-works/pi-coding-agent:highlight-language-loaders-v3");
// jiti can evaluate this module more than once. Share imported definitions and
// listeners so extension-triggered loads still refresh the host TUI.
const LANGUAGE_REGISTRY_KEY = Symbol.for("@earendil-works/pi-coding-agent:highlight-language-loaders-v4");
const globalRegistry = globalThis as Record<symbol, LanguageRegistryState | undefined>;
const languageRegistry: LanguageRegistryState = globalRegistry[LANGUAGE_REGISTRY_KEY] ?? {
loads: new Map(),
definitions: new Map(),
requests: new Map(),
listeners: new Set(),
};
globalRegistry[LANGUAGE_REGISTRY_KEY] = languageRegistry;
if (!languageRegistry.requests) languageRegistry.requests = new Map();
const languageRequests = languageRegistry.requests;
const registeredLanguages = new Set<string>();
for (const [name, definition] of Object.entries(HIGHLIGHT_LANGUAGE_DEFINITIONS)) {
hljs.registerLanguage(name, definition);
registeredLanguages.add(name);
}
function getCanonicalLanguageName(name: string): string | undefined {
if (Object.hasOwn(HIGHLIGHT_LANGUAGE_DEFINITIONS, name) || Object.hasOwn(HIGHLIGHT_LANGUAGE_LOADERS, name)) {
return name;
}
return HIGHLIGHT_LANGUAGE_ALIASES.get(name);
}
function notifyLanguageLoad(event: HighlightLanguageLoadEvent): void {
if (languageRegistry.listeners.size > 0) {
@ -80,131 +66,93 @@ function notifyLanguageLoad(event: HighlightLanguageLoadEvent): void {
}
}
function getLanguageClosure(language: HighlightLanguage): HighlightLanguage[] {
const result: HighlightLanguage[] = [];
const visited = new Set<string>();
const visit = (current: HighlightLanguage) => {
if (visited.has(current.name)) return;
visited.add(current.name);
for (const dependencyName of current.dependencies ?? []) {
const dependency = LANGUAGE_BY_NAME.get(dependencyName);
if (dependency) visit(dependency);
}
result.push(current);
};
visit(language);
return result;
}
function loadLanguageDefinition(name: string): Promise<LanguageDefinition> {
const eagerDefinition = HIGHLIGHT_LANGUAGE_DEFINITIONS[name];
if (eagerDefinition) return Promise.resolve(eagerDefinition);
function ensureLanguagesRegistered(languages: HighlightLanguage[], requestedName: string): boolean {
for (const language of languages) {
const definition = language.definition ?? languageRegistry.loads.get(language.name)?.definition;
if (definition) registerLanguage(language, definition);
}
return (
languages.every(
(language) =>
registeredLanguages.has(language.name) ||
(!language.definition && !language.load && supportsLanguage(language.name)),
) && supportsLanguage(requestedName)
);
}
function loadLanguageDefinition(language: HighlightLanguage): Promise<LanguageDefinition> | undefined {
if (language.definition) return Promise.resolve(language.definition);
if (!language.load) return undefined;
let state = languageRegistry.loads.get(language.name);
if (!state) {
const promise = Promise.resolve()
.then(language.load)
let promise = languageRegistry.definitions.get(name);
if (!promise) {
const loader = HIGHLIGHT_LANGUAGE_LOADERS[name];
promise = Promise.resolve()
.then(loader)
.then(
({ default: definition }) => {
const currentState = languageRegistry.loads.get(language.name);
if (currentState) currentState.definition = definition;
return definition;
},
({ default: definition }) => definition,
(error: unknown) => {
notifyLanguageLoad({ language: language.name, error });
notifyLanguageLoad({ language: name, error });
throw error;
},
);
state = { promise };
languageRegistry.loads.set(language.name, state);
languageRegistry.definitions.set(name, promise);
}
return state.promise;
return promise;
}
function getLanguageRequest(language: HighlightLanguage, languages: HighlightLanguage[]): LanguageRequestState {
let request = languageRequests.get(language.name);
if (request) return request;
const callbacks = new Set<() => void>();
const promises: Promise<LanguageDefinition>[] = [];
for (const current of languages) {
const promise = loadLanguageDefinition(current);
if (promise) promises.push(promise);
async function loadLanguageClosure(name: string, visited = new Set<string>()): Promise<HighlightLanguage[]> {
if (visited.has(name)) return [];
visited.add(name);
const definition = await loadLanguageDefinition(name);
const languages: HighlightLanguage[] = [];
for (const dependency of HIGHLIGHT_LANGUAGE_DEPENDENCIES.get(name) ?? []) {
languages.push(...(await loadLanguageClosure(dependency, visited)));
}
languages.push({ name, definition });
return languages;
}
function registerLanguages(languages: HighlightLanguage[], requestedName: string): boolean {
for (const { name, definition } of languages) {
if (registeredLanguages.has(name)) continue;
hljs.registerLanguage(name, definition);
registeredLanguages.add(name);
}
return registeredLanguages.has(requestedName);
}
function getLanguageRequest(name: string): Promise<HighlightLanguage[]> {
let request = languageRegistry.requests.get(name);
if (!request) {
request = loadLanguageClosure(name);
languageRegistry.requests.set(name, request);
void request.then(
(languages) => notifyLanguageLoad({ language: name, languages }),
() => {},
);
}
const promise = Promise.all(promises).then(() => ensureLanguagesRegistered(languages, language.name));
request = { promise, callbacks, requiresGlobalRefresh: false, status: "pending" };
languageRequests.set(language.name, request);
void promise.then(
(ready) => {
request.status = ready ? "ready" : "failed";
if (ready) {
for (const callback of callbacks) callback();
notifyLanguageLoad({ language: language.name, requiresGlobalRefresh: request.requiresGlobalRefresh });
}
callbacks.clear();
},
() => {
request.status = "failed";
callbacks.clear();
},
);
return request;
}
export function onHighlightLanguageLoad(listener: HighlightLanguageLoadListener): () => void {
languageRegistry.listeners.add(listener);
return () => languageRegistry.listeners.delete(listener);
const localListener: HighlightLanguageLoadListener = (event) => {
if ("languages" in event) registerLanguages(event.languages, event.language);
listener(event);
};
languageRegistry.listeners.add(localListener);
return () => languageRegistry.listeners.delete(localListener);
}
export async function loadHighlightLanguage(name: string): Promise<boolean> {
const normalizedName = name.toLowerCase();
const language = LANGUAGE_BY_NAME.get(normalizedName);
if (!language) return supportsLanguage(normalizedName);
const languages = getLanguageClosure(language);
if (ensureLanguagesRegistered(languages, language.name)) return true;
const request = getLanguageRequest(language, languages);
request.requiresGlobalRefresh = true;
return request.promise;
const canonicalName = getCanonicalLanguageName(normalizedName);
if (!canonicalName) return supportsLanguage(normalizedName);
if (registeredLanguages.has(canonicalName)) return true;
return registerLanguages(await getLanguageRequest(canonicalName), canonicalName);
}
/** Return the canonical grammar when ready, starting a background load when needed. */
export function requestHighlightLanguage(name: string, onReady?: () => void): string | undefined {
export function requestHighlightLanguage(name: string): string | undefined {
const normalizedName = name.toLowerCase();
const language = LANGUAGE_BY_NAME.get(normalizedName);
if (!language) return supportsLanguage(normalizedName) ? normalizedName : undefined;
const languages = getLanguageClosure(language);
if (ensureLanguagesRegistered(languages, language.name)) return language.name;
if (!languages.some((current) => current.load)) return undefined;
const request = getLanguageRequest(language, languages);
if (request.status === "pending") {
if (onReady) request.callbacks.add(onReady);
else request.requiresGlobalRefresh = true;
const canonicalName = getCanonicalLanguageName(normalizedName);
if (!canonicalName) return supportsLanguage(normalizedName) ? normalizedName : undefined;
if (registeredLanguages.has(canonicalName)) {
return normalizedName === "html" || normalizedName === "toml" ? normalizedName : canonicalName;
}
void getLanguageRequest(canonicalName).then(
(languages) => registerLanguages(languages, canonicalName),
() => {},
);
return undefined;
}
export function getLanguageFromPath(filePath: string): string | undefined {
const extension = filePath.split(".").pop()?.toLowerCase();
return extension ? LANGUAGE_BY_EXTENSION.get(extension) : undefined;
}
const SPAN_CLOSE = "</span>";
const HIGHLIGHT_CLASS_PREFIX = "hljs-";

View file

@ -1,11 +1,10 @@
import { resetCapabilitiesCache, setCapabilities, type TUI } from "@earendil-works/pi-tui";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SettingsManager } from "../src/core/settings-manager.ts";
import { highlightCode, initTheme } from "../src/modes/interactive/theme/theme.ts";
import { getLanguageFromPath, highlightCode, initTheme } from "../src/modes/interactive/theme/theme.ts";
import { InteractiveThemeController } from "../src/modes/interactive/theme/theme-controller.ts";
import { HIGHLIGHT_LANGUAGES } from "../src/utils/highlight-languages.ts";
import { HIGHLIGHT_LANGUAGE_LOADERS } from "../src/utils/highlight-languages.ts";
import {
getLanguageFromPath,
highlight,
loadHighlightLanguage,
onHighlightLanguageLoad,
@ -60,72 +59,31 @@ describe("syntax highlight renderer", () => {
});
it("maps file extensions and preloads only common languages", () => {
expect(getLanguageFromPath("src/main.ts")).toBe("typescript");
expect(getLanguageFromPath("public/index.html")).toBe("html");
expect(getLanguageFromPath("Dockerfile")).toBe("dockerfile");
expect(getLanguageFromPath("build.ps1")).toBe("powershell");
expect(getLanguageFromPath("config.toml")).toBe("toml");
expect(getLanguageFromPath("shell.fish")).toBe("fish");
expect(getLanguageFromPath("style.sass")).toBe("sass");
expect(getLanguageFromPath("schema.graphql")).toBe("graphql");
expect(getLanguageFromPath("main.tf")).toBe("hcl");
expect(getLanguageFromPath("config.hcl")).toBe("hcl");
expect(supportsLanguage(getLanguageFromPath("src/main.ts")!)).toBe(true);
expect(supportsLanguage(getLanguageFromPath("public/index.html")!)).toBe(true);
expect(supportsLanguage(getLanguageFromPath("Dockerfile")!)).toBe(false);
expect(supportsLanguage(getLanguageFromPath("build.ps1")!)).toBe(false);
const handlebars = HIGHLIGHT_LANGUAGES.find(({ name }) => name === "handlebars");
expect(handlebars?.definition).toBeUndefined();
expect(handlebars?.load).toBeTypeOf("function");
expect(["main.ts", "index.html", "Dockerfile", "build.ps1", "main.tf"].map(getLanguageFromPath)).toEqual([
"typescript",
"html",
"dockerfile",
"powershell",
"hcl",
]);
for (const common of ["typescript", "html"]) expect(supportsLanguage(common)).toBe(true);
for (const lazy of ["dockerfile", "powershell"]) expect(supportsLanguage(lazy)).toBe(false);
for (const unsupported of ["fish", "graphql", "hcl", "sass"]) {
expect(requestHighlightLanguage(unsupported)).toBeUndefined();
}
});
it("normalizes names and loads aliases through their canonical grammar", async () => {
expect(requestHighlightLanguage("ToMl")).toBeUndefined();
expect(await loadHighlightLanguage("TOML")).toBe(true);
expect(supportsLanguage("ini")).toBe(true);
expect(supportsLanguage("toml")).toBe(true);
expect(requestHighlightLanguage("ToMl")).toBe("toml");
it("loads one uncommon grammar with its aliases and dependencies", async () => {
const loaded: string[] = [];
const unsubscribe = onHighlightLanguageLoad((event) => {
if (!("error" in event)) loaded.push(event.language);
});
expect(requestHighlightLanguage("Rust")).toBeUndefined();
expect(await loadHighlightLanguage("RUST")).toBe(true);
expect(supportsLanguage("rust")).toBe(true);
expect(requestHighlightLanguage("Rust")).toBe("rust");
expect(requestHighlightLanguage("BF")).toBeUndefined();
expect(await loadHighlightLanguage("BF")).toBe(true);
expect(requestHighlightLanguage("bf")).toBe("brainfuck");
expect(supportsLanguage("abnf")).toBe(false);
expect(await loadHighlightLanguage("PowerShell")).toBe(true);
expect(supportsLanguage("POWERSHELL")).toBe(true);
});
it("keeps duplicate aliases bound to their canonical grammar", async () => {
expect(await loadHighlightLanguage("ls")).toBe(true);
expect(requestHighlightLanguage("ls")).toBe("livescript");
expect(await loadHighlightLanguage("lasso")).toBe(true);
expect(requestHighlightLanguage("ls")).toBe("livescript");
expect(await loadHighlightLanguage("ml")).toBe(true);
expect(requestHighlightLanguage("ml")).toBe("sml");
expect(await loadHighlightLanguage("ocaml")).toBe(true);
expect(requestHighlightLanguage("ml")).toBe("sml");
expect(await loadHighlightLanguage("hbs")).toBe(true);
expect(requestHighlightLanguage("hbs")).toBe("htmlbars");
expect(await loadHighlightLanguage("handlebars")).toBe(true);
expect(requestHighlightLanguage("hbs")).toBe("htmlbars");
});
it("loads required sublanguage grammars", async () => {
expect(supportsLanguage("ruby")).toBe(true);
expect(await loadHighlightLanguage("ERB")).toBe(true);
expect(
highlight("<%= User.find(1) %>", {
language: "erb",
theme: { number: (text) => `[number:${text}]` },
}),
).toContain("[number:1]");
expect(supportsLanguage("php")).toBe(false);
expect(await loadHighlightLanguage("php-template")).toBe(true);
expect(supportsLanguage("php")).toBe(true);
expect(
@ -134,37 +92,24 @@ describe("syntax highlight renderer", () => {
theme: { keyword: (text) => `[keyword:${text}]` },
}),
).toContain("[keyword:echo]");
expect(loaded).toEqual(["brainfuck", "php-template"]);
unsubscribe();
expect(await loadHighlightLanguage("TOML")).toBe(true);
expect(requestHighlightLanguage("ToMl")).toBe("toml");
expect(await loadHighlightLanguage("mojolicious")).toBe(true);
expect(supportsLanguage("perl")).toBe(true);
});
it("loads only the requested uncommon language", async () => {
expect(supportsLanguage("brainfuck")).toBe(false);
expect(supportsLanguage("abnf")).toBe(false);
let notifications = 0;
const unsubscribe = onHighlightLanguageLoad(() => notifications++);
expect(requestHighlightLanguage("bf")).toBeUndefined();
expect(await loadHighlightLanguage("bf")).toBe(true);
expect(supportsLanguage("brainfuck")).toBe(true);
expect(supportsLanguage("bf")).toBe(true);
expect(supportsLanguage("abnf")).toBe(false);
expect(notifications).toBe(1);
expect(await loadHighlightLanguage("abnf")).toBe(true);
expect(supportsLanguage("abnf")).toBe(true);
expect(notifications).toBe(2);
unsubscribe();
expect(await loadHighlightLanguage("ls")).toBe(true);
expect(await loadHighlightLanguage("lasso")).toBe(true);
expect(requestHighlightLanguage("ls")).toBe("livescript");
});
it("reports background load failures once without retrying", async () => {
const language = HIGHLIGHT_LANGUAGES.find(({ name }) => name === "accesslog");
if (!language?.load) throw new Error("accesslog test grammar is missing");
const originalLoad = language.load;
const originalLoad = HIGHLIGHT_LANGUAGE_LOADERS.accesslog;
if (!originalLoad) throw new Error("accesslog test grammar is missing");
const loadError = new Error("test grammar load failed");
let attempts = 0;
language.load = () => {
HIGHLIGHT_LANGUAGE_LOADERS.accesslog = () => {
attempts++;
return Promise.reject(loadError);
};
@ -185,14 +130,14 @@ describe("syntax highlight renderer", () => {
expect(attempts).toBe(1);
await expect(loadHighlightLanguage("accesslog")).rejects.toThrow("test grammar load failed");
} finally {
language.load = originalLoad;
HIGHLIGHT_LANGUAGE_LOADERS.accesslog = originalLoad;
unsubscribe();
}
});
});
describe("highlight language load lifecycle", () => {
it("invalidates only affected targets while retaining a global fallback", async () => {
it("refreshes the TUI when a grammar loads", async () => {
const invalidate = vi.fn();
const requestRender = vi.fn();
const unsubscribeTerminalTheme = vi.fn();
@ -203,62 +148,19 @@ describe("highlight language load lifecycle", () => {
} as unknown as TUI;
const settingsManager = { getThemeSetting: () => "dark" } as unknown as SettingsManager;
const controller = new InteractiveThemeController(ui, settingsManager, vi.fn(), vi.fn());
const arcade = HIGHLIGHT_LANGUAGES.find(({ name }) => name === "arcade");
const oneC = HIGHLIGHT_LANGUAGES.find(({ name }) => name === "1c");
const eager = HIGHLIGHT_LANGUAGES.find(({ name }) => name === "bash");
if (!arcade?.load || !oneC?.load || !eager?.definition) throw new Error("highlight test grammars are missing");
const originalArcadeLoad = arcade.load;
const originalOneCLoad = oneC.load;
arcade.load = () => Promise.resolve({ default: eager.definition! });
oneC.load = () => Promise.resolve({ default: eager.definition! });
try {
const loaded = new Set<string>();
let resolveLoads = (): void => {};
const loadsFinished = new Promise<void>((resolve) => {
resolveLoads = resolve;
});
const unsubscribeLoads = onHighlightLanguageLoad((event) => {
if ("error" in event) return;
loaded.add(event.language);
if (loaded.has("arcade") && loaded.has("1c")) resolveLoads();
});
const invalidateArcade = vi.fn();
const invalidateOneC = vi.fn();
highlightCode("let value = 1", "arcade");
await loadHighlightLanguage("arcade");
expect(invalidate).toHaveBeenCalledOnce();
expect(requestRender).toHaveBeenCalledOnce();
highlightCode("let value = 1", "arcade", { onLanguageReady: invalidateArcade });
highlightCode("let value = 1", "1c", { onLanguageReady: invalidateOneC });
await loadsFinished;
unsubscribeLoads();
expect(invalidateArcade).toHaveBeenCalledOnce();
expect(invalidateOneC).toHaveBeenCalledOnce();
expect(invalidate).not.toHaveBeenCalled();
expect(requestRender).not.toHaveBeenCalled();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(invalidate).not.toHaveBeenCalled();
expect(requestRender).toHaveBeenCalledOnce();
requestRender.mockClear();
highlightCode("package main", "ada");
await loadHighlightLanguage("ada");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(invalidate).toHaveBeenCalledOnce();
expect(requestRender).toHaveBeenCalledOnce();
invalidate.mockClear();
requestRender.mockClear();
controller.dispose();
await loadHighlightLanguage("apache");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(unsubscribeTerminalTheme).toHaveBeenCalledOnce();
expect(invalidate).not.toHaveBeenCalled();
expect(requestRender).not.toHaveBeenCalled();
} finally {
arcade.load = originalArcadeLoad;
oneC.load = originalOneCLoad;
controller.dispose();
}
invalidate.mockClear();
requestRender.mockClear();
controller.dispose();
await loadHighlightLanguage("apache");
expect(unsubscribeTerminalTheme).toHaveBeenCalledOnce();
expect(invalidate).not.toHaveBeenCalled();
expect(requestRender).not.toHaveBeenCalled();
});
});

View file

@ -212,7 +212,7 @@ export interface MarkdownTheme {
italic: (text: string) => string;
strikethrough: (text: string) => string;
underline: (text: string) => string;
highlightCode?: (code: string, lang?: string, onLanguageReady?: () => void) => string[];
highlightCode?: (code: string, lang?: string) => string[];
/** Prefix applied to each rendered code block line (default: " ") */
codeBlockIndent?: string;
}
@ -241,7 +241,6 @@ export class Markdown implements Component {
private theme: MarkdownTheme;
private options: MarkdownOptions;
private defaultStylePrefix?: string;
private readonly handleHighlightLanguageReady = (): void => this.invalidate();
// Cache for rendered output
private cachedText?: string;
@ -522,11 +521,7 @@ export class Markdown implements Component {
const indent = this.theme.codeBlockIndent ?? " ";
lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
if (this.theme.highlightCode) {
const highlightedLines = this.theme.highlightCode(
token.text,
token.lang,
this.handleHighlightLanguageReady,
);
const highlightedLines = this.theme.highlightCode(token.text, token.lang);
for (const hlLine of highlightedLines) {
lines.push(`${indent}${hlLine}`);
}

View file

@ -38,28 +38,6 @@ function stripAnsi(line: string): string {
describe("Markdown component", () => {
describe("Transforms", () => {
it("invalidates its cache when a code language finishes loading", () => {
const callbacks: Array<() => void> = [];
let highlightCalls = 0;
const markdown = new Markdown("```example\ncode\n```", 0, 0, {
...defaultMarkdownTheme,
highlightCode: (_code, _lang, onLanguageReady) => {
highlightCalls++;
if (onLanguageReady) callbacks.push(onLanguageReady);
return ["code"];
},
});
markdown.render(80);
markdown.render(80);
assert.strictEqual(highlightCalls, 1);
callbacks[0]?.();
markdown.render(80);
assert.strictEqual(highlightCalls, 2);
assert.strictEqual(callbacks[0], callbacks[1]);
});
it("caches transformed Markdown by source and available width", () => {
const calls: Array<{ source: string; availableWidth: number }> = [];
const markdown = new Markdown("source", 2, 0, defaultMarkdownTheme, undefined, {