diff --git a/packages/plugins/threads/src/client/components/threads-comment.ts b/packages/plugins/threads/src/client/components/threads-comment.ts index 8549636..5314ef1 100644 --- a/packages/plugins/threads/src/client/components/threads-comment.ts +++ b/packages/plugins/threads/src/client/components/threads-comment.ts @@ -96,12 +96,6 @@ export class ThreadsComment extends LitElement { } private renderMarkdown(text: string): ReturnType { - if (typeof (window as any).docmd?.compile === 'function') { - try { - const rendered = (window as any).docmd.compile(text); - return html`
`; - } catch { /* fall through */ } - } return html`
`; } @@ -195,4 +189,4 @@ export class ThreadsComment extends LitElement { `; } -} \ No newline at end of file +} diff --git a/packages/plugins/threads/src/plugin/containers.ts b/packages/plugins/threads/src/plugin/containers.ts index 2041050..b4824f3 100644 --- a/packages/plugins/threads/src/plugin/containers.ts +++ b/packages/plugins/threads/src/plugin/containers.ts @@ -16,6 +16,109 @@ interface CommentInfo { editedAt: string | null; } +function smartDedent(str: string): string { + const lines = str.split('\n'); + + while (lines.length && lines[0].trim() === '') lines.shift(); + while (lines.length && lines[lines.length - 1].trim() === '') lines.pop(); + + let minIndent = Infinity; + for (const line of lines) { + if (!line.trim()) continue; + const indent = line.match(/^ */)?.[0].length || 0; + minIndent = Math.min(minIndent, indent); + } + + if (!isFinite(minIndent) || minIndent === 0) return lines.join('\n'); + + return lines.map(line => + line.startsWith(' '.repeat(minIndent)) ? line.slice(minIndent) : line + ).join('\n'); +} + +function renderMarkdownWithoutRawHtml(md: any, source: string, env: any): string { + const previousHtml = md.options.html; + md.options.html = false; + try { + return md.render(source, env); + } finally { + md.options.html = previousHtml; + } +} + +function setupSafeCommentContainer(md: any): void { + md.block.ruler.before('fence', 'custom_comment', (state: any, startLine: number, endLine: number, silent: boolean) => { + const start = state.bMarks[startLine] + state.tShift[startLine]; + const max = state.eMarks[startLine]; + const lineContent = state.src.slice(start, max).trim(); + const match = lineContent.match(/^:::\s*comment(?:\s+(.*))?$/); + if (!match) return false; + if (silent) return true; + + let nextLine = startLine; + let found = false; + let depth = 1; + let fenceMarker: string | null = null; + + while (nextLine < endLine) { + nextLine++; + if (nextLine >= endLine) break; + + const nextStart = state.bMarks[nextLine] + state.tShift[nextLine]; + const nextMax = state.eMarks[nextLine]; + const nextContent = state.src.slice(nextStart, nextMax).trim(); + + if (!fenceMarker) { + const fenceMatch = nextContent.match(/^(`{3,}|~{3,})/); + if (fenceMatch) fenceMarker = fenceMatch[1]; + } else if (nextContent.startsWith(fenceMarker)) { + fenceMarker = null; + } + + if (!fenceMarker) { + if (nextContent.match(/^:::\s*[a-zA-Z]/) && !nextContent.match(/^:::\s*(button|embed|tag)\b/)) { + depth++; + } else if (nextContent.match(/^:::\s*$/)) { + depth--; + if (depth === 0) { + found = true; + break; + } + } + } + } + + if (!found) return false; + + let rawContent = ''; + for (let i = startLine + 1; i < nextLine; i++) { + rawContent += state.src.slice(state.bMarks[i], state.eMarks[i]) + '\n'; + } + const innerContent = smartDedent(rawContent); + + const openToken = state.push('custom_comment_open', 'div', 1); + openToken.info = match[1] || ''; + + if (innerContent) { + const oldIsInsideContainer = state.env.isInsideContainer; + state.env.isInsideContainer = true; + let renderedContent = ''; + try { + renderedContent = renderMarkdownWithoutRawHtml(state.md, innerContent, state.env); + } finally { + state.env.isInsideContainer = oldIsInsideContainer; + } + + const htmlToken = state.push('html_block', '', 0); + htmlToken.content = renderedContent; + } + + state.push('custom_comment_close', 'div', -1); + state.line = nextLine + 1; + return true; + }, { alt: ['paragraph', 'reference', 'blockquote', 'list'] }); +} + /** * Parse a thread info string. * Format: ` [resolved "" ""]` @@ -124,33 +227,30 @@ export function setup(md: any): void { ); // 3. comment - individual comment - createDepthTrackingContainer( - md, - 'comment', - (tokens: any[], idx: number) => { - const info = tokens[idx].info.trim(); - const parsed = parseCommentInfo(info); - - const safeId = parsed.id ? md.utils.escapeHtml(parsed.id) : null; - const safeParentId = parsed.parentId ? md.utils.escapeHtml(parsed.parentId) : null; - const safeAuthor = md.utils.escapeHtml(parsed.author); - const safeDate = md.utils.escapeHtml(parsed.date); - const safeEdited = parsed.editedAt ? md.utils.escapeHtml(parsed.editedAt) : null; - - const idAttr = safeId ? ` data-comment-id="${safeId}"` : ''; - const parentAttr = safeParentId ? ` data-parent-id="${safeParentId}"` : ''; - const editedAttr = safeEdited ? ` data-edited="${safeEdited}"` : ''; - const replyClass = parsed.parentId ? ' threads-comment--reply' : ''; - - return ( - `
` + - `
` + - `
${safeAuthor} · ${safeDate}
` + - `
\n` - ); - }, - () => '
\n' - ); + setupSafeCommentContainer(md); + md.renderer.rules.custom_comment_open = (tokens: any[], idx: number) => { + const info = tokens[idx].info.trim(); + const parsed = parseCommentInfo(info); + + const safeId = parsed.id ? md.utils.escapeHtml(parsed.id) : null; + const safeParentId = parsed.parentId ? md.utils.escapeHtml(parsed.parentId) : null; + const safeAuthor = md.utils.escapeHtml(parsed.author); + const safeDate = md.utils.escapeHtml(parsed.date); + const safeEdited = parsed.editedAt ? md.utils.escapeHtml(parsed.editedAt) : null; + + const idAttr = safeId ? ` data-comment-id="${safeId}"` : ''; + const parentAttr = safeParentId ? ` data-parent-id="${safeParentId}"` : ''; + const editedAttr = safeEdited ? ` data-edited="${safeEdited}"` : ''; + const replyClass = parsed.parentId ? ' threads-comment--reply' : ''; + + return ( + `
` + + `
` + + `
${safeAuthor} · ${safeDate}
` + + `
\n` + ); + }; + md.renderer.rules.custom_comment_close = () => '
\n'; // 4. reactions - reactions container createDepthTrackingContainer( @@ -160,38 +260,4 @@ export function setup(md: any): void { () => '\n' ); - // 5. Security: Harden comment bodies by disabling raw HTML rendering - // We push this to the core ruler to intercept tokens after block parsing - md.core.ruler.after('block', 'threads_harden', (state: any) => { - let insideComment = false; - - for (const token of state.tokens) { - if (token.type === 'container_comment_open') { - insideComment = true; - continue; - } - if (token.type === 'container_comment_close') { - insideComment = false; - continue; - } - - if (insideComment) { - // Disable raw HTML blocks inside comments - if (token.type === 'html_block') { - token.type = 'text'; - token.content = md.utils.escapeHtml(token.content); - } - - // Disable raw HTML inline inside comments - if (token.type === 'inline' && token.children) { - for (const child of token.children) { - if (child.type === 'html_inline') { - child.type = 'text'; - child.content = md.utils.escapeHtml(child.content); - } - } - } - } - } - }); } diff --git a/packages/plugins/threads/tests/containers.test.js b/packages/plugins/threads/tests/containers.test.js index 3d8ecd9..5b175dd 100644 --- a/packages/plugins/threads/tests/containers.test.js +++ b/packages/plugins/threads/tests/containers.test.js @@ -7,10 +7,8 @@ * @license MIT */ -const MarkdownIt = require('markdown-it'); - -// Also need the common-containers for createDepthTrackingContainer -const { setup: setupContainers } = require('../src/plugin/containers.js'); +import MarkdownIt from 'markdown-it'; +import { setup as setupContainers } from '../dist/plugin/containers.js'; let passed = 0; let total = 0; @@ -235,23 +233,43 @@ const html10 = md10.render(markdownBodyInput); assertContains(html10, 'bold', 'bold markdown renders in comment body'); assertContains(html10, 'italic', 'italic markdown renders in comment body'); -// ─── Test 11: Empty threads block ─── +// ─── Test 11: Comment body escapes raw HTML while preserving markdown ─── -console.log('\nTest 11: Empty threads block'); +console.log('\nTest 11: Comment body escapes raw HTML'); const md11 = createMd(); -const emptyInput = `::: threads +const rawHtmlInput = `::: threads + ::: thread t-html01 + ::: comment "alice" "2026-01-01" + raw html + This still has **bold** text + ::: + ::: ::: `; -const html11 = md11.render(emptyInput); -assertContains(html11, 'class="threads-sidebar"', 'empty threads wrapper still renders'); +const html11 = md11.render(rawHtmlInput); +assert(!html11.includes('raw html'), 'raw HTML is not emitted as an element'); +assert(html11.includes('<span') || html11.includes('&lt;span'), 'raw HTML tag marker is escaped'); +assertContains(html11, 'bold', 'markdown still renders in escaped comment body'); -// ─── Test 12: Comment with ID (new serialized format) ─── +// ─── Test 12: Empty threads block ─── -console.log('\nTest 12: Comment with ID in info string'); +console.log('\nTest 12: Empty threads block'); const md12 = createMd(); +const emptyInput = `::: threads +::: +`; + +const html12 = md12.render(emptyInput); +assertContains(html12, 'class="threads-sidebar"', 'empty threads wrapper still renders'); + +// ─── Test 13: Comment with ID (new serialized format) ─── + +console.log('\nTest 13: Comment with ID in info string'); + +const md13 = createMd(); const commentWithIdInput = `::: threads ::: thread t-id01 ::: comment c-abc12345 "alice" "2026-03-07" @@ -261,16 +279,16 @@ const commentWithIdInput = `::: threads ::: `; -const html12 = md12.render(commentWithIdInput); -assertContains(html12, 'data-comment-id="c-abc12345"', 'comment has data-comment-id from new format'); -assertContains(html12, 'data-author="alice"', 'comment has correct author from new format'); -assertContains(html12, 'data-date="2026-03-07"', 'comment has correct date from new format'); +const html13 = md13.render(commentWithIdInput); +assertContains(html13, 'data-comment-id="c-abc12345"', 'comment has data-comment-id from new format'); +assertContains(html13, 'data-author="alice"', 'comment has correct author from new format'); +assertContains(html13, 'data-date="2026-03-07"', 'comment has correct date from new format'); -// ─── Test 13: Comment with ID and edited (new serialized format) ─── +// ─── Test 14: Comment with ID and edited (new serialized format) ─── -console.log('\nTest 13: Comment with ID and edited'); +console.log('\nTest 14: Comment with ID and edited'); -const md13 = createMd(); +const md14 = createMd(); const commentWithIdEditedInput = `::: threads ::: thread t-id02 ::: comment c-def67890 "bob" "2026-03-07" edited "2026-03-08" @@ -280,10 +298,10 @@ const commentWithIdEditedInput = `::: threads ::: `; -const html13 = md13.render(commentWithIdEditedInput); -assertContains(html13, 'data-comment-id="c-def67890"', 'edited comment has data-comment-id'); -assertContains(html13, 'data-author="bob"', 'edited comment has correct author'); -assertContains(html13, 'data-edited="2026-03-08"', 'edited comment has data-edited'); +const html14 = md14.render(commentWithIdEditedInput); +assertContains(html14, 'data-comment-id="c-def67890"', 'edited comment has data-comment-id'); +assertContains(html14, 'data-author="bob"', 'edited comment has correct author'); +assertContains(html14, 'data-edited="2026-03-08"', 'edited comment has data-edited'); // ─── Done ───