shows relative images in markdown
Some checks failed
CI / check (push) Has been cancelled

This commit is contained in:
2026-09-14 11:27:10 +02:00
parent f6a551d7c4
commit a2d1c5df83
6 changed files with 230 additions and 29 deletions

View File

@@ -6,24 +6,100 @@ import type { Diff, DiffRow, GitStatus, SideLine, SplitRow } from './types'
interface Op { t: 'same' | 'del' | 'add'; a?: number; b?: number }
export function buildDiff(origText: string, updText: string): Omit<Diff, 'deleted' | 'added' | 'original' | 'updated'> {
const a = origText === '' ? [] : origText.replace(/\n$/, '').split('\n')
const b = updText === '' ? [] : updText.replace(/\n$/, '').split('\n')
const n = a.length, m = b.length
/** Largest DP matrix we build: 4M cells = 16 MB of Int32. A region over the
* budget is split on unique anchor lines instead, because the full n*m matrix
* of a big file exhausts the heap and kills the renderer. */
const MAX_CELLS = 4_000_000
type Pair = [number, number]
/** Longest strictly increasing subsequence over the b-index of each pair. */
function longestRun(pairs: Pair[]): Pair[] {
const tails: number[] = []
const prev = new Int32Array(pairs.length).fill(-1)
for (let k = 0; k < pairs.length; k++) {
const v = pairs[k][1]
let lo = 0, hi = tails.length
while (lo < hi) {
const mid = (lo + hi) >> 1
if (pairs[tails[mid]][1] < v) lo = mid + 1
else hi = mid
}
prev[k] = lo > 0 ? tails[lo - 1] : -1
tails[lo] = k
}
const out: Pair[] = []
let k = tails.length ? tails[tails.length - 1] : -1
while (k >= 0) { out.push(pairs[k]); k = prev[k] }
return out.reverse()
}
/** Lines that appear exactly once on each side, in an order both sides share. */
function anchors(a: string[], b: string[], a0: number, a1: number, b0: number, b1: number): Pair[] {
const countA = new Map<string, number>()
for (let i = a0; i < a1; i++) countA.set(a[i], (countA.get(a[i]) ?? 0) + 1)
const countB = new Map<string, number>(), atB = new Map<string, number>()
for (let j = b0; j < b1; j++) { countB.set(b[j], (countB.get(b[j]) ?? 0) + 1); atB.set(b[j], j) }
const pairs: Pair[] = []
for (let i = a0; i < a1; i++) {
if (countA.get(a[i]) !== 1 || countB.get(a[i]) !== 1) continue
pairs.push([i, atB.get(a[i])!])
}
return longestRun(pairs)
}
function dpOps(a: string[], b: string[], a0: number, a1: number, b0: number, b1: number, ops: Op[]): void {
const n = a1 - a0, m = b1 - b0
const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1))
for (let i = n - 1; i >= 0; i--)
for (let j = m - 1; j >= 0; j--)
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1])
dp[i][j] = a[a0 + i] === b[b0 + j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1])
const ops: Op[] = []
let i = 0, j = 0
while (i < n && j < m) {
if (a[i] === b[j]) { ops.push({ t: 'same', a: i, b: j }); i++; j++ }
else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ t: 'del', a: i }); i++ }
else { ops.push({ t: 'add', b: j }); j++ }
if (a[a0 + i] === b[b0 + j]) { ops.push({ t: 'same', a: a0 + i, b: b0 + j }); i++; j++ }
else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ t: 'del', a: a0 + i }); i++ }
else { ops.push({ t: 'add', b: b0 + j }); j++ }
}
while (i < n) { ops.push({ t: 'del', a: i++ }) }
while (j < m) { ops.push({ t: 'add', b: j++ }) }
while (i < n) ops.push({ t: 'del', a: a0 + i++ })
while (j < m) ops.push({ t: 'add', b: b0 + j++ })
}
function region(a: string[], b: string[], a0: number, a1: number, b0: number, b1: number, ops: Op[]): void {
while (a0 < a1 && b0 < b1 && a[a0] === b[b0]) { ops.push({ t: 'same', a: a0, b: b0 }); a0++; b0++ }
const tail: Op[] = []
while (a1 > a0 && b1 > b0 && a[a1 - 1] === b[b1 - 1]) { a1--; b1--; tail.push({ t: 'same', a: a1, b: b1 }) }
const n = a1 - a0, m = b1 - b0
const replaceAll = (): void => {
for (let i = a0; i < a1; i++) ops.push({ t: 'del', a: i })
for (let j = b0; j < b1; j++) ops.push({ t: 'add', b: j })
}
if (n === 0 || m === 0) replaceAll()
else if (n * m <= MAX_CELLS) dpOps(a, b, a0, a1, b0, b1, ops)
else {
const pins = anchors(a, b, a0, a1, b0, b1)
if (pins.length === 0) replaceAll()
else {
let ai = a0, bi = b0
for (const [ax, bx] of pins) {
region(a, b, ai, ax, bi, bx, ops)
ops.push({ t: 'same', a: ax, b: bx })
ai = ax + 1; bi = bx + 1
}
region(a, b, ai, a1, bi, b1, ops)
}
}
for (let k = tail.length - 1; k >= 0; k--) ops.push(tail[k])
}
export function buildDiff(origText: string, updText: string): Omit<Diff, 'deleted' | 'added' | 'original' | 'updated'> {
const a = origText === '' ? [] : origText.replace(/\n$/, '').split('\n')
const b = updText === '' ? [] : updText.replace(/\n$/, '').split('\n')
const ops: Op[] = []
region(a, b, 0, a.length, 0, b.length, ops)
const rows: DiffRow[] = [], left: SideLine[] = [], right: SideLine[] = [], split: SplitRow[] = []
const delSet = new Set<number>(), addSet = new Set<number>()

View File

@@ -6,6 +6,7 @@ import { useProject } from './project'
import { HL } from './highlight'
import { isKnownSymbol, useSymbols } from './symbols'
import { renderMarkdown } from './markdown'
import { rlog } from './log'
import { FileIcon, Icon } from './components'
import type { OnContext } from './components'
@@ -316,12 +317,30 @@ function NoOriginal(): React.ReactElement {
)
}
/* Rendered-markdown preview: read-only, derived from the live buffer text. */
/* Rendered-markdown preview: read-only, derived from the live buffer text.
* An image that the document names by a relative path is resolved against the
* document's own folder and then read over IPC, because the preview runs from
* the app's origin and cannot reach the project folder by itself. */
function MarkdownView({ path, text, onContext }: { path: string; text: string; onContext: OnContext }): React.ReactElement {
const html = useMemo(() => renderMarkdown(text), [text])
const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''
const html = useMemo(() => renderMarkdown(text, dir), [text, dir])
const body = useRef<HTMLDivElement>(null)
useEffect(() => {
const root = body.current
const bridge = window.helder
if (!root || !bridge) return
let alive = true
for (const img of Array.from(root.querySelectorAll<HTMLImageElement>('img[data-src]'))) {
const rel = img.dataset.src ?? ''
bridge.fs.imageDataUrl(rel)
.then((url) => { if (alive && url) img.src = url })
.catch((e) => rlog.warn('markdown', 'image read failed', { rel, err: String(e) }))
}
return () => { alive = false }
}, [html])
return (
<div className="md-view" onContextMenu={(e) => { e.preventDefault(); onContext(e, { path, kind: 'editor', line: 1 }) }}>
<div className="md-body" dangerouslySetInnerHTML={{ __html: html }} />
<div className="md-body" ref={body} dangerouslySetInnerHTML={{ __html: html }} />
</div>
)
}

View File

@@ -28,8 +28,47 @@ function safeUrl(url: string): string {
return u // bare relative (e.g. `images/x.png`)
}
/** True when the URL names a file next to the document instead of a remote one. */
function isLocalUrl(u: string): boolean {
return !/^(https?:|data:|mailto:|#)/i.test(u)
}
/**
* Turn a link that a document writes into a project-relative path. The preview
* runs from the app's own origin, not from the document's folder, so a bare
* `img.png` resolves against the app and finds nothing. `baseDir` is the
* folder of the document, and a leading `/` means the project root.
*/
export function resolveRel(baseDir: string, url: string): string {
let u = url.split('#')[0].split('?')[0]
try { u = decodeURIComponent(u) } catch { /* a stray % stays literal */ }
const segs = (u.startsWith('/') ? u : baseDir + '/' + u).split('/')
const out: string[] = []
for (const seg of segs) {
if (!seg || seg === '.') continue
if (seg === '..') { out.pop(); continue }
out.push(seg)
}
return out.join('/')
}
/** Quote one attribute value. The text already passed escapeHtml, so `&`, `<`
* and `>` are gone and only the quote itself can still break out. */
function attr(value: string): string {
return value.replace(/"/g, '&quot;')
}
// `(url)`, `(url "title")` or `(<url> 'title')` — the title is not part of the URL.
const DEST_RE = /^\s*(?:<([^>]*)>|([^\s)]*))(?:\s+["'(]([^"')]*)["')])?\s*$/
/** Split a link destination into its URL and its optional title. */
function dest(raw: string): { url: string; title: string } {
const m = raw.match(DEST_RE)
if (!m) return { url: raw.trim(), title: '' }
return { url: (m[1] ?? m[2] ?? '').trim(), title: m[3] ?? '' }
}
/** Inline markdown on one already-untrusted text run. */
function inline(src: string): string {
function inline(src: string, baseDir: string): string {
// Pull code spans out first so their literal content is never re-processed.
const codes: string[] = []
let s = src.replace(/`([^`]+)`/g, (_m, c) => {
@@ -37,13 +76,21 @@ function inline(src: string): string {
return SENT + (codes.length - 1) + SENT
})
s = HL.escapeHtml(s)
s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, url) => {
s = s.replace(/!\[([^\]]*)\]\(([^)]*)\)/g, (_m, alt, raw) => {
const { url, title } = dest(raw)
const u = safeUrl(url)
return u ? `<img alt="${alt}" src="${u}" />` : alt
if (!u) return alt
const t = title ? ` title="${attr(title)}"` : ''
// A local image has no src yet: the view reads the file over IPC and fills it.
const ref = isLocalUrl(u) ? `data-src="${attr(resolveRel(baseDir, u))}"` : `src="${attr(u)}"`
return `<img alt="${attr(alt)}" ${ref}${t} />`
})
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, t, url) => {
s = s.replace(/\[([^\]]+)\]\(([^)]*)\)/g, (_m, text, raw) => {
const { url, title } = dest(raw)
const u = safeUrl(url)
return u ? `<a href="${u}" target="_blank" rel="noreferrer">${t}</a>` : t
if (!u) return text
const t = title ? ` title="${attr(title)}"` : ''
return `<a href="${attr(u)}" target="_blank" rel="noreferrer"${t}>${text}</a>`
})
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
s = s.replace(/__([^_]+)__/g, '<strong>$1</strong>')
@@ -83,17 +130,17 @@ function tableAligns(line: string): (string | null)[] | null {
}
/** One `<td>`/`<th>`, with the column's alignment when the header set one. */
function cell(tag: string, text: string, align: string | null): string {
function cell(tag: string, text: string, align: string | null, baseDir: string): string {
const a = align ? ` style="text-align:${align}"` : ''
return `<${tag}${a}>` + inline(text) + `</${tag}>`
return `<${tag}${a}>` + inline(text, baseDir) + `</${tag}>`
}
export function renderMarkdown(text: string): string {
export function renderMarkdown(text: string, baseDir = ''): string {
const lines = text.replace(/\r\n?/g, '\n').split('\n')
const out: string[] = []
let para: string[] = []
const flushPara = (): void => {
if (para.length) { out.push('<p>' + inline(para.join(' ')) + '</p>'); para = [] }
if (para.length) { out.push('<p>' + inline(para.join(' '), baseDir) + '</p>'); para = [] }
}
let i = 0
@@ -116,7 +163,7 @@ export function renderMarkdown(text: string): string {
if (/^\s*$/.test(line)) { flushPara(); i++; continue }
const h = line.match(/^(#{1,6})\s+(.*)$/)
if (h) { flushPara(); const n = h[1].length; out.push(`<h${n}>` + inline(h[2].trim()) + `</h${n}>`); i++; continue }
if (h) { flushPara(); const n = h[1].length; out.push(`<h${n}>` + inline(h[2].trim(), baseDir) + `</h${n}>`); i++; continue }
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) { flushPara(); out.push('<hr />'); i++; continue }
@@ -132,10 +179,10 @@ export function renderMarkdown(text: string): string {
while (i < lines.length && lines[i].includes('|') && !/^\s*$/.test(lines[i])) {
rows.push(splitRow(lines[i])); i++
}
const body = rows.map((r) => '<tr>' + head.map((_c, n) => cell('td', r[n] ?? '', aligns[n])).join('') + '</tr>').join('')
const body = rows.map((r) => '<tr>' + head.map((_c, n) => cell('td', r[n] ?? '', aligns[n], baseDir)).join('') + '</tr>').join('')
out.push(
'<table class="md-table"><thead><tr>' +
head.map((c, n) => cell('th', c, aligns[n])).join('') +
head.map((c, n) => cell('th', c, aligns[n], baseDir)).join('') +
'</tr></thead>' + (body ? '<tbody>' + body + '</tbody>' : '') + '</table>',
)
continue
@@ -146,7 +193,7 @@ export function renderMarkdown(text: string): string {
flushPara()
const buf: string[] = []
while (i < lines.length && /^\s*>/.test(lines[i])) { buf.push(lines[i].replace(/^\s*>\s?/, '')); i++ }
out.push('<blockquote>' + renderMarkdown(buf.join('\n')) + '</blockquote>')
out.push('<blockquote>' + renderMarkdown(buf.join('\n'), baseDir) + '</blockquote>')
continue
}
@@ -154,7 +201,7 @@ export function renderMarkdown(text: string): string {
flushPara()
const items: string[] = []
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) { items.push(lines[i].replace(/^\s*[-*+]\s+/, '')); i++ }
out.push('<ul>' + items.map((it) => '<li>' + inline(it) + '</li>').join('') + '</ul>')
out.push('<ul>' + items.map((it) => '<li>' + inline(it, baseDir) + '</li>').join('') + '</ul>')
continue
}
@@ -162,7 +209,7 @@ export function renderMarkdown(text: string): string {
flushPara()
const items: string[] = []
while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i])) { items.push(lines[i].replace(/^\s*\d+[.)]\s+/, '')); i++ }
out.push('<ol>' + items.map((it) => '<li>' + inline(it) + '</li>').join('') + '</ol>')
out.push('<ol>' + items.map((it) => '<li>' + inline(it, baseDir) + '</li>').join('') + '</ol>')
continue
}

View File

@@ -432,6 +432,8 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
color:var(--accent-lite); font-style:italic; font-size:15px; }
.md-body hr { border:0; border-top:1px solid var(--border); margin:1.6em 0; }
.md-body img { max-width:100%; border-radius:var(--r-sm); }
/* A local image whose file could not be read keeps only its alt text. */
.md-body img:not([src]) { color:var(--fg-3); font-family:var(--mono); font-size:12px; }
.md-body code { font-family:var(--code-font); font-size:13px; color:var(--accent-lite); background:var(--bg-2);
border:1px solid var(--border); border-radius:var(--r-sm); padding:1px 5px; }
.md-body pre.md-code { background:var(--bg-2); border:1px solid var(--border); border-radius:var(--r-sm); padding:14px 16px; overflow:auto; margin:1.2em 0; }

View File

@@ -69,3 +69,33 @@ describe('makeDiff', () => {
expect(deleted.added).toBe(false)
})
})
describe('buildDiff on large files', () => {
it('diffs a 60k-line file without exhausting the heap', () => {
const base = Array.from({ length: 60_000 }, (_, i) => `line ${i}`)
const changed = base.slice()
changed[30_000] = 'line 30000 edited'
const d = buildDiff(base.join('\n'), changed.join('\n'))
expect(d.add).toBe(1)
expect(d.del).toBe(1)
expect(d.right[30_000].mark).toBe('add')
})
it('survives a full rewrite of a large file with no shared lines', () => {
const a = Array.from({ length: 40_000 }, (_, i) => `old ${i}`).join('\n')
const b = Array.from({ length: 40_000 }, (_, i) => `new ${i}`).join('\n')
const d = buildDiff(a, b)
expect(d.del).toBe(40_000)
expect(d.add).toBe(40_000)
})
it('keeps the unchanged head and tail of a large, heavily edited file', () => {
const head = Array.from({ length: 5_000 }, (_, i) => `head ${i}`)
const tail = Array.from({ length: 5_000 }, (_, i) => `tail ${i}`)
const mid = (p: string): string[] => Array.from({ length: 30_000 }, (_, i) => `${p} ${i % 7}`)
const d = buildDiff([...head, ...mid('x'), ...tail].join('\n'), [...head, ...mid('y'), ...tail].join('\n'))
expect(d.left.slice(0, 5_000).every((l) => l.mark === null)).toBe(true)
expect(d.left.slice(-5_000).every((l) => l.mark === null)).toBe(true)
expect(d.add).toBe(30_000)
})
})

View File

@@ -70,4 +70,31 @@ describe('renderMarkdown', () => {
expect(html).not.toContain('<table')
expect(html).toContain('<p>a | b not a table</p>')
})
it('resolves a relative image against the document folder', () => {
const html = renderMarkdown('![Forge](forge-app-config.png)', 'docs/how-to')
expect(html).toContain('data-src="docs/how-to/forge-app-config.png"')
expect(html).not.toContain(' src=')
})
it('keeps the image title out of the path and walks up a parent folder', () => {
const html = renderMarkdown('![c](../img/a%20b.png "A title")', 'docs/how-to')
expect(html).toContain('data-src="docs/img/a b.png"')
expect(html).toContain('title="A title"')
})
it('reads a leading slash as the project root', () => {
expect(renderMarkdown('![c](/img/a.png)', 'docs/how-to')).toContain('data-src="img/a.png"')
})
it('leaves a remote image on src', () => {
const html = renderMarkdown('![c](https://x.com/a.png)', 'docs')
expect(html).toContain('src="https://x.com/a.png"')
expect(html).not.toContain('data-src')
})
it('keeps a link title out of the href', () => {
const html = renderMarkdown('[a](https://x.com "Home")')
expect(html).toContain('href="https://x.com"')
expect(html).toContain('title="Home"')
})
})