@erdemtuna/doc-review 0.8.1 → 0.10.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/README.md +52 -137
  2. package/{src → lib}/SKILL.md +28 -4
  3. package/lib/anchor-text.js +155 -0
  4. package/lib/atomic-write.js +54 -0
  5. package/lib/chrome-api.js +78 -0
  6. package/lib/chrome-client.js +2255 -0
  7. package/lib/chrome-session.js +76 -0
  8. package/{src → lib}/chrome.css +253 -8
  9. package/lib/chrome.html +196 -0
  10. package/lib/cli.js +255 -0
  11. package/lib/click-target.js +28 -0
  12. package/lib/comment-anchor.js +23 -0
  13. package/lib/comment-target.js +164 -0
  14. package/lib/comparison-view.js +380 -0
  15. package/lib/contracts/feedback.js +1 -0
  16. package/lib/contracts/frame.js +7 -0
  17. package/lib/contracts/history.js +1 -0
  18. package/lib/contracts/index.js +2 -0
  19. package/lib/contracts/page.js +23 -0
  20. package/lib/document-execution.js +115 -0
  21. package/lib/document-trust.js +2 -0
  22. package/lib/edit-limits.js +24 -0
  23. package/lib/editing.js +107 -0
  24. package/lib/execution-client.js +69 -0
  25. package/lib/feedback-controller.js +106 -0
  26. package/lib/frame-channel.js +42 -0
  27. package/lib/frame-controller.js +313 -0
  28. package/lib/frame-host.js +136 -0
  29. package/lib/frame-policy.js +50 -0
  30. package/lib/history-client.js +112 -0
  31. package/lib/history-coordinator.js +210 -0
  32. package/lib/history-policy.js +43 -0
  33. package/lib/history-server.js +464 -0
  34. package/lib/html-transform.js +52 -0
  35. package/lib/icons.js +249 -0
  36. package/{src → lib}/markdown.js +25 -26
  37. package/lib/paths.js +83 -0
  38. package/lib/poll-transport.js +220 -0
  39. package/lib/positioning.js +97 -0
  40. package/lib/review-controller.js +93 -0
  41. package/lib/review-mode.js +66 -0
  42. package/lib/revision-diff.js +482 -0
  43. package/lib/revision-schema.js +235 -0
  44. package/lib/revision-store.js +190 -0
  45. package/lib/save-controller.js +300 -0
  46. package/lib/sdk.js +2629 -0
  47. package/lib/semantic-snapshot.js +268 -0
  48. package/lib/serialize.js +32 -0
  49. package/lib/server-entry.js +26 -0
  50. package/lib/server-lock.js +109 -0
  51. package/lib/server.js +1501 -0
  52. package/lib/setup-guidance.js +119 -0
  53. package/{src → lib}/setup.js +44 -55
  54. package/lib/state.js +733 -0
  55. package/lib/view-identity.js +112 -0
  56. package/package.json +24 -12
  57. package/src/anchor-text.js +0 -160
  58. package/src/atomic-write.js +0 -53
  59. package/src/chrome-client.js +0 -2006
  60. package/src/chrome-session.js +0 -85
  61. package/src/chrome.html +0 -129
  62. package/src/cli.js +0 -267
  63. package/src/click-target.js +0 -26
  64. package/src/comment-anchor.js +0 -22
  65. package/src/comment-target.js +0 -77
  66. package/src/edit-limits.js +0 -23
  67. package/src/editing.js +0 -99
  68. package/src/frame-channel.js +0 -44
  69. package/src/frame-policy.js +0 -16
  70. package/src/html-transform.js +0 -62
  71. package/src/icons.js +0 -251
  72. package/src/paths.js +0 -91
  73. package/src/poll-transport.js +0 -222
  74. package/src/positioning.js +0 -107
  75. package/src/review-mode.js +0 -59
  76. package/src/sdk.js +0 -2243
  77. package/src/serialize.js +0 -32
  78. package/src/server-entry.js +0 -26
  79. package/src/server-lock.js +0 -101
  80. package/src/server.js +0 -1298
  81. package/src/setup-guidance.js +0 -128
  82. package/src/state.js +0 -507
package/src/editing.js DELETED
@@ -1,99 +0,0 @@
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
- }
@@ -1,44 +0,0 @@
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
- }
@@ -1,16 +0,0 @@
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
- }
@@ -1,62 +0,0 @@
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
- }
package/src/icons.js DELETED
@@ -1,251 +0,0 @@
1
- // Generated by scripts/generate-icons.js from lucide-static. Do not edit.
2
- export const ICON_NODES = Object.freeze({
3
- "eye": [
4
- [
5
- "path",
6
- {
7
- "d": "M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"
8
- }
9
- ],
10
- [
11
- "circle",
12
- {
13
- "cx": "12",
14
- "cy": "12",
15
- "r": "3"
16
- }
17
- ]
18
- ],
19
- "pencil": [
20
- [
21
- "path",
22
- {
23
- "d": "M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"
24
- }
25
- ],
26
- [
27
- "path",
28
- {
29
- "d": "m15 5 4 4"
30
- }
31
- ]
32
- ],
33
- "chevronDown": [
34
- [
35
- "path",
36
- {
37
- "d": "m6 9 6 6 6-6"
38
- }
39
- ]
40
- ],
41
- "messageSquarePlus": [
42
- [
43
- "path",
44
- {
45
- "d": "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"
46
- }
47
- ],
48
- [
49
- "path",
50
- {
51
- "d": "M12 8v6"
52
- }
53
- ],
54
- [
55
- "path",
56
- {
57
- "d": "M9 11h6"
58
- }
59
- ]
60
- ],
61
- "messages": [
62
- [
63
- "path",
64
- {
65
- "d": "M16 10a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 14.286V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"
66
- }
67
- ],
68
- [
69
- "path",
70
- {
71
- "d": "M20 9a2 2 0 0 1 2 2v10.286a.71.71 0 0 1-1.212.502l-2.202-2.202A2 2 0 0 0 17.172 19H10a2 2 0 0 1-2-2v-1"
72
- }
73
- ]
74
- ],
75
- "send": [
76
- [
77
- "path",
78
- {
79
- "d": "M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"
80
- }
81
- ],
82
- [
83
- "path",
84
- {
85
- "d": "m21.854 2.147-10.94 10.939"
86
- }
87
- ]
88
- ],
89
- "trash": [
90
- [
91
- "path",
92
- {
93
- "d": "M10 11v6"
94
- }
95
- ],
96
- [
97
- "path",
98
- {
99
- "d": "M14 11v6"
100
- }
101
- ],
102
- [
103
- "path",
104
- {
105
- "d": "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"
106
- }
107
- ],
108
- [
109
- "path",
110
- {
111
- "d": "M3 6h18"
112
- }
113
- ],
114
- [
115
- "path",
116
- {
117
- "d": "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
118
- }
119
- ]
120
- ],
121
- "moreHorizontal": [
122
- [
123
- "circle",
124
- {
125
- "cx": "12",
126
- "cy": "12",
127
- "r": "1"
128
- }
129
- ],
130
- [
131
- "circle",
132
- {
133
- "cx": "19",
134
- "cy": "12",
135
- "r": "1"
136
- }
137
- ],
138
- [
139
- "circle",
140
- {
141
- "cx": "5",
142
- "cy": "12",
143
- "r": "1"
144
- }
145
- ]
146
- ],
147
- "x": [
148
- [
149
- "path",
150
- {
151
- "d": "M18 6 6 18"
152
- }
153
- ],
154
- [
155
- "path",
156
- {
157
- "d": "m6 6 12 12"
158
- }
159
- ]
160
- ],
161
- "check": [
162
- [
163
- "path",
164
- {
165
- "d": "M20 6 9 17l-5-5"
166
- }
167
- ]
168
- ],
169
- "sun": [
170
- [
171
- "circle",
172
- {
173
- "cx": "12",
174
- "cy": "12",
175
- "r": "4"
176
- }
177
- ],
178
- [
179
- "path",
180
- {
181
- "d": "M12 2v2"
182
- }
183
- ],
184
- [
185
- "path",
186
- {
187
- "d": "M12 20v2"
188
- }
189
- ],
190
- [
191
- "path",
192
- {
193
- "d": "m4.93 4.93 1.41 1.41"
194
- }
195
- ],
196
- [
197
- "path",
198
- {
199
- "d": "m17.66 17.66 1.41 1.41"
200
- }
201
- ],
202
- [
203
- "path",
204
- {
205
- "d": "M2 12h2"
206
- }
207
- ],
208
- [
209
- "path",
210
- {
211
- "d": "M20 12h2"
212
- }
213
- ],
214
- [
215
- "path",
216
- {
217
- "d": "m6.34 17.66-1.41 1.41"
218
- }
219
- ],
220
- [
221
- "path",
222
- {
223
- "d": "m19.07 4.93-1.41 1.41"
224
- }
225
- ]
226
- ],
227
- "moon": [
228
- [
229
- "path",
230
- {
231
- "d": "M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"
232
- }
233
- ]
234
- ]
235
- });
236
-
237
- const escape = (value) => String(value).replace(/[&<>"]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[char]);
238
-
239
- export function iconMarkup(name, { title = "", className = "icon", size = 16 } = {}) {
240
- const nodes = ICON_NODES[name];
241
- if (!nodes) throw new Error(`Unknown icon: ${name}`);
242
- const body = nodes.map(([tag, attrs]) => `<${tag} ${Object.entries(attrs).map(([key, value]) => `${key}="${escape(value)}"`).join(" ")}></${tag}>`).join("");
243
- const label = title ? ` role="img" aria-label="${escape(title)}"` : ' aria-hidden="true"';
244
- return `<svg class="${escape(className)}" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"${label}>${body}</svg>`;
245
- }
246
-
247
- export function createIcon(name, options = {}) {
248
- const template = document.createElement("template");
249
- template.innerHTML = iconMarkup(name, options);
250
- return template.content.firstElementChild;
251
- }
package/src/paths.js DELETED
@@ -1,91 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { homedir } from "node:os";
3
- import path from "node:path";
4
- import fs from "node:fs";
5
-
6
- // Bump this when the CLI and detached server no longer share the same request
7
- // contract. A new CLI must not silently reuse an older background server.
8
- export const SERVER_PROTOCOL = 13;
9
-
10
- export function serverProtocolMatches(protocol) {
11
- return Number(protocol) === SERVER_PROTOCOL;
12
- }
13
-
14
- export function stateDir() {
15
- const override = process.env.DOC_REVIEW_STATE_DIR;
16
- return override ? path.resolve(override) : path.join(homedir(), ".doc-review");
17
- }
18
-
19
- export function statePath() {
20
- return path.join(stateDir(), "state.json");
21
- }
22
-
23
- export function serverPath() {
24
- return path.join(stateDir(), "server.json");
25
- }
26
-
27
- export function serverLockPath() {
28
- return path.join(stateDir(), "server.lock");
29
- }
30
-
31
- export function ensureStateDir() {
32
- // Comments and the server token live here; keep it private to the user.
33
- fs.mkdirSync(stateDir(), { recursive: true, mode: 0o700 });
34
- }
35
-
36
- const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
37
-
38
- /**
39
- * Return a canonical loopback URL, or null when the input is a filesystem path.
40
- * HTTP-looking non-loopback targets fail loudly instead of becoming odd paths.
41
- */
42
- export function localUrl(target) {
43
- const value = String(target || "");
44
- if (!/^https?:\/\//i.test(value)) return null;
45
- let parsed;
46
- try {
47
- parsed = new URL(value);
48
- } catch {
49
- throw new Error(`Invalid localhost URL: ${value}`);
50
- }
51
- if (!LOOPBACK_HOSTS.has(parsed.hostname)) {
52
- throw new Error("doc-review URL support is limited to localhost, 127.0.0.1, and [::1].");
53
- }
54
- if (parsed.username || parsed.password) {
55
- throw new Error("Localhost review URLs cannot contain credentials.");
56
- }
57
- parsed.hash = "";
58
- return parsed.href;
59
- }
60
-
61
- /** Canonical identity for a page: the real path of the file on disk. */
62
- export function pageKey(file) {
63
- let real = path.resolve(file);
64
- try {
65
- real = fs.realpathSync(real);
66
- } catch {
67
- // File may not exist yet; the resolved path is still a stable identity.
68
- }
69
- return createHash("sha256").update(real).digest("hex").slice(0, 16);
70
- }
71
-
72
- /** Stable identity for either an existing file target or a localhost URL. */
73
- export function targetKey(target) {
74
- const url = localUrl(target);
75
- if (!url) return pageKey(target);
76
- return createHash("sha256").update(`url:${url}`).digest("hex").slice(0, 16);
77
- }
78
-
79
- export function canonicalTarget(target) {
80
- const url = localUrl(target);
81
- return url ? { kind: "url", value: url } : { kind: "file", value: realFile(target) };
82
- }
83
-
84
- export function realFile(file) {
85
- const resolved = path.resolve(file);
86
- try {
87
- return fs.realpathSync(resolved);
88
- } catch {
89
- return resolved;
90
- }
91
- }