Skip to content

Commit e1a43bd

Browse files
committed
feat(hub): upgrade public report markdown renderer
1 parent ea17d16 commit e1a43bd

4 files changed

Lines changed: 118 additions & 138 deletions

File tree

bun.lock

Lines changed: 6 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

hub/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"grammy": "^1.38.4",
2121
"hono": "^4.11.2",
2222
"jose": "^6.1.3",
23+
"marked": "^17.0.3",
2324
"qrcode": "^1.5.4",
2425
"socket.io": "^4.8.3",
2526
"web-push": "^3.6.7",
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, expect, it } from 'bun:test'
2+
import type { Database } from 'bun:sqlite'
3+
4+
import { Store } from '../../store'
5+
import { createPublicReportsRoutes } from './publicReports'
6+
7+
function closeStore(store: Store): void {
8+
const db = (store as unknown as { db: Database }).db
9+
db.close()
10+
}
11+
12+
describe('public report markdown rendering', () => {
13+
it('renders GFM markdown blocks with marked', async () => {
14+
const store = new Store(':memory:')
15+
16+
const report = store.reports.createReport({
17+
namespace: 'default',
18+
title: 'Markdown Feature Report',
19+
markdown: [
20+
'# Heading',
21+
'',
22+
'> quote line',
23+
'',
24+
'| A | B |',
25+
'| --- | --- |',
26+
'| 1 | 2 |',
27+
'',
28+
'- [x] done',
29+
'- [ ] todo',
30+
'',
31+
'~~strike~~'
32+
].join('\n')
33+
})
34+
const share = store.reports.createShare({
35+
reportId: report.id,
36+
namespace: 'default'
37+
})
38+
39+
const app = createPublicReportsRoutes({
40+
store,
41+
reportsStorageDir: '/tmp'
42+
})
43+
const response = await app.request(`http://localhost/share/r/${share.token}`)
44+
const html = await response.text()
45+
46+
expect(response.status).toBe(200)
47+
expect(html).toContain('<table>')
48+
expect(html).toContain('<blockquote>')
49+
expect(html).toContain('<del>strike</del>')
50+
expect(html).toContain('type="checkbox"')
51+
52+
closeStore(store)
53+
})
54+
55+
it('sanitizes raw html and unsafe links while preserving asset links', async () => {
56+
const store = new Store(':memory:')
57+
58+
const report = store.reports.createReport({
59+
namespace: 'default',
60+
title: 'Sanitize Report',
61+
markdown: [
62+
'<script>alert(1)</script>',
63+
'',
64+
'[bad](javascript:alert(1))',
65+
'',
66+
'![shot](asset://asset-123)'
67+
].join('\n')
68+
})
69+
const share = store.reports.createShare({
70+
reportId: report.id,
71+
namespace: 'default'
72+
})
73+
74+
const app = createPublicReportsRoutes({
75+
store,
76+
reportsStorageDir: '/tmp'
77+
})
78+
const response = await app.request(`http://localhost/share/r/${share.token}`)
79+
const html = await response.text()
80+
81+
expect(response.status).toBe(200)
82+
expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;')
83+
expect(html).toContain('<a href="#">bad</a>')
84+
expect(html).toContain(`/share/r/${share.token}/assets/asset-123`)
85+
86+
closeStore(store)
87+
})
88+
})

hub/src/web/routes/publicReports.ts

Lines changed: 23 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Hono } from 'hono'
22
import { join } from 'node:path'
3+
import { marked, type Tokens } from 'marked'
34

45
import type { Store, StoredReportAsset, StoredReportShare } from '../../store'
56

@@ -51,150 +52,29 @@ function sanitizeLink(rawUrl: string, resolveAssetUrl: (assetId: string) => stri
5152
}
5253
}
5354

54-
function renderInlineMarkdown(text: string, resolveAssetUrl: (assetId: string) => string): string {
55-
const pattern = /!\[([^\]]*)\]\(([^)]+)\)|\[([^\]]+)\]\(([^)]+)\)|`([^`]+)`/g
56-
let result = ''
57-
let index = 0
58-
59-
for (const match of text.matchAll(pattern)) {
60-
const full = match[0]
61-
const start = match.index ?? 0
62-
const end = start + full.length
63-
64-
result += escapeHtml(text.slice(index, start))
65-
66-
if (typeof match[1] === 'string' && typeof match[2] === 'string') {
67-
const alt = escapeHtml(match[1])
68-
const src = escapeHtml(sanitizeLink(match[2], resolveAssetUrl))
69-
result += `<img src="${src}" alt="${alt}" loading="lazy" />`
70-
} else if (typeof match[3] === 'string' && typeof match[4] === 'string') {
71-
const label = escapeHtml(match[3])
72-
const href = escapeHtml(sanitizeLink(match[4], resolveAssetUrl))
73-
result += `<a href="${href}" target="_blank" rel="noreferrer">${label}</a>`
74-
} else if (typeof match[5] === 'string') {
75-
result += `<code>${escapeHtml(match[5])}</code>`
76-
}
77-
78-
index = end
79-
}
80-
81-
result += escapeHtml(text.slice(index))
82-
return result
83-
}
84-
8555
function renderMarkdown(markdown: string, resolveAssetUrl: (assetId: string) => string): string {
86-
const lines = markdown.replace(/\r\n/g, '\n').split('\n')
87-
const html: string[] = []
56+
const renderer = new marked.Renderer()
57+
const defaultLink = renderer.link.bind(renderer)
58+
const defaultImage = renderer.image.bind(renderer)
8859

89-
let paragraphLines: string[] = []
90-
let listType: 'ul' | 'ol' | null = null
91-
let listItems: string[] = []
92-
let inCodeBlock = false
93-
let codeLines: string[] = []
94-
let codeLanguage = ''
95-
96-
const flushParagraph = () => {
97-
if (paragraphLines.length === 0) return
98-
const content = paragraphLines
99-
.map((line) => renderInlineMarkdown(line, resolveAssetUrl))
100-
.join('<br />')
101-
html.push(`<p>${content}</p>`)
102-
paragraphLines = []
103-
}
104-
105-
const flushList = () => {
106-
if (!listType || listItems.length === 0) {
107-
listType = null
108-
listItems = []
109-
return
110-
}
111-
112-
const items = listItems
113-
.map((item) => `<li>${renderInlineMarkdown(item, resolveAssetUrl)}</li>`)
114-
.join('\n')
115-
html.push(`<${listType}>${items}</${listType}>`)
116-
listType = null
117-
listItems = []
118-
}
119-
120-
const flushCode = () => {
121-
if (codeLines.length === 0) return
122-
const classAttr = codeLanguage ? ` class="language-${escapeHtml(codeLanguage)}"` : ''
123-
html.push(`<pre><code${classAttr}>${escapeHtml(codeLines.join('\n'))}</code></pre>`)
124-
codeLines = []
125-
codeLanguage = ''
60+
renderer.link = (token: Tokens.Link) => {
61+
const href = sanitizeLink(token.href, resolveAssetUrl)
62+
return defaultLink({ ...token, href })
12663
}
12764

128-
for (const line of lines) {
129-
const trimmed = line.trim()
130-
131-
if (trimmed.startsWith('```')) {
132-
if (inCodeBlock) {
133-
inCodeBlock = false
134-
flushCode()
135-
} else {
136-
flushParagraph()
137-
flushList()
138-
inCodeBlock = true
139-
codeLanguage = trimmed.slice(3).trim()
140-
}
141-
continue
142-
}
143-
144-
if (inCodeBlock) {
145-
codeLines.push(line)
146-
continue
147-
}
148-
149-
if (!trimmed) {
150-
flushParagraph()
151-
flushList()
152-
continue
153-
}
154-
155-
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/)
156-
if (headingMatch) {
157-
flushParagraph()
158-
flushList()
159-
const level = Math.min(6, headingMatch[1].length)
160-
const content = renderInlineMarkdown(headingMatch[2], resolveAssetUrl)
161-
html.push(`<h${level}>${content}</h${level}>`)
162-
continue
163-
}
164-
165-
const unorderedMatch = trimmed.match(/^[-*+]\s+(.+)$/)
166-
if (unorderedMatch) {
167-
flushParagraph()
168-
if (listType !== 'ul') {
169-
flushList()
170-
listType = 'ul'
171-
}
172-
listItems.push(unorderedMatch[1])
173-
continue
174-
}
175-
176-
const orderedMatch = trimmed.match(/^\d+\.\s+(.+)$/)
177-
if (orderedMatch) {
178-
flushParagraph()
179-
if (listType !== 'ol') {
180-
flushList()
181-
listType = 'ol'
182-
}
183-
listItems.push(orderedMatch[1])
184-
continue
185-
}
186-
187-
flushList()
188-
paragraphLines.push(line)
65+
renderer.image = (token: Tokens.Image) => {
66+
const href = sanitizeLink(token.href, resolveAssetUrl)
67+
return defaultImage({ ...token, href })
18968
}
19069

191-
flushParagraph()
192-
flushList()
193-
if (inCodeBlock) {
194-
flushCode()
195-
}
70+
renderer.html = (token: Tokens.HTML | Tokens.Tag) => escapeHtml(token.text)
19671

197-
return html.join('\n')
72+
return marked.parse(markdown, {
73+
async: false,
74+
gfm: true,
75+
breaks: true,
76+
renderer
77+
})
19878
}
19979

20080
function reportAssetPath(root: string, reportId: string, storageKey: string): string {
@@ -264,8 +144,14 @@ function buildSharePageHtml(options: {
264144
.status-unknown { background: #6663; color: #666; }
265145
.card { border: 1px solid #8884; border-radius: 12px; padding: 16px; margin-top: 12px; }
266146
article { line-height: 1.6; }
147+
blockquote { margin: 0; padding: 0 0 0 12px; border-left: 4px solid #8884; color: #999; }
148+
hr { border: 0; border-top: 1px solid #8884; margin: 16px 0; }
267149
pre { overflow-x: auto; border-radius: 8px; padding: 12px; background: #8882; }
268150
code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
151+
:not(pre) > code { border-radius: 6px; padding: 2px 6px; background: #8882; }
152+
table { width: 100%; border-collapse: collapse; margin: 12px 0; }
153+
th, td { border: 1px solid #8884; padding: 6px 8px; text-align: left; }
154+
input[type='checkbox'] { pointer-events: none; }
269155
img { max-width: 100%; border-radius: 8px; }
270156
.asset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
271157
.asset-card { margin: 0; border: 1px solid #8883; border-radius: 10px; overflow: hidden; }

0 commit comments

Comments
 (0)