@@ -5,21 +5,37 @@ import { useTheme } from '@/lib/theme'
55
66/** Guard for [text](url) links: only render a real anchor for http(s) or
77 * scheme-less (relative) URLs. Anything with another scheme (javascript:,
8- * data:, vbscript:, …) is treated as literal text, never an anchor. */
9- const isSafeUrl = ( u : string ) => / ^ h t t p s ? : \/ \/ / i. test ( u ) || ! / ^ [ a - z ] [ \w + . - ] * : / i. test ( u )
8+ * data:, vbscript:, …) is treated as literal text, never an anchor.
9+ *
10+ * Browsers strip leading/embedded control chars & whitespace from a URL before
11+ * resolving it, so ` javascript:x` and `java\tscript:x` both execute. We mirror
12+ * that: collapse ALL whitespace/control chars, then only permit an explicit
13+ * `scheme:` when it is http(s). The caller also trims the raw URL for the href. */
14+ const isSafeUrl = ( u : string ) => {
15+ const s = u . replace ( / [ \u0000 - \u0020 \u007f - \u009f ] / g, '' )
16+ const scheme = / ^ [ a - z ] [ a - z 0 - 9 + . - ] * : / i. exec ( s )
17+ return scheme ? / ^ h t t p s ? : / i. test ( scheme [ 0 ] ) : true
18+ }
1019
1120/**
1221 * Minimal inline Markdown renderer. Tokens (in match-priority order):
1322 * [[wikilink|alias]] · \( math \) · **bold** · ~~strike~~ · `code` ·
1423 * [text](url) · *italic* / _italic_.
1524 * ORDER IS LOAD-BEARING inside the alternation: wikilink must precede the link
1625 * pattern (so `[[x]]` is not parsed as a link), and bold must precede italic
17- * (so `**x**` is not parsed as `*`-italic-`*`). Every alternative uses a bounded
18- * `[^…]+` class (no nested quantifiers) to avoid catastrophic backtracking.
26+ * (so `**x**` is not parsed as `*`-italic-`*`). Alternatives use bounded `[^…]+`
27+ * classes or a single non-greedy `.+?` (bold/math) — no nested quantifiers — so
28+ * there is no catastrophic backtracking. The inner text of bold/italic/strike
29+ * and a link's LABEL are re-run through inline() so nested markup resolves; the
30+ * recursed slice is always strictly shorter, which bounds the recursion (the
31+ * wikilink target is NOT recursed). Single `*`/`_` obey a minimal left/right
32+ * flanking rule so intraword runs (`a_b_c`, `2*3*4`) stay literal.
1933 */
2034function inline ( text : string , onWikiLink ?: ( target : string ) => void ) : React . ReactNode [ ] {
2135 const parts : React . ReactNode [ ] = [ ]
22- const re = / ( \[ \[ [ ^ \] ] + \] \] | \\ \( [ \s \S ] + ?\\ \) | \* \* [ ^ * ] + \* \* | ~ ~ [ ^ ~ ] + ~ ~ | ` [ ^ ` ] + ` | \[ [ ^ \] ] + \] \( [ ^ ) ] + \) | \* [ ^ * ] + \* | _ [ ^ _ ] + _ ) / g
36+ // Bold is non-greedy (`.+?`) so it tolerates an inner opposite-emphasis
37+ // (`**a *b* c**`); italic keeps a bounded `[^*]+`/`[^_]+` class.
38+ const re = / ( \[ \[ [ ^ \] ] + \] \] | \\ \( [ \s \S ] + ?\\ \) | \* \* .+ ?\* \* | ~ ~ [ ^ ~ ] + ~ ~ | ` [ ^ ` ] + ` | \[ [ ^ \] ] + \] \( [ ^ ) ] + \) | \* [ ^ * ] + \* | _ [ ^ _ ] + _ ) / g
2339 let last = 0 , m : RegExpExecArray | null , k = 0
2440 while ( ( m = re . exec ( text ) ) ) {
2541 if ( m . index > last ) parts . push ( text . slice ( last , m . index ) )
@@ -68,9 +84,10 @@ function inline(text: string, onWikiLink?: (target: string) => void): React.Reac
6884 ) ,
6985 )
7086 } else if ( tok . startsWith ( '**' ) ) {
71- parts . push ( < strong key = { k ++ } className = "font-semibold text-foreground" > { tok . slice ( 2 , - 2 ) } </ strong > )
87+ // Recurse so nested markup (e.g. `**[docs](url)**`) resolves. slice is strictly shorter.
88+ parts . push ( < strong key = { k ++ } className = "font-semibold text-foreground" > { inline ( tok . slice ( 2 , - 2 ) , onWikiLink ) } </ strong > )
7289 } else if ( tok . startsWith ( '~~' ) ) {
73- parts . push ( < del key = { k ++ } className = "line-through" > { tok . slice ( 2 , - 2 ) } </ del > )
90+ parts . push ( < del key = { k ++ } className = "line-through" > { inline ( tok . slice ( 2 , - 2 ) , onWikiLink ) } </ del > )
7491 } else if ( tok . startsWith ( '`' ) ) {
7592 parts . push ( < code key = { k ++ } className = "font-mono2 text-[12px] bg-muted rounded px-1 py-px" > { tok . slice ( 1 , - 1 ) } </ code > )
7693 } else if ( tok . startsWith ( '[' ) ) {
@@ -79,7 +96,10 @@ function inline(text: string, onWikiLink?: (target: string) => void): React.Reac
7996 // passes the scheme guard; otherwise render the raw token as literal text.
8097 const lm = / ^ \[ ( [ ^ \] ] + ) \] \( ( [ ^ ) ] + ) \) $ / . exec ( tok )
8198 const label = lm ? lm [ 1 ] : tok
82- const url = lm ? lm [ 2 ] : ''
99+ // Trim the captured URL BEFORE the guard so ` javascript:x` can't slip
100+ // past the scheme test (browsers trim & would run it). Same trimmed
101+ // value is used for href. The label is recursed so `[**x**](url)` works.
102+ const url = lm ? lm [ 2 ] . trim ( ) : ''
83103 parts . push (
84104 lm && isSafeUrl ( url ) ? (
85105 < a
@@ -89,15 +109,33 @@ function inline(text: string, onWikiLink?: (target: string) => void): React.Reac
89109 rel = "noopener noreferrer"
90110 className = "text-accent-brand hover:underline"
91111 >
92- { label }
112+ { inline ( label , onWikiLink ) }
93113 </ a >
94114 ) : (
95115 < span key = { k ++ } > { tok } </ span >
96116 ) ,
97117 )
98118 } else {
99- // *italic* or _italic_ (bold `**…**` was consumed above).
100- parts . push ( < em key = { k ++ } className = "italic" > { tok . slice ( 1 , - 1 ) } </ em > )
119+ // *italic* / _italic_ (bold `**…**` was consumed above). Enforce a
120+ // minimal CommonMark-ish flanking rule so intraword runs stay literal:
121+ // the delimiter may only OPEN when the preceding char isn't a word char
122+ // (and the run doesn't start with whitespace), and only CLOSE when the
123+ // following char isn't a word char (and the run doesn't end with
124+ // whitespace). This keeps `a_b_c` / `2*3*4` as plain text.
125+ const before = m . index > 0 ? text [ m . index - 1 ] : ''
126+ const after = text [ m . index + tok . length ] ?? ''
127+ const innerEm = tok . slice ( 1 , - 1 )
128+ const openable = ! / \w / . test ( before ) && ! / ^ \s / . test ( innerEm )
129+ const closable = ! / \w / . test ( after ) && ! / \s $ / . test ( innerEm )
130+ if ( ! openable || ! closable ) {
131+ // Not a real emphasis run: emit the opening delimiter literally and
132+ // resume scanning right after it (inner text may still hold tokens).
133+ parts . push ( tok [ 0 ] )
134+ last = m . index + 1
135+ re . lastIndex = m . index + 1
136+ continue
137+ }
138+ parts . push ( < em key = { k ++ } className = "italic" > { inline ( innerEm , onWikiLink ) } </ em > )
101139 }
102140 last = m . index + tok . length
103141 }
@@ -191,6 +229,8 @@ export default function MarkdownView({
191229 const out : React . ReactNode [ ] = [ ]
192230 let list : string [ ] = [ ]
193231 let olist : string [ ] = [ ]
232+ // Source number of the first `<ol>` item, so a list starting at N≠1 keeps it.
233+ let olistStart = 1
194234 let key = 0
195235
196236 const flushList = ( ) => {
@@ -211,7 +251,7 @@ export default function MarkdownView({
211251 const flushOList = ( ) => {
212252 if ( ! olist . length ) return
213253 out . push (
214- < ol key = { key ++ } className = "my-2.5 space-y-1.5 pl-6 list-decimal" >
254+ < ol key = { key ++ } start = { olistStart } className = "my-2.5 space-y-1.5 pl-6 list-decimal" >
215255 { olist . map ( ( li , i ) => (
216256 < li key = { i } className = "pl-1 text-[14px] leading-relaxed text-muted-foreground marker:text-muted-foreground" >
217257 < span > { inline ( li , onWikiLink ) } </ span >
@@ -220,6 +260,7 @@ export default function MarkdownView({
220260 </ ol > ,
221261 )
222262 olist = [ ]
263+ olistStart = 1
223264 }
224265
225266 // Block boundary: any non-list line closes BOTH pending lists.
@@ -279,10 +320,23 @@ export default function MarkdownView({
279320 / ^ \s * \| ? \s * : ? - { 1 , } : ? \s * ( \| \s * : ? - { 1 , } : ? \s * ) + \| ? \s * $ / . test ( tblNext )
280321 ) {
281322 flushBlocks ( )
282- // Split a row into trimmed cells, dropping the empty cell an optional
283- // leading/trailing edge pipe produces (`| a | b |` → ['a','b']).
323+ // Split a row into trimmed cells on unescaped, non-code `|`. A naive
324+ // `split('|')` tears apart an escaped `\|` or a `|` inside an inline code
325+ // span; we walk the string so `` `a|b` `` and `a \| b` survive as one
326+ // cell. Drops the empty cell an optional leading/trailing edge pipe makes.
284327 const cells = ( row : string ) => {
285- const c = row . trim ( ) . split ( '|' ) . map ( ( s ) => s . trim ( ) )
328+ const t = row . trim ( )
329+ const c : string [ ] = [ ]
330+ let cur = ''
331+ let inCode = false
332+ for ( let j = 0 ; j < t . length ; j ++ ) {
333+ const ch = t [ j ]
334+ if ( ch === '\\' && t [ j + 1 ] === '|' ) { cur += '|' ; j ++ ; continue }
335+ if ( ch === '`' ) { inCode = ! inCode ; cur += ch ; continue }
336+ if ( ch === '|' && ! inCode ) { c . push ( cur . trim ( ) ) ; cur = '' ; continue }
337+ cur += ch
338+ }
339+ c . push ( cur . trim ( ) )
286340 if ( c . length && c [ 0 ] === '' ) c . shift ( )
287341 if ( c . length && c [ c . length - 1 ] === '' ) c . pop ( )
288342 return c
@@ -295,10 +349,25 @@ export default function MarkdownView({
295349 return l && r ? 'text-center' : r ? 'text-right' : l ? 'text-left' : ''
296350 } )
297351 const alignOf = ( col : number ) => aligns [ col ] ?? ''
298- // Consume header + delimiter, then all contiguous non-blank `|` rows.
352+ // A continuation line is a body row only when it is table-SHAPED, not
353+ // merely a line that happens to contain a `|`. We key off the header's
354+ // edge-pipe style: if the header had a leading/trailing `|`, body rows
355+ // must match — so a trailing prose/list line with a stray `|` (and no
356+ // blank separator) is NOT swallowed as a phantom row.
357+ const hTrim = line . trim ( )
358+ const edgeLead = hTrim . startsWith ( '|' )
359+ const edgeTrail = hTrim . endsWith ( '|' )
360+ const isRow = ( l : string ) => {
361+ const t = l . trim ( )
362+ if ( ! t . includes ( '|' ) ) return false
363+ if ( edgeLead && ! t . startsWith ( '|' ) ) return false
364+ if ( edgeTrail && ! t . endsWith ( '|' ) ) return false
365+ return true
366+ }
367+ // Consume header + delimiter, then all contiguous non-blank table rows.
299368 const body : string [ ] [ ] = [ ]
300369 i += 2
301- while ( i < lines . length && lines [ i ] . trim ( ) && lines [ i ] . includes ( '|' ) ) {
370+ while ( i < lines . length && lines [ i ] . trim ( ) && isRow ( lines [ i ] ) ) {
302371 body . push ( cells ( lines [ i ] ) )
303372 i ++
304373 }
@@ -341,9 +410,16 @@ export default function MarkdownView({
341410 // Unordered item: close any open ordered list, keep accumulating the ul.
342411 if ( line . startsWith ( '- ' ) ) { flushOList ( ) ; list . push ( line . slice ( 2 ) ) ; continue }
343412 // Ordered item `N. …`: close any open unordered list, strip the marker, and
344- // accumulate into a real <ol> (mirrors the ul buffer/flush pattern).
345- const om = / ^ \d + \. \s + / . exec ( line )
346- if ( om ) { flushList ( ) ; olist . push ( line . slice ( om [ 0 ] . length ) ) ; continue }
413+ // accumulate into a real <ol> (mirrors the ul buffer/flush pattern). The
414+ // first item's source number seeds `<ol start>` so a list beginning at N≠1
415+ // keeps its numbering (a blank line still ends the list).
416+ const om = / ^ ( \d + ) \. \s + / . exec ( line )
417+ if ( om ) {
418+ flushList ( )
419+ if ( ! olist . length ) olistStart = parseInt ( om [ 1 ] , 10 )
420+ olist . push ( line . slice ( om [ 0 ] . length ) )
421+ continue
422+ }
347423 flushBlocks ( )
348424 if ( ! line . trim ( ) ) { out . push ( < div key = { key ++ } className = "h-2" /> ) ; continue }
349425 if ( line . startsWith ( '### ' ) ) out . push ( < h3 key = { key ++ } className = "mt-4 mb-1.5 text-[14px] font-semibold text-foreground" > { inline ( line . slice ( 4 ) , onWikiLink ) } </ h3 > )
0 commit comments