From c4a6226539ef4018321be3a2d0ea768db4b78a03 Mon Sep 17 00:00:00 2001 From: Evgenii Orlov Date: Mon, 31 Aug 2026 09:22:21 +0200 Subject: [PATCH] fix(parser): keep slashes in unquoted attribute values The unquoted attribute value scanner treated '/' as a terminator, so `` yielded href="books" and the rest of the tag leaked into the document as text (issue #2). Per the HTML tokenizer, an unquoted value ends only at whitespace or '>'; a solidus is an ordinary value character. A solidus in before-attribute-name position now closes the tag only when '>' follows immediately, so stray slashes no longer discard the remaining attributes. Fixes #2 Claude-Session: https://claude.ai/code/session_01VtdWqKJzT4pmd28HhYmi8X --- src/dom/parser.zig | 73 +++++++++++++++++++++++++------ test/html_parser_test.zig | 90 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 14 deletions(-) diff --git a/src/dom/parser.zig b/src/dom/parser.zig index e87d22f..bf93311 100644 --- a/src/dom/parser.zig +++ b/src/dom/parser.zig @@ -213,9 +213,29 @@ const Parser = struct { // Parse attributes. var attrs: std.ArrayList(Attribute) = .empty; - while (self.pos < self.input.len and self.input[self.pos] != '>' and self.input[self.pos] != '/') { + var self_closing = false; + while (self.pos < self.input.len) { self.skipWhitespace(); - if (self.pos >= self.input.len or self.input[self.pos] == '>' or self.input[self.pos] == '/') break; + if (self.pos >= self.input.len) break; + + const c = self.input[self.pos]; + if (c == '>') { + self.pos += 1; + break; + } + if (c == '/') { + // A solidus here only closes the tag when '>' follows it + // immediately; otherwise it is a stray character and the next + // attribute still has to be parsed. + self.pos += 1; + if (self.pos < self.input.len and self.input[self.pos] == '>') { + self_closing = true; + self.pos += 1; + break; + } + continue; + } + const attr = try self.parseAttribute(); var duplicate = false; for (attrs.items) |existing| { @@ -225,16 +245,6 @@ const Parser = struct { } } if (!duplicate) try attrs.append(self.allocator, attr); - self.skipWhitespace(); - } - - var self_closing = false; - if (self.pos < self.input.len and self.input[self.pos] == '/') { - self_closing = true; - self.pos += 1; - } - if (self.pos < self.input.len and self.input[self.pos] == '>') { - self.pos += 1; } // Handle auto-closing of open elements. @@ -497,10 +507,11 @@ const Parser = struct { return self.decodeEntities(raw); } - // Unquoted value. + // Unquoted value: only whitespace and '>' terminate it. A solidus is + // an ordinary value character here, so `href=a/b/` keeps its slashes. const start = self.pos; while (self.pos < self.input.len and !isWhitespace(self.input[self.pos]) and - self.input[self.pos] != '>' and self.input[self.pos] != '/') + self.input[self.pos] != '>') { self.pos += 1; } @@ -796,3 +807,37 @@ test "parse entities" { const doc = try parse(arena.allocator(), "

& < >

"); _ = doc; } + +test "unquoted attribute values keep slashes" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const doc = try parse(arena.allocator(), "
Chapter 11"); + + const a = findElement(doc, "a").?; + try std.testing.expectEqualStrings("books/learning-zig/chapter11/", a.getAttr("href").?); + try std.testing.expectEqualStrings("link", a.getAttr("class").?); +} + +test "solidus only self closes a tag when '>' follows" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const doc = try parse(arena.allocator(), "
"); + + const div = findElement(doc, "div").?; + try std.testing.expectEqualStrings("a", div.getAttr("id").?); + try std.testing.expectEqualStrings("b", div.getAttr("class").?); + + const img = findElement(doc, "img").?; + try std.testing.expectEqualStrings("x/y.png", img.getAttr("src").?); +} + +fn findElement(root: *Node, tag: []const u8) ?*Node { + if (root.node_type == .element and std.mem.eql(u8, root.data, tag)) return root; + var child = root.first_child; + while (child) |c| : (child = c.next_sibling) { + if (findElement(c, tag)) |found| return found; + } + return null; +} diff --git a/test/html_parser_test.zig b/test/html_parser_test.zig index b95b478..ef56ca2 100644 --- a/test/html_parser_test.zig +++ b/test/html_parser_test.zig @@ -127,3 +127,93 @@ test "duplicate attributes keep the first value" { try std.testing.expectEqualStrings("first", div.attr("id").?); try std.testing.expectEqual(@as(usize, 1), div.nodes[0].attr.len); } + +test "unquoted attribute value keeps slashes" { + var doc = try zq.Document.initFromSlice( + std.testing.allocator, + "Chapter 11", + ); + defer doc.deinit(); + + const a = try doc.find("a"); + try std.testing.expectEqual(@as(usize, 1), a.len()); + try std.testing.expectEqualStrings("books/learning-zig/chapter11/", a.attr("href").?); + try std.testing.expectEqualStrings("link", a.attr("class").?); + + const t = try a.text(); + try std.testing.expectEqualStrings("Chapter 11", t); +} + +test "unquoted attributes across a minified document" { + const html = + "" ++ + "" ++ + "" ++ + "" ++ + "Document" ++ + "Chapter 11" ++ + ""; + + var doc = try zq.Document.initFromSlice(std.testing.allocator, html); + defer doc.deinit(); + + try std.testing.expectEqualStrings("en", (try doc.find("html")).attr("lang").?); + try std.testing.expectEqualStrings("UTF-8", (try doc.find("meta[charset]")).attr("charset").?); + try std.testing.expectEqualStrings( + "width=device-width, initial-scale=1.0", + (try doc.find("meta[name=viewport]")).attr("content").?, + ); + try std.testing.expectEqualStrings("Document", try (try doc.find("title")).text()); + + const links = try doc.find("a.link"); + try std.testing.expectEqual(@as(usize, 1), links.len()); + try std.testing.expectEqualStrings("books/learning-zig/chapter11/", links.attr("href").?); +} + +test "unquoted attribute followed by self closing slash" { + var doc = try zq.Document.initFromSlice( + std.testing.allocator, + "
", + ); + defer doc.deinit(); + + const imgs = try doc.find("img"); + try std.testing.expectEqual(@as(usize, 2), imgs.len()); + try std.testing.expectEqualStrings("a/b.png", imgs.nodes[0].getAttr("src").?); + try std.testing.expectEqualStrings("c.png", imgs.nodes[1].getAttr("src").?); +} + +test "trailing slash without gap belongs to unquoted value" { + var doc = try zq.Document.initFromSlice(std.testing.allocator, "text"); + defer doc.deinit(); + + const a = try doc.find("a"); + try std.testing.expectEqual(@as(usize, 1), a.len()); + try std.testing.expectEqualStrings("/docs/", a.attr("href").?); + try std.testing.expectEqualStrings("text", try a.text()); +} + +test "stray slash between attributes does not drop them" { + var doc = try zq.Document.initFromSlice(std.testing.allocator, "
"); + defer doc.deinit(); + + const div = try doc.find("div"); + try std.testing.expectEqual(@as(usize, 1), div.len()); + try std.testing.expectEqualStrings("a", div.attr("id").?); + try std.testing.expectEqualStrings("b", div.attr("class").?); +} + +test "void element self closes with unquoted attribute" { + var doc = try zq.Document.initFromSlice(std.testing.allocator, "

a
b

"); + defer doc.deinit(); + + try std.testing.expectEqual(@as(usize, 1), (try doc.find("br")).len()); + try std.testing.expectEqual(@as(usize, 1), (try doc.find("p")).len()); +} + +test "unquoted value decodes entities" { + var doc = try zq.Document.initFromSlice(std.testing.allocator, "x"); + defer doc.deinit(); + + try std.testing.expectEqualStrings("/a&b/c", (try doc.find("a")).attr("href").?); +}