1import type { ComposerContextRecord, ElementContextDetails } from "@t3tools/contracts";
2
3import { 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 */
13export 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
55function 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
67function 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
72function indent(value: string): string {
73 return value
74 .split("\n")
75 .map((line) => (line.length === 0 ? "" : ` ${line}`))
76 .join("\n");
77}
78
79function 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
94function renderElementEntry(record: ComposerContextRecord): string {
95 if (!("tagName" in record)) return "";
96 return renderElementDetailsEntry(record);
97}
98
99function 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
115function 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
133function 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
141function 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}