Cr_oss — The Atlas Cr_oss's docs bound to its code: the README and the code-verified architecture analysis, drift-checked against the tree.
6 documents

Following a redline through the engine

The path a draft takes from baseline-plus-current text to email-safe redline HTML — the doc section first, then the modules that do the work.

src/redline/htmlPlainMap.ts347 lines
Outline 20 symbols
1import type { FormattingContext } from './dominantStyle';
2import { wrapWithDominantInline } from './dominantStyle';
3import { decodeHtmlEntities } from './normalize';
4import { escapeHtml, formatTextForHtml } from './render';
5
6export interface PlainTextMap {
7 text: string;
8 /** Text spans with pre-serialized HTML for each contiguous text node run. */
9 segments: Array<{ start: number; end: number; html: string }>;
10}
11
12const BLOCK_TAGS = new Set(['P', 'DIV', 'LI', 'TR', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6']);
13const INLINE_FORMATTING_PATTERN = /<(b|strong|i|em|u|span|font|a)\b/i;
14
15function isRedlineInlineWrapper(element: Element): boolean {
16 return element.hasAttribute('data-redline-inline');
17}
18
19function hasInlineFormatting(html: string): boolean {
20 return INLINE_FORMATTING_PATTERN.test(html);
21}
22
23function collapseNewlines(text: string): string {
24 return text.replace(/\n{3,}/g, '\n\n');
25}
26
27function escapeAttr(value: string): string {
28 return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
29}
30
31function inlineOpenTag(node: Element): string {
32 const tag = node.tagName.toLowerCase();
33 if (tag === 'font') {
34 const face = node.getAttribute('face');
35 const size = node.getAttribute('size');
36 const color = node.getAttribute('color');
37 const attrs = [
38 face ? ` face="${escapeAttr(face)}"` : '',
39 size ? ` size="${escapeAttr(size)}"` : '',
40 color ? ` color="${escapeAttr(color)}"` : '',
41 ].join('');
42 return `<font${attrs}>`;
43 }
44
45 const style = node.getAttribute('style');
46 const styleAttr = style ? ` style="${escapeAttr(style)}"` : '';
47 if (tag === 'a') {
48 const href = node.getAttribute('href');
49 const hrefAttr = href ? ` href="${escapeAttr(href)}"` : '';
50 return `<a${hrefAttr}${styleAttr}>`;
51 }
52
53 return `<${tag}${styleAttr}>`;
54}
55
56/** Walk inline ancestors down to the text node (block tags are handled separately). */
57function serializeTextNode(textNode: Text, body: HTMLElement): string {
58 const content = textNode.textContent ?? '';
59 if (!content) {
60 return '';
61 }
62
63 const chain: Element[] = [];
64 let el: Element | null = textNode.parentElement;
65 while (el && el !== body) {
66 if (isRedlineInlineWrapper(el)) {
67 el = el.parentElement;
68 continue;
69 }
70 if (BLOCK_TAGS.has(el.tagName)) {
71 break;
72 }
73 chain.unshift(el);
74 el = el.parentElement;
75 }
76
77 let html = escapeHtml(content);
78 for (const node of chain) {
79 const open = inlineOpenTag(node);
80 const tag = open.match(/^<([a-z0-9]+)/i)?.[1] ?? node.tagName.toLowerCase();
81 html = `${open}${html}</${tag}>`;
82 }
83
84 return html;
85}
86
87function walkNode(
88 node: Node,
89 body: HTMLElement,
90 text: string,
91 segments: PlainTextMap['segments'],
92): string {
93 if (node.nodeType === Node.TEXT_NODE) {
94 const value = node.textContent ?? '';
95 if (!value) {
96 return text;
97 }
98 const start = text.length;
99 text += value;
100 segments.push({
101 start,
102 end: text.length,
103 html: serializeTextNode(node as Text, body),
104 });
105 return text;
106 }
107
108 if (node.nodeType !== Node.ELEMENT_NODE) {
109 return text;
110 }
111
112 const element = node as Element;
113 if (element.tagName === 'BR') {
114 return `${text}\n`;
115 }
116
117 for (const child of element.childNodes) {
118 text = walkNode(child, body, text, segments);
119 }
120
121 if (BLOCK_TAGS.has(element.tagName) && text.length > 0 && !text.endsWith('\n')) {
122 text += '\n';
123 }
124
125 return text;
126}
127
128/** Build plain text plus index map for HTML-preserving redline render. */
129export function buildPlainTextMap(html: string, options?: { inlineOnly?: boolean }): PlainTextMap {
130 const inlineOnly = options?.inlineOnly ?? false;
131 const source = inlineOnly ? `<div data-redline-inline="">${html}</div>` : html;
132
133 if (!source.trim()) {
134 return { text: '', segments: [] };
135 }
136
137 if (typeof DOMParser === 'undefined') {
138 return buildPlainTextMapFallback(html);
139 }
140
141 const doc = new DOMParser().parseFromString(source, 'text/html');
142 const segments: PlainTextMap['segments'] = [];
143 let text = '';
144
145 for (const child of doc.body.childNodes) {
146 text = walkNode(child, doc.body, text, segments);
147 }
148
149 return { text, segments };
150}
151
152/** Map inline HTML within a single block (no outer p/li wrapper in segments). */
153export function buildInlinePlainTextMap(innerHtml: string): PlainTextMap {
154 return buildPlainTextMap(innerHtml, { inlineOnly: true });
155}
156
157/** Regex fallback when DOMParser is unavailable (tests/SSR). */
158function buildPlainTextMapFallback(html: string): PlainTextMap {
159 let stripped = html
160 .replace(/\r\n/g, '\n')
161 .replace(/<!--[\s\S]*?-->/g, '')
162 .replace(/<br\s*\/?>/gi, '\n')
163 .replace(/<\/p>/gi, '\n')
164 .replace(/<\/div>/gi, '\n')
165 .replace(/<\/li>/gi, '\n')
166 .replace(/<\/tr>/gi, '\n')
167 .replace(/<[^>]+>/g, '');
168
169 stripped = decodeHtmlEntities(stripped);
170 const text = collapseNewlines(stripped);
171
172 if (!text) {
173 return { text: '', segments: [] };
174 }
175
176 return {
177 text,
178 segments: [{ start: 0, end: text.length, html: escapeHtml(text).replace(/\n/g, '<br>') }],
179 };
180}
181
182/** Reapply opening inline tags from a segment when a diff splits mid-run. */
183function wrapPartialWithSegmentHtml(segmentHtml: string, plain: string): string {
184 const formatted = escapeHtml(plain).replace(/\n/g, '<br>');
185 const openTags: string[] = [];
186 const closeTags: string[] = [];
187 const tagPattern = /<\/?([a-z0-9]+)([^>]*)>/gi;
188 let match: RegExpExecArray | null;
189
190 while ((match = tagPattern.exec(segmentHtml)) !== null) {
191 const isClose = match[0].startsWith('</');
192 const tag = match[1].toLowerCase();
193 if (isClose) {
194 closeTags.unshift(tag);
195 } else if (!match[0].endsWith('/>')) {
196 openTags.push(`<${tag}${match[2]}>`);
197 }
198 }
199
200 if (openTags.length === 0) {
201 return formatted;
202 }
203
204 return `${openTags.join('')}${formatted}${closeTags.map((tag) => `</${tag}>`).join('')}`;
205}
206
207function formatPlainFallback(
208 plain: string,
209 formatting?: FormattingContext,
210): string {
211 if (formatting?.dominant?.inlineStyle) {
212 return wrapWithDominantInline(plain, formatting.dominant);
213 }
214 return escapeHtml(plain).replace(/\n/g, '<br>');
215}
216
217/** Extract HTML for a plain-text character range; partial overlaps fall back to escaped text. */
218export function sliceMapRange(
219 map: PlainTextMap,
220 start: number,
221 end: number,
222 formatting?: FormattingContext,
223): string {
224 if (start >= end) {
225 return '';
226 }
227
228 const parts: Array<{ start: number; end: number; html: string }> = [];
229
230 for (const segment of map.segments) {
231 if (segment.end <= start || segment.start >= end) {
232 continue;
233 }
234
235 const fullSegment = segment.start >= start && segment.end <= end;
236 if (fullSegment) {
237 parts.push({ start: segment.start, end: segment.end, html: segment.html });
238 continue;
239 }
240
241 const sliceStart = Math.max(start, segment.start);
242 const sliceEnd = Math.min(end, segment.end);
243 const plain = map.text.slice(sliceStart, sliceEnd);
244 parts.push({
245 start: sliceStart,
246 end: sliceEnd,
247 html: wrapPartialWithSegmentHtml(segment.html, plain),
248 });
249 }
250
251 if (parts.length === 0) {
252 return formatPlainFallback(map.text.slice(start, end), formatting);
253 }
254
255 parts.sort((a, b) => a.start - b.start);
256
257 let result = '';
258 let cursor = start;
259
260 for (const part of parts) {
261 if (part.start > cursor) {
262 result += formatPlainFallback(map.text.slice(cursor, part.start), formatting);
263 }
264 result += part.html;
265 cursor = part.end;
266 }
267
268 if (cursor < end) {
269 result += formatPlainFallback(map.text.slice(cursor, end), formatting);
270 }
271
272 return result;
273}
274
275/** Apply inline tags from the baseline segment at `position` to plain diff text. */
276function stylePlainFromBaselineAt(
277 map: PlainTextMap,
278 position: number,
279 plain: string,
280 formatting?: FormattingContext,
281): string {
282 for (const segment of map.segments) {
283 if (position >= segment.start && position <= segment.end) {
284 return wrapPartialWithSegmentHtml(segment.html, plain);
285 }
286 }
287 return formatTextForHtml(plain, formatting?.dominant);
288}
289
290/**
291 * Prefer current HTML formatting when present; otherwise keep baseline styling
292 * so redlines preserve the original look when the revised text is plain.
293 */
294export function sliceWithFormattingPreference(
295 baselineMap: PlainTextMap,
296 currentMap: PlainTextMap,
297 oldStart: number,
298 oldEnd: number,
299 newStart: number,
300 newEnd: number,
301 formatting?: FormattingContext,
302): string {
303 const fromCurrent = sliceMapRange(currentMap, newStart, newEnd, formatting);
304 if (hasInlineFormatting(fromCurrent)) {
305 return fromCurrent;
306 }
307
308 const fromBaseline = sliceMapRange(baselineMap, oldStart, oldEnd, formatting);
309 if (hasInlineFormatting(fromBaseline)) {
310 return fromBaseline;
311 }
312
313 return (
314 fromCurrent ||
315 fromBaseline ||
316 formatTextForHtml(currentMap.text.slice(newStart, newEnd), formatting?.dominant)
317 );
318}
319
320/** HTML for an inserted run, inheriting baseline styling at the insertion point when needed. */
321export function resolveInsertHtml(
322 baselineMap: PlainTextMap,
323 currentMap: PlainTextMap,
324 oldCursor: number,
325 newStart: number,
326 newEnd: number,
327 plain: string,
328 formatting?: FormattingContext,
329): string {
330 const fromCurrent = sliceMapRange(currentMap, newStart, newEnd, formatting);
331 if (hasInlineFormatting(fromCurrent)) {
332 return fromCurrent;
333 }
334
335 const inherited = stylePlainFromBaselineAt(baselineMap, oldCursor, plain, formatting);
336 if (hasInlineFormatting(inherited)) {
337 return inherited;
338 }
339
340 return fromCurrent || inherited || formatTextForHtml(plain, formatting?.dominant);
341}
342
343/** Wrap consecutive list items in a single ul for email-safe list output. */
344export function wrapConsecutiveListItems(html: string): string {
345 return html.replace(/(?:<li>[\s\S]*?<\/li>\s*)+/g, (match) => `<ul>${match.trim()}</ul>`);
346}
347