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").?);
+}