|
1 | 1 | import { Hono } from 'hono' |
2 | 2 | import { join } from 'node:path' |
| 3 | +import { marked, type Tokens } from 'marked' |
3 | 4 |
|
4 | 5 | import type { Store, StoredReportAsset, StoredReportShare } from '../../store' |
5 | 6 |
|
@@ -51,150 +52,29 @@ function sanitizeLink(rawUrl: string, resolveAssetUrl: (assetId: string) => stri |
51 | 52 | } |
52 | 53 | } |
53 | 54 |
|
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 | | - |
85 | 55 | 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) |
88 | 59 |
|
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 }) |
126 | 63 | } |
127 | 64 |
|
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 }) |
189 | 68 | } |
190 | 69 |
|
191 | | - flushParagraph() |
192 | | - flushList() |
193 | | - if (inCodeBlock) { |
194 | | - flushCode() |
195 | | - } |
| 70 | + renderer.html = (token: Tokens.HTML | Tokens.Tag) => escapeHtml(token.text) |
196 | 71 |
|
197 | | - return html.join('\n') |
| 72 | + return marked.parse(markdown, { |
| 73 | + async: false, |
| 74 | + gfm: true, |
| 75 | + breaks: true, |
| 76 | + renderer |
| 77 | + }) |
198 | 78 | } |
199 | 79 |
|
200 | 80 | function reportAssetPath(root: string, reportId: string, storageKey: string): string { |
@@ -264,8 +144,14 @@ function buildSharePageHtml(options: { |
264 | 144 | .status-unknown { background: #6663; color: #666; } |
265 | 145 | .card { border: 1px solid #8884; border-radius: 12px; padding: 16px; margin-top: 12px; } |
266 | 146 | 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; } |
267 | 149 | pre { overflow-x: auto; border-radius: 8px; padding: 12px; background: #8882; } |
268 | 150 | 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; } |
269 | 155 | img { max-width: 100%; border-radius: 8px; } |
270 | 156 | .asset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; } |
271 | 157 | .asset-card { margin: 0; border: 1px solid #8883; border-radius: 10px; overflow: hidden; } |
|
0 commit comments