From 423034d5c461453f9ee85a45c1f892a9467aa0da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Fri, 20 Feb 2026 00:11:38 +0900 Subject: [PATCH 1/2] markdown: handle block-level and standalone anchors in Adds logic to detect if an anchor contains block descendants or is a standalone element within a layout block. These are now rendered with appropriate spacing and link formatting. Also adds `.main` to the list of block elements. --- src/browser/markdown.zig | 153 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 148 insertions(+), 5 deletions(-) diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index fcbac478..b1d80a1d 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -46,7 +46,7 @@ const State = struct { fn isBlock(tag: Element.Tag) bool { return switch (tag) { - .p, .div, .section, .article, .header, .footer, .nav, .aside, .h1, .h2, .h3, .h4, .h5, .h6, .ul, .ol, .blockquote, .pre, .table, .hr => true, + .p, .div, .section, .article, .main, .header, .footer, .nav, .aside, .h1, .h2, .h3, .h4, .h5, .h6, .ul, .ol, .blockquote, .pre, .table, .hr => true, else => false, }; } @@ -58,6 +58,74 @@ fn shouldAddSpacing(tag: Element.Tag) bool { }; } +fn isLayoutBlock(tag: Element.Tag) bool { + return switch (tag) { + .main, .section, .article, .nav, .aside, .header, .footer, .div, .ul, .ol => true, + else => false, + }; +} + +fn isStandaloneAnchor(el: *Element) bool { + const node = el.asNode(); + const parent = node.parentNode() orelse return false; + + if (parent._type != .element) return false; + const parent_el = parent.as(Element); + if (!isLayoutBlock(parent_el.getTag())) return false; + + var prev = node.previousSibling(); + while (prev) |p| : (prev = p.previousSibling()) { + if (isSignificantText(p)) return false; + if (p._type == .element) { + if (isVisibleElement(p.as(Element))) break; + } + } + + var next = node.nextSibling(); + while (next) |n| : (next = n.nextSibling()) { + if (isSignificantText(n)) return false; + if (n._type == .element) { + if (isVisibleElement(n.as(Element))) break; + } + } + + return true; +} + +fn isSignificantText(node: *Node) bool { + if (node._type != .cdata) return false; + const cd = node.as(Node.CData); + if (node.is(Node.CData.Text)) |_| { + return !isAllWhitespace(cd.getData()); + } + return false; +} + +fn isVisibleElement(el: *Element) bool { + return switch (el.getTag()) { + .script, .style, .noscript, .template, .head, .meta, .link, .title, .svg => false, + else => true, + }; +} + +fn isAllWhitespace(text: []const u8) bool { + return for (text) |c| { + if (!std.ascii.isWhitespace(c)) break false; + } else true; +} + +fn hasBlockDescendant(node: *Node) bool { + var it = node.childrenIterator(); + while (it.next()) |child| { + if (child._type == .element) { + const el = child.as(Element); + if (isBlock(el.getTag())) return true; + if (hasBlockDescendant(child)) return true; + } + } + return false; +} + fn ensureNewline(state: *State, writer: *std.Io.Writer) !void { if (!state.last_char_was_newline) { try writer.writeByte('\n'); @@ -110,10 +178,7 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag const tag = el.getTag(); // Skip hidden/metadata elements - switch (tag) { - .script, .style, .noscript, .template, .head, .meta, .link, .title, .svg => return, - else => {}, - } + if (!isVisibleElement(el)) return; // --- Opening Tag Logic --- @@ -238,6 +303,32 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag return; // Treat as void }, .anchor => { + const has_block = hasBlockDescendant(el.asNode()); + if (has_block) { + try renderChildren(el.asNode(), state, writer, page); + if (el.getAttributeSafe(comptime .wrap("href"))) |href| { + if (!state.last_char_was_newline) try writer.writeByte('\n'); + try writer.writeAll("([Link]("); + try writer.writeAll(href); + try writer.writeAll("))\n"); + state.last_char_was_newline = true; + } + return; + } + + if (isStandaloneAnchor(el)) { + if (!state.last_char_was_newline) try writer.writeByte('\n'); + try writer.writeByte('['); + try renderChildren(el.asNode(), state, writer, page); + try writer.writeAll("]("); + if (el.getAttributeSafe(comptime .wrap("href"))) |href| { + try writer.writeAll(href); + } + try writer.writeAll(")\n"); + state.last_char_was_newline = true; + return; + } + try writer.writeByte('['); try renderChildren(el.asNode(), state, writer, page); try writer.writeAll("]("); @@ -504,3 +595,55 @@ test "markdown: code" { \\ ); } + +test "markdown: block link" { + try testMarkdownHTML( + \\ + \\

Title

+ \\

Description

+ \\
+ , + \\ + \\### Title + \\ + \\Description + \\([Link](https://example.com)) + \\ + ); +} + +test "markdown: inline link" { + try testMarkdownHTML( + \\

Visit Example.

+ , + \\ + \\Visit [Example](https://example.com). + \\ + ); +} + +test "markdown: standalone anchors" { + // Inside main, with whitespace between anchors -> treated as blocks + try testMarkdownHTML( + \\
+ \\ Link 1 + \\ Link 2 + \\
+ , + \\[Link 1](1) + \\[Link 2](2) + \\ + ); +} + +test "markdown: mixed anchors in main" { + // Anchors surrounded by text should remain inline + try testMarkdownHTML( + \\
+ \\ Welcome Link 1. + \\
+ , + \\Welcome [Link 1](1). + \\ + ); +} From 68d5edca60bcc99ae43c6f7f9f5281e119c55e72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Fri, 20 Feb 2026 08:14:15 +0900 Subject: [PATCH 2/2] markdown: use node.is() for type checking and casting --- src/browser/markdown.zig | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index b1d80a1d..aee9a8ff 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 CData = @import("webapi/CData.zig"); const Element = @import("webapi/Element.zig"); const Node = @import("webapi/Node.zig"); @@ -68,24 +69,23 @@ fn isLayoutBlock(tag: Element.Tag) bool { fn isStandaloneAnchor(el: *Element) bool { const node = el.asNode(); const parent = node.parentNode() orelse return false; + const parent_el = parent.is(Element) orelse return false; - if (parent._type != .element) return false; - const parent_el = parent.as(Element); if (!isLayoutBlock(parent_el.getTag())) return false; var prev = node.previousSibling(); while (prev) |p| : (prev = p.previousSibling()) { if (isSignificantText(p)) return false; - if (p._type == .element) { - if (isVisibleElement(p.as(Element))) break; + if (p.is(Element)) |pe| { + if (isVisibleElement(pe)) break; } } var next = node.nextSibling(); while (next) |n| : (next = n.nextSibling()) { if (isSignificantText(n)) return false; - if (n._type == .element) { - if (isVisibleElement(n.as(Element))) break; + if (n.is(Element)) |ne| { + if (isVisibleElement(ne)) break; } } @@ -93,12 +93,8 @@ fn isStandaloneAnchor(el: *Element) bool { } fn isSignificantText(node: *Node) bool { - if (node._type != .cdata) return false; - const cd = node.as(Node.CData); - if (node.is(Node.CData.Text)) |_| { - return !isAllWhitespace(cd.getData()); - } - return false; + const text = node.is(Node.CData.Text) orelse return false; + return !isAllWhitespace(text.getWholeText()); } fn isVisibleElement(el: *Element) bool { @@ -117,8 +113,7 @@ fn isAllWhitespace(text: []const u8) bool { fn hasBlockDescendant(node: *Node) bool { var it = node.childrenIterator(); while (it.next()) |child| { - if (child._type == .element) { - const el = child.as(Element); + if (child.is(Element)) |el| { if (isBlock(el.getTag())) return true; if (hasBlockDescendant(child)) return true; }