From b6cde68ef68874dd8d3cc60cb51f4f486f28a5c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sun, 27 Sep 2026 21:17:08 +0200 Subject: [PATCH 1/4] markdown: render without href as plain text An anchor with no href is a placeholder, not a hyperlink, so emitting [text]() produced a broken link. Render its children inline instead. --- src/browser/markdown.zig | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index c7c137f2d..a789a1c4d 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -83,8 +83,8 @@ const Context = struct { const Epilogue = union(enum) { none, element: Element.Tag, - block_anchor: struct { href: ?[]const u8, label: ?[]const u8 }, - inline_anchor: struct { href: ?[]const u8, standalone: bool }, + block_anchor: struct { href: []const u8, label: ?[]const u8 }, + inline_anchor: struct { href: []const u8, standalone: bool }, }; fn deinit(self: *Context) void { @@ -258,16 +258,15 @@ const Context = struct { return; }, .anchor => { + // Without href, is a placeholder, not a hyperlink. + const href_raw = el.getAttributeInterned("href") orelse { + return self.open(.init(self.tree.content(el, boxed)), .none); + }; + const frame = self.frame; const info = RenderTree.analyzeContent(el.asNode(), frame); const label = getAnchorLabel(el); - const href_raw = el.getAttributeInterned("href"); - - if (!info.has_visible and label == null and href_raw == null) { - return; - } - - const href = if (href_raw) |h| URL.resolve(frame.local_arena, frame.base(), h, .{ .encoding = frame.charset }) catch h else null; + const href = URL.resolve(frame.local_arena, frame.base(), href_raw, .{ .encoding = frame.charset }) catch href_raw; if (info.has_block) { return self.open(.init(self.tree.content(el, boxed)), .{ .block_anchor = .{ @@ -311,20 +310,17 @@ const Context = struct { .none => return, .element => |t| t, .block_anchor => |anchor| { - const href = anchor.href orelse return; try self.ensureNewline(); try self.writer.writeByte('['); - try self.writer.writeAll(anchor.label orelse href); + try self.writer.writeAll(anchor.label orelse anchor.href); try self.writer.writeAll("]("); - try self.writer.writeAll(href); + try self.writer.writeAll(anchor.href); try self.writer.writeAll(")\n"); return; }, .inline_anchor => |anchor| { try self.writer.writeAll("]("); - if (anchor.href) |h| { - try self.writer.writeAll(h); - } + try self.writer.writeAll(anchor.href); try self.writer.writeByte(')'); if (anchor.standalone) { try self.writer.writeByte('\n'); @@ -756,6 +752,16 @@ test "browser.markdown: anchor fallback label" { , "[](http://localhost/no-label)\n"); } +test "browser.markdown: anchor without href is plain text" { + try testMarkdownHTML( + \\
Tags: change deep
+ , "Tags: change **deep**\n"); + + try testMarkdownHTML( + \\ + , ""); +} + test "browser.markdown: hidden elements are skipped" { try testMarkdownHTML( \\

before

From 2be33e4af86127fd1150f3b26773a183f24baf55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sun, 27 Sep 2026 23:56:42 +0200 Subject: [PATCH 2/4] clutter: don't count without href as link text Tag chips like x pushed short quotes on quotes.toscrape.com/js/ over the 0.2 link-density limit, so --strip-mode clutter pruned them as navigation. --- src/browser/clutter.zig | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/browser/clutter.zig b/src/browser/clutter.zig index e7964767a..5c64fb482 100644 --- a/src/browser/clutter.zig +++ b/src/browser/clutter.zig @@ -286,9 +286,10 @@ const Pass = struct { var inner = ctx; switch (tag) { - .anchor => { + // Unlike readability: without href, is a placeholder + // (tag chips, JS buttons), and its text is not link text. + .anchor => if (el.getAttributeSafe(comptime .wrap("href"))) |href| { inner.in_link = true; - const href = el.getAttributeSafe(comptime .wrap("href")) orelse ""; inner.hash_link = href.len > 0 and href[0] == '#'; }, .h1, .h2, .h3, .h4, .h5, .h6 => inner.in_heading = true, @@ -921,6 +922,15 @@ test "clutter: qualifying sibling paragraphs come along" { try testing.expectEqual(null, std.mem.indexOf(u8, out, "[a]")); } +test "clutter: anchors without href are not link text" { + const quote = ""; + // A short quote with many tags: over 0.2 link density if tags counted. + const tagged = "
I have not failed. I've just found ten thousand ways that won't work.by Thomas A. Edison" ++ + "
"; + const out = try extract("
" ++ quote ++ quote ++ tagged ++ quote ++ "
"); + try testing.expectEqual(true, std.mem.indexOf(u8, out, "ten thousand ways") != null); +} + test "clutter: the prune set lives in the caller's allocator" { const frame = try testing.createFrame(); defer testing.test_session.closeAllPages(); From 550c2b3c61f19567f74565ee3e6859332cc81d3e Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 28 Sep 2026 10:17:13 +0800 Subject: [PATCH 3/4] apply same href-less detection to shell measurement --- src/browser/RenderTree.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/RenderTree.zig b/src/browser/RenderTree.zig index 211846b76..fe3ea26c9 100644 --- a/src/browser/RenderTree.zig +++ b/src/browser/RenderTree.zig @@ -343,7 +343,7 @@ fn measureChild(self: *const RenderTree, child: Child, where: Measure.Where, m: const el = child.node.subtype(Element); const inner: Measure.Where = .{ .shell = where.shell or dump_html.isShellElement(el), - .link = where.link or el.getTag() == .anchor, + .link = where.link or (el.getTag() == .anchor and el.getAttributeInterned("href") != null), }; if (el.is(Slot)) |slot| { var it = self.slotted(slot); From b8a6994bafa660055b0b020aec40e0eb06b7d21a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 28 Sep 2026 09:27:16 +0200 Subject: [PATCH 4/4] markdown: keep standalone href-less anchors on their own line Adjacent placeholder anchors, like a JS-driven nav, ran together as HomeAbout. Keep the standalone line placement linked anchors get and drop only the link syntax. --- src/browser/markdown.zig | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index a789a1c4d..c4c756eac 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -82,6 +82,8 @@ const Context = struct { // what follows after the children const Epilogue = union(enum) { none, + // A standalone anchor without href: its own line, but no link syntax. + standalone_text, element: Element.Tag, block_anchor: struct { href: []const u8, label: ?[]const u8 }, inline_anchor: struct { href: []const u8, standalone: bool }, @@ -258,12 +260,16 @@ const Context = struct { return; }, .anchor => { + const frame = self.frame; // Without href, is a placeholder, not a hyperlink. const href_raw = el.getAttributeInterned("href") orelse { - return self.open(.init(self.tree.content(el, boxed)), .none); + if (!RenderTree.isStandaloneAnchor(el, frame)) { + return self.open(.init(self.tree.content(el, boxed)), .none); + } + try self.ensureNewline(); + return self.open(.init(self.tree.content(el, boxed)), .standalone_text); }; - const frame = self.frame; const info = RenderTree.analyzeContent(el.asNode(), frame); const label = getAnchorLabel(el); const href = URL.resolve(frame.local_arena, frame.base(), href_raw, .{ .encoding = frame.charset }) catch href_raw; @@ -308,6 +314,10 @@ const Context = struct { fn close(self: *Context, epilogue: Epilogue) Error!void { const tag = switch (epilogue) { .none => return, + .standalone_text => { + try self.ensureNewline(); + return; + }, .element => |t| t, .block_anchor => |anchor| { try self.ensureNewline(); @@ -754,8 +764,12 @@ test "browser.markdown: anchor fallback label" { test "browser.markdown: anchor without href is plain text" { try testMarkdownHTML( - \\
Tags: change deep
- , "Tags: change **deep**\n"); + \\

Read the glossary first.

+ , "\nRead the glossary first.\n"); + + try testMarkdownHTML( + \\ + , "Home\n**About**\n"); try testMarkdownHTML( \\