Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,6 @@ export class ThreadsComment extends LitElement {
}

private renderMarkdown(text: string): ReturnType<typeof html> {
if (typeof (window as any).docmd?.compile === 'function') {
try {
const rendered = (window as any).docmd.compile(text);
return html`<div .innerHTML=${this.sanitize(rendered)}></div>`;
} catch { /* fall through */ }
}
return html`<div .innerHTML=${this.sanitize(this.simpleMarkdown(text))}></div>`;
}

Expand Down Expand Up @@ -195,4 +189,4 @@ export class ThreadsComment extends LitElement {
</div>
`;
}
}
}
188 changes: 127 additions & 61 deletions packages/plugins/threads/src/plugin/containers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<id> [resolved "<by>" "<date>"]`
Expand Down Expand Up @@ -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 (
`<div class="threads-comment${replyClass}"${idAttr}${parentAttr} data-author="${safeAuthor}" data-date="${safeDate}"${editedAttr}>` +
`<div class="threads-comment__avatar-col"></div>` +
`<div class="threads-comment__meta"><strong>${safeAuthor}</strong> &middot; ${safeDate}</div>` +
`<div class="threads-comment__body">\n`
);
},
() => '</div></div>\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 (
`<div class="threads-comment${replyClass}"${idAttr}${parentAttr} data-author="${safeAuthor}" data-date="${safeDate}"${editedAttr}>` +
`<div class="threads-comment__avatar-col"></div>` +
`<div class="threads-comment__meta"><strong>${safeAuthor}</strong> &middot; ${safeDate}</div>` +
`<div class="threads-comment__body">\n`
);
};
md.renderer.rules.custom_comment_close = () => '</div></div>\n';

// 4. reactions - reactions container
createDepthTrackingContainer(
Expand All @@ -160,38 +260,4 @@ export function setup(md: any): void {
() => '</div>\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);
}
}
}
}
}
});
}
62 changes: 40 additions & 22 deletions packages/plugins/threads/tests/containers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -235,23 +233,43 @@ const html10 = md10.render(markdownBodyInput);
assertContains(html10, '<strong>bold</strong>', 'bold markdown renders in comment body');
assertContains(html10, '<em>italic</em>', '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"
<span data-test="raw">raw html</span>
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('<span data-test="raw">raw html</span>'), 'raw HTML is not emitted as an element');
assert(html11.includes('&lt;span') || html11.includes('&amp;lt;span'), 'raw HTML tag marker is escaped');
assertContains(html11, '<strong>bold</strong>', '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"
Expand All @@ -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"
Expand All @@ -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 ───

Expand Down
Loading