mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(coding-agent): lazily load uncommon syntax grammars
This commit is contained in:
parent
541ed488d8
commit
2149e32489
11 changed files with 1063 additions and 119 deletions
|
|
@ -2877,11 +2877,17 @@ For syntax highlighting in custom tool renderers:
|
|||
import { highlightCode, getLanguageFromPath } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
// Highlight code with explicit language
|
||||
const highlighted = highlightCode("const x = 1;", "typescript", theme);
|
||||
const typescriptLines = highlightCode("const x = 1;", "typescript");
|
||||
|
||||
// Auto-detect language from file path
|
||||
// Detect language from a file path
|
||||
const lang = getLanguageFromPath("/path/to/file.rs"); // "rust"
|
||||
const highlighted = highlightCode(code, lang, theme);
|
||||
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
|
||||
|
|
|
|||
|
|
@ -393,6 +393,7 @@ export {
|
|||
getMarkdownTheme,
|
||||
getSelectListTheme,
|
||||
getSettingsListTheme,
|
||||
type HighlightCodeOptions,
|
||||
highlightCode,
|
||||
initTheme,
|
||||
Theme,
|
||||
|
|
|
|||
|
|
@ -6379,7 +6379,7 @@ export class InteractiveMode {
|
|||
this.ui.terminal.setProgress(false);
|
||||
}
|
||||
this.clearStatusIndicator();
|
||||
this.themeController.disableAutoSync();
|
||||
this.themeController.dispose();
|
||||
this.clearExtensionTerminalInputListeners();
|
||||
this.footer.dispose();
|
||||
this.footerDataProvider.dispose();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { TUI } from "@earendil-works/pi-tui";
|
||||
import type { SettingsManager } from "../../../core/settings-manager.ts";
|
||||
import { onHighlightLanguageLoad } from "../../../utils/syntax-highlight.ts";
|
||||
import {
|
||||
detectTerminalBackgroundFromEnv,
|
||||
detectTerminalBackgroundTheme,
|
||||
|
|
@ -24,6 +25,9 @@ export class InteractiveThemeController {
|
|||
private activeThemeName: string | undefined;
|
||||
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;
|
||||
|
|
@ -32,6 +36,21 @@ export class InteractiveThemeController {
|
|||
this.onChanged = onChanged;
|
||||
this.activeThemeName = resolveThemeSetting(this.settingsManager.getThemeSetting(), this.terminalTheme);
|
||||
initTheme(this.activeThemeName, true);
|
||||
this.highlightLanguageLoadUnsubscribe = onHighlightLanguageLoad((event) => {
|
||||
if ("error" in event) {
|
||||
const message = event.error instanceof Error ? event.error.message : String(event.error);
|
||||
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.bindTerminalColorSchemeListener();
|
||||
}
|
||||
|
||||
|
|
@ -92,6 +111,17 @@ export class InteractiveThemeController {
|
|||
this.setAutoSync(false);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disableAutoSync();
|
||||
this.terminalColorSchemeUnsubscribe?.();
|
||||
this.terminalColorSchemeUnsubscribe = undefined;
|
||||
this.highlightLanguageLoadUnsubscribe?.();
|
||||
this.highlightLanguageLoadUnsubscribe = undefined;
|
||||
if (this.highlightLanguageRefreshTimer !== undefined) clearTimeout(this.highlightLanguageRefreshTimer);
|
||||
this.highlightLanguageRefreshTimer = undefined;
|
||||
this.highlightLanguageGlobalRefreshPending = false;
|
||||
}
|
||||
|
||||
getTerminalTheme(): TerminalTheme {
|
||||
return this.terminalTheme;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ import { Compile } from "typebox/compile";
|
|||
import { getCustomThemesDir, getThemesDir } from "../../../config.ts";
|
||||
import type { SourceInfo } from "../../../core/source-info.ts";
|
||||
import { closeWatcher, watchWithErrorHandler } from "../../../utils/fs-watch.ts";
|
||||
import { highlight, supportsLanguage } from "../../../utils/syntax-highlight.ts";
|
||||
import { highlight, requestHighlightLanguage } from "../../../utils/syntax-highlight.ts";
|
||||
|
||||
export { getLanguageFromPath } from "../../../utils/syntax-highlight.ts";
|
||||
|
||||
// ============================================================================
|
||||
// Types & Schema
|
||||
|
|
@ -1153,14 +1155,19 @@ 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): string[] {
|
||||
// Validate language before highlighting to avoid stderr spam from cli-highlight
|
||||
const validLang = lang && supportsLanguage(lang) ? lang : undefined;
|
||||
// Skip highlighting when no valid language is specified. cli-highlight's
|
||||
export function highlightCode(code: string, lang?: string, options: HighlightCodeOptions = {}): 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;
|
||||
// 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.
|
||||
if (!validLang) {
|
||||
|
|
@ -1174,81 +1181,10 @@ export function highlightCode(code: string, lang?: string): string[] {
|
|||
try {
|
||||
return highlight(code, opts).split("\n");
|
||||
} catch {
|
||||
return code.split("\n");
|
||||
return code.split("\n").map((line) => theme.fg("mdCodeBlock", line));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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),
|
||||
|
|
@ -1265,26 +1201,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: string, lang?: string): string[] => {
|
||||
// Validate language before highlighting to avoid stderr spam from cli-highlight
|
||||
const validLang = lang && supportsLanguage(lang) ? lang : undefined;
|
||||
// Skip highlighting when no valid language is specified. cli-highlight's
|
||||
// auto-detection is unreliable and can misidentify prose as AppleScript,
|
||||
// LiveCodeServer, etc., coloring random English words as keywords.
|
||||
if (!validLang) {
|
||||
return code.split("\n").map((line) => theme.fg("mdCodeBlock", line));
|
||||
}
|
||||
const opts = {
|
||||
language: validLang,
|
||||
ignoreIllegals: true,
|
||||
theme: getCliHighlightTheme(theme),
|
||||
};
|
||||
try {
|
||||
return highlight(code, opts).split("\n");
|
||||
} catch {
|
||||
return code.split("\n").map((line) => theme.fg("mdCodeBlock", line));
|
||||
}
|
||||
},
|
||||
highlightCode: (code, lang, onLanguageReady) => highlightCode(code, lang, { onLanguageReady }),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
declare module "highlight.js/lib/index.js" {
|
||||
interface HighlightResult {
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface HighlightOptions {
|
||||
language: string;
|
||||
ignoreIllegals?: boolean;
|
||||
}
|
||||
declare module "highlight.js/lib/core.js" {
|
||||
export type LanguageDefinition = (hljs: HighlightJs) => unknown;
|
||||
|
||||
interface HighlightJs {
|
||||
highlight(code: string, options: HighlightOptions): HighlightResult;
|
||||
highlightAuto(code: string, languageSubset?: string[]): HighlightResult;
|
||||
highlight(code: string, options: { language: string; ignoreIllegals?: boolean }): { value: string };
|
||||
highlightAuto(code: string, languageSubset?: string[]): { value: string };
|
||||
getLanguage(name: string): unknown;
|
||||
registerLanguage(name: string, definition: LanguageDefinition): void;
|
||||
}
|
||||
|
||||
const hljs: HighlightJs;
|
||||
export default hljs;
|
||||
}
|
||||
|
||||
declare module "highlight.js/lib/languages/*.js" {
|
||||
import type { LanguageDefinition } from "highlight.js/lib/core.js";
|
||||
const definition: LanguageDefinition;
|
||||
export default definition;
|
||||
}
|
||||
|
|
|
|||
556
packages/coding-agent/src/utils/highlight-languages.ts
Normal file
556
packages/coding-agent/src/utils/highlight-languages.ts
Normal file
|
|
@ -0,0 +1,556 @@
|
|||
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 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";
|
||||
import python from "highlight.js/lib/languages/python.js";
|
||||
import ruby from "highlight.js/lib/languages/ruby.js";
|
||||
import typescript from "highlight.js/lib/languages/typescript.js";
|
||||
import xml from "highlight.js/lib/languages/xml.js";
|
||||
import yaml from "highlight.js/lib/languages/yaml.js";
|
||||
|
||||
type LanguageModule = { default: LanguageDefinition };
|
||||
|
||||
export interface HighlightLanguage {
|
||||
name: string;
|
||||
extensions?: readonly string[];
|
||||
aliases?: readonly string[];
|
||||
dependencies?: readonly string[];
|
||||
definition?: LanguageDefinition;
|
||||
load?: () => Promise<LanguageModule>;
|
||||
}
|
||||
|
||||
// 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") },
|
||||
];
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import hljs from "highlight.js/lib/index.js";
|
||||
import hljs, { type LanguageDefinition } from "highlight.js/lib/core.js";
|
||||
import { HIGHLIGHT_LANGUAGES, type HighlightLanguage } from "./highlight-languages.ts";
|
||||
import { decodeHtmlEntityAt } from "./html.ts";
|
||||
|
||||
export type HighlightFormatter = (text: string) => string;
|
||||
|
|
@ -11,6 +12,199 @@ 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);
|
||||
}
|
||||
|
||||
type HighlightLanguageLoadEvent =
|
||||
| { language: string; requiresGlobalRefresh: boolean }
|
||||
| { 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>;
|
||||
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");
|
||||
const globalRegistry = globalThis as Record<symbol, LanguageRegistryState | undefined>;
|
||||
const languageRegistry: LanguageRegistryState = globalRegistry[LANGUAGE_REGISTRY_KEY] ?? {
|
||||
loads: new Map(),
|
||||
listeners: new Set(),
|
||||
};
|
||||
globalRegistry[LANGUAGE_REGISTRY_KEY] = languageRegistry;
|
||||
if (!languageRegistry.requests) languageRegistry.requests = new Map();
|
||||
const languageRequests = languageRegistry.requests;
|
||||
|
||||
function notifyLanguageLoad(event: HighlightLanguageLoadEvent): void {
|
||||
if (languageRegistry.listeners.size > 0) {
|
||||
for (const listener of languageRegistry.listeners) listener(event);
|
||||
} else if ("error" in event) {
|
||||
const message = event.error instanceof Error ? event.error.message : String(event.error);
|
||||
console.error(`Failed to load syntax highlighting language "${event.language}": ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
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 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)
|
||||
.then(
|
||||
({ default: definition }) => {
|
||||
const currentState = languageRegistry.loads.get(language.name);
|
||||
if (currentState) currentState.definition = definition;
|
||||
return definition;
|
||||
},
|
||||
(error: unknown) => {
|
||||
notifyLanguageLoad({ language: language.name, error });
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
state = { promise };
|
||||
languageRegistry.loads.set(language.name, state);
|
||||
}
|
||||
return state.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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Return the canonical grammar when ready, starting a background load when needed. */
|
||||
export function requestHighlightLanguage(name: string, onReady?: () => void): 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;
|
||||
}
|
||||
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-";
|
||||
|
||||
|
|
@ -142,5 +336,5 @@ export function highlight(code: string, options: HighlightOptions = {}): string
|
|||
}
|
||||
|
||||
export function supportsLanguage(name: string): boolean {
|
||||
return hljs.getLanguage(name) !== undefined;
|
||||
return hljs.getLanguage(name.toLowerCase()) !== undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,18 @@
|
|||
import { resetCapabilitiesCache, setCapabilities } from "@earendil-works/pi-tui";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
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 { highlight, renderHighlightedHtml, supportsLanguage } from "../src/utils/syntax-highlight.ts";
|
||||
import { InteractiveThemeController } from "../src/modes/interactive/theme/theme-controller.ts";
|
||||
import { HIGHLIGHT_LANGUAGES } from "../src/utils/highlight-languages.ts";
|
||||
import {
|
||||
getLanguageFromPath,
|
||||
highlight,
|
||||
loadHighlightLanguage,
|
||||
onHighlightLanguageLoad,
|
||||
renderHighlightedHtml,
|
||||
requestHighlightLanguage,
|
||||
supportsLanguage,
|
||||
} from "../src/utils/syntax-highlight.ts";
|
||||
|
||||
describe("syntax highlight renderer", () => {
|
||||
it("renders highlighted spans with the provided theme", () => {
|
||||
|
|
@ -47,6 +58,208 @@ describe("syntax highlight renderer", () => {
|
|||
expect(rendered).toContain("[keyword:const]");
|
||||
expect(rendered).toContain("[number:1]");
|
||||
});
|
||||
|
||||
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");
|
||||
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");
|
||||
|
||||
expect(requestHighlightLanguage("Rust")).toBeUndefined();
|
||||
expect(await loadHighlightLanguage("RUST")).toBe(true);
|
||||
expect(supportsLanguage("rust")).toBe(true);
|
||||
expect(requestHighlightLanguage("Rust")).toBe("rust");
|
||||
|
||||
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(
|
||||
highlight("<?php echo $value; ?>", {
|
||||
language: "php-template",
|
||||
theme: { keyword: (text) => `[keyword:${text}]` },
|
||||
}),
|
||||
).toContain("[keyword:echo]");
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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 loadError = new Error("test grammar load failed");
|
||||
let attempts = 0;
|
||||
language.load = () => {
|
||||
attempts++;
|
||||
return Promise.reject(loadError);
|
||||
};
|
||||
let resolveFailure: (failure: { language: string; error: unknown }) => void = () => {};
|
||||
const failure = new Promise<{ language: string; error: unknown }>((resolve) => {
|
||||
resolveFailure = resolve;
|
||||
});
|
||||
const unsubscribe = onHighlightLanguageLoad((event) => {
|
||||
if ("error" in event) resolveFailure({ language: event.language, error: event.error });
|
||||
});
|
||||
|
||||
try {
|
||||
expect(requestHighlightLanguage("AccessLog")).toBeUndefined();
|
||||
await expect(failure).resolves.toEqual({ language: "accesslog", error: loadError });
|
||||
expect(requestHighlightLanguage("accesslog")).toBeUndefined();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(attempts).toBe(1);
|
||||
await expect(loadHighlightLanguage("accesslog")).rejects.toThrow("test grammar load failed");
|
||||
} finally {
|
||||
language.load = originalLoad;
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("highlight language load lifecycle", () => {
|
||||
it("invalidates only affected targets while retaining a global fallback", async () => {
|
||||
const invalidate = vi.fn();
|
||||
const requestRender = vi.fn();
|
||||
const unsubscribeTerminalTheme = vi.fn();
|
||||
const ui = {
|
||||
invalidate,
|
||||
requestRender,
|
||||
onTerminalColorSchemeChange: () => unsubscribeTerminalTheme,
|
||||
} 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", { 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("theme syntax highlighting", () => {
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ export interface MarkdownTheme {
|
|||
italic: (text: string) => string;
|
||||
strikethrough: (text: string) => string;
|
||||
underline: (text: string) => string;
|
||||
highlightCode?: (code: string, lang?: string) => string[];
|
||||
highlightCode?: (code: string, lang?: string, onLanguageReady?: () => void) => string[];
|
||||
/** Prefix applied to each rendered code block line (default: " ") */
|
||||
codeBlockIndent?: string;
|
||||
}
|
||||
|
|
@ -241,6 +241,7 @@ 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;
|
||||
|
|
@ -521,7 +522,11 @@ 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);
|
||||
const highlightedLines = this.theme.highlightCode(
|
||||
token.text,
|
||||
token.lang,
|
||||
this.handleHighlightLanguageReady,
|
||||
);
|
||||
for (const hlLine of highlightedLines) {
|
||||
lines.push(`${indent}${hlLine}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,28 @@ 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, {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue