From 4a57d81fe9aac19749040252973d0dc73a7b0384 Mon Sep 17 00:00:00 2001 From: Simon Grieve Date: Tue, 29 Sep 2026 10:45:43 +0100 Subject: [PATCH] Release ctrlaltbot 1.1.2 Fix section text from docs sites that only send rendered HTML. Each heading's "#" permalink was left in the text, so Discord showed headings as plain text and the following paragraph as a large heading. The HTML fallback now removes permalinks, renders headings in bold, keeps code blocks, inline code, bold text, and lists, and escapes other text Discord would treat as formatting. --- CHANGELOG.md | 6 +++++ package-lock.json | 4 ++-- package.json | 2 +- src/discord/sections.ts | 52 +++++++++++++++++++++++++++++++++++++---- test/sections.test.ts | 30 ++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f26b328..8f02c77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 0b2f82b..7379511 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ctrlaltbot", - "version": "1.1.1", + "version": "1.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ctrlaltbot", - "version": "1.1.1", + "version": "1.1.2", "license": "ISC", "dependencies": { "discord-interactions": "^4.4.0" diff --git a/package.json b/package.json index 522bfe5..1d86a4c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/discord/sections.ts b/src/discord/sections.ts index ceae876..272d07c 100644 --- a/src/discord/sections.ts +++ b/src/discord/sections.ts @@ -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(` 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(/]*\bclass="[^"]*\bheading-anchor\b[^"]*"[^>]*>[\s\S]*?<\/a>/gi, '') .replace(/[\s\S]*?<\/span>/gi, '') - .replace(/<\/(p|div|li|pre|tr|h\d)>|/gi, '\n') - .replace(/<[^>]+>/g, '') - .replace(/&(#?\w+);/g, (entity, name: string) => HTML_ENTITIES[name] ?? entity) + .replace(/]*>([\s\S]*?)<\/pre>/gi, (_, code: string) => { + // Drop template whitespace around , keeping the code's own indentation. + codeBlocks.push(decodeEntities(stripTags(code.replace(/^\s*(?=]*>([\s\S]*?)<\/h[1-6]>/gi, (_, inner: string) => `\n\n${BOLD}${inlineText(inner)}${BOLD}\n`) + .replace(/]*>([\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(/]*>/gi, `\n${BULLET}`) + // Each
  • already starts a new line, so closing one doesn't add another. + .replace(/<\/(p|div|ul|ol|tr|blockquote)>|/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. */ diff --git a/test/sections.test.ts b/test/sections.test.ts index fb5dcc3..39732d0 100644 --- a/test/sections.test.ts +++ b/test/sections.test.ts @@ -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\n\t\t\t\t\t#\n\t\t\t\t\n\t\t`; + const content = [ + `

    \n\t\t\tBoiling${anchor('boiling')}

    `, + '\t

    Place the egg in boiling water & wait. Use boil().

    ', + `

    \n\t\t\tTiming${anchor('timing')}

    `, + '\t

    - not a list, # not a heading

    ', + '
    • Six minutes
    • Seven if large
    ', + '
    sh
    \n\t\t\t# set a timer\nsleep 360
    ', + `

    \n\t\t\tFrying${anchor('frying')}

    `, + '

    Heat the pan.

    ' + ].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); });