| /** | |
| * @license | |
| * Copyright 2026 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import {DiffInfo} from '../../../types/diff'; | |
| import {CommentSide, Side} from '../../../constants/constants'; | |
| import {CommentThread, PatchRange} from '../../../types/common'; | |
| import {isInBaseOfPatchRange} from '../../../utils/comment-util'; | |
| import {getDiffLines} from '../../../utils/diff-util'; | |
| import {html, TemplateResult} from 'lit'; | |
| import {Lexer, Marked, Token, Tokens} from 'marked'; | |
| export type DiffBlockStatus = 'unchanged' | 'added' | 'deleted' | 'modified'; | |
| export type WrapperTag = | |
| | 'p' | |
| | 'li' | |
| | 'th' | |
| | 'td' | |
| | 'h1' | |
| | 'h2' | |
| | 'h3' | |
| | 'h4' | |
| | 'h5' | |
| | 'h6'; | |
| export type MarkdownToken = Token & { | |
| startLine?: number; | |
| endLine?: number; | |
| }; | |
| export interface AlignedDiffRow { | |
| status: DiffBlockStatus; | |
| leftToken?: MarkdownToken; | |
| rightToken?: MarkdownToken; | |
| leftHtml?: TemplateResult; | |
| rightHtml?: TemplateResult; | |
| leftStartLine?: number; | |
| leftEndLine?: number; | |
| rightStartLine?: number; | |
| rightEndLine?: number; | |
| } | |
| export interface AlignedDiffRowWithThreads extends AlignedDiffRow { | |
| leftThreads: CommentThread[]; | |
| rightThreads: CommentThread[]; | |
| } | |
| export interface InlineDiffSegment { | |
| text: string; | |
| type: 'common' | 'added' | 'deleted'; | |
| } | |
| /** | |
| * Maximum normalized edit distance (0.0 = identical, 1.0 = completely disjoint) | |
| * for two blocks, lines, or list items to be inferred as homologous | |
| * modifications of one another (inspired by dandavison/delta's | |
| * --max-line-distance, which defaults to 0.6). | |
| */ | |
| export const DEFAULT_MAX_HOMOLOGOUS_DISTANCE = 0.6; | |
| /** Reconstruct the entire file content from diff chunks for the given side. */ | |
| export function reconstructFileContent(diff: DiffInfo, side: Side): string { | |
| return getDiffLines(diff, side).join('\n'); | |
| } | |
| /** Parse markdown text into top-level block tokens with line number metadata. */ | |
| export function parseMarkdownBlocks(markdown: string): MarkdownToken[] { | |
| if (!markdown) return []; | |
| const marked = new Marked(); | |
| const tokens = marked.lexer(markdown); | |
| const result: MarkdownToken[] = []; | |
| let curLine = 1; | |
| for (const t of tokens) { | |
| const raw = t.raw; | |
| const newlineCount = (raw.match(/\n/g) || []).length; | |
| const startLine = curLine; | |
| const endLine = curLine + newlineCount - (raw.endsWith('\n') ? 1 : 0); | |
| curLine += newlineCount; | |
| if (t.type === 'space') { | |
| continue; | |
| } | |
| (t as MarkdownToken).startLine = startLine; | |
| (t as MarkdownToken).endLine = Math.max(startLine, endLine); | |
| result.push(t); | |
| } | |
| return result; | |
| } | |
| function isMarkdownLinkToken(token: Token): token is Tokens.Link { | |
| return token.type === 'link'; | |
| } | |
| function isInlineContainerToken( | |
| token: Token | |
| ): token is (Tokens.Strong | Tokens.Em | Tokens.Del) & {tokens: Token[]} { | |
| return ( | |
| (token.type === 'strong' || token.type === 'em' || token.type === 'del') && | |
| 'tokens' in token && | |
| Array.isArray(token.tokens) | |
| ); | |
| } | |
| function getContainerDelimiters(token: Token & {tokens?: Token[]}): { | |
| openDelim: string; | |
| closeDelim: string; | |
| } { | |
| const innerRaw = (token.tokens ?? []).map(t => t.raw).join(''); | |
| if (!innerRaw) { | |
| return {openDelim: '', closeDelim: ''}; | |
| } | |
| const symIdx = Math.floor((token.raw.length - innerRaw.length) / 2); | |
| if ( | |
| symIdx > 0 && | |
| token.raw.slice(symIdx, symIdx + innerRaw.length) === innerRaw | |
| ) { | |
| return { | |
| openDelim: token.raw.slice(0, symIdx), | |
| closeDelim: token.raw.slice(symIdx + innerRaw.length), | |
| }; | |
| } | |
| const idx = token.raw.indexOf(innerRaw); | |
| if (idx >= 0) { | |
| return { | |
| openDelim: token.raw.slice(0, idx), | |
| closeDelim: token.raw.slice(idx + innerRaw.length), | |
| }; | |
| } | |
| return {openDelim: '', closeDelim: ''}; | |
| } | |
| function tokenizeInlineAstTokens(tokens: Token[], result: string[]) { | |
| for (const token of tokens) { | |
| if (token.type === 'codespan' || isMarkdownLinkToken(token)) { | |
| result.push(token.raw); | |
| } else if (isInlineContainerToken(token)) { | |
| const {openDelim, closeDelim} = getContainerDelimiters(token); | |
| if (openDelim) result.push(openDelim); | |
| tokenizeInlineAstTokens(token.tokens, result); | |
| if (closeDelim) result.push(closeDelim); | |
| } else { | |
| const subTokens = token.raw.match(/\s+|[^\s\w]+|\w+/g); | |
| if (subTokens) { | |
| result.push(...subTokens); | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Tokenize text using Marked's inline AST lexer (`Lexer.lexInline`) so | |
| * markdown links and inline code spans remain atomic AST units (including when | |
| * nested inside container tokens such as `strong`, `em`, or `del`) while plain | |
| * text nodes are split into words, whitespace, and punctuation for | |
| * fine-grained inline diffing. | |
| */ | |
| export function tokenizeWords(text: string): string[] { | |
| if (!text) return []; | |
| const inlineTokens = Lexer.lexInline(text); | |
| const result: string[] = []; | |
| tokenizeInlineAstTokens(inlineTokens, result); | |
| return result; | |
| } | |
| const SAFE_URL_SCHEMES = new Set(['http:', 'https:']); | |
| /** | |
| * Sanitize a markdown link href so dangerous schemes (such as `javascript:`, | |
| * `data:`, or `vbscript:`) cannot execute when rendered in an `<a>` tag. | |
| */ | |
| export function sanitizeMarkdownUrl(url: string): string { | |
| const trimmed = url.trim(); | |
| if (!trimmed) return ''; | |
| try { | |
| const parsed = new URL(trimmed); | |
| if (SAFE_URL_SCHEMES.has(parsed.protocol.toLowerCase())) { | |
| return trimmed; | |
| } | |
| return 'about:invalid#zClosurez'; | |
| } catch { | |
| // Relative URL (no explicit scheme); reject control-character-obfuscated schemes. | |
| // eslint-disable-next-line no-control-regex | |
| const normalized = trimmed.replace(/[\u0000-\u0020]+/g, ''); | |
| if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(normalized)) { | |
| return 'about:invalid#zClosurez'; | |
| } | |
| return trimmed; | |
| } | |
| } | |
| function renderCodeSpanToken(token: Tokens.Codespan): TemplateResult { | |
| return html`<code>${token.text}</code>`; | |
| } | |
| function renderInlineContainerTag( | |
| type: 'strong' | 'em' | 'del', | |
| content: unknown | |
| ): TemplateResult { | |
| switch (type) { | |
| case 'strong': | |
| return html`<strong>${content}</strong>`; | |
| case 'em': | |
| return html`<em>${content}</em>`; | |
| case 'del': | |
| return html`<del>${content}</del>`; | |
| } | |
| } | |
| function renderHighlightTag( | |
| tag: 'del' | 'ins', | |
| highlightClass: string, | |
| content: unknown | |
| ): TemplateResult { | |
| return tag === 'del' | |
| ? html`<del class=${highlightClass}>${content}</del>` | |
| : html`<ins class=${highlightClass}>${content}</ins>`; | |
| } | |
| function renderWrapperTag(tag: WrapperTag, content: unknown): TemplateResult { | |
| switch (tag) { | |
| case 'h1': | |
| return html`<h1>${content}</h1>`; | |
| case 'h2': | |
| return html`<h2>${content}</h2>`; | |
| case 'h3': | |
| return html`<h3>${content}</h3>`; | |
| case 'h4': | |
| return html`<h4>${content}</h4>`; | |
| case 'h5': | |
| return html`<h5>${content}</h5>`; | |
| case 'h6': | |
| return html`<h6>${content}</h6>`; | |
| case 'li': | |
| return html`<li>${content}</li>`; | |
| case 'th': | |
| return html`<th>${content}</th>`; | |
| case 'td': | |
| return html`<td>${content}</td>`; | |
| case 'p': | |
| return html`<p>${content}</p>`; | |
| } | |
| } | |
| function renderInlineAstTokens( | |
| tokens: Token[], | |
| options: {includeLinks: boolean} | |
| ): TemplateResult { | |
| const parts: Array<TemplateResult | string> = []; | |
| for (const token of tokens) { | |
| if (token.type === 'codespan') { | |
| parts.push(renderCodeSpanToken(token as Tokens.Codespan)); | |
| } else if (options.includeLinks && isMarkdownLinkToken(token)) { | |
| const labelContent = | |
| token.tokens && token.tokens.length > 0 | |
| ? renderInlineAstTokens(token.tokens, { | |
| includeLinks: false, | |
| }) | |
| : token.text || token.href; | |
| const safeHref = sanitizeMarkdownUrl(token.href); | |
| parts.push(html`<a href=${safeHref}>${labelContent}</a>`); | |
| } else if (isInlineContainerToken(token)) { | |
| parts.push( | |
| renderInlineContainerTag( | |
| token.type, | |
| renderInlineAstTokens(token.tokens, options) | |
| ) | |
| ); | |
| } else if (token.type === 'br') { | |
| parts.push(html`<br />`); | |
| } else if (token.type === 'escape') { | |
| parts.push((token as Tokens.Escape).text); | |
| } else if ( | |
| 'tokens' in token && | |
| token.tokens && | |
| token.type !== 'image' && | |
| !isMarkdownLinkToken(token) | |
| ) { | |
| parts.push(renderInlineAstTokens(token.tokens, options)); | |
| } else { | |
| parts.push(token.raw); | |
| } | |
| } | |
| return html`${parts}`; | |
| } | |
| /** | |
| * Render text with inline backtick code spans (`...` or ``...``) converted to | |
| * <code> Lit template elements using Marked's inline AST lexer. | |
| */ | |
| export function renderInlineCodeSpans(text: string): TemplateResult { | |
| if (!text) return html``; | |
| return renderInlineAstTokens(Lexer.lexInline(text), {includeLinks: false}); | |
| } | |
| /** | |
| * Render text with markdown links ([label](url)) converted to <a> Lit template | |
| * elements and inline backtick code spans converted to <code> Lit template | |
| * elements using Marked's inline AST lexer. | |
| */ | |
| export function renderInlineMarkdown(text: string): TemplateResult { | |
| if (!text) return html``; | |
| return renderInlineAstTokens(Lexer.lexInline(text), {includeLinks: true}); | |
| } | |
| /** Compute LCS-based diff between two arrays of strings (e.g. words or lines). */ | |
| export function computeSequenceDiff( | |
| seqA: string[], | |
| seqB: string[] | |
| ): InlineDiffSegment[] { | |
| const m = seqA.length; | |
| const n = seqB.length; | |
| // DP table for LCS lengths | |
| const dp: number[][] = Array.from({length: m + 1}, () => | |
| new Array(n + 1).fill(0) | |
| ); | |
| for (let i = 0; i < m; i++) { | |
| for (let j = 0; j < n; j++) { | |
| if (seqA[i] === seqB[j]) { | |
| dp[i + 1][j + 1] = dp[i][j] + 1; | |
| } else { | |
| dp[i + 1][j + 1] = Math.max(dp[i + 1][j], dp[i][j + 1]); | |
| } | |
| } | |
| } | |
| // Backtrack to build diff segments | |
| const segments: InlineDiffSegment[] = []; | |
| let i = m; | |
| let j = n; | |
| while (i > 0 || j > 0) { | |
| if (i > 0 && j > 0 && seqA[i - 1] === seqB[j - 1]) { | |
| segments.unshift({text: seqA[i - 1], type: 'common'}); | |
| i--; | |
| j--; | |
| } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { | |
| segments.unshift({text: seqB[j - 1], type: 'added'}); | |
| j--; | |
| } else if (i > 0 && (j === 0 || dp[i][j - 1] < dp[i - 1][j])) { | |
| segments.unshift({text: seqA[i - 1], type: 'deleted'}); | |
| i--; | |
| } | |
| } | |
| return segments; | |
| } | |
| /** | |
| * Tokenize a single line of code into placeholders (<...>), CLI flags, | |
| * identifiers, whitespace, and punctuation for within-line code diffing. | |
| */ | |
| export function tokenizeCodeWords(line: string): string[] { | |
| const codeTokenRegex = | |
| /<[A-Za-z0-9_.-]+>|--?[A-Za-z0-9_.-]+|\w+|\s+|[^\s\w]/g; | |
| return line.match(codeTokenRegex) || []; | |
| } | |
| /** | |
| * Compute the normalized token edit distance in [0.0, 1.0] between two token | |
| * sequences, ignoring pure-whitespace and pure-punctuation tokens so spaces and | |
| * punctuation do not artificially inflate similarity between unrelated | |
| * sentences. | |
| */ | |
| export function computeNormalizedEditDistance( | |
| seqA: string[], | |
| seqB: string[] | |
| ): number { | |
| const nonWsA = seqA.filter(t => !/^\s+$/.test(t)); | |
| const nonWsB = seqB.filter(t => !/^\s+$/.test(t)); | |
| if (nonWsA.length === 0 && nonWsB.length === 0) return 0; | |
| if (nonWsA.length === 0 || nonWsB.length === 0) return 1; | |
| const wordsA = nonWsA.filter(t => /\w/.test(t)); | |
| const wordsB = nonWsB.filter(t => /\w/.test(t)); | |
| const contentA = wordsA.length > 0 || wordsB.length > 0 ? wordsA : nonWsA; | |
| const contentB = wordsA.length > 0 || wordsB.length > 0 ? wordsB : nonWsB; | |
| if (contentA.length === 0 || contentB.length === 0) return 1; | |
| const diff = computeSequenceDiff(contentA, contentB); | |
| let commonCount = 0; | |
| for (const seg of diff) { | |
| if (seg.type === 'common') { | |
| commonCount++; | |
| } | |
| } | |
| const minLen = Math.min(contentA.length, contentB.length); | |
| const maxLen = Math.max(contentA.length, contentB.length); | |
| const diceDistance = | |
| 1 - (2 * commonCount) / (contentA.length + contentB.length); | |
| const overlapDistance = 1 - commonCount / minLen; | |
| // If one sequence is an extension of the other (high overlap with the shorter | |
| // sequence, bounded length ratio, and at least 3 shared content tokens), | |
| // treat them as homologous so shared prefixes/clauses remain unhighlighted. | |
| if (commonCount >= 3 && maxLen <= minLen * 5 && overlapDistance <= 0.4) { | |
| return Math.min(diceDistance, overlapDistance); | |
| } | |
| return diceDistance; | |
| } | |
| /** | |
| * Coalesce fragmented inline diff segments (inspired by dandavison/delta's | |
| * within-line edit coalescing): | |
| * 1. Merge consecutive segments of the same type. | |
| * 2. Absorb pure-whitespace 'common' tokens sandwiched between edit segments | |
| * so multi-word edits form a single continuous highlight span. | |
| * 3. Absorb isolated non-word punctuation at the boundary of a multi-word | |
| * clause edit so trailing periods do not detach from rewritten sentences. | |
| */ | |
| export function coalesceInlineDiffSegments( | |
| segments: InlineDiffSegment[] | |
| ): InlineDiffSegment[] { | |
| const mergeAdjacent = (segs: InlineDiffSegment[]): InlineDiffSegment[] => { | |
| const merged: InlineDiffSegment[] = []; | |
| for (const seg of segs) { | |
| if (!seg.text) continue; | |
| const last = merged[merged.length - 1]; | |
| if (last && last.type === seg.type) { | |
| last.text += seg.text; | |
| } else { | |
| merged.push({...seg}); | |
| } | |
| } | |
| return merged; | |
| }; | |
| // Group segments into alternating 'common' and edit ('deleted' + 'added') runs. | |
| type Run = | |
| | {kind: 'common'; text: string} | |
| | {kind: 'edit'; delText: string; addText: string}; | |
| const buildRuns = (segs: InlineDiffSegment[]): Run[] => { | |
| const runs: Run[] = []; | |
| for (const seg of mergeAdjacent(segs)) { | |
| if (seg.type === 'common') { | |
| const last = runs[runs.length - 1]; | |
| if (last && last.kind === 'common') { | |
| last.text += seg.text; | |
| } else { | |
| runs.push({kind: 'common', text: seg.text}); | |
| } | |
| } else { | |
| let last = runs[runs.length - 1]; | |
| if (!last || last.kind !== 'edit') { | |
| last = {kind: 'edit', delText: '', addText: ''}; | |
| runs.push(last); | |
| } | |
| if (seg.type === 'deleted') { | |
| last.delText += seg.text; | |
| } else { | |
| last.addText += seg.text; | |
| } | |
| } | |
| } | |
| return runs; | |
| }; | |
| const runs = buildRuns(segments); | |
| // Coalesce pure-whitespace common runs sandwiched between two edit runs | |
| // when both edits have deleted text or both edits have added text. | |
| let changed = true; | |
| while (changed) { | |
| changed = false; | |
| for (let i = 1; i < runs.length - 1; i++) { | |
| const prev = runs[i - 1]; | |
| const curr = runs[i]; | |
| const next = runs[i + 1]; | |
| if ( | |
| curr.kind === 'common' && | |
| /^\s+$/.test(curr.text) && | |
| prev.kind === 'edit' && | |
| next.kind === 'edit' | |
| ) { | |
| const bothHaveDel = prev.delText.length > 0 && next.delText.length > 0; | |
| const bothHaveAdd = prev.addText.length > 0 && next.addText.length > 0; | |
| const prevIsPair = prev.delText.length > 0 && prev.addText.length > 0; | |
| const nextIsPair = next.delText.length > 0 && next.addText.length > 0; | |
| if ( | |
| (bothHaveDel && !prev.addText && !next.addText) || | |
| (bothHaveAdd && !prev.delText && !next.delText) || | |
| (prevIsPair && nextIsPair) | |
| ) { | |
| if (prev.delText || next.delText) { | |
| prev.delText += curr.text + next.delText; | |
| } | |
| if (prev.addText || next.addText) { | |
| prev.addText += curr.text + next.addText; | |
| } | |
| runs.splice(i, 2); | |
| changed = true; | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| // Absorb a trailing punctuation-only common run (e.g. a lone '.') when it | |
| // immediately follows a multi-word replacement edit that itself contains | |
| // sentence punctuation or whitespace. | |
| if (runs.length >= 2) { | |
| const last = runs[runs.length - 1]; | |
| const prev = runs[runs.length - 2]; | |
| if ( | |
| last.kind === 'common' && | |
| /^[.,;!?]+$/.test(last.text) && | |
| prev.kind === 'edit' && | |
| prev.delText.length > 0 && | |
| prev.addText.length > 0 && | |
| (/\s/.test(prev.delText) || /\s/.test(prev.addText)) && | |
| prev.delText.length + prev.addText.length > 20 | |
| ) { | |
| prev.delText += last.text; | |
| prev.addText += last.text; | |
| runs.pop(); | |
| } | |
| } | |
| const out: InlineDiffSegment[] = []; | |
| for (const r of runs) { | |
| if (r.kind === 'common') { | |
| out.push({text: r.text, type: 'common'}); | |
| } else { | |
| if (r.delText) out.push({text: r.delText, type: 'deleted'}); | |
| if (r.addText) out.push({text: r.addText, type: 'added'}); | |
| } | |
| } | |
| return mergeAdjacent(out); | |
| } | |
| /** | |
| * Align two lists of items using dynamic programming over normalized edit | |
| * distance, pairing items only when their distance is <= maxDistance. | |
| */ | |
| export function alignHomologousItems( | |
| itemsA: string[], | |
| itemsB: string[], | |
| tokenizer: (text: string) => string[] = tokenizeWords, | |
| maxDistance = DEFAULT_MAX_HOMOLOGOUS_DISTANCE | |
| ): Array< | |
| | { | |
| type: 'unchanged'; | |
| textA: string; | |
| textB: string; | |
| aIdx: number; | |
| bIdx: number; | |
| } | |
| | {type: 'modified'; textA: string; textB: string; aIdx: number; bIdx: number} | |
| | {type: 'deleted'; textA: string; aIdx: number} | |
| | {type: 'added'; textB: string; bIdx: number} | |
| > { | |
| const m = itemsA.length; | |
| const n = itemsB.length; | |
| const tokensCacheA = itemsA.map(tokenizer); | |
| const tokensCacheB = itemsB.map(tokenizer); | |
| // Score matrix: pairing (i, j) yields a positive score of (1 - distance) | |
| // when distance <= maxDistance. | |
| const pairScore: number[][] = Array.from({length: m}, (_, i) => | |
| Array.from({length: n}, (__, j) => { | |
| if (itemsA[i] === itemsB[j]) return 2; // Strongly prefer exact matches | |
| const dist = computeNormalizedEditDistance( | |
| tokensCacheA[i], | |
| tokensCacheB[j] | |
| ); | |
| return dist <= maxDistance ? 1 - dist : -1; | |
| }) | |
| ); | |
| const dp: number[][] = Array.from({length: m + 1}, () => | |
| new Array(n + 1).fill(0) | |
| ); | |
| for (let i = 0; i < m; i++) { | |
| for (let j = 0; j < n; j++) { | |
| let best = Math.max(dp[i][j + 1], dp[i + 1][j]); | |
| if (pairScore[i][j] >= 0) { | |
| best = Math.max(best, dp[i][j] + pairScore[i][j]); | |
| } | |
| dp[i + 1][j + 1] = best; | |
| } | |
| } | |
| const result: Array< | |
| | { | |
| type: 'unchanged'; | |
| textA: string; | |
| textB: string; | |
| aIdx: number; | |
| bIdx: number; | |
| } | |
| | { | |
| type: 'modified'; | |
| textA: string; | |
| textB: string; | |
| aIdx: number; | |
| bIdx: number; | |
| } | |
| | {type: 'deleted'; textA: string; aIdx: number} | |
| | {type: 'added'; textB: string; bIdx: number} | |
| > = []; | |
| let i = m; | |
| let j = n; | |
| while (i > 0 || j > 0) { | |
| if ( | |
| i > 0 && | |
| j > 0 && | |
| pairScore[i - 1][j - 1] >= 0 && | |
| Math.abs(dp[i][j] - (dp[i - 1][j - 1] + pairScore[i - 1][j - 1])) < 1e-9 | |
| ) { | |
| const aIdx = i - 1; | |
| const bIdx = j - 1; | |
| result.unshift({ | |
| type: itemsA[aIdx] === itemsB[bIdx] ? 'unchanged' : 'modified', | |
| textA: itemsA[aIdx], | |
| textB: itemsB[bIdx], | |
| aIdx, | |
| bIdx, | |
| }); | |
| i--; | |
| j--; | |
| } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { | |
| result.unshift({type: 'added', textB: itemsB[j - 1], bIdx: j - 1}); | |
| j--; | |
| } else { | |
| result.unshift({type: 'deleted', textA: itemsA[i - 1], aIdx: i - 1}); | |
| i--; | |
| } | |
| } | |
| return result; | |
| } | |
| function renderListContainer( | |
| listTok: Tokens.List, | |
| items: TemplateResult[] | |
| ): TemplateResult { | |
| if (!listTok.ordered) { | |
| return html`<ul> | |
| ${items} | |
| </ul>`; | |
| } | |
| return typeof listTok.start === 'number' && listTok.start !== 1 | |
| ? html`<ol start=${listTok.start}> | |
| ${items} | |
| </ol>` | |
| : html`<ol> | |
| ${items} | |
| </ol>`; | |
| } | |
| /** Render a single marked block token to a Lit TemplateResult. */ | |
| export function renderTokenToHtml(token?: Token): TemplateResult { | |
| if (!token) return html``; | |
| switch (token.type) { | |
| case 'heading': { | |
| const heading = token as Tokens.Heading; | |
| const depth = Math.min(Math.max(heading.depth, 1), 6); | |
| const tag = `h${depth}` as WrapperTag; | |
| const content = heading.tokens | |
| ? renderInlineAstTokens(heading.tokens, {includeLinks: true}) | |
| : renderInlineMarkdown(heading.text); | |
| return renderWrapperTag(tag, content); | |
| } | |
| case 'paragraph': { | |
| const para = token as Tokens.Paragraph; | |
| const content = para.tokens | |
| ? renderInlineAstTokens(para.tokens, {includeLinks: true}) | |
| : renderInlineMarkdown(para.text); | |
| return html`<p>${content}</p>`; | |
| } | |
| case 'text': { | |
| const textTok = token as Tokens.Text; | |
| return textTok.tokens | |
| ? renderInlineAstTokens(textTok.tokens, {includeLinks: true}) | |
| : renderInlineMarkdown(textTok.text); | |
| } | |
| case 'code': { | |
| const codeTok = token as Tokens.Code; | |
| const langClass = codeTok.lang ? `language-${codeTok.lang}` : undefined; | |
| return langClass | |
| ? html`<pre><code class=${langClass}>${codeTok.text}</code></pre>` | |
| : html`<pre><code>${codeTok.text}</code></pre>`; | |
| } | |
| case 'blockquote': { | |
| const bq = token as Tokens.Blockquote; | |
| const children = bq.tokens | |
| ? bq.tokens.map(t => renderTokenToHtml(t)) | |
| : html`<p>${renderInlineMarkdown(bq.text.trim())}</p>`; | |
| return html`<blockquote>${children}</blockquote>`; | |
| } | |
| case 'list': { | |
| const listTok = token as Tokens.List; | |
| const items = listTok.items.map(item => { | |
| const itemContent = item.tokens | |
| ? item.tokens.map(t => | |
| t.type === 'text' | |
| ? (t as Tokens.Text).tokens | |
| ? renderInlineAstTokens((t as Tokens.Text).tokens!, { | |
| includeLinks: true, | |
| }) | |
| : renderInlineMarkdown((t as Tokens.Text).text) | |
| : renderTokenToHtml(t) | |
| ) | |
| : renderInlineMarkdown(item.text); | |
| return html`<li>${itemContent}</li>`; | |
| }); | |
| return renderListContainer(listTok, items); | |
| } | |
| case 'table': { | |
| const tableTok = token as Tokens.Table; | |
| const headerCells = tableTok.header.map(cell => { | |
| const content = cell.tokens | |
| ? renderInlineAstTokens(cell.tokens, {includeLinks: true}) | |
| : renderInlineMarkdown(cell.text); | |
| return html`<th>${content}</th>`; | |
| }); | |
| const bodyRows = tableTok.rows.map(row => { | |
| const cells = row.map(cell => { | |
| const content = cell.tokens | |
| ? renderInlineAstTokens(cell.tokens, {includeLinks: true}) | |
| : renderInlineMarkdown(cell.text); | |
| return html`<td>${content}</td>`; | |
| }); | |
| return html`<tr> | |
| ${cells} | |
| </tr>`; | |
| }); | |
| return html`<table> | |
| <thead> | |
| <tr> | |
| ${headerCells} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| ${bodyRows} | |
| </tbody> | |
| </table>`; | |
| } | |
| case 'hr': | |
| return html`<hr />`; | |
| case 'space': | |
| case 'def': | |
| return html``; | |
| default: | |
| return html`<p>${token.raw}</p>`; | |
| } | |
| } | |
| function renderTokensWithDiffRanges( | |
| tokens: Token[], | |
| baseOffset: number, | |
| isEditedAt: boolean[], | |
| highlightTag: 'del' | 'ins', | |
| highlightClass: string | |
| ): TemplateResult { | |
| const result: Array<TemplateResult | string> = []; | |
| let activeHighlight: Array<TemplateResult | string> = []; | |
| const flushHighlight = () => { | |
| if (activeHighlight.length > 0) { | |
| result.push( | |
| renderHighlightTag( | |
| highlightTag, | |
| highlightClass, | |
| html`${activeHighlight}` | |
| ) | |
| ); | |
| activeHighlight = []; | |
| } | |
| }; | |
| let offset = baseOffset; | |
| for (const token of tokens) { | |
| const tokStart = offset; | |
| const tokEnd = offset + token.raw.length; | |
| offset = tokEnd; | |
| let anyEdited = false; | |
| let allEdited = token.raw.length > 0; | |
| for (let p = tokStart; p < tokEnd; p++) { | |
| if (isEditedAt[p]) { | |
| anyEdited = true; | |
| } else { | |
| allEdited = false; | |
| } | |
| } | |
| if (allEdited || (anyEdited && token.type === 'escape')) { | |
| activeHighlight.push( | |
| renderInlineAstTokens([token], {includeLinks: true}) | |
| ); | |
| } else if (!anyEdited) { | |
| flushHighlight(); | |
| result.push(renderInlineAstTokens([token], {includeLinks: true})); | |
| } else if (isInlineContainerToken(token)) { | |
| const {openDelim, closeDelim} = getContainerDelimiters(token); | |
| const innerStart = tokStart + openDelim.length; | |
| const innerEnd = tokEnd - closeDelim.length; | |
| let innerAnyEdited = false; | |
| for (let p = innerStart; p < innerEnd; p++) { | |
| if (isEditedAt[p]) { | |
| innerAnyEdited = true; | |
| break; | |
| } | |
| } | |
| if (!innerAnyEdited) { | |
| activeHighlight.push( | |
| renderInlineAstTokens([token], {includeLinks: true}) | |
| ); | |
| } else { | |
| flushHighlight(); | |
| const innerTemplate = renderTokensWithDiffRanges( | |
| token.tokens, | |
| innerStart, | |
| isEditedAt, | |
| highlightTag, | |
| highlightClass | |
| ); | |
| result.push(renderInlineContainerTag(token.type, innerTemplate)); | |
| } | |
| } else { | |
| let runStart = tokStart; | |
| while (runStart < tokEnd) { | |
| const runEdited = isEditedAt[runStart]; | |
| let runEnd = runStart + 1; | |
| while (runEnd < tokEnd && isEditedAt[runEnd] === runEdited) { | |
| runEnd++; | |
| } | |
| const sliceText = token.raw.slice( | |
| runStart - tokStart, | |
| runEnd - tokStart | |
| ); | |
| if (runEdited) { | |
| activeHighlight.push(sliceText); | |
| } else { | |
| flushHighlight(); | |
| result.push(sliceText); | |
| } | |
| runStart = runEnd; | |
| } | |
| } | |
| } | |
| flushHighlight(); | |
| return html`${result}`; | |
| } | |
| function renderSideInlineDiff( | |
| fullText: string, | |
| segments: InlineDiffSegment[], | |
| editType: 'deleted' | 'added', | |
| highlightTag: 'del' | 'ins', | |
| highlightClass: string | |
| ): TemplateResult { | |
| if (!fullText) return html``; | |
| const isEditedAt = new Array<boolean>(fullText.length).fill(false); | |
| let pos = 0; | |
| for (const seg of segments) { | |
| if (seg.type === 'common' || seg.type === editType) { | |
| if (seg.type === editType) { | |
| for (let i = 0; i < seg.text.length && pos + i < fullText.length; i++) { | |
| isEditedAt[pos + i] = true; | |
| } | |
| } | |
| pos += seg.text.length; | |
| } | |
| } | |
| return renderTokensWithDiffRanges( | |
| Lexer.lexInline(fullText), | |
| 0, | |
| isEditedAt, | |
| highlightTag, | |
| highlightClass | |
| ); | |
| } | |
| function renderInlineTextDiffContent( | |
| textA: string, | |
| textB: string | |
| ): {leftContent: TemplateResult; rightContent: TemplateResult} { | |
| const wordsA = tokenizeWords(textA); | |
| const wordsB = tokenizeWords(textB); | |
| const distance = computeNormalizedEditDistance(wordsA, wordsB); | |
| let mergedSegments: InlineDiffSegment[]; | |
| if (distance > DEFAULT_MAX_HOMOLOGOUS_DISTANCE) { | |
| // Non-homologous lines/blocks: highlight as whole replacements rather than | |
| // producing noisy "ransom-note" diffs across incidental stop words. | |
| mergedSegments = []; | |
| if (textA) mergedSegments.push({text: textA, type: 'deleted'}); | |
| if (textB) mergedSegments.push({text: textB, type: 'added'}); | |
| } else { | |
| const diff = computeSequenceDiff(wordsA, wordsB); | |
| mergedSegments = coalesceInlineDiffSegments(diff); | |
| } | |
| const leftContent = renderSideInlineDiff( | |
| textA, | |
| mergedSegments, | |
| 'deleted', | |
| 'del', | |
| 'diff-highlight-del' | |
| ); | |
| const rightContent = renderSideInlineDiff( | |
| textB, | |
| mergedSegments, | |
| 'added', | |
| 'ins', | |
| 'diff-highlight-add' | |
| ); | |
| return {leftContent, rightContent}; | |
| } | |
| /** Render an inline diff for modified text in headings or paragraphs. */ | |
| export function renderInlineTextDiff( | |
| textA: string, | |
| textB: string, | |
| wrapperTag: WrapperTag = 'p', | |
| rightWrapperTag: WrapperTag = wrapperTag | |
| ): {leftHtml: TemplateResult; rightHtml: TemplateResult} { | |
| const {leftContent, rightContent} = renderInlineTextDiffContent(textA, textB); | |
| return { | |
| leftHtml: renderWrapperTag(wrapperTag, leftContent), | |
| rightHtml: renderWrapperTag(rightWrapperTag, rightContent), | |
| }; | |
| } | |
| /** | |
| * Render a modified code block with two-tier dandavison/delta-style highlights: | |
| * homologous modified lines get a subtle full-line background plus within-line | |
| * token emphasis on the specific tokens that changed. | |
| */ | |
| export function renderCodeBlockDiff( | |
| tokenA: Tokens.Code, | |
| tokenB: Tokens.Code | |
| ): {leftHtml: TemplateResult; rightHtml: TemplateResult} { | |
| const linesA = tokenA.text.split('\n'); | |
| const linesB = tokenB.text.split('\n'); | |
| const aligned = alignHomologousItems( | |
| linesA, | |
| linesB, | |
| tokenizeCodeWords, | |
| DEFAULT_MAX_HOMOLOGOUS_DISTANCE | |
| ); | |
| const leftLineEntries: Array<TemplateResult | string> = []; | |
| const rightLineEntries: Array<TemplateResult | string> = []; | |
| for (const item of aligned) { | |
| if (item.type === 'unchanged') { | |
| leftLineEntries.push(item.textA); | |
| rightLineEntries.push(item.textA); | |
| } else if (item.type === 'deleted') { | |
| leftLineEntries.push( | |
| html`<span class="diff-highlight-del">${item.textA}</span>` | |
| ); | |
| } else if (item.type === 'added') { | |
| rightLineEntries.push( | |
| html`<span class="diff-highlight-add">${item.textB}</span>` | |
| ); | |
| } else { | |
| const wordsA = tokenizeCodeWords(item.textA); | |
| const wordsB = tokenizeCodeWords(item.textB); | |
| const rawDiff = computeSequenceDiff(wordsA, wordsB); | |
| const commonNonWsCount = rawDiff.filter( | |
| s => s.type === 'common' && !/^\s+$/.test(s.text) | |
| ).length; | |
| if (commonNonWsCount < 2) { | |
| leftLineEntries.push( | |
| html`<span class="diff-highlight-del">${item.textA}</span>` | |
| ); | |
| rightLineEntries.push( | |
| html`<span class="diff-highlight-add">${item.textB}</span>` | |
| ); | |
| } else { | |
| const coalesced = coalesceInlineDiffSegments(rawDiff); | |
| const leftParts: Array<TemplateResult | string> = []; | |
| const rightParts: Array<TemplateResult | string> = []; | |
| for (const seg of coalesced) { | |
| if (seg.type === 'common') { | |
| leftParts.push(seg.text); | |
| rightParts.push(seg.text); | |
| } else if (seg.type === 'deleted') { | |
| leftParts.push( | |
| html`<del class="diff-highlight-del">${seg.text}</del>` | |
| ); | |
| } else if (seg.type === 'added') { | |
| rightParts.push( | |
| html`<ins class="diff-highlight-add">${seg.text}</ins>` | |
| ); | |
| } | |
| } | |
| leftLineEntries.push( | |
| html`<span class="diff-code-line-del">${leftParts}</span>` | |
| ); | |
| rightLineEntries.push( | |
| html`<span class="diff-code-line-add">${rightParts}</span>` | |
| ); | |
| } | |
| } | |
| } | |
| const joinLinesWithNewline = ( | |
| entries: Array<TemplateResult | string> | |
| ): Array<TemplateResult | string> => { | |
| const out: Array<TemplateResult | string> = []; | |
| for (let i = 0; i < entries.length; i++) { | |
| if (i > 0) out.push('\n'); | |
| out.push(entries[i]); | |
| } | |
| return out; | |
| }; | |
| const leftCodeContent = joinLinesWithNewline(leftLineEntries); | |
| const rightCodeContent = joinLinesWithNewline(rightLineEntries); | |
| const langA = tokenA.lang ? `language-${tokenA.lang}` : undefined; | |
| const langB = tokenB.lang ? `language-${tokenB.lang}` : undefined; | |
| return { | |
| leftHtml: langA | |
| ? html`<pre><code class=${langA}>${leftCodeContent}</code></pre>` | |
| : html`<pre><code>${leftCodeContent}</code></pre>`, | |
| rightHtml: langB | |
| ? html`<pre><code class=${langB}>${rightCodeContent}</code></pre>` | |
| : html`<pre><code>${rightCodeContent}</code></pre>`, | |
| }; | |
| } | |
| function splitListItemTokens(item: Tokens.ListItem): { | |
| headText: string; | |
| sublists: Tokens.List[]; | |
| otherBlocks: Token[]; | |
| } { | |
| if (!item.tokens || item.tokens.length === 0) { | |
| return {headText: item.text, sublists: [], otherBlocks: []}; | |
| } | |
| const headParts: string[] = []; | |
| const sublists: Tokens.List[] = []; | |
| const otherBlocks: Token[] = []; | |
| for (const t of item.tokens) { | |
| if (t.type === 'text' || t.type === 'paragraph') { | |
| headParts.push((t as Tokens.Text | Tokens.Paragraph).text); | |
| } else if (t.type === 'list') { | |
| sublists.push(t as Tokens.List); | |
| } else if (t.type !== 'space') { | |
| otherBlocks.push(t); | |
| } | |
| } | |
| return { | |
| headText: headParts.join('\n'), | |
| sublists, | |
| otherBlocks, | |
| }; | |
| } | |
| /** | |
| * Render a modified markdown list (<ul> or <ol>) by aligning homologous list | |
| * items and highlighting within-item word/code/link diffs as well as added or | |
| * deleted items. Emits placeholder <li> elements on the opposing side so | |
| * side-by-side alignment and <ol> numbering stay in sync. | |
| */ | |
| export function renderListDiff( | |
| tokenA: Tokens.List, | |
| tokenB: Tokens.List | |
| ): {leftHtml: TemplateResult; rightHtml: TemplateResult} { | |
| const splitItemsA = tokenA.items.map(splitListItemTokens); | |
| const splitItemsB = tokenB.items.map(splitListItemTokens); | |
| const itemsA = splitItemsA.map( | |
| (s, idx) => s.headText || tokenA.items[idx].text | |
| ); | |
| const itemsB = splitItemsB.map( | |
| (s, idx) => s.headText || tokenB.items[idx].text | |
| ); | |
| const aligned = alignHomologousItems( | |
| itemsA, | |
| itemsB, | |
| tokenizeWords, | |
| DEFAULT_MAX_HOMOLOGOUS_DISTANCE | |
| ); | |
| const leftItems: TemplateResult[] = []; | |
| const rightItems: TemplateResult[] = []; | |
| for (const item of aligned) { | |
| if (item.type === 'unchanged' || item.type === 'modified') { | |
| const splitA = splitItemsA[item.aIdx]; | |
| const splitB = splitItemsB[item.bIdx]; | |
| let leftHead: TemplateResult; | |
| let rightHead: TemplateResult; | |
| if (splitA.headText === splitB.headText) { | |
| leftHead = renderInlineMarkdown(splitA.headText); | |
| rightHead = leftHead; | |
| } else { | |
| const diffContent = renderInlineTextDiffContent( | |
| splitA.headText, | |
| splitB.headText | |
| ); | |
| leftHead = diffContent.leftContent; | |
| rightHead = diffContent.rightContent; | |
| } | |
| const leftSublists: TemplateResult[] = []; | |
| const rightSublists: TemplateResult[] = []; | |
| const maxSub = Math.max(splitA.sublists.length, splitB.sublists.length); | |
| for (let s = 0; s < maxSub; s++) { | |
| const subA = splitA.sublists[s]; | |
| const subB = splitB.sublists[s]; | |
| if (subA && subB) { | |
| const subDiff = renderListDiff(subA, subB); | |
| leftSublists.push(subDiff.leftHtml); | |
| rightSublists.push(subDiff.rightHtml); | |
| } else if (subA) { | |
| leftSublists.push( | |
| renderListDiff(subA, {...subA, items: []}).leftHtml | |
| ); | |
| } else if (subB) { | |
| rightSublists.push( | |
| renderListDiff({...subB, items: []}, subB).rightHtml | |
| ); | |
| } | |
| } | |
| const leftOther = splitA.otherBlocks.map(t => renderTokenToHtml(t)); | |
| const rightOther = splitB.otherBlocks.map(t => renderTokenToHtml(t)); | |
| leftItems.push(html`<li>${leftHead}${leftOther}${leftSublists}</li>`); | |
| rightItems.push(html`<li>${rightHead}${rightOther}${rightSublists}</li>`); | |
| } else if (item.type === 'deleted') { | |
| const splitA = splitItemsA[item.aIdx]; | |
| const sublists = splitA.sublists.map( | |
| sub => renderListDiff(sub, {...sub, items: []}).leftHtml | |
| ); | |
| const other = splitA.otherBlocks.map(t => renderTokenToHtml(t)); | |
| leftItems.push( | |
| html`<li class="diff-list-item-del"> | |
| <del class="diff-highlight-del" | |
| >${renderInlineMarkdown(splitA.headText)}</del | |
| >${other}${sublists} | |
| </li>` | |
| ); | |
| rightItems.push( | |
| html`<li class="diff-list-item-placeholder" aria-hidden="true"></li>` | |
| ); | |
| } else if (item.type === 'added') { | |
| const splitB = splitItemsB[item.bIdx]; | |
| const sublists = splitB.sublists.map( | |
| sub => renderListDiff({...sub, items: []}, sub).rightHtml | |
| ); | |
| const other = splitB.otherBlocks.map(t => renderTokenToHtml(t)); | |
| leftItems.push( | |
| html`<li class="diff-list-item-placeholder" aria-hidden="true"></li>` | |
| ); | |
| rightItems.push( | |
| html`<li class="diff-list-item-add"> | |
| <ins class="diff-highlight-add" | |
| >${renderInlineMarkdown(splitB.headText)}</ins | |
| >${other}${sublists} | |
| </li>` | |
| ); | |
| } | |
| } | |
| return { | |
| leftHtml: renderListContainer(tokenA, leftItems), | |
| rightHtml: renderListContainer(tokenB, rightItems), | |
| }; | |
| } | |
| /** Render a modified blockquote with inline word/code/link diff highlights. */ | |
| export function renderBlockquoteDiff( | |
| tokenA: Tokens.Blockquote, | |
| tokenB: Tokens.Blockquote | |
| ): {leftHtml: TemplateResult; rightHtml: TemplateResult} { | |
| const childTokensA = ( | |
| tokenA.tokens && tokenA.tokens.length > 0 | |
| ? tokenA.tokens | |
| : parseMarkdownBlocks(tokenA.text) | |
| ).filter(t => t.type !== 'space') as MarkdownToken[]; | |
| const childTokensB = ( | |
| tokenB.tokens && tokenB.tokens.length > 0 | |
| ? tokenB.tokens | |
| : parseMarkdownBlocks(tokenB.text) | |
| ).filter(t => t.type !== 'space') as MarkdownToken[]; | |
| if ( | |
| childTokensA.length <= 1 && | |
| childTokensB.length <= 1 && | |
| (childTokensA[0]?.type ?? 'paragraph') === 'paragraph' && | |
| (childTokensB[0]?.type ?? 'paragraph') === 'paragraph' | |
| ) { | |
| const {leftHtml, rightHtml} = renderInlineTextDiff( | |
| tokenA.text.trim(), | |
| tokenB.text.trim(), | |
| 'p' | |
| ); | |
| return { | |
| leftHtml: html`<blockquote>${leftHtml}</blockquote>`, | |
| rightHtml: html`<blockquote>${rightHtml}</blockquote>`, | |
| }; | |
| } | |
| const alignedChildren = alignMarkdownTokens(childTokensA, childTokensB); | |
| const leftBlocks: TemplateResult[] = []; | |
| const rightBlocks: TemplateResult[] = []; | |
| for (const row of alignedChildren) { | |
| if (row.status === 'unchanged' || row.status === 'modified') { | |
| if (row.leftHtml) leftBlocks.push(row.leftHtml); | |
| if (row.rightHtml) rightBlocks.push(row.rightHtml); | |
| } else if (row.status === 'deleted' && row.leftToken) { | |
| if (row.leftToken.type === 'paragraph') { | |
| leftBlocks.push( | |
| renderInlineTextDiff( | |
| (row.leftToken as Tokens.Paragraph).text, | |
| '', | |
| 'p' | |
| ).leftHtml | |
| ); | |
| } else if (row.leftHtml) { | |
| leftBlocks.push( | |
| html`<del class="diff-highlight-del">${row.leftHtml}</del>` | |
| ); | |
| } | |
| } else if (row.status === 'added' && row.rightToken) { | |
| if (row.rightToken.type === 'paragraph') { | |
| rightBlocks.push( | |
| renderInlineTextDiff( | |
| '', | |
| (row.rightToken as Tokens.Paragraph).text, | |
| 'p' | |
| ).rightHtml | |
| ); | |
| } else if (row.rightHtml) { | |
| rightBlocks.push( | |
| html`<ins class="diff-highlight-add">${row.rightHtml}</ins>` | |
| ); | |
| } | |
| } | |
| } | |
| return { | |
| leftHtml: html`<blockquote>${leftBlocks}</blockquote>`, | |
| rightHtml: html`<blockquote>${rightBlocks}</blockquote>`, | |
| }; | |
| } | |
| /** | |
| * Render a modified markdown table by aligning homologous rows and performing | |
| * cell-by-cell inline diffs on modified header and body cells, emitting | |
| * placeholder cells and rows on the opposing side when columns or rows are | |
| * added or deleted. | |
| */ | |
| export function renderTableDiff( | |
| tokenA: Tokens.Table, | |
| tokenB: Tokens.Table | |
| ): {leftHtml: TemplateResult; rightHtml: TemplateResult} { | |
| const colCount = Math.max( | |
| tokenA.header.length, | |
| tokenB.header.length, | |
| ...tokenA.rows.map(r => r.length), | |
| ...tokenB.rows.map(r => r.length) | |
| ); | |
| const leftHeaderCells: TemplateResult[] = []; | |
| const rightHeaderCells: TemplateResult[] = []; | |
| for (let c = 0; c < colCount; c++) { | |
| const cellA = tokenA.header[c]?.text; | |
| const cellB = tokenB.header[c]?.text; | |
| if (cellA !== undefined && cellB !== undefined) { | |
| if (cellA === cellB) { | |
| const th = html`<th>${renderInlineMarkdown(cellA)}</th>`; | |
| leftHeaderCells.push(th); | |
| rightHeaderCells.push(th); | |
| } else { | |
| const {leftHtml, rightHtml} = renderInlineTextDiff(cellA, cellB, 'th'); | |
| leftHeaderCells.push(leftHtml); | |
| rightHeaderCells.push(rightHtml); | |
| } | |
| } else if (cellA !== undefined) { | |
| leftHeaderCells.push( | |
| html`<th> | |
| <del class="diff-highlight-del">${renderInlineMarkdown(cellA)}</del> | |
| </th>` | |
| ); | |
| rightHeaderCells.push( | |
| html`<th class="diff-table-cell-placeholder" aria-hidden="true"></th>` | |
| ); | |
| } else if (cellB !== undefined) { | |
| leftHeaderCells.push( | |
| html`<th class="diff-table-cell-placeholder" aria-hidden="true"></th>` | |
| ); | |
| rightHeaderCells.push( | |
| html`<th> | |
| <ins class="diff-highlight-add">${renderInlineMarkdown(cellB)}</ins> | |
| </th>` | |
| ); | |
| } else { | |
| leftHeaderCells.push( | |
| html`<th class="diff-table-cell-placeholder" aria-hidden="true"></th>` | |
| ); | |
| rightHeaderCells.push( | |
| html`<th class="diff-table-cell-placeholder" aria-hidden="true"></th>` | |
| ); | |
| } | |
| } | |
| const rowsTextA = tokenA.rows.map(r => r.map(c => c.text).join(' ')); | |
| const rowsTextB = tokenB.rows.map(r => r.map(c => c.text).join(' ')); | |
| const alignedRows = alignHomologousItems( | |
| rowsTextA, | |
| rowsTextB, | |
| tokenizeWords, | |
| DEFAULT_MAX_HOMOLOGOUS_DISTANCE | |
| ); | |
| const leftBodyRows: TemplateResult[] = []; | |
| const rightBodyRows: TemplateResult[] = []; | |
| for (const row of alignedRows) { | |
| if (row.type === 'unchanged') { | |
| const trLeft: TemplateResult[] = []; | |
| const trRight: TemplateResult[] = []; | |
| const cellsA = tokenA.rows[row.aIdx]; | |
| const cellsB = tokenB.rows[row.bIdx]; | |
| for (let c = 0; c < colCount; c++) { | |
| const cellA = cellsA[c]?.text; | |
| const cellB = cellsB[c]?.text; | |
| trLeft.push( | |
| cellA !== undefined | |
| ? html`<td>${renderInlineMarkdown(cellA)}</td>` | |
| : html`<td | |
| class="diff-table-cell-placeholder" | |
| aria-hidden="true" | |
| ></td>` | |
| ); | |
| trRight.push( | |
| cellB !== undefined | |
| ? html`<td>${renderInlineMarkdown(cellB)}</td>` | |
| : html`<td | |
| class="diff-table-cell-placeholder" | |
| aria-hidden="true" | |
| ></td>` | |
| ); | |
| } | |
| leftBodyRows.push( | |
| html`<tr> | |
| ${trLeft} | |
| </tr>` | |
| ); | |
| rightBodyRows.push( | |
| html`<tr> | |
| ${trRight} | |
| </tr>` | |
| ); | |
| } else if (row.type === 'modified') { | |
| const cellsA = tokenA.rows[row.aIdx]; | |
| const cellsB = tokenB.rows[row.bIdx]; | |
| const trLeft: TemplateResult[] = []; | |
| const trRight: TemplateResult[] = []; | |
| for (let c = 0; c < colCount; c++) { | |
| const textA = cellsA[c]?.text; | |
| const textB = cellsB[c]?.text; | |
| if (textA !== undefined && textB !== undefined) { | |
| if (textA === textB) { | |
| const td = html`<td>${renderInlineMarkdown(textA)}</td>`; | |
| trLeft.push(td); | |
| trRight.push(td); | |
| } else { | |
| const {leftHtml, rightHtml} = renderInlineTextDiff( | |
| textA, | |
| textB, | |
| 'td' | |
| ); | |
| trLeft.push(leftHtml); | |
| trRight.push(rightHtml); | |
| } | |
| } else if (textA !== undefined) { | |
| trLeft.push( | |
| html`<td> | |
| <del class="diff-highlight-del" | |
| >${renderInlineMarkdown(textA)}</del | |
| > | |
| </td>` | |
| ); | |
| trRight.push( | |
| html`<td | |
| class="diff-table-cell-placeholder" | |
| aria-hidden="true" | |
| ></td>` | |
| ); | |
| } else if (textB !== undefined) { | |
| trLeft.push( | |
| html`<td | |
| class="diff-table-cell-placeholder" | |
| aria-hidden="true" | |
| ></td>` | |
| ); | |
| trRight.push( | |
| html`<td> | |
| <ins class="diff-highlight-add" | |
| >${renderInlineMarkdown(textB)}</ins | |
| > | |
| </td>` | |
| ); | |
| } else { | |
| trLeft.push( | |
| html`<td | |
| class="diff-table-cell-placeholder" | |
| aria-hidden="true" | |
| ></td>` | |
| ); | |
| trRight.push( | |
| html`<td | |
| class="diff-table-cell-placeholder" | |
| aria-hidden="true" | |
| ></td>` | |
| ); | |
| } | |
| } | |
| leftBodyRows.push( | |
| html`<tr> | |
| ${trLeft} | |
| </tr>` | |
| ); | |
| rightBodyRows.push( | |
| html`<tr> | |
| ${trRight} | |
| </tr>` | |
| ); | |
| } else if (row.type === 'deleted') { | |
| const cellsA = tokenA.rows[row.aIdx]; | |
| const trLeft: TemplateResult[] = []; | |
| const trRight: TemplateResult[] = []; | |
| for (let c = 0; c < colCount; c++) { | |
| const cellA = cellsA[c]?.text; | |
| trLeft.push( | |
| cellA !== undefined | |
| ? html`<td> | |
| <del class="diff-highlight-del" | |
| >${renderInlineMarkdown(cellA)}</del | |
| > | |
| </td>` | |
| : html`<td | |
| class="diff-table-cell-placeholder" | |
| aria-hidden="true" | |
| ></td>` | |
| ); | |
| trRight.push( | |
| html`<td class="diff-table-cell-placeholder" aria-hidden="true"></td>` | |
| ); | |
| } | |
| leftBodyRows.push( | |
| html`<tr class="diff-table-row-del"> | |
| ${trLeft} | |
| </tr>` | |
| ); | |
| rightBodyRows.push( | |
| html`<tr class="diff-table-row-placeholder" aria-hidden="true"> | |
| ${trRight} | |
| </tr>` | |
| ); | |
| } else if (row.type === 'added') { | |
| const cellsB = tokenB.rows[row.bIdx]; | |
| const trLeft: TemplateResult[] = []; | |
| const trRight: TemplateResult[] = []; | |
| for (let c = 0; c < colCount; c++) { | |
| const cellB = cellsB[c]?.text; | |
| trLeft.push( | |
| html`<td class="diff-table-cell-placeholder" aria-hidden="true"></td>` | |
| ); | |
| trRight.push( | |
| cellB !== undefined | |
| ? html`<td> | |
| <ins class="diff-highlight-add" | |
| >${renderInlineMarkdown(cellB)}</ins | |
| > | |
| </td>` | |
| : html`<td | |
| class="diff-table-cell-placeholder" | |
| aria-hidden="true" | |
| ></td>` | |
| ); | |
| } | |
| leftBodyRows.push( | |
| html`<tr class="diff-table-row-placeholder" aria-hidden="true"> | |
| ${trLeft} | |
| </tr>` | |
| ); | |
| rightBodyRows.push( | |
| html`<tr class="diff-table-row-add"> | |
| ${trRight} | |
| </tr>` | |
| ); | |
| } | |
| } | |
| return { | |
| leftHtml: html`<table> | |
| <thead> | |
| <tr> | |
| ${leftHeaderCells} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| ${leftBodyRows} | |
| </tbody> | |
| </table>`, | |
| rightHtml: html`<table> | |
| <thead> | |
| <tr> | |
| ${rightHeaderCells} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| ${rightBodyRows} | |
| </tbody> | |
| </table>`, | |
| }; | |
| } | |
| /** | |
| * Align base (A) and revision (B) markdown block tokens into rows. | |
| * Matches exact blocks as anchors, pairs modified blocks of compatible types, | |
| * and leaves unmatched blocks as added or deleted with empty partner cells. | |
| */ | |
| export function alignMarkdownTokens( | |
| tokensA: MarkdownToken[], | |
| tokensB: MarkdownToken[] | |
| ): AlignedDiffRow[] { | |
| // Filter out whitespace-only 'space' tokens between blocks | |
| const blocksA = tokensA.filter(t => t.type !== 'space'); | |
| const blocksB = tokensB.filter(t => t.type !== 'space'); | |
| const m = blocksA.length; | |
| const n = blocksB.length; | |
| // DP table to find LCS of exact matches | |
| const dp: number[][] = Array.from({length: m + 1}, () => | |
| new Array(n + 1).fill(0) | |
| ); | |
| for (let i = 0; i < m; i++) { | |
| for (let j = 0; j < n; j++) { | |
| if (blocksA[i].raw === blocksB[j].raw) { | |
| dp[i + 1][j + 1] = dp[i][j] + 1; | |
| } else { | |
| dp[i + 1][j + 1] = Math.max(dp[i + 1][j], dp[i][j + 1]); | |
| } | |
| } | |
| } | |
| // Backtrack to extract exact match pairs | |
| const anchorPairs: {aIdx: number; bIdx: number}[] = []; | |
| let i = m; | |
| let j = n; | |
| while (i > 0 && j > 0) { | |
| if (blocksA[i - 1].raw === blocksB[j - 1].raw) { | |
| anchorPairs.unshift({aIdx: i - 1, bIdx: j - 1}); | |
| i--; | |
| j--; | |
| } else if (dp[i][j - 1] >= dp[i - 1][j]) { | |
| j--; | |
| } else { | |
| i--; | |
| } | |
| } | |
| const rows: AlignedDiffRow[] = []; | |
| let lastA = 0; | |
| let lastB = 0; | |
| function renderPairedBlocks( | |
| tokA: MarkdownToken, | |
| tokB: MarkdownToken | |
| ): {leftHtml: TemplateResult; rightHtml: TemplateResult} { | |
| if (tokA.type === 'code' && tokB.type === 'code') { | |
| return renderCodeBlockDiff(tokA as Tokens.Code, tokB as Tokens.Code); | |
| } | |
| if (tokA.type === 'heading' && tokB.type === 'heading') { | |
| const depthA = Math.min(Math.max((tokA as Tokens.Heading).depth, 1), 6); | |
| const depthB = Math.min(Math.max((tokB as Tokens.Heading).depth, 1), 6); | |
| return renderInlineTextDiff( | |
| tokA.text, | |
| tokB.text, | |
| `h${depthA}` as WrapperTag, | |
| `h${depthB}` as WrapperTag | |
| ); | |
| } | |
| if (tokA.type === 'paragraph' && tokB.type === 'paragraph') { | |
| return renderInlineTextDiff(tokA.text, tokB.text, 'p'); | |
| } | |
| if (tokA.type === 'list' && tokB.type === 'list') { | |
| return renderListDiff(tokA as Tokens.List, tokB as Tokens.List); | |
| } | |
| if (tokA.type === 'blockquote' && tokB.type === 'blockquote') { | |
| return renderBlockquoteDiff( | |
| tokA as Tokens.Blockquote, | |
| tokB as Tokens.Blockquote | |
| ); | |
| } | |
| if (tokA.type === 'table' && tokB.type === 'table') { | |
| return renderTableDiff(tokA as Tokens.Table, tokB as Tokens.Table); | |
| } | |
| return { | |
| leftHtml: renderTokenToHtml(tokA), | |
| rightHtml: renderTokenToHtml(tokB), | |
| }; | |
| } | |
| function processInterval(endA: number, endB: number) { | |
| const unalignedA = blocksA.slice(lastA, endA); | |
| const unalignedB = blocksB.slice(lastB, endB); | |
| const uA = unalignedA.length; | |
| const uB = unalignedB.length; | |
| if (uA === 0 && uB === 0) return; | |
| const tokenizeBlock = (t: MarkdownToken): string[] => | |
| t.type === 'code' | |
| ? tokenizeCodeWords((t as Tokens.Code).text) | |
| : tokenizeWords(t.raw); | |
| const wordsCacheA = unalignedA.map(tokenizeBlock); | |
| const wordsCacheB = unalignedB.map(tokenizeBlock); | |
| // Score compatible block pairs by homologous similarity so inserted or | |
| // deleted blocks do not shift adjacent modified blocks out of alignment. | |
| const pairScore: number[][] = Array.from({length: uA}, (_, a) => | |
| Array.from({length: uB}, (__, b) => { | |
| if (unalignedA[a].type !== unalignedB[b].type) return -1; | |
| const dist = computeNormalizedEditDistance( | |
| wordsCacheA[a], | |
| wordsCacheB[b] | |
| ); | |
| if (dist <= DEFAULT_MAX_HOMOLOGOUS_DISTANCE) { | |
| return 1 + (1 - dist); | |
| } | |
| return uA === 1 && uB === 1 ? 0.01 + 0.1 * (1 - dist) : -1; | |
| }) | |
| ); | |
| const subDp: number[][] = Array.from({length: uA + 1}, () => | |
| new Array(uB + 1).fill(0) | |
| ); | |
| for (let a = 0; a < uA; a++) { | |
| for (let b = 0; b < uB; b++) { | |
| let best = Math.max(subDp[a][b + 1], subDp[a + 1][b]); | |
| if (pairScore[a][b] >= 0) { | |
| best = Math.max(best, subDp[a][b] + pairScore[a][b]); | |
| } | |
| subDp[a + 1][b + 1] = best; | |
| } | |
| } | |
| const intervalRows: AlignedDiffRow[] = []; | |
| let a = uA; | |
| let b = uB; | |
| while (a > 0 || b > 0) { | |
| if ( | |
| a > 0 && | |
| b > 0 && | |
| pairScore[a - 1][b - 1] >= 0 && | |
| Math.abs( | |
| subDp[a][b] - (subDp[a - 1][b - 1] + pairScore[a - 1][b - 1]) | |
| ) < 1e-9 | |
| ) { | |
| const tokA = unalignedA[a - 1]; | |
| const tokB = unalignedB[b - 1]; | |
| const {leftHtml, rightHtml} = renderPairedBlocks(tokA, tokB); | |
| intervalRows.unshift({ | |
| status: 'modified', | |
| leftToken: tokA, | |
| rightToken: tokB, | |
| leftHtml, | |
| rightHtml, | |
| leftStartLine: tokA.startLine, | |
| leftEndLine: tokA.endLine, | |
| rightStartLine: tokB.startLine, | |
| rightEndLine: tokB.endLine, | |
| }); | |
| a--; | |
| b--; | |
| } else if (b > 0 && (a === 0 || subDp[a][b - 1] >= subDp[a - 1][b])) { | |
| const tokB = unalignedB[b - 1]; | |
| intervalRows.unshift({ | |
| status: 'added', | |
| rightToken: tokB, | |
| rightHtml: renderTokenToHtml(tokB), | |
| rightStartLine: tokB.startLine, | |
| rightEndLine: tokB.endLine, | |
| }); | |
| b--; | |
| } else { | |
| const tokA = unalignedA[a - 1]; | |
| intervalRows.unshift({ | |
| status: 'deleted', | |
| leftToken: tokA, | |
| leftHtml: renderTokenToHtml(tokA), | |
| leftStartLine: tokA.startLine, | |
| leftEndLine: tokA.endLine, | |
| }); | |
| a--; | |
| } | |
| } | |
| rows.push(...intervalRows); | |
| } | |
| // Interleave intervals between anchor pairs | |
| for (const anchor of anchorPairs) { | |
| processInterval(anchor.aIdx, anchor.bIdx); | |
| const tokA = blocksA[anchor.aIdx]; | |
| const tokB = blocksB[anchor.bIdx]; | |
| const htmlA = renderTokenToHtml(tokA); | |
| const htmlB = renderTokenToHtml(tokB); | |
| rows.push({ | |
| status: 'unchanged', | |
| leftToken: tokA, | |
| rightToken: tokB, | |
| leftHtml: htmlA, | |
| rightHtml: htmlB, | |
| leftStartLine: tokA.startLine, | |
| leftEndLine: tokA.endLine, | |
| rightStartLine: tokB.startLine, | |
| rightEndLine: tokB.endLine, | |
| }); | |
| lastA = anchor.aIdx + 1; | |
| lastB = anchor.bIdx + 1; | |
| } | |
| // Trailing interval | |
| processInterval(m, n); | |
| return rows; | |
| } | |
| /** Determine which side of the diff (LEFT or RIGHT) a comment thread belongs to. */ | |
| export function getThreadDiffSide( | |
| thread: CommentThread, | |
| patchRange?: PatchRange | |
| ): Side { | |
| if (!patchRange) { | |
| return thread.commentSide === CommentSide.PARENT ? Side.LEFT : Side.RIGHT; | |
| } | |
| const commentProps = { | |
| patch_set: thread.patchNum, | |
| side: thread.commentSide, | |
| parent: thread.mergeParentNum, | |
| }; | |
| if (isInBaseOfPatchRange(commentProps, patchRange)) { | |
| return Side.LEFT; | |
| } | |
| return Side.RIGHT; | |
| } | |
| /** | |
| * Assigns comment threads to their corresponding markdown diff rows based on line | |
| * numbers and diff side. File-level comments (or threads without line numbers) are | |
| * grouped separately. | |
| */ | |
| export function attachThreadsToRows( | |
| rows: AlignedDiffRow[], | |
| threads: CommentThread[] = [], | |
| patchRange?: PatchRange | |
| ): { | |
| rowsWithThreads: AlignedDiffRowWithThreads[]; | |
| fileLevelThreads: CommentThread[]; | |
| } { | |
| const rowsWithThreads: AlignedDiffRowWithThreads[] = rows.map(r => { | |
| return { | |
| ...r, | |
| leftThreads: [], | |
| rightThreads: [], | |
| }; | |
| }); | |
| const fileLevelThreads: CommentThread[] = []; | |
| if (rowsWithThreads.length === 0) { | |
| return { | |
| rowsWithThreads, | |
| fileLevelThreads: [...threads], | |
| }; | |
| } | |
| for (const thread of threads) { | |
| const line = thread.line; | |
| if (line === undefined || line === 'FILE') { | |
| fileLevelThreads.push(thread); | |
| continue; | |
| } | |
| const lineNum = typeof line === 'number' ? line : Number(line); | |
| if (isNaN(lineNum)) { | |
| fileLevelThreads.push(thread); | |
| continue; | |
| } | |
| const side = getThreadDiffSide(thread, patchRange); | |
| if (side === Side.LEFT) { | |
| const leftRows = rowsWithThreads.filter( | |
| r => r.leftStartLine !== undefined | |
| ); | |
| if (leftRows.length === 0) { | |
| rowsWithThreads[0].leftThreads.push(thread); | |
| continue; | |
| } | |
| const exactRow = leftRows.find( | |
| r => lineNum >= r.leftStartLine! && lineNum <= r.leftEndLine! | |
| ); | |
| if (exactRow) { | |
| exactRow.leftThreads.push(thread); | |
| continue; | |
| } | |
| if (lineNum < leftRows[0].leftStartLine!) { | |
| leftRows[0].leftThreads.push(thread); | |
| continue; | |
| } | |
| let targetRow = leftRows[leftRows.length - 1]; | |
| for (let i = 0; i < leftRows.length - 1; i++) { | |
| if ( | |
| lineNum > leftRows[i].leftEndLine! && | |
| lineNum < leftRows[i + 1].leftStartLine! | |
| ) { | |
| targetRow = leftRows[i]; | |
| break; | |
| } | |
| } | |
| targetRow.leftThreads.push(thread); | |
| } else { | |
| const rightRows = rowsWithThreads.filter( | |
| r => r.rightStartLine !== undefined | |
| ); | |
| if (rightRows.length === 0) { | |
| rowsWithThreads[0].rightThreads.push(thread); | |
| continue; | |
| } | |
| const exactRow = rightRows.find( | |
| r => lineNum >= r.rightStartLine! && lineNum <= r.rightEndLine! | |
| ); | |
| if (exactRow) { | |
| exactRow.rightThreads.push(thread); | |
| continue; | |
| } | |
| if (lineNum < rightRows[0].rightStartLine!) { | |
| rightRows[0].rightThreads.push(thread); | |
| continue; | |
| } | |
| let targetRow = rightRows[rightRows.length - 1]; | |
| for (let i = 0; i < rightRows.length - 1; i++) { | |
| if ( | |
| lineNum > rightRows[i].rightEndLine! && | |
| lineNum < rightRows[i + 1].rightStartLine! | |
| ) { | |
| targetRow = rightRows[i]; | |
| break; | |
| } | |
| } | |
| targetRow.rightThreads.push(thread); | |
| } | |
| } | |
| return {rowsWithThreads, fileLevelThreads}; | |
| } |