1import {
2 type ComposerContextId,
3 ComposerContextRecord,
4 type ElementContextRecord,
5 type PreviewAnnotationContextRecord,
6 ReviewCommentContextRecord,
7 type TerminalContextRecord,
8} from "@t3tools/contracts";
9import * as Schema from "effect/Schema";
10
11import { formatComposerContextReference } from "./composerContextReferences.ts";
12
13/** Converts in memory on read; never rewrites event history. */
14
15export interface UpgradedLegacyContext {
16 text: string;
17 records: ComposerContextRecord[];
18}
19
20const PLACEHOLDER = "";
21const TRAILING_TERMINAL =
22 /\n*<terminal_context>\n((?:(?!\n<\/terminal_context>)[\s\S])*)\n<\/terminal_context>\s*$/;
23const TRAILING_ELEMENT =
24 /\n*<element_context>\n((?:(?!\n<\/element_context>)[\s\S])*)\n<\/element_context>\s*$/;
25const TRAILING_PREVIEW =
26 /\n*<preview_annotation>\n((?:(?!\n<\/preview_annotation>)[\s\S])*)\n<\/preview_annotation>\s*$/;
27const REVIEW_OR_CONTEXT_BLOCK =
28 /<review_comment\b([^>]*)>|^<(terminal_context|element_context|preview_annotation)>\n[\s\S]*?\n<\/\2>/gm;
29const REVIEW_ATTRIBUTE = /([a-zA-Z][a-zA-Z0-9_-]*)="([^"]*)"/g;
30const REVIEW_FENCE = /(`{3,})([^\s`]*)[^\n]*\n([\s\S]*?)\n\1/g;
31const REVIEW_TOKEN = "\uE000";
32const LEGACY_MARKERS =
33 /<(?:terminal_context|element_context|preview_annotation|review_comment)\b|/;
34const isReviewCommentContextRecord = Schema.is(ReviewCommentContextRecord);
35const isLegacyContextRecords = Schema.is(
36 Schema.Array(ComposerContextRecord).check(Schema.isMaxLength(200)),
37);
38
39interface ParsedEntry {
40 header: string;
41 body: string;
42}
43
44function parseEntries(block: string): ParsedEntry[] | null {
45 const entries: ParsedEntry[] = [];
46 let current: { header: string; bodyLines: string[] } | null = null;
47 const commit = () => {
48 if (!current) return;
49 entries.push({ header: current.header, body: current.bodyLines.join("\n").trimEnd() });
50 current = null;
51 };
52 for (const line of block.split("\n")) {
53 const headerMatch = /^- (.+):$/.exec(line);
54 if (headerMatch) {
55 commit();
56 current = { header: headerMatch[1]!, bodyLines: [] };
57 continue;
58 }
59 if (current && line.startsWith(" ")) current.bodyLines.push(line.slice(2));
60 else if (line.trim().length > 0) return null;
61 else if (current) current.bodyLines.push("");
62 }
63 commit();
64 return entries;
65}
66
67function inlineTerminalLabel(record: TerminalContextRecord): string {
68 const slug = record.terminalLabel.trim().toLowerCase().replace(/\s+/g, "-");
69 const range =
70 record.lineStart === record.lineEnd
71 ? `${record.lineStart}`
72 : `${record.lineStart}-${record.lineEnd}`;
73 return `@${slug}:${range}`;
74}
75
76function legacyId(kind: string, index: number): ComposerContextId {
77 return `legacy_${kind}_${index}` as ComposerContextId;
78}
79
80function terminalRecord(entry: ParsedEntry, index: number): TerminalContextRecord | null {
81 const header = /^(.*?) (?:line (\d+)|lines (\d+)-(\d+))$/.exec(entry.header);
82 if (!header) return null;
83 const terminalLabel = header[1]!.trim();
84 if (!terminalLabel) return null;
85 const lineStart = Number(header[2] ?? header[3]);
86 const lineEnd = Number(header[2] ?? header[4]);
87 const text = entry.body
88 .split("\n")
89 .map((line) => line.replace(/^\d+ \| ?/, ""))
90 .join("\n");
91 return {
92 version: 1,
93 contextId: legacyId("terminal", index),
94 kind: "terminal",
95 label: entry.header,
96 terminalId: terminalLabel.toLowerCase().replace(/\s+/g, "-"),
97 terminalLabel,
98 lineStart,
99 lineEnd,
100 text,
101 };
102}
103
104function parseSource(location: string): ElementContextRecord["source"] {
105 const match = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(location);
106 if (!match || !match[1]) return null;
107 return {
108 functionName: null,
109 fileName: match[1],
110 lineNumber: match[2] === undefined ? null : Number(match[2]),
111 columnNumber: match[3] === undefined ? null : Number(match[3]),
112 };
113}
114
115function elementRecord(entry: ParsedEntry, index: number): ElementContextRecord | null {
116 const header = /^<([^>]+)>/.exec(entry.header);
117 if (!header) return null;
118 const inner = header[1]!;
119 const fields: Record<string, string> = {};
120 const sections: Record<string, string[]> = {};
121 let section: string | null = null;
122 for (const line of entry.body.split("\n")) {
123 if (section && (line.startsWith(" ") || line.length === 0)) {
124 sections[section]!.push(line.slice(2));
125 continue;
126 }
127 section = null;
128 const field = /^([a-z]+): (.*)$/.exec(line);
129 if (field) {
130 fields[field[1]!] = field[2]!;
131 continue;
132 }
133 const sectionStart = /^(html|styles):$/.exec(line);
134 if (sectionStart) {
135 section = sectionStart[1]!;
136 sections[section] = [];
137 }
138 }
139 return {
140 version: 1,
141 contextId: legacyId("element", index),
142 kind: "element",
143 label: `<${inner}>`,
144 pageUrl: fields.url ?? "",
145 pageTitle: null,
146 tagName: inner.toLowerCase(),
147 selector: fields.selector ?? null,
148 htmlPreview: (sections.html ?? []).join("\n").trimEnd(),
149 componentName: /[A-Z]/.test(inner) ? inner : null,
150 source: fields.source ? parseSource(fields.source) : null,
151 styles: (sections.styles ?? []).join("\n").trimEnd(),
152 };
153}
154
155function previewRecord(body: string, index: number): PreviewAnnotationContextRecord | null {
156 const lines = body.split("\n");
157 // Preserve multiline comments, including blank lines and markup, until the next field or context block.
158 const FIELD_PREFIXES = ["Preview annotation:", "Id: ", "Page: ", "Comment: ", "Targets: "];
159 const BLOCK_DELIMITER =
160 /^<\/?(?:terminal_context|element_context|preview_annotation|review_comment)\b/;
161 const isFieldStart = (line: string) =>
162 FIELD_PREFIXES.some((candidate) => line.startsWith(candidate)) ||
163 line === "Requested visual changes:" ||
164 BLOCK_DELIMITER.test(line) ||
165 line === "The attached screenshot is the annotated preview crop.";
166 const read = (prefix: string) => {
167 const start = lines.findIndex((line) => line.startsWith(prefix));
168 if (start < 0) return "";
169 let end = start + 1;
170 while (end < lines.length && !isFieldStart(lines[end]!)) end += 1;
171 return [lines[start]!.slice(prefix.length), ...lines.slice(start + 1, end)].join("\n").trim();
172 };
173 const styleHeading = lines.indexOf("Requested visual changes:");
174 const styleChanges: string[] = [];
175 if (styleHeading >= 0) {
176 for (const line of lines.slice(styleHeading + 1)) {
177 if (!line.startsWith("- ")) break;
178 styleChanges.push(line.slice(2));
179 }
180 }
181 const page = read("Page: ");
182 const pageIsUrl = /^https?:\/\//i.test(page);
183 const elements: NonNullable<PreviewAnnotationContextRecord["elements"]>[number][] = [];
184 for (const match of body.matchAll(/<element_context>\n([\s\S]*?)\n<\/element_context>/g)) {
185 const entries = parseEntries(match[1] ?? "");
186 if (entries === null) return null;
187 for (const entry of entries) {
188 const record = elementRecord(entry, elements.length + 1);
189 if (record === null) return null;
190 const {
191 version: _version,
192 contextId: _contextId,
193 kind: _kind,
194 label: _label,
195 ...details
196 } = record;
197 elements.push(details);
198 }
199 }
200 return {
201 version: 1,
202 contextId: legacyId("preview-annotation", index),
203 kind: "preview-annotation",
204 label: page || "Preview annotation",
205 annotationId: read("Id: "),
206 pageUrl: pageIsUrl ? page : (elements[0]?.pageUrl ?? ""),
207 pageTitle: pageIsUrl ? null : page || null,
208 comment: read("Comment: "),
209 targetSummary: read("Targets: "),
210 styleChanges,
211 ...(elements.length > 0 ? { elements } : {}),
212 };
213}
214
215function unescapeAttribute(value: string): string {
216 return value
217 .replace(/</g, "<")
218 .replace(/>/g, ">")
219 .replace(/"/g, '"')
220 .replace(/&/g, "&");
221}
222
223function reviewRecord(
224 rawAttributes: string,
225 rawBody: string,
226 index: number,
227): ReviewCommentContextRecord | null {
228 const attributes: Record<string, string> = {};
229 for (const match of rawAttributes.matchAll(REVIEW_ATTRIBUTE)) {
230 attributes[match[1]!] = unescapeAttribute(match[2] ?? "");
231 }
232 const filePath = attributes.filePath?.trim();
233 const sectionId = attributes.sectionId?.trim();
234 const startIndex = attributes.startIndex;
235 const endIndex = attributes.endIndex;
236 if (!filePath || !sectionId || !/^\d+$/.test(startIndex ?? "") || !/^\d+$/.test(endIndex ?? "")) {
237 return null;
238 }
239 const fences = Array.from(rawBody.matchAll(REVIEW_FENCE));
240 const fence = fences.at(-1);
241 const rangeLabel = attributes.rangeLabel?.trim() || "line";
242 const basename = filePath.split(/[\\/]/).at(-1) ?? filePath;
243 return {
244 version: 1,
245 contextId: legacyId("review-comment", index),
246 kind: "review-comment",
247 label: `${basename} ${rangeLabel}`,
248 sectionId,
249 sectionTitle: attributes.sectionTitle?.trim() || "Review",
250 filePath,
251 startIndex: Math.min(Number(startIndex), Number(endIndex)),
252 endIndex: Math.max(Number(startIndex), Number(endIndex)),
253 rangeLabel,
254 text: rawBody.slice(0, fence?.index ?? rawBody.length).trim(),
255 diff: fence?.[3] ?? "",
256 fenceLanguage: fence?.[2]?.trim() || "diff",
257 };
258}
259
260function stripTrailing(
261 text: string,
262 pattern: RegExp,
263): { text: string; match: RegExpExecArray } | null {
264 const match = pattern.exec(text);
265 if (!match) return null;
266 return { text: text.slice(0, match.index).replace(/\n+$/, ""), match };
267}
268
269/** Review source can contain literal closing tags inside its dynamically sized code fence. */
270function replaceReviewBlocks(
271 text: string,
272 replace: (whole: string, attributes: string, body: string) => string,
273): string {
274 const parts: string[] = [];
275 const openings = new RegExp(REVIEW_OR_CONTEXT_BLOCK);
276 let consumed = 0;
277 for (let opening = openings.exec(text); opening; opening = openings.exec(text)) {
278 // Other context blocks own their payload, including any review-shaped source text.
279 if (opening[2]) continue;
280 const bodyStart = openings.lastIndex;
281 const boundaries = /^(`{3,})([^\n]*)$|<\/review_comment>/gm;
282 boundaries.lastIndex = bodyStart;
283 let fenceLength = 0;
284 for (let boundary = boundaries.exec(text); boundary; boundary = boundaries.exec(text)) {
285 if (boundary[1]) {
286 if (fenceLength === 0) fenceLength = boundary[1].length;
287 else if (boundary[1].length >= fenceLength && !boundary[2]?.trim()) fenceLength = 0;
288 } else if (fenceLength === 0) {
289 parts.push(text.slice(consumed, opening.index));
290 consumed = boundaries.lastIndex;
291 parts.push(
292 replace(
293 text.slice(opening.index, consumed),
294 opening[1]!,
295 text.slice(bodyStart, boundary.index),
296 ),
297 );
298 openings.lastIndex = consumed;
299 break;
300 }
301 }
302 if (consumed < bodyStart) break;
303 }
304 parts.push(text.slice(consumed));
305 return parts.join("");
306}
307
308export function upgradeLegacyContextMessage(text: string): UpgradedLegacyContext {
309 if (!LEGACY_MARKERS.test(text)) return { text, records: [] };
310
311 const terminalEntries: ParsedEntry[] = [];
312 const elementEntries: ParsedEntry[] = [];
313 const previewBodies: string[] = [];
314 const reviews: ReviewCommentContextRecord[] = [];
315
316 // A literal private-use token in the message must never be mistaken for our placeholder.
317 let reviewToken = REVIEW_TOKEN;
318 while (text.includes(reviewToken)) reviewToken += reviewToken;
319 const reviewTokenPattern = new RegExp(`${reviewToken}(\\d+)${reviewToken}`, "g");
320 const trailingReviewTokenPattern = new RegExp(`(?:\\s*${reviewToken}\\d+${reviewToken})+\\s*$`);
321
322 let rest = replaceReviewBlocks(text, (whole, attributes, rawBody) => {
323 const record = reviewRecord(attributes, rawBody, reviews.length + 1);
324 if (!record || !isReviewCommentContextRecord(record)) return whole;
325 reviews.push(record);
326 return `${reviewToken}${reviews.length - 1}${reviewToken}`;
327 });
328
329 // Peel appended blocks in reverse send order. Peel reviews only to expose context blocks, preserving inline prose spacing.
330 const trailingReviewTokens: number[] = [];
331 const tokens = trailingReviewTokenPattern.exec(rest);
332 if (tokens && tokens[0].length > 0) {
333 const preceding = rest.slice(0, tokens.index);
334 if (
335 TRAILING_PREVIEW.test(preceding) ||
336 TRAILING_ELEMENT.test(preceding) ||
337 TRAILING_TERMINAL.test(preceding)
338 ) {
339 trailingReviewTokens.push(
340 ...Array.from(tokens[0].matchAll(reviewTokenPattern), (m) => Number(m[1])),
341 );
342 rest = preceding;
343 }
344 }
345 for (;;) {
346 const preview = stripTrailing(rest, TRAILING_PREVIEW);
347 if (preview) {
348 rest = preview.text;
349 previewBodies.unshift(preview.match[1] ?? "");
350 continue;
351 }
352 const element = stripTrailing(rest, TRAILING_ELEMENT);
353 if (element) {
354 const entries = parseEntries(element.match[1] ?? "");
355 if (!entries?.length || entries.some((entry) => elementRecord(entry, 1) === null))
356 return { text, records: [] };
357 rest = element.text;
358 elementEntries.unshift(...entries);
359 continue;
360 }
361 const terminal = stripTrailing(rest, TRAILING_TERMINAL);
362 if (terminal) {
363 const entries = parseEntries(terminal.match[1] ?? "");
364 if (!entries?.length || entries.some((entry) => terminalRecord(entry, 1) === null))
365 return { text, records: [] };
366 rest = terminal.text;
367 terminalEntries.unshift(...entries);
368 continue;
369 }
370 break;
371 }
372
373 const terminals = terminalEntries
374 .map((entry, index) => terminalRecord(entry, index + 1))
375 .filter((record) => record !== null);
376 const elements = elementEntries
377 .map((entry, index) => elementRecord(entry, index + 1))
378 .filter((record) => record !== null);
379 const previews: PreviewAnnotationContextRecord[] = [];
380 for (const [index, previewBody] of previewBodies.entries()) {
381 const record = previewRecord(previewBody, index + 1);
382 if (record === null) return { text, records: [] };
383 previews.push(record);
384 }
385 const appendedReviews = trailingReviewTokens.map((index) => reviews[index]!);
386
387 let body = rest.replace(reviewTokenPattern, (_whole, index: string) =>
388 formatComposerContextReference(reviews[Number(index)]!),
389 );
390
391 let placeholderIndex = 0;
392 body = body.replace(new RegExp(PLACEHOLDER, "g"), () => {
393 const record = terminals[placeholderIndex];
394 placeholderIndex += 1;
395 return record ? formatComposerContextReference(record) : "";
396 });
397
398 const placedTerminals = new Set(terminals.slice(0, placeholderIndex));
399 for (const record of terminals) {
400 if (placedTerminals.has(record)) continue;
401 const label = inlineTerminalLabel(record);
402 let at = body.indexOf(label);
403 // Alternation instead of a character class: on Electron 44's V8 (15.2) a class
404 // directly before `$` with the `u` flag never matches a code point outside the
405 // BMP, so `𐐀@build:7` would become a chip.
406 while (
407 at !== -1 &&
408 (/(?:\p{L}|\p{N}|\p{M}|[_@.-])$/u.test(body.slice(0, at)) ||
409 /^(?:[\p{L}\p{N}\p{M}_-]|[.@]+[\p{L}\p{N}\p{M}_-])/u.test(body.slice(at + label.length)))
410 ) {
411 at = body.indexOf(label, at + 1);
412 }
413 if (at === -1) continue;
414 body = `${body.slice(0, at)}${formatComposerContextReference(record)}${body.slice(at + label.length)}`;
415 placedTerminals.add(record);
416 }
417 body = body.trimEnd();
418
419 const appended = [
420 ...terminals.filter((record) => !placedTerminals.has(record)),
421 ...elements,
422 ...previews,
423 ...appendedReviews,
424 ].map((record) => formatComposerContextReference(record));
425 const upgradedText =
426 appended.length === 0
427 ? body
428 : body.length > 0
429 ? `${body}\n\n${appended.join(" ")}`
430 : appended.join(" ");
431
432 const records = [...terminals, ...elements, ...previews, ...reviews];
433 // Conversion is atomic: keep the source if any record would be dropped on the wire.
434 if (!isLegacyContextRecords(records)) return { text, records: [] };
435 return {
436 text: upgradedText,
437 records,
438 };
439}