mockpit 0.0.0-stage → 1.0.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.
Files changed (82) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +232 -2
  3. package/bin/demoData.js +132 -0
  4. package/bin/initDesign.js +846 -0
  5. package/bin/mockpit.js +1830 -0
  6. package/bin/serveUrl.d.ts +1 -0
  7. package/bin/serveUrl.js +7 -0
  8. package/dist/mcp/server.d.ts +2 -0
  9. package/dist/mcp/server.js +230 -0
  10. package/dist/server/apiViews.d.ts +329 -0
  11. package/dist/server/apiViews.js +135 -0
  12. package/dist/server/app.d.ts +53 -0
  13. package/dist/server/app.js +3112 -0
  14. package/dist/server/base64.d.ts +1 -0
  15. package/dist/server/base64.js +16 -0
  16. package/dist/server/cdn.d.ts +6 -0
  17. package/dist/server/cdn.js +33 -0
  18. package/dist/server/codeRunner.d.ts +5 -0
  19. package/dist/server/codeRunner.js +89 -0
  20. package/dist/server/codeWorker.d.ts +1 -0
  21. package/dist/server/codeWorker.js +196 -0
  22. package/dist/server/designGuide.d.ts +16 -0
  23. package/dist/server/designGuide.js +311 -0
  24. package/dist/server/events.d.ts +53 -0
  25. package/dist/server/events.js +14 -0
  26. package/dist/server/feedbackBatch.d.ts +43 -0
  27. package/dist/server/feedbackBatch.js +98 -0
  28. package/dist/server/icons.d.ts +50 -0
  29. package/dist/server/icons.js +225 -0
  30. package/dist/server/index.d.ts +1 -0
  31. package/dist/server/index.js +64 -0
  32. package/dist/server/kits.d.ts +36 -0
  33. package/dist/server/kits.js +279 -0
  34. package/dist/server/knobs.d.ts +15 -0
  35. package/dist/server/knobs.js +333 -0
  36. package/dist/server/mcpHttp.d.ts +53 -0
  37. package/dist/server/mcpHttp.js +143 -0
  38. package/dist/server/mcpSpec.d.ts +191 -0
  39. package/dist/server/mcpSpec.js +402 -0
  40. package/dist/server/parts.d.ts +31 -0
  41. package/dist/server/parts.js +338 -0
  42. package/dist/server/postMarkdown.d.ts +10 -0
  43. package/dist/server/postMarkdown.js +271 -0
  44. package/dist/server/postSurfaces.d.ts +40 -0
  45. package/dist/server/postSurfaces.js +407 -0
  46. package/dist/server/public.d.ts +5 -0
  47. package/dist/server/public.js +5 -0
  48. package/dist/server/push.d.ts +45 -0
  49. package/dist/server/push.js +234 -0
  50. package/dist/server/richRender.d.ts +15 -0
  51. package/dist/server/richRender.js +392 -0
  52. package/dist/server/run.d.ts +82 -0
  53. package/dist/server/run.js +290 -0
  54. package/dist/server/runApi.d.ts +6 -0
  55. package/dist/server/runApi.js +82 -0
  56. package/dist/server/slots.d.ts +14 -0
  57. package/dist/server/slots.js +71 -0
  58. package/dist/server/sqlStore.d.ts +119 -0
  59. package/dist/server/sqlStore.js +954 -0
  60. package/dist/server/sqliteStorage.d.ts +2 -0
  61. package/dist/server/sqliteStorage.js +90 -0
  62. package/dist/server/surfacePage.d.ts +48 -0
  63. package/dist/server/surfacePage.js +1099 -0
  64. package/dist/server/themes.d.ts +48 -0
  65. package/dist/server/themes.js +305 -0
  66. package/dist/server/types.d.ts +599 -0
  67. package/dist/server/types.js +299 -0
  68. package/dist/workers/public.d.ts +1 -0
  69. package/dist/workers/public.js +3 -0
  70. package/extensions/mockpit.js +703 -0
  71. package/guide/AGENT_SETUP.md +23 -0
  72. package/guide/topics/asks.md +87 -0
  73. package/guide/topics/feedback.md +127 -0
  74. package/guide/topics/html.md +175 -0
  75. package/guide/topics/http.md +76 -0
  76. package/guide/topics/knobs.md +72 -0
  77. package/guide/topics/scripts.md +129 -0
  78. package/guide/topics/surfaces.md +100 -0
  79. package/package.json +165 -4
  80. package/skills/launch-video/SKILL.md +159 -0
  81. package/skills/mockpit/SKILL.md +40 -0
  82. package/viewer/dist/index.html +1922 -0
@@ -0,0 +1,234 @@
1
+ // Web Push (VAPID + aes128gcm) and outbound webhooks, runtime-agnostic: Web
2
+ // Crypto and fetch only, no `node:` imports, so the same code runs on Node and
3
+ // on the Durable Object. Keys and subscriptions live in workspace settings.
4
+ //
5
+ // Nothing here may block or fail a write: every send is fire-and-forget and
6
+ // every failure is logged. The operator's phone missing a notification is a
7
+ // nuisance; a publish failing because a push endpoint is down is data loss.
8
+ const VAPID_KEY = "push:vapid";
9
+ const SUBS_KEY = "push:subs";
10
+ const HOOKS_KEY = "hooks";
11
+ // Contact for the push service, per the VAPID spec. A mailto is required; this
12
+ // deployment has no operator address, so use the project's.
13
+ const VAPID_SUBJECT = "mailto:mockpit@mockpit.sh";
14
+ // --- base64url ---
15
+ const b64uEncode = (bytes) => {
16
+ let s = "";
17
+ for (const b of bytes)
18
+ s += String.fromCharCode(b);
19
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
20
+ };
21
+ const b64uDecode = (input) => {
22
+ const padded = input.replace(/-/g, "+").replace(/_/g, "/");
23
+ const raw = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
24
+ const out = new Uint8Array(raw.length);
25
+ for (let i = 0; i < raw.length; i++)
26
+ out[i] = raw.charCodeAt(i);
27
+ return out;
28
+ };
29
+ const utf8 = (s) => new TextEncoder().encode(s);
30
+ const concat = (...parts) => {
31
+ const total = parts.reduce((n, p) => n + p.length, 0);
32
+ const out = new Uint8Array(total);
33
+ let at = 0;
34
+ for (const p of parts) {
35
+ out.set(p, at);
36
+ at += p.length;
37
+ }
38
+ return out;
39
+ };
40
+ // Web Crypto wants a definite ArrayBuffer, not a possibly-shared view.
41
+ const buf = (bytes) => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
42
+ const subtle = crypto.subtle;
43
+ // Generated once per workspace and persisted: the public key is baked into the
44
+ // browser's subscription, so regenerating it would silently orphan every
45
+ // existing subscriber.
46
+ export async function vapidKeys(store) {
47
+ const stored = await store.getSetting(VAPID_KEY);
48
+ if (stored) {
49
+ try {
50
+ return JSON.parse(stored);
51
+ }
52
+ catch {
53
+ // unreadable — fall through and mint a fresh pair
54
+ }
55
+ }
56
+ const pair = await subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [
57
+ "sign",
58
+ "verify",
59
+ ]);
60
+ const publicJwk = await subtle.exportKey("jwk", pair.publicKey);
61
+ const privateJwk = await subtle.exportKey("jwk", pair.privateKey);
62
+ const publicKey = b64uEncode(concat(new Uint8Array([4]), b64uDecode(publicJwk.x), b64uDecode(publicJwk.y)));
63
+ const keys = { publicKey, privateJwk };
64
+ await store.setSetting(VAPID_KEY, JSON.stringify(keys));
65
+ return keys;
66
+ }
67
+ async function vapidHeader(endpoint, keys) {
68
+ const audience = new URL(endpoint).origin;
69
+ const header = b64uEncode(utf8(JSON.stringify({ typ: "JWT", alg: "ES256" })));
70
+ const payload = b64uEncode(utf8(JSON.stringify({
71
+ aud: audience,
72
+ exp: Math.floor(Date.now() / 1000) + 12 * 60 * 60,
73
+ sub: VAPID_SUBJECT,
74
+ })));
75
+ const key = await subtle.importKey("jwk", keys.privateJwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]);
76
+ const signature = new Uint8Array(await subtle.sign({ name: "ECDSA", hash: "SHA-256" }, key, buf(utf8(`${header}.${payload}`))));
77
+ return `vapid t=${header}.${payload}.${b64uEncode(signature)}, k=${keys.publicKey}`;
78
+ }
79
+ // --- aes128gcm payload encryption (RFC 8291) ---
80
+ async function hkdf(salt, ikm, info, length) {
81
+ const key = await subtle.importKey("raw", buf(ikm), "HKDF", false, ["deriveBits"]);
82
+ const bits = await subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: buf(salt), info: buf(info) }, key, length * 8);
83
+ return new Uint8Array(bits);
84
+ }
85
+ async function encryptPayload(subscription, plaintext) {
86
+ const uaPublic = b64uDecode(subscription.keys.p256dh);
87
+ const authSecret = b64uDecode(subscription.keys.auth);
88
+ const ephemeral = await subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, [
89
+ "deriveBits",
90
+ ]);
91
+ const asPublic = new Uint8Array(await subtle.exportKey("raw", ephemeral.publicKey));
92
+ const uaKey = await subtle.importKey("raw", buf(uaPublic), { name: "ECDH", namedCurve: "P-256" }, false, []);
93
+ const shared = new Uint8Array(await subtle.deriveBits({ name: "ECDH", public: uaKey }, ephemeral.privateKey, 256));
94
+ const prk = await hkdf(authSecret, shared, concat(utf8("WebPush: info\0"), uaPublic, asPublic), 32);
95
+ const salt = crypto.getRandomValues(new Uint8Array(16));
96
+ const cek = await hkdf(salt, prk, utf8("Content-Encoding: aes128gcm\0"), 16);
97
+ const nonce = await hkdf(salt, prk, utf8("Content-Encoding: nonce\0"), 12);
98
+ const key = await subtle.importKey("raw", buf(cek), "AES-GCM", false, ["encrypt"]);
99
+ // 0x02 is the last-record padding delimiter; a single record carries the
100
+ // whole payload.
101
+ const body = concat(plaintext, new Uint8Array([2]));
102
+ const sealed = new Uint8Array(await subtle.encrypt({ name: "AES-GCM", iv: buf(nonce) }, key, buf(body)));
103
+ const recordSize = new Uint8Array(4);
104
+ new DataView(recordSize.buffer).setUint32(0, 4096);
105
+ return concat(salt, recordSize, new Uint8Array([asPublic.length]), asPublic, sealed);
106
+ }
107
+ // --- subscriptions ---
108
+ export async function listSubscriptions(store) {
109
+ const raw = await store.getSetting(SUBS_KEY);
110
+ if (!raw)
111
+ return [];
112
+ try {
113
+ const parsed = JSON.parse(raw);
114
+ return Array.isArray(parsed) ? parsed : [];
115
+ }
116
+ catch {
117
+ return [];
118
+ }
119
+ }
120
+ export function isPushSubscription(value) {
121
+ if (!value || typeof value !== "object")
122
+ return false;
123
+ const s = value;
124
+ const keys = s.keys;
125
+ return (typeof s.endpoint === "string" &&
126
+ s.endpoint.startsWith("https://") &&
127
+ !!keys &&
128
+ typeof keys.p256dh === "string" &&
129
+ typeof keys.auth === "string");
130
+ }
131
+ export async function addSubscription(store, sub) {
132
+ const subs = await listSubscriptions(store);
133
+ if (subs.some((s) => s.endpoint === sub.endpoint))
134
+ return;
135
+ subs.push({ endpoint: sub.endpoint, keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth } });
136
+ await store.setSetting(SUBS_KEY, JSON.stringify(subs));
137
+ }
138
+ async function dropSubscription(store, endpoint) {
139
+ const subs = await listSubscriptions(store);
140
+ const left = subs.filter((s) => s.endpoint !== endpoint);
141
+ if (left.length !== subs.length)
142
+ await store.setSetting(SUBS_KEY, JSON.stringify(left));
143
+ }
144
+ // --- hooks ---
145
+ export async function listHooks(store) {
146
+ const raw = await store.getSetting(HOOKS_KEY);
147
+ if (!raw)
148
+ return [];
149
+ try {
150
+ const parsed = JSON.parse(raw);
151
+ return Array.isArray(parsed) ? parsed : [];
152
+ }
153
+ catch {
154
+ return [];
155
+ }
156
+ }
157
+ export async function addHook(store, hook) {
158
+ const hooks = await listHooks(store);
159
+ hooks.push(hook);
160
+ await store.setSetting(HOOKS_KEY, JSON.stringify(hooks));
161
+ return hook;
162
+ }
163
+ export async function removeHook(store, id) {
164
+ const hooks = await listHooks(store);
165
+ const left = hooks.filter((h) => h.id !== id);
166
+ if (left.length === hooks.length)
167
+ return false;
168
+ await store.setSetting(HOOKS_KEY, JSON.stringify(left));
169
+ return true;
170
+ }
171
+ // --- delivery ---
172
+ // Push + webhooks for one event. Never throws: the caller awaits it only to
173
+ // keep the Worker alive long enough to flush, and a dead endpoint must not
174
+ // surface as a failed publish.
175
+ export async function notify(store, payload) {
176
+ await Promise.all([sendPush(store, payload), postHooks(store, payload)]);
177
+ }
178
+ async function sendPush(store, payload) {
179
+ const subs = await listSubscriptions(store);
180
+ if (subs.length === 0)
181
+ return;
182
+ let keys;
183
+ try {
184
+ keys = await vapidKeys(store);
185
+ }
186
+ catch (err) {
187
+ console.warn("[mockpit] push: vapid key unavailable", err);
188
+ return;
189
+ }
190
+ const body = utf8(JSON.stringify({
191
+ title: payload.event === "ask" ? `${payload.slug} needs you` : `${payload.slug} updated`,
192
+ body: payload.text.slice(0, 300),
193
+ url: payload.url,
194
+ event: payload.event,
195
+ }));
196
+ await Promise.all(subs.map(async (sub) => {
197
+ try {
198
+ const encrypted = await encryptPayload(sub, body);
199
+ const res = await fetch(sub.endpoint, {
200
+ method: "POST",
201
+ headers: {
202
+ Authorization: await vapidHeader(sub.endpoint, keys),
203
+ "Content-Encoding": "aes128gcm",
204
+ "Content-Type": "application/octet-stream",
205
+ TTL: "86400",
206
+ },
207
+ body: buf(encrypted),
208
+ });
209
+ // 404/410 mean the browser dropped the subscription — stop retrying it.
210
+ if (res.status === 404 || res.status === 410)
211
+ await dropSubscription(store, sub.endpoint);
212
+ else if (!res.ok)
213
+ console.warn(`[mockpit] push rejected (${res.status})`);
214
+ }
215
+ catch (err) {
216
+ console.warn("[mockpit] push failed", err);
217
+ }
218
+ }));
219
+ }
220
+ async function postHooks(store, payload) {
221
+ const hooks = (await listHooks(store)).filter((h) => h.events.includes(payload.event));
222
+ await Promise.all(hooks.map(async (hook) => {
223
+ try {
224
+ await fetch(hook.url, {
225
+ method: "POST",
226
+ headers: { "Content-Type": "application/json" },
227
+ body: JSON.stringify(payload),
228
+ });
229
+ }
230
+ catch (err) {
231
+ console.warn("[mockpit] hook failed", err);
232
+ }
233
+ }));
234
+ }
@@ -0,0 +1,15 @@
1
+ import { type Mode } from "./themes.ts";
2
+ import type { CodeSurface, DiffSurface, MarkdownSurface, TerminalSurface } from "./types.ts";
3
+ export type RenderedSurface = {
4
+ body: string;
5
+ css: string;
6
+ };
7
+ export type RenderOpts = {
8
+ theme?: string;
9
+ mode?: Mode;
10
+ };
11
+ export declare const MAX_HIGHLIGHT_BYTES = 200000;
12
+ export declare function renderMarkdown(part: MarkdownSurface, opts?: RenderOpts): Promise<RenderedSurface>;
13
+ export declare function renderTerminal(part: TerminalSurface): RenderedSurface;
14
+ export declare function renderCode(part: CodeSurface, opts?: RenderOpts): Promise<RenderedSurface>;
15
+ export declare function renderDiff(part: DiffSurface, opts?: RenderOpts): Promise<RenderedSurface>;
@@ -0,0 +1,392 @@
1
+ // Server-side rich-surface rendering, runtime-agnostic. Renders markdown/terminal/
2
+ // code/diff surfaces to an HTML body + CSS so they can be served from /s/:id (real
3
+ // URL + sandbox CSP header) exactly like html surfaces — no POST round-trip, no
4
+ // in-memory frame store. No `node:` imports, no DOM globals: passes
5
+ // tsconfig.workers.json and runs on the Worker DO (verified on workerd; shiki
6
+ // uses the JS regex engine and @pierre/diffs the shiki-js SSR path, so neither
7
+ // needs WASM or a DOM).
8
+ //
9
+ // Each renderer returns { body, css }; renderSandboxedPart (surfacePage.ts)
10
+ // wraps body+css in the themed opaque-origin document, injecting the chrome
11
+ // theme vars (viewerThemeCss) — so this file only owns the surface-specific markup
12
+ // and stylesheet, never the surrounding doc/CSP/bridge. (mermaid can't render
13
+ // without a DOM, so it stays a self-rendering CDN doc — see renderMermaidPage in
14
+ // surfacePage.ts — not a function here.)
15
+ import MarkdownIt from "markdown-it";
16
+ import { AnsiUp } from "ansi_up";
17
+ import { createHighlighter } from "shiki";
18
+ import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
19
+ import { getFiletypeFromFileName, parseDiffFromFile, parsePatchFiles, processFile, } from "@pierre/diffs";
20
+ import { preloadFileDiff } from "@pierre/diffs/ssr";
21
+ import { preloadHighlighter } from "@pierre/diffs";
22
+ import { THEMES, themeById } from "./themes.js";
23
+ // Above this many bytes of source, syntax highlighting is skipped and the
24
+ // surface renders as plain escaped text. Tokenizing a multi-megabyte paste
25
+ // costs seconds of CPU in a single-threaded Durable Object — which stalls SSE
26
+ // and every long-poll for the whole workspace, not just this one render — and
27
+ // the resulting document is unreadable anyway. 200 KB is far above any real
28
+ // code excerpt (the bench's "large" code fixture is ~45 KB).
29
+ export const MAX_HIGHLIGHT_BYTES = 200_000;
30
+ const overBudget = (text) => text.length > MAX_HIGHLIGHT_BYTES;
31
+ // ---------------------------------------------------------------------------
32
+ // shiki: one shared highlighter on the JS regex engine (no oniguruma WASM —
33
+ // the Workers-safe path, same engine the viewer's highlight.ts uses). Themes
34
+ // preload once; languages load on demand. Mirrors highlight.ts but server-side.
35
+ // ---------------------------------------------------------------------------
36
+ const SHIKI_DARK_RULE = ".shiki, .shiki span { color: var(--shiki-dark) !important; background-color: var(--shiki-dark-bg) !important; }";
37
+ // shiki emits the light theme inline + a --shiki-dark prop on every span; this
38
+ // flips to the dark theme. PINNED to the chrome-resolved scheme when mode is
39
+ // given (no media query), else follows the OS — identical to shikiSchemeCss in
40
+ // the viewer's highlight.ts (kept in lockstep so a refactor can delete that).
41
+ function shikiSchemeCss(mode) {
42
+ // A resolved mode highlights against ONE theme (see shikiThemeOptions), so the
43
+ // colors are already the right ones inline and there is nothing to flip.
44
+ if (mode)
45
+ return "";
46
+ return `@media (prefers-color-scheme: dark){${SHIKI_DARK_RULE}}`;
47
+ }
48
+ // Every shiki theme any registry theme might select — preloaded once when the
49
+ // shared highlighter is created, so switching the workspace theme is just a
50
+ // re-highlight against an already-loaded theme (the highlighter is a singleton,
51
+ // so loading only the first-requested pair would leave every other theme
52
+ // unloaded and codeToHtml would throw on it). Mirrors the viewer's highlight.ts.
53
+ const ALL_THEMES = [...new Set(THEMES.flatMap((t) => [t.shiki.light, t.shiki.dark]))];
54
+ let highlighterPromise = null;
55
+ async function getHighlighter() {
56
+ if (!highlighterPromise) {
57
+ highlighterPromise = createHighlighter({
58
+ themes: ALL_THEMES,
59
+ langs: [],
60
+ engine: createJavaScriptRegexEngine({ forgiving: true }),
61
+ });
62
+ }
63
+ return highlighterPromise;
64
+ }
65
+ // Load languages, settling silently on unknown ids (shiki throws synchronously
66
+ // on an unknown id, so each load is wrapped to turn the throw into a settled
67
+ // rejection we ignore — same as the viewer's loadLangs).
68
+ async function loadLangs(hl, langs) {
69
+ await Promise.allSettled(langs.map(async (l) => hl.loadLanguage(l)));
70
+ }
71
+ function highlight(hl, code, lang, themeOpts) {
72
+ if (!lang)
73
+ return null;
74
+ try {
75
+ return hl.codeToHtml(code, { lang, ...themeOpts });
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }
81
+ function escapeHtml(s) {
82
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
83
+ }
84
+ // shiki tokenizes once PER THEME, so asking for a light/dark pair costs exactly
85
+ // twice as much as asking for one. When the caller already resolved the scheme —
86
+ // which the viewer always does, it appends &mode= to every surface iframe src —
87
+ // the second tokenization is pure waste: shikiSchemeCss then discards one half
88
+ // with CSS. Only an unpinned load (a bare /s/:id opened outside the viewer) needs
89
+ // both, and that one follows the OS through a media query.
90
+ function shikiThemeOptions(theme, mode) {
91
+ const t = themeById(theme);
92
+ if (mode === "dark")
93
+ return { theme: t.shiki.dark };
94
+ if (mode === "light")
95
+ return { theme: t.shiki.light };
96
+ return { themes: { light: t.shiki.light, dark: t.shiki.dark } };
97
+ }
98
+ // ---------------------------------------------------------------------------
99
+ // Markdown
100
+ // ---------------------------------------------------------------------------
101
+ const MD_CSS = `
102
+ body {
103
+ margin: 0;
104
+ padding: 4px 16px 14px;
105
+ background: transparent;
106
+ color: var(--text);
107
+ font:
108
+ 14px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
109
+ overflow-wrap: anywhere;
110
+ }
111
+ h1, h2, h3, h4 { line-height: 1.3; margin: 1.2em 0 0.5em; font-weight: 600; }
112
+ h1 { font-size: 1.5em; }
113
+ h2 { font-size: 1.25em; }
114
+ h3 { font-size: 1.1em; }
115
+ body > :first-child { margin-top: 0.4em; }
116
+ p, ul, ol, blockquote, table { margin: 0.5em 0; }
117
+ ul, ol { padding-left: 1.5em; }
118
+ li { margin: 0.2em 0; }
119
+ a { color: var(--accent); text-decoration: none; }
120
+ a:hover { text-decoration: underline; }
121
+ code {
122
+ font: 0.875em ui-monospace, monospace;
123
+ background: var(--hover);
124
+ padding: 0.12em 0.35em;
125
+ border-radius: 4px;
126
+ }
127
+ pre {
128
+ background: var(--panel);
129
+ border: 0.5px solid var(--border);
130
+ border-radius: 8px;
131
+ padding: 10px 12px;
132
+ overflow: auto;
133
+ }
134
+ pre code { background: none; padding: 0; font-size: 12.5px; }
135
+ blockquote {
136
+ margin-left: 0;
137
+ padding-left: 12px;
138
+ border-left: 2px solid var(--border-2);
139
+ color: var(--muted);
140
+ }
141
+ table { border-collapse: collapse; font-size: 13px; }
142
+ th, td { border: 0.5px solid var(--border); padding: 4px 8px; text-align: left; }
143
+ th { background: var(--hover); }
144
+ img { max-width: 100%; height: auto; border-radius: 6px; }
145
+ hr { border: none; border-top: 0.5px solid var(--border); margin: 1em 0; }
146
+ `;
147
+ // The languages named on fenced code blocks (```ts, ~~~python).
148
+ function fenceLangs(src) {
149
+ const langs = new Set();
150
+ const re = /^[ \t]*(?:```|~~~)[ \t]*([\w+#.-]+)/gm;
151
+ let m;
152
+ while ((m = re.exec(src)))
153
+ langs.add(m[1].toLowerCase());
154
+ return [...langs];
155
+ }
156
+ export async function renderMarkdown(part, opts = {}) {
157
+ const src = part.markdown ?? "";
158
+ const themeOpts = shikiThemeOptions(opts.theme, opts.mode);
159
+ // Oversized prose renders without highlighting rather than tokenizing every
160
+ // fenced block (see MAX_HIGHLIGHT_BYTES); markdown-it's own escaping still
161
+ // produces a correct, readable document.
162
+ const hl = overBudget(src) ? null : await getHighlighter();
163
+ if (hl)
164
+ await loadLangs(hl, fenceLangs(src));
165
+ const md = new MarkdownIt({
166
+ html: false,
167
+ linkify: true,
168
+ highlight: (code, lang) => (hl ? (highlight(hl, code, lang, themeOpts) ?? "") : ""),
169
+ });
170
+ const renderLinkOpen = md.renderer.rules.link_open ??
171
+ ((tokens, idx, options, _env, self) => self.renderToken(tokens, idx, options));
172
+ md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
173
+ tokens[idx].attrSet("target", "_blank");
174
+ tokens[idx].attrSet("rel", "noopener noreferrer");
175
+ return renderLinkOpen(tokens, idx, options, env, self);
176
+ };
177
+ return { body: md.render(src), css: MD_CSS + shikiSchemeCss(opts.mode) };
178
+ }
179
+ // ---------------------------------------------------------------------------
180
+ // Terminal
181
+ // ---------------------------------------------------------------------------
182
+ const TERM_CSS = `
183
+ body { margin: 0; background: var(--term-bg); }
184
+ .term-bar {
185
+ display: flex; align-items: center; gap: 8px;
186
+ padding: 7px 12px; background: var(--term-bar);
187
+ border-bottom: 0.5px solid #000;
188
+ }
189
+ .term-dots { display: inline-flex; gap: 6px; }
190
+ .term-dots span { width: 11px; height: 11px; border-radius: 50%; background: #555; }
191
+ .term-dots span:nth-child(1) { background: #ff5f56; }
192
+ .term-dots span:nth-child(2) { background: #ffbd2e; }
193
+ .term-dots span:nth-child(3) { background: #27c93f; }
194
+ .term-title { font-size: 11.5px; color: var(--term-title); font-family: ui-monospace, monospace; }
195
+ .term-body {
196
+ margin: 0; padding: 12px 14px; overflow-x: auto; white-space: pre;
197
+ color: var(--term-fg);
198
+ font: 12.5px/1.5 ui-monospace, "SF Mono", Menlo, Consolas, monospace;
199
+ tab-size: 8;
200
+ }
201
+ `;
202
+ function resolveCarriageReturns(text) {
203
+ return text
204
+ .replace(/\r\n/g, "\n")
205
+ .split("\n")
206
+ .map((line) => {
207
+ const lastCr = line.lastIndexOf("\r");
208
+ return lastCr === -1 ? line : line.slice(lastCr + 1);
209
+ })
210
+ .join("\n");
211
+ }
212
+ export function renderTerminal(part) {
213
+ const au = new AnsiUp();
214
+ au.use_classes = false;
215
+ const ansi = au.ansi_to_html(resolveCarriageReturns(part.text ?? ""));
216
+ const title = escapeHtml(part.title ?? "terminal");
217
+ const width = part.cols ? ` style="width:${Number(part.cols)}ch"` : "";
218
+ const body = `<div class="term-bar"><span class="term-dots" aria-hidden="true">` +
219
+ `<span></span><span></span><span></span></span>` +
220
+ `<span class="term-title">${title}</span></div>` +
221
+ `<pre class="term-body"${width}>${ansi}</pre>`;
222
+ return { body, css: TERM_CSS };
223
+ }
224
+ // ---------------------------------------------------------------------------
225
+ // Code
226
+ // ---------------------------------------------------------------------------
227
+ const CODE_CSS = `
228
+ body { margin: 0; padding: 0; background: transparent; }
229
+ .code-wrap { position: relative; }
230
+ .code-head {
231
+ display: flex; align-items: center; gap: 8px; padding: 6px 12px;
232
+ background: var(--panel); border: 0.5px solid var(--border);
233
+ border-bottom: 0; border-radius: 8px 8px 0 0;
234
+ }
235
+ .code-filename {
236
+ flex: 1; font: 500 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
237
+ color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
238
+ }
239
+ .code-lang {
240
+ font: 400 11px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
241
+ color: var(--faint); background: var(--hover); padding: 1px 6px;
242
+ border-radius: 4px; text-transform: lowercase;
243
+ }
244
+ .copy-btn {
245
+ font: 400 12px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
246
+ color: var(--muted); background: var(--hover); border: 0.5px solid var(--border);
247
+ border-radius: 5px; padding: 2px 9px; cursor: pointer; white-space: nowrap;
248
+ transition: color 0.12s;
249
+ }
250
+ .copy-btn:hover { color: var(--text); }
251
+ .copy-btn.copied { color: var(--accent); }
252
+ .code-wrap:not(.code-wrap-head) .copy-btn {
253
+ position: absolute; top: 6px; right: 8px; opacity: 0; transition: opacity 0.15s; z-index: 1;
254
+ }
255
+ .code-wrap:not(.code-wrap-head):hover .copy-btn,
256
+ .code-wrap:not(.code-wrap-head):focus-within .copy-btn { opacity: 1; }
257
+ pre.shiki, pre.plain {
258
+ margin: 0; padding: 12px 14px; background: var(--panel);
259
+ border: 0.5px solid var(--border); border-radius: 8px; overflow: auto;
260
+ font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
261
+ counter-reset: line;
262
+ }
263
+ .code-wrap.code-wrap-head pre.shiki,
264
+ .code-wrap.code-wrap-head pre.plain { border-radius: 0 0 8px 8px; }
265
+ pre.shiki code, pre.plain code { background: none; padding: 0; }
266
+ .line { counter-increment: line; display: block; min-height: 1.5em; }
267
+ .line::before {
268
+ content: counter(line); display: inline-block; width: 2.5em; margin-right: 12px;
269
+ color: var(--faint); text-align: right; user-select: none; -webkit-user-select: none;
270
+ }
271
+ pre.plain { color: var(--text); }
272
+ `;
273
+ function plainHtml(code) {
274
+ const lines = code.split("\n");
275
+ return `<pre class="plain"><code>${lines
276
+ .map((l) => `<span class="line">${escapeHtml(l)}</span>`)
277
+ .join("")}</code></pre>`;
278
+ }
279
+ export async function renderCode(part, opts = {}) {
280
+ const code = part.code ?? "";
281
+ const lang = part.language ?? "text";
282
+ const lineStart = part.lineStart ?? 1;
283
+ const themeOpts = shikiThemeOptions(opts.theme, opts.mode);
284
+ const plain = overBudget(code);
285
+ const hl = plain ? null : await getHighlighter();
286
+ if (hl && lang && lang !== "text")
287
+ await loadLangs(hl, [lang]);
288
+ const highlighted = hl ? highlight(hl, code, lang, themeOpts) : null;
289
+ const pre = highlighted
290
+ ? highlighted.replace(/\n*(<\/span>)\n*(<span class="line")/g, "$1$2")
291
+ : plainHtml(code);
292
+ const preWithStart = lineStart > 1
293
+ ? pre.replace(/<pre\b[^>]*>/, (open) => {
294
+ const decl = `counter-reset:line ${lineStart - 1};`;
295
+ return /\sstyle="/.test(open)
296
+ ? open.replace(/\sstyle="/, ` style="${decl}`)
297
+ : open.replace(/<pre\b/, `<pre style="${decl}"`);
298
+ })
299
+ : pre;
300
+ const hasHead = !!(part.title || (lang && lang !== "text"));
301
+ const wrapClass = hasHead ? "code-wrap code-wrap-head" : "code-wrap";
302
+ const lineEnd = lineStart + code.split("\n").length - 1;
303
+ const range = lineStart > 1 ? `:${lineStart}-${lineEnd}` : "";
304
+ const filename = part.title
305
+ ? `<span class="code-filename">${escapeHtml(part.title)}${escapeHtml(range)}</span>`
306
+ : hasHead
307
+ ? `<span class="code-filename"></span>`
308
+ : "";
309
+ const langBadge = lang && lang !== "text" ? `<span class="code-lang">${escapeHtml(lang)}</span>` : "";
310
+ const copyBtn = `<button class="copy-btn" onclick="__codeCopy(this)">Copy</button>`;
311
+ const head = hasHead ? `<div class="code-head">${filename}${langBadge}${copyBtn}</div>` : copyBtn;
312
+ const codeJs = JSON.stringify(code).replace(/</g, "\\u003c");
313
+ const body = `<div class="${wrapClass}">${head}${preWithStart}<script>(function(){var c=${codeJs};window.__codeCopy=function(b){copyToClipboard(c);b.textContent="Copied!";b.classList.add("copied");setTimeout(function(){b.textContent="Copy";b.classList.remove("copied")},1500)}})();</script></div>`;
314
+ return { body, css: CODE_CSS + shikiSchemeCss(opts.mode) };
315
+ }
316
+ // ---------------------------------------------------------------------------
317
+ // Diff (@pierre/diffs SSR)
318
+ // ---------------------------------------------------------------------------
319
+ const DIFF_CSS = `
320
+ body { margin: 0; padding: 0; background: transparent; font-size: 12.5px; }
321
+ diffs-container { display: block; }
322
+ diffs-container + diffs-container { border-top: 0.5px solid var(--border); }
323
+ `;
324
+ // Fallback styling for a patch too large to render as a real diff.
325
+ const DIFF_PLAIN_CSS = `
326
+ .diff-plain {
327
+ margin: 0; padding: 12px 14px; color: var(--text); background: var(--panel);
328
+ border: 0.5px solid var(--border); border-radius: 8px; overflow: auto;
329
+ font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
330
+ }
331
+ `;
332
+ const BASE_LANGS = ["text", "json", "javascript", "typescript", "tsx", "jsx"];
333
+ function buildFileDiffs(part) {
334
+ const langs = new Set(BASE_LANGS);
335
+ const diffs = [];
336
+ if (part.patch) {
337
+ for (const parsed of parsePatchFiles(part.patch)) {
338
+ for (const fd of parsed.files) {
339
+ diffs.push(fd);
340
+ if (fd.name)
341
+ langs.add(getFiletypeFromFileName(fd.name));
342
+ }
343
+ }
344
+ if (diffs.length === 0) {
345
+ const fd = processFile(part.patch);
346
+ if (fd)
347
+ diffs.push(fd);
348
+ }
349
+ }
350
+ else if (part.files) {
351
+ for (const f of part.files) {
352
+ const lang = f.language ?? getFiletypeFromFileName(f.filename);
353
+ langs.add(lang);
354
+ diffs.push(parseDiffFromFile({ name: f.filename, contents: f.before, lang: lang }, { name: f.filename, contents: f.after, lang: lang }));
355
+ }
356
+ }
357
+ return { diffs, langs: [...langs] };
358
+ }
359
+ export async function renderDiff(part, opts = {}) {
360
+ const t = themeById(opts.theme);
361
+ const shiki = { dark: t.shiki.dark, light: t.shiki.light };
362
+ const source = part.patch ??
363
+ (part.files ?? []).map((f) => `${f.filename}\n${f.before}\n${f.after}`).join("\n");
364
+ // A giant patch is the most expensive render there is (parse + per-file SSR +
365
+ // highlighting). Past the budget, show it as a plain escaped patch instead of
366
+ // pinning the single-threaded runtime for seconds.
367
+ if (overBudget(source)) {
368
+ return {
369
+ body: `<pre class="diff-plain">${escapeHtml(source)}</pre>`,
370
+ css: `${DIFF_CSS}${DIFF_PLAIN_CSS}`,
371
+ };
372
+ }
373
+ const { diffs, langs } = buildFileDiffs(part);
374
+ if (diffs.length === 0)
375
+ throw new Error("No diff content.");
376
+ await preloadHighlighter({
377
+ themes: [shiki.dark, shiki.light],
378
+ langs: langs,
379
+ preferredHighlighter: "shiki-js",
380
+ });
381
+ const options = {
382
+ diffStyle: part.layout ?? "unified",
383
+ theme: { dark: shiki.dark, light: shiki.light },
384
+ themeType: opts.mode ?? "system",
385
+ preferredHighlighter: "shiki-js",
386
+ };
387
+ const rendered = await Promise.all(diffs.map((fileDiff) => preloadFileDiff({ fileDiff, options })));
388
+ const body = rendered
389
+ .map((r) => `<diffs-container><template shadowrootmode="open">${r.prerenderedHTML}</template></diffs-container>`)
390
+ .join("");
391
+ return { body, css: DIFF_CSS };
392
+ }