markdown pipe

This commit is contained in:
shamoon committed 2026-10-11 15:40:07 -07:00
1 parent e557854d8c
commit 82cfe868b5
2 files changed
+127

No files matched your search

@@ -0,0 +1,70 @@
import { MarkdownPipe } from './markdown.pipe'
describe('MarkdownPipe', () => {
const pipe = new MarkdownPipe()
it('should return empty string for empty input', () => {
expect(pipe.transform(null)).toEqual('')
expect(pipe.transform(undefined)).toEqual('')
expect(pipe.transform('')).toEqual('')
})
it('should render basic markdown', () => {
const html = pipe.transform(
'**bold** _em_ `code`\n\n- one\n- two\n\n| a | b |\n|---|---|\n| 1 | 2 |'
)
expect(html).toContain('<strong>bold</strong>')
expect(html).toContain('<em>em</em>')
expect(html).toContain('<code>code</code>')
expect(html).toContain('<li>one</li>')
expect(html).toContain('<table>')
})
it('should escape raw html', () => {
const html = pipe.transform(
'hi <img src=x onerror="alert(1)"> <b>x</b>\n\n<script>alert(1)</script>'
)
expect(html).not.toContain('<img')
expect(html).not.toContain('<script')
expect(html).not.toContain('<b>')
expect(html).toContain('&lt;script&gt;')
})
it('should not render images', () => {
const html = pipe.transform('![secret](https://evil.example/x.png?d=1)')
expect(html).not.toContain('<img')
expect(html).not.toContain('evil.example')
expect(html).toContain('secret')
})
it('should render safe links with target and rel', () => {
const html = pipe.transform('[docs](https://docs.paperless-ngx.com "Docs")')
expect(html).toContain(
'<a href="https://docs.paperless-ngx.com/" title="Docs" target="_blank" rel="noopener noreferrer nofollow">docs</a>'
)
expect(pipe.transform('[mail](mailto:a@b.c)')).toContain(
'href="mailto:a@b.c"'
)
})
it('should drop unsafe or relative links but keep their text', () => {
for (const href of [
'javascript:alert(1)',
'JaVaScRiPt:alert(1)',
'data:text/html,<script>alert(1)</script>',
'vbscript:msgbox',
'/api/documents/',
]) {
const html = pipe.transform(`[click](${href})`)
expect(html).not.toContain('<a')
expect(html).toContain('click')
}
})
it('should escape link titles', () => {
const html = pipe.transform(
'[x](https://a.example "a\\" onmouseover=\\"1")'
)
expect(html).not.toMatch(/"\s*onmouseover=/)
})
})
+57
View File
@@ -0,0 +1,57 @@
import { Pipe, PipeTransform } from '@angular/core'
import { Marked, Renderer, Tokens } from 'marked'
const ALLOWED_LINK_PROTOCOLS = ['http:', 'https:', 'mailto:']
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function safeHref(href: string): string | null {
try {
const url = new URL(href)
return ALLOWED_LINK_PROTOCOLS.includes(url.protocol) ? url.href : null
} catch {
return null
}
}
// Treat chat content as untrusted: no raw HTML, no images, and only
// absolute links. Angular sanitizer runs on top of this as well.
const renderer: Partial<Renderer> = {
html({ text }: Tokens.HTML | Tokens.Tag): string {
return escapeHtml(text)
},
image({ text }: Tokens.Image): string {
return escapeHtml(text)
},
link(this: Renderer, { href, title, tokens }: Tokens.Link): string {
const text = this.parser.parseInline(tokens)
const url = safeHref(href)
if (!url) return text
const titleAttr = title ? ` title="${escapeHtml(title)}"` : ''
return `<a href="${escapeHtml(url)}"${titleAttr} target="_blank" rel="noopener noreferrer nofollow">${text}</a>`
},
}
const markdown = new Marked({
async: false,
gfm: true,
breaks: true,
renderer,
})
@Pipe({
name: 'markdown',
})
export class MarkdownPipe implements PipeTransform {
transform(value: string | null | undefined): string {
if (!value) return ''
return markdown.parse(value) as string
}
}