@erdemtuna/doc-review 0.7.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/src/cli.js ADDED
@@ -0,0 +1,341 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import http from "node:http";
4
+ import path from "node:path";
5
+ import { spawn } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
7
+ import { canonicalTarget, ensureStateDir, SERVER_PROTOCOL, serverPath, statePath, targetKey } from "./paths.js";
8
+ import { readServerLock } from "./server-lock.js";
9
+ import { installSkills, shellQuote } from "./setup.js";
10
+
11
+ const here = path.dirname(fileURLToPath(import.meta.url));
12
+ const pkg = JSON.parse(fs.readFileSync(path.join(here, "..", "package.json"), "utf8"));
13
+
14
+ const HELP = `doc-review ${pkg.version}
15
+
16
+ doc-review <file-or-localhost-url> Open a file or localhost page for review
17
+ doc-review poll <target> Wait for feedback, print it as JSON (for agents)
18
+ --ack <batch_id> Acknowledge that exact delivered batch, then keep waiting
19
+ --timeout <secs> Exit with {"status":"timeout"} if nothing arrives
20
+ doc-review status <target> Report whether feedback is waiting, without blocking
21
+ doc-review setup Teach Claude Code / Codex how to use doc-review
22
+ doc-review setup --global ...for every project, not just this one
23
+
24
+ Everything runs locally. No account, no cloud, no database.
25
+ `;
26
+
27
+ // --------------------------------------------------------------- server glue
28
+
29
+ function readServerRecord() {
30
+ try {
31
+ return JSON.parse(fs.readFileSync(serverPath(), "utf8"));
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ function request(server, options, body) {
38
+ const port = typeof server === "number" ? server : server.port;
39
+ const token = typeof server === "number" ? "" : server.token || "";
40
+ return new Promise((resolve, reject) => {
41
+ const req = http.request(
42
+ {
43
+ host: "127.0.0.1",
44
+ port,
45
+ ...options,
46
+ headers: { ...(token ? { "x-doc-review-token": token } : {}), ...(options.headers || {}) },
47
+ },
48
+ (res) => {
49
+ let raw = "";
50
+ res.setEncoding("utf8");
51
+ res.on("data", (chunk) => {
52
+ raw += chunk;
53
+ });
54
+ res.on("end", () => resolve({ status: res.statusCode, raw }));
55
+ }
56
+ );
57
+ req.on("error", reject);
58
+ if (options.timeout) req.setTimeout(options.timeout, () => req.destroy(new Error("timeout")));
59
+ if (body) req.write(JSON.stringify(body));
60
+ req.end();
61
+ });
62
+ }
63
+
64
+ async function alive(server) {
65
+ try {
66
+ const lock = readServerLock();
67
+ if (lock?.pid !== server.pid || lock?.instance_id !== server.instance_id) return false;
68
+ const res = await request(server, { method: "GET", path: "/health", timeout: 1200 });
69
+ if (res.status !== 200) return false;
70
+ const health = JSON.parse(res.raw);
71
+ return (
72
+ health.protocol === SERVER_PROTOCOL &&
73
+ health.pid === server.pid &&
74
+ health.instance_id === server.instance_id
75
+ );
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ async function ensureServer() {
82
+ ensureStateDir();
83
+ for (let launch = 0; launch < 3; launch += 1) {
84
+ const saved = readServerRecord();
85
+ if (saved?.protocol === SERVER_PROTOCOL && saved.port && saved.instance_id && (await alive(saved))) return saved;
86
+
87
+ const child = spawn(process.execPath, [path.join(here, "server-entry.js")], {
88
+ detached: true,
89
+ stdio: "ignore",
90
+ });
91
+ child.unref();
92
+
93
+ for (let attempt = 0; attempt < 60; attempt += 1) {
94
+ await new Promise((r) => setTimeout(r, 100));
95
+ const record = readServerRecord();
96
+ // Same protocol gate as above: a still-running server from an older
97
+ // version answers /health too, and must not be adopted here.
98
+ if (record?.protocol === SERVER_PROTOCOL && record.port && record.instance_id && (await alive(record))) return record;
99
+ if (child.exitCode !== null && !readServerLock()) break;
100
+ }
101
+ }
102
+ throw new Error("Could not start the local doc-review server.");
103
+ }
104
+
105
+ function openBrowser(url) {
106
+ const command =
107
+ process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
108
+ const child = spawn(command[0], command[1], { detached: true, stdio: "ignore" });
109
+ // A missing opener (headless Linux without xdg-open) surfaces as an async
110
+ // 'error' event, not a throw. Printing the URL below is the fallback.
111
+ child.on("error", () => {});
112
+ child.unref();
113
+ }
114
+
115
+ // ------------------------------------------------------------------ commands
116
+
117
+ async function openCommand(input) {
118
+ const target = canonicalTarget(input);
119
+ if (target.kind === "file" && !fs.existsSync(target.value)) {
120
+ console.error(`File not found: ${target.value}`);
121
+ process.exit(1);
122
+ }
123
+ const server = await ensureServer();
124
+ const res = await request(server, { method: "POST", path: "/api/session", headers: { "content-type": "application/json" } }, { target: target.value });
125
+ const body = JSON.parse(res.raw);
126
+ if (res.status !== 200) {
127
+ console.error(body.error || "Could not open that file.");
128
+ process.exit(1);
129
+ }
130
+ const url = `http://127.0.0.1:${server.port}${body.path}`;
131
+ openBrowser(url);
132
+ console.log(`Reviewing ${target.kind === "url" ? target.value : path.basename(target.value)}`);
133
+ console.log(url);
134
+ console.log(`\nWaiting for feedback? Run:\n doc-review poll ${shellQuote(target.value)}`);
135
+ }
136
+
137
+ /**
138
+ * One long-poll attempt. Resolves { kind: "data", raw } when the server
139
+ * answers, or { kind: "timeout" } when the caller's deadline passes first.
140
+ */
141
+ function pollOnce(server, target, ackId, timeoutMs) {
142
+ const query = `target=${encodeURIComponent(target)}${ackId ? `&ack=${encodeURIComponent(ackId)}` : ""}`;
143
+ return new Promise((resolve, reject) => {
144
+ let done = false;
145
+ const settle = (fn, value) => {
146
+ if (done) return;
147
+ done = true;
148
+ clearTimeout(timer);
149
+ fn(value);
150
+ };
151
+ const req = http.request(
152
+ {
153
+ host: "127.0.0.1",
154
+ port: server.port,
155
+ method: "GET",
156
+ path: `/api/poll?${query}`,
157
+ headers: { "x-doc-review-token": server.token || "" },
158
+ },
159
+ (res) => {
160
+ let raw = "";
161
+ res.setEncoding("utf8");
162
+ res.on("data", (chunk) => {
163
+ raw += chunk;
164
+ });
165
+ res.on("end", () => settle(resolve, { kind: "data", raw: raw.trim() }));
166
+ }
167
+ );
168
+ const timer = timeoutMs
169
+ ? setTimeout(() => {
170
+ settle(resolve, { kind: "timeout" });
171
+ req.destroy();
172
+ }, timeoutMs)
173
+ : null;
174
+ req.on("error", (err) => settle(reject, err));
175
+ req.end();
176
+ });
177
+ }
178
+
179
+ /**
180
+ * The consumer is an agent reading a pipe. process.exit() does not wait for
181
+ * pending stdout writes, so a large payload could arrive truncated — always
182
+ * wait for the write to hand off before returning.
183
+ */
184
+ function writeStdout(text) {
185
+ return new Promise((resolve) => process.stdout.write(text, resolve));
186
+ }
187
+
188
+ function printTimeout(waitedSecs) {
189
+ const payload = {
190
+ status: "timeout",
191
+ waited_seconds: waitedSecs,
192
+ next_step:
193
+ "No feedback yet. Run the same poll command again to keep waiting, or `doc-review status <target>` to check without blocking.",
194
+ };
195
+ return writeStdout(`${JSON.stringify(payload, null, 2)}\n`);
196
+ }
197
+
198
+ async function pollCommand(input, { ackId = "", timeoutSecs = 0 } = {}) {
199
+ const target = canonicalTarget(input).value;
200
+ let server = await ensureServer();
201
+
202
+ const label = /^https?:\/\//i.test(target) ? target : path.basename(target);
203
+ process.stderr.write(`Waiting for feedback on ${label} — comment in the browser, then hit Send.\n`);
204
+
205
+ const deadline = timeoutSecs ? Date.now() + timeoutSecs * 1000 : null;
206
+ for (let attempt = 0; attempt < 3; attempt += 1) {
207
+ const remaining = deadline ? deadline - Date.now() : 0;
208
+ if (deadline && remaining <= 0) return printTimeout(timeoutSecs);
209
+ let result;
210
+ try {
211
+ result = await pollOnce(server, target, ackId && attempt === 0 ? ackId : "", remaining);
212
+ } catch (err) {
213
+ process.stderr.write(`Lost the connection (${err.message}); retrying.\n`);
214
+ server = await ensureServer();
215
+ continue;
216
+ }
217
+ if (result.kind === "timeout") return printTimeout(timeoutSecs);
218
+ if (!result.raw) {
219
+ server = await ensureServer();
220
+ continue;
221
+ }
222
+ try {
223
+ const batch = JSON.parse(result.raw);
224
+ await writeStdout(`${JSON.stringify(batch, null, 2)}\n`);
225
+ return;
226
+ } catch {
227
+ process.stderr.write("Unexpected response from the doc-review server; retrying.\n");
228
+ }
229
+ }
230
+ process.stderr.write("Gave up waiting for feedback.\n");
231
+ process.exit(1);
232
+ }
233
+
234
+ /**
235
+ * Instant answer, no blocking. Asks the running server when there is one;
236
+ * otherwise reads the persisted state directly, so a dead server still
237
+ * reports feedback that is waiting for a fresh poll.
238
+ */
239
+ async function statusCommand(input) {
240
+ const target = canonicalTarget(input).value;
241
+ const saved = readServerRecord();
242
+ if (saved?.protocol === SERVER_PROTOCOL && saved.port && saved.instance_id && (await alive(saved))) {
243
+ const res = await request(saved, { method: "GET", path: `/api/status?target=${encodeURIComponent(target)}` });
244
+ if (res.status === 200) {
245
+ process.stdout.write(`${JSON.stringify(JSON.parse(res.raw), null, 2)}\n`);
246
+ return;
247
+ }
248
+ }
249
+
250
+ let data = { pages: {}, batches: {} };
251
+ try {
252
+ data = JSON.parse(fs.readFileSync(statePath(), "utf8"));
253
+ } catch {
254
+ // No state yet: everything below reads as empty.
255
+ }
256
+ const key = targetKey(target);
257
+ const pending = (data.batches || {})[key];
258
+ const page = (data.pages || {})[key];
259
+ const payload = {
260
+ status: pending ? "feedback-waiting" : "idle",
261
+ feedback_waiting: !!pending,
262
+ agent_listening: false,
263
+ server_running: false,
264
+ unsent: {
265
+ comments: page ? page.comments.length : 0,
266
+ edits: page ? page.edits.length : 0,
267
+ },
268
+ };
269
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
270
+ }
271
+
272
+ // ---------------------------------------------------------------------- main
273
+
274
+ const argv = process.argv.slice(2);
275
+
276
+ if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h" || argv[0] === "help") {
277
+ console.log(HELP);
278
+ process.exit(0);
279
+ }
280
+
281
+ if (argv[0] === "--version" || argv[0] === "-v") {
282
+ console.log(pkg.version);
283
+ process.exit(0);
284
+ }
285
+
286
+ process.on("SIGINT", () => {
287
+ process.stderr.write("\nStopped waiting. Your feedback is safe — run the same command again to pick it up.\n");
288
+ process.exit(130);
289
+ });
290
+
291
+ function parsePollArgs(rest) {
292
+ const parsed = { file: "", ackId: "", timeoutSecs: 0 };
293
+ let sawTimeout = false;
294
+ for (let i = 0; i < rest.length; i += 1) {
295
+ const arg = rest[i];
296
+ if (arg === "--ack") {
297
+ const value = rest[(i += 1)];
298
+ if (!value || value.startsWith("-")) {
299
+ throw new Error("--ack requires the batch_id from the feedback response, e.g. --ack b_123");
300
+ }
301
+ parsed.ackId = value;
302
+ }
303
+ else if (arg.startsWith("--ack=")) {
304
+ throw new Error("Use --ack <batch_id> with the batch ID as a separate argument.");
305
+ }
306
+ else if (arg === "--timeout") {
307
+ sawTimeout = true;
308
+ parsed.timeoutSecs = Number(rest[(i += 1)]);
309
+ } else if (arg.startsWith("--timeout=")) {
310
+ sawTimeout = true;
311
+ parsed.timeoutSecs = Number(arg.slice("--timeout=".length));
312
+ } else if (!arg.startsWith("-") && !parsed.file) parsed.file = arg;
313
+ else throw new Error(`Unknown poll argument: ${arg}`);
314
+ }
315
+ // A malformed value must fail loudly — NaN or 0 silently waiting forever is
316
+ // the exact hang the flag exists to prevent.
317
+ if (sawTimeout && (!Number.isFinite(parsed.timeoutSecs) || parsed.timeoutSecs <= 0)) {
318
+ throw new Error("--timeout wants a number of seconds, e.g. --timeout 300");
319
+ }
320
+ return parsed;
321
+ }
322
+
323
+ try {
324
+ if (argv[0] === "poll") {
325
+ const { file, ackId, timeoutSecs } = parsePollArgs(argv.slice(1));
326
+ if (!file) throw new Error("Usage: doc-review poll <file-or-localhost-url> [--ack <batch_id>] [--timeout <secs>]");
327
+ await pollCommand(file, { ackId, timeoutSecs });
328
+ } else if (argv[0] === "status") {
329
+ const file = argv.find((a, i) => i > 0 && !a.startsWith("-"));
330
+ if (!file) throw new Error("Usage: doc-review status <file-or-localhost-url>");
331
+ await statusCommand(file);
332
+ } else if (argv[0] === "setup") {
333
+ const isGlobal = argv.includes("--global") || argv.includes("-g");
334
+ installSkills(process.cwd(), { global: isGlobal }).forEach((line) => console.log(line));
335
+ } else {
336
+ await openCommand(argv[0]);
337
+ }
338
+ } catch (err) {
339
+ console.error(err.message || String(err));
340
+ process.exit(1);
341
+ }
@@ -0,0 +1,26 @@
1
+ export function navigationHref(target) {
2
+ const link = target?.closest?.("a[href]");
3
+ if (link) return link.getAttribute("href") || "";
4
+ const control = target?.closest?.("[data-href]");
5
+ return control ? control.getAttribute("data-href") || "" : "";
6
+ }
7
+
8
+ /**
9
+ * Decide what a modified click on a "#…" link should do. Setting the real
10
+ * hash is what makes CSS :target routing (single-file "pages") show the
11
+ * section — a bare scrollIntoView can't reach a display:none target and
12
+ * never fires :target. Only when the hash is already current does scrolling
13
+ * become the right move, since re-setting an identical hash is a no-op.
14
+ */
15
+ export function hashClickAction(href, currentHash) {
16
+ const decode = (value) => {
17
+ try {
18
+ return decodeURIComponent(value);
19
+ } catch {
20
+ return value;
21
+ }
22
+ };
23
+ const id = decode(href.slice(1));
24
+ if (decode((currentHash || "").slice(1)) === id) return { kind: "scroll", id };
25
+ return { kind: "navigate", hash: href };
26
+ }
package/src/editing.js ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Pure editing helpers, shared by the in-page SDK and Node tests.
3
+ *
4
+ * Everything here is deliberately DOM-free: the SDK feeds in strings and
5
+ * computed-style snapshots, and gets back decisions it can apply.
6
+ */
7
+
8
+ /**
9
+ * The list command for a marker typed at the start of a line, or null.
10
+ * `lead` is everything between the start of the block and the caret, so a
11
+ * marker typed mid-sentence never converts.
12
+ */
13
+ export function listCommandFor(lead) {
14
+ const marker = String(lead || "").replace(/\u00a0/g, " ").trim();
15
+ if (/^[-*]$/.test(marker)) return "insertUnorderedList";
16
+ if (/^\d{1,3}[.)]$/.test(marker)) return "insertOrderedList";
17
+ return null;
18
+ }
19
+
20
+ /**
21
+ * Style properties a fresh list needs when the page's CSS reset hides it.
22
+ * Tailwind preflight and similar resets set `list-style: none` and zero the
23
+ * indent, so a just-created list looks like nothing happened. Returns only
24
+ * the properties that are actually broken, so styled pages stay untouched.
25
+ */
26
+ export function listStyleFixup(tagName, computed) {
27
+ const patch = {};
28
+ if (computed.listStyleType === "none") {
29
+ patch.listStyleType = /^ol$/i.test(tagName) ? "decimal" : "disc";
30
+ }
31
+ const inset = (parseFloat(computed.paddingLeft) || 0) + (parseFloat(computed.marginLeft) || 0);
32
+ if (inset < 16) patch.paddingLeft = "1.5em";
33
+ return patch;
34
+ }
35
+
36
+ /**
37
+ * Style a fresh link needs when the page can't distinguish it from prose.
38
+ * Reset stylesheets (`a { color: inherit; text-decoration: inherit }`) make a
39
+ * just-created link invisible; underline it only when neither color nor
40
+ * decoration sets it apart.
41
+ */
42
+ export function linkStyleFixup(anchorComputed, parentComputed) {
43
+ const decorated = String(anchorComputed.textDecorationLine || "").includes("underline");
44
+ const recolored = !!parentComputed && anchorComputed.color !== parentComputed.color;
45
+ return decorated || recolored ? {} : { textDecoration: "underline" };
46
+ }
47
+
48
+ /**
49
+ * A typed link, made openable and safe. Bare domains get https://, in-page
50
+ * and relative references pass through, and anything with an executable or
51
+ * unknown scheme (javascript:, data:, …) is rejected outright.
52
+ */
53
+ export function normalizeHref(raw) {
54
+ const href = String(raw || "").replace(/[\u0000-\u001f\u007f]/g, "").trim();
55
+ if (!href) return "";
56
+ let candidate = href;
57
+ if (!/^(https?:|mailto:|tel:|#|\/|\.)/i.test(href)) {
58
+ const head = href.split(/[/?#]/)[0];
59
+ if (/^([\w-]+\.)+[\w-]+(:\d+)?$/.test(head) || /^localhost(:\d+)?$/i.test(head)) {
60
+ // A bare domain — "example.com", "localhost:3000/wiki" — gets a scheme.
61
+ candidate = `https://${href}`;
62
+ } else if (/^[a-z][a-z0-9+.-]*:/i.test(href)) {
63
+ return "";
64
+ }
65
+ }
66
+ try {
67
+ const parsed = new URL(candidate, "https://relative.invalid");
68
+ if (!/^(https?:|mailto:|tel:)$/i.test(parsed.protocol)) return "";
69
+ } catch {
70
+ return "";
71
+ }
72
+ return candidate;
73
+ }
74
+
75
+ /**
76
+ * Classify an authored navigation target. Relative references stay under
77
+ * controlled source-relative navigation; only explicitly safe external
78
+ * schemes leave the review.
79
+ */
80
+ export function classifyHref(raw) {
81
+ const href = String(raw || "").replace(/[\u0000-\u001f\u007f]/g, "").trim();
82
+ if (!href) return "invalid";
83
+ if (href.startsWith("#")) return "hash";
84
+ if (/^\/\//.test(href) || /^https?:/i.test(href) || /^(?:mailto|tel):/i.test(href)) return "external";
85
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return "invalid";
86
+ return "navigate";
87
+ }
88
+
89
+ export function externalHref(raw, baseHref = "https://relative.invalid/") {
90
+ const href = String(raw || "").replace(/[\u0000-\u001f\u007f]/g, "").trim();
91
+ if (classifyHref(href) !== "external") return "";
92
+ if (/^(?:mailto|tel):/i.test(href)) return href;
93
+ try {
94
+ const parsed = new URL(href, baseHref);
95
+ return /^https?:$/i.test(parsed.protocol) ? parsed.href : "";
96
+ } catch {
97
+ return "";
98
+ }
99
+ }
@@ -0,0 +1,44 @@
1
+ let channel = null;
2
+
3
+ export function initializeChannel(capability, generation, pageKey) {
4
+ if (channel) throw new Error("Doc Review frame channel is already initialized.");
5
+ channel = {
6
+ capability: String(capability),
7
+ generation: Number(generation),
8
+ pageKey: String(pageKey),
9
+ };
10
+ }
11
+
12
+ export function initializeChannelFromDocument() {
13
+ const script = document.querySelector("script[data-eh-sdk][data-eh-bootstrap]");
14
+ if (!script) throw new Error("Doc Review frame bootstrap is missing.");
15
+ const capability = script.nonce;
16
+ const generation = Number(script.dataset.generation);
17
+ const pageKey = String(script.dataset.pageKey || "");
18
+ script.remove();
19
+ if (!capability || !Number.isSafeInteger(generation) || !pageKey) {
20
+ throw new Error("Doc Review frame bootstrap is invalid.");
21
+ }
22
+ initializeChannel(capability, generation, pageKey);
23
+ }
24
+
25
+ export function frameMessage(type, payload = {}) {
26
+ if (!channel) throw new Error("Doc Review frame channel is not initialized.");
27
+ return {
28
+ ...payload,
29
+ type,
30
+ capability: channel.capability,
31
+ generation: channel.generation,
32
+ pageKey: channel.pageKey,
33
+ };
34
+ }
35
+
36
+ export function matchesFrameMessage(message) {
37
+ return !!(
38
+ channel &&
39
+ message &&
40
+ message.capability === channel.capability &&
41
+ message.generation === channel.generation &&
42
+ message.pageKey === channel.pageKey
43
+ );
44
+ }
@@ -0,0 +1,16 @@
1
+ const BASE_SANDBOX = "allow-scripts allow-forms allow-modals";
2
+ const LOCALHOST_SANDBOX = `${BASE_SANDBOX} allow-popups allow-downloads allow-same-origin`;
3
+
4
+ /**
5
+ * Localhost apps need their real origin so their routing and JavaScript work.
6
+ * Files and rendered Markdown do not: An opaque iframe origin prevents them
7
+ * from reading sibling files served by the artifact route.
8
+ */
9
+ export function framePolicy(page, artifactOrigin) {
10
+ const keepsOrigin = page?.kind === "url";
11
+ return {
12
+ sandbox: keepsOrigin ? LOCALHOST_SANDBOX : BASE_SANDBOX,
13
+ incomingOrigin: keepsOrigin ? artifactOrigin : "null",
14
+ targetOrigin: keepsOrigin ? artifactOrigin : "*",
15
+ };
16
+ }
@@ -0,0 +1,62 @@
1
+ // Match only the tag itself: injection adds no whitespace, so stripping must
2
+ // not eat any either, or open→save would not round-trip byte-identically.
3
+ const SDK_TAG_RE = /<script[^>]*\bdata-eh-sdk\b[^>]*\bdata-eh-bootstrap\b[^>]*><\/script>/gi;
4
+ const SDK_BASE_RE = /<base[^>]*\bdata-eh-sdk\b[^>]*>/gi;
5
+ const SDK_ROUTE_RE = /<script[^>]*\bdata-eh-route\b[^>]*>[\s\S]*?<\/script>/gi;
6
+ const ASSET_TAG_RE = /<(script|link|img|source|video|audio|iframe|embed|object)\b[^>]*>/gi;
7
+ const ASSET_ATTR_RE = /(\s)(src|href|poster|data)=(['"])(.*?)\3/gi;
8
+
9
+ function absolutizeAssets(html, baseHref) {
10
+ return String(html).replace(ASSET_TAG_RE, (tag) =>
11
+ tag.replace(ASSET_ATTR_RE, (attribute, space, name, quote, value) => {
12
+ if (!value || /^(?:data|blob|javascript):/i.test(value) || value.startsWith("#")) return attribute;
13
+ const absolute = new URL(value, baseHref).href;
14
+ return `${space}${name}=${quote}${absolute}${quote}`;
15
+ })
16
+ );
17
+ }
18
+
19
+ /**
20
+ * Add the review bootstrap tags. Everything else about the artifact is left
21
+ * byte-identical, so the saved file renders the same standalone.
22
+ */
23
+ export function injectSdk(
24
+ html,
25
+ key,
26
+ { src = "/sdk.js", baseHref = "", nonce = "", generation = 0, pageKey = key } = {}
27
+ ) {
28
+ const clean = stripSdk(html);
29
+ const escapedSrc = String(src).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
30
+ const escapedNonce = String(nonce).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
31
+ const escapedPageKey = String(pageKey).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
32
+ const nonceAttr = escapedNonce ? ` nonce="${escapedNonce}"` : "";
33
+ const tag =
34
+ `<script data-eh-sdk data-eh-bootstrap type="module"${nonceAttr}` +
35
+ ` data-generation="${Number(generation)}" data-page-key="${escapedPageKey}" src="${escapedSrc}"></script>`;
36
+ let prepared = clean;
37
+ if (baseHref) {
38
+ const url = new URL(baseHref);
39
+ const route = JSON.stringify(`${url.pathname}${url.search}${url.hash}`).replace(/</g, "\\u003c");
40
+ const restoreRoute = `<script data-eh-route>history.replaceState(null,"",location.origin+${route})</script>`;
41
+ prepared = absolutizeAssets(prepared, baseHref);
42
+ prepared = /<head(?:\s[^>]*)?>/i.test(prepared)
43
+ ? prepared.replace(/<head(\s[^>]*)?>/i, (head) => `${head}${restoreRoute}`)
44
+ : `${restoreRoute}${prepared}`;
45
+ }
46
+ // Put the trusted module before authored markup can enter an unclosed
47
+ // script/style/textarea raw-text context. Removing this exact tag restores
48
+ // every authored byte.
49
+ const doctype = /^(\uFEFF?\s*(?:<!--[\s\S]*?-->\s*)*<!doctype[^>]*>)/i.exec(prepared);
50
+ const at = doctype ? doctype[0].length : 0;
51
+ return `${prepared.slice(0, at)}${tag}${prepared.slice(at)}`;
52
+ }
53
+
54
+ /** Remove any injected tag, so a file saved with one never keeps it. */
55
+ export function stripSdk(html) {
56
+ return String(html).replace(SDK_TAG_RE, "").replace(SDK_BASE_RE, "").replace(SDK_ROUTE_RE, "");
57
+ }
58
+
59
+ export function hasSdk(html) {
60
+ SDK_TAG_RE.lastIndex = 0;
61
+ return SDK_TAG_RE.test(String(html));
62
+ }
@@ -0,0 +1,87 @@
1
+ import path from "node:path";
2
+ import { marked, Renderer } from "marked";
3
+
4
+ export const isMarkdown = (file) => /\.(md|markdown)$/i.test(file);
5
+
6
+ /**
7
+ * Readable defaults for rendered Markdown. This HTML is a viewing surface
8
+ * only — it is never written back to disk, so the styling can be opinionated.
9
+ */
10
+ const STYLE = `
11
+ * { box-sizing: border-box; }
12
+ body {
13
+ margin: 0; background: #fdfcfa; color: #1b1a16;
14
+ font: 16px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
15
+ -webkit-font-smoothing: antialiased;
16
+ }
17
+ main { max-width: 72ch; margin: 0 auto; padding: 48px 28px 96px; }
18
+ h1, h2, h3, h4 { line-height: 1.25; margin: 1.6em 0 .5em; }
19
+ h1 { font-size: 2em; margin-top: .4em; }
20
+ h2 { font-size: 1.45em; border-bottom: 1px solid #eceae3; padding-bottom: .25em; }
21
+ h3 { font-size: 1.15em; }
22
+ p, ul, ol { margin: .75em 0; }
23
+ li { margin: .3em 0; }
24
+ a { color: #295fcc; }
25
+ code {
26
+ background: #f2f0ea; border-radius: 4px; padding: .12em .35em;
27
+ font: .88em/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
28
+ }
29
+ pre { background: #f2f0ea; border-radius: 8px; padding: 14px 16px; overflow-x: auto; }
30
+ pre code { background: none; padding: 0; }
31
+ blockquote { margin: 1em 0; padding: .1em 1em; border-left: 3px solid #d8d5cb; color: #6b6862; }
32
+ table { border-collapse: collapse; margin: 1em 0; width: 100%; }
33
+ th, td { border: 1px solid #e4e2db; padding: 7px 11px; text-align: left; }
34
+ th { background: #f7f5f0; }
35
+ img { max-width: 100%; height: auto; }
36
+ hr { border: none; border-top: 1px solid #eceae3; margin: 2.2em 0; }
37
+ `;
38
+
39
+ const escapeHtml = (value) =>
40
+ String(value)
41
+ .replace(/&/g, "&amp;")
42
+ .replace(/</g, "&lt;")
43
+ .replace(/>/g, "&gt;");
44
+
45
+ function safeUrl(value, { image = false } = {}) {
46
+ const url = String(value || "").trim();
47
+ const probe = url.replace(/[\u0000-\u0020\u007f]+/g, "");
48
+ const match = /^([a-z][a-z0-9+.-]*):/i.exec(probe);
49
+ if (!match) return url;
50
+ const scheme = match[1].toLowerCase();
51
+ if (scheme === "http" || scheme === "https") return url;
52
+ if (!image && scheme === "mailto") return url;
53
+ if (image && /^data:image\/(?:avif|gif|jpe?g|png|webp);base64,/i.test(probe)) return url;
54
+ return null;
55
+ }
56
+
57
+ // Markdown can contain arbitrary HTML. Show that source as text so event
58
+ // handlers, embeds, SVG, and future browser features can never become active.
59
+ const INERT_RENDERER = new Renderer();
60
+ INERT_RENDERER.html = ({ text }) => escapeHtml(text);
61
+ INERT_RENDERER.link = function (token) {
62
+ const href = safeUrl(token.href);
63
+ if (!href) return this.parser.parseInline(token.tokens);
64
+ return Renderer.prototype.link.call(this, { ...token, href });
65
+ };
66
+ INERT_RENDERER.image = function (token) {
67
+ const href = safeUrl(token.href, { image: true });
68
+ if (!href) return escapeHtml(token.text || "");
69
+ return Renderer.prototype.image.call(this, { ...token, href });
70
+ };
71
+
72
+ /** Render a Markdown file into a standalone review page. */
73
+ export function renderMarkdownPage(mdText, file) {
74
+ const body = marked.parse(mdText, { gfm: true, async: false, renderer: INERT_RENDERER });
75
+ const title = path.basename(file);
76
+ return `<!DOCTYPE html>
77
+ <html lang="en">
78
+ <head>
79
+ <meta charset="utf-8">
80
+ <meta name="viewport" content="width=device-width, initial-scale=1">
81
+ <title>${title.replace(/&/g, "&amp;").replace(/</g, "&lt;")}</title>
82
+ <style>${STYLE}</style>
83
+ </head>
84
+ <body><main>${body}</main></body>
85
+ </html>
86
+ `;
87
+ }