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
(`<!DOCTYPE ...>`, `<?xml ...?>`) 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FumxeDJPEgPqX8mz3xkM6b
This commit is contained in:
Claude
2026-08-21 17:08:21 +00:00
parent 6560288fcb
commit b3d4cd924b
2 changed files with 213 additions and 3 deletions
@@ -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 `</name` that closes a raw-text element, or at the end of the input. */
private fun skipRawText(name: String) {
val end = input.indexOf("</$name", p, ignoreCase = true)
p = if (end < 0) input.length else end
}
fun nextTag(): RawTag? {
skipWhile { it != '<' }
if (this.exhausted()) return null
consume()
if (this.exhausted()) return null
// `<!-- ... -->`, `<!DOCTYPE ...>` 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 `<meta property="og:*">` 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..<nameEnd)
if (!name.matches(Regex("""[0-9a-zA-Z]+"""))) {
if (!name.matches(TAG_NAME)) {
return null
}
val lowercaseName = name.lowercase()
// Script and style bodies are raw text: a `<` in `for (i = 0; i < n; i++)` or a quote
// in a JS string is not markup and must not be scanned as such, for the same reason
// comments can't be.
if (!isEnd && (lowercaseName == "script" || lowercaseName == "style")) {
skipRawText(lowercaseName)
}
val attrsPart = input.slice(attrsStart..<attrsEnd)
return RawTag(isEnd, name.lowercase(), attrsPart)
return RawTag(isEnd, lowercaseName, attrsPart)
}
}
@@ -0,0 +1,168 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.preview
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Comments, declarations and script bodies are not element markup. Scanning them for
* attribute quotes lets an odd apostrophe -- "we don't" is enough -- leave the scanner
* inside a phantom quoted value, swallowing every tag up to the next quote character.
* That is what hid the entire og: block of https://brainstorm.world from link previews.
*/
class MetaTagsParserCommentTest {
@Test
fun commentWithUnbalancedApostropheDoesNotSwallowFollowingMetaTags() {
val input =
"""
|<html><head>
| <!-- Fresh visitors stay LIGHT -- we don't auto-dark a dark-OS visitor.
| Honors 'dark' and 'system'. -->
| <meta property="og:title" content="Brainstorm">
| <meta property="og:image" content="https://example.com/og-image.png">
|</head></html>
""".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 =
"""
|<html><head>
| <!-- <meta property="og:title" content="Commented Out"> -->
| <meta property="og:title" content="Real Title">
|</head></html>
""".trimMargin()
val metaTags = MetaTagsParser.parse(input).toList()
assertEquals(1, metaTags.size)
assertEquals("Real Title", metaTags[0].attr("content"))
}
@Test
fun scriptBodyDoesNotSwallowFollowingMetaTags() {
val input =
"""
|<html><head>
| <script>
| var t = localStorage.getItem('theme');
| for (var i = 0; i < 3; i++) { console.log("<meta property=\"og:title\" content=\"Fake\">"); }
| </script>
| <meta property="og:title" content="Real Title">
|</head></html>
""".trimMargin()
val metaTags = MetaTagsParser.parse(input).toList()
assertEquals(1, metaTags.size)
assertEquals("Real Title", metaTags[0].attr("content"))
}
@Test
fun styleBodyDoesNotSwallowFollowingMetaTags() {
val input =
"""
|<html><head>
| <style>body { font-family: 'Figtree', sans-serif; }</style>
| <meta property="og:title" content="Real Title">
|</head></html>
""".trimMargin()
val metaTags = MetaTagsParser.parse(input).toList()
assertEquals(1, metaTags.size)
assertEquals("Real Title", metaTags[0].attr("content"))
}
@Test
fun doctypeAndProcessingInstructionsAreSkipped() {
val input =
"""
|<?xml version="1.0" encoding="utf-8"?>
|<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN">
|<html><head>
| <meta property="og:title" content="Real Title">
|</head></html>
""".trimMargin()
val metaTags = MetaTagsParser.parse(input).toList()
assertEquals(1, metaTags.size)
assertEquals("Real Title", metaTags[0].attr("content"))
}
@Test
fun unterminatedCommentEndsTheDocument() {
val input =
"""
|<html><head>
| <meta property="og:title" content="Real Title">
| <!-- truncated download cuts the comment here
""".trimMargin()
val metaTags = MetaTagsParser.parse(input).toList()
assertEquals(1, metaTags.size)
assertEquals("Real Title", metaTags[0].attr("content"))
}
@Test
fun extractsOpenGraphFromAHeadWithCommentsAndScripts() {
// Shape of https://brainstorm.world/ (any /p/<npub> route serves the same index.html).
val input =
"""
|<!DOCTYPE html>
|<html lang="en">
| <head>
| <meta charset="UTF-8" />
| <!-- No-flash theme: set class="dark" synchronously before first paint.
| Fresh visitors (no stored choice) stay LIGHT -- we don't auto-dark a
| dark-OS visitor. When ready, change to: 't === dark || (!t || t === system)'. -->
| <script>
| (function () {
| var t = localStorage.getItem("brainstorm_theme");
| if (t === "dark") document.documentElement.classList.add("dark");
| })();
| </script>
| <title>Brainstorm - Web of Trust for Nostr</title>
| <meta property="og:title" content="Brainstorm - Your Network. Your Rules." />
| <meta property="og:description" content="The decentralized Web of Trust layer for Nostr." />
| <meta property="og:image" content="https://brainstorm.nosfabrica.com/og-image.png" />
| </head>
| <body><div id="root"></div></body>
|</html>
""".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)
}
}