Skip to content

Commit bccd7e6

Browse files
wiseyodaclaude
andcommitted
feat: block-level text highlights for paragraphs, list items, code
Extends change highlighting from section headings to the actual prose that changed. Uses diffLines (from the 'diff' package already in use for the history panel) to compute a Set of added/modified line numbers in the current post-frontmatter content, then consults node.position on each react-markdown component override to mark any paragraph, list item, blockquote, or code block whose source range overlaps a changed line. The same baseline content that drives the section-level hash diff is reused for the line diff, so only one extra GitHub fetch per view regardless of how many change indicators are shown. Nested blocks are CSS-protected from double-marking (an <li> that contains a <p> on a changed line gets the rail once, not twice). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2538abc commit bccd7e6

2 files changed

Lines changed: 148 additions & 26 deletions

File tree

src/app/globals.css

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,3 +485,33 @@ body {
485485
.dark .prose [data-change="markbase-section-changed"]::after {
486486
background: rgb(134 213 244 / 0.85);
487487
}
488+
489+
/* ─── Block-level "changed text" highlights (paragraphs, list items, code) ─── */
490+
491+
.prose p[data-change="text-changed"],
492+
.prose li[data-change="text-changed"],
493+
.prose blockquote[data-change="text-changed"],
494+
.prose div[data-change="text-changed"] {
495+
position: relative;
496+
background: rgb(14 165 233 / 0.06);
497+
border-left: 2px solid rgb(14 165 233 / 0.4);
498+
padding-left: 0.75rem;
499+
margin-left: -0.9rem;
500+
border-radius: 0 4px 4px 0;
501+
}
502+
503+
.dark .prose p[data-change="text-changed"],
504+
.dark .prose li[data-change="text-changed"],
505+
.dark .prose blockquote[data-change="text-changed"],
506+
.dark .prose div[data-change="text-changed"] {
507+
background: rgb(134 213 244 / 0.07);
508+
border-left-color: rgb(134 213 244 / 0.4);
509+
}
510+
511+
/* Don't stack nested highlights: inner p inside an already-changed li should not double-mark. */
512+
.prose [data-change="text-changed"] [data-change="text-changed"] {
513+
background: transparent;
514+
border-left: 0;
515+
padding-left: 0;
516+
margin-left: 0;
517+
}

src/app/repos/[owner]/[repo]/[...path]/page.tsx

Lines changed: 118 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import rehypeHighlight from "rehype-highlight";
1818
import rehypeRaw from "rehype-raw";
1919
import rehypeSanitize from "rehype-sanitize";
2020
import matter from "gray-matter";
21+
import { diffLines } from "diff";
2122
import type { Components } from "react-markdown";
2223
import {
2324
getDefaultBranch,
@@ -114,6 +115,7 @@ export default async function MarkdownViewPage({
114115
let previousCommitShaForDigest: string | null = null;
115116
let changedSectionSlugs = new Set<string>();
116117
let newSectionSlugs = new Set<string>();
118+
let baselineContentForTextDiff: string | null = null;
117119
if (userId && currentCommitSha) {
118120
try {
119121
// Read-only baseline lookup. The baseline only advances on explicit
@@ -142,8 +144,26 @@ export default async function MarkdownViewPage({
142144
content: rawContent,
143145
});
144146

145-
// Section-level diff: compare current blob against whichever blob the
146-
// user has previously acknowledged. Pure hash comparison, no LLM.
147+
// Determine which commit's content to diff against. Prefer the user's
148+
// acknowledged commit, fall back to the parent commit on first view.
149+
const diffSourceCommitSha =
150+
baseline.commitSha && baseline.commitSha !== currentCommitSha
151+
? baseline.commitSha
152+
: history[1]?.sha ?? null;
153+
154+
if (diffSourceCommitSha) {
155+
baselineContentForTextDiff = await getFileAtCommit(
156+
session.accessToken,
157+
owner,
158+
repo,
159+
diffSourceCommitSha,
160+
filePath,
161+
).catch(() => null);
162+
}
163+
164+
// Section-level diff. If we have stored hashes for the baseline blob,
165+
// use them (cheaper than re-parsing). Otherwise extract hashes from
166+
// the just-fetched baseline content.
147167
let baselineContentHashes: Awaited<ReturnType<typeof getSectionHashes>> | null = null;
148168
if (baseline.blobSha && baseline.blobSha !== blobSha) {
149169
baselineContentHashes = await getSectionHashes({
@@ -152,21 +172,8 @@ export default async function MarkdownViewPage({
152172
filePath,
153173
blobSha: baseline.blobSha,
154174
});
155-
} else if (!baseline.blobSha && history.length > 1) {
156-
// First-ever view fallback: show highlights for whatever changed in
157-
// the most recent commit by comparing current content against the
158-
// previous commit's file content. One extra (cached) GitHub fetch.
159-
const parentSha = history[1].sha;
160-
const parentContent = await getFileAtCommit(
161-
session.accessToken,
162-
owner,
163-
repo,
164-
parentSha,
165-
filePath,
166-
).catch(() => null);
167-
if (parentContent !== null) {
168-
baselineContentHashes = extractSectionHashes(parentContent);
169-
}
175+
} else if (baselineContentForTextDiff !== null) {
176+
baselineContentHashes = extractSectionHashes(baselineContentForTextDiff);
170177
}
171178

172179
if (baselineContentHashes && baselineContentHashes.length > 0) {
@@ -193,6 +200,53 @@ export default async function MarkdownViewPage({
193200
}
194201
const hasFrontmatter = Object.keys(frontmatter).length > 0;
195202

203+
// Build a Set of 1-indexed line numbers in the POST-frontmatter content
204+
// that were added or modified since the diff source commit. These line
205+
// numbers match the positions that remark attaches to hast nodes, which
206+
// are exposed via node.position to react-markdown component overrides.
207+
// Pure line-level diff — no LLM — and cached against whichever baseline
208+
// we already fetched for section hashes.
209+
const textChangedLines = new Set<number>();
210+
if (baselineContentForTextDiff !== null) {
211+
let baselineText = baselineContentForTextDiff;
212+
try {
213+
baselineText = matter(baselineContentForTextDiff).content;
214+
} catch {
215+
// Broken frontmatter in baseline — diff the raw content, it still works.
216+
}
217+
const changes = diffLines(baselineText, content);
218+
let currentLine = 1;
219+
for (const change of changes) {
220+
const valueLines = change.value.split("\n");
221+
// diffLines typically terminates parts with a trailing newline, which
222+
// split() leaves as an empty final element — drop it before counting.
223+
if (valueLines.length > 0 && valueLines[valueLines.length - 1] === "") {
224+
valueLines.pop();
225+
}
226+
const lineCount = valueLines.length;
227+
if (change.added) {
228+
for (let i = 0; i < lineCount; i++) {
229+
textChangedLines.add(currentLine + i);
230+
}
231+
}
232+
if (!change.removed) {
233+
currentLine += lineCount;
234+
}
235+
}
236+
}
237+
238+
const lineRangeChanged = (
239+
start: number | undefined,
240+
end: number | undefined,
241+
): boolean => {
242+
if (textChangedLines.size === 0) return false;
243+
if (start === undefined || end === undefined) return false;
244+
for (let line = start; line <= end; line++) {
245+
if (textChangedLines.has(line)) return true;
246+
}
247+
return false;
248+
};
249+
196250
// Extract TOC
197251
const toc = extractToc(content);
198252
const hasToc = toc.length > 2;
@@ -263,30 +317,68 @@ export default async function MarkdownViewPage({
263317
/>
264318
);
265319
},
266-
pre: ({ children, ...props }) => {
320+
pre: ({ node, children, ...props }) => {
267321
// Extract text content for the copy button
268322
let codeText = "";
269-
const extractText = (node: React.ReactNode): void => {
270-
if (typeof node === "string") {
271-
codeText += node;
272-
} else if (Array.isArray(node)) {
273-
node.forEach(extractText);
274-
} else if (node && typeof node === "object" && "props" in node) {
275-
const reactNode = node as React.ReactElement<{ children?: React.ReactNode }>;
323+
const extractText = (n: React.ReactNode): void => {
324+
if (typeof n === "string") {
325+
codeText += n;
326+
} else if (Array.isArray(n)) {
327+
n.forEach(extractText);
328+
} else if (n && typeof n === "object" && "props" in n) {
329+
const reactNode = n as React.ReactElement<{ children?: React.ReactNode }>;
276330
if (reactNode.props?.children) {
277331
extractText(reactNode.props.children);
278332
}
279333
}
280334
};
281335
extractText(children);
282336

337+
const changed = lineRangeChanged(
338+
node?.position?.start.line,
339+
node?.position?.end.line,
340+
);
341+
283342
return (
284-
<div className="group relative">
343+
<div className="group relative" data-change={changed ? "text-changed" : undefined}>
285344
<CopyButton text={codeText.trim()} />
286345
<pre {...props}>{children}</pre>
287346
</div>
288347
);
289348
},
349+
p: ({ node, children, ...props }) => {
350+
const changed = lineRangeChanged(
351+
node?.position?.start.line,
352+
node?.position?.end.line,
353+
);
354+
return (
355+
<p data-change={changed ? "text-changed" : undefined} {...props}>
356+
{children}
357+
</p>
358+
);
359+
},
360+
li: ({ node, children, ...props }) => {
361+
const changed = lineRangeChanged(
362+
node?.position?.start.line,
363+
node?.position?.end.line,
364+
);
365+
return (
366+
<li data-change={changed ? "text-changed" : undefined} {...props}>
367+
{children}
368+
</li>
369+
);
370+
},
371+
blockquote: ({ node, children, ...props }) => {
372+
const changed = lineRangeChanged(
373+
node?.position?.start.line,
374+
node?.position?.end.line,
375+
);
376+
return (
377+
<blockquote data-change={changed ? "text-changed" : undefined} {...props}>
378+
{children}
379+
</blockquote>
380+
);
381+
},
290382
h1: ({ children, ...props }) => {
291383
const text = String(children).replace(/[*_`~\[\]]/g, "");
292384
const id = slugifyHeading(text);

0 commit comments

Comments
 (0)