@runtypelabs/persona 4.7.0 → 4.9.0
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 +1 -0
- package/dist/animations/glyph-cycle.cjs +2 -2
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/glyph-cycle.js +2 -2
- package/dist/animations/{types-CSmiKRVa.d.cts → types-4ROVJ1gA.d.cts} +85 -3
- package/dist/animations/{types-CSmiKRVa.d.ts → types-4ROVJ1gA.d.ts} +85 -3
- package/dist/animations/wipe.cjs +2 -2
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-5EIIHQLQ.js +1 -0
- package/dist/chunk-IO5VVUKP.js +3 -0
- package/dist/chunk-IPVK3KOM.js +1 -0
- package/dist/chunk-UPO4GUFC.js +1 -0
- package/dist/codegen.cjs +13 -13
- package/dist/codegen.js +14 -14
- package/dist/context-mentions-7S5KVUTG.js +169 -0
- package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
- package/dist/context-mentions-inline.cjs +4 -0
- package/dist/context-mentions-inline.d.cts +203 -0
- package/dist/context-mentions-inline.d.ts +203 -0
- package/dist/context-mentions-inline.js +4 -0
- package/dist/context-mentions.cjs +295 -0
- package/dist/context-mentions.d.cts +7025 -0
- package/dist/context-mentions.d.ts +7025 -0
- package/dist/context-mentions.js +295 -0
- package/dist/index.cjs +99 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1507 -182
- package/dist/index.d.ts +1507 -182
- package/dist/index.global.js +88 -56
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +99 -68
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/launcher.global.js +3 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/markdown-parsers.js +24 -24
- package/dist/plugin-kit.cjs +1 -1
- package/dist/plugin-kit.d.cts +17 -0
- package/dist/plugin-kit.d.ts +17 -0
- package/dist/plugin-kit.js +1 -1
- package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
- package/dist/runtype-tts.js +1 -1
- package/dist/session-reconnect-JKIJBHS5.js +1 -0
- package/dist/smart-dom-reader.cjs +18 -17
- package/dist/smart-dom-reader.d.cts +1260 -16
- package/dist/smart-dom-reader.d.ts +1260 -16
- package/dist/smart-dom-reader.js +18 -17
- package/dist/testing.cjs +3 -3
- package/dist/testing.js +3 -3
- package/dist/theme-editor-preview.cjs +260 -58
- package/dist/theme-editor-preview.d.cts +1234 -15
- package/dist/theme-editor-preview.d.ts +1234 -15
- package/dist/theme-editor-preview.js +87 -58
- package/dist/theme-editor.cjs +10 -10
- package/dist/theme-editor.d.cts +1226 -15
- package/dist/theme-editor.d.ts +1226 -15
- package/dist/theme-editor.js +10 -10
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +76 -0
- package/dist/theme-reference.d.ts +76 -0
- package/dist/theme-reference.js +1 -1
- package/dist/voice-worklet-player.cjs +2 -2
- package/dist/voice-worklet-player.js +2 -2
- package/dist/webmcp-polyfill.js +2 -2
- package/dist/widget.css +1 -1
- package/package.json +16 -5
- package/src/artifacts-session.test.ts +178 -0
- package/src/client.test.ts +255 -1
- package/src/client.ts +157 -60
- package/src/components/artifact-card.test.ts +333 -0
- package/src/components/artifact-card.ts +75 -28
- package/src/components/artifact-inline.test.ts +1328 -0
- package/src/components/artifact-inline.ts +920 -0
- package/src/components/artifact-pane.test.ts +1042 -0
- package/src/components/artifact-pane.ts +440 -131
- package/src/components/artifact-preview.test.ts +1155 -0
- package/src/components/artifact-preview.ts +994 -0
- package/src/components/composer-parts.ts +3 -12
- package/src/components/context-mention-button.test.ts +70 -0
- package/src/components/context-mention-button.ts +82 -0
- package/src/components/context-mention-chip.ts +134 -0
- package/src/components/context-mention-menu.test.ts +508 -0
- package/src/components/context-mention-menu.ts +0 -0
- package/src/components/message-bubble.test.ts +175 -0
- package/src/components/message-bubble.ts +177 -19
- package/src/components/panel.ts +7 -10
- package/src/components/pill-composer-builder.test.ts +6 -2
- package/src/components/pill-composer-builder.ts +6 -6
- package/src/components/reasoning-bubble.ts +1 -13
- package/src/components/registry.ts +38 -3
- package/src/components/tool-bubble.ts +1 -13
- package/src/context-mentions-bundle.test.ts +163 -0
- package/src/context-mentions-entry.ts +185 -0
- package/src/context-mentions-inline-entry.test.ts +136 -0
- package/src/context-mentions-inline-entry.ts +226 -0
- package/src/context-mentions-inline-loader.test.ts +30 -0
- package/src/context-mentions-inline-loader.ts +36 -0
- package/src/context-mentions-inline.ts +15 -0
- package/src/context-mentions-loader.ts +32 -0
- package/src/context-mentions.ts +16 -0
- package/src/defaults.ts +1 -0
- package/src/generated/runtype-openapi-contract.ts +55 -3
- package/src/index-core.ts +46 -1
- package/src/index-global.ts +51 -0
- package/src/index.ts +8 -0
- package/src/markdown-parsers-loader.test.ts +158 -0
- package/src/markdown-parsers-loader.ts +83 -27
- package/src/plugin-kit.test.ts +40 -0
- package/src/plugin-kit.ts +39 -5
- package/src/runtime/host-layout.test.ts +163 -0
- package/src/runtime/host-layout.ts +110 -7
- package/src/runtime/init.ts +18 -61
- package/src/session.mentions.test.ts +175 -0
- package/src/session.test.ts +52 -4
- package/src/session.ts +197 -27
- package/src/smart-dom-reader.test.ts +129 -2
- package/src/smart-dom-reader.ts +127 -1
- package/src/styles/context-mention-menu-css.ts +176 -0
- package/src/styles/widget.css +1002 -152
- package/src/theme-editor/preview.ts +2 -0
- package/src/theme-editor/sections.test.ts +26 -1
- package/src/theme-editor/sections.ts +13 -5
- package/src/theme-reference.ts +2 -2
- package/src/tool-call-display-defaults.test.ts +1 -0
- package/src/types/theme.ts +79 -0
- package/src/types.ts +1041 -17
- package/src/ui.artifact-pane-gating.test.ts +636 -0
- package/src/ui.component-directive.test.ts +104 -0
- package/src/ui.composer-bar.test.ts +60 -2
- package/src/ui.detached-panel.test.ts +1049 -0
- package/src/ui.mention-submit.test.ts +235 -0
- package/src/ui.tool-display.test.ts +51 -0
- package/src/ui.ts +998 -159
- package/src/utils/artifact-custom-actions.ts +128 -0
- package/src/utils/artifact-display.test.ts +42 -0
- package/src/utils/artifact-display.ts +84 -0
- package/src/utils/artifact-file.test.ts +116 -0
- package/src/utils/artifact-file.ts +117 -0
- package/src/utils/artifact-gate.test.ts +112 -5
- package/src/utils/artifact-gate.ts +39 -14
- package/src/utils/artifact-loading-status.ts +55 -0
- package/src/utils/artifact-status-label.ts +190 -0
- package/src/utils/buttons.ts +7 -1
- package/src/utils/chunk-loader.test.ts +97 -0
- package/src/utils/chunk-loader.ts +88 -0
- package/src/utils/code-highlight.test.ts +186 -0
- package/src/utils/code-highlight.ts +400 -0
- package/src/utils/composer-contenteditable.test.ts +507 -0
- package/src/utils/composer-contenteditable.ts +626 -0
- package/src/utils/composer-document.test.ts +280 -0
- package/src/utils/composer-document.ts +293 -0
- package/src/utils/composer-history.test.ts +35 -7
- package/src/utils/composer-history.ts +30 -20
- package/src/utils/composer-input.ts +159 -0
- package/src/utils/context-mention-controller.test.ts +1215 -0
- package/src/utils/context-mention-controller.ts +1186 -0
- package/src/utils/context-mention-manager.test.ts +422 -0
- package/src/utils/context-mention-manager.ts +410 -0
- package/src/utils/context-mention-orchestrator.test.ts +538 -0
- package/src/utils/context-mention-orchestrator.ts +348 -0
- package/src/utils/icons.ts +2 -0
- package/src/utils/live-region.test.ts +108 -0
- package/src/utils/live-region.ts +94 -0
- package/src/utils/mention-channels.ts +63 -0
- package/src/utils/mention-llm-format.test.ts +91 -0
- package/src/utils/mention-llm-format.ts +79 -0
- package/src/utils/mention-matcher.test.ts +86 -0
- package/src/utils/mention-matcher.ts +221 -0
- package/src/utils/mention-token.ts +72 -0
- package/src/utils/mention-trigger.test.ts +155 -0
- package/src/utils/mention-trigger.ts +156 -0
- package/src/utils/roving-tablist.test.ts +152 -0
- package/src/utils/roving-tablist.ts +111 -0
- package/src/utils/spinner.ts +45 -0
- package/src/utils/theme.test.ts +76 -4
- package/src/utils/theme.ts +7 -0
- package/src/utils/tokens.ts +118 -11
- package/src/utils/tool-loading-animation.test.ts +32 -0
- package/src/utils/tool-loading-animation.ts +24 -0
- package/dist/chunk-DFBSCFYN.js +0 -1
- package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
- package/dist/session-reconnect-U77QFUR7.js +0 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
highlightCode,
|
|
6
|
+
resolveHighlightLanguage,
|
|
7
|
+
MAX_HIGHLIGHT_LENGTH,
|
|
8
|
+
} from "./code-highlight";
|
|
9
|
+
|
|
10
|
+
/** Reconstruct the source from a highlighted fragment (invariant helper). */
|
|
11
|
+
const textOf = (frag: DocumentFragment): string => {
|
|
12
|
+
const holder = document.createElement("div");
|
|
13
|
+
holder.appendChild(frag.cloneNode(true));
|
|
14
|
+
return holder.textContent ?? "";
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Collect token classes present in a highlighted fragment. */
|
|
18
|
+
const tokenClasses = (frag: DocumentFragment): string[] => {
|
|
19
|
+
const holder = document.createElement("div");
|
|
20
|
+
holder.appendChild(frag.cloneNode(true));
|
|
21
|
+
return Array.from(holder.querySelectorAll("span[class^='persona-code-token-']"))
|
|
22
|
+
.map((el) => el.className);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const classFor = (source: string, lang: string, snippet: string): string | null => {
|
|
26
|
+
const holder = document.createElement("div");
|
|
27
|
+
holder.appendChild(highlightCode(source, lang));
|
|
28
|
+
const spans = Array.from(
|
|
29
|
+
holder.querySelectorAll("span[class^='persona-code-token-']")
|
|
30
|
+
);
|
|
31
|
+
const hit = spans.find((el) => el.textContent === snippet);
|
|
32
|
+
return hit ? hit.className : null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
describe("resolveHighlightLanguage", () => {
|
|
36
|
+
it("prefers explicit language and maps aliases", () => {
|
|
37
|
+
expect(resolveHighlightLanguage("html")).toBe("html");
|
|
38
|
+
expect(resolveHighlightLanguage("htm")).toBe("html");
|
|
39
|
+
expect(resolveHighlightLanguage("mjs")).toBe("js");
|
|
40
|
+
expect(resolveHighlightLanguage("tsx")).toBe("js");
|
|
41
|
+
expect(resolveHighlightLanguage("typescript")).toBe("js");
|
|
42
|
+
expect(resolveHighlightLanguage("jsonc")).toBe("json");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("falls back to the file extension", () => {
|
|
46
|
+
expect(resolveHighlightLanguage(undefined, "src/app.css")).toBe("css");
|
|
47
|
+
expect(resolveHighlightLanguage(undefined, "a/b/main.ts")).toBe("js");
|
|
48
|
+
expect(resolveHighlightLanguage(undefined, "index.html")).toBe("html");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns null for unknown languages and extensionless paths", () => {
|
|
52
|
+
expect(resolveHighlightLanguage("rust")).toBeNull();
|
|
53
|
+
expect(resolveHighlightLanguage(undefined, "Makefile")).toBeNull();
|
|
54
|
+
expect(resolveHighlightLanguage(undefined)).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("highlightCode token classification", () => {
|
|
59
|
+
it("classifies js keywords, strings, numbers, and comments", () => {
|
|
60
|
+
expect(classFor("const x = 1;", "js", "const")).toBe(
|
|
61
|
+
"persona-code-token-keyword"
|
|
62
|
+
);
|
|
63
|
+
expect(classFor('const s = "hi";', "js", '"hi"')).toBe(
|
|
64
|
+
"persona-code-token-string"
|
|
65
|
+
);
|
|
66
|
+
expect(classFor("const n = 42;", "js", "42")).toBe(
|
|
67
|
+
"persona-code-token-number"
|
|
68
|
+
);
|
|
69
|
+
expect(classFor("// note\nx", "js", "// note")).toBe(
|
|
70
|
+
"persona-code-token-comment"
|
|
71
|
+
);
|
|
72
|
+
expect(classFor("const b = true;", "js", "true")).toBe(
|
|
73
|
+
"persona-code-token-keyword"
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("classifies json keys as property and values as string", () => {
|
|
78
|
+
const src = '{"name": "cat", "n": 3, "ok": true}';
|
|
79
|
+
expect(classFor(src, "json", '"name"')).toBe("persona-code-token-property");
|
|
80
|
+
expect(classFor(src, "json", '"cat"')).toBe("persona-code-token-string");
|
|
81
|
+
expect(classFor(src, "json", "3")).toBe("persona-code-token-number");
|
|
82
|
+
expect(classFor(src, "json", "true")).toBe("persona-code-token-keyword");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("classifies css properties, values, at-rules, and comments", () => {
|
|
86
|
+
const src = "@media all { .a { color: #fff; margin: 4px; } } /* c */";
|
|
87
|
+
expect(classFor(src, "css", "color")).toBe("persona-code-token-property");
|
|
88
|
+
expect(classFor(src, "css", "#fff")).toBe("persona-code-token-number");
|
|
89
|
+
expect(classFor(src, "css", "@media")).toBe("persona-code-token-keyword");
|
|
90
|
+
expect(classFor(src, "css", "/* c */")).toBe("persona-code-token-comment");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("classifies html tags, attributes, attribute values, and comments", () => {
|
|
94
|
+
const src = '<a href="x">hi</a><!-- c -->';
|
|
95
|
+
expect(classFor(src, "html", "href")).toBe("persona-code-token-attr");
|
|
96
|
+
expect(classFor(src, "html", '"x"')).toBe("persona-code-token-string");
|
|
97
|
+
expect(classFor(src, "html", "<!-- c -->")).toBe(
|
|
98
|
+
"persona-code-token-comment"
|
|
99
|
+
);
|
|
100
|
+
// Tag bracket + name.
|
|
101
|
+
expect(classFor(src, "html", "<a")).toBe("persona-code-token-tag");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("highlights embedded <script> bodies with the js tokenizer", () => {
|
|
105
|
+
const src = "<script>const y = 2;</script>";
|
|
106
|
+
expect(classFor(src, "html", "const")).toBe("persona-code-token-keyword");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("highlightCode line + newline invariants", () => {
|
|
111
|
+
it("splits a multi-line block comment across lines without newlines in spans", () => {
|
|
112
|
+
const src = "/* line one\nline two */\nvar x;";
|
|
113
|
+
const frag = highlightCode(src, "js");
|
|
114
|
+
const holder = document.createElement("div");
|
|
115
|
+
holder.appendChild(frag.cloneNode(true));
|
|
116
|
+
const commentSpans = holder.querySelectorAll(".persona-code-token-comment");
|
|
117
|
+
// The block comment spans two lines → two comment spans, neither with "\n".
|
|
118
|
+
expect(commentSpans.length).toBe(2);
|
|
119
|
+
commentSpans.forEach((el) => expect(el.textContent).not.toContain("\n"));
|
|
120
|
+
// No token span anywhere contains a newline.
|
|
121
|
+
holder
|
|
122
|
+
.querySelectorAll("span[class^='persona-code-token-']")
|
|
123
|
+
.forEach((el) => expect(el.textContent).not.toContain("\n"));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("reconstructs the source verbatim (textContent === source)", () => {
|
|
127
|
+
for (const [src, lang] of [
|
|
128
|
+
["const x = 1;\nconst y = 2;\n", "js"],
|
|
129
|
+
["line1\nline2\nline3", "js"],
|
|
130
|
+
["a\n\nb\n", "js"],
|
|
131
|
+
['{"k": "v"}\n', "json"],
|
|
132
|
+
["<div>\n <span>hi</span>\n</div>\n", "html"],
|
|
133
|
+
["", "js"],
|
|
134
|
+
] as const) {
|
|
135
|
+
expect(textOf(highlightCode(src, lang))).toBe(src);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("does not emit a phantom numbered line for a trailing newline", () => {
|
|
140
|
+
const holder = document.createElement("div");
|
|
141
|
+
holder.appendChild(highlightCode("a\nb\n", "js"));
|
|
142
|
+
// "a\nb\n" → 2 numbered lines, trailing newline preserved in textContent.
|
|
143
|
+
expect(holder.querySelectorAll(".persona-code-line").length).toBe(2);
|
|
144
|
+
expect(holder.textContent).toBe("a\nb\n");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("keeps one line for wholly empty source", () => {
|
|
148
|
+
const holder = document.createElement("div");
|
|
149
|
+
holder.appendChild(highlightCode("", "js"));
|
|
150
|
+
expect(holder.querySelectorAll(".persona-code-line").length).toBe(1);
|
|
151
|
+
expect(holder.textContent).toBe("");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("highlightCode fallbacks and safety", () => {
|
|
156
|
+
it("returns plain line-numbered text for an unknown language", () => {
|
|
157
|
+
const frag = highlightCode("const x = 1;", "rust");
|
|
158
|
+
expect(tokenClasses(frag)).toEqual([]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("skips tokenization for oversized sources", () => {
|
|
162
|
+
const big = "const x = 1;\n".repeat(
|
|
163
|
+
Math.ceil((MAX_HIGHLIGHT_LENGTH + 100) / 13)
|
|
164
|
+
);
|
|
165
|
+
expect(big.length).toBeGreaterThan(MAX_HIGHLIGHT_LENGTH);
|
|
166
|
+
const frag = highlightCode(big, "js");
|
|
167
|
+
expect(tokenClasses(frag)).toEqual([]);
|
|
168
|
+
// Still line-numbered (line spans present) and verbatim.
|
|
169
|
+
const holder = document.createElement("div");
|
|
170
|
+
holder.appendChild(frag);
|
|
171
|
+
expect(holder.querySelector(".persona-code-line")).toBeTruthy();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("never emits any element other than spans for hostile input", () => {
|
|
175
|
+
// XSS-shaped content embedded as a JS string literal.
|
|
176
|
+
const src = 'const html = "<img src=x onerror=alert(1)>";';
|
|
177
|
+
const holder = document.createElement("div");
|
|
178
|
+
holder.appendChild(highlightCode(src, "js"));
|
|
179
|
+
// Only <span> elements exist; no <img> (or anything else) was created.
|
|
180
|
+
const all = Array.from(holder.querySelectorAll("*"));
|
|
181
|
+
expect(all.every((el) => el.tagName === "SPAN")).toBe(true);
|
|
182
|
+
expect(holder.querySelector("img")).toBeNull();
|
|
183
|
+
// The dangerous text survives as inert text content.
|
|
184
|
+
expect(holder.textContent).toContain("<img src=x onerror=alert(1)>");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny hand-rolled syntax highlighter for the artifact source view.
|
|
3
|
+
*
|
|
4
|
+
* Zero dependencies by design: the IIFE bundle is built with `--splitting
|
|
5
|
+
* false`, so any highlight.js / prism / shiki style library would be inlined
|
|
6
|
+
* and inflate the critical bundle. This module is a small regex tokenizer
|
|
7
|
+
* (html / css / js|ts / json) plus a DOM builder that emits one
|
|
8
|
+
* `span.persona-code-line` per source line for the CSS-counter gutter.
|
|
9
|
+
*
|
|
10
|
+
* Safety by construction: the DOM is built ONLY with `createElement` +
|
|
11
|
+
* `textContent` + `createTextNode` — never `innerHTML` — so the output is safe
|
|
12
|
+
* regardless of the widget's `sanitize` config, even when the source itself is
|
|
13
|
+
* hostile markup (e.g. `<img src=x onerror=…>`).
|
|
14
|
+
*
|
|
15
|
+
* Verbatim-reconstruction invariant: the built fragment's `textContent` equals
|
|
16
|
+
* the original `source` exactly. Every source newline becomes a literal `"\n"`
|
|
17
|
+
* text node placed *between* line spans; token spans never contain a newline
|
|
18
|
+
* (multi-line tokens such as block comments and template literals are split at
|
|
19
|
+
* newline boundaries into per-line spans of the same class). A trailing source
|
|
20
|
+
* newline is preserved as a `"\n"` text node without a phantom numbered empty
|
|
21
|
+
* final line.
|
|
22
|
+
*/
|
|
23
|
+
import { createElement, createFragment } from "./dom";
|
|
24
|
+
|
|
25
|
+
/** Token classification. `plain`/`punctuation` render as bare text (no span). */
|
|
26
|
+
export type CodeTokenType =
|
|
27
|
+
| "keyword"
|
|
28
|
+
| "string"
|
|
29
|
+
| "comment"
|
|
30
|
+
| "number"
|
|
31
|
+
| "tag"
|
|
32
|
+
| "attr"
|
|
33
|
+
| "property"
|
|
34
|
+
| "punctuation"
|
|
35
|
+
| "plain";
|
|
36
|
+
|
|
37
|
+
export interface CodeToken {
|
|
38
|
+
type: CodeTokenType;
|
|
39
|
+
value: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Canonical tokenizer keys. `ts`/`jsx`/`tsx` share the `js` tokenizer. */
|
|
43
|
+
export type HighlightLanguage = "html" | "css" | "js" | "json";
|
|
44
|
+
|
|
45
|
+
/** Token type → CSS class. Types absent here render as a bare text node. */
|
|
46
|
+
const TOKEN_CLASS: Partial<Record<CodeTokenType, string>> = {
|
|
47
|
+
keyword: "persona-code-token-keyword",
|
|
48
|
+
string: "persona-code-token-string",
|
|
49
|
+
comment: "persona-code-token-comment",
|
|
50
|
+
number: "persona-code-token-number",
|
|
51
|
+
tag: "persona-code-token-tag",
|
|
52
|
+
attr: "persona-code-token-attr",
|
|
53
|
+
property: "persona-code-token-property",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Streaming perf guard: skip tokenization above this many characters and fall
|
|
58
|
+
* back to plain line-numbered text. `renderPre` runs on every streaming delta,
|
|
59
|
+
* so keep the tokenizer off huge sources.
|
|
60
|
+
*/
|
|
61
|
+
export const MAX_HIGHLIGHT_LENGTH = 150_000;
|
|
62
|
+
|
|
63
|
+
/** Extension / short-name → canonical tokenizer key. */
|
|
64
|
+
const LANGUAGE_ALIASES: Record<string, HighlightLanguage> = {
|
|
65
|
+
html: "html",
|
|
66
|
+
htm: "html",
|
|
67
|
+
xhtml: "html",
|
|
68
|
+
xml: "html",
|
|
69
|
+
svg: "html",
|
|
70
|
+
css: "css",
|
|
71
|
+
js: "js",
|
|
72
|
+
jsx: "js",
|
|
73
|
+
mjs: "js",
|
|
74
|
+
cjs: "js",
|
|
75
|
+
ts: "js",
|
|
76
|
+
tsx: "js",
|
|
77
|
+
mts: "js",
|
|
78
|
+
cts: "js",
|
|
79
|
+
javascript: "js",
|
|
80
|
+
typescript: "js",
|
|
81
|
+
ecmascript: "js",
|
|
82
|
+
json: "json",
|
|
83
|
+
jsonc: "json",
|
|
84
|
+
json5: "json",
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolve a canonical tokenizer from an explicit language or a file path
|
|
89
|
+
* extension. Returns `null` for anything unmapped (plain-text fallback).
|
|
90
|
+
*/
|
|
91
|
+
export function resolveHighlightLanguage(
|
|
92
|
+
language?: string,
|
|
93
|
+
path?: string
|
|
94
|
+
): HighlightLanguage | null {
|
|
95
|
+
const lang = (language || "").trim().toLowerCase();
|
|
96
|
+
if (lang && LANGUAGE_ALIASES[lang]) return LANGUAGE_ALIASES[lang];
|
|
97
|
+
|
|
98
|
+
if (path) {
|
|
99
|
+
const dot = path.lastIndexOf(".");
|
|
100
|
+
if (dot >= 0) {
|
|
101
|
+
const ext = path.slice(dot + 1).toLowerCase();
|
|
102
|
+
if (LANGUAGE_ALIASES[ext]) return LANGUAGE_ALIASES[ext];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface Rule {
|
|
109
|
+
type: CodeTokenType;
|
|
110
|
+
/** Sticky (`y`) regex so a match only counts when it starts at `lastIndex`. */
|
|
111
|
+
re: RegExp;
|
|
112
|
+
/** Optional reclassifier (e.g. identifier → keyword when in a keyword set). */
|
|
113
|
+
map?: (text: string) => CodeTokenType;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Left-to-right rule scanner. At each index the first rule that matches at that
|
|
118
|
+
* exact position wins; unmatched characters accrete into a single `plain` run.
|
|
119
|
+
*/
|
|
120
|
+
function runRules(source: string, rules: Rule[]): CodeToken[] {
|
|
121
|
+
const tokens: CodeToken[] = [];
|
|
122
|
+
let i = 0;
|
|
123
|
+
let plainStart = 0;
|
|
124
|
+
const flushPlain = (end: number) => {
|
|
125
|
+
if (end > plainStart) {
|
|
126
|
+
tokens.push({ type: "plain", value: source.slice(plainStart, end) });
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
while (i < source.length) {
|
|
131
|
+
let matched = false;
|
|
132
|
+
for (const rule of rules) {
|
|
133
|
+
rule.re.lastIndex = i;
|
|
134
|
+
const m = rule.re.exec(source);
|
|
135
|
+
if (m && m.index === i && m[0].length > 0) {
|
|
136
|
+
flushPlain(i);
|
|
137
|
+
const type = rule.map ? rule.map(m[0]) : rule.type;
|
|
138
|
+
tokens.push({ type, value: m[0] });
|
|
139
|
+
i += m[0].length;
|
|
140
|
+
plainStart = i;
|
|
141
|
+
matched = true;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (!matched) i += 1;
|
|
146
|
+
}
|
|
147
|
+
flushPlain(source.length);
|
|
148
|
+
return tokens;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const JS_KEYWORDS = new Set([
|
|
152
|
+
"abstract", "any", "as", "async", "await", "boolean", "break", "case",
|
|
153
|
+
"catch", "class", "const", "continue", "debugger", "declare", "default",
|
|
154
|
+
"delete", "do", "else", "enum", "export", "extends", "false", "finally",
|
|
155
|
+
"for", "from", "function", "get", "if", "implements", "import", "in",
|
|
156
|
+
"instanceof", "interface", "keyof", "let", "namespace", "never", "new",
|
|
157
|
+
"null", "number", "object", "of", "private", "protected", "public",
|
|
158
|
+
"readonly", "return", "satisfies", "set", "static", "string", "super",
|
|
159
|
+
"switch", "symbol", "this", "throw", "true", "try", "type", "typeof",
|
|
160
|
+
"undefined", "unknown", "var", "void", "while", "yield",
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
const JS_RULES: Rule[] = [
|
|
164
|
+
{ type: "comment", re: /\/\/[^\n]*/y },
|
|
165
|
+
{ type: "comment", re: /\/\*[\s\S]*?\*\//y },
|
|
166
|
+
{ type: "string", re: /`(?:\\[\s\S]|[^\\`])*`/y },
|
|
167
|
+
{ type: "string", re: /"(?:\\.|[^"\\\n])*"/y },
|
|
168
|
+
{ type: "string", re: /'(?:\\.|[^'\\\n])*'/y },
|
|
169
|
+
{
|
|
170
|
+
type: "number",
|
|
171
|
+
re: /0[xX][0-9a-fA-F]+|(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/y,
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
type: "plain",
|
|
175
|
+
re: /[A-Za-z_$][\w$]*/y,
|
|
176
|
+
map: (t) => (JS_KEYWORDS.has(t) ? "keyword" : "plain"),
|
|
177
|
+
},
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
function tokenizeJs(source: string): CodeToken[] {
|
|
181
|
+
return runRules(source, JS_RULES);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const JSON_RULES: Rule[] = [
|
|
185
|
+
{ type: "string", re: /"(?:\\.|[^"\\])*"/y },
|
|
186
|
+
{ type: "number", re: /-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/y },
|
|
187
|
+
{
|
|
188
|
+
type: "plain",
|
|
189
|
+
re: /[A-Za-z_]\w*/y,
|
|
190
|
+
map: (t) =>
|
|
191
|
+
t === "true" || t === "false" || t === "null" ? "keyword" : "plain",
|
|
192
|
+
},
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
function tokenizeJson(source: string): CodeToken[] {
|
|
196
|
+
const tokens = runRules(source, JSON_RULES);
|
|
197
|
+
// Reclassify object keys: a string whose next significant token opens with
|
|
198
|
+
// `:` is a property, not a value string.
|
|
199
|
+
for (let idx = 0; idx < tokens.length; idx += 1) {
|
|
200
|
+
if (tokens[idx].type !== "string") continue;
|
|
201
|
+
let j = idx + 1;
|
|
202
|
+
while (j < tokens.length && tokens[j].value.trim() === "") j += 1;
|
|
203
|
+
const next = tokens[j];
|
|
204
|
+
if (next && next.value.replace(/^\s*/, "").startsWith(":")) {
|
|
205
|
+
tokens[idx].type = "property";
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return tokens;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const CSS_RULES: Rule[] = [
|
|
212
|
+
{ type: "comment", re: /\/\*[\s\S]*?\*\//y },
|
|
213
|
+
{ type: "string", re: /"(?:\\.|[^"\\\n])*"/y },
|
|
214
|
+
{ type: "string", re: /'(?:\\.|[^'\\\n])*'/y },
|
|
215
|
+
{ type: "keyword", re: /@[A-Za-z-]+/y },
|
|
216
|
+
{ type: "number", re: /#[0-9a-fA-F]{3,8}\b/y },
|
|
217
|
+
{ type: "number", re: /-?(?:\d+\.?\d*|\.\d+)(?:[a-z%]+)?/y },
|
|
218
|
+
{ type: "plain", re: /[A-Za-z_-][\w-]*/y },
|
|
219
|
+
];
|
|
220
|
+
|
|
221
|
+
function tokenizeCss(source: string): CodeToken[] {
|
|
222
|
+
const tokens = runRules(source, CSS_RULES);
|
|
223
|
+
// Property detection: an identifier immediately followed by `:` counts as a
|
|
224
|
+
// declaration property only when the previous significant character opens a
|
|
225
|
+
// declaration (`{`, `}`, `;`, `,`, or start of input). This keeps selectors
|
|
226
|
+
// and pseudo-classes (`a:hover`) plain in the common cases.
|
|
227
|
+
let lastSignificant = "";
|
|
228
|
+
for (let idx = 0; idx < tokens.length; idx += 1) {
|
|
229
|
+
const tok = tokens[idx];
|
|
230
|
+
if (tok.type === "plain" && /^[A-Za-z_-][\w-]*$/.test(tok.value)) {
|
|
231
|
+
let j = idx + 1;
|
|
232
|
+
while (j < tokens.length && tokens[j].value.trim() === "") j += 1;
|
|
233
|
+
const next = tokens[j];
|
|
234
|
+
if (
|
|
235
|
+
next &&
|
|
236
|
+
next.value.replace(/^\s*/, "").startsWith(":") &&
|
|
237
|
+
(lastSignificant === "" ||
|
|
238
|
+
lastSignificant === "{" ||
|
|
239
|
+
lastSignificant === "}" ||
|
|
240
|
+
lastSignificant === ";" ||
|
|
241
|
+
lastSignificant === ",")
|
|
242
|
+
) {
|
|
243
|
+
tok.type = "property";
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const trimmed = tok.value.replace(/\s+$/, "");
|
|
247
|
+
if (trimmed) lastSignificant = trimmed[trimmed.length - 1];
|
|
248
|
+
}
|
|
249
|
+
return tokens;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const HTML_TAG_RULES: Rule[] = [
|
|
253
|
+
{ type: "tag", re: /<\/?\s*[A-Za-z][\w:-]*/y },
|
|
254
|
+
{ type: "string", re: /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/y },
|
|
255
|
+
{ type: "attr", re: /[A-Za-z_:@][\w:.-]*(?=\s*=)/y },
|
|
256
|
+
{ type: "attr", re: /[A-Za-z_:@][\w:.-]*/y },
|
|
257
|
+
{ type: "tag", re: /\/?>/y },
|
|
258
|
+
];
|
|
259
|
+
|
|
260
|
+
function tokenizeHtml(source: string): CodeToken[] {
|
|
261
|
+
const tokens: CodeToken[] = [];
|
|
262
|
+
const push = (type: CodeTokenType, value: string) => {
|
|
263
|
+
if (value) tokens.push({ type, value });
|
|
264
|
+
};
|
|
265
|
+
const n = source.length;
|
|
266
|
+
let i = 0;
|
|
267
|
+
|
|
268
|
+
while (i < n) {
|
|
269
|
+
// Comments.
|
|
270
|
+
if (source.startsWith("<!--", i)) {
|
|
271
|
+
const end = source.indexOf("-->", i + 4);
|
|
272
|
+
const stop = end === -1 ? n : end + 3;
|
|
273
|
+
push("comment", source.slice(i, stop));
|
|
274
|
+
i = stop;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
// Doctype (and other `<!…>` declarations).
|
|
278
|
+
if (source[i] === "<" && source[i + 1] === "!") {
|
|
279
|
+
const end = source.indexOf(">", i);
|
|
280
|
+
const stop = end === -1 ? n : end + 1;
|
|
281
|
+
push("tag", source.slice(i, stop));
|
|
282
|
+
i = stop;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
// Element tag.
|
|
286
|
+
if (source[i] === "<" && /[A-Za-z/]/.test(source[i + 1] || "")) {
|
|
287
|
+
const tagEnd = source.indexOf(">", i);
|
|
288
|
+
const stop = tagEnd === -1 ? n : tagEnd + 1;
|
|
289
|
+
const tagText = source.slice(i, stop);
|
|
290
|
+
for (const t of runRules(tagText, HTML_TAG_RULES)) tokens.push(t);
|
|
291
|
+
i = stop;
|
|
292
|
+
|
|
293
|
+
// Embedded <script>/<style> bodies: tokenize with the js/css tokenizer.
|
|
294
|
+
const embed = /^<\s*(script|style)\b/i.exec(tagText);
|
|
295
|
+
if (embed && !/\/>\s*$/.test(tagText)) {
|
|
296
|
+
const tag = embed[1].toLowerCase();
|
|
297
|
+
const closeRe = new RegExp("</\\s*" + tag + "\\s*>", "i");
|
|
298
|
+
const cm = closeRe.exec(source.slice(i));
|
|
299
|
+
const innerEnd = cm ? i + cm.index : n;
|
|
300
|
+
const inner = source.slice(i, innerEnd);
|
|
301
|
+
const innerTokens =
|
|
302
|
+
tag === "script" ? tokenizeJs(inner) : tokenizeCss(inner);
|
|
303
|
+
for (const t of innerTokens) tokens.push(t);
|
|
304
|
+
i = innerEnd;
|
|
305
|
+
}
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
// Text content up to the next `<` (or a lone `<` that is not a tag).
|
|
309
|
+
const lt = source.indexOf("<", i);
|
|
310
|
+
if (lt === i) {
|
|
311
|
+
push("plain", source[i]);
|
|
312
|
+
i += 1;
|
|
313
|
+
} else {
|
|
314
|
+
const stop = lt === -1 ? n : lt;
|
|
315
|
+
push("plain", source.slice(i, stop));
|
|
316
|
+
i = stop;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return tokens;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function tokenizeByLanguage(
|
|
323
|
+
source: string,
|
|
324
|
+
lang: HighlightLanguage
|
|
325
|
+
): CodeToken[] {
|
|
326
|
+
switch (lang) {
|
|
327
|
+
case "html":
|
|
328
|
+
return tokenizeHtml(source);
|
|
329
|
+
case "css":
|
|
330
|
+
return tokenizeCss(source);
|
|
331
|
+
case "js":
|
|
332
|
+
return tokenizeJs(source);
|
|
333
|
+
case "json":
|
|
334
|
+
return tokenizeJson(source);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Build one `span.persona-code-line` per source line from a token stream. */
|
|
339
|
+
function buildLines(tokens: CodeToken[]): DocumentFragment {
|
|
340
|
+
const frag = createFragment();
|
|
341
|
+
let line = createElement("span", "persona-code-line");
|
|
342
|
+
|
|
343
|
+
const appendSegment = (type: CodeTokenType, text: string) => {
|
|
344
|
+
if (!text) return;
|
|
345
|
+
const cls = TOKEN_CLASS[type];
|
|
346
|
+
if (cls) {
|
|
347
|
+
const span = createElement("span", cls);
|
|
348
|
+
span.textContent = text;
|
|
349
|
+
line.appendChild(span);
|
|
350
|
+
} else {
|
|
351
|
+
line.appendChild(document.createTextNode(text));
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
for (const tok of tokens) {
|
|
356
|
+
const parts = tok.value.split("\n");
|
|
357
|
+
for (let p = 0; p < parts.length; p += 1) {
|
|
358
|
+
if (p > 0) {
|
|
359
|
+
// Close the current line, then emit the literal newline separator that
|
|
360
|
+
// keeps `textContent` verbatim. `.persona-code` sets white-space:normal
|
|
361
|
+
// so this whitespace-only node collapses visually between block lines.
|
|
362
|
+
frag.appendChild(line);
|
|
363
|
+
frag.appendChild(document.createTextNode("\n"));
|
|
364
|
+
line = createElement("span", "persona-code-line");
|
|
365
|
+
}
|
|
366
|
+
appendSegment(tok.type, parts[p]);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (line.childNodes.length > 0) {
|
|
371
|
+
frag.appendChild(line);
|
|
372
|
+
} else if (!frag.lastChild) {
|
|
373
|
+
// Wholly empty source: keep the single empty line so the gutter shows "1".
|
|
374
|
+
frag.appendChild(line);
|
|
375
|
+
}
|
|
376
|
+
// Otherwise the source ended with a newline: the trailing "\n" separator is
|
|
377
|
+
// already in the fragment, so drop the empty phantom final line + number.
|
|
378
|
+
return frag;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Tokenize `source` and return a DocumentFragment of line spans (+ newline
|
|
383
|
+
* separator text nodes). Prefers the explicit `language`, then the `path`
|
|
384
|
+
* extension, else plain text. Sources longer than {@link MAX_HIGHLIGHT_LENGTH}
|
|
385
|
+
* are returned as plain line-numbered text (still gutter-numbered, no colors).
|
|
386
|
+
*/
|
|
387
|
+
export function highlightCode(
|
|
388
|
+
source: string,
|
|
389
|
+
language?: string,
|
|
390
|
+
path?: string
|
|
391
|
+
): DocumentFragment {
|
|
392
|
+
const lang =
|
|
393
|
+
source.length <= MAX_HIGHLIGHT_LENGTH
|
|
394
|
+
? resolveHighlightLanguage(language, path)
|
|
395
|
+
: null;
|
|
396
|
+
const tokens: CodeToken[] = lang
|
|
397
|
+
? tokenizeByLanguage(source, lang)
|
|
398
|
+
: [{ type: "plain", value: source }];
|
|
399
|
+
return buildLines(tokens);
|
|
400
|
+
}
|