@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/setup.js ADDED
@@ -0,0 +1,117 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const here = path.dirname(fileURLToPath(import.meta.url));
8
+ export const PACKAGE_NAME = "@erdemtuna/doc-review";
9
+ export const COMMAND_NAME = "doc-review";
10
+ export const NPX_COMMAND = `npx -y ${PACKAGE_NAME}`;
11
+
12
+ /**
13
+ * Teach agents the command that will actually work here. A global install or
14
+ * `npm link` puts `doc-review` on PATH; otherwise fall back to npx, which only
15
+ * resolves once the package is published.
16
+ *
17
+ * The probe has to discount its own npx run. `${NPX_COMMAND} setup --global`
18
+ * puts this package on PATH for the duration of that one command, out of npm's
19
+ * `_npx` cache, so a naive `which` succeeds and setup writes a bare
20
+ * `doc-review` into SKILL.md. That binary is gone the moment npx exits, and
21
+ * every later agent invocation dies with "command not found".
22
+ */
23
+ export function invocation(run = spawnSync) {
24
+ const probe = process.platform === "win32" ? "where" : "which";
25
+ const found = run(probe, [COMMAND_NAME], { encoding: "utf8", windowsHide: true });
26
+ const resolved = found.status === 0 ? found.stdout.trim().split(/\r?\n/)[0].trim() : "";
27
+ return resolved && !isNpxCachePath(resolved) ? COMMAND_NAME : NPX_COMMAND;
28
+ }
29
+
30
+ /** True for a binary npm placed in its transient `_npx` cache for one command. */
31
+ export function isNpxCachePath(binPath) {
32
+ return binPath.split(/[\\/]/).includes("_npx");
33
+ }
34
+
35
+ /**
36
+ * Quote a path for copy-paste into any shell. JSON.stringify would double
37
+ * Windows backslashes; plain double quotes work in bash, zsh, cmd and
38
+ * PowerShell alike, and paths cannot legally contain a double quote on Windows.
39
+ */
40
+ export function shellQuote(arg) {
41
+ const text = String(arg);
42
+ return /^[\w@%+=:,./-]+$/.test(text) ? text : `"${text.replaceAll('"', '\\"')}"`;
43
+ }
44
+
45
+ /** The skill lives in its own markdown file so nothing needs escaping. */
46
+ export const readSkill = () => fs.readFileSync(path.join(here, "SKILL.md"), "utf8");
47
+
48
+ export const skillFor = (cmd) => readSkill().replaceAll(NPX_COMMAND, cmd);
49
+
50
+ const CODEX_BLOCK = `
51
+ ## Reviewing files and localhost pages with doc-review
52
+
53
+ After writing an HTML or Markdown file the user will read, open it for them with
54
+ \`${NPX_COMMAND} <file.html>\`. For a locally running web page, open the real
55
+ route with \`${NPX_COMMAND} http://localhost:3000/path\` instead of recreating
56
+ it as a static file. Then block on
57
+ \`${NPX_COMMAND} poll <target> --timeout 600\` until they send feedback.
58
+ If it prints \`{"status":"timeout"}\`, no feedback arrived yet — run the same
59
+ poll command again to keep waiting. When a \`{"status":"feedback"}\` batch
60
+ arrives, apply it, then run the exact acknowledgement command in its
61
+ \`next_step\`, which uses \`--ack <batch_id>\`.
62
+
63
+ Keep the poll command in the foreground and do not end the turn while it waits.
64
+ If the shell returns a process or session handle, keep waiting on that handle until
65
+ the command exits. \`${NPX_COMMAND} status <target>\` reports instantly
66
+ whether feedback is already waiting, without blocking.
67
+
68
+ The batch groups feedback by page under \`pages\`, so fix every page listed. Items
69
+ under \`edits\` are changes the user already made: \`after\` is their exact wording,
70
+ so carry it across verbatim and never revert it — and if the HTML was generated
71
+ from MDX or Markdown, apply it to the source too. Markdown files open rendered
72
+ and are never written by doc-review: apply their comments and edits to the
73
+ Markdown source, keeping its syntax. There is no reply channel; the user sees
74
+ your work when the page reloads. For a localhost page, direct edits and deletions
75
+ arrive with \`kind: "url"\`; find and update the matching MDX, TSX, template, or
76
+ component source. Never write the rendered HTTP response over project source.
77
+ `;
78
+
79
+ export function installSkills(cwd, { global: isGlobal = false, home = os.homedir(), command } = {}) {
80
+ const done = [];
81
+ const cmd = command || invocation();
82
+
83
+ const skillRoots = isGlobal
84
+ ? [
85
+ ["Claude Code", path.join(home, ".claude")],
86
+ ["Codex", path.join(home, ".codex")],
87
+ ["Shared agents", path.join(home, ".agents")],
88
+ ]
89
+ : [["Claude Code", path.join(cwd, ".claude")]];
90
+
91
+ for (const [agent, base] of skillRoots) {
92
+ const skillFile = path.join(base, "skills", "doc-review", "SKILL.md");
93
+ fs.mkdirSync(path.dirname(skillFile), { recursive: true });
94
+ fs.writeFileSync(skillFile, skillFor(cmd));
95
+ done.push(`${agent} skill ${skillFile}${isGlobal ? " (all projects)" : ""}`);
96
+ }
97
+
98
+ if (!isGlobal) {
99
+ const agents = path.join(cwd, "AGENTS.md");
100
+ const existing = fs.existsSync(agents) ? fs.readFileSync(agents, "utf8") : "";
101
+ if (existing.includes("doc-review")) {
102
+ done.push("AGENTS.md already mentions doc-review — left it alone");
103
+ } else {
104
+ const block = CODEX_BLOCK.replaceAll(NPX_COMMAND, cmd);
105
+ fs.writeFileSync(agents, existing ? `${existing.trimEnd()}\n${block}` : block.trimStart());
106
+ done.push(`${existing ? "Updated" : "Created"} AGENTS.md (Codex)`);
107
+ }
108
+ }
109
+
110
+ done.push("", `Agents will be told to run: ${cmd}`);
111
+ if (cmd.startsWith("npx")) {
112
+ done.push(`Heads up: npx only works once ${PACKAGE_NAME} is published. Run \`npm link\` in the`);
113
+ done.push("doc-review folder first if you want to use it locally, then re-run setup.");
114
+ }
115
+ done.push("Any other agent works too — see the JSON contract in the README.");
116
+ return done;
117
+ }
package/src/state.js ADDED
@@ -0,0 +1,491 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { canonicalTarget, ensureStateDir, pageKey, realFile, statePath, targetKey } from "./paths.js";
5
+
6
+ /** Anything untouched this long is review debris, not work in progress. */
7
+ const PRUNE_AGE_MS = 30 * 24 * 60 * 60 * 1000;
8
+ const DELIVERY_STATES = new Set(["queued", "possibly_delivered", "delivered"]);
9
+
10
+ const fresh = (entry, now) => !!entry && now - (entry.updatedAt || 0) < PRUNE_AGE_MS;
11
+ const batchId = () => `b_${crypto.randomBytes(12).toString("hex")}`;
12
+ const emptyState = () => ({ pages: {}, batches: {}, receipts: {} });
13
+
14
+ function pruneData(data, now = Date.now()) {
15
+ let changed = false;
16
+ for (const [key, page] of Object.entries(data.pages)) {
17
+ const missingFile = page.kind !== "url" && !fs.existsSync(page.file);
18
+ if (!fresh(page, now) || missingFile) {
19
+ delete data.pages[key];
20
+ delete data.batches[key];
21
+ changed = true;
22
+ }
23
+ }
24
+ for (const [key, batch] of Object.entries(data.batches)) {
25
+ if (!fresh(batch, now)) {
26
+ delete data.batches[key];
27
+ changed = true;
28
+ }
29
+ }
30
+ for (const [id, receipt] of Object.entries(data.receipts)) {
31
+ if (!fresh(receipt, now)) {
32
+ delete data.receipts[id];
33
+ changed = true;
34
+ }
35
+ }
36
+ return changed;
37
+ }
38
+
39
+ function normalizeState(parsed, makeBatchId) {
40
+ if (!parsed || typeof parsed !== "object" || !parsed.pages || typeof parsed.pages !== "object") {
41
+ throw new Error("Invalid doc-review state: expected a pages object.");
42
+ }
43
+ const data = {
44
+ pages: parsed.pages,
45
+ batches: parsed.batches && typeof parsed.batches === "object" ? parsed.batches : {},
46
+ receipts: parsed.receipts && typeof parsed.receipts === "object" ? parsed.receipts : {},
47
+ };
48
+ let changed = !parsed.batches || !parsed.receipts;
49
+ for (const record of Object.values(data.batches)) {
50
+ if (!record || typeof record !== "object" || !record.batch || !Array.isArray(record.cleanup)) {
51
+ throw new Error("Invalid doc-review state: malformed feedback batch.");
52
+ }
53
+ const existingId = record.batch_id || record.batch.batch_id;
54
+ if (record.batch_id && record.batch.batch_id && record.batch_id !== record.batch.batch_id) {
55
+ throw new Error("Invalid doc-review state: feedback batch IDs disagree.");
56
+ }
57
+ if (!existingId) {
58
+ record.batch_id = makeBatchId();
59
+ record.batch.batch_id = record.batch_id;
60
+ record.delivery_state = "possibly_delivered";
61
+ changed = true;
62
+ } else {
63
+ record.batch_id = existingId;
64
+ if (record.batch.batch_id !== existingId) {
65
+ record.batch.batch_id = existingId;
66
+ changed = true;
67
+ }
68
+ if (!record.delivery_state) {
69
+ record.delivery_state = "possibly_delivered";
70
+ changed = true;
71
+ }
72
+ }
73
+ if (!DELIVERY_STATES.has(record.delivery_state)) {
74
+ throw new Error(`Invalid doc-review state: unknown delivery state ${record.delivery_state}.`);
75
+ }
76
+ }
77
+ return { data, changed };
78
+ }
79
+
80
+ /**
81
+ * Atomic write via a unique sibling tmp file. The name is unguessable and the
82
+ * create is exclusive, so a pre-planted symlink can never redirect the write,
83
+ * and a failed rename never leaves a predictable orphan behind.
84
+ */
85
+ export function atomicWrite(file, data) {
86
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.doc-review.tmp`;
87
+ fs.writeFileSync(tmp, data, { flag: "wx" });
88
+ try {
89
+ fs.renameSync(tmp, file);
90
+ } catch (err) {
91
+ try {
92
+ fs.unlinkSync(tmp);
93
+ } catch {}
94
+ throw err;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * All durable state lives in one JSON file. No database, no network.
100
+ *
101
+ * Shape:
102
+ * {
103
+ * pages: { <key>: { key, file, pristine, comments[], edits[], updatedAt } },
104
+ * batches: { <entryKey>: { batch_id, batch, cleanup, delivery_state, updatedAt } },
105
+ * receipts:{ <batchId>: { cleanup, delivery_state, updatedAt } },
106
+ * }
107
+ *
108
+ * Pages are fully independent: no page ever references another. Batches are
109
+ * feedback the user sent that no agent has acknowledged yet; persisting them
110
+ * means "your feedback is safe" stays true across server restarts.
111
+ */
112
+ export class Store {
113
+ constructor({ write = atomicWrite, makeBatchId = batchId } = {}) {
114
+ this.data = emptyState();
115
+ this.write = write;
116
+ this.makeBatchId = makeBatchId;
117
+ this.load();
118
+ }
119
+
120
+ load() {
121
+ let parsed;
122
+ try {
123
+ const raw = fs.readFileSync(statePath(), "utf8");
124
+ parsed = JSON.parse(raw);
125
+ } catch (err) {
126
+ if (err.code === "ENOENT") return this.data;
127
+ throw err;
128
+ }
129
+ const normalized = normalizeState(parsed, this.makeBatchId);
130
+ this.data = normalized.data;
131
+ const changed = pruneData(this.data);
132
+ if (normalized.changed || changed) this.persist(this.data);
133
+ return this.data;
134
+ }
135
+
136
+ persist(data) {
137
+ ensureStateDir();
138
+ this.write(statePath(), JSON.stringify(data, null, 2));
139
+ }
140
+
141
+ /** Replace durable state once, then publish the committed draft in memory. */
142
+ transaction(mutator) {
143
+ const draft = structuredClone(this.data);
144
+ const result = mutator(draft);
145
+ if (result && typeof result.then === "function") {
146
+ throw new Error("Store.transaction mutators must be synchronous.");
147
+ }
148
+ pruneData(draft);
149
+ this.persist(draft);
150
+ this.data = draft;
151
+ return result;
152
+ }
153
+
154
+ /** Persist deliberate direct changes used by maintenance and tests. */
155
+ save() {
156
+ const draft = structuredClone(this.data);
157
+ pruneData(draft);
158
+ this.persist(draft);
159
+ this.data = draft;
160
+ return this.data;
161
+ }
162
+
163
+ /** Register a file as a reviewable page, capturing the agent's version. */
164
+ openPage(file, pristine) {
165
+ const key = pageKey(file);
166
+ return this.transaction((draft) => {
167
+ const existing = draft.pages[key];
168
+ const page = existing || {
169
+ key,
170
+ kind: "file",
171
+ file: realFile(file),
172
+ pristine: "",
173
+ comments: [],
174
+ edits: [],
175
+ updatedAt: 0,
176
+ };
177
+ page.kind = "file";
178
+ page.file = realFile(file);
179
+ delete page.url;
180
+ if (!existing || typeof pristine === "string") {
181
+ page.pristine = typeof pristine === "string" ? pristine : page.pristine;
182
+ }
183
+ page.updatedAt = Date.now();
184
+ draft.pages[key] = page;
185
+ return page;
186
+ });
187
+ }
188
+
189
+ /** Register a rendered localhost route. Browser edits are never written to it. */
190
+ openUrl(url) {
191
+ const target = canonicalTarget(url);
192
+ if (target.kind !== "url") throw new Error("Expected a localhost URL.");
193
+ const key = targetKey(target.value);
194
+ return this.transaction((draft) => {
195
+ const existing = draft.pages[key];
196
+ const page = existing || {
197
+ key,
198
+ kind: "url",
199
+ url: target.value,
200
+ pristine: "",
201
+ comments: [],
202
+ edits: [],
203
+ updatedAt: 0,
204
+ };
205
+ page.kind = "url";
206
+ page.url = target.value;
207
+ delete page.file;
208
+ page.updatedAt = Date.now();
209
+ draft.pages[key] = page;
210
+ return page;
211
+ });
212
+ }
213
+
214
+ page(key) {
215
+ return this.data.pages[key] || null;
216
+ }
217
+
218
+ pageForFile(file) {
219
+ return this.page(pageKey(file));
220
+ }
221
+
222
+ pageForTarget(target) {
223
+ return this.page(targetKey(target));
224
+ }
225
+
226
+ update(key, mutate) {
227
+ if (!this.page(key)) return null;
228
+ return this.transaction((draft) => {
229
+ const page = draft.pages[key];
230
+ mutate(page);
231
+ page.updatedAt = Date.now();
232
+ return page;
233
+ });
234
+ }
235
+
236
+ addComment(key, comment) {
237
+ return this.update(key, (page) => {
238
+ page.comments.push(comment);
239
+ });
240
+ }
241
+
242
+ removeComment(key, id) {
243
+ return this.update(key, (page) => {
244
+ page.comments = page.comments.filter((c) => c.id !== id);
245
+ });
246
+ }
247
+
248
+ /** Reword feedback, optionally turning a delivered instruction into a correction. */
249
+ updateComment(key, id, feedback, { replacementId = "", correctionOf = "" } = {}) {
250
+ let found = false;
251
+ const page = this.update(key, (p) => {
252
+ const index = p.comments.findIndex((c) => c.id === id);
253
+ const comment = p.comments[index];
254
+ if (comment) {
255
+ const updated = {
256
+ ...comment,
257
+ ...(replacementId ? { id: replacementId } : {}),
258
+ feedback,
259
+ updatedAt: Date.now(),
260
+ ...(correctionOf ? { correction: true, correctionOf } : {}),
261
+ };
262
+ p.comments[index] = updated;
263
+ found = true;
264
+ }
265
+ });
266
+ return found ? page : null;
267
+ }
268
+
269
+ /**
270
+ * Reword a comment and every queued copy in one commit. Any evidence that
271
+ * the old ID may have shipped turns the edit into a replacement correction.
272
+ */
273
+ reviseComment(key, id, feedback, { replacementId } = {}) {
274
+ if (!this.page(key)?.comments.some((comment) => comment.id === id)) return null;
275
+ return this.transaction((draft) => {
276
+ const page = draft.pages[key];
277
+ const index = page.comments.findIndex((comment) => comment.id === id);
278
+ const existing = page.comments[index];
279
+ const matching = (record) => record.cleanup.some((item) => item.key === key && item.ids.includes(id));
280
+ const mayHaveShipped =
281
+ Object.values(draft.batches).some((record) => record.delivery_state !== "queued" && matching(record)) ||
282
+ Object.values(draft.receipts).some((record) => matching(record));
283
+
284
+ if (mayHaveShipped) {
285
+ page.comments[index] = {
286
+ ...existing,
287
+ id: replacementId || this.makeBatchId().replace(/^b_/, "c_"),
288
+ feedback,
289
+ updatedAt: Date.now(),
290
+ correction: true,
291
+ correctionOf: existing.feedback,
292
+ };
293
+ page.updatedAt = Date.now();
294
+ return { delivery: "correction", page };
295
+ }
296
+
297
+ page.comments[index] = { ...existing, feedback, updatedAt: Date.now() };
298
+ page.updatedAt = Date.now();
299
+ let updatedPending = false;
300
+ for (const record of Object.values(draft.batches)) {
301
+ if (record.delivery_state !== "queued" || !matching(record)) continue;
302
+ for (const batchPage of record.batch.pages || []) {
303
+ const comment = (batchPage.comments || []).find((item) => item.id === id);
304
+ if (comment) {
305
+ comment.feedback = feedback;
306
+ updatedPending = true;
307
+ }
308
+ }
309
+ record.updatedAt = Date.now();
310
+ }
311
+ return { delivery: updatedPending ? "updated-pending" : "unsent", page };
312
+ });
313
+ }
314
+
315
+ /**
316
+ * Edits are deduped by label+kind so retyping one block stays one row, but
317
+ * the text is refreshed every time so `after` is always the latest wording.
318
+ */
319
+ addEdit(key, label, kind, before, after, beforeHtml, afterHtml, extra) {
320
+ return this.update(key, (page) => {
321
+ const row = page.edits.find((e) => e.label === label && e.kind === kind);
322
+ if (row) {
323
+ if (after !== undefined) row.after = after;
324
+ if (afterHtml !== undefined) row.after_html = afterHtml;
325
+ // A re-move of the same block replaces its landing spot.
326
+ if (extra) {
327
+ if (extra.staged_assets) {
328
+ const assets = [...(row.staged_assets || []), ...extra.staged_assets];
329
+ extra = { ...extra, staged_assets: [...new Map(assets.map((asset) => [asset.path, asset])).values()] };
330
+ }
331
+ Object.assign(row, extra);
332
+ }
333
+ row.updatedAt = Date.now();
334
+ return;
335
+ }
336
+ page.edits.push({ label, kind, before, after, before_html: beforeHtml, after_html: afterHtml, ...(extra || {}), at: Date.now(), updatedAt: Date.now() });
337
+ });
338
+ }
339
+
340
+ clearEdits(key) {
341
+ return this.update(key, (page) => {
342
+ page.edits = [];
343
+ });
344
+ }
345
+
346
+ /** After the agent writes, its version becomes the new revert target. */
347
+ setPristine(key, html) {
348
+ return this.update(key, (page) => {
349
+ page.pristine = html;
350
+ page.edits = [];
351
+ });
352
+ }
353
+
354
+ /**
355
+ * Drop exactly what the acknowledged batch carried. Comments made after
356
+ * Send have unknown ids; edits made (or retyped) after Send have a newer
357
+ * timestamp than the batch. Both must survive for the next batch.
358
+ */
359
+ clearSent(key, ids, sentAt) {
360
+ return this.update(key, (page) => {
361
+ const drop = new Set(ids);
362
+ page.comments = page.comments.filter((c) => !drop.has(c.id));
363
+ // >= not >: an edit stamped the same millisecond as the send may not
364
+ // have shipped — resending it is harmless, dropping it loses work.
365
+ page.edits = typeof sentAt === "number" ? page.edits.filter((e) => (e.updatedAt || e.at || 0) >= sentAt) : [];
366
+ });
367
+ }
368
+
369
+ // Sent-but-unacked feedback, keyed by the entry page the agent polls.
370
+
371
+ batch(entryKey) {
372
+ return this.data.batches[entryKey] || null;
373
+ }
374
+
375
+ allBatches() {
376
+ return this.data.batches;
377
+ }
378
+
379
+ setBatch(entryKey, { batch, cleanup, deliveryState = "queued" }) {
380
+ if (!DELIVERY_STATES.has(deliveryState)) throw new Error(`Unknown delivery state: ${deliveryState}`);
381
+ return this.transaction((draft) => {
382
+ const existing = draft.batches[entryKey];
383
+ if (existing && existing.delivery_state !== "queued") {
384
+ draft.receipts[existing.batch_id] = {
385
+ cleanup: existing.cleanup,
386
+ delivery_state: existing.delivery_state,
387
+ updatedAt: Date.now(),
388
+ };
389
+ }
390
+ const id = batch.batch_id || this.makeBatchId();
391
+ const storedBatch = { ...batch, batch_id: id };
392
+ const record = {
393
+ batch_id: id,
394
+ batch: storedBatch,
395
+ cleanup,
396
+ delivery_state: deliveryState,
397
+ updatedAt: Date.now(),
398
+ };
399
+ draft.batches[entryKey] = record;
400
+ return record;
401
+ });
402
+ }
403
+
404
+ markBatchDelivered(entryKey) {
405
+ if (!this.batch(entryKey)) return null;
406
+ return this.transaction((draft) => {
407
+ const record = draft.batches[entryKey];
408
+ record.delivery_state = "delivered";
409
+ record.updatedAt = Date.now();
410
+ return record;
411
+ });
412
+ }
413
+
414
+ /**
415
+ * Clear exactly one delivered receipt and its shipped page contents.
416
+ * Stale, duplicate, queued, and legacy possibly-delivered IDs are no-ops.
417
+ */
418
+ acknowledgeBatch(entryKey, id) {
419
+ const current = this.batch(entryKey);
420
+ if (!current || current.batch_id !== id || current.delivery_state !== "delivered") {
421
+ return { acknowledged: false, staged: [], keys: [] };
422
+ }
423
+ return this.transaction((draft) => {
424
+ const record = draft.batches[entryKey];
425
+ delete draft.batches[entryKey];
426
+ draft.receipts[id] = {
427
+ cleanup: record.cleanup,
428
+ delivery_state: "acknowledged",
429
+ updatedAt: Date.now(),
430
+ };
431
+ const staged = [];
432
+ const keys = [];
433
+ for (const { key, ids, staged: assets = [], sentAt } of record.cleanup) {
434
+ staged.push(...assets);
435
+ keys.push(key);
436
+ const page = draft.pages[key];
437
+ if (!page) continue;
438
+ const drop = new Set(ids);
439
+ page.comments = page.comments.filter((comment) => !drop.has(comment.id));
440
+ page.edits =
441
+ typeof sentAt === "number"
442
+ ? page.edits.filter((edit) => (edit.updatedAt || edit.at || 0) >= sentAt)
443
+ : [];
444
+ page.updatedAt = Date.now();
445
+ }
446
+ return { acknowledged: true, staged, keys };
447
+ });
448
+ }
449
+
450
+ clearBatch(entryKey) {
451
+ if (!this.batch(entryKey)) return null;
452
+ return this.transaction((draft) => {
453
+ const record = draft.batches[entryKey];
454
+ delete draft.batches[entryKey];
455
+ return record;
456
+ });
457
+ }
458
+ }
459
+
460
+ /** Resolve a sibling asset request without escaping the artifact's directory. */
461
+ export function resolveAsset(pageFile, relative) {
462
+ let decoded;
463
+ try {
464
+ decoded = decodeURIComponent(relative);
465
+ } catch {
466
+ return null;
467
+ }
468
+ const base = path.dirname(pageFile);
469
+ const target = path.resolve(base, decoded);
470
+ const contained = (candidate, root) => {
471
+ const rel = path.relative(root, candidate);
472
+ return !rel.startsWith("..") && !path.isAbsolute(rel);
473
+ };
474
+ if (!contained(target, base)) return null;
475
+ // The lexical check alone would follow a symlink out of the directory, so
476
+ // the resolved filesystem path must land inside it too.
477
+ let real;
478
+ try {
479
+ real = fs.realpathSync(target);
480
+ } catch {
481
+ // Nothing readable at that path — anything a symlink could point to would
482
+ // have resolved. The caller's read fails with a plain 404.
483
+ return target;
484
+ }
485
+ let realBase = base;
486
+ try {
487
+ realBase = fs.realpathSync(base);
488
+ } catch {}
489
+ if (!contained(real, realBase)) return null;
490
+ return real;
491
+ }