@@ -35,6 +35,19 @@ export interface RenderPreviewResult {
3535 ranges : CommentableRange [ ] ;
3636}
3737
38+ interface FrontmatterEntry {
39+ key : string ;
40+ label : string ;
41+ value : string | string [ ] ;
42+ isArray : boolean ;
43+ }
44+
45+ interface FrontmatterParseResult {
46+ entries : FrontmatterEntry [ ] ;
47+ bodyMarkdown : string ;
48+ consumedChars : number ;
49+ }
50+
3851export function initPreview ( container : HTMLElement ) {
3952 previewContainer = container ;
4053}
@@ -172,6 +185,168 @@ function injectHighlightSpans(content: string, comments: ReviewComment[]): strin
172185 return result ;
173186}
174187
188+ function escapeHtml ( value : string ) : string {
189+ return value
190+ . replace ( / & / g, '&' )
191+ . replace ( / < / g, '<' )
192+ . replace ( / > / g, '>' )
193+ . replace ( / " / g, '"' )
194+ . replace ( / ' / g, ''' ) ;
195+ }
196+
197+ function toFrontmatterLabel ( key : string ) : string {
198+ return key
199+ . split ( / [ _ - ] + / )
200+ . filter ( ( part ) => part . length > 0 )
201+ . map ( ( part ) => part [ 0 ] . toUpperCase ( ) + part . slice ( 1 ) )
202+ . join ( ' ' ) ;
203+ }
204+
205+ function parseArrayValue ( rawValue : string ) : string [ ] {
206+ const inner = rawValue . slice ( 1 , - 1 ) . trim ( ) ;
207+ if ( ! inner ) {
208+ return [ ] ;
209+ }
210+
211+ return inner
212+ . split ( ',' )
213+ . map ( ( part ) => part . trim ( ) )
214+ . filter ( ( part ) => part . length > 0 )
215+ . map ( ( part ) => {
216+ const quote = part [ 0 ] ;
217+ if (
218+ ( quote === '"' || quote === "'" ) &&
219+ part . length >= 2 &&
220+ part [ part . length - 1 ] === quote
221+ ) {
222+ return part . slice ( 1 , - 1 ) ;
223+ }
224+ return part ;
225+ } ) ;
226+ }
227+
228+ function parseLeadingFrontmatter ( content : string ) : FrontmatterParseResult {
229+ const bomOffset = content . startsWith ( '\uFEFF' ) ? 1 : 0 ;
230+ const working = content . slice ( bomOffset ) ;
231+
232+ if ( ! ( working . startsWith ( '---\n' ) || working . startsWith ( '---\r\n' ) ) ) {
233+ return { entries : [ ] , bodyMarkdown : content , consumedChars : 0 } ;
234+ }
235+
236+ const afterOpening = working . startsWith ( '---\r\n' ) ? 5 : 4 ;
237+ let cursor = afterOpening ;
238+ let closingLineEnd = - 1 ;
239+
240+ while ( cursor < working . length ) {
241+ const nextNewline = working . indexOf ( '\n' , cursor ) ;
242+ const lineEnd = nextNewline === - 1 ? working . length : nextNewline + 1 ;
243+ const line = working
244+ . slice ( cursor , nextNewline === - 1 ? working . length : nextNewline )
245+ . replace ( / \r $ / , '' ) ;
246+
247+ if ( / ^ - - - [ \t ] * $ / . test ( line ) ) {
248+ closingLineEnd = lineEnd ;
249+ break ;
250+ }
251+
252+ cursor = lineEnd ;
253+ }
254+
255+ if ( closingLineEnd < 0 ) {
256+ return { entries : [ ] , bodyMarkdown : content , consumedChars : 0 } ;
257+ }
258+
259+ const rawFrontmatter = working . slice ( afterOpening , cursor ) ;
260+ const entries : FrontmatterEntry [ ] = [ ] ;
261+
262+ for ( const rawLine of rawFrontmatter . split ( / \r ? \n / ) ) {
263+ if ( ! rawLine . trim ( ) || rawLine . trim ( ) . startsWith ( '#' ) ) {
264+ continue ;
265+ }
266+
267+ const separatorIndex = rawLine . indexOf ( ':' ) ;
268+ if ( separatorIndex <= 0 ) {
269+ continue ;
270+ }
271+
272+ const key = rawLine . slice ( 0 , separatorIndex ) . trim ( ) ;
273+ if ( ! key ) {
274+ continue ;
275+ }
276+
277+ const rawValue = rawLine . slice ( separatorIndex + 1 ) . trim ( ) ;
278+ const isArray = rawValue . startsWith ( '[' ) && rawValue . endsWith ( ']' ) ;
279+
280+ entries . push ( {
281+ key,
282+ label : toFrontmatterLabel ( key ) ,
283+ value : isArray ? parseArrayValue ( rawValue ) : rawValue ,
284+ isArray,
285+ } ) ;
286+ }
287+
288+ const consumedChars = bomOffset + closingLineEnd ;
289+ const bodyMarkdown = content . slice ( consumedChars ) ;
290+
291+ return { entries, bodyMarkdown, consumedChars } ;
292+ }
293+
294+ function renderFrontmatterHtml ( entries : FrontmatterEntry [ ] ) : string {
295+ if ( entries . length === 0 ) {
296+ return '' ;
297+ }
298+
299+ const rows = entries
300+ . map ( ( entry ) => {
301+ const renderedValue = entry . isArray
302+ ? ( ( ) => {
303+ const items = entry . value as string [ ] ;
304+ if ( items . length === 0 ) {
305+ return '<span class="frontmatter-empty">-</span>' ;
306+ }
307+
308+ const chips = items
309+ . map ( ( item ) => `<span class="frontmatter-chip">${ escapeHtml ( item ) } </span>` )
310+ . join ( '' ) ;
311+ return `<span class="frontmatter-chip-list">${ chips } </span>` ;
312+ } ) ( )
313+ : ( ( ) => {
314+ const value = entry . value as string ;
315+ if ( ! value ) {
316+ return '<span class="frontmatter-empty">-</span>' ;
317+ }
318+ return `<span class="frontmatter-text">${ escapeHtml ( value ) } </span>` ;
319+ } ) ( ) ;
320+
321+ return [
322+ '<div class="frontmatter-row">' ,
323+ `<span class="frontmatter-label">${ escapeHtml ( entry . label ) } </span>` ,
324+ `<span class="frontmatter-value">${ renderedValue } </span>` ,
325+ '</div>' ,
326+ ] . join ( '' ) ;
327+ } )
328+ . join ( '' ) ;
329+
330+ return [
331+ '<div class="frontmatter-card" data-frontmatter="true">' ,
332+ '<div class="frontmatter-title">Metadata</div>' ,
333+ `<div class="frontmatter-grid">${ rows } </div>` ,
334+ '</div>' ,
335+ ] . join ( '' ) ;
336+ }
337+
338+ function offsetRanges ( ranges : CommentableRange [ ] , offset : number ) : CommentableRange [ ] {
339+ if ( offset === 0 ) {
340+ return ranges ;
341+ }
342+
343+ return ranges . map ( ( range ) => ( {
344+ ...range ,
345+ start : range . start + offset ,
346+ end : range . end + offset ,
347+ } ) ) ;
348+ }
349+
175350function trimTrailingLineBreaks ( raw : string ) : number {
176351 let end = raw . length ;
177352 while ( end > 0 ) {
@@ -421,19 +596,26 @@ function applyCommentHighlights(root: ParentNode, comments: ReviewComment[]) {
421596 * Render markdown with highlights
422597 */
423598export function renderMarkdown ( content : string , comments : ReviewComment [ ] ) : RenderPreviewResult {
424- // Step 1: Replace comment markers with HTML spans BEFORE markdown parsing
425- const contentWithSpans = injectHighlightSpans ( content , comments ) ;
599+ const frontmatter = parseLeadingFrontmatter ( content ) ;
426600
427- // Step 2: Compute exact source ranges from the original markdown content.
428- const ranges = collectCommentableRanges ( content ) ;
601+ // Step 1: Replace comment markers with HTML spans BEFORE markdown parsing.
602+ const contentWithSpans = injectHighlightSpans ( frontmatter . bodyMarkdown , comments ) ;
603+
604+ // Step 2: Compute exact source ranges from markdown body, then remap to original source offsets.
605+ const ranges = offsetRanges (
606+ collectCommentableRanges ( frontmatter . bodyMarkdown ) ,
607+ frontmatter . consumedChars
608+ ) ;
429609
430610 // Step 3: Parse markdown - marked preserves inline HTML by default.
431- const html = marked . parse ( contentWithSpans , {
611+ const markdownHtml = marked . parse ( contentWithSpans , {
432612 async : false ,
433613 gfm : true ,
434614 breaks : true ,
435615 } ) as string ;
436616
617+ const html = renderFrontmatterHtml ( frontmatter . entries ) + markdownHtml ;
618+
437619 return { html, ranges } ;
438620}
439621
0 commit comments