From ea66a91a95220b64a539c8cc3bb28fd904d00a4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Thu, 5 Mar 2026 10:48:18 +0900 Subject: [PATCH 1/3] markdown: resolve absolute URLs and skip empty links --- src/browser/markdown.zig | 79 ++++++++++++++++++++++++++++++++++------ 1 file changed, 67 insertions(+), 12 deletions(-) diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index d1bb860e..d7f833ff 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -19,6 +19,7 @@ const std = @import("std"); const Page = @import("Page.zig"); +const URL = @import("URL.zig"); const CData = @import("webapi/CData.zig"); const Element = @import("webapi/Element.zig"); const Node = @import("webapi/Node.zig"); @@ -119,6 +120,21 @@ fn hasBlockDescendant(node: *Node) bool { } else false; } +fn hasVisibleContent(node: *Node) bool { + var it = node.childrenIterator(); + while (it.next()) |child| { + if (isSignificantText(child)) return true; + if (child.is(Element)) |el| { + if (!isVisibleElement(el)) continue; + // Images are visible + if (el.getTag() == .img) return true; + // Recursive check + if (hasVisibleContent(child)) return true; + } + } + return false; +} + fn ensureNewline(state: *State, writer: *std.Io.Writer) !void { if (!state.last_char_was_newline) { try writer.writeByte('\n'); @@ -278,20 +294,26 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag } try writer.writeAll("]("); if (el.getAttributeSafe(comptime .wrap("src"))) |src| { - try writer.writeAll(src); + const absolute_src = URL.resolve(page.call_arena, page.base(), src, .{}) catch src; + try writer.writeAll(absolute_src); } try writer.writeAll(")"); state.last_char_was_newline = false; return; }, .anchor => { + if (!hasVisibleContent(el.asNode())) return; + const has_block = hasBlockDescendant(el.asNode()); + const href_raw = el.getAttributeSafe(comptime .wrap("href")); + const href = if (href_raw) |h| URL.resolve(page.call_arena, page.base(), h, .{}) catch h else null; + if (has_block) { try renderChildren(el.asNode(), state, writer, page); - if (el.getAttributeSafe(comptime .wrap("href"))) |href| { + if (href) |h| { if (!state.last_char_was_newline) try writer.writeByte('\n'); try writer.writeAll("([Link]("); - try writer.writeAll(href); + try writer.writeAll(h); try writer.writeAll("))\n"); state.last_char_was_newline = true; } @@ -303,8 +325,8 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag try writer.writeByte('['); try renderChildren(el.asNode(), state, writer, page); try writer.writeAll("]("); - if (el.getAttributeSafe(comptime .wrap("href"))) |href| { - try writer.writeAll(href); + if (href) |h| { + try writer.writeAll(h); } try writer.writeAll(")\n"); state.last_char_was_newline = true; @@ -314,8 +336,8 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag try writer.writeByte('['); try renderChildren(el.asNode(), state, writer, page); try writer.writeAll("]("); - if (el.getAttributeSafe(comptime .wrap("href"))) |href| { - try writer.writeAll(href); + if (href) |h| { + try writer.writeAll(h); } try writer.writeByte(')'); state.last_char_was_newline = false; @@ -452,6 +474,8 @@ fn testMarkdownHTML(html: []const u8, expected: []const u8) !void { const testing = @import("../testing.zig"); const page = try testing.test_session.createPage(); defer testing.test_session.removePage(); + page.url = "http://localhost/"; + const doc = page.window._document; const div = try doc.createElement("div", null, page); @@ -520,11 +544,11 @@ test "browser.markdown: blockquote" { } test "browser.markdown: links" { - try testMarkdownHTML("Lightpanda", "[Lightpanda](https://lightpanda.io)\n"); + try testMarkdownHTML("Link", "[Link](http://localhost/relative)\n"); } test "browser.markdown: images" { - try testMarkdownHTML("\"Logo\"", "![Logo](logo.png)\n"); + try testMarkdownHTML("\"Logo\"", "![Logo](http://localhost/logo.png)\n"); } test "browser.markdown: headings" { @@ -588,8 +612,8 @@ test "browser.markdown: standalone anchors" { \\ Link 2 \\ , - \\[Link 1](1) - \\[Link 2](2) + \\[Link 1](http://localhost/1) + \\[Link 2](http://localhost/2) \\ ); } @@ -601,7 +625,38 @@ test "browser.markdown: mixed anchors in main" { \\ Welcome Link 1. \\ , - \\Welcome [Link 1](1). + \\Welcome [Link 1](http://localhost/1). \\ ); } + +test "browser.markdown: skip empty links" { + try testMarkdownHTML( + \\ + \\ + , ""); +} + +test "browser.markdown: resolve links" { + const testing = @import("../testing.zig"); + const page = try testing.test_session.createPage(); + defer testing.test_session.removePage(); + page.url = "https://example.com/a/index.html"; + + const doc = page.window._document; + const div = try doc.createElement("div", null, page); + try page.parseHtmlAsChildren(div.asNode(), + \\Link + \\Img + ); + + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + try dump(div.asNode(), .{}, &aw.writer, page); + + try testing.expectString( + \\[Link](https://example.com/a/b) + \\![Img](https://example.com/c.png) + \\ + , aw.written()); +} From e2f1609116d13abbeb40f5609d9da091bde5a753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Thu, 5 Mar 2026 11:27:51 +0900 Subject: [PATCH 2/3] markdown: use aria-label or title for empty links --- src/browser/markdown.zig | 47 ++++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index d7f833ff..5d7f3123 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -104,6 +104,10 @@ fn isVisibleElement(el: *Element) bool { }; } +fn getAnchorLabel(el: *Element) ?[]const u8 { + return el.getAttributeSafe(comptime .wrap("aria-label")) orelse el.getAttributeSafe(comptime .wrap("title")); +} + fn isAllWhitespace(text: []const u8) bool { return for (text) |c| { if (!std.ascii.isWhitespace(c)) break false; @@ -302,17 +306,20 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag return; }, .anchor => { - if (!hasVisibleContent(el.asNode())) return; + const has_content = hasVisibleContent(el.asNode()); + const label = getAnchorLabel(el); + const href_raw = el.getAttributeSafe(comptime .wrap("href")); + + if (!has_content and label == null and href_raw == null) return; const has_block = hasBlockDescendant(el.asNode()); - const href_raw = el.getAttributeSafe(comptime .wrap("href")); const href = if (href_raw) |h| URL.resolve(page.call_arena, page.base(), h, .{}) catch h else null; if (has_block) { try renderChildren(el.asNode(), state, writer, page); if (href) |h| { if (!state.last_char_was_newline) try writer.writeByte('\n'); - try writer.writeAll("([Link]("); + try writer.writeAll("([]("); try writer.writeAll(h); try writer.writeAll("))\n"); state.last_char_was_newline = true; @@ -323,7 +330,11 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag if (isStandaloneAnchor(el)) { if (!state.last_char_was_newline) try writer.writeByte('\n'); try writer.writeByte('['); - try renderChildren(el.asNode(), state, writer, page); + if (has_content) { + try renderChildren(el.asNode(), state, writer, page); + } else { + try writer.writeAll(label orelse ""); + } try writer.writeAll("]("); if (href) |h| { try writer.writeAll(h); @@ -334,7 +345,11 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag } try writer.writeByte('['); - try renderChildren(el.asNode(), state, writer, page); + if (has_content) { + try renderChildren(el.asNode(), state, writer, page); + } else { + try writer.writeAll(label orelse ""); + } try writer.writeAll("]("); if (href) |h| { try writer.writeAll(h); @@ -589,7 +604,7 @@ test "browser.markdown: block link" { \\### Title \\ \\Description - \\([Link](https://example.com)) + \\([](https://example.com)) \\ ); } @@ -634,7 +649,11 @@ test "browser.markdown: skip empty links" { try testMarkdownHTML( \\ \\ - , ""); + , + \\[](http://localhost/) + \\[](http://localhost/) + \\ + ); } test "browser.markdown: resolve links" { @@ -660,3 +679,17 @@ test "browser.markdown: resolve links" { \\ , aw.written()); } + +test "browser.markdown: anchor fallback label" { + try testMarkdownHTML( + \\ + , "[Discord Server](http://localhost/discord)\n"); + + try testMarkdownHTML( + \\ + , "[Search Site](http://localhost/search)\n"); + + try testMarkdownHTML( + \\ + , "[](http://localhost/no-label)\n"); +} From a27de38c030ef3a6edf6ca01008749b09238d65d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Thu, 5 Mar 2026 13:57:42 +0900 Subject: [PATCH 3/3] markdown: encode resolved URLs in links and images --- src/browser/markdown.zig | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index 5d7f3123..91e04196 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -298,7 +298,7 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag } try writer.writeAll("]("); if (el.getAttributeSafe(comptime .wrap("src"))) |src| { - const absolute_src = URL.resolve(page.call_arena, page.base(), src, .{}) catch src; + const absolute_src = URL.resolve(page.call_arena, page.base(), src, .{ .encode = true }) catch src; try writer.writeAll(absolute_src); } try writer.writeAll(")"); @@ -313,7 +313,7 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag if (!has_content and label == null and href_raw == null) return; const has_block = hasBlockDescendant(el.asNode()); - const href = if (href_raw) |h| URL.resolve(page.call_arena, page.base(), h, .{}) catch h else null; + const href = if (href_raw) |h| URL.resolve(page.call_arena, page.base(), h, .{ .encode = true }) catch h else null; if (has_block) { try renderChildren(el.asNode(), state, writer, page); @@ -667,6 +667,7 @@ test "browser.markdown: resolve links" { try page.parseHtmlAsChildren(div.asNode(), \\Link \\Img + \\Space ); var aw: std.Io.Writer.Allocating = .init(testing.allocator); @@ -675,7 +676,8 @@ test "browser.markdown: resolve links" { try testing.expectString( \\[Link](https://example.com/a/b) - \\![Img](https://example.com/c.png) + \\![Img](https://example.com/c.png) + \\[Space](https://example.com/my%20page) \\ , aw.written()); }