@dev.fast/whiteboard 0.0.0-stage → 0.2.1-preview.20261005.98

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 (247) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -2
  3. package/THIRD_PARTY_NOTICES.md +93 -0
  4. package/dist/account-alias-DOHN1RSH.js +973 -0
  5. package/dist/agent-cli-ChOq0ZuO.js +117 -0
  6. package/dist/agent-cli-DzWfZr6G.js +2 -0
  7. package/dist/agent-client-CPQo7iTI.js +305 -0
  8. package/dist/authoring-tools-Cr6Kpjsx.js +234 -0
  9. package/dist/build-info.json +1 -0
  10. package/dist/cli-hVgHcdsv.js +47 -0
  11. package/dist/cli-runner-D8y2_luU.js +5095 -0
  12. package/dist/cli.d.ts +1 -0
  13. package/dist/cli.js +128 -0
  14. package/dist/client-DFoh50W3.js +950 -0
  15. package/dist/desktop-discovery-DBO-PM2V.js +164 -0
  16. package/dist/error-message-OtiDonty.js +6 -0
  17. package/dist/fs-utils-BMPLt0cr.js +22 -0
  18. package/dist/fuzzy-match-BoAmcyak.js +30 -0
  19. package/dist/headless-host-BJxf4dUU.js +94 -0
  20. package/dist/input-error-OLgB_h31.js +11 -0
  21. package/dist/local-data-CxLk75rx.js +4491 -0
  22. package/dist/mcp-QeF8vkdf.js +140 -0
  23. package/dist/package-paths-B6-zxvIO.js +47 -0
  24. package/dist/process-error-telemetry-v9e5h6D7.js +3952 -0
  25. package/dist/profile-4ry2f3AM.js +76 -0
  26. package/dist/profile-Bh5FIrmf.js +2 -0
  27. package/dist/request-origin-D_4QIdoN.js +811 -0
  28. package/dist/review-agent-traces-DNJFYzc8.js +3866 -0
  29. package/dist/review-home-paths-6zZH-1c9.js +2560 -0
  30. package/dist/review-telemetry-DEFnQsQ2.js +1195 -0
  31. package/dist/runtime-CKiWhGrf.js +52 -0
  32. package/dist/runtime.d.ts +9 -0
  33. package/dist/runtime.js +2 -0
  34. package/dist/s3-SzLpeCG6.js +345 -0
  35. package/dist/s3-config-BgxkSoOy.js +2 -0
  36. package/dist/s3-config-CAxhO9u_.js +676 -0
  37. package/dist/server/desktop-host.d.ts +4 -0
  38. package/dist/server/desktop-host.js +1702 -0
  39. package/dist/server-discovery-Dbzn3w6Z.js +66 -0
  40. package/dist/sharing/index.d.ts +1977 -0
  41. package/dist/sharing/index.js +2 -0
  42. package/dist/src-CGP5ytbV.js +283 -0
  43. package/dist/src-CmdiBL20.js +3676 -0
  44. package/dist/src-DnwdaQ2r.js +865 -0
  45. package/dist/src-X9phtB2j.js +68 -0
  46. package/dist/stored-document-migration-DHeMpLMb.js +2671 -0
  47. package/dist/tool-failure-C8zB73HV.js +98 -0
  48. package/dist/tutorial-trace-DrBvkaeL.js +43 -0
  49. package/instructions/authoring.md +36 -0
  50. package/instructions/file-lenses.md +13 -0
  51. package/instructions/scratchpad.md +23 -0
  52. package/instructions/trace-archaeology.md +113 -0
  53. package/onboarding.md +8 -0
  54. package/package.json +106 -3
  55. package/src/agent-selection.ts +100 -0
  56. package/src/agent-session-ref.ts +80 -0
  57. package/src/ask/agents.ts +351 -0
  58. package/src/ask/checkout-files.ts +47 -0
  59. package/src/ask/file-refs.ts +111 -0
  60. package/src/ask/pi-mcp.ts +154 -0
  61. package/src/ask/protocol.ts +244 -0
  62. package/src/ask/thread-state.ts +350 -0
  63. package/src/ask/thread.ts +1400 -0
  64. package/src/ask/threads.ts +219 -0
  65. package/src/ask/watch.ts +79 -0
  66. package/src/cli-install.ts +1041 -0
  67. package/src/cli-runner.ts +1461 -0
  68. package/src/cli-runtime-info.ts +35 -0
  69. package/src/cli.ts +222 -0
  70. package/src/connect-prompts.ts +248 -0
  71. package/src/cursor-deeplink.ts +11 -0
  72. package/src/desktop-discovery.ts +355 -0
  73. package/src/diff-selection-migration.ts +63 -0
  74. package/src/embedded-posthog-key.ts +6 -0
  75. package/src/error-telemetry.ts +247 -0
  76. package/src/evidence.ts +14 -0
  77. package/src/exception-telemetry.ts +126 -0
  78. package/src/fixtures/blocks/call_stack_diff.json +27 -0
  79. package/src/fixtures/blocks/callout.json +11 -0
  80. package/src/fixtures/blocks/code.json +9 -0
  81. package/src/fixtures/blocks/code_peek.json +8 -0
  82. package/src/fixtures/blocks/database_lens.json +51 -0
  83. package/src/fixtures/blocks/divider.json +1 -0
  84. package/src/fixtures/blocks/fixtures.ts +33 -0
  85. package/src/fixtures/blocks/flow_diagram.json +36 -0
  86. package/src/fixtures/blocks/ids.ts +8 -0
  87. package/src/fixtures/blocks/image.json +9 -0
  88. package/src/fixtures/blocks/markdown.json +7 -0
  89. package/src/fixtures/blocks/section.json +11 -0
  90. package/src/fixtures/blocks/sequence.json +31 -0
  91. package/src/fixtures/blocks/software_map.json +7 -0
  92. package/src/fixtures/blocks/trace_quote.json +9 -0
  93. package/src/fixtures/blocks/tutorial.json +51 -0
  94. package/src/fs-utils.ts +32 -0
  95. package/src/fuzzy-match.ts +94 -0
  96. package/src/install.ts +29 -0
  97. package/src/legacy-skills.ts +133 -0
  98. package/src/lens-selection.ts +230 -0
  99. package/src/markdown-latex-math.ts +230 -0
  100. package/src/markdown.ts +81 -0
  101. package/src/package-paths.ts +53 -0
  102. package/src/posthog-capture-client.ts +610 -0
  103. package/src/review-api/README.md +295 -0
  104. package/src/review-api/activity.ts +340 -0
  105. package/src/review-api/agent-cli.ts +219 -0
  106. package/src/review-api/agent-client.ts +188 -0
  107. package/src/review-api/anchor-quotes.ts +88 -0
  108. package/src/review-api/ask-history.ts +242 -0
  109. package/src/review-api/authoring-tools.ts +211 -0
  110. package/src/review-api/blocks/call_stack_diff.ts +75 -0
  111. package/src/review-api/blocks/callout.ts +24 -0
  112. package/src/review-api/blocks/code.ts +9 -0
  113. package/src/review-api/blocks/code_peek.ts +13 -0
  114. package/src/review-api/blocks/database_lens.ts +150 -0
  115. package/src/review-api/blocks/definition.ts +40 -0
  116. package/src/review-api/blocks/divider.ts +6 -0
  117. package/src/review-api/blocks/flow_diagram.ts +111 -0
  118. package/src/review-api/blocks/image.ts +10 -0
  119. package/src/review-api/blocks/index.ts +90 -0
  120. package/src/review-api/blocks/markdown.ts +17 -0
  121. package/src/review-api/blocks/section.ts +24 -0
  122. package/src/review-api/blocks/sequence.ts +56 -0
  123. package/src/review-api/blocks/software_map.ts +9 -0
  124. package/src/review-api/blocks/trace_quote.ts +10 -0
  125. package/src/review-api/blocks/tutorial.ts +39 -0
  126. package/src/review-api/checkout-fs.ts +14 -0
  127. package/src/review-api/client.ts +1 -0
  128. package/src/review-api/comparison-coverage.ts +304 -0
  129. package/src/review-api/component-reference.ts +30 -0
  130. package/src/review-api/diff-lenses.ts +175 -0
  131. package/src/review-api/document-headings.ts +51 -0
  132. package/src/review-api/document-text.ts +200 -0
  133. package/src/review-api/document.ts +906 -0
  134. package/src/review-api/file-lenses.ts +100 -0
  135. package/src/review-api/http.ts +1877 -0
  136. package/src/review-api/image-decode.ts +29 -0
  137. package/src/review-api/input-error.ts +9 -0
  138. package/src/review-api/instructions.ts +89 -0
  139. package/src/review-api/lens-alignment.ts +52 -0
  140. package/src/review-api/local-data.ts +1837 -0
  141. package/src/review-api/map-input.ts +154 -0
  142. package/src/review-api/mcp-client-agent.ts +32 -0
  143. package/src/review-api/mcp.ts +240 -0
  144. package/src/review-api/origin.ts +43 -0
  145. package/src/review-api/profile.ts +146 -0
  146. package/src/review-api/public-tools.ts +102 -0
  147. package/src/review-api/pull-request.ts +389 -0
  148. package/src/review-api/read-schemas.ts +85 -0
  149. package/src/review-api/recovery.ts +2 -0
  150. package/src/review-api/request-origin.ts +33 -0
  151. package/src/review-api/review-progress.ts +382 -0
  152. package/src/review-api/status-tool.ts +10 -0
  153. package/src/review-api/store-schema.ts +22 -0
  154. package/src/review-api/store.ts +1647 -0
  155. package/src/review-api/tool-failure.ts +64 -0
  156. package/src/review-api/trace-schema.ts +13 -0
  157. package/src/review-api/traces.ts +119 -0
  158. package/src/review-api/unsupported-files.integration.ts +91 -0
  159. package/src/review-api/workspaces.ts +692 -0
  160. package/src/review-api/worktree-source.ts +170 -0
  161. package/src/review-api/worktree-structural.integration.ts +320 -0
  162. package/src/review-app-launcher.ts +432 -0
  163. package/src/review-app-picker.ts +168 -0
  164. package/src/review-app.ts +134 -0
  165. package/src/review-bundled-tools.ts +249 -0
  166. package/src/review-checkout-paths.ts +37 -0
  167. package/src/review-diff-files.ts +89 -0
  168. package/src/review-head-checkout.ts +300 -0
  169. package/src/review-home-paths.ts +73 -0
  170. package/src/review-info.ts +59 -0
  171. package/src/review-instances.ts +113 -0
  172. package/src/review-logger.ts +183 -0
  173. package/src/review-preferences.ts +88 -0
  174. package/src/review-prepare.ts +291 -0
  175. package/src/review-stack.ts +112 -0
  176. package/src/review-telemetry.ts +1066 -0
  177. package/src/runtime.ts +74 -0
  178. package/src/server/account-alias.ts +30 -0
  179. package/src/server/bounded-stream.ts +34 -0
  180. package/src/server/bug-report.ts +301 -0
  181. package/src/server/client-error-budget.ts +45 -0
  182. package/src/server/crash-report.ts +273 -0
  183. package/src/server/desktop-host-shutdown.ts +64 -0
  184. package/src/server/desktop-host.ts +190 -0
  185. package/src/server/desktop-server.ts +703 -0
  186. package/src/server/diffr-config.ts +509 -0
  187. package/src/server/diffr-languages.ts +97 -0
  188. package/src/server/global-verb-relay.ts +190 -0
  189. package/src/server/headless-host.ts +149 -0
  190. package/src/server/hono-http.ts +162 -0
  191. package/src/server/http-json.ts +24 -0
  192. package/src/server/json-review-reporting.ts +174 -0
  193. package/src/server/process-error-telemetry.ts +174 -0
  194. package/src/server/review-api-parsers.ts +90 -0
  195. package/src/server/review-lifecycle-telemetry.ts +73 -0
  196. package/src/server/review-open-watchdog.ts +46 -0
  197. package/src/server/review-server-core.ts +290 -0
  198. package/src/server/structural-comparisons.ts +153 -0
  199. package/src/server/structural-diff.ts +186 -0
  200. package/src/server/tutorial-service.ts +241 -0
  201. package/src/server/ui-telemetry.ts +198 -0
  202. package/src/server-discovery.ts +95 -0
  203. package/src/session-markers.ts +132 -0
  204. package/src/sharing/auth.ts +41 -0
  205. package/src/sharing/cli.ts +83 -0
  206. package/src/sharing/client.ts +334 -0
  207. package/src/sharing/export.ts +195 -0
  208. package/src/sharing/host.ts +316 -0
  209. package/src/sharing/import.ts +787 -0
  210. package/src/sharing/index.ts +16 -0
  211. package/src/sharing/repository.ts +145 -0
  212. package/src/sharing/routes.ts +34 -0
  213. package/src/slug.ts +23 -0
  214. package/src/software-map-diff-counts.ts +517 -0
  215. package/src/software-map-model.ts +1147 -0
  216. package/src/software-map-topology-diff.ts +260 -0
  217. package/src/source.ts +92 -0
  218. package/src/startup-trace.ts +232 -0
  219. package/src/stored-document-migration.ts +151 -0
  220. package/src/telemetry-clean-text.ts +257 -0
  221. package/src/telemetry-config.ts +298 -0
  222. package/src/telemetry-debug-sink.ts +38 -0
  223. package/src/telemetry.ts +13 -0
  224. package/src/trace-cli.ts +156 -0
  225. package/src/trace-storage-cli.ts +509 -0
  226. package/src/tutorial-conversation.ts +18 -0
  227. package/src/ui-telemetry-events.ts +765 -0
  228. package/src/unified-diff.ts +71 -0
  229. package/src/viewed-coverage.ts +259 -0
  230. package/src/windows-cli.ts +125 -0
  231. package/tutorial/document.json +275 -0
  232. package/tutorial/runtime-manifest.json +12 -0
  233. package/tutorial/sample-service/package.json +9 -0
  234. package/tutorial/sample-service/src/api/checkout-api.ts +17 -0
  235. package/tutorial/sample-service/src/app.ts +26 -0
  236. package/tutorial/sample-service/src/database/schema.ts +10 -0
  237. package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +15 -0
  238. package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +24 -0
  239. package/tutorial/sample-service/src/inventory/inventory-service.ts +11 -0
  240. package/tutorial/sample-service/src/orders/order-service.ts +34 -0
  241. package/tutorial/sample-service/src/orders/order.ts +21 -0
  242. package/tutorial/sample-service/src/orders/orders-repository.ts +24 -0
  243. package/tutorial/sample-service/src/payments/payment-gateway.ts +12 -0
  244. package/tutorial/sample-service/src/shipping/shipping-gateway.ts +15 -0
  245. package/tutorial/sample-service/tsconfig.json +12 -0
  246. package/tutorial/software-map.json +139 -0
  247. package/tutorial/trace.json +20 -0
@@ -0,0 +1,133 @@
1
+ import { lstat, readFile, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { valid as validVersion } from "semver";
5
+
6
+ export const LEGACY_SKILL_NAMES = [
7
+ "whiteboard",
8
+ "dev-review",
9
+ "dev-review-batch",
10
+ "dev-file-lenses",
11
+ "scratchpad",
12
+ "trace-archaeology",
13
+ "dev-review-map",
14
+ "review",
15
+ "review-map",
16
+ "review-stop",
17
+ "progressive-review",
18
+ "pr-review",
19
+ ] as const;
20
+
21
+ export function legacySkillRoots(homeDir: string): string[] {
22
+ return [
23
+ path.join(homeDir, ".claude", "skills"),
24
+ path.join(homeDir, ".cursor", "skills"),
25
+ path.join(homeDir, ".config", "opencode", "skills"),
26
+ path.join(homeDir, ".agents", "skills"),
27
+ ];
28
+ }
29
+
30
+ /**
31
+ * True only for a real skill directory (never a symlink to someone else's)
32
+ * holding a regular SKILL.md whose frontmatter Review Desktop wrote.
33
+ */
34
+ async function isReviewStamped(skillDir: string): Promise<boolean> {
35
+ const file = path.join(skillDir, "SKILL.md");
36
+
37
+ try {
38
+ if (!(await lstat(skillDir)).isDirectory()) return false;
39
+
40
+ if (!(await lstat(file)).isFile()) return false;
41
+ } catch {
42
+ return false;
43
+ }
44
+
45
+ const source = await readTextOrUndefined(file);
46
+
47
+ // An unreadable file cannot be verified as Review's, so it is never removed.
48
+ if (source === undefined) return false;
49
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(source)?.[1];
50
+
51
+ const metadata = frontmatter?.match(
52
+ /^metadata:\r?\n((?:[ \t]+[^\n]*(?:\n|$))*)/m,
53
+ )?.[1];
54
+
55
+ if (
56
+ !metadata ||
57
+ !(
58
+ (/^ review-managed-by: "Review Desktop"\r?$/m.test(metadata) &&
59
+ /^ review-generated: "[^"\r\n]+"\r?$/m.test(metadata)) ||
60
+ (/^ whiteboard-managed-by: "Whiteboard(?: Desktop)?"\r?$/m.test(
61
+ metadata,
62
+ ) &&
63
+ /^ whiteboard-generated: "[^"\r\n]+"\r?$/m.test(metadata))
64
+ )
65
+ )
66
+ return false;
67
+
68
+ const version = metadata.match(
69
+ /^ (?:review|whiteboard)-version: "([^"\r\n]+)"\r?$/m,
70
+ )?.[1];
71
+
72
+ return (
73
+ version !== undefined &&
74
+ (version === "development" || validVersion(version) !== null)
75
+ );
76
+ }
77
+
78
+ const OPENCODE_PLUGIN_MARKER =
79
+ "// Managed by Review Desktop (@dev.fast/review).";
80
+
81
+ /** The OpenCode plugin file Review Desktop wrote before it connected over MCP. */
82
+ async function isLegacyOpenCodePlugin(file: string): Promise<boolean> {
83
+ try {
84
+ if (!(await lstat(file)).isFile()) return false;
85
+ } catch {
86
+ return false;
87
+ }
88
+
89
+ const source = await readTextOrUndefined(file);
90
+
91
+ return source?.split(/\r?\n/, 1)[0] === OPENCODE_PLUGIN_MARKER;
92
+ }
93
+
94
+ async function readTextOrUndefined(file: string): Promise<string | undefined> {
95
+ try {
96
+ return await readFile(file, "utf8");
97
+ } catch {
98
+ return undefined;
99
+ }
100
+ }
101
+
102
+ export async function scanLegacySkills(homeDir: string): Promise<string[]> {
103
+ const found: string[] = [];
104
+
105
+ for (const name of ["review.ts", "whiteboard.ts"]) {
106
+ const plugin = path.join(homeDir, ".config", "opencode", "plugins", name);
107
+
108
+ if (await isLegacyOpenCodePlugin(plugin)) found.push(plugin);
109
+ }
110
+
111
+ for (const root of legacySkillRoots(homeDir)) {
112
+ for (const name of LEGACY_SKILL_NAMES) {
113
+ const skillDir = path.join(root, name);
114
+
115
+ if (await isReviewStamped(skillDir)) found.push(skillDir);
116
+ }
117
+ }
118
+
119
+ return found.sort();
120
+ }
121
+
122
+ export async function removeLegacySkills(
123
+ homeDir: string,
124
+ ): Promise<{ removed: string[] }> {
125
+ const removed: string[] = [];
126
+
127
+ for (const skillDir of await scanLegacySkills(homeDir)) {
128
+ await rm(skillDir, { recursive: true, force: true });
129
+ removed.push(skillDir);
130
+ }
131
+
132
+ return { removed };
133
+ }
@@ -0,0 +1,230 @@
1
+ import { z } from "zod";
2
+
3
+ import { type FileLineRange, type SourcePins } from "./source.js";
4
+ import { unionIntervals } from "./viewed-coverage.js";
5
+
6
+ const ANCHOR_FORMS =
7
+ '"head/path#L10-L24" or "base/path#L7" (one side, lines 10–24 or line 7); across sides, GitHub diff style "diff/path#L84-R90" (L = base line, R = head line)';
8
+
9
+ /** Inclusive endpoints in the uncollapsed alignment, independent of diff
10
+ * layout. `pins` names the repository and commits the selection was read
11
+ * from; a selection without them resolves against its document's pins. */
12
+ export type DiffSelection = {
13
+ file: string;
14
+ start: { side: "base" | "head"; line: number };
15
+ end: { side: "base" | "head"; line: number };
16
+ pins?: SourcePins;
17
+ };
18
+
19
+ /** A selection as written and stored: "head/path#L10-L24", "base/path#L7",
20
+ * or "diff/path#L84-R90" across sides. Pins live on whatever holds it. */
21
+ export type Anchor = string;
22
+
23
+ const sameSideAnchor = /^(head|base)\/(.+)#L(\d+)(?:-L(\d+))?$/;
24
+
25
+ const crossSideAnchor = /^diff\/(.+)#([LR])(\d+)-([LR])(\d+)$/;
26
+
27
+ /** An anchor's selection, as markdown source links write it too (without
28
+ * their `review-source:` scheme). Undefined when it isn't one. */
29
+ export function parseAnchor(text: string): DiffSelection | undefined {
30
+ const same = sameSideAnchor.exec(text);
31
+
32
+ if (same) {
33
+ const side = same[1] === "base" ? "base" : "head";
34
+
35
+ return {
36
+ file: same[2]!,
37
+ start: { side, line: Number(same[3]) },
38
+ end: { side, line: Number(same[4] ?? same[3]) },
39
+ };
40
+ }
41
+
42
+ const cross = crossSideAnchor.exec(text);
43
+
44
+ // One side has its own spelling, so a diff anchor must cross.
45
+ if (!cross || cross[2] === cross[4]) return undefined;
46
+ const side = (letter: string) => (letter === "L" ? "base" : "head");
47
+
48
+ return {
49
+ file: cross[1]!,
50
+ start: { side: side(cross[2]!), line: Number(cross[3]) },
51
+ end: { side: side(cross[4]!), line: Number(cross[5]) },
52
+ };
53
+ }
54
+
55
+ /** Why a parsed selection can't be quoted, or undefined when it can. */
56
+ export function selectionProblem(selection: DiffSelection): string | undefined {
57
+ if (selection.start.line < 1 || selection.end.line < 1)
58
+ return "Source lines count from 1.";
59
+
60
+ if (
61
+ selection.start.side === selection.end.side &&
62
+ selection.start.line > selection.end.line
63
+ )
64
+ return "Source range ends before it starts.";
65
+
66
+ if (
67
+ selection.pins &&
68
+ selection.pins.base === undefined &&
69
+ (selection.start.side === "base" || selection.end.side === "base")
70
+ )
71
+ return "A base-side anchor needs base pins.";
72
+ }
73
+
74
+ export const anchorSchema = z
75
+ .string({ error: `Expected a source anchor: ${ANCHOR_FORMS}.` })
76
+ .superRefine((text, context) => {
77
+ const selection = parseAnchor(text);
78
+
79
+ const problem = selection
80
+ ? selectionProblem(selection)
81
+ : `Use ${ANCHOR_FORMS} for a source anchor.`;
82
+
83
+ if (problem) context.addIssue({ code: "custom", message: problem });
84
+ })
85
+ .describe("head/path#L10-L24, base/path#L7, or diff/path#L84-R90");
86
+
87
+ /** A stored anchor's selection, read at the pins of whatever holds it. */
88
+ export function anchorSelection(
89
+ anchor: Anchor,
90
+ pins?: SourcePins,
91
+ ): DiffSelection {
92
+ const selection = parseAnchor(anchor);
93
+
94
+ if (!selection) throw new Error(`Not a source anchor: ${anchor}`);
95
+
96
+ return pins ? { ...selection, pins } : selection;
97
+ }
98
+
99
+ /** The anchor that writes a selection; its pins belong to its holder. */
100
+ export function formatAnchor(selection: DiffSelection): Anchor {
101
+ const { file, start, end } = selection;
102
+
103
+ if (start.side === end.side)
104
+ return start.line === end.line
105
+ ? `${start.side}/${file}#L${start.line}`
106
+ : `${start.side}/${file}#L${start.line}-L${end.line}`;
107
+
108
+ const letter = (side: "base" | "head") => (side === "base" ? "L" : "R");
109
+
110
+ return `diff/${file}#${letter(start.side)}${start.line}-${letter(end.side)}${end.line}`;
111
+ }
112
+
113
+ /** A selection as its holder stores it: the anchor, and its pins when it
114
+ * names its own. */
115
+ export function anchored(selection: DiffSelection): {
116
+ source: Anchor;
117
+ pins?: SourcePins;
118
+ } {
119
+ return selection.pins
120
+ ? { source: formatAnchor(selection), pins: selection.pins }
121
+ : { source: formatAnchor(selection) };
122
+ }
123
+
124
+ export type LensSource = DiffSelection;
125
+
126
+ export type AlignmentRow = readonly [number | null, number | null];
127
+
128
+ /** Pins-less selections keep the key they always had. */
129
+ export function selectionKey(source: LensSource): string {
130
+ return JSON.stringify([
131
+ source.file,
132
+ source.start.side,
133
+ source.start.line,
134
+ source.end.side,
135
+ source.end.line,
136
+ ...(source.pins ? [sourcePinsKey(source.pins)] : []),
137
+ ]);
138
+ }
139
+
140
+ export function sourcePinsKey(pins: SourcePins): string {
141
+ return `${pins.repositoryId}:${pins.base ?? ""}:${pins.head}`;
142
+ }
143
+
144
+ /** The comparison a source's pins name: head alone means head against
145
+ * itself. Reference coverage is grouped under this key on both sides. */
146
+ export function comparisonKey(pins: SourcePins): string {
147
+ return `${pins.repositoryId}:${pins.base ?? pins.head}:${pins.head}`;
148
+ }
149
+
150
+ /** The anchor for one side's line range; its pins belong to its holder. */
151
+ export function rangeAnchor(
152
+ source: Pick<FileLineRange, "file" | "side" | "fromLine" | "toLine">,
153
+ ): Anchor {
154
+ return formatAnchor({
155
+ file: source.file,
156
+ start: { side: source.side, line: source.fromLine },
157
+ end: { side: source.side, line: source.toLine },
158
+ });
159
+ }
160
+
161
+ /** Adapt internal source links (for example software-map evidence) to a selection. */
162
+ export function selectSource(source: FileLineRange): DiffSelection {
163
+ const selection: DiffSelection = {
164
+ file: source.file,
165
+ start: { side: source.side, line: source.fromLine },
166
+ end: { side: source.side, line: source.toLine },
167
+ };
168
+
169
+ return source.pins ? { ...selection, pins: source.pins } : selection;
170
+ }
171
+
172
+ /** Navigation/quote anchors only; never use these as lens coverage. */
173
+ export function sourceAnchors(source: LensSource): FileLineRange[] {
174
+ return (["head", "base"] as const).flatMap((side) => {
175
+ const lines = [source.start, source.end].flatMap((endpoint) =>
176
+ endpoint.side === side ? [endpoint.line] : [],
177
+ );
178
+
179
+ if (!lines.length) return [];
180
+
181
+ const anchor: FileLineRange = {
182
+ file: source.file,
183
+ side,
184
+ fromLine: Math.min(...lines),
185
+ toLine: Math.max(...lines),
186
+ };
187
+
188
+ if (source.pins) anchor.pins = source.pins;
189
+
190
+ return [anchor];
191
+ });
192
+ }
193
+
194
+ export function sourceAnchor(source: LensSource): FileLineRange {
195
+ return sourceAnchors(source)[0]!;
196
+ }
197
+
198
+ /** Resolve once against the renderer's rows. No matching or context expansion. */
199
+ export function resolveDiffSelection(
200
+ source: LensSource,
201
+ rows: readonly AlignmentRow[],
202
+ file: { path: string; previousPath?: string },
203
+ ): FileLineRange[] {
204
+ const locate = (endpoint: DiffSelection["start"]): number =>
205
+ rows.findIndex(
206
+ (row) => row[endpoint.side === "base" ? 0 : 1] === endpoint.line - 1,
207
+ );
208
+
209
+ const start = locate(source.start);
210
+ const end = locate(source.end);
211
+
212
+ if (start < 0 || end < start)
213
+ throw new Error(
214
+ "Lens endpoints do not identify an ordered interval in this diff.",
215
+ );
216
+ const selected = rows.slice(start, end + 1);
217
+
218
+ return (["base", "head"] as const).flatMap((side, index) =>
219
+ unionIntervals(
220
+ selected.flatMap((row) =>
221
+ row[index] === null ? [] : [[row[index]!, row[index]! + 1]],
222
+ ),
223
+ ).map(([from, to]) => ({
224
+ file: side === "base" ? (file.previousPath ?? file.path) : file.path,
225
+ side,
226
+ fromLine: from + 1,
227
+ toLine: to,
228
+ })),
229
+ );
230
+ }
@@ -0,0 +1,230 @@
1
+ import type {
2
+ Extension as FromMarkdownExtension,
3
+ Handle,
4
+ } from "mdast-util-from-markdown";
5
+ import type {
6
+ Code,
7
+ Construct,
8
+ Extension,
9
+ State,
10
+ TokenizeContext,
11
+ } from "micromark-util-types";
12
+
13
+ declare module "micromark-util-types" {
14
+ interface TokenTypeMap {
15
+ latexInlineMath: "latexInlineMath";
16
+ latexDisplayMath: "latexDisplayMath";
17
+ latexMathSequence: "latexMathSequence";
18
+ latexMathData: "latexMathData";
19
+ }
20
+ }
21
+
22
+ const backslash = 92;
23
+
24
+ /**
25
+ * `\(…\)` inline and `\[…\]` display math, read ahead of character escapes.
26
+ * `\[` and `\]` count only on their own lines, so an escaped `\[1\]` stays prose.
27
+ */
28
+ export function latexMath(): Extension {
29
+ return {
30
+ text: {
31
+ [backslash]: [
32
+ delimitedMath("latexInlineMath", 40, 41, false),
33
+ delimitedMath("latexDisplayMath", 91, 93, true),
34
+ ],
35
+ },
36
+ };
37
+ }
38
+
39
+ /** Builds the nodes `$` and `$$` produce. */
40
+ export function latexMathFromMarkdown(): FromMarkdownExtension {
41
+ return {
42
+ enter: {
43
+ latexInlineMath(token) {
44
+ this.enter({ type: "inlineMath", value: "" }, token);
45
+ this.buffer();
46
+ },
47
+ latexDisplayMath(token) {
48
+ this.enter({ type: "math", value: "" }, token);
49
+ this.buffer();
50
+ },
51
+ },
52
+ exit: {
53
+ latexInlineMath: exitMath,
54
+ latexDisplayMath: exitMath,
55
+ latexMathData(token) {
56
+ this.config.enter.data.call(this, token);
57
+ this.config.exit.data.call(this, token);
58
+ },
59
+ },
60
+ };
61
+ }
62
+
63
+ const exitMath: Handle = function (token) {
64
+ const value = this.resume().trim();
65
+ const node = this.stack.at(-1);
66
+
67
+ if (node?.type !== "inlineMath" && node?.type !== "math")
68
+ throw new Error(`Expected a math node, found ${node?.type}.`);
69
+ node.value = value;
70
+ this.exit(token);
71
+ };
72
+
73
+ const lineEnding = (code: Code) => code !== null && code < -2;
74
+
75
+ // Space, tab and micromark's virtual space.
76
+ const lineSpace = (code: Code) => code === 32 || code === -2 || code === -1;
77
+
78
+ function delimitedMath(
79
+ type: "latexInlineMath" | "latexDisplayMath",
80
+ open: number,
81
+ close: number,
82
+ ownLine: boolean,
83
+ ): Construct {
84
+ const closing: Construct = { tokenize: tokenizeClosing, partial: true };
85
+
86
+ return { name: type, tokenize: tokenizeMath };
87
+
88
+ function tokenizeMath(
89
+ this: TokenizeContext,
90
+ effects: Parameters<Construct["tokenize"]>[0],
91
+ ok: State,
92
+ nok: State,
93
+ ): State {
94
+ let lineStart = false;
95
+
96
+ return start;
97
+
98
+ function start(code: Code): State | undefined {
99
+ effects.enter(type);
100
+ effects.enter("latexMathSequence");
101
+ effects.consume(code);
102
+
103
+ return opening;
104
+ }
105
+
106
+ function opening(code: Code): State | undefined {
107
+ if (code !== open) return nok(code);
108
+ effects.consume(code);
109
+
110
+ return ownLine ? openingLine : openingEnd;
111
+ }
112
+
113
+ function openingLine(code: Code): State | undefined {
114
+ if (lineSpace(code)) {
115
+ effects.consume(code);
116
+
117
+ return openingLine;
118
+ }
119
+
120
+ return lineEnding(code) ? openingEnd(code) : nok(code);
121
+ }
122
+
123
+ function openingEnd(code: Code): State | undefined {
124
+ effects.exit("latexMathSequence");
125
+
126
+ return between(code);
127
+ }
128
+
129
+ function between(code: Code): State | undefined {
130
+ if (code === null) return nok(code);
131
+
132
+ if (code === backslash)
133
+ return ownLine && !lineStart
134
+ ? escape(code)
135
+ : effects.attempt(closing, after, escape)(code);
136
+
137
+ if (lineEnding(code)) {
138
+ effects.enter("lineEnding");
139
+ effects.consume(code);
140
+ effects.exit("lineEnding");
141
+ lineStart = true;
142
+
143
+ return between;
144
+ }
145
+
146
+ effects.enter("latexMathData");
147
+
148
+ return data(code);
149
+ }
150
+
151
+ // `\\` is one unit, so the `)` in `\\)` never closes.
152
+ function escape(code: Code): State | undefined {
153
+ effects.enter("latexMathData");
154
+ effects.consume(code);
155
+ lineStart = false;
156
+
157
+ return escaped;
158
+ }
159
+
160
+ function escaped(code: Code): State | undefined {
161
+ if (code === null || lineEnding(code)) {
162
+ effects.exit("latexMathData");
163
+
164
+ return between(code);
165
+ }
166
+
167
+ effects.consume(code);
168
+
169
+ return data;
170
+ }
171
+
172
+ function data(code: Code): State | undefined {
173
+ if (code === null || code === backslash || lineEnding(code)) {
174
+ effects.exit("latexMathData");
175
+
176
+ return between(code);
177
+ }
178
+
179
+ if (!lineSpace(code)) lineStart = false;
180
+ effects.consume(code);
181
+
182
+ return data;
183
+ }
184
+
185
+ function after(code: Code): State | undefined {
186
+ effects.exit(type);
187
+
188
+ return ok(code);
189
+ }
190
+ }
191
+
192
+ function tokenizeClosing(
193
+ this: TokenizeContext,
194
+ effects: Parameters<Construct["tokenize"]>[0],
195
+ ok: State,
196
+ nok: State,
197
+ ): State {
198
+ return start;
199
+
200
+ function start(code: Code): State | undefined {
201
+ effects.enter("latexMathSequence");
202
+ effects.consume(code);
203
+
204
+ return closer;
205
+ }
206
+
207
+ function closer(code: Code): State | undefined {
208
+ if (code !== close) return nok(code);
209
+ effects.consume(code);
210
+
211
+ return ownLine ? closingLine : closingEnd;
212
+ }
213
+
214
+ function closingLine(code: Code): State | undefined {
215
+ if (lineSpace(code)) {
216
+ effects.consume(code);
217
+
218
+ return closingLine;
219
+ }
220
+
221
+ return code === null || lineEnding(code) ? closingEnd(code) : nok(code);
222
+ }
223
+
224
+ function closingEnd(code: Code): State | undefined {
225
+ effects.exit("latexMathSequence");
226
+
227
+ return ok(code);
228
+ }
229
+ }
230
+ }
@@ -0,0 +1,81 @@
1
+ import { fromMarkdown } from "mdast-util-from-markdown";
2
+ import { gfmFromMarkdown } from "mdast-util-gfm";
3
+ import { mathFromMarkdown } from "mdast-util-math";
4
+ import { gfm } from "micromark-extension-gfm";
5
+ import { math } from "micromark-extension-math";
6
+
7
+ import { latexMath, latexMathFromMarkdown } from "./markdown-latex-math";
8
+
9
+ export interface MarkdownNode {
10
+ type: string;
11
+ position?: { start: { offset?: number }; end: { offset?: number } };
12
+ children?: MarkdownNode[];
13
+ value?: string;
14
+ depth?: number;
15
+ ordered?: boolean | null;
16
+ start?: number | null;
17
+ checked?: boolean | null;
18
+ lang?: string | null;
19
+ url?: string;
20
+ title?: string | null;
21
+ align?: Array<string | null> | null;
22
+ alt?: string | null;
23
+ identifier?: string;
24
+ label?: string | null;
25
+ }
26
+
27
+ export function* markdownNodes(node: MarkdownNode): Generator<MarkdownNode> {
28
+ yield node;
29
+
30
+ for (const child of node.children ?? []) yield* markdownNodes(child);
31
+ }
32
+
33
+ /** The text a node carries, its own and every descendant's. */
34
+ export function markdownText(node: MarkdownNode): string {
35
+ return [...markdownNodes(node)].map((child) => child.value ?? "").join("");
36
+ }
37
+
38
+ /** Shared syntax for submit-time source checks and safe Markdown rendering. */
39
+ export function parseMarkdown(source: string): MarkdownNode {
40
+ const tree: MarkdownNode = fromMarkdown(source, {
41
+ extensions: [gfm(), math(), latexMath()],
42
+ mdastExtensions: [
43
+ gfmFromMarkdown(),
44
+ mathFromMarkdown(),
45
+ latexMathFromMarkdown(),
46
+ ],
47
+ });
48
+
49
+ // `$$x$$` alone in its paragraph is a display equation written on one line.
50
+ for (const node of markdownNodes(tree)) {
51
+ const [only, ...others] = node.children ?? [];
52
+
53
+ if (
54
+ node.type === "paragraph" &&
55
+ only?.type === "inlineMath" &&
56
+ others.length === 0 &&
57
+ source.startsWith("$$", only.position?.start.offset)
58
+ )
59
+ only.type = "math";
60
+ }
61
+
62
+ const definitions = new Map<string, MarkdownNode>();
63
+
64
+ for (const node of markdownNodes(tree))
65
+ if (node.type === "definition" && !definitions.has(node.identifier!))
66
+ definitions.set(node.identifier!, node);
67
+
68
+ for (const node of markdownNodes(tree)) {
69
+ if (node.type !== "linkReference") continue;
70
+ const definition = definitions.get(node.identifier!);
71
+
72
+ if (definition)
73
+ Object.assign(node, {
74
+ type: "link",
75
+ url: definition.url,
76
+ title: definition.title,
77
+ });
78
+ }
79
+
80
+ return tree;
81
+ }