@@ -18,6 +18,7 @@ import rehypeHighlight from "rehype-highlight";
1818import rehypeRaw from "rehype-raw" ;
1919import rehypeSanitize from "rehype-sanitize" ;
2020import matter from "gray-matter" ;
21+ import { diffLines } from "diff" ;
2122import type { Components } from "react-markdown" ;
2223import {
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