Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

191 linesCodeBlameRaw
1/**
2 * Changes made to a page's live document on the server: an agent's edit,
3 * an accepted suggestion, a restored version, a comment's anchor. Each
4 * runs in one Yjs transaction, so every open editor receives it as one
5 * update and the change merges with whatever people are typing. Pure
6 * (yjs only).
7 */
8import type { DocEditTarget } from "@g1t/contracts";
9import * as Y from "yjs";
10
11import { blockGroupOf, parseMarkdown, writeBlocks } from "./blocks.ts";
12import { blocksMarkdown, partsOf, plainText, topContainers } from "./markdown.ts";
13
14/** Top-level blocks `start` through `end - 1`; `start === end` is an insertion point. */
15export type Range = { start: number; end: number };
16
17function norm(text: string): string {
18 return text
19 .replace(/[*_`~#]/g, "")
20 .replace(/\s+/g, " ")
21 .trim()
22 .toLowerCase();
23}
24
25/** Where a target is in the document now, or null when it is gone. */
26export function findTarget(fragment: Y.XmlFragment, target: DocEditTarget): Range | null {
27 const tops = topContainers(fragment);
28 switch (target.kind) {
29 case "append":
30 return { start: tops.length, end: tops.length };
31 case "document":
32 return { start: 0, end: tops.length };
33 case "section": {
34 const want = norm(target.heading);
35 if (!want) return null;
36 const at = tops.findIndex((c) => {
37 const content = partsOf(c).content;
38 return content?.nodeName === "heading" && norm(plainText(content)) === want;
39 });
40 if (at < 0) return null;
41 const level = Number(partsOf(tops[at]!).content!.getAttribute("level")) || 1;
42 let end = at + 1;
43 while (end < tops.length) {
44 const content = partsOf(tops[end]!).content;
45 if (content?.nodeName === "heading" && (Number(content.getAttribute("level")) || 1) <= level) break;
46 end++;
47 }
48 return { start: at, end };
49 }
50 case "blocks": {
51 const from = tops.findIndex((c) => c.getAttribute("id") === target.from_block);
52 const to = tops.findIndex((c) => c.getAttribute("id") === target.to_block);
53 if (from < 0 || to < 0) return null;
54 return { start: Math.min(from, to), end: Math.max(from, to) + 1 };
55 }
56 default:
57 return null;
58 }
59}
60
61/** The Markdown of a range of top-level blocks. */
62export function rangeMarkdown(fragment: Y.XmlFragment, range: Range): string {
63 return blocksMarkdown(topContainers(fragment).slice(range.start, range.end));
64}
65
66/** The ids of a range's top-level blocks. */
67export function rangeIds(fragment: Y.XmlFragment, range: Range): string[] {
68 return topContainers(fragment)
69 .slice(range.start, range.end)
70 .map((c) => String(c.getAttribute("id") ?? ""));
71}
72
73/**
74 * Replaces a range of top-level blocks with Markdown, in one transaction
75 * whose origin is `origin` (who made it, for the page's history).
76 */
77export function replaceRange(doc: Y.Doc, fragment: Y.XmlFragment, range: Range, markdown: string, origin: unknown = null): void {
78 const specs = parseMarkdown(markdown);
79 doc.transact(() => {
80 const group = blockGroupOf(fragment);
81 // Only containers count; the group holds nothing else.
82 const count = Math.min(range.end, group.length) - range.start;
83 if (count > 0) group.delete(range.start, count);
84 if (specs.length) group.insert(range.start, writeBlocks(specs));
85 // The editor always keeps one block.
86 if (group.length === 0) group.insert(0, writeBlocks([]));
87 }, origin);
88}
89
90/** Applies an edit to a target; false when the target is gone. */
91export function applyEdit(doc: Y.Doc, fragment: Y.XmlFragment, target: DocEditTarget, markdown: string, origin: unknown = null): boolean {
92 const range = findTarget(fragment, target);
93 if (!range) return false;
94 replaceRange(doc, fragment, range, String(markdown ?? ""), origin);
95 return true;
96}
97
98/**
99 * Makes the document's blocks what another document's were (restoring a
100 * version): the current blocks are removed and copies of the old ones put
101 * in, as one change that every open editor merges.
102 */
103export function restoreFrom(doc: Y.Doc, fragment: Y.XmlFragment, old: Y.XmlFragment, origin: unknown = null): void {
104 const oldGroup = old.toArray().find((n): n is Y.XmlElement => n instanceof Y.XmlElement && n.nodeName === "blockGroup");
105 doc.transact(() => {
106 const group = blockGroupOf(fragment);
107 if (group.length) group.delete(0, group.length);
108 const copies = (oldGroup?.toArray() ?? []).filter((n): n is Y.XmlElement => n instanceof Y.XmlElement).map((n) => n.clone());
109 group.insert(0, copies.length ? copies : writeBlocks([]));
110 }, origin);
111}
112
113/** Every text in the document, in document order. */
114function texts(node: Y.XmlFragment | Y.XmlElement, out: Y.XmlText[] = []): Y.XmlText[] {
115 for (const child of node.toArray()) {
116 if (child instanceof Y.XmlText) out.push(child);
117 else if (child instanceof Y.XmlElement) texts(child, out);
118 }
119 return out;
120}
121
122/** The mark key y-prosemirror uses for a comment (overlapping marks carry an 8-character suffix). */
123export function commentMarkKey(threadId: string): string {
124 let h = 0x811c9dc5;
125 for (let i = 0; i < threadId.length; i++) {
126 h ^= threadId.charCodeAt(i);
127 h = Math.imul(h, 0x01000193);
128 }
129 const bytes = [h >>> 24, (h >>> 16) & 255, (h >>> 8) & 255, h & 255, threadId.length & 255, 0x2a];
130 return `comment--${btoa(String.fromCharCode(...bytes))}`;
131}
132
133/**
134 * Marks the text between two positions with a comment thread, the way the
135 * editor does: the `comment` mark on every character in between, across
136 * blocks if need be. Returns the text marked (the thread's quote), or null
137 * when the positions are not in this document's text.
138 */
139export function anchorThread(doc: Y.Doc, fragment: Y.XmlFragment, anchor: unknown, head: unknown, threadId: string): string | null {
140 let a: Y.AbsolutePosition | null = null;
141 let b: Y.AbsolutePosition | null = null;
142 try {
143 a = Y.createAbsolutePositionFromRelativePosition(Y.createRelativePositionFromJSON(anchor), doc);
144 b = Y.createAbsolutePositionFromRelativePosition(Y.createRelativePositionFromJSON(head), doc);
145 } catch {
146 return null;
147 }
148 if (!a || !b || !(a.type instanceof Y.XmlText) || !(b.type instanceof Y.XmlText)) return null;
149 const all = texts(fragment);
150 let from = { at: all.indexOf(a.type as Y.XmlText), index: a.index };
151 let to = { at: all.indexOf(b.type as Y.XmlText), index: b.index };
152 if (from.at < 0 || to.at < 0) return null;
153 if (from.at > to.at || (from.at === to.at && from.index > to.index)) [from, to] = [to, from];
154 const key = commentMarkKey(threadId);
155 const value = { orphan: false, threadId };
156 let quote = "";
157 doc.transact(() => {
158 for (let t = from.at; t <= to.at; t++) {
159 const text = all[t]!;
160 const start = t === from.at ? from.index : 0;
161 const end = t === to.at ? to.index : text.length;
162 if (end > start) {
163 text.format(start, end - start, { [key]: value });
164 quote +=
165 (quote ? " " : "") +
166 text
167 .toString()
168 .replace(/<[^>]+>/g, "")
169 .slice(start, end);
170 }
171 }
172 });
173 return quote || null;
174}
175
176/** Takes a deleted thread's mark off the text it was on. */
177export function unanchorThread(doc: Y.Doc, fragment: Y.XmlFragment, threadId: string): void {
178 const key = commentMarkKey(threadId);
179 doc.transact(() => {
180 for (const text of texts(fragment)) {
181 let at = 0;
182 for (const d of text.toDelta() as { insert: string | object; attributes?: Record<string, { threadId?: string }> }[]) {
183 const length = typeof d.insert === "string" ? d.insert.length : 1;
184 for (const [name, attrs] of Object.entries(d.attributes ?? {})) {
185 if (name.startsWith("comment") && attrs?.threadId === threadId) text.format(at, length, { [name]: null, [key]: null });
186 }
187 at += length;
188 }
189 }
190 });
191}