diff --git a/src/browser/structured_data.zig b/src/browser/structured_data.zig index 4335fb88..9b6e7fbe 100644 --- a/src/browser/structured_data.zig +++ b/src/browser/structured_data.zig @@ -93,11 +93,41 @@ pub const StructuredData = struct { } }; +/// Serializes properties as a JSON object. When a key appears multiple times +/// (e.g. multiple og:image tags), values are grouped into an array. +/// Alternatives considered: always-array values (verbose), or an array of +/// {key, value} pairs (preserves order but less ergonomic for consumers). fn writeProperties(jw: anytype, properties: []const Property) !void { try jw.beginObject(); - for (properties) |prop| { + for (properties, 0..) |prop, i| { + // Skip keys already written by an earlier occurrence. + var already_written = false; + for (properties[0..i]) |prev| { + if (std.mem.eql(u8, prev.key, prop.key)) { + already_written = true; + break; + } + } + if (already_written) continue; + + // Count total occurrences to decide string vs array. + var count: usize = 0; + for (properties) |p| { + if (std.mem.eql(u8, p.key, prop.key)) count += 1; + } + try jw.objectField(prop.key); - try jw.write(prop.value); + if (count == 1) { + try jw.write(prop.value); + } else { + try jw.beginArray(); + for (properties) |p| { + if (std.mem.eql(u8, p.key, prop.key)) { + try jw.write(p.value); + } + } + try jw.endArray(); + } } try jw.endObject(); } @@ -194,16 +224,16 @@ fn collectMeta( // Open Graph: if (el.getAttributeSafe(comptime .wrap("property"))) |property| { - if (startsWith(property, "og:")) { + if (std.mem.startsWith(u8, property, "og:")) { try open_graph.append(arena, .{ .key = property[3..], .value = content }); return; } // Article, profile, etc. are OG sub-namespaces. - if (startsWith(property, "article:") or - startsWith(property, "profile:") or - startsWith(property, "book:") or - startsWith(property, "music:") or - startsWith(property, "video:")) + if (std.mem.startsWith(u8, property, "article:") or + std.mem.startsWith(u8, property, "profile:") or + std.mem.startsWith(u8, property, "book:") or + std.mem.startsWith(u8, property, "music:") or + std.mem.startsWith(u8, property, "video:")) { try open_graph.append(arena, .{ .key = property, .value = content }); return; @@ -212,7 +242,7 @@ fn collectMeta( // Twitter Cards: if (el.getAttributeSafe(comptime .wrap("name"))) |name| { - if (startsWith(name, "twitter:")) { + if (std.mem.startsWith(u8, name, "twitter:")) { try twitter_card.append(arena, .{ .key = name[8..], .value = content }); return; } @@ -283,11 +313,6 @@ fn collectLink( } } -fn startsWith(haystack: []const u8, prefix: []const u8) bool { - if (haystack.len < prefix.len) return false; - return std.mem.eql(u8, haystack[0..prefix.len], prefix); -} - // --- Tests --- const testing = @import("../testing.zig"); @@ -344,6 +369,35 @@ test "structured_data: open graph" { try testing.expectEqual("2026-03-10", findProperty(data.open_graph, "article:published_time").?); } +test "structured_data: open graph duplicate keys" { + const data = try testStructuredData( + \\ + \\ + \\ + \\ + ); + // Duplicate keys are preserved as separate Property entries. + try testing.expectEqual(4, data.open_graph.len); + + // Verify serialization groups duplicates into arrays. + const json = try std.json.Stringify.valueAlloc(testing.allocator, data, .{}); + defer testing.allocator.free(json); + + const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); + defer parsed.deinit(); + const og = parsed.value.object.get("openGraph").?.object; + // "title" appears once → string. + switch (og.get("title").?) { + .string => {}, + else => return error.TestUnexpectedResult, + } + // "image" appears 3 times → array. + switch (og.get("image").?) { + .array => |arr| try testing.expectEqual(3, arr.items.len), + else => return error.TestUnexpectedResult, + } +} + test "structured_data: twitter card" { const data = try testStructuredData( \\