// Dependency-free line diff (LCS) for the CMS version comparison view. // Input lines are plain text; output entries are typed add/del/ctx rows. function diffLines(oldLines, newLines) { const n = oldLines.length; const m = newLines.length; // LCS lengths DP (files are small, a few hundred lines — O(n*m) is fine) const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); for (let i = n - 1; i >= 0; i--) { for (let j = m - 1; j >= 0; j--) { dp[i][j] = oldLines[i] === newLines[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); } } const out = []; let i = 0; let j = 0; while (i < n && j < m) { if (oldLines[i] === newLines[j]) { out.push({ type: 'ctx', text: oldLines[i] }); i++; j++; } else if (dp[i + 1][j] >= dp[i][j + 1]) { out.push({ type: 'del', text: oldLines[i] }); i++; } else { out.push({ type: 'add', text: newLines[j] }); j++; } } while (i < n) { out.push({ type: 'del', text: oldLines[i] }); i++; } while (j < m) { out.push({ type: 'add', text: newLines[j] }); j++; } return out; } function escapeHtml(value) { return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } // Keep only ±contextAround context lines around changes to keep pages small. function trimContext(entries, contextAround = 3) { const keep = new Array(entries.length).fill(false); entries.forEach((e, idx) => { if (e.type !== 'ctx') { for (let k = Math.max(0, idx - contextAround); k <= Math.min(entries.length - 1, idx + contextAround); k++) keep[k] = true; } }); const out = []; let skipping = false; entries.forEach((e, idx) => { if (keep[idx]) { out.push(e); skipping = false; } else if (!skipping) { out.push({ type: 'skip', text: '…' }); skipping = true; } }); return out; } function renderDiffHtml(oldText, newText) { const entries = trimContext(diffLines(oldText.split('\n'), newText.split('\n'))); return entries.map(e => `