From b3d4cd924b759fad548384a634665bdc8e2c37f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 17:08:21 +0000 Subject: [PATCH] fix: stop HTML comments and script bodies from hiding og: meta tags MetaTagsParser's scanner treated `` like an element and ran the attribute quote tracker over its text. A comment holding an odd number of quote characters -- an apostrophe in "we don't" is enough -- left the scanner inside a phantom quoted attribute value, so every tag that followed was swallowed until the next matching quote character. brainstorm.world hits this: its head opens with a theme comment containing `don't`, `'dark'` and `'system'` (five apostrophes), and the scanner only resurfaced at the apostrophe in `manifest's`, several comments later. The whole og: block sat in between, so the parser saw 4 meta tags instead of 22 and none of them og:*. With no title/description/image, UrlInfoItem.fetchComplete() is false, UrlCachedPreviewer stores Empty and the note renders a bare link. Comments are now skipped to `-->`, declarations and processing instructions (``, ``) to the next `>` without quote tracking, and script/style bodies to their end tag -- `for (i = 0; i < n; i++)` and quotes in JS strings are raw text, not markup, and can hide the same way. Also guards a peek() past the end of a body truncated right after a `/`. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FumxeDJPEgPqX8mz3xkM6b --- .../commons/preview/MetaTagsParser.kt | 48 ++++- .../preview/MetaTagsParserCommentTest.kt | 168 ++++++++++++++++++ 2 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParserCommentTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt index 77092cadb2..6293f8c4f4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt @@ -33,6 +33,7 @@ data class MetaTag( } object MetaTagsParser { + private val TAG_NAME = Regex("""[0-9a-zA-Z]+""") private val NON_ATTR_NAME_CHARS = setOf(Char(0x0), '"', '\'', '>', '/') private val NON_UNQUOTED_ATTR_VALUE_CHARS = setOf('"', '\'', '=', '>', '<', '`') @@ -81,10 +82,42 @@ object MetaTagsParser { this.skipWhile { it.isWhitespace() } } + private fun skipComment() { + val end = input.indexOf("-->", p) + p = if (end < 0) input.length else end + 3 + } + + private fun skipToTagEnd() { + skipWhile { it != '>' } + if (!exhausted()) consume() + } + + /** Leaves [p] on the ``, `` and `` are not element markup, so the + // attribute-quote tracking below must not run over them. A comment holding an odd + // number of quote characters -- an apostrophe in "we don't", a lone `"` -- would + // otherwise leave the scanner inside a phantom quoted attribute value and make it + // swallow every tag that follows, until the next matching quote character. That is + // enough to hide a page's whole `` block from the preview. + if (peek() == '!' || peek() == '?') { + if (input.startsWith("!--", p)) { + skipComment() + } else { + skipToTagEnd() + } + return null + } // read tag name val isEnd = peek() == '/' @@ -105,7 +138,7 @@ object MetaTagsParser { val c = consume() when { // `/>` out of quote -> end of tag - quote == null && c == '/' && peek() == '>' -> { + quote == null && c == '/' && !exhausted() && peek() == '>' -> { consume() break } @@ -129,11 +162,20 @@ object MetaTagsParser { val attrsEnd = p - 1 val name = input.slice(nameStart.. + | + | + | + | + """.trimMargin() + + val metaTags = MetaTagsParser.parse(input).toList() + + assertEquals(2, metaTags.size) + assertEquals("Brainstorm", metaTags[0].attr("content")) + assertEquals("https://example.com/og-image.png", metaTags[1].attr("content")) + } + + @Test + fun metaTagsInsideCommentsAreNotParsed() { + val input = + """ + | + | + | + | + """.trimMargin() + + val metaTags = MetaTagsParser.parse(input).toList() + + assertEquals(1, metaTags.size) + assertEquals("Real Title", metaTags[0].attr("content")) + } + + @Test + fun scriptBodyDoesNotSwallowFollowingMetaTags() { + val input = + """ + | + | + | + | + """.trimMargin() + + val metaTags = MetaTagsParser.parse(input).toList() + + assertEquals(1, metaTags.size) + assertEquals("Real Title", metaTags[0].attr("content")) + } + + @Test + fun styleBodyDoesNotSwallowFollowingMetaTags() { + val input = + """ + | + | + | + | + """.trimMargin() + + val metaTags = MetaTagsParser.parse(input).toList() + + assertEquals(1, metaTags.size) + assertEquals("Real Title", metaTags[0].attr("content")) + } + + @Test + fun doctypeAndProcessingInstructionsAreSkipped() { + val input = + """ + | + | + | + | + | + """.trimMargin() + + val metaTags = MetaTagsParser.parse(input).toList() + + assertEquals(1, metaTags.size) + assertEquals("Real Title", metaTags[0].attr("content")) + } + + @Test + fun unterminatedCommentEndsTheDocument() { + val input = + """ + | + | + | + | + | Brainstorm - Web of Trust for Nostr + | + | + | + | + |
+ | + """.trimMargin() + + val info = OpenGraphParser().extractUrlInfo(MetaTagsParser.parse(input)) + + assertEquals("Brainstorm - Your Network. Your Rules.", info.title) + assertEquals("The decentralized Web of Trust layer for Nostr.", info.description) + assertEquals("https://brainstorm.nosfabrica.com/og-image.png", info.image) + } +}