Skip to content
182 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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