@zenodinh/pi-render 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +110 -2
- package/index.ts +279 -0
- package/package.json +66 -5
- package/src/commands/canvas.test.ts +150 -0
- package/src/commands/canvas.ts +57 -0
- package/src/core/code-theme.test.ts +266 -0
- package/src/core/code-theme.ts +275 -0
- package/src/core/log.test.ts +127 -0
- package/src/core/log.ts +57 -0
- package/src/core/paint.test.ts +322 -0
- package/src/core/paint.ts +64 -0
- package/src/core/registry.test.ts +307 -0
- package/src/core/registry.ts +135 -0
- package/src/core/settings.test.ts +183 -0
- package/src/core/settings.ts +119 -0
- package/src/core/types/code-theme.ts +24 -0
- package/src/core/types/host.ts +160 -0
- package/src/core/types/log.ts +28 -0
- package/src/core/types/paint.ts +54 -0
- package/src/core/types/registry.ts +44 -0
- package/src/core/types/settings.ts +31 -0
- package/src/core/types.ts +34 -0
- package/src/renderers/content/artifacts/artifacts.test.ts +199 -0
- package/src/renderers/content/artifacts/cache.ts +234 -0
- package/src/renderers/content/artifacts/cards.test.ts +216 -0
- package/src/renderers/content/artifacts/cards.ts +136 -0
- package/src/renderers/content/artifacts/engines-extra.test.ts +556 -0
- package/src/renderers/content/artifacts/engines.ts +396 -0
- package/src/renderers/content/artifacts/local-binary.test.ts +207 -0
- package/src/renderers/content/artifacts/local-binary.ts +80 -0
- package/src/renderers/content/artifacts/prereqs.ts +128 -0
- package/src/renderers/content/artifacts/server.ts +181 -0
- package/src/renderers/content/code-panel.ts +161 -0
- package/src/renderers/content/image-card.test.ts +170 -0
- package/src/renderers/content/image-card.ts +252 -0
- package/src/renderers/content/index.ts +79 -0
- package/src/renderers/content/json-panel.ts +116 -0
- package/src/renderers/content/panels.test.ts +188 -0
- package/src/renderers/content/table.test.ts +209 -0
- package/src/renderers/content/table.ts +174 -0
- package/src/renderers/content/transformer.test.ts +254 -0
- package/src/renderers/content/types.ts +20 -0
- package/src/renderers/tool/index.ts +113 -0
- package/src/renderers/tool/resolver.test.ts +257 -0
- package/src/renderers/tool/runtime.test.ts +313 -0
- package/src/renderers/tool/runtime.ts +267 -0
- package/src/renderers/tool/specs/bash.test.ts +110 -0
- package/src/renderers/tool/specs/bash.ts +168 -0
- package/src/renderers/tool/specs/codemode.test.ts +212 -0
- package/src/renderers/tool/specs/codemode.ts +248 -0
- package/src/renderers/tool/specs/edit.test.ts +260 -0
- package/src/renderers/tool/specs/edit.ts +213 -0
- package/src/renderers/tool/specs/ls.test.ts +173 -0
- package/src/renderers/tool/specs/ls.ts +136 -0
- package/src/renderers/tool/specs/read.test.ts +340 -0
- package/src/renderers/tool/specs/read.ts +296 -0
- package/src/renderers/tool/specs/search.test.ts +197 -0
- package/src/renderers/tool/specs/search.ts +325 -0
- package/src/renderers/tool/specs/write.test.ts +145 -0
- package/src/renderers/tool/specs/write.ts +142 -0
- package/src/renderers/tool/types.ts +45 -0
- package/themes/dracula-soft.json +81 -0
- package/themes/one-dark.json +80 -0
- package/themes/themes.test.ts +251 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// ported from pi-pretty-tui/src/features/canvas/index.ts:90-100 — survives because: the predecessor's
|
|
2
|
+
// /canvas registerCommand shape — a thin handler that opens the artifact through an injected seam and
|
|
3
|
+
// notifies instead of throwing when it cannot — is exactly the command contract pi-render needs. Its
|
|
4
|
+
// shortcuts, cmux routing, warmup and cache-dir plumbing stay behind (registration-adjacent chrome).
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* canvas.ts — the package's one command: /canvas opens the artifact browser, or reports its status.
|
|
8
|
+
*
|
|
9
|
+
* Boundary: `ctx` arrives from the host and is read as core/types/host.ts declares it — nothing here
|
|
10
|
+
* narrows or re-declares a host shape. The injected `deps.registry` is the command's ONLY channel to
|
|
11
|
+
* the rest of the package (SA §7), and it is read-only: /canvas reports module state and never writes
|
|
12
|
+
* it, so a disabled module's command cannot act anyway.
|
|
13
|
+
*
|
|
14
|
+
* Why `canvasView` is injected: `index.ts` (T-28) is the only file that composes lanes, so the real
|
|
15
|
+
* browser arrives as a parameter; this module never imports a renderer and its tests drive a fake view.
|
|
16
|
+
*
|
|
17
|
+
* shape: none — trigger #1 dispatch object does not apply: one command, two guards (host mode, module
|
|
18
|
+
* enabled) resolved in sequence, no per-kind handler table.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { CommandContext, ExtensionApi, Registry } from "../core/types.ts";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The content lane's artifacts module key (SA §6). A literal, not an import: `commands/` may not
|
|
25
|
+
* reach into a renderer lane for its constant (SA §1 rule 3).
|
|
26
|
+
*/
|
|
27
|
+
const ARTIFACTS_KEY = "content.artifacts";
|
|
28
|
+
|
|
29
|
+
/** What /canvas needs from its callers: module state to report, and the view to open. */
|
|
30
|
+
export interface CommandsDeps {
|
|
31
|
+
/** Registry the command reads, e.g. isEnabled("content.artifacts"). Required; never written here. */
|
|
32
|
+
readonly registry: Registry;
|
|
33
|
+
/** Opens the artifact browser. Required; index.ts injects the real view, tests a fake one. */
|
|
34
|
+
readonly canvasView: (ctx: CommandContext) => Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** shape: none — one interpolated line; a status table would restate the same registry read. */
|
|
38
|
+
function summarize(mode: CommandContext["mode"], enabled: boolean): string {
|
|
39
|
+
if (!enabled) return "canvas: content.artifacts is disabled — enable it to open the artifact browser";
|
|
40
|
+
return `canvas: content.artifacts is enabled — the artifact browser needs a TUI host (mode: ${mode})`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** shape: none — one guarded registration; the handler below is this module's only command. */
|
|
44
|
+
export function installCommands(pi: ExtensionApi, deps: CommandsDeps): void {
|
|
45
|
+
pi.registerCommand("canvas", {
|
|
46
|
+
description: "canvas: open the artifact browser, or report its status",
|
|
47
|
+
handler: async (_args, ctx) => {
|
|
48
|
+
const enabled = deps.registry.isEnabled(ARTIFACTS_KEY);
|
|
49
|
+
// A non-TUI host cannot draw the view and a disabled module has nothing to open: both report.
|
|
50
|
+
if (ctx.mode !== "tui" || !enabled) {
|
|
51
|
+
ctx.ui.notify(summarize(ctx.mode, enabled));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
await deps.canvasView(ctx);
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* code-theme.test.ts — the spec of createCodeTheme at its seam, with a counting engine double.
|
|
3
|
+
*
|
|
4
|
+
* Spec: createCodeTheme(bindEngine, {registry, log}) resolves `codeTheme` to a bundled name (unknown →
|
|
5
|
+
* dracula-soft + one log line), highlights once per theme+lang+code, never hands the engine an empty or
|
|
6
|
+
* over-80k block, and returns plain source lines when the engine or the bind throws.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import { type BindHighlightEngine, CODE_THEME_MODULE, type CodeThemeName, createCodeTheme } from "./code-theme.ts";
|
|
11
|
+
|
|
12
|
+
// invented: the engine is faked, so the block only has to be two stable lines.
|
|
13
|
+
const CODE = "const a = 1;\nlet b = 2;";
|
|
14
|
+
|
|
15
|
+
/** One engine invocation: the theme it was bound to, plus the block it was asked for. */
|
|
16
|
+
interface EngineCall {
|
|
17
|
+
theme: CodeThemeName;
|
|
18
|
+
code: string;
|
|
19
|
+
lang: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Engine double that counts: it records every call and stamps its bound theme into the lines, so the
|
|
24
|
+
* resolved theme is visible in the output rather than only in the bind argument.
|
|
25
|
+
*/
|
|
26
|
+
function countingEngine(options: { throwOnBind?: boolean; throwOnCall?: boolean } = {}): {
|
|
27
|
+
bind: BindHighlightEngine;
|
|
28
|
+
calls: EngineCall[];
|
|
29
|
+
} {
|
|
30
|
+
const calls: EngineCall[] = [];
|
|
31
|
+
const bind: BindHighlightEngine = (theme) => {
|
|
32
|
+
if (options.throwOnBind) throw new Error("bind failed");
|
|
33
|
+
return (code, lang) => {
|
|
34
|
+
calls.push({ theme, code, lang });
|
|
35
|
+
if (options.throwOnCall) throw new Error("engine failed");
|
|
36
|
+
return code.split("\n").map((line) => `«${theme}»${line}`);
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
return { bind, calls };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Logger double: logOnce's once-per-key rule plus the readout, so "exactly one line" is observable. */
|
|
43
|
+
function logDouble(): {
|
|
44
|
+
logOnce(key: string, scope: string, message: string): void;
|
|
45
|
+
entries: Array<{ key: string; scope: string; message: string }>;
|
|
46
|
+
attempts: number;
|
|
47
|
+
} {
|
|
48
|
+
const entries: Array<{ key: string; scope: string; message: string }> = [];
|
|
49
|
+
const seen = new Set<string>();
|
|
50
|
+
const double = {
|
|
51
|
+
entries,
|
|
52
|
+
attempts: 0,
|
|
53
|
+
logOnce(key: string, scope: string, message: string): void {
|
|
54
|
+
double.attempts += 1;
|
|
55
|
+
if (seen.has(key)) return;
|
|
56
|
+
seen.add(key);
|
|
57
|
+
entries.push({ key, scope, message });
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
return double;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Settings double over the injected contract. invented: the slice is contracted as
|
|
65
|
+
* `Record<string, unknown>`, so any bag is a legal stored document; `set` models the registry's
|
|
66
|
+
* "a write lands on the next read".
|
|
67
|
+
*/
|
|
68
|
+
function registryDouble(initial: Record<string, unknown> = {}): {
|
|
69
|
+
getSettings(key: string): Record<string, unknown>;
|
|
70
|
+
set(patch: Record<string, unknown>): void;
|
|
71
|
+
} {
|
|
72
|
+
const slices = new Map<string, Record<string, unknown>>([["codetheme", { ...initial }]]);
|
|
73
|
+
return {
|
|
74
|
+
getSettings: (key) => slices.get(key) ?? {},
|
|
75
|
+
set: (patch) => slices.set("codetheme", { ...(slices.get("codetheme") ?? {}), ...patch }),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("createCodeTheme — theme resolution", () => {
|
|
80
|
+
it("AC-1: an unknown codeTheme renders the default theme and logs exactly one line", async () => {
|
|
81
|
+
const engine = countingEngine();
|
|
82
|
+
const log = logDouble();
|
|
83
|
+
// invented: the setting is persisted JSON, so an out-of-set name is the exact input AC-1 names.
|
|
84
|
+
const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "nope" }), log });
|
|
85
|
+
|
|
86
|
+
const first = await theme.highlight(CODE, "ts");
|
|
87
|
+
const second = await theme.highlight(CODE, "ts");
|
|
88
|
+
const joined = theme.highlightSync(CODE, "ts");
|
|
89
|
+
|
|
90
|
+
// fails_when: a crash, empty output, or one log line per call.
|
|
91
|
+
expect(first).toEqual(["«dracula-soft»const a = 1;", "«dracula-soft»let b = 2;"]);
|
|
92
|
+
expect([second, joined]).toEqual([first, first.join("\n")]);
|
|
93
|
+
expect(engine.calls.map((call) => call.theme)).toEqual(["dracula-soft"]);
|
|
94
|
+
expect(log.entries.map((entry) => entry.key)).toEqual(["theme:nope"]);
|
|
95
|
+
expect(log.entries[0]?.message).toContain("dracula-soft");
|
|
96
|
+
expect(log.attempts).toBe(3);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("AC-1: a bundled non-default theme is rendered without a log line", async () => {
|
|
100
|
+
const engine = countingEngine();
|
|
101
|
+
const log = logDouble();
|
|
102
|
+
const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "one-dark-pro" }), log });
|
|
103
|
+
|
|
104
|
+
// fails_when: the setting is ignored and the default theme always renders.
|
|
105
|
+
expect(await theme.highlight(CODE, "ts")).toEqual(["«one-dark-pro»const a = 1;", "«one-dark-pro»let b = 2;"]);
|
|
106
|
+
expect(log.entries).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("AC-1: an absent setting reads the default theme silently", async () => {
|
|
110
|
+
const engine = countingEngine();
|
|
111
|
+
const log = logDouble();
|
|
112
|
+
const theme = createCodeTheme(engine.bind, { registry: registryDouble(), log });
|
|
113
|
+
|
|
114
|
+
// fails_when: a fresh install logs a fallback line on every render.
|
|
115
|
+
expect(await theme.highlight(CODE, "ts")).toEqual(["«dracula-soft»const a = 1;", "«dracula-soft»let b = 2;"]);
|
|
116
|
+
expect(log.entries).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("AC-1: the descriptor is exported for T-28, and the injected registry has no write route", async () => {
|
|
120
|
+
// The injected registry shape is read-only (getSettings only), so a registration here cannot compile.
|
|
121
|
+
expect(CODE_THEME_MODULE).toEqual({
|
|
122
|
+
key: "codetheme",
|
|
123
|
+
name: "Code theme",
|
|
124
|
+
defaultEnabled: true,
|
|
125
|
+
settings: [
|
|
126
|
+
{ name: "codeTheme", kind: "enum", values: ["dracula-soft", "one-dark-pro"], default: "dracula-soft" },
|
|
127
|
+
],
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("createCodeTheme — cache", () => {
|
|
133
|
+
it("AC-2: the same theme+lang+code highlights once", async () => {
|
|
134
|
+
const engine = countingEngine();
|
|
135
|
+
const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "dracula-soft" }) });
|
|
136
|
+
|
|
137
|
+
const first = await theme.highlight(CODE, "ts");
|
|
138
|
+
const second = await theme.highlight(CODE, "ts");
|
|
139
|
+
|
|
140
|
+
// fails_when: every call re-highlights (the #21 defect class).
|
|
141
|
+
expect(second).toEqual(first);
|
|
142
|
+
expect(engine.calls).toHaveLength(1);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("AC-2: lang and theme are part of the key", async () => {
|
|
146
|
+
const engine = countingEngine();
|
|
147
|
+
const registry = registryDouble({ codeTheme: "dracula-soft" });
|
|
148
|
+
const theme = createCodeTheme(engine.bind, { registry });
|
|
149
|
+
|
|
150
|
+
await theme.highlight(CODE, "ts");
|
|
151
|
+
await theme.highlight(CODE, "py");
|
|
152
|
+
registry.set({ codeTheme: "one-dark-pro" });
|
|
153
|
+
await theme.highlight(CODE, "ts");
|
|
154
|
+
|
|
155
|
+
// fails_when: the key ignores lang or theme and serves the previous theme's colours.
|
|
156
|
+
expect(engine.calls.map((call) => `${call.theme}:${call.lang}`)).toEqual([
|
|
157
|
+
"dracula-soft:ts",
|
|
158
|
+
"dracula-soft:py",
|
|
159
|
+
"one-dark-pro:ts",
|
|
160
|
+
]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("AC-2: highlightSync shares the cache with highlight", async () => {
|
|
164
|
+
const engine = countingEngine();
|
|
165
|
+
const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "dracula-soft" }) });
|
|
166
|
+
|
|
167
|
+
await theme.highlight(CODE, "ts");
|
|
168
|
+
const joined = theme.highlightSync(CODE, "ts");
|
|
169
|
+
|
|
170
|
+
// fails_when: the panel path keeps a second cache and highlights the same block again.
|
|
171
|
+
expect(joined).toBe("«dracula-soft»const a = 1;\n«dracula-soft»let b = 2;");
|
|
172
|
+
expect(engine.calls).toHaveLength(1);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("AC-2: the cache is bounded at 128 entries and a hit counts as recent use", async () => {
|
|
176
|
+
const engine = countingEngine();
|
|
177
|
+
const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "dracula-soft" }) });
|
|
178
|
+
|
|
179
|
+
for (let i = 0; i < 128; i += 1) await theme.highlight(`line ${i}`, "ts");
|
|
180
|
+
await theme.highlight("line 0", "ts");
|
|
181
|
+
await theme.highlight("line 128", "ts");
|
|
182
|
+
expect(engine.calls).toHaveLength(129); // the 129th entry evicted the oldest untouched one
|
|
183
|
+
|
|
184
|
+
// fails_when: the cache grows without bound, or a hit does not count as recent use.
|
|
185
|
+
expect(await theme.highlight("line 0", "ts")).toEqual(["«dracula-soft»line 0"]);
|
|
186
|
+
expect(engine.calls).toHaveLength(129);
|
|
187
|
+
expect(await theme.highlight("line 1", "ts")).toEqual(["«dracula-soft»line 1"]);
|
|
188
|
+
expect(engine.calls).toHaveLength(130);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("createCodeTheme — what never reaches the engine", () => {
|
|
193
|
+
it("AC-3: a block past 80k characters returns its own plain lines", async () => {
|
|
194
|
+
const engine = countingEngine();
|
|
195
|
+
const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "dracula-soft" }) });
|
|
196
|
+
// invented: one character past the ported 80_000 gate (P render.ts:86).
|
|
197
|
+
const over = `x${"y".repeat(80_000)}`;
|
|
198
|
+
|
|
199
|
+
// fails_when: a blob this size enters shiki.
|
|
200
|
+
expect(await theme.highlight(over, "ts")).toEqual([over]);
|
|
201
|
+
expect(engine.calls).toEqual([]);
|
|
202
|
+
|
|
203
|
+
// the gate is strict: exactly 80_000 characters reach the engine.
|
|
204
|
+
await theme.highlight("y".repeat(80_000), "ts");
|
|
205
|
+
expect(engine.calls).toHaveLength(1);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("AC-3: empty code returns one empty line", async () => {
|
|
209
|
+
const engine = countingEngine();
|
|
210
|
+
const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "dracula-soft" }) });
|
|
211
|
+
|
|
212
|
+
// fails_when: an empty block enters shiki, or the panel path emits a stray newline.
|
|
213
|
+
expect(await theme.highlight("", "ts")).toEqual([""]);
|
|
214
|
+
expect(theme.highlightSync("", "ts")).toBe("");
|
|
215
|
+
expect(engine.calls).toEqual([]);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("createCodeTheme — degradation", () => {
|
|
220
|
+
it("AC-4: a throwing engine returns plain lines and nothing escapes", async () => {
|
|
221
|
+
const engine = countingEngine({ throwOnCall: true });
|
|
222
|
+
const log = logDouble();
|
|
223
|
+
const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "dracula-soft" }), log });
|
|
224
|
+
|
|
225
|
+
// fails_when: a shiki failure kills a render.
|
|
226
|
+
await expect(theme.highlight(CODE, "ts")).resolves.toEqual(["const a = 1;", "let b = 2;"]);
|
|
227
|
+
expect(theme.highlightSync(CODE, "ts")).toBe("const a = 1;\nlet b = 2;");
|
|
228
|
+
expect(log.entries.map((entry) => entry.key)).toEqual(["fail:ts"]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("AC-4: a throwing engine bind degrades the same way", async () => {
|
|
232
|
+
const engine = countingEngine({ throwOnBind: true });
|
|
233
|
+
const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "dracula-soft" }) });
|
|
234
|
+
|
|
235
|
+
// fails_when: a bind failure (shiki not loaded yet, a bad theme) escapes into the render path.
|
|
236
|
+
await expect(theme.highlight(CODE, "ts")).resolves.toEqual(["const a = 1;", "let b = 2;"]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("AC-4: an engine with no registry and no logger still highlights and still degrades", async () => {
|
|
240
|
+
const engine = countingEngine();
|
|
241
|
+
const theme = createCodeTheme(engine.bind);
|
|
242
|
+
|
|
243
|
+
// fails_when: the optional collaborators are required, so a bare construction throws.
|
|
244
|
+
expect(await theme.highlight(CODE, "ts")).toEqual(["«dracula-soft»const a = 1;", "«dracula-soft»let b = 2;"]);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("createCodeTheme — contrast normalization (P render.ts:112-140)", () => {
|
|
249
|
+
it("drops a foreground below the readability floor and keeps the rest", async () => {
|
|
250
|
+
// invented: RGB triples chosen around the 72 luminance floor (20 below, 80 just above) and the
|
|
251
|
+
// indexed-black form P treated as dark.
|
|
252
|
+
const DIM = "\u001b[38;2;20;20;20m";
|
|
253
|
+
const INDEXED = "\u001b[38;5;0m";
|
|
254
|
+
const READABLE = "\u001b[38;2;80;80;80m";
|
|
255
|
+
const RESET = "\u001b[0m";
|
|
256
|
+
const bind: BindHighlightEngine = () => () => [
|
|
257
|
+
`${DIM}dim${RESET}`,
|
|
258
|
+
`${INDEXED}indexed${RESET}`,
|
|
259
|
+
`${READABLE}readable${RESET}`,
|
|
260
|
+
];
|
|
261
|
+
const theme = createCodeTheme(bind, { registry: registryDouble({ codeTheme: "dracula-soft" }) });
|
|
262
|
+
|
|
263
|
+
// fails_when: an unreadable near-black token survives to the terminal.
|
|
264
|
+
expect(await theme.highlight("x", "ts")).toEqual([`dim${RESET}`, `indexed${RESET}`, `${READABLE}readable${RESET}`]);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// ported from pi-pretty-tui/src/core/render.ts:48-140 — survives because: the theme fallback with one log
|
|
2
|
+
// line, the LRU keyed theme+lang+code, the 80k size gate and the low-contrast fg normalization are the one
|
|
3
|
+
// place a highlight decision is made.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* code-theme.ts — syntax highlighting behind two calls: rows await `highlight`, panels call `highlightSync`;
|
|
7
|
+
* one cache serves both, so a block is highlighted once per theme.
|
|
8
|
+
*
|
|
9
|
+
* Boundary: `codeTheme` arrives from the injected registry as persisted JSON and is narrowed here before it
|
|
10
|
+
* is read; diagnostics go to the injected logOnce sink. The shiki adapter below ports
|
|
11
|
+
* pi-pretty-tui/src/features/canvas/code-panel.ts:16-140 (warm sync core, JavaScript regex engine, the
|
|
12
|
+
* predecessor's grammar set) and returns shiki's own token colours — that is the highlighting; the package
|
|
13
|
+
* palette stays in paint.ts.
|
|
14
|
+
*
|
|
15
|
+
* shape: none — dispatch object / Strategy do not apply: two factories over one cache and one warm core,
|
|
16
|
+
* with no discriminator to dispatch on. Each factory declares its own shape below.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { BundledLanguage } from "shiki";
|
|
20
|
+
import type { Logger } from "./types/log.ts";
|
|
21
|
+
import type { ModuleDescriptor, Registry } from "./types/registry.ts";
|
|
22
|
+
import type { CodeTheme, HighlightEngine } from "./types.ts";
|
|
23
|
+
|
|
24
|
+
/** Bundled theme set: the predecessor's five trimmed to the two the host themes ship (PRD EB-1). */
|
|
25
|
+
const THEMES = ["dracula-soft", "one-dark-pro"] as const;
|
|
26
|
+
|
|
27
|
+
/** A bundled theme name — the closed set a `codeTheme` setting is allowed to name. */
|
|
28
|
+
export type CodeThemeName = (typeof THEMES)[number];
|
|
29
|
+
|
|
30
|
+
const MODULE_KEY = "codetheme";
|
|
31
|
+
const SETTING = "codeTheme";
|
|
32
|
+
const SCOPE = "code-theme";
|
|
33
|
+
const DEFAULT_THEME: CodeThemeName = "dracula-soft";
|
|
34
|
+
|
|
35
|
+
/** The descriptor T-28 registers for the /render panel; exported here, never registered here. */
|
|
36
|
+
export const CODE_THEME_MODULE: ModuleDescriptor = {
|
|
37
|
+
key: MODULE_KEY,
|
|
38
|
+
name: "Code theme",
|
|
39
|
+
defaultEnabled: true,
|
|
40
|
+
settings: [{ name: SETTING, kind: "enum", values: [...THEMES], default: DEFAULT_THEME }],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Bounded by entries, not bytes: one entry is at most MAX_HL_CHARS, and the cap is the predecessor's. */
|
|
44
|
+
const CACHE_LIMIT = 128;
|
|
45
|
+
/** Past this size highlighting costs more than it is worth (P render.ts:86). */
|
|
46
|
+
const MAX_HL_CHARS = 80_000;
|
|
47
|
+
/** Readability floor for a token colour over the host background (P render.ts:124). */
|
|
48
|
+
const MIN_FG_LUMINANCE = 72;
|
|
49
|
+
|
|
50
|
+
/** Settings read plus the diagnostics sink — the whole injected surface, so a test double is three lines. */
|
|
51
|
+
export interface CodeThemeOptions {
|
|
52
|
+
/** Effective settings by module key. Optional; absent reads the default theme. */
|
|
53
|
+
registry?: Pick<Registry, "getSettings">;
|
|
54
|
+
/** Once-per-key diagnostics. Optional; absent is silent. */
|
|
55
|
+
log?: Pick<Logger, "logOnce">;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Binds the engine to one theme: production memoizes per theme over one warm core, tests count the calls. */
|
|
59
|
+
export type BindHighlightEngine = (theme: CodeThemeName) => HighlightEngine;
|
|
60
|
+
|
|
61
|
+
/** boundary: `codeTheme` is persisted JSON, so only a bundled name is trusted; anything else falls back. */
|
|
62
|
+
function isCodeThemeName(value: unknown): value is CodeThemeName {
|
|
63
|
+
return typeof value === "string" && THEMES.some((theme) => theme === value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// shape: closure returning an object literal — trigger #4, two methods over one Map, no subclassing.
|
|
67
|
+
export function createCodeTheme(bindEngine: BindHighlightEngine, options: CodeThemeOptions = {}): CodeTheme {
|
|
68
|
+
const cache = new Map<string, string[]>();
|
|
69
|
+
|
|
70
|
+
/** LRU: a hit moves to the tail, the head goes once the cap is passed. */
|
|
71
|
+
const remember = (key: string, lines: string[]): string[] => {
|
|
72
|
+
cache.delete(key);
|
|
73
|
+
cache.set(key, lines);
|
|
74
|
+
if (cache.size > CACHE_LIMIT) {
|
|
75
|
+
const oldest = cache.keys().next().value;
|
|
76
|
+
if (oldest !== undefined) cache.delete(oldest);
|
|
77
|
+
}
|
|
78
|
+
return lines;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Read per call: a settings write lands on the next render, and an unknown name falls back exactly once. */
|
|
82
|
+
const resolveTheme = (): CodeThemeName => {
|
|
83
|
+
const stored: unknown = options.registry?.getSettings(MODULE_KEY)[SETTING];
|
|
84
|
+
if (isCodeThemeName(stored)) return stored;
|
|
85
|
+
if (stored !== undefined) {
|
|
86
|
+
options.log?.logOnce(
|
|
87
|
+
`theme:${String(stored)}`,
|
|
88
|
+
SCOPE,
|
|
89
|
+
`unknown code theme "${String(stored)}" — rendering "${DEFAULT_THEME}"`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return DEFAULT_THEME;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Engine output behind the cache; the size gate and any engine failure fall through to plain source lines. */
|
|
96
|
+
const lines = (code: string, lang: string): string[] => {
|
|
97
|
+
if (!code) return [""];
|
|
98
|
+
if (code.length > MAX_HL_CHARS) return code.split("\n");
|
|
99
|
+
const theme = resolveTheme();
|
|
100
|
+
const key = `${theme}\0${lang}\0${code}`;
|
|
101
|
+
const hit = cache.get(key);
|
|
102
|
+
if (hit !== undefined) return remember(key, hit);
|
|
103
|
+
try {
|
|
104
|
+
return remember(key, bindEngine(theme)(code, lang).map(dropLowContrastFg));
|
|
105
|
+
} catch {
|
|
106
|
+
// Shiki fails on a grammar, a theme or a broken engine; a row degrades, it never dies.
|
|
107
|
+
options.log?.logOnce(`fail:${lang}`, SCOPE, `highlight failed for "${lang}" — rendering plain`);
|
|
108
|
+
return code.split("\n");
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
/** Async for the row seam; the engine itself is synchronous. */
|
|
114
|
+
highlight: async (code, lang) => lines(code, lang),
|
|
115
|
+
/** The panel seam: the same cache and engine, joined into one block. */
|
|
116
|
+
highlightSync: (code, lang) => lines(code, lang).join("\n"),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Shiki adapter
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The ESC byte, built rather than written: a colour literal belongs to paint.ts, and the T-29 scan keeps
|
|
126
|
+
* escape literals on that one owner.
|
|
127
|
+
*/
|
|
128
|
+
const ESC = String.fromCharCode(27);
|
|
129
|
+
const RESET = `${ESC}[0m`;
|
|
130
|
+
const SGR = new RegExp(`${ESC}\\[([0-9;]*)m`, "g");
|
|
131
|
+
|
|
132
|
+
/** A fence spelling the grammars below register under a different id (P code-panel.ts:73-88). */
|
|
133
|
+
const LANG_ALIASES = new Map<string, string>([
|
|
134
|
+
["ts", "typescript"],
|
|
135
|
+
["js", "javascript"],
|
|
136
|
+
["py", "python"],
|
|
137
|
+
["sh", "bash"],
|
|
138
|
+
["shell", "bash"],
|
|
139
|
+
["zsh", "bash"],
|
|
140
|
+
["yml", "yaml"],
|
|
141
|
+
["md", "markdown"],
|
|
142
|
+
["golang", "go"],
|
|
143
|
+
["c++", "cpp"],
|
|
144
|
+
["c#", "csharp"],
|
|
145
|
+
["rs", "rust"],
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The grammars worth carrying — the predecessor's set (P code-panel.ts:32-62), by shiki id. Typed against
|
|
150
|
+
* shiki's own union, so an id the bundle does not carry fails the type-check instead of at first render.
|
|
151
|
+
*/
|
|
152
|
+
const LANG_IDS: readonly BundledLanguage[] = [
|
|
153
|
+
"typescript",
|
|
154
|
+
"tsx",
|
|
155
|
+
"javascript",
|
|
156
|
+
"jsx",
|
|
157
|
+
"python",
|
|
158
|
+
"java",
|
|
159
|
+
"json",
|
|
160
|
+
"yaml",
|
|
161
|
+
"markdown",
|
|
162
|
+
"bash",
|
|
163
|
+
"sql",
|
|
164
|
+
"xml",
|
|
165
|
+
"html",
|
|
166
|
+
"css",
|
|
167
|
+
"go",
|
|
168
|
+
"rust",
|
|
169
|
+
"diff",
|
|
170
|
+
"toml",
|
|
171
|
+
"ini",
|
|
172
|
+
"dockerfile",
|
|
173
|
+
"csharp",
|
|
174
|
+
"cpp",
|
|
175
|
+
"kotlin",
|
|
176
|
+
"php",
|
|
177
|
+
"ruby",
|
|
178
|
+
"swift",
|
|
179
|
+
"lua",
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
/** One foreground SGR too dark to read over the host background (P render.ts:112-125). */
|
|
183
|
+
function isLowContrastFg(params: string): boolean {
|
|
184
|
+
if (params === "30" || params === "90" || params === "38;5;0" || params === "38;5;8") return true;
|
|
185
|
+
if (!params.startsWith("38;2;")) return false;
|
|
186
|
+
const channels = params.slice("38;2;".length).split(";").map(Number);
|
|
187
|
+
if (channels.length !== 3 || channels.some((n) => !Number.isFinite(n))) return false;
|
|
188
|
+
const [r, g, b] = channels;
|
|
189
|
+
if (r === undefined || g === undefined || b === undefined) return false;
|
|
190
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b < MIN_FG_LUMINANCE;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Drops such a foreground instead of substituting one: the row then renders in the theme's own foreground,
|
|
195
|
+
* which is the readable default, and no second palette appears outside paint.ts.
|
|
196
|
+
*/
|
|
197
|
+
function dropLowContrastFg(line: string): string {
|
|
198
|
+
return line.replace(SGR, (sequence, params: string) => (isLowContrastFg(params) ? "" : sequence));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The SGR for one token: its colour plus the font styles shiki reports as bit flags (P code-panel.ts:106-118). */
|
|
202
|
+
function tokenSgr(token: { color?: string; fontStyle?: number }): string {
|
|
203
|
+
const parts: string[] = [];
|
|
204
|
+
if (typeof token.color === "string" && /^#[0-9a-f]{6}$/i.test(token.color)) {
|
|
205
|
+
const value = Number.parseInt(token.color.slice(1), 16);
|
|
206
|
+
parts.push(`38;2;${(value >> 16) & 0xff};${(value >> 8) & 0xff};${value & 0xff}`);
|
|
207
|
+
}
|
|
208
|
+
const style = token.fontStyle ?? 0;
|
|
209
|
+
if (style & 1) parts.push("3");
|
|
210
|
+
if (style & 2) parts.push("1");
|
|
211
|
+
if (style & 4) parts.push("4");
|
|
212
|
+
return parts.length > 0 ? `${ESC}[${parts.join(";")}m` : "";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** One line of tokens to one SGR string; the reset after non-blank content stops a colour bleeding onward. */
|
|
216
|
+
function tokensToAnsi(tokens: readonly { content: string; color?: string; fontStyle?: number }[]): string {
|
|
217
|
+
let out = RESET;
|
|
218
|
+
for (const token of tokens) {
|
|
219
|
+
out += tokenSgr(token) + token.content + (token.content.trim() === "" ? "" : RESET);
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// shape: closure returning a function — trigger #4 (one warm core plus a per-theme memo, no subclassing);
|
|
225
|
+
// the core itself is built lazily, so a process that never highlights never pays for shiki.
|
|
226
|
+
export async function createShikiEngine(log?: Pick<Logger, "logOnce">): Promise<BindHighlightEngine> {
|
|
227
|
+
// The bundled registries come from shiki's own entry points, not from @shikijs/langs: only `shiki` is a
|
|
228
|
+
// declared dependency of this package (package.json).
|
|
229
|
+
const [core, regex, bundledLangs, bundledThemes] = await Promise.all([
|
|
230
|
+
import("shiki/core"),
|
|
231
|
+
import("shiki/engine/javascript"),
|
|
232
|
+
import("shiki/langs"),
|
|
233
|
+
import("shiki/themes"),
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
// A grammar that failed to load costs that language only; the rest of the set still highlights.
|
|
237
|
+
const registrations = await Promise.all(
|
|
238
|
+
LANG_IDS.map(async (id) => (await bundledLangs.bundledLanguages[id]().catch(() => undefined))?.default),
|
|
239
|
+
);
|
|
240
|
+
const themes = await Promise.all(THEMES.map(async (name) => (await bundledThemes.bundledThemes[name]()).default));
|
|
241
|
+
const highlighter = core.createHighlighterCoreSync({
|
|
242
|
+
themes,
|
|
243
|
+
langs: registrations.filter((registration) => registration !== undefined),
|
|
244
|
+
engine: regex.createJavaScriptRegexEngine(),
|
|
245
|
+
});
|
|
246
|
+
const known = new Set(highlighter.getLoadedLanguages());
|
|
247
|
+
|
|
248
|
+
const plain = (code: string): string[] => code.split("\n");
|
|
249
|
+
|
|
250
|
+
const highlight = (code: string, lang: string, theme: CodeThemeName): string[] => {
|
|
251
|
+
// A Map lookup, not a bare object index: `lang` is caller input, so a prototype key must not resolve.
|
|
252
|
+
const id = LANG_ALIASES.get(lang) ?? lang;
|
|
253
|
+
if (!known.has(id)) {
|
|
254
|
+
log?.logOnce(`lang:${id}`, SCOPE, `unknown language "${id}" — rendering plain`);
|
|
255
|
+
return plain(code);
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
return highlighter.codeToTokensBase(code, { lang: id, theme }).map(tokensToAnsi);
|
|
259
|
+
} catch {
|
|
260
|
+
// A grammar the engine rejects degrades that block, exactly as the predecessor's panel did.
|
|
261
|
+
log?.logOnce(`lang:${id}`, SCOPE, `highlight failed for "${id}" — rendering plain`);
|
|
262
|
+
return plain(code);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
// One bound engine per theme: the low-level call takes the theme, the seam's type does not.
|
|
267
|
+
const bound = new Map<CodeThemeName, HighlightEngine>();
|
|
268
|
+
return (theme) => {
|
|
269
|
+
const engine = bound.get(theme);
|
|
270
|
+
if (engine !== undefined) return engine;
|
|
271
|
+
const boundEngine: HighlightEngine = (code, lang) => highlight(code, lang, theme);
|
|
272
|
+
bound.set(theme, boundEngine);
|
|
273
|
+
return boundEngine;
|
|
274
|
+
};
|
|
275
|
+
}
|