@dogsbay/highlight 0.0.0-stage → 0.2.0-beta.117

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,48 @@
1
- # Temporary Holding Version
1
+ # @dogsbay/highlight
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Syntax highlighting for `@dogsbay/render-html`. It implements the package's
4
+ `Highlighter` interface with Shiki's core build and JavaScript regex engine.
5
+
6
+ - **No WebAssembly**, so it bundles into a Cloudflare Worker.
7
+ - **Grammars load per language**, one `import()` each, so a split bundle reads only the grammars a site uses.
8
+ - **Both themes from one tokenization** (`one-pass.ts`).
9
+ - **A bounded cache** for identical blocks.
10
+
11
+ ```ts
12
+ import { createHighlighter } from "@dogsbay/highlight";
13
+ import { highlightInputs } from "@dogsbay/render-html";
14
+ const highlighter = await createHighlighter({ cacheChars: 8_000_000 });
15
+ // before each page renders (rendering is synchronous):
16
+ await highlighter.load(highlightInputs(tree));
17
+ // pass it as RenderContext.highlighter
18
+ ```
19
+
20
+ 38 grammars, about 2.1 MB minified in all, each loaded when `load` (or the
21
+ `langs` option; `langs: "all"` loads every one) first names one of its
22
+ spellings: the grammar's name, its aliases (`sh`, `zsh`), or ours (`py`, `yml`,
23
+ `dita`). `src/grammars.ts` maps every spelling to its loader; it is generated by
24
+ `node scripts/grammars.mjs` and a test fails when it is stale. A block whose
25
+ grammar was not loaded renders as plain text and is counted in
26
+ `cacheStats().unloaded`, so a caller can warn. Unknown languages render as plain
27
+ text, the same way `@dogsbay/render-html` renders all code when no highlighter
28
+ is passed.
29
+
30
+ `load` takes blocks (tag and code), not only tags, because markdown embeds other
31
+ grammars lazily: its fences and front matter colour from whatever grammars are
32
+ registered when the block is tokenized, and its module imports none of them.
33
+ `src/grammars.ts` also lists, for each such grammar (`LAZY_EMBEDS`: markdown and
34
+ haml), the trigger patterns of each embed, taken from the grammar with their
35
+ anchors removed so they match anywhere the rule could start, and which grammars
36
+ reach one of them (`LAZY_VIA`: ruby reaches markdown through haml, whose
37
+ `:markdown` filter a HAML heredoc can hold). `load` scans a block's lines with
38
+ them (skipping lines Shiki does not tokenize, 1,000 characters or more) and
39
+ loads what they reach; a block renders
40
+ only once all of that is loaded (else plain, and counted), so its colours never
41
+ depend on what other pages happened to load. A tag passed without code loads
42
+ everything that grammar can embed.
43
+
44
+ `tests/eager-golden.json` is the output of the highlighter as it was, with every
45
+ grammar imported statically (`scripts/eager-golden.mjs`); `tests/lazy.test.ts`
46
+ holds every spelling and every grammar, each loaded alone, to it, and markdown
47
+ blocks with fences, front matter and raw HTML, loaded for their own content, to
48
+ the output with every grammar loaded.
@@ -0,0 +1,24 @@
1
+ import type { LanguageRegistration } from "shiki/core";
2
+ type Loader = () => Promise<{
3
+ default: LanguageRegistration[];
4
+ }>;
5
+ /** One literal import() per grammar, so a bundler splits each into its own chunk. */
6
+ export declare const LOADERS: Record<string, Loader>;
7
+ /** Every spelling the grammars declare → the grammar in LOADERS that brings it. */
8
+ export declare const SPELLINGS: Record<string, string>;
9
+ /**
10
+ * Grammars that embed others lazily (Shiki's embeddedLangsLazy: they resolve
11
+ * an embedded scope from whatever is registered when a block is tokenized, and
12
+ * import nothing), and for each embed among ours, the patterns of which one
13
+ * must match a line of a block for the block to reach it ("" for always).
14
+ * The patterns are the grammar's own, with the anchors ^, \A and \G removed,
15
+ * so they match anywhere a nested rule could start them, and never less.
16
+ */
17
+ export declare const LAZY_EMBEDS: Record<string, Record<string, string[]>>;
18
+ /**
19
+ * Every grammar that can reach a grammar in LAZY_EMBEDS (itself, or through
20
+ * the grammars it includes: ruby includes haml, which embeds markdown lazily),
21
+ * and those it reaches. A block in it needs whatever their triggers find.
22
+ */
23
+ export declare const LAZY_VIA: Record<string, string[]>;
24
+ export {};
@@ -0,0 +1,173 @@
1
+ /** One literal import() per grammar, so a bundler splits each into its own chunk. */
2
+ export const LOADERS = {
3
+ "bash": () => import("@shikijs/langs/bash"),
4
+ "shellsession": () => import("@shikijs/langs/shellsession"),
5
+ "powershell": () => import("@shikijs/langs/powershell"),
6
+ "bat": () => import("@shikijs/langs/bat"),
7
+ "javascript": () => import("@shikijs/langs/javascript"),
8
+ "typescript": () => import("@shikijs/langs/typescript"),
9
+ "jsx": () => import("@shikijs/langs/jsx"),
10
+ "tsx": () => import("@shikijs/langs/tsx"),
11
+ "json": () => import("@shikijs/langs/json"),
12
+ "jsonc": () => import("@shikijs/langs/jsonc"),
13
+ "yaml": () => import("@shikijs/langs/yaml"),
14
+ "toml": () => import("@shikijs/langs/toml"),
15
+ "ini": () => import("@shikijs/langs/ini"),
16
+ "properties": () => import("@shikijs/langs/properties"),
17
+ "xml": () => import("@shikijs/langs/xml"),
18
+ "xsl": () => import("@shikijs/langs/xsl"),
19
+ "html": () => import("@shikijs/langs/html"),
20
+ "css": () => import("@shikijs/langs/css"),
21
+ "scss": () => import("@shikijs/langs/scss"),
22
+ "markdown": () => import("@shikijs/langs/markdown"),
23
+ "java": () => import("@shikijs/langs/java"),
24
+ "python": () => import("@shikijs/langs/python"),
25
+ "go": () => import("@shikijs/langs/go"),
26
+ "rust": () => import("@shikijs/langs/rust"),
27
+ "c": () => import("@shikijs/langs/c"),
28
+ "cpp": () => import("@shikijs/langs/cpp"),
29
+ "csharp": () => import("@shikijs/langs/csharp"),
30
+ "sql": () => import("@shikijs/langs/sql"),
31
+ "diff": () => import("@shikijs/langs/diff"),
32
+ "docker": () => import("@shikijs/langs/docker"),
33
+ "makefile": () => import("@shikijs/langs/makefile"),
34
+ "groovy": () => import("@shikijs/langs/groovy"),
35
+ "kotlin": () => import("@shikijs/langs/kotlin"),
36
+ "ruby": () => import("@shikijs/langs/ruby"),
37
+ "graphql": () => import("@shikijs/langs/graphql"),
38
+ "nginx": () => import("@shikijs/langs/nginx"),
39
+ "hcl": () => import("@shikijs/langs/hcl"),
40
+ "http": () => import("@shikijs/langs/http"),
41
+ };
42
+ /** Every spelling the grammars declare → the grammar in LOADERS that brings it. */
43
+ export const SPELLINGS = {
44
+ "bash": "bash",
45
+ "bat": "bat",
46
+ "batch": "bat",
47
+ "c": "c",
48
+ "c#": "csharp",
49
+ "c++": "cpp",
50
+ "cjs": "javascript",
51
+ "console": "shellsession",
52
+ "cpp": "cpp",
53
+ "cpp-macro": "cpp",
54
+ "cs": "csharp",
55
+ "csharp": "csharp",
56
+ "css": "css",
57
+ "cts": "typescript",
58
+ "diff": "diff",
59
+ "docker": "docker",
60
+ "dockerfile": "docker",
61
+ "glsl": "cpp",
62
+ "go": "go",
63
+ "gql": "graphql",
64
+ "graphql": "graphql",
65
+ "groovy": "groovy",
66
+ "haml": "ruby",
67
+ "hcl": "hcl",
68
+ "html": "html",
69
+ "http": "http",
70
+ "ini": "ini",
71
+ "java": "java",
72
+ "javascript": "javascript",
73
+ "js": "javascript",
74
+ "json": "json",
75
+ "jsonc": "jsonc",
76
+ "jsx": "jsx",
77
+ "kotlin": "kotlin",
78
+ "kt": "kotlin",
79
+ "kts": "kotlin",
80
+ "lua": "ruby",
81
+ "make": "makefile",
82
+ "makefile": "makefile",
83
+ "markdown": "markdown",
84
+ "md": "markdown",
85
+ "mjs": "javascript",
86
+ "mts": "typescript",
87
+ "nginx": "nginx",
88
+ "powershell": "powershell",
89
+ "properties": "ini",
90
+ "ps": "powershell",
91
+ "ps1": "powershell",
92
+ "py": "python",
93
+ "python": "python",
94
+ "rb": "ruby",
95
+ "regex": "cpp",
96
+ "regexp": "cpp",
97
+ "rs": "rust",
98
+ "ruby": "ruby",
99
+ "rust": "rust",
100
+ "scss": "scss",
101
+ "sh": "bash",
102
+ "shell": "bash",
103
+ "shellscript": "bash",
104
+ "shellsession": "shellsession",
105
+ "sql": "sql",
106
+ "toml": "toml",
107
+ "ts": "typescript",
108
+ "tsx": "tsx",
109
+ "typescript": "typescript",
110
+ "xml": "xml",
111
+ "xsl": "xsl",
112
+ "yaml": "yaml",
113
+ "yml": "yaml",
114
+ "zsh": "bash",
115
+ };
116
+ /**
117
+ * Grammars that embed others lazily (Shiki's embeddedLangsLazy: they resolve
118
+ * an embedded scope from whatever is registered when a block is tokenized, and
119
+ * import nothing), and for each embed among ours, the patterns of which one
120
+ * must match a line of a block for the block to reach it ("" for always).
121
+ * The patterns are the grammar's own, with the anchors ^, \A and \G removed,
122
+ * so they match anywhere a nested rule could start them, and never less.
123
+ */
124
+ export const LAZY_EMBEDS = {
125
+ "markdown": {
126
+ "bat": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(bat(?:|ch))((\\s+|[,:?{])[^`]*)?$)"],
127
+ "c": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:([ch])((\\s+|[,:?{])[^`]*)?$)"],
128
+ "cpp": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(c(?:pp|\\+\\+|xx))((\\s+|[,:?{])[^`]*)?$)"],
129
+ "csharp": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(c(?:s|sharp|#))((\\s+|[,:?{])[^`]*)?$)"],
130
+ "css": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(css(?:|.erb))((\\s+|[,:?{])[^`]*)?$)"],
131
+ "diff": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(patch|diff|rej)((\\s+|[,:?{])[^`]*)?$)"],
132
+ "docker": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:([Dd]ockerfile)((\\s+|[,:?{])[^`]*)?$)"],
133
+ "go": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(go(?:|lang))((\\s+|[,:?{])[^`]*)?$)"],
134
+ "groovy": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(g(?:roovy|vy))((\\s+|[,:?{])[^`]*)?$)"],
135
+ "html": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(html?|shtml|xhtml|inc|tmpl|tpl)((\\s+|[,:?{])[^`]*)?$)", "(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(php3??|php4|php5|phpt|phtml|aw|ctp)((\\s+|[,:?{])[^`]*)?$)"],
136
+ "ini": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(ini|conf)((\\s+|[,:?{])[^`]*)?$)"],
137
+ "java": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(java|bsh)((\\s+|[,:?{])[^`]*)?$)"],
138
+ "javascript": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(jsx??|javascript|es6|mjs|cjs|dataviewjs|\\{\\.js.+?})((\\s+|[,:?{])[^`]*)?$)"],
139
+ "json": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(json5??|sublime-settings|sublime-menu|sublime-keymap|sublime-mousemap|sublime-theme|sublime-build|sublime-project|sublime-completions)((\\s+|[,:?{])[^`]*)?$)"],
140
+ "jsonc": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(jsonc)((\\s+|[,:?{])[^`]*)?$)"],
141
+ "lua": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(lua)((\\s+|[,:?{])[^`]*)?$)"],
142
+ "make": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:((?:[Mm]|GNUm|OCamlM)akefile)((\\s+|[,:?{])[^`]*)?$)"],
143
+ "powershell": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(p(?:owershell|s1|sm1|sd1|wsh))((\\s+|[,:?{])[^`]*)?$)"],
144
+ "python": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(python|py3??|rpy|pyw|cpy|SConstruct|Sconstruct|sconstruct|SConscript|gypi??|\\{\\.python.+?})((\\s+|[,:?{])[^`]*)?$)"],
145
+ "regexp": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(re)((\\s+|[,:?{])[^`]*)?$)"],
146
+ "ruby": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(ruby|rbx??|rjs|Rakefile|rake|cgi|fcgi|gemspec|irbrc|Capfile|ru|prawn|Cheffile|Gemfile|Guardfile|Hobofile|Vagrantfile|Appraisals|Rantfile|Berksfile|Berksfile.lock|Thorfile|Puppetfile)((\\s+|[,:?{])[^`]*)?$)"],
147
+ "rust": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(rust|rs|\\{\\.rust.+?})((\\s+|[,:?{])[^`]*)?$)"],
148
+ "scss": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(scss)((\\s+|[,:?{])[^`]*)?$)"],
149
+ "shellscript": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(shell|sh|bash|zsh|bashrc|bash_profile|bash_login|profile|bash_logout|.textmate_init|\\{\\.bash.+?})((\\s+|[,:?{])[^`]*)?$)"],
150
+ "sql": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(sql|ddl|dml)((\\s+|[,:?{])[^`]*)?$)"],
151
+ "tsx": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(tsx)((\\s+|[,:?{])[^`]*)?$)"],
152
+ "typescript": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(t(?:ypescript|s))((\\s+|[,:?{])[^`]*)?$)"],
153
+ "xml": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(xml|xsd|tld|jsp|pt|cpt|dtml|rss|opml)((\\s+|[,:?{])[^`]*)?$)"],
154
+ "xsl": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(xslt??)((\\s+|[,:?{])[^`]*)?$)"],
155
+ "yaml": ["(|)(\\s*)(`{3,}|~{3,})\\s*(?i:(ya?ml)((\\s+|[,:?{])[^`]*)?$)", "(?=(-{3,}))"],
156
+ },
157
+ "haml": {
158
+ "css": ["(\\s*)(:(css|styles?))$"],
159
+ "javascript": ["(\\s*):javascript", "(\\s*)%script"],
160
+ "markdown": ["(\\s*)(:markdown)"],
161
+ "ruby": ["(\\s*):ruby", "(\\s*):ruby$", "\\s*(?:((%)([-:\\w]+))|(?=[#.]))", "(\\s*):(ruby|opal)$", "(\\s*)(:ruby)", "#\\{", "!?==", "\\{", "(&amp|!)?([-=~])"],
162
+ },
163
+ };
164
+ /**
165
+ * Every grammar that can reach a grammar in LAZY_EMBEDS (itself, or through
166
+ * the grammars it includes: ruby includes haml, which embeds markdown lazily),
167
+ * and those it reaches. A block in it needs whatever their triggers find.
168
+ */
169
+ export const LAZY_VIA = {
170
+ "haml": ["haml"],
171
+ "markdown": ["markdown"],
172
+ "ruby": ["haml"],
173
+ };
@@ -0,0 +1,24 @@
1
+ import { type Highlighter } from "@dogsbay/render-html";
2
+ export { parseLineRanges } from "@dogsbay/render-html";
3
+ export type { CodeOptions, Highlighter } from "@dogsbay/render-html";
4
+ /**
5
+ * Characters of rendered blocks (and their keys) the highlighter keeps by
6
+ * default, for library callers: 8M characters is 8–16 MB (a string with any
7
+ * character outside Latin-1 takes two bytes per character). The CLI passes
8
+ * 64M, the Worker 4M.
9
+ */
10
+ export declare const DEFAULT_HIGHLIGHT_CACHE_CHARS = 8000000;
11
+ export interface HighlighterOptions {
12
+ cacheChars?: number;
13
+ onePass?: boolean;
14
+ /** Grammars to load now: tags as written (`py`, `yml`), or blocks (see `load`), or "all" for every grammar (today's start-up cost, for a caller that cannot list its languages first). */
15
+ langs?: Iterable<LoadItem> | "all";
16
+ }
17
+ /** What `load` takes: a block (its tag as written and its code), or a tag alone, which for markdown loads every grammar markdown can embed. */
18
+ export type LoadItem = string | undefined | {
19
+ lang: string | undefined;
20
+ code: string;
21
+ };
22
+ export declare function createHighlighter(o?: HighlighterOptions): Promise<Highlighter & {
23
+ load(items: Iterable<LoadItem>): Promise<void>;
24
+ }>;
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Syntax highlighting with Shiki's core build and JavaScript regex
3
+ * engine: no WebAssembly to instantiate, so it bundles into a Worker.
4
+ * Grammars load per language (`load`, before the code that needs them
5
+ * renders), one literal import() each (grammars.ts), so a split bundle reads
6
+ * only the grammars a site uses; the set covers the languages found in the
7
+ * Dogsbay test corpora. Unknown languages render as escaped plain text.
8
+ * Markdown embeds other grammars lazily (its fences name grammars it does not
9
+ * import), and ruby reaches it through haml, so what such a block needs
10
+ * depends on its content: `load` scans it (LAZY_EMBEDS, LAZY_VIA), and a block
11
+ * renders only once all of it is loaded, so its output never depends on what
12
+ * other pages loaded.
13
+ *
14
+ * Output uses dual themes with `defaultColor: false`, so colours come
15
+ * from `--shiki-light` / `--shiki-dark` CSS variables and the theme
16
+ * toggle switches them with no re-render.
17
+ *
18
+ * Output must not depend on machine load. Shiki's default guard is a
19
+ * 500 ms WALL-CLOCK limit per line, after which the rest of the line gets
20
+ * the default colour. On OpenShift a 4,445-character base64 line took
21
+ * ~310 ms idle and crossed 500 ms under build load, so a page's colours
22
+ * changed between identical builds. The bound is now a line LENGTH
23
+ * (deterministic): longer lines are left untokenized. The time limit is
24
+ * kept only as a Worker safety net far above any real line.
25
+ */
26
+ import { createHighlighterCore } from "shiki/core";
27
+ import { createJavaScriptRegexEngine, defaultJavaScriptRegexConstructor } from "shiki/engine/javascript";
28
+ import { onePassRenderer } from "./one-pass.js";
29
+ import githubLight from "@shikijs/themes/github-light-default";
30
+ import githubDark from "@shikijs/themes/github-dark-default";
31
+ import { LAZY_EMBEDS, LAZY_VIA, LOADERS, SPELLINGS } from "./grammars.js";
32
+ import { transformerNotationDiff, transformerNotationErrorLevel, transformerNotationFocus, transformerNotationHighlight, transformerNotationWordHighlight, } from "@shikijs/transformers";
33
+ import { esc, PLAIN, plainBlock } from "@dogsbay/render-html";
34
+ import { stripUnprocessableMarkers } from "@dogsbay/render-html/code-markers";
35
+ export { parseLineRanges } from "@dogsbay/render-html";
36
+ /** Aliases the grammars do not declare themselves. */
37
+ const ALIASES = {
38
+ terminal: "bash",
39
+ console: "shellsession",
40
+ "shell-session": "shellsession",
41
+ cmd: "bat",
42
+ dos: "bat",
43
+ batch: "bat",
44
+ dita: "xml",
45
+ ditamap: "xml",
46
+ ditaval: "xml",
47
+ xhtml: "html",
48
+ svg: "xml",
49
+ xslt: "xsl",
50
+ yml: "yaml",
51
+ conf: "ini",
52
+ cfg: "ini",
53
+ env: "properties",
54
+ dockerfile: "docker",
55
+ containerfile: "docker",
56
+ "c++": "cpp",
57
+ "c#": "csharp",
58
+ cs: "csharp",
59
+ gradle: "groovy",
60
+ kt: "kotlin",
61
+ py: "python",
62
+ rb: "ruby",
63
+ md: "markdown",
64
+ terraform: "hcl",
65
+ tf: "hcl",
66
+ };
67
+ /** Lines longer than this are not tokenized: 14 of 167,153 OpenShift code lines, all base64 or certificates. */
68
+ const MAX_LINE_LENGTH = 1000;
69
+ /** Safety net against a pathological grammar regex; a 1,000-character line takes ~70 ms. */
70
+ const TIME_LIMIT_MS = 5000;
71
+ let corePromise;
72
+ /** One core per process, shared by every highlighter: the themes, and the grammars loaded so far. */
73
+ function core() {
74
+ corePromise ??= createHighlighterCore({
75
+ themes: [githubLight, githubDark],
76
+ langs: [],
77
+ engine: createJavaScriptRegexEngine({ forgiving: true }),
78
+ });
79
+ return corePromise;
80
+ }
81
+ /** Grammar module → its load, kept while it succeeds (a failed import is tried again). */
82
+ const grammarLoads = new Map();
83
+ /** Ids the core has registered (names and aliases), for every highlighter on it. */
84
+ const loaded = new Set();
85
+ function loadGrammar(hl, module) {
86
+ let p = grammarLoads.get(module);
87
+ if (!p) {
88
+ p = LOADERS[module]()
89
+ .then(async (m) => {
90
+ await hl.loadLanguage(...m.default);
91
+ for (const id of hl.getLoadedLanguages())
92
+ loaded.add(id);
93
+ })
94
+ .catch((e) => {
95
+ grammarLoads.delete(module);
96
+ throw e;
97
+ });
98
+ grammarLoads.set(module, p);
99
+ }
100
+ return p;
101
+ }
102
+ /** A trigger pattern compiled as the core compiles grammar patterns; null for one the engine rejects, which then always triggers. */
103
+ const triggers = new Map();
104
+ function trigger(src) {
105
+ if (!triggers.has(src)) {
106
+ let re = null;
107
+ try {
108
+ re = defaultJavaScriptRegexConstructor(src);
109
+ }
110
+ catch {
111
+ re = null;
112
+ }
113
+ triggers.set(src, re);
114
+ }
115
+ return triggers.get(src);
116
+ }
117
+ /**
118
+ * The grammar ids a block needs: its own, and for a grammar that reaches one
119
+ * that embeds others lazily (LAZY_VIA: markdown itself; ruby through haml),
120
+ * each embed one of whose triggers matches a line of the block (all of them
121
+ * when the block's code is not known). An embed that reaches lazy grammars in
122
+ * turn is followed over the same code.
123
+ */
124
+ function needs(id, code, out = new Set(), lines = blockLines(code)) {
125
+ if (id === "text" || out.has(id))
126
+ return out;
127
+ out.add(id);
128
+ for (const lazy of LAZY_VIA[id] ?? []) {
129
+ for (const [embed, pats] of Object.entries(LAZY_EMBEDS[lazy])) {
130
+ if (out.has(embed))
131
+ continue;
132
+ const hit = !lines ||
133
+ pats.some((p) => {
134
+ if (p === "")
135
+ return true;
136
+ const re = trigger(p);
137
+ return !re || lines.some((l) => ((re.lastIndex = 0), re.test(l)));
138
+ });
139
+ if (hit)
140
+ needs(embed, code, out, lines);
141
+ }
142
+ }
143
+ return out;
144
+ }
145
+ /**
146
+ * A block's lines as `render` has Shiki tokenize them (markers stripped, the
147
+ * last newline dropped, split on \r?\n), each with the newline the tokenizer
148
+ * adds. Lines at or over MAX_LINE_LENGTH are left out: Shiki does not
149
+ * tokenize them and keeps its state across them, so no rule starts there (and
150
+ * the unanchored triggers would be quadratic on a long run of spaces).
151
+ */
152
+ function blockLines(code) {
153
+ if (code === undefined)
154
+ return undefined;
155
+ return stripUnprocessableMarkers(code.replace(/\n$/, ""), false)
156
+ .split(/\r?\n/)
157
+ .filter((l) => l.length < MAX_LINE_LENGTH)
158
+ .map((l) => l + "\n");
159
+ }
160
+ /** Everything a block in this grammar could need, whatever its code; when all of it is loaded, no scan is needed. */
161
+ const reachable = new Map();
162
+ function canReach(id) {
163
+ let r = reachable.get(id);
164
+ if (!r)
165
+ reachable.set(id, (r = [...needs(id, undefined)]));
166
+ return r;
167
+ }
168
+ /** Whether everything a block needs is loaded, so it renders as with every grammar loaded. */
169
+ function ready(id, code) {
170
+ if (!loaded.has(id))
171
+ return false;
172
+ if (!LAZY_VIA[id] || canReach(id).every((n) => loaded.has(n)))
173
+ return true;
174
+ for (const n of needs(id, code))
175
+ if (!loaded.has(n))
176
+ return false;
177
+ return true;
178
+ }
179
+ /** The language id a block's tag means: "text" for plain or unknown tags. */
180
+ function resolveId(lang) {
181
+ const l = (lang ?? "").toLowerCase().trim();
182
+ if (PLAIN.has(l))
183
+ return "text";
184
+ const a = ALIASES[l] ?? l;
185
+ return Object.hasOwn(SPELLINGS, a) ? a : "text";
186
+ }
187
+ /**
188
+ * Characters of rendered blocks (and their keys) the highlighter keeps by
189
+ * default, for library callers: 8M characters is 8–16 MB (a string with any
190
+ * character outside Latin-1 takes two bytes per character). The CLI passes
191
+ * 64M, the Worker 4M.
192
+ */
193
+ export const DEFAULT_HIGHLIGHT_CACHE_CHARS = 8_000_000;
194
+ export async function createHighlighter(o = {}) {
195
+ const hl = await core();
196
+ // Both themes from one tokenization (src/render/one-pass.ts); `onePass: false` keeps Shiki's codeToHtml, for comparison.
197
+ const THEMES = { light: "github-light-default", dark: "github-dark-default" };
198
+ const onePass = o.onePass === false ? undefined : onePassRenderer(hl, THEMES);
199
+ const resolve = resolveId;
200
+ let unloaded = 0;
201
+ /** Whether the last `render` had everything its block needs (so `code` may cache it). */
202
+ let lastReady = true;
203
+ const load = async (items) => {
204
+ const modules = new Set();
205
+ for (const item of items) {
206
+ const block = typeof item === "object" ? item : { lang: item, code: undefined };
207
+ const id = resolveId(block.lang);
208
+ if (id === "text")
209
+ continue;
210
+ const all = canReach(id);
211
+ if (all.every((n) => loaded.has(n)))
212
+ continue;
213
+ for (const n of block.code === undefined || all.length === 1 ? all : needs(id, block.code))
214
+ if (!loaded.has(n))
215
+ modules.add(SPELLINGS[n]);
216
+ }
217
+ if (!modules.size)
218
+ return;
219
+ await Promise.all([...modules].map((m) => loadGrammar(hl, m)));
220
+ };
221
+ if (o.langs === "all")
222
+ await load(Object.keys(SPELLINGS));
223
+ else if (o.langs)
224
+ await load(o.langs);
225
+ // Identical blocks (install commands, shared snippets) are common across a
226
+ // corpus; tokenizing dominates highlight time, so memoise by input. An LRU:
227
+ // a Map keeps insertion order, a hit moves the entry to the end, and the
228
+ // oldest entries go when the budget (characters of key and HTML) is spent.
229
+ // The key is the full input, not a hash, so a collision cannot return the
230
+ // wrong block. A hit re-inserts the entry under its own flattened key: the
231
+ // caller's key is a rope around `code`, which may be a slice of its page.
232
+ const cache = new Map();
233
+ let cacheChars = 0;
234
+ let hits = 0;
235
+ let misses = 0;
236
+ const CACHE_MAX = Number.isFinite(o.cacheChars) && o.cacheChars >= 0 ? o.cacheChars : DEFAULT_HIGHLIGHT_CACHE_CHARS;
237
+ const keyOf = (code, lang, opts) => `${lang ?? ""}\0${opts.lineNumbers ? 1 : 0}\0${(opts.highlights ?? []).join(",")}\0${(opts.diffAdd ?? []).join(",")}\0${(opts.diffRemove ?? []).join(",")}\0${code}`;
238
+ const keep = (key, html) => {
239
+ const size = key.length + html.length;
240
+ // A block over a quarter of the budget is not kept, so one block cannot empty the cache (as TextCache does).
241
+ if (size > CACHE_MAX / 4)
242
+ return;
243
+ for (const [k, v] of cache) {
244
+ if (cacheChars + size <= CACHE_MAX)
245
+ break;
246
+ cache.delete(k);
247
+ cacheChars -= v.key.length + v.html.length;
248
+ }
249
+ // Flatten before keeping: the rendered string is a rope of slices that
250
+ // would pin every token and source line it was built from.
251
+ const kept = { key: flatten(key), html: flatten(html) };
252
+ cache.set(kept.key, kept);
253
+ cacheChars += size;
254
+ };
255
+ const render = (code, lang, opts = {}) => {
256
+ const id = resolve(lang);
257
+ // Markers Shiki cannot see (plain text has no comment tokens; mid-line markers) are removed, as CodeRich does.
258
+ const src = stripUnprocessableMarkers(code.replace(/\n$/, ""), id === "text");
259
+ const marks = new Set(opts.highlights ?? []);
260
+ const adds = new Set(opts.diffAdd ?? []);
261
+ const removes = new Set(opts.diffRemove ?? []);
262
+ const cls = opts.lineNumbers ? " line-numbers" : "";
263
+ lastReady = true;
264
+ if (id === "text")
265
+ return plainBlock(src, opts);
266
+ // A grammar nobody loaded first (a caller missed `load`), or one a markdown block embeds: plain, counted, and not cached by `code`.
267
+ if (!ready(id, code)) {
268
+ lastReady = false;
269
+ unloaded++;
270
+ return plainBlock(src, opts);
271
+ }
272
+ // The notation transformers each walk every line: only for code that has markers.
273
+ const notation = src.includes("[!code");
274
+ const transformers = [
275
+ ...(notation
276
+ ? [transformerNotationDiff(), transformerNotationHighlight(), transformerNotationFocus(), transformerNotationErrorLevel(), transformerNotationWordHighlight()]
277
+ : []),
278
+ {
279
+ name: "dogsbay:lines",
280
+ line(node, line) {
281
+ if (marks.has(line))
282
+ this.addClassToHast(node, "highlighted");
283
+ if (adds.has(line))
284
+ this.addClassToHast(node, ["diff", "add"]);
285
+ else if (removes.has(line))
286
+ this.addClassToHast(node, ["diff", "remove"]);
287
+ },
288
+ },
289
+ ];
290
+ if (opts.lineNumbers || adds.size || removes.size || notation)
291
+ transformers.push(gutter(!!opts.lineNumbers));
292
+ try {
293
+ const shiki = () => hl.codeToHtml(src, { lang: id, themes: THEMES, defaultColor: false, tokenizeMaxLineLength: MAX_LINE_LENGTH, tokenizeTimeLimit: TIME_LIMIT_MS, transformers });
294
+ let out;
295
+ if (onePass && !notation) {
296
+ try {
297
+ // undefined: a block nested too deep for it, which Shiki's own path handles.
298
+ out = onePass(src, { lang: id, tokenizeMaxLineLength: MAX_LINE_LENGTH, tokenizeTimeLimit: TIME_LIMIT_MS, transformers });
299
+ }
300
+ catch {
301
+ out = undefined; // an unexpected failure: Shiki's path gives the color, before falling back to plain text
302
+ }
303
+ }
304
+ out ??= shiki();
305
+ return cls ? out.replace('<pre class="shiki', `<pre class="shiki${cls}`) : out;
306
+ }
307
+ catch {
308
+ return `<pre class="shiki shiki-plain${cls}" tabindex="0"><code>${esc(src)}</code></pre>`;
309
+ }
310
+ };
311
+ return {
312
+ resolve,
313
+ render,
314
+ load,
315
+ cacheStats: () => ({ entries: cache.size, chars: cacheChars, hits, misses, unloaded }),
316
+ has: (code, lang, opts = {}) => cache.has(keyOf(code, lang, opts)),
317
+ prime(code, lang, opts, html) {
318
+ const key = keyOf(code, lang, opts);
319
+ // Trusted as given: a thread that rendered it loaded what the block needs first (this highlighter may not have yet).
320
+ if (!cache.has(key))
321
+ keep(key, html);
322
+ },
323
+ code(code, lang, opts = {}) {
324
+ const key = keyOf(code, lang, opts);
325
+ const hit = cache.get(key);
326
+ if (hit !== undefined) {
327
+ hits++;
328
+ cache.delete(key);
329
+ cache.set(hit.key, hit);
330
+ return hit.html;
331
+ }
332
+ misses++;
333
+ const html = render(code, lang, opts);
334
+ if (lastReady)
335
+ keep(key, html);
336
+ return html;
337
+ },
338
+ };
339
+ }
340
+ /**
341
+ * A string's own copy. V8 keeps a sliced or concatenated string as a rope that
342
+ * pins every string it was built from, so the cache flattens what it keeps.
343
+ */
344
+ const flatten = (s) => JSON.parse(JSON.stringify(s));
345
+ /** The gutter as a Shiki transformer, after the notation transformers have marked diff lines. */
346
+ function gutter(lineNumbers) {
347
+ return {
348
+ name: "dogsbay:gutter",
349
+ code(node) {
350
+ const cls = (n) => {
351
+ const c = n.properties?.class;
352
+ return Array.isArray(c) ? c.join(" ") : String(c ?? "");
353
+ };
354
+ const lines = node.children.filter((c) => c.type === "element" && c.tagName === "span");
355
+ const diffs = lines.some((l) => /\bdiff\b/.test(cls(l)));
356
+ if (!lineNumbers && !diffs)
357
+ return;
358
+ let n = 1;
359
+ for (const line of lines) {
360
+ const c = cls(line);
361
+ if (!/\bline\b/.test(c))
362
+ continue;
363
+ const kind = /\badd\b/.test(c) ? "add" : /\bremove\b/.test(c) ? "remove" : "";
364
+ const parts = [];
365
+ if (lineNumbers)
366
+ parts.push(String(n).padStart(3));
367
+ if (diffs)
368
+ parts.push(kind === "add" ? "+" : kind === "remove" ? "−" : " ");
369
+ // Not text of the code: Pagefind skips it (it indexed "1function", "5const").
370
+ line.children.unshift({ type: "element", tagName: "span", properties: { class: `gutter${kind ? ` ${kind}` : ""}`, "data-pagefind-ignore": "" }, children: [{ type: "text", value: parts.join(" ") }] });
371
+ n++;
372
+ }
373
+ },
374
+ };
375
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Dual-theme highlighting with one tokenization (plans/build-performance.md,
3
+ * step 3). Shiki's `codeToHtml` with `themes: { light, dark }` runs the
4
+ * grammar once per theme (`codeToTokensWithThemes` maps `codeToTokensBase`
5
+ * over them), and the grammar is most of the cost. The scopes a grammar
6
+ * assigns do not depend on the theme, so this tokenizes each line once with
7
+ * `tokenizeLine` (scopes) and colors every token for both themes with the
8
+ * TextMate theme matcher, the same `Theme` Shiki's registry uses.
9
+ *
10
+ * The result goes through Shiki's own `tokensToHast` and `hastToHtml`, so
11
+ * transformers that work on lines and code (our line classes, gutter) run
12
+ * as before. Colors per character are the same as Shiki's; the span
13
+ * boundaries can differ (Shiki also splits where a token's type changes but
14
+ * its colors do not), which does not change what is shown.
15
+ *
16
+ * Not used for code with `[!code …]` notation, whose transformers work on
17
+ * the source before tokenization: those blocks keep Shiki's `codeToHtml`.
18
+ */
19
+ import { type HighlighterCore, type ShikiTransformer } from "shiki/core";
20
+ /** A token nested deeper than this sends its block to Shiki's own path, which has no per-path cost. */
21
+ export declare const MAX_SCOPE_DEPTH = 100;
22
+ export interface OnePassOptions {
23
+ lang: string;
24
+ tokenizeMaxLineLength: number;
25
+ tokenizeTimeLimit: number;
26
+ transformers: ShikiTransformer[];
27
+ }
28
+ /**
29
+ * A renderer bound to one highlighter and its two themes; `render` replaces
30
+ * `codeToHtml`, and returns undefined for a block it leaves to Shiki (tokens
31
+ * nested deeper than MAX_SCOPE_DEPTH).
32
+ */
33
+ export declare function onePassRenderer(hl: HighlighterCore, themes: {
34
+ light: string;
35
+ dark: string;
36
+ }): (code: string, o: OnePassOptions) => string | undefined;
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Dual-theme highlighting with one tokenization (plans/build-performance.md,
3
+ * step 3). Shiki's `codeToHtml` with `themes: { light, dark }` runs the
4
+ * grammar once per theme (`codeToTokensWithThemes` maps `codeToTokensBase`
5
+ * over them), and the grammar is most of the cost. The scopes a grammar
6
+ * assigns do not depend on the theme, so this tokenizes each line once with
7
+ * `tokenizeLine` (scopes) and colors every token for both themes with the
8
+ * TextMate theme matcher, the same `Theme` Shiki's registry uses.
9
+ *
10
+ * The result goes through Shiki's own `tokensToHast` and `hastToHtml`, so
11
+ * transformers that work on lines and code (our line classes, gutter) run
12
+ * as before. Colors per character are the same as Shiki's; the span
13
+ * boundaries can differ (Shiki also splits where a token's type changes but
14
+ * its colors do not), which does not change what is shown.
15
+ *
16
+ * Not used for code with `[!code …]` notation, whose transformers work on
17
+ * the source before tokenization: those blocks keep Shiki's `codeToHtml`.
18
+ */
19
+ import { applyColorReplacements, flatTokenVariants, hastToHtml, resolveColorReplacements, splitLines, tokensToHast, } from "shiki/core";
20
+ import { INITIAL, Theme } from "shiki/textmate";
21
+ /** vscode-textmate's FontStyle (a const enum, so not importable here). */
22
+ const FontStyle = { NotSet: -1, Underline: 4, Strikethrough: 8 };
23
+ function themeInfo(hl, themeName) {
24
+ const resolved = hl.getTheme(themeName);
25
+ const theme = Theme.createFromRawTheme(resolved);
26
+ const replacements = resolveColorReplacements(resolved, {});
27
+ return {
28
+ name: resolved.name,
29
+ fg: applyColorReplacements(resolved.fg, replacements) ?? "",
30
+ bg: applyColorReplacements(resolved.bg, replacements) ?? "",
31
+ theme,
32
+ colorMap: theme.getColorMap(),
33
+ replacements,
34
+ defaults: theme.getDefaults(),
35
+ };
36
+ }
37
+ /** Nodes kept before the trie is cleared (a corpus has a few thousand distinct paths). */
38
+ const MAX_NODES = 50_000;
39
+ /** A token nested deeper than this sends its block to Shiki's own path, which has no per-path cost. */
40
+ export const MAX_SCOPE_DEPTH = 100;
41
+ class DeepNesting extends Error {
42
+ }
43
+ /**
44
+ * A renderer bound to one highlighter and its two themes; `render` replaces
45
+ * `codeToHtml`, and returns undefined for a block it leaves to Shiki (tokens
46
+ * nested deeper than MAX_SCOPE_DEPTH).
47
+ */
48
+ export function onePassRenderer(hl, themes) {
49
+ const t = [themeInfo(hl, themes.light), themeInfo(hl, themes.dark)];
50
+ const order = ["light", "dark"];
51
+ const fg = `--shiki-light:${t[0].fg || "inherit"};--shiki-dark:${t[1].fg || "inherit"}`;
52
+ const bg = `--shiki-light-bg:${t[0].bg || "inherit"};--shiki-dark-bg:${t[1].bg || "inherit"}`;
53
+ const themeName = `shiki-themes ${t[0].name} ${t[1].name}`;
54
+ const newRoot = () => ({ path: null, fontStyle: [t[0].defaults.fontStyle, t[1].defaults.fontStyle], fg: [t[0].defaults.foregroundId, t[1].defaults.foregroundId], children: new Map() });
55
+ let root = newRoot();
56
+ let nodes = 0;
57
+ const child = (parent, scopeName) => {
58
+ let n = parent.children.get(scopeName);
59
+ if (n)
60
+ return n;
61
+ const path = { parent: parent.path, scopeName };
62
+ n = { path, fontStyle: [...parent.fontStyle], fg: [...parent.fg], children: new Map() };
63
+ for (const k of [0, 1]) {
64
+ const m = t[k].theme.match(path);
65
+ if (!m)
66
+ continue;
67
+ if (m.fontStyle !== FontStyle.NotSet)
68
+ n.fontStyle[k] = m.fontStyle;
69
+ if (m.foregroundId !== 0)
70
+ n.fg[k] = m.foregroundId;
71
+ }
72
+ parent.children.set(scopeName, n);
73
+ nodes++;
74
+ return n;
75
+ };
76
+ const styleOf = (n) => (n.style ??= [0, 1].map((k) => ({
77
+ color: applyColorReplacements(t[k].colorMap[n.fg[k]], t[k].replacements) ?? "",
78
+ fontStyle: n.fontStyle[k] === FontStyle.NotSet ? 0 : n.fontStyle[k],
79
+ })));
80
+ const nodeFor = (scopes) => {
81
+ if (scopes.length > MAX_SCOPE_DEPTH)
82
+ throw new DeepNesting();
83
+ let n = root;
84
+ for (const s of scopes)
85
+ n = child(n, s);
86
+ return n;
87
+ };
88
+ return function render(code, o) {
89
+ if (nodes > MAX_NODES) {
90
+ root = newRoot();
91
+ nodes = 0;
92
+ }
93
+ const grammar = hl.getLanguage(o.lang);
94
+ let stack = INITIAL;
95
+ const lines = [];
96
+ try {
97
+ for (const [line, offset] of splitLines(code)) {
98
+ if (line === "") {
99
+ lines.push([]);
100
+ continue;
101
+ }
102
+ if (o.tokenizeMaxLineLength > 0 && line.length >= o.tokenizeMaxLineLength) {
103
+ // As Shiki does: a line this long is not tokenized, and gets no color.
104
+ lines.push([flatTokenVariants({ content: line, offset, variants: { light: { color: "", fontStyle: 0 }, dark: { color: "", fontStyle: 0 } } }, order, "--shiki-", false)]);
105
+ continue;
106
+ }
107
+ const r = grammar.tokenizeLine(line, stack, o.tokenizeTimeLimit);
108
+ const out = [];
109
+ let prev;
110
+ const flush = () => {
111
+ if (!prev)
112
+ return;
113
+ out.push(flatTokenVariants({ content: line.slice(prev.start, prev.end), offset: offset + prev.start, variants: { light: prev.l, dark: prev.d } }, order, "--shiki-", false));
114
+ };
115
+ for (let i = 0; i < r.tokens.length; i++) {
116
+ const tok = r.tokens[i];
117
+ // As Shiki reads tokenizeLine2: a token runs to the next one's start, and the last to the end of the line,
118
+ // so a line cut short by the time limit keeps its text (the rest takes the last token's style).
119
+ const end = i + 1 < r.tokens.length ? r.tokens[i + 1].startIndex : line.length;
120
+ if (end <= tok.startIndex)
121
+ continue;
122
+ const [l, d] = styleOf(nodeFor(tok.scopes));
123
+ // Adjacent tokens styled the same in both themes are one span, as Shiki's merge of the two themes' tokens gives.
124
+ // Shiki's spans can split differently around whitespace (its tokens also break where a token's type changes),
125
+ // so a space may take the color of the token before it rather than after: invisible, since spaces are not
126
+ // underlined or struck through here unless the whole token is.
127
+ if (prev && prev.end === tok.startIndex && same(prev.l, l) && same(prev.d, d))
128
+ prev.end = end;
129
+ else {
130
+ flush();
131
+ prev = { l, d, start: tok.startIndex, end };
132
+ }
133
+ }
134
+ flush();
135
+ lines.push(out);
136
+ stack = r.ruleStack;
137
+ }
138
+ }
139
+ catch (e) {
140
+ if (e instanceof DeepNesting)
141
+ return undefined;
142
+ throw e;
143
+ }
144
+ const context = {
145
+ meta: {},
146
+ options: { lang: o.lang, themes, defaultColor: false, transformers: o.transformers },
147
+ codeToHast: () => {
148
+ throw new Error("codeToHast is not available in the one-pass renderer");
149
+ },
150
+ codeToTokens: () => {
151
+ throw new Error("codeToTokens is not available in the one-pass renderer");
152
+ },
153
+ get source() {
154
+ return code;
155
+ },
156
+ };
157
+ const hast = tokensToHast(mergeWhitespaceTokens(lines), { ...context.options, fg, bg, themeName, rootStyle: `${fg};${bg}` }, context);
158
+ return hastToHtml(hast);
159
+ };
160
+ }
161
+ const same = (a, b) => a.color === b.color && a.fontStyle === b.fontStyle;
162
+ /**
163
+ * Shiki's default `mergeWhitespaces: true` (its `mergeWhitespaceTokens`, not
164
+ * exported): a whitespace-only token joins the next one, and trailing
165
+ * whitespace joins the previous one, unless it is underlined or struck through.
166
+ */
167
+ function mergeWhitespaceTokens(lines) {
168
+ const WS = /^\s+$/;
169
+ return lines.map((line) => {
170
+ const out = [];
171
+ let carry = "";
172
+ let firstOffset;
173
+ line.forEach((token, idx) => {
174
+ const couldMerge = !(token.fontStyle && (token.fontStyle & FontStyle.Underline || token.fontStyle & FontStyle.Strikethrough));
175
+ if (couldMerge && WS.test(token.content) && line[idx + 1]) {
176
+ if (firstOffset === undefined)
177
+ firstOffset = token.offset;
178
+ carry += token.content;
179
+ }
180
+ else if (carry) {
181
+ if (couldMerge)
182
+ out.push({ ...token, offset: firstOffset, content: carry + token.content });
183
+ else
184
+ out.push({ content: carry, offset: firstOffset }, token);
185
+ firstOffset = undefined;
186
+ carry = "";
187
+ }
188
+ else
189
+ out.push(token);
190
+ });
191
+ return out;
192
+ });
193
+ }
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@dogsbay/highlight",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0-beta.117",
4
+ "description": "Syntax highlighting for @dogsbay/render-html: Shiki core with the JavaScript regex engine, both themes from one tokenization, a bounded cache. No WebAssembly; Worker-safe",
5
+ "type": "module",
6
+ "main": "./dist/highlight.js",
7
+ "types": "./dist/highlight.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/highlight.d.ts",
11
+ "import": "./dist/highlight.js"
12
+ },
13
+ "./one-pass": {
14
+ "types": "./dist/one-pass.d.ts",
15
+ "import": "./dist/one-pass.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md"
21
+ ],
22
+ "dependencies": {
23
+ "@shikijs/engine-javascript": "4.3.1",
24
+ "@shikijs/langs": "4.3.1",
25
+ "@shikijs/themes": "4.3.1",
26
+ "@shikijs/transformers": "4.3.1",
27
+ "shiki": "4.3.1",
28
+ "@dogsbay/render-html": "0.2.0-beta.117"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^22.20.1",
32
+ "typescript": "^5.9.3",
33
+ "vitest": "^4.1.10",
34
+ "esbuild": "^0.25.0"
35
+ },
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/dogsbay/dogsbay.git",
40
+ "directory": "packages/highlight"
41
+ },
42
+ "scripts": {
43
+ "build": "tsc",
44
+ "test": "vitest run",
45
+ "test:watch": "vitest",
46
+ "typecheck": "tsc --noEmit"
47
+ }
6
48
  }