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.
- package/LICENSE +22 -0
- package/README.md +232 -2
- package/bin/demoData.js +132 -0
- package/bin/initDesign.js +846 -0
- package/bin/mockpit.js +1830 -0
- package/bin/serveUrl.d.ts +1 -0
- package/bin/serveUrl.js +7 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.js +230 -0
- package/dist/server/apiViews.d.ts +329 -0
- package/dist/server/apiViews.js +135 -0
- package/dist/server/app.d.ts +53 -0
- package/dist/server/app.js +3112 -0
- package/dist/server/base64.d.ts +1 -0
- package/dist/server/base64.js +16 -0
- package/dist/server/cdn.d.ts +6 -0
- package/dist/server/cdn.js +33 -0
- package/dist/server/codeRunner.d.ts +5 -0
- package/dist/server/codeRunner.js +89 -0
- package/dist/server/codeWorker.d.ts +1 -0
- package/dist/server/codeWorker.js +196 -0
- package/dist/server/designGuide.d.ts +16 -0
- package/dist/server/designGuide.js +311 -0
- package/dist/server/events.d.ts +53 -0
- package/dist/server/events.js +14 -0
- package/dist/server/feedbackBatch.d.ts +43 -0
- package/dist/server/feedbackBatch.js +98 -0
- package/dist/server/icons.d.ts +50 -0
- package/dist/server/icons.js +225 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +64 -0
- package/dist/server/kits.d.ts +36 -0
- package/dist/server/kits.js +279 -0
- package/dist/server/knobs.d.ts +15 -0
- package/dist/server/knobs.js +333 -0
- package/dist/server/mcpHttp.d.ts +53 -0
- package/dist/server/mcpHttp.js +143 -0
- package/dist/server/mcpSpec.d.ts +191 -0
- package/dist/server/mcpSpec.js +402 -0
- package/dist/server/parts.d.ts +31 -0
- package/dist/server/parts.js +338 -0
- package/dist/server/postMarkdown.d.ts +10 -0
- package/dist/server/postMarkdown.js +271 -0
- package/dist/server/postSurfaces.d.ts +40 -0
- package/dist/server/postSurfaces.js +407 -0
- package/dist/server/public.d.ts +5 -0
- package/dist/server/public.js +5 -0
- package/dist/server/push.d.ts +45 -0
- package/dist/server/push.js +234 -0
- package/dist/server/richRender.d.ts +15 -0
- package/dist/server/richRender.js +392 -0
- package/dist/server/run.d.ts +82 -0
- package/dist/server/run.js +290 -0
- package/dist/server/runApi.d.ts +6 -0
- package/dist/server/runApi.js +82 -0
- package/dist/server/slots.d.ts +14 -0
- package/dist/server/slots.js +71 -0
- package/dist/server/sqlStore.d.ts +119 -0
- package/dist/server/sqlStore.js +954 -0
- package/dist/server/sqliteStorage.d.ts +2 -0
- package/dist/server/sqliteStorage.js +90 -0
- package/dist/server/surfacePage.d.ts +48 -0
- package/dist/server/surfacePage.js +1099 -0
- package/dist/server/themes.d.ts +48 -0
- package/dist/server/themes.js +305 -0
- package/dist/server/types.d.ts +599 -0
- package/dist/server/types.js +299 -0
- package/dist/workers/public.d.ts +1 -0
- package/dist/workers/public.js +3 -0
- package/extensions/mockpit.js +703 -0
- package/guide/AGENT_SETUP.md +23 -0
- package/guide/topics/asks.md +87 -0
- package/guide/topics/feedback.md +127 -0
- package/guide/topics/html.md +175 -0
- package/guide/topics/http.md +76 -0
- package/guide/topics/knobs.md +72 -0
- package/guide/topics/scripts.md +129 -0
- package/guide/topics/surfaces.md +100 -0
- package/package.json +165 -4
- package/skills/launch-video/SKILL.md +159 -0
- package/skills/mockpit/SKILL.md +40 -0
- 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
+
}
|