Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 59 additions & 14 deletions src/dom/parser.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand All @@ -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.
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -796,3 +807,37 @@ test "parse entities" {
const doc = try parse(arena.allocator(), "<p>&amp; &lt; &gt;</p>");
_ = 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(), "<a href=books/learning-zig/chapter11/ class=link>Chapter 11</a>");

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(), "<div id=a / class=b><img src=x/y.png /></div>");

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;
}
90 changes: 90 additions & 0 deletions test/html_parser_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
"<a href=books/learning-zig/chapter11/ class=link>Chapter 11</a>",
);
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 =
"<!DOCTYPE html>" ++
"<html lang=en>" ++
"<head><meta charset=UTF-8>" ++
"<meta name=viewport content=\"width=device-width, initial-scale=1.0\">" ++
"<title>Document</title></head>" ++
"<body><a href=books/learning-zig/chapter11/ class=link>Chapter 11</a></body>" ++
"</html>";

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,
"<div><img src=a/b.png /><img src=c.png></div>",
);
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, "<a href=/docs/>text</a>");
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, "<div id=a / class=b></div>");
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, "<p>a<br/>b</p>");
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, "<a href=/a&amp;b/c>x</a>");
defer doc.deinit();

try std.testing.expectEqualStrings("/a&b/c", (try doc.find("a")).attr("href").?);
}
Loading