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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to this project are documented here.

## [1.1.2] - 2026-09-29

### Fixed

- Section text from docs sites that only send rendered HTML showed headings as plain text and the paragraph after each heading as a large heading, because each heading's "#" permalink was left in the text. Headings now appear in bold, code blocks as code blocks, and inline code, bold text, and lists are kept; other text that Discord would treat as formatting is escaped.

## [1.1.1] - 2026-09-29

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ctrlaltbot",
"version": "1.1.1",
"version": "1.1.2",
"description": "Discord bot for ctrl alt doc documentation sites, running as a Cloudflare Worker.",
"repository": {
"type": "git",
Expand Down
52 changes: 47 additions & 5 deletions src/discord/sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,17 +101,59 @@ function fromHtml(html: string, selected: TocItem): string | undefined {
const rest = html.slice(headingEnd + closingTag.length);
const next = rest.search(new RegExp(`<h[1-${selected.level}]\\b`, 'i'));

return (next < 0 ? rest : rest.slice(0, next))
return htmlToDiscord(next < 0 ? rest : rest.slice(0, next));
}

// Stand-ins for Markdown the converter adds itself, so escaping the page's own
// text doesn't touch them. Private-use characters never appear in page text.
const BOLD = '';
const BULLET = '';
const CODE_BLOCK = '';

const decodeEntities = (text: string) => text.replace(/&(#x[\da-f]+|#\d+|\w+);/gi, (entity, name: string) => {
if (name[0] === '#') {
const code = name[1] === 'x' || name[1] === 'X' ? Number.parseInt(name.slice(2), 16) : Number.parseInt(name.slice(1), 10);
return Number.isFinite(code) ? String.fromCodePoint(code) : entity;
}
return HTML_ENTITIES[name.toLowerCase()] ?? entity;
});

const stripTags = (html: string) => html.replace(/<[^>]+>/g, '');

const inlineText = (html: string) => decodeEntities(stripTags(html)).replace(/\s+/g, ' ').trim();

/** Converts rendered CAD HTML to Discord Markdown, escaping anything in the text that Discord would treat as formatting. */
function htmlToDiscord(html: string): string {
const codeBlocks: string[] = [];

const text = html
.replace(/<(svg|button|script|style)\b[\s\S]*?<\/\1>/gi, '')
// Heading permalinks render as a "#" that Discord would read as heading syntax.
.replace(/<a\b[^>]*\bclass="[^"]*\bheading-anchor\b[^"]*"[^>]*>[\s\S]*?<\/a>/gi, '')
.replace(/<span class="code-language">[\s\S]*?<\/span>/gi, '')
.replace(/<\/(p|div|li|pre|tr|h\d)>|<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&(#?\w+);/g, (entity, name: string) => HTML_ENTITIES[name] ?? entity)
.replace(/<pre\b[^>]*>([\s\S]*?)<\/pre>/gi, (_, code: string) => {
// Drop template whitespace around <code>, keeping the code's own indentation.
codeBlocks.push(decodeEntities(stripTags(code.replace(/^\s*(?=<code\b)/i, ''))).replace(/^\n+|\s+$/g, ''));
return `\n${CODE_BLOCK}${codeBlocks.length - 1}${CODE_BLOCK}\n`;
})
.replace(/<h[1-6]\b[^>]*>([\s\S]*?)<\/h[1-6]>/gi, (_, inner: string) => `\n\n${BOLD}${inlineText(inner)}${BOLD}\n`)
.replace(/<code\b[^>]*>([\s\S]*?)<\/code>/gi, (_, inner: string) => `\`${inlineText(inner).replace(/`/g, 'ˋ')}\``)
.replace(/<(strong|b)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_, _tag: string, inner: string) => `${BOLD}${inlineText(inner)}${BOLD}`)
.replace(/<li\b[^>]*>/gi, `\n${BULLET}`)
// Each <li> already starts a new line, so closing one doesn't add another.
.replace(/<\/(p|div|ul|ol|tr|blockquote)>|<br\s*\/?>/gi, '\n');

return decodeEntities(stripTags(text))
.split('\n')
.map((line) => line.replace(/\s+/g, ' ').trim())
// Escape line starts that Discord renders as headings, quotes, or lists.
.map((line) => line.replace(/^(#|>|-|\+|\*\s|\d+[.)]\s)/, '\\$1'))
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
.trim()
.replaceAll(BOLD, '**')
.replaceAll(BULLET, '- ')
.replace(new RegExp(`${CODE_BLOCK}(\\d+)${CODE_BLOCK}`, 'g'), (_, index: string) => `\`\`\`\n${codeBlocks[Number(index)]}\n\`\`\``);
}

/** Truncates without leaving a code block open, which would break the rest of the embed. */
Expand Down
30 changes: 30 additions & 0 deletions test/sections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,36 @@ test('reads Markdown sent as content by sites that predate the markdown field',
assert.equal(sectionText(page({ content: markdown }), toc[3]!), 'First notes.');
});

test('converts rendered HTML without leaking heading permalinks as Markdown', () => {
const anchor = (id: string) => `\n\t\t\t\t<a\n\t\t\t\t\tclass="heading-anchor"\n\t\t\t\t\thref="#${id}"\n\t\t\t\t>\n\t\t\t\t\t#\n\t\t\t\t</a>\n\t\t`;
const content = [
`<h2 id="boiling">\n\t\t\tBoiling${anchor('boiling')}</h2>`,
'\t<p>Place the egg in <strong>boiling</strong> water &amp; wait. Use <code>boil()</code>.</p>',
`<h3 id="timing">\n\t\t\tTiming${anchor('timing')}</h3>`,
'\t<p>- not a list, # not a heading</p>',
'<ul><li>Six minutes</li><li>Seven if large</li></ul>',
'<div class="code-block"><span class="code-language">sh</span><button>Copy</button><pre class="shiki">\n\t\t\t<code><span class="line"># set a timer</span>\n<span class="line">sleep 360</span></code></pre></div>',
`<h2 id="frying">\n\t\t\tFrying${anchor('frying')}</h2>`,
'<p>Heat the pan.</p>'
].join('\n');

assert.equal(sectionText(page({ content }), toc[0]!), [
'Place the egg in **boiling** water & wait. Use `boil()`.',
'',
'**Timing**',
'',
'\\- not a list, # not a heading',
'',
'- Six minutes',
'- Seven if large',
'',
'```',
'# set a timer',
'sleep 360',
'```'
].join('\n'));
});

test('returns nothing for a heading that is not on the page', () => {
assert.equal(sectionText(page({ markdown }), { id: 'missing', title: 'Missing', level: 2 }), undefined);
});
Expand Down
Loading