Merge pull request #1717 from lightpanda-io/improve-markdown-links

Improve markdown links
This commit is contained in:
Karl Seguin
2026-03-05 13:09:16 +08:00
committed by GitHub

View File

@@ -19,6 +19,7 @@
const std = @import("std"); const std = @import("std");
const Page = @import("Page.zig"); const Page = @import("Page.zig");
const URL = @import("URL.zig");
const CData = @import("webapi/CData.zig"); const CData = @import("webapi/CData.zig");
const Element = @import("webapi/Element.zig"); const Element = @import("webapi/Element.zig");
const Node = @import("webapi/Node.zig"); const Node = @import("webapi/Node.zig");
@@ -103,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 { fn isAllWhitespace(text: []const u8) bool {
return for (text) |c| { return for (text) |c| {
if (!std.ascii.isWhitespace(c)) break false; if (!std.ascii.isWhitespace(c)) break false;
@@ -119,6 +124,21 @@ fn hasBlockDescendant(node: *Node) bool {
} else false; } 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 { fn ensureNewline(state: *State, writer: *std.Io.Writer) !void {
if (!state.last_char_was_newline) { if (!state.last_char_was_newline) {
try writer.writeByte('\n'); try writer.writeByte('\n');
@@ -278,20 +298,29 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag
} }
try writer.writeAll("]("); try writer.writeAll("](");
if (el.getAttributeSafe(comptime .wrap("src"))) |src| { if (el.getAttributeSafe(comptime .wrap("src"))) |src| {
try writer.writeAll(src); const absolute_src = URL.resolve(page.call_arena, page.base(), src, .{ .encode = true }) catch src;
try writer.writeAll(absolute_src);
} }
try writer.writeAll(")"); try writer.writeAll(")");
state.last_char_was_newline = false; state.last_char_was_newline = false;
return; return;
}, },
.anchor => { .anchor => {
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 has_block = hasBlockDescendant(el.asNode());
const href = if (href_raw) |h| URL.resolve(page.call_arena, page.base(), h, .{ .encode = true }) catch h else null;
if (has_block) { if (has_block) {
try renderChildren(el.asNode(), state, writer, page); 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'); if (!state.last_char_was_newline) try writer.writeByte('\n');
try writer.writeAll("([Link]("); try writer.writeAll("([](");
try writer.writeAll(href); try writer.writeAll(h);
try writer.writeAll("))\n"); try writer.writeAll("))\n");
state.last_char_was_newline = true; state.last_char_was_newline = true;
} }
@@ -301,10 +330,14 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag
if (isStandaloneAnchor(el)) { if (isStandaloneAnchor(el)) {
if (!state.last_char_was_newline) try writer.writeByte('\n'); if (!state.last_char_was_newline) try writer.writeByte('\n');
try writer.writeByte('['); try writer.writeByte('[');
if (has_content) {
try renderChildren(el.asNode(), state, writer, page); try renderChildren(el.asNode(), state, writer, page);
} else {
try writer.writeAll(label orelse "");
}
try writer.writeAll("]("); try writer.writeAll("](");
if (el.getAttributeSafe(comptime .wrap("href"))) |href| { if (href) |h| {
try writer.writeAll(href); try writer.writeAll(h);
} }
try writer.writeAll(")\n"); try writer.writeAll(")\n");
state.last_char_was_newline = true; state.last_char_was_newline = true;
@@ -312,10 +345,14 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag
} }
try writer.writeByte('['); try writer.writeByte('[');
if (has_content) {
try renderChildren(el.asNode(), state, writer, page); try renderChildren(el.asNode(), state, writer, page);
} else {
try writer.writeAll(label orelse "");
}
try writer.writeAll("]("); try writer.writeAll("](");
if (el.getAttributeSafe(comptime .wrap("href"))) |href| { if (href) |h| {
try writer.writeAll(href); try writer.writeAll(h);
} }
try writer.writeByte(')'); try writer.writeByte(')');
state.last_char_was_newline = false; state.last_char_was_newline = false;
@@ -452,6 +489,8 @@ fn testMarkdownHTML(html: []const u8, expected: []const u8) !void {
const testing = @import("../testing.zig"); const testing = @import("../testing.zig");
const page = try testing.test_session.createPage(); const page = try testing.test_session.createPage();
defer testing.test_session.removePage(); defer testing.test_session.removePage();
page.url = "http://localhost/";
const doc = page.window._document; const doc = page.window._document;
const div = try doc.createElement("div", null, page); const div = try doc.createElement("div", null, page);
@@ -520,11 +559,11 @@ test "browser.markdown: blockquote" {
} }
test "browser.markdown: links" { test "browser.markdown: links" {
try testMarkdownHTML("<a href=\"https://lightpanda.io\">Lightpanda</a>", "[Lightpanda](https://lightpanda.io)\n"); try testMarkdownHTML("<a href=\"/relative\">Link</a>", "[Link](http://localhost/relative)\n");
} }
test "browser.markdown: images" { test "browser.markdown: images" {
try testMarkdownHTML("<img src=\"logo.png\" alt=\"Logo\">", "![Logo](logo.png)\n"); try testMarkdownHTML("<img src=\"logo.png\" alt=\"Logo\">", "![Logo](http://localhost/logo.png)\n");
} }
test "browser.markdown: headings" { test "browser.markdown: headings" {
@@ -565,7 +604,7 @@ test "browser.markdown: block link" {
\\### Title \\### Title
\\ \\
\\Description \\Description
\\([Link](https://example.com)) \\([](https://example.com))
\\ \\
); );
} }
@@ -588,8 +627,8 @@ test "browser.markdown: standalone anchors" {
\\ <a href="2">Link 2</a> \\ <a href="2">Link 2</a>
\\</main> \\</main>
, ,
\\[Link 1](1) \\[Link 1](http://localhost/1)
\\[Link 2](2) \\[Link 2](http://localhost/2)
\\ \\
); );
} }
@@ -601,7 +640,58 @@ test "browser.markdown: mixed anchors in main" {
\\ Welcome <a href="1">Link 1</a>. \\ Welcome <a href="1">Link 1</a>.
\\</main> \\</main>
, ,
\\Welcome [Link 1](1). \\Welcome [Link 1](http://localhost/1).
\\ \\
); );
} }
test "browser.markdown: skip empty links" {
try testMarkdownHTML(
\\<a href="/"></a>
\\<a href="/"><svg></svg></a>
,
\\[](http://localhost/)
\\[](http://localhost/)
\\
);
}
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(),
\\<a href="b">Link</a>
\\<img src="../c.png" alt="Img">
\\<a href="/my page">Space</a>
);
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)
\\[Space](https://example.com/my%20page)
\\
, aw.written());
}
test "browser.markdown: anchor fallback label" {
try testMarkdownHTML(
\\<a href="/discord" aria-label="Discord Server"><svg></svg></a>
, "[Discord Server](http://localhost/discord)\n");
try testMarkdownHTML(
\\<a href="/search" title="Search Site"><svg></svg></a>
, "[Search Site](http://localhost/search)\n");
try testMarkdownHTML(
\\<a href="/no-label"><svg></svg></a>
, "[](http://localhost/no-label)\n");
}