From e2ea4ca9399451c4fd6eb5049522d093757fa060 Mon Sep 17 00:00:00 2001 From: Johannes Fleck Date: Tue, 3 Nov 2026 12:13:18 +0100 Subject: [PATCH] fix: Decode HTML entities in llms.txt titles and descriptions Asciidoctor escapes the doctitle and header attributes for HTML, so llms.txt showed "Configuration & Environment Overrides" and "Contributor’s guide" instead of the plain characters. Decode numeric entities and the basic named ones (amp, lt, gt, quot, apos) in a single pass, so "&lt;" correctly stays the text "<". --- lib/llms-txt.js | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/lib/llms-txt.js b/lib/llms-txt.js index ac3f9b8c8..274db7e4e 100644 --- a/lib/llms-txt.js +++ b/lib/llms-txt.js @@ -68,16 +68,30 @@ module.exports.register = function () { } function entryFor (page, siteUrl) { - const title = (page.asciidoc && page.asciidoc.doctitle ? page.asciidoc.doctitle : page.src.stem) - .replace(/<[^>]+>/g, '') + const title = decodeEntities( + (page.asciidoc && page.asciidoc.doctitle ? page.asciidoc.doctitle : page.src.stem).replace(/<[^>]+>/g, '') + ) const description = page.asciidoc && page.asciidoc.attributes['description'] const source = sourceUrl(page.src) let entry = `- [${title}](${siteUrl}${page.pub.url})` - if (description) entry += `: ${description.replace(/\s+/g, ' ').trim()}` + if (description) entry += `: ${decodeEntities(description.replace(/\s+/g, ' ').trim())}` if (source) entry += ` (source: ${source})` return entry } +// Asciidoctor escapes the doctitle and header attributes such as description for +// HTML, so "&" arrives as "&" and "'" as "’". +// Decode in a single pass so "&lt;" stays the literal text "<". +const NAMED_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" } + +function decodeEntities (text) { + return text.replace(/&(?:#(\d+)|#x([0-9a-f]+)|(amp|lt|gt|quot|apos));/gi, (match, dec, hex, name) => { + if (dec) return String.fromCodePoint(Number(dec)) + if (hex) return String.fromCodePoint(parseInt(hex, 16)) + return NAMED_ENTITIES[name.toLowerCase()] + }) +} + function sourceUrl (src) { const origin = src.origin if (!origin || !origin.url || !origin.url.startsWith('https://github.com/')) return undefined