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