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