fix: render task notes with standard markdown
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 6m20s

This commit is contained in:
2026-09-15 13:56:44 +08:00
parent ebabb87780
commit 9e1847329b
7 changed files with 117 additions and 93 deletions
+10 -6
View File
@@ -86,16 +86,20 @@ describe('task utilities', () => {
expect(buildTaskRecurrencePayload('none', { afterCompletionDays: '1' })).toEqual({})
})
it('renders safe practical markdown and strips unsafe html', () => {
const html = renderMarkdown('# Plan\n> Note\n1. first\n- [x] done\n\n```js\nconst x = 1\n```\n**bold** [link](https://example.com)\n<script>alert(1)</script>')
it('renders safe standard markdown with paragraphs, nesting, tasks, and fenced code', () => {
const html = renderMarkdown('# Plan\n\nFirst line\nsecond line\n\n- parent\n - child\n\n- [x] done\n\n```js\nconst x = 1\n```\n\n**bold** [link](https://example.com)\n<script>alert(1)</script>')
expect(html).toContain('<h1>Plan</h1>')
expect(html).toContain('<blockquote>Note</blockquote>')
expect(html).toContain('<ol><li>first</li></ol>')
expect(html).toContain('type="checkbox" disabled checked')
expect(html).toContain('<pre><code class="language-js">const x = 1</code></pre>')
expect(html).toContain('<p>First line<br>\nsecond line</p>')
expect(html).toMatch(/<li>\s*<p>parent<\/p>\s*<ul>\s*<li>child<\/li>\s*<\/ul>\s*<\/li>/)
expect(html).toContain('class="task-list-item')
expect(html).toContain('type="checkbox"')
expect(html).toContain('checked=""')
expect(html).toContain('<pre><code class="language-js">const x = 1\n</code></pre>')
expect(html).toContain('<strong>bold</strong>')
expect(html).toContain('target="_blank"')
expect(html).toContain('rel="noopener noreferrer"')
expect(html).not.toContain('<script>')
expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;')
})
it('inserts markdown around selections and prefixes selected lines', () => {
+18 -85
View File
@@ -1,3 +1,6 @@
import MarkdownIt from 'markdown-it'
import taskLists from 'markdown-it-task-lists'
export type MinimalTask = {
id: string
title: string
@@ -49,94 +52,24 @@ export function applyMarkdownFormat(value: string, start: number, end: number, f
return prefixLines('> ', '引用')
}
const renderInline = (value: string) => {
let out = escapeHtml(value)
out = out.replace(/`([^`]+)`/g, '<code>$1</code>')
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
out = out.replace(/\*([^*]+)\*/g, '<em>$1</em>')
out = out.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
)
return out
const markdownRenderer = new MarkdownIt({
breaks: true,
html: false,
linkify: true,
typographer: false,
})
markdownRenderer.use(taskLists, { enabled: false, label: false })
const defaultLinkOpen = markdownRenderer.renderer.rules.link_open
markdownRenderer.renderer.rules.link_open = (tokens, index, options, env, self) => {
const token = tokens[index]
token.attrSet('target', '_blank')
token.attrSet('rel', 'noopener noreferrer')
return defaultLinkOpen ? defaultLinkOpen(tokens, index, options, env, self) : self.renderToken(tokens, index, options)
}
export function renderMarkdown(markdown = '') {
const lines = markdown.replace(/\r\n/g, '\n').split('\n')
const html: string[] = []
let listType: 'ul' | 'ol' | null = null
let inCode = false
let codeLanguage = ''
const codeLines: string[] = []
const closeList = () => {
if (listType) {
html.push(`</${listType}>`)
listType = null
}
}
const openList = (type: 'ul' | 'ol') => {
if (listType === type) return
closeList()
html.push(`<${type}>`)
listType = type
}
for (const raw of lines) {
const line = raw.trimEnd()
const fence = line.match(/^```([\w-]*)\s*$/)
if (fence) {
closeList()
if (inCode) {
const languageClass = codeLanguage ? ` class="language-${escapeHtml(codeLanguage)}"` : ''
html.push(`<pre><code${languageClass}>${escapeHtml(codeLines.join('\n'))}</code></pre>`)
codeLines.length = 0
codeLanguage = ''
inCode = false
} else {
inCode = true
codeLanguage = fence[1]
}
continue
}
if (inCode) {
codeLines.push(raw)
continue
}
if (!line.trim()) {
closeList()
continue
}
if (line.startsWith('# ')) {
closeList()
html.push(`<h1>${renderInline(line.slice(2))}</h1>`)
} else if (line.startsWith('## ')) {
closeList()
html.push(`<h2>${renderInline(line.slice(3))}</h2>`)
} else if (line.startsWith('> ')) {
closeList()
html.push(`<blockquote>${renderInline(line.slice(2))}</blockquote>`)
} else if (/^\d+\. /.test(line)) {
openList('ol')
html.push(`<li>${renderInline(line.replace(/^\d+\. /, ''))}</li>`)
} else if (/^[-*] \[[ xX]\] /.test(line)) {
openList('ul')
const checked = /^[-*] \[[xX]\] /.test(line)
html.push(`<li class="task-list-item"><input type="checkbox" disabled${checked ? ' checked' : ''}>${renderInline(line.replace(/^[-*] \[[ xX]\] /, ''))}</li>`)
} else if (/^[-*] /.test(line)) {
openList('ul')
html.push(`<li>${renderInline(line.slice(2))}</li>`)
} else if (/^(---|\*\*\*)$/.test(line.trim())) {
closeList()
html.push('<hr>')
} else {
closeList()
html.push(`<p>${renderInline(line)}</p>`)
}
}
closeList()
if (inCode) html.push(`<pre><code>${escapeHtml(['```' + codeLanguage, ...codeLines].join('\n'))}</code></pre>`)
return html.join('')
return markdownRenderer.render(markdown)
}
export function filterTasks<T extends MinimalTask>(tasks: T[], query: string) {
+12
View File
@@ -0,0 +1,12 @@
declare module 'markdown-it-task-lists' {
import type MarkdownIt from 'markdown-it'
type TaskListOptions = {
enabled?: boolean
label?: boolean
labelAfter?: boolean
}
const taskLists: MarkdownIt.PluginWithOptions<TaskListOptions>
export default taskLists
}
File diff suppressed because one or more lines are too long
+2
View File
@@ -288,6 +288,8 @@ describe('completion feedback motion', () => {
expect(css).toContain('.markdown-toolbar{display:flex;')
expect(css).toContain('overflow-x:auto')
expect(css).toContain('.markdown-toolbar button{min-width:44px;min-height:44px;')
expect(css).toContain('.markdown-preview>.contains-task-list{list-style:none;padding-left:3px}')
expect(css).toContain('.markdown-preview .contains-task-list .contains-task-list{padding-left:24px}')
})
it('keeps all task detail changes on the unified save button', () => {