| /** |
| * @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 as MarkdownToken); |
| } |
| 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}; |
| } |