| 1 | import type { ComposerContextRecord, ElementContextDetails } from "@t3tools/contracts"; |
| 2 | |
| 3 | import { collectComposerContextReferences } from "./composerContextReferences.ts"; |
| 4 | |
| 5 | /** |
| 6 | * Serializes a canonical message (inline reference links plus records) into the pre-inline-context |
| 7 | * wire shape, for servers that do not advertise `inlineMessageContext`. Those servers drop the |
| 8 | * records and forward the links as literal text, so the payload has to travel inside the message. |
| 9 | * |
| 10 | * The output is the format `upgradeLegacyContextMessage` parses, so a newer client reading the |
| 11 | * resulting message reconstructs the same records. |
| 12 | */ |
| 13 | export function serializeLegacyContextMessage(input: { |
| 14 | text: string; |
| 15 | records: ReadonlyArray<ComposerContextRecord>; |
| 16 | }): string { |
| 17 | const recordsById = new Map(input.records.map((record) => [record.contextId, record])); |
| 18 | const used = new Set<string>(); |
| 19 | |
| 20 | // Inline references become the plain label the old composer wrote; the payload follows in a |
| 21 | // trailing block, which is where the legacy format carried it. |
| 22 | let text = input.text; |
| 23 | const occurrences = collectComposerContextReferences(text); |
| 24 | for (const occurrence of [...occurrences].reverse()) { |
| 25 | const record = recordsById.get(occurrence.contextId); |
| 26 | if (!record) continue; |
| 27 | used.add(occurrence.contextId); |
| 28 | const replacement = |
| 29 | record.kind === "review-comment" ? renderReviewComment(record) : inlineLabel(record); |
| 30 | text = `${text.slice(0, occurrence.start)}${replacement}${text.slice(occurrence.end)}`; |
| 31 | } |
| 32 | |
| 33 | const blocks = [ |
| 34 | renderBlock( |
| 35 | "terminal_context", |
| 36 | input.records.filter((record) => record.kind === "terminal").map(renderTerminalEntry), |
| 37 | ), |
| 38 | renderBlock( |
| 39 | "element_context", |
| 40 | input.records.filter((record) => record.kind === "element").map(renderElementEntry), |
| 41 | ), |
| 42 | ...input.records |
| 43 | .filter((record) => record.kind === "preview-annotation") |
| 44 | .map((record) => `<preview_annotation>\n${renderPreviewBody(record)}\n</preview_annotation>`), |
| 45 | ...input.records |
| 46 | .filter((record) => record.kind === "review-comment" && !used.has(record.contextId)) |
| 47 | .map(renderReviewComment), |
| 48 | ].filter((block) => block.length > 0); |
| 49 | |
| 50 | // Review comments already inlined their payload above; anything else unreferenced still ships |
| 51 | // its block so no context is silently dropped. |
| 52 | return [text.trimEnd(), ...blocks].filter((part) => part.length > 0).join("\n\n"); |
| 53 | } |
| 54 | |
| 55 | function inlineLabel(record: ComposerContextRecord): string { |
| 56 | if (record.kind === "terminal" && "terminalLabel" in record) { |
| 57 | const slug = record.terminalLabel.trim().toLowerCase().replace(/\s+/g, "-"); |
| 58 | const range = |
| 59 | record.lineStart === record.lineEnd |
| 60 | ? `${record.lineStart}` |
| 61 | : `${record.lineStart}-${record.lineEnd}`; |
| 62 | return `@${slug}:${range}`; |
| 63 | } |
| 64 | return record.label; |
| 65 | } |
| 66 | |
| 67 | function renderBlock(tag: string, entries: ReadonlyArray<string>): string { |
| 68 | const body = entries.filter((entry) => entry.length > 0).join("\n"); |
| 69 | return body.length === 0 ? "" : `<${tag}>\n${body}\n</${tag}>`; |
| 70 | } |
| 71 | |
| 72 | function indent(value: string): string { |
| 73 | return value |
| 74 | .split("\n") |
| 75 | .map((line) => (line.length === 0 ? "" : ` ${line}`)) |
| 76 | .join("\n"); |
| 77 | } |
| 78 | |
| 79 | function renderTerminalEntry(record: ComposerContextRecord): string { |
| 80 | if (!("terminalLabel" in record)) return ""; |
| 81 | const range = |
| 82 | record.lineStart === record.lineEnd |
| 83 | ? `line ${record.lineStart}` |
| 84 | : `lines ${record.lineStart}-${record.lineEnd}`; |
| 85 | const body = record.text |
| 86 | .split("\n") |
| 87 | // A trailing newline would otherwise number a line past the range the label declares. |
| 88 | .slice(0, record.lineEnd - record.lineStart + 1) |
| 89 | .map((line, index) => `${record.lineStart + index} | ${line}`) |
| 90 | .join("\n"); |
| 91 | return `- ${record.terminalLabel} ${range}:\n${indent(body)}`; |
| 92 | } |
| 93 | |
| 94 | function renderElementEntry(record: ComposerContextRecord): string { |
| 95 | if (!("tagName" in record)) return ""; |
| 96 | return renderElementDetailsEntry(record); |
| 97 | } |
| 98 | |
| 99 | function renderElementDetailsEntry(record: ElementContextDetails): string { |
| 100 | const lines: string[] = []; |
| 101 | if (record.pageUrl) lines.push(`url: ${record.pageUrl}`); |
| 102 | if (record.selector) lines.push(`selector: ${record.selector}`); |
| 103 | const source = record.source; |
| 104 | if (source?.fileName) { |
| 105 | const position = [source.lineNumber, source.columnNumber].filter( |
| 106 | (part) => part !== null && part !== undefined, |
| 107 | ); |
| 108 | lines.push(`source: ${[source.fileName, ...position].join(":")}`); |
| 109 | } |
| 110 | if (record.htmlPreview) lines.push(`html:\n${indent(record.htmlPreview)}`); |
| 111 | if (record.styles) lines.push(`styles:\n${indent(record.styles)}`); |
| 112 | return `- <${record.componentName ?? record.tagName}>:\n${indent(lines.join("\n"))}`; |
| 113 | } |
| 114 | |
| 115 | function renderPreviewBody(record: ComposerContextRecord): string { |
| 116 | if (!("annotationId" in record)) return ""; |
| 117 | const lines = [`Id: ${record.annotationId}`, `Page: ${record.pageUrl || record.pageTitle || ""}`]; |
| 118 | if (record.comment) lines.push(`Comment: ${record.comment}`); |
| 119 | if (record.targetSummary) lines.push(`Targets: ${record.targetSummary}`); |
| 120 | if (record.styleChanges.length > 0) { |
| 121 | lines.push("Requested visual changes:", ...record.styleChanges.map((change) => `- ${change}`)); |
| 122 | } |
| 123 | if (record.elements && record.elements.length > 0) { |
| 124 | lines.push( |
| 125 | "<element_context>", |
| 126 | record.elements.map(renderElementDetailsEntry).join("\n"), |
| 127 | "</element_context>", |
| 128 | ); |
| 129 | } |
| 130 | return lines.join("\n"); |
| 131 | } |
| 132 | |
| 133 | function escapeAttribute(value: string): string { |
| 134 | return value |
| 135 | .replace(/&/g, "&amp;") |
| 136 | .replace(/</g, "&lt;") |
| 137 | .replace(/>/g, "&gt;") |
| 138 | .replace(/"/g, "&quot;"); |
| 139 | } |
| 140 | |
| 141 | function renderReviewComment(record: ComposerContextRecord): string { |
| 142 | if (!("sectionId" in record)) return record.label; |
| 143 | const attributes = [ |
| 144 | `sectionId="${escapeAttribute(record.sectionId)}"`, |
| 145 | `sectionTitle="${escapeAttribute(record.sectionTitle)}"`, |
| 146 | `filePath="${escapeAttribute(record.filePath)}"`, |
| 147 | `rangeLabel="${escapeAttribute(record.rangeLabel)}"`, |
| 148 | `startIndex="${record.startIndex}"`, |
| 149 | `endIndex="${record.endIndex}"`, |
| 150 | ].join(" "); |
| 151 | // The fence must be longer than any run of backticks inside the diff so the body stays intact. |
| 152 | const longestRun = Math.max( |
| 153 | 0, |
| 154 | ...[...record.diff.matchAll(/`+/g)].map((match) => match[0].length), |
| 155 | ); |
| 156 | const fence = "`".repeat(Math.max(3, longestRun + 1)); |
| 157 | const body = record.diff |
| 158 | ? `${record.text}\n\n${fence}${record.fenceLanguage ?? "diff"}\n${record.diff}\n${fence}` |
| 159 | : record.text; |
| 160 | return `<review_comment ${attributes}>\n${body}\n</review_comment>`; |
| 161 | } |