From 1b5efea6ebc8d5ade3aafe330de1c8f34ef98be8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sun, 15 Feb 2026 23:15:56 +0900 Subject: [PATCH 01/17] Add --dump-markdown flag Add a new module to handle HTML-to-Markdown conversion and integrate it into the fetch command via a new CLI flag. --- src/Config.zig | 15 +++ src/browser/markdown.zig | 282 +++++++++++++++++++++++++++++++++++++++ src/lightpanda.zig | 8 +- src/main.zig | 3 +- 4 files changed, 306 insertions(+), 2 deletions(-) create mode 100644 src/browser/markdown.zig diff --git a/src/Config.zig b/src/Config.zig index c9725168..8b29ee24 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -166,6 +166,7 @@ pub const Serve = struct { pub const Fetch = struct { url: [:0]const u8, dump: bool = false, + dump_markdown: bool = false, common: Common = .{}, withbase: bool = false, strip: dump.Opts.Strip = .{}, @@ -308,6 +309,9 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\--dump Dumps document to stdout. \\ Defaults to false. \\ + \\--dump-markdown Dumps document to stdout as Markdown. + \\ Defaults to false. + \\ \\--strip_mode Comma separated list of tag groups to remove from dump \\ the dump. e.g. --strip_mode js,css \\ - "js" script and link[as=script, rel=preload] @@ -410,6 +414,10 @@ fn inferMode(opt: []const u8) ?RunMode { return .fetch; } + if (std.mem.eql(u8, opt, "--dump-markdown")) { + return .fetch; + } + if (std.mem.eql(u8, opt, "--noscript")) { return .fetch; } @@ -547,6 +555,7 @@ fn parseFetchArgs( args: *std.process.ArgIterator, ) !Fetch { var fetch_dump: bool = false; + var fetch_dump_markdown: bool = false; var withbase: bool = false; var url: ?[:0]const u8 = null; var common: Common = .{}; @@ -558,6 +567,11 @@ fn parseFetchArgs( continue; } + if (std.mem.eql(u8, "--dump-markdown", opt)) { + fetch_dump_markdown = true; + continue; + } + if (std.mem.eql(u8, "--noscript", opt)) { log.warn(.app, "deprecation warning", .{ .feature = "--noscript argument", @@ -622,6 +636,7 @@ fn parseFetchArgs( return .{ .url = url.?, .dump = fetch_dump, + .dump_markdown = fetch_dump_markdown, .strip = strip, .common = common, .withbase = withbase, diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig new file mode 100644 index 00000000..4738218f --- /dev/null +++ b/src/browser/markdown.zig @@ -0,0 +1,282 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const Page = @import("Page.zig"); +const Node = @import("webapi/Node.zig"); +const Element = @import("webapi/Element.zig"); +const Slot = @import("webapi/element/html/Slot.zig"); + +pub const Opts = struct { + // Options for future customization (e.g., dialect) +}; + +const State = struct { + list_depth: usize = 0, + in_pre: bool = false, + in_code: bool = false, + in_blockquote: bool = false, + last_char_was_newline: bool = true, +}; + +pub fn dump(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) !void { + _ = opts; + var state = State{}; + try render(node, &state, writer, page); + if (!state.last_char_was_newline) { + try writer.writeByte('\n'); + } +} + +fn render(node: *Node, state: *State, writer: *std.Io.Writer, page: *Page) anyerror!void { + switch (node._type) { + .document, .document_fragment => { + try renderChildren(node, state, writer, page); + }, + .element => |el| { + try renderElement(el, state, writer, page); + }, + .cdata => |cd| { + if (node.is(Node.CData.Text)) |_| { + try renderText(cd.getData(), state, writer); + } + }, + else => {}, // Ignore other node types + } +} + +fn renderChildren(parent: *Node, state: *State, writer: *std.Io.Writer, page: *Page) anyerror!void { + var it = parent.childrenIterator(); + while (it.next()) |child| { + try render(child, state, writer, page); + } +} + +fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Page) anyerror!void { + const tag = el.getTag(); + + // Skip hidden/metadata elements + switch (tag) { + .script, .style, .noscript, .template, .head, .meta, .link, .title, .svg => return, + else => {}, + } + + // --- Opening Tag Logic --- + + // Ensure block elements start on a new line (double newline for paragraphs etc) + switch (tag) { + .p, .div, .section, .article, .header, .footer, .nav, .aside, .h1, .h2, .h3, .h4, .h5, .h6, .ul, .ol, .blockquote, .pre, .table, .hr => { + if (!state.last_char_was_newline) { + try writer.writeByte('\n'); + state.last_char_was_newline = true; + } + if (tag == .p or tag == .h1 or tag == .h2 or tag == .h3 or tag == .h4 or tag == .h5 or tag == .h6 or tag == .blockquote or tag == .pre or tag == .table) { + // Add an extra newline for spacing between blocks + try writer.writeByte('\n'); + } + }, + .li, .tr => { + if (!state.last_char_was_newline) { + try writer.writeByte('\n'); + state.last_char_was_newline = true; + } + }, + else => {}, + } + + // Prefixes + switch (tag) { + .h1 => try writer.writeAll("# "), + .h2 => try writer.writeAll("## "), + .h3 => try writer.writeAll("### "), + .h4 => try writer.writeAll("#### "), + .h5 => try writer.writeAll("##### "), + .h6 => try writer.writeAll("###### "), + .ul, .ol => { + state.list_depth += 1; + }, + .li => { + const indent = if (state.list_depth > 0) state.list_depth - 1 else 0; + try writeIndentation(indent, writer); + try writer.writeAll("- "); + state.last_char_was_newline = false; + }, + .blockquote => { + try writer.writeAll("> "); + state.in_blockquote = true; + state.last_char_was_newline = false; + }, + .pre => { + try writer.writeAll("```\n"); + state.in_pre = true; + state.last_char_was_newline = true; + }, + .code => { + if (!state.in_pre) { + try writer.writeByte('`'); + state.in_code = true; + state.last_char_was_newline = false; + } + }, + .b, .strong => { + try writer.writeAll("**"); + state.last_char_was_newline = false; + }, + .i, .em => { + try writer.writeAll("*"); + state.last_char_was_newline = false; + }, + .hr => { + try writer.writeAll("---\n"); + state.last_char_was_newline = true; + return; // Void element + }, + .br => { + try writer.writeByte('\n'); + state.last_char_was_newline = true; + return; // Void element + }, + .img => { + try writer.writeAll("!["); + if (el.getAttributeSafe(comptime .wrap("alt"))) |alt| { + try writer.writeAll(alt); + } + try writer.writeAll("]("); + if (el.getAttributeSafe(comptime .wrap("src"))) |src| { + try writer.writeAll(src); + } + try writer.writeAll(")"); + state.last_char_was_newline = false; + return; // Treat as void + }, + .anchor => { + try writer.writeByte('['); + state.last_char_was_newline = false; + }, + else => {}, + } + + // --- Render Children --- + try renderChildren(el.asNode(), state, writer, page); + + // --- Closing Tag Logic --- + + // Suffixes + switch (tag) { + .anchor => { + try writer.writeAll("]("); + if (el.getAttributeSafe(comptime .wrap("href"))) |href| { + try writer.writeAll(href); + } + try writer.writeByte(')'); + state.last_char_was_newline = false; + }, + .pre => { + if (!state.last_char_was_newline) { + try writer.writeByte('\n'); + } + try writer.writeAll("```\n"); + state.in_pre = false; + state.last_char_was_newline = true; + }, + .code => { + if (!state.in_pre) { + try writer.writeByte('`'); + state.in_code = false; + state.last_char_was_newline = false; + } + }, + .b, .strong => { + try writer.writeAll("**"); + state.last_char_was_newline = false; + }, + .i, .em => { + try writer.writeAll("*"); + state.last_char_was_newline = false; + }, + .blockquote => { + state.in_blockquote = false; + }, + .ul, .ol => { + if (state.list_depth > 0) state.list_depth -= 1; + }, + else => {}, + } + + // Post-block newlines + switch (tag) { + .p, .div, .section, .article, .header, .footer, .nav, .aside, .h1, .h2, .h3, .h4, .h5, .h6, .ul, .ol, .blockquote, .table, .tr => { + if (!state.last_char_was_newline) { + try writer.writeByte('\n'); + state.last_char_was_newline = true; + } + }, + else => {}, + } +} + +fn renderText(text: []const u8, state: *State, writer: *std.Io.Writer) anyerror!void { + if (text.len == 0) return; + + if (state.in_pre) { + try writer.writeAll(text); + if (text.len > 0 and text[text.len - 1] == '\n') { + state.last_char_was_newline = true; + } else { + state.last_char_was_newline = false; + } + return; + } + + // Collapse whitespace + var it = std.mem.tokenizeAny(u8, text, " \t\n\r"); + var first = true; + while (it.next()) |word| { + // If this is the first word we're writing in this sequence... + if (first) { + // ...and we didn't just write a newline... + if (!state.last_char_was_newline) { + // ...check if the original text had leading whitespace. + if (text.len > 0 and std.ascii.isWhitespace(text[0])) { + try writer.writeByte(' '); + } + } + } else { + // Between words always add space + try writer.writeByte(' '); + } + + try writer.writeAll(word); + state.last_char_was_newline = false; + first = false; + } + + // Handle trailing whitespace from the original text + if (!first and !state.last_char_was_newline) { + if (text.len > 0 and std.ascii.isWhitespace(text[text.len - 1])) { + try writer.writeByte(' '); + } + } +} + +fn writeIndentation(level: usize, writer: *std.Io.Writer) anyerror!void { + var i: usize = 0; + while (i < level) : (i += 1) { + try writer.writeAll(" "); + } +} diff --git a/src/lightpanda.zig b/src/lightpanda.zig index c40120cc..7a7f7341 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -28,6 +28,7 @@ pub const Notification = @import("Notification.zig"); pub const log = @import("log.zig"); pub const js = @import("browser/js/js.zig"); pub const dump = @import("browser/dump.zig"); +pub const markdown = @import("browser/markdown.zig"); pub const build_config = @import("build_config"); pub const crash_handler = @import("crash_handler.zig"); @@ -36,6 +37,7 @@ const IS_DEBUG = @import("builtin").mode == .Debug; pub const FetchOpts = struct { wait_ms: u32 = 5000, dump: dump.RootOpts, + dump_markdown: bool = false, writer: ?*std.Io.Writer = null, }; pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { @@ -94,7 +96,11 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { _ = session.wait(opts.wait_ms); const writer = opts.writer orelse return; - try dump.root(page.window._document, opts.dump, writer, page); + if (opts.dump_markdown) { + try markdown.dump(page.window._document.asNode(), .{}, writer, page); + } else { + try dump.root(page.window._document, opts.dump, writer, page); + } try writer.flush(); } diff --git a/src/main.zig b/src/main.zig index 3bbcb492..22f8dc39 100644 --- a/src/main.zig +++ b/src/main.zig @@ -111,6 +111,7 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { var fetch_opts = lp.FetchOpts{ .wait_ms = 5000, + .dump_markdown = opts.dump_markdown, .dump = .{ .strip = opts.strip, .with_base = opts.withbase, @@ -119,7 +120,7 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { var stdout = std.fs.File.stdout(); var writer = stdout.writer(&.{}); - if (opts.dump) { + if (opts.dump or opts.dump_markdown) { fetch_opts.writer = &writer.interface; } From be4e6e5ba5d8d4669ffb060da41e3fa833cdfdc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 16 Feb 2026 00:10:00 +0900 Subject: [PATCH 02/17] Escape special characters and handle whitespace in Markdown --- src/browser/markdown.zig | 108 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 2 deletions(-) diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index 4738218f..095f6de5 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -154,7 +154,7 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag .img => { try writer.writeAll("!["); if (el.getAttributeSafe(comptime .wrap("alt"))) |alt| { - try writer.writeAll(alt); + try escapeMarkdown(writer, alt); } try writer.writeAll("]("); if (el.getAttributeSafe(comptime .wrap("src"))) |src| { @@ -243,6 +243,18 @@ fn renderText(text: []const u8, state: *State, writer: *std.Io.Writer) anyerror! return; } + // Check for pure whitespace + const is_all_whitespace = for (text) |c| { + if (!std.ascii.isWhitespace(c)) break false; + } else true; + + if (is_all_whitespace) { + if (!state.last_char_was_newline) { + try writer.writeByte(' '); + } + return; + } + // Collapse whitespace var it = std.mem.tokenizeAny(u8, text, " \t\n\r"); var first = true; @@ -261,7 +273,7 @@ fn renderText(text: []const u8, state: *State, writer: *std.Io.Writer) anyerror! try writer.writeByte(' '); } - try writer.writeAll(word); + try escapeMarkdown(writer, word); state.last_char_was_newline = false; first = false; } @@ -274,9 +286,101 @@ fn renderText(text: []const u8, state: *State, writer: *std.Io.Writer) anyerror! } } +fn escapeMarkdown(writer: *std.Io.Writer, text: []const u8) !void { + // Escaping: \ ` * _ { } [ ] ( ) # + - . ! | < > + for (text) |c| { + switch (c) { + '\\', + '`', + '*', + '_', + '{', + '}', + '[', + ']', + '(', + ')', + '#', + '+', + '-', + '.', + '!', + '|', + '<', + '>', + => { + try writer.writeByte('\\'); + try writer.writeByte(c); + }, + else => try writer.writeByte(c), + } + } +} + fn writeIndentation(level: usize, writer: *std.Io.Writer) anyerror!void { var i: usize = 0; while (i < level) : (i += 1) { try writer.writeAll(" "); } } + +test "markdown: basic" { + const testing = @import("../testing.zig"); + const page = try testing.test_session.createPage(); + defer testing.test_session.removePage(); + const doc = page.window._document; + + const div = try doc.createElement("div", null, page); + try div.asNode().setTextContent("Hello world", page); + + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + try dump(div.asNode(), .{}, &aw.writer, page); + + try testing.expectString("Hello world\n", aw.written()); +} + +test "markdown: whitespace" { + const testing = @import("../testing.zig"); + const page = try testing.test_session.createPage(); + defer testing.test_session.removePage(); + const doc = page.window._document; + + const div = try doc.createElement("div", null, page); + + const s1 = try doc.createElement("span", null, page); + try s1.asNode().setTextContent("A", page); + const s2 = try doc.createElement("span", null, page); + try s2.asNode().setTextContent("B", page); + + _ = try div.asNode().appendChild(s1.asNode(), page); + // Add text node with space + const txt = try page.createTextNode(" "); + _ = try div.asNode().appendChild(txt, page); + _ = try div.asNode().appendChild(s2.asNode(), page); + + var aw = std.Io.Writer.Allocating.init(testing.allocator); + defer aw.deinit(); + try dump(div.asNode(), .{}, &aw.writer, page); + + try testing.expectString("A B\n", aw.written()); +} + +test "markdown: escaping" { + const testing = @import("../testing.zig"); + const page = try testing.test_session.createPage(); + defer testing.test_session.removePage(); + const doc = page.window._document; + + const div = try doc.createElement("div", null, page); + + const p = try doc.createElement("p", null, page); + try p.asNode().setTextContent("# Not a header", page); + _ = try div.asNode().appendChild(p.asNode(), page); + + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + try dump(div.asNode(), .{}, &aw.writer, page); + + try testing.expectString("\n\\# Not a header\n", aw.written()); +} From 9f13b14f6d64da1194714b46c7875751c57c2665 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 16 Feb 2026 20:55:51 +0900 Subject: [PATCH 03/17] Add support strikethrough and task lists in Markdown --- src/browser/markdown.zig | 64 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index 095f6de5..72b859d9 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -141,6 +141,10 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag try writer.writeAll("*"); state.last_char_was_newline = false; }, + .s, .del => { + try writer.writeAll("~~"); + state.last_char_was_newline = false; + }, .hr => { try writer.writeAll("---\n"); state.last_char_was_newline = true; @@ -168,6 +172,19 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag try writer.writeByte('['); state.last_char_was_newline = false; }, + .input => { + if (el.getAttributeSafe(comptime .wrap("type"))) |t| { + if (std.mem.eql(u8, t, "checkbox")) { + if (el.hasAttributeSafe(comptime .wrap("checked"))) { + try writer.writeAll("[x] "); + } else { + try writer.writeAll("[ ] "); + } + state.last_char_was_newline = false; + } + } + return; // Void element + }, else => {}, } @@ -209,6 +226,10 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag try writer.writeAll("*"); state.last_char_was_newline = false; }, + .s, .del => { + try writer.writeAll("~~"); + state.last_char_was_newline = false; + }, .blockquote => { state.in_blockquote = false; }, @@ -384,3 +405,46 @@ test "markdown: escaping" { try testing.expectString("\n\\# Not a header\n", aw.written()); } + +test "markdown: strikethrough" { + const testing = @import("../testing.zig"); + const page = try testing.test_session.createPage(); + defer testing.test_session.removePage(); + const doc = page.window._document; + + const div = try doc.createElement("div", null, page); + + const s = try doc.createElement("s", null, page); + try s.asNode().setTextContent("deleted", page); + _ = try div.asNode().appendChild(s.asNode(), page); + + var aw = std.Io.Writer.Allocating.init(testing.allocator); + defer aw.deinit(); + try dump(div.asNode(), .{}, &aw.writer, page); + + try testing.expectString("~~deleted~~\n", aw.written()); +} + +test "markdown: task list" { + const testing = @import("../testing.zig"); + const page = try testing.test_session.createPage(); + defer testing.test_session.removePage(); + const doc = page.window._document; + + const div = try doc.createElement("div", null, page); + + const input1 = try doc.createElement("input", null, page); + try input1.setAttributeSafe(comptime .wrap("type"), .wrap("checkbox"), page); + try input1.setAttributeSafe(comptime .wrap("checked"), .wrap(""), page); + _ = try div.asNode().appendChild(input1.asNode(), page); + + const input2 = try doc.createElement("input", null, page); + try input2.setAttributeSafe(comptime .wrap("type"), .wrap("checkbox"), page); + _ = try div.asNode().appendChild(input2.asNode(), page); + + var aw = std.Io.Writer.Allocating.init(testing.allocator); + defer aw.deinit(); + try dump(div.asNode(), .{}, &aw.writer, page); + + try testing.expectString("[x] [ ] \n", aw.written()); +} From ec0b9de7131c081020f3c997e131de2ca275688e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 16 Feb 2026 21:04:06 +0900 Subject: [PATCH 04/17] Add support for ordered lists in Markdown --- src/browser/markdown.zig | 63 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index 72b859d9..362f3229 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -27,7 +27,14 @@ pub const Opts = struct { }; const State = struct { + const ListType = enum { ordered, unordered }; + const ListState = struct { + type: ListType, + index: usize, + }; + list_depth: usize = 0, + list_stack: [32]ListState = undefined, in_pre: bool = false, in_code: bool = false, in_blockquote: bool = false, @@ -107,13 +114,33 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag .h4 => try writer.writeAll("#### "), .h5 => try writer.writeAll("##### "), .h6 => try writer.writeAll("###### "), - .ul, .ol => { - state.list_depth += 1; + .ul => { + if (state.list_depth < state.list_stack.len) { + state.list_stack[state.list_depth] = .{ .type = .unordered, .index = 0 }; + state.list_depth += 1; + } + }, + .ol => { + if (state.list_depth < state.list_stack.len) { + state.list_stack[state.list_depth] = .{ .type = .ordered, .index = 1 }; + state.list_depth += 1; + } }, .li => { const indent = if (state.list_depth > 0) state.list_depth - 1 else 0; try writeIndentation(indent, writer); - try writer.writeAll("- "); + + if (state.list_depth > 0) { + const current_list = &state.list_stack[state.list_depth - 1]; + if (current_list.type == .ordered) { + try writer.print("{d}. ", .{current_list.index}); + current_list.index += 1; + } else { + try writer.writeAll("- "); + } + } else { + try writer.writeAll("- "); + } state.last_char_was_newline = false; }, .blockquote => { @@ -418,7 +445,7 @@ test "markdown: strikethrough" { try s.asNode().setTextContent("deleted", page); _ = try div.asNode().appendChild(s.asNode(), page); - var aw = std.Io.Writer.Allocating.init(testing.allocator); + var aw: std.Io.Writer.Allocating = .init(testing.allocator); defer aw.deinit(); try dump(div.asNode(), .{}, &aw.writer, page); @@ -442,9 +469,35 @@ test "markdown: task list" { try input2.setAttributeSafe(comptime .wrap("type"), .wrap("checkbox"), page); _ = try div.asNode().appendChild(input2.asNode(), page); - var aw = std.Io.Writer.Allocating.init(testing.allocator); + var aw: std.Io.Writer.Allocating = .init(testing.allocator); defer aw.deinit(); try dump(div.asNode(), .{}, &aw.writer, page); try testing.expectString("[x] [ ] \n", aw.written()); } + +test "markdown: ordered list" { + const testing = @import("../testing.zig"); + const page = try testing.test_session.createPage(); + defer testing.test_session.removePage(); + const doc = page.window._document; + + const div = try doc.createElement("div", null, page); + + const ol = try doc.createElement("ol", null, page); + _ = try div.asNode().appendChild(ol.asNode(), page); + + const li1 = try doc.createElement("li", null, page); + try li1.asNode().setTextContent("First", page); + _ = try ol.asNode().appendChild(li1.asNode(), page); + + const li2 = try doc.createElement("li", null, page); + try li2.asNode().setTextContent("Second", page); + _ = try ol.asNode().appendChild(li2.asNode(), page); + + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + try dump(div.asNode(), .{}, &aw.writer, page); + + try testing.expectString("1. First\n2. Second\n", aw.written()); +} From 425a36aa511311bb6b265bcd8f50d9dfd04fef77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 16 Feb 2026 21:24:24 +0900 Subject: [PATCH 05/17] Add support for table rendering in Markdown --- src/browser/markdown.zig | 324 +++++++++++++++++++++++++-------------- 1 file changed, 212 insertions(+), 112 deletions(-) diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index 362f3229..52e0cd18 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -33,118 +33,137 @@ const State = struct { index: usize, }; - list_depth: usize = 0, - list_stack: [32]ListState = undefined, - in_pre: bool = false, - in_code: bool = false, - in_blockquote: bool = false, - last_char_was_newline: bool = true, -}; - -pub fn dump(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) !void { - _ = opts; - var state = State{}; - try render(node, &state, writer, page); - if (!state.last_char_was_newline) { - try writer.writeByte('\n'); + list_depth: usize = 0, + list_stack: [32]ListState = undefined, + in_pre: bool = false, + in_code: bool = false, + in_blockquote: bool = false, + in_table: bool = false, + table_row_index: usize = 0, + table_col_count: usize = 0, + last_char_was_newline: bool = true, + }; + + pub fn dump(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) !void { + _ = opts; + var state = State{}; + try render(node, &state, writer, page); + if (!state.last_char_was_newline) { + try writer.writeByte('\n'); + } } -} - -fn render(node: *Node, state: *State, writer: *std.Io.Writer, page: *Page) anyerror!void { - switch (node._type) { - .document, .document_fragment => { - try renderChildren(node, state, writer, page); - }, - .element => |el| { - try renderElement(el, state, writer, page); - }, - .cdata => |cd| { - if (node.is(Node.CData.Text)) |_| { - try renderText(cd.getData(), state, writer); - } - }, - else => {}, // Ignore other node types + + fn render(node: *Node, state: *State, writer: *std.Io.Writer, page: *Page) anyerror!void { + switch (node._type) { + .document, .document_fragment => { + try renderChildren(node, state, writer, page); + }, + .element => |el| { + try renderElement(el, state, writer, page); + }, + .cdata => |cd| { + if (node.is(Node.CData.Text)) |_| { + try renderText(cd.getData(), state, writer); + } + }, + else => {}, // Ignore other node types + } } -} - -fn renderChildren(parent: *Node, state: *State, writer: *std.Io.Writer, page: *Page) anyerror!void { - var it = parent.childrenIterator(); - while (it.next()) |child| { - try render(child, state, writer, page); + + fn renderChildren(parent: *Node, state: *State, writer: *std.Io.Writer, page: *Page) anyerror!void { + var it = parent.childrenIterator(); + while (it.next()) |child| { + try render(child, state, writer, page); + } } -} - -fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Page) anyerror!void { - const tag = el.getTag(); - - // Skip hidden/metadata elements - switch (tag) { - .script, .style, .noscript, .template, .head, .meta, .link, .title, .svg => return, - else => {}, - } - - // --- Opening Tag Logic --- - - // Ensure block elements start on a new line (double newline for paragraphs etc) - switch (tag) { - .p, .div, .section, .article, .header, .footer, .nav, .aside, .h1, .h2, .h3, .h4, .h5, .h6, .ul, .ol, .blockquote, .pre, .table, .hr => { - if (!state.last_char_was_newline) { - try writer.writeByte('\n'); - state.last_char_was_newline = true; - } - if (tag == .p or tag == .h1 or tag == .h2 or tag == .h3 or tag == .h4 or tag == .h5 or tag == .h6 or tag == .blockquote or tag == .pre or tag == .table) { - // Add an extra newline for spacing between blocks - try writer.writeByte('\n'); - } - }, - .li, .tr => { - if (!state.last_char_was_newline) { - try writer.writeByte('\n'); - state.last_char_was_newline = true; - } - }, - else => {}, - } - - // Prefixes - switch (tag) { - .h1 => try writer.writeAll("# "), - .h2 => try writer.writeAll("## "), - .h3 => try writer.writeAll("### "), - .h4 => try writer.writeAll("#### "), - .h5 => try writer.writeAll("##### "), - .h6 => try writer.writeAll("###### "), - .ul => { - if (state.list_depth < state.list_stack.len) { - state.list_stack[state.list_depth] = .{ .type = .unordered, .index = 0 }; - state.list_depth += 1; - } - }, - .ol => { - if (state.list_depth < state.list_stack.len) { - state.list_stack[state.list_depth] = .{ .type = .ordered, .index = 1 }; - state.list_depth += 1; - } - }, - .li => { - const indent = if (state.list_depth > 0) state.list_depth - 1 else 0; - try writeIndentation(indent, writer); - - if (state.list_depth > 0) { - const current_list = &state.list_stack[state.list_depth - 1]; - if (current_list.type == .ordered) { - try writer.print("{d}. ", .{current_list.index}); - current_list.index += 1; + + fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Page) anyerror!void { + const tag = el.getTag(); + + // Skip hidden/metadata elements + switch (tag) { + .script, .style, .noscript, .template, .head, .meta, .link, .title, .svg => return, + else => {}, + } + + // --- Opening Tag Logic --- + + // Ensure block elements start on a new line (double newline for paragraphs etc) + switch (tag) { + .p, .div, .section, .article, .header, .footer, .nav, .aside, .h1, .h2, .h3, .h4, .h5, .h6, .ul, .ol, .blockquote, .pre, .table, .hr => { + if (!state.in_table) { + if (!state.last_char_was_newline) { + try writer.writeByte('\n'); + state.last_char_was_newline = true; + } + if (tag == .p or tag == .h1 or tag == .h2 or tag == .h3 or tag == .h4 or tag == .h5 or tag == .h6 or tag == .blockquote or tag == .pre or tag == .table) { + // Add an extra newline for spacing between blocks + try writer.writeByte('\n'); + } + } + }, + .li, .tr => { + if (!state.last_char_was_newline) { + try writer.writeByte('\n'); + state.last_char_was_newline = true; + } + }, + else => {}, + } + + // Prefixes + switch (tag) { + .h1 => try writer.writeAll("# "), + .h2 => try writer.writeAll("## "), + .h3 => try writer.writeAll("### "), + .h4 => try writer.writeAll("#### "), + .h5 => try writer.writeAll("##### "), + .h6 => try writer.writeAll("###### "), + .ul => { + if (state.list_depth < state.list_stack.len) { + state.list_stack[state.list_depth] = .{ .type = .unordered, .index = 0 }; + state.list_depth += 1; + } + }, + .ol => { + if (state.list_depth < state.list_stack.len) { + state.list_stack[state.list_depth] = .{ .type = .ordered, .index = 1 }; + state.list_depth += 1; + } + }, + .li => { + const indent = if (state.list_depth > 0) state.list_depth - 1 else 0; + try writeIndentation(indent, writer); + + if (state.list_depth > 0) { + const current_list = &state.list_stack[state.list_depth - 1]; + if (current_list.type == .ordered) { + try writer.print("{d}. ", .{current_list.index}); + current_list.index += 1; + } else { + try writer.writeAll("- "); + } } else { try writer.writeAll("- "); } - } else { - try writer.writeAll("- "); - } - state.last_char_was_newline = false; - }, - .blockquote => { - try writer.writeAll("> "); + state.last_char_was_newline = false; + }, + .table => { + state.in_table = true; + state.table_row_index = 0; + state.table_col_count = 0; + }, + .tr => { + state.table_col_count = 0; + try writer.writeByte('|'); + }, + .td, .th => { + // Note: leading pipe handled by previous cell closing or tr opening + state.last_char_was_newline = false; + // Add spacing + try writer.writeByte(' '); + }, + .blockquote => { try writer.writeAll("> "); state.in_blockquote = true; state.last_char_was_newline = false; }, @@ -178,8 +197,12 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag return; // Void element }, .br => { - try writer.writeByte('\n'); - state.last_char_was_newline = true; + if (state.in_table) { + try writer.writeByte(' '); + } else { + try writer.writeByte('\n'); + state.last_char_was_newline = true; + } return; // Void element }, .img => { @@ -263,17 +286,41 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag .ul, .ol => { if (state.list_depth > 0) state.list_depth -= 1; }, + .table => { + state.in_table = false; + }, + .tr => { + try writer.writeByte('\n'); + if (state.table_row_index == 0) { + try writer.writeByte('|'); + var i: usize = 0; + while (i < state.table_col_count) : (i += 1) { + try writer.writeAll("---|"); + } + try writer.writeByte('\n'); + } + state.table_row_index += 1; + state.last_char_was_newline = true; + }, + .td, .th => { + try writer.writeAll(" |"); + state.table_col_count += 1; + state.last_char_was_newline = false; + }, else => {}, } // Post-block newlines switch (tag) { - .p, .div, .section, .article, .header, .footer, .nav, .aside, .h1, .h2, .h3, .h4, .h5, .h6, .ul, .ol, .blockquote, .table, .tr => { - if (!state.last_char_was_newline) { - try writer.writeByte('\n'); - state.last_char_was_newline = true; + .p, .div, .section, .article, .header, .footer, .nav, .aside, .h1, .h2, .h3, .h4, .h5, .h6, .ul, .ol, .blockquote, .table => { + if (!state.in_table) { + if (!state.last_char_was_newline) { + try writer.writeByte('\n'); + state.last_char_was_newline = true; + } } }, + .tr => {}, // Handled explicitly in closing tag logic else => {}, } } @@ -501,3 +548,56 @@ test "markdown: ordered list" { try testing.expectString("1. First\n2. Second\n", aw.written()); } + +test "markdown: table" { + const testing = @import("../testing.zig"); + const page = try testing.test_session.createPage(); + defer testing.test_session.removePage(); + const doc = page.window._document; + + const div = try doc.createElement("div", null, page); + + const table = try doc.createElement("table", null, page); + _ = try div.asNode().appendChild(table.asNode(), page); + + const thead = try doc.createElement("thead", null, page); + _ = try table.asNode().appendChild(thead.asNode(), page); + + const tr1 = try doc.createElement("tr", null, page); + _ = try thead.asNode().appendChild(tr1.asNode(), page); + + const th1 = try doc.createElement("th", null, page); + try th1.asNode().setTextContent("Head 1", page); + _ = try tr1.asNode().appendChild(th1.asNode(), page); + + const th2 = try doc.createElement("th", null, page); + try th2.asNode().setTextContent("Head 2", page); + _ = try tr1.asNode().appendChild(th2.asNode(), page); + + const tbody = try doc.createElement("tbody", null, page); + _ = try table.asNode().appendChild(tbody.asNode(), page); + + const tr2 = try doc.createElement("tr", null, page); + _ = try tbody.asNode().appendChild(tr2.asNode(), page); + + const td1 = try doc.createElement("td", null, page); + try td1.asNode().setTextContent("Cell 1", page); + _ = try tr2.asNode().appendChild(td1.asNode(), page); + + const td2 = try doc.createElement("td", null, page); + try td2.asNode().setTextContent("Cell 2", page); + _ = try tr2.asNode().appendChild(td2.asNode(), page); + + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + try dump(div.asNode(), .{}, &aw.writer, page); + + const expected = + \\ + \\| Head 1 | Head 2 | + \\|---|---| + \\| Cell 1 | Cell 2 | + \\ + ; + try testing.expectString(expected, aw.written()); +} From b49b2af11fc3a9c7d831a3eb7b7210aaa48a6fce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 16 Feb 2026 21:29:57 +0900 Subject: [PATCH 06/17] Stop escaping periods in markdown --- src/browser/markdown.zig | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index 52e0cd18..9165863c 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -382,7 +382,7 @@ fn renderText(text: []const u8, state: *State, writer: *std.Io.Writer) anyerror! } fn escapeMarkdown(writer: *std.Io.Writer, text: []const u8) !void { - // Escaping: \ ` * _ { } [ ] ( ) # + - . ! | < > + // Escaping: \ ` * _ { } [ ] ( ) # + - ! | < > for (text) |c| { switch (c) { '\\', @@ -398,7 +398,6 @@ fn escapeMarkdown(writer: *std.Io.Writer, text: []const u8) !void { '#', '+', '-', - '.', '!', '|', '<', From 3c14dbe3827255707248923943bb6eb572d9675d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 16 Feb 2026 21:34:46 +0900 Subject: [PATCH 07/17] Trim trailing whitespace in pre blocks in Markdown --- src/browser/markdown.zig | 252 ++++++++++++++++++++------------------- 1 file changed, 132 insertions(+), 120 deletions(-) diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index 9165863c..92be5f92 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -33,143 +33,154 @@ const State = struct { index: usize, }; - list_depth: usize = 0, - list_stack: [32]ListState = undefined, - in_pre: bool = false, - in_code: bool = false, - in_blockquote: bool = false, - in_table: bool = false, - table_row_index: usize = 0, - table_col_count: usize = 0, - last_char_was_newline: bool = true, - }; - - pub fn dump(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) !void { - _ = opts; - var state = State{}; - try render(node, &state, writer, page); - if (!state.last_char_was_newline) { - try writer.writeByte('\n'); - } + list_depth: usize = 0, + list_stack: [32]ListState = undefined, + in_pre: bool = false, + pre_node: ?*Node = null, + in_code: bool = false, + in_blockquote: bool = false, + in_table: bool = false, + table_row_index: usize = 0, + table_col_count: usize = 0, + last_char_was_newline: bool = true, +}; + +pub fn dump(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) !void { + _ = opts; + var state = State{}; + try render(node, &state, writer, page); + if (!state.last_char_was_newline) { + try writer.writeByte('\n'); } - - fn render(node: *Node, state: *State, writer: *std.Io.Writer, page: *Page) anyerror!void { - switch (node._type) { - .document, .document_fragment => { - try renderChildren(node, state, writer, page); - }, - .element => |el| { - try renderElement(el, state, writer, page); - }, - .cdata => |cd| { - if (node.is(Node.CData.Text)) |_| { - try renderText(cd.getData(), state, writer); - } - }, - else => {}, // Ignore other node types - } - } - - fn renderChildren(parent: *Node, state: *State, writer: *std.Io.Writer, page: *Page) anyerror!void { - var it = parent.childrenIterator(); - while (it.next()) |child| { - try render(child, state, writer, page); - } - } - - fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Page) anyerror!void { - const tag = el.getTag(); - - // Skip hidden/metadata elements - switch (tag) { - .script, .style, .noscript, .template, .head, .meta, .link, .title, .svg => return, - else => {}, - } - - // --- Opening Tag Logic --- - - // Ensure block elements start on a new line (double newline for paragraphs etc) - switch (tag) { - .p, .div, .section, .article, .header, .footer, .nav, .aside, .h1, .h2, .h3, .h4, .h5, .h6, .ul, .ol, .blockquote, .pre, .table, .hr => { - if (!state.in_table) { - if (!state.last_char_was_newline) { - try writer.writeByte('\n'); - state.last_char_was_newline = true; - } - if (tag == .p or tag == .h1 or tag == .h2 or tag == .h3 or tag == .h4 or tag == .h5 or tag == .h6 or tag == .blockquote or tag == .pre or tag == .table) { - // Add an extra newline for spacing between blocks - try writer.writeByte('\n'); +} + +fn render(node: *Node, state: *State, writer: *std.Io.Writer, page: *Page) anyerror!void { + switch (node._type) { + .document, .document_fragment => { + try renderChildren(node, state, writer, page); + }, + .element => |el| { + try renderElement(el, state, writer, page); + }, + .cdata => |cd| { + if (node.is(Node.CData.Text)) |_| { + var text = cd.getData(); + if (state.in_pre) { + if (state.pre_node) |pre| { + if (node.parentNode() == pre and node.nextSibling() == null) { + text = std.mem.trimRight(u8, text, " \t\r\n"); + } } } - }, - .li, .tr => { + try renderText(text, state, writer); + } + }, + else => {}, // Ignore other node types + } +} + +fn renderChildren(parent: *Node, state: *State, writer: *std.Io.Writer, page: *Page) anyerror!void { + var it = parent.childrenIterator(); + while (it.next()) |child| { + try render(child, state, writer, page); + } +} + +fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Page) anyerror!void { + const tag = el.getTag(); + + // Skip hidden/metadata elements + switch (tag) { + .script, .style, .noscript, .template, .head, .meta, .link, .title, .svg => return, + else => {}, + } + + // --- Opening Tag Logic --- + + // Ensure block elements start on a new line (double newline for paragraphs etc) + switch (tag) { + .p, .div, .section, .article, .header, .footer, .nav, .aside, .h1, .h2, .h3, .h4, .h5, .h6, .ul, .ol, .blockquote, .pre, .table, .hr => { + if (!state.in_table) { if (!state.last_char_was_newline) { try writer.writeByte('\n'); state.last_char_was_newline = true; } - }, - else => {}, - } - - // Prefixes - switch (tag) { - .h1 => try writer.writeAll("# "), - .h2 => try writer.writeAll("## "), - .h3 => try writer.writeAll("### "), - .h4 => try writer.writeAll("#### "), - .h5 => try writer.writeAll("##### "), - .h6 => try writer.writeAll("###### "), - .ul => { - if (state.list_depth < state.list_stack.len) { - state.list_stack[state.list_depth] = .{ .type = .unordered, .index = 0 }; - state.list_depth += 1; + if (tag == .p or tag == .h1 or tag == .h2 or tag == .h3 or tag == .h4 or tag == .h5 or tag == .h6 or tag == .blockquote or tag == .pre or tag == .table) { + // Add an extra newline for spacing between blocks + try writer.writeByte('\n'); } - }, - .ol => { - if (state.list_depth < state.list_stack.len) { - state.list_stack[state.list_depth] = .{ .type = .ordered, .index = 1 }; - state.list_depth += 1; - } - }, - .li => { - const indent = if (state.list_depth > 0) state.list_depth - 1 else 0; - try writeIndentation(indent, writer); - - if (state.list_depth > 0) { - const current_list = &state.list_stack[state.list_depth - 1]; - if (current_list.type == .ordered) { - try writer.print("{d}. ", .{current_list.index}); - current_list.index += 1; - } else { - try writer.writeAll("- "); - } + } + }, + .li, .tr => { + if (!state.last_char_was_newline) { + try writer.writeByte('\n'); + state.last_char_was_newline = true; + } + }, + else => {}, + } + + // Prefixes + switch (tag) { + .h1 => try writer.writeAll("# "), + .h2 => try writer.writeAll("## "), + .h3 => try writer.writeAll("### "), + .h4 => try writer.writeAll("#### "), + .h5 => try writer.writeAll("##### "), + .h6 => try writer.writeAll("###### "), + .ul => { + if (state.list_depth < state.list_stack.len) { + state.list_stack[state.list_depth] = .{ .type = .unordered, .index = 0 }; + state.list_depth += 1; + } + }, + .ol => { + if (state.list_depth < state.list_stack.len) { + state.list_stack[state.list_depth] = .{ .type = .ordered, .index = 1 }; + state.list_depth += 1; + } + }, + .li => { + const indent = if (state.list_depth > 0) state.list_depth - 1 else 0; + try writeIndentation(indent, writer); + + if (state.list_depth > 0) { + const current_list = &state.list_stack[state.list_depth - 1]; + if (current_list.type == .ordered) { + try writer.print("{d}. ", .{current_list.index}); + current_list.index += 1; } else { try writer.writeAll("- "); } - state.last_char_was_newline = false; - }, - .table => { - state.in_table = true; - state.table_row_index = 0; - state.table_col_count = 0; - }, - .tr => { - state.table_col_count = 0; - try writer.writeByte('|'); - }, - .td, .th => { - // Note: leading pipe handled by previous cell closing or tr opening - state.last_char_was_newline = false; - // Add spacing - try writer.writeByte(' '); - }, - .blockquote => { try writer.writeAll("> "); + } else { + try writer.writeAll("- "); + } + state.last_char_was_newline = false; + }, + .table => { + state.in_table = true; + state.table_row_index = 0; + state.table_col_count = 0; + }, + .tr => { + state.table_col_count = 0; + try writer.writeByte('|'); + }, + .td, .th => { + // Note: leading pipe handled by previous cell closing or tr opening + state.last_char_was_newline = false; + // Add spacing + try writer.writeByte(' '); + }, + .blockquote => { + try writer.writeAll("> "); state.in_blockquote = true; state.last_char_was_newline = false; }, .pre => { try writer.writeAll("```\n"); state.in_pre = true; + state.pre_node = el.asNode(); state.last_char_was_newline = true; }, .code => { @@ -259,6 +270,7 @@ const State = struct { } try writer.writeAll("```\n"); state.in_pre = false; + state.pre_node = null; state.last_char_was_newline = true; }, .code => { From 748b37f1d619bc1f3c1f35f4bb8eaf7daa499e97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 17 Feb 2026 00:21:10 +0900 Subject: [PATCH 08/17] Rename --dump-markdown to --markdown --- src/Config.zig | 14 +++++++------- src/lightpanda.zig | 4 ++-- src/main.zig | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 8b29ee24..e07f6b2f 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -166,7 +166,7 @@ pub const Serve = struct { pub const Fetch = struct { url: [:0]const u8, dump: bool = false, - dump_markdown: bool = false, + markdown: bool = false, common: Common = .{}, withbase: bool = false, strip: dump.Opts.Strip = .{}, @@ -309,7 +309,7 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\--dump Dumps document to stdout. \\ Defaults to false. \\ - \\--dump-markdown Dumps document to stdout as Markdown. + \\--markdown Dumps document to stdout as Markdown. \\ Defaults to false. \\ \\--strip_mode Comma separated list of tag groups to remove from dump @@ -414,7 +414,7 @@ fn inferMode(opt: []const u8) ?RunMode { return .fetch; } - if (std.mem.eql(u8, opt, "--dump-markdown")) { + if (std.mem.eql(u8, opt, "--markdown")) { return .fetch; } @@ -555,7 +555,7 @@ fn parseFetchArgs( args: *std.process.ArgIterator, ) !Fetch { var fetch_dump: bool = false; - var fetch_dump_markdown: bool = false; + var fetch_markdown: bool = false; var withbase: bool = false; var url: ?[:0]const u8 = null; var common: Common = .{}; @@ -567,8 +567,8 @@ fn parseFetchArgs( continue; } - if (std.mem.eql(u8, "--dump-markdown", opt)) { - fetch_dump_markdown = true; + if (std.mem.eql(u8, "--markdown", opt)) { + fetch_markdown = true; continue; } @@ -636,7 +636,7 @@ fn parseFetchArgs( return .{ .url = url.?, .dump = fetch_dump, - .dump_markdown = fetch_dump_markdown, + .markdown = fetch_markdown, .strip = strip, .common = common, .withbase = withbase, diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 7a7f7341..1904c3ba 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -37,7 +37,7 @@ const IS_DEBUG = @import("builtin").mode == .Debug; pub const FetchOpts = struct { wait_ms: u32 = 5000, dump: dump.RootOpts, - dump_markdown: bool = false, + markdown: bool = false, writer: ?*std.Io.Writer = null, }; pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { @@ -96,7 +96,7 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { _ = session.wait(opts.wait_ms); const writer = opts.writer orelse return; - if (opts.dump_markdown) { + if (opts.markdown) { try markdown.dump(page.window._document.asNode(), .{}, writer, page); } else { try dump.root(page.window._document, opts.dump, writer, page); diff --git a/src/main.zig b/src/main.zig index 22f8dc39..6a648ed1 100644 --- a/src/main.zig +++ b/src/main.zig @@ -111,7 +111,7 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { var fetch_opts = lp.FetchOpts{ .wait_ms = 5000, - .dump_markdown = opts.dump_markdown, + .markdown = opts.markdown, .dump = .{ .strip = opts.strip, .with_base = opts.withbase, @@ -120,7 +120,7 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { var stdout = std.fs.File.stdout(); var writer = stdout.writer(&.{}); - if (opts.dump or opts.dump_markdown) { + if (opts.dump or opts.markdown) { fetch_opts.writer = &writer.interface; } From d3ba714aba9df652dd19358bcec0ed10539b6975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 17 Feb 2026 00:35:15 +0900 Subject: [PATCH 09/17] Rename --dump flag to --html --dump is still supported but deprecated --- src/Config.zig | 20 +++++++++++++------- src/main.zig | 4 ++-- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index e07f6b2f..071c74bb 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -165,7 +165,7 @@ pub const Serve = struct { pub const Fetch = struct { url: [:0]const u8, - dump: bool = false, + html: bool = false, markdown: bool = false, common: Common = .{}, withbase: bool = false, @@ -303,12 +303,14 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ \\fetch command \\Fetches the specified URL - \\Example: {s} fetch --dump https://lightpanda.io/ + \\Example: {s} fetch --html https://lightpanda.io/ \\ \\Options: - \\--dump Dumps document to stdout. + \\--html Dumps document to stdout as HTML. \\ Defaults to false. \\ + \\--dump Alias for --html (deprecated). + \\ \\--markdown Dumps document to stdout as Markdown. \\ Defaults to false. \\ @@ -410,6 +412,10 @@ fn inferMode(opt: []const u8) ?RunMode { return .fetch; } + if (std.mem.eql(u8, opt, "--html")) { + return .fetch; + } + if (std.mem.eql(u8, opt, "--dump")) { return .fetch; } @@ -554,7 +560,7 @@ fn parseFetchArgs( allocator: Allocator, args: *std.process.ArgIterator, ) !Fetch { - var fetch_dump: bool = false; + var fetch_html: bool = false; var fetch_markdown: bool = false; var withbase: bool = false; var url: ?[:0]const u8 = null; @@ -562,8 +568,8 @@ fn parseFetchArgs( var strip: dump.Opts.Strip = .{}; while (args.next()) |opt| { - if (std.mem.eql(u8, "--dump", opt)) { - fetch_dump = true; + if (std.mem.eql(u8, "--html", opt) or std.mem.eql(u8, "--dump", opt)) { + fetch_html = true; continue; } @@ -635,7 +641,7 @@ fn parseFetchArgs( return .{ .url = url.?, - .dump = fetch_dump, + .html = fetch_html, .markdown = fetch_markdown, .strip = strip, .common = common, diff --git a/src/main.zig b/src/main.zig index 6a648ed1..25257533 100644 --- a/src/main.zig +++ b/src/main.zig @@ -107,7 +107,7 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { }, .fetch => |opts| { const url = opts.url; - log.debug(.app, "startup", .{ .mode = "fetch", .dump = opts.dump, .url = url, .snapshot = app.snapshot.fromEmbedded() }); + log.debug(.app, "startup", .{ .mode = "fetch", .html = opts.html, .url = url, .snapshot = app.snapshot.fromEmbedded() }); var fetch_opts = lp.FetchOpts{ .wait_ms = 5000, @@ -120,7 +120,7 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { var stdout = std.fs.File.stdout(); var writer = stdout.writer(&.{}); - if (opts.dump or opts.markdown) { + if (opts.html or opts.markdown) { fetch_opts.writer = &writer.interface; } From dea492fd64023223ed1c2d4521d0c6c1be628c16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 17 Feb 2026 00:42:06 +0900 Subject: [PATCH 10/17] Unify dump flags into --dump --- src/Config.zig | 49 ++++++++++++++++++++-------------------------- src/lightpanda.zig | 11 ++++++----- src/main.zig | 6 +++--- 3 files changed, 30 insertions(+), 36 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 071c74bb..acb99eca 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -163,10 +163,14 @@ pub const Serve = struct { common: Common = .{}, }; +pub const DumpFormat = enum { + html, + markdown, +}; + pub const Fetch = struct { url: [:0]const u8, - html: bool = false, - markdown: bool = false, + dump_mode: ?DumpFormat = null, common: Common = .{}, withbase: bool = false, strip: dump.Opts.Strip = .{}, @@ -303,16 +307,12 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ \\fetch command \\Fetches the specified URL - \\Example: {s} fetch --html https://lightpanda.io/ + \\Example: {s} fetch --dump html https://lightpanda.io/ \\ \\Options: - \\--html Dumps document to stdout as HTML. - \\ Defaults to false. - \\ - \\--dump Alias for --html (deprecated). - \\ - \\--markdown Dumps document to stdout as Markdown. - \\ Defaults to false. + \\--dump Dumps document to stdout. + \\ Argument must be 'html' or 'markdown'. + \\ Defaults to no dump. \\ \\--strip_mode Comma separated list of tag groups to remove from dump \\ the dump. e.g. --strip_mode js,css @@ -412,18 +412,10 @@ fn inferMode(opt: []const u8) ?RunMode { return .fetch; } - if (std.mem.eql(u8, opt, "--html")) { - return .fetch; - } - if (std.mem.eql(u8, opt, "--dump")) { return .fetch; } - if (std.mem.eql(u8, opt, "--markdown")) { - return .fetch; - } - if (std.mem.eql(u8, opt, "--noscript")) { return .fetch; } @@ -560,21 +552,23 @@ fn parseFetchArgs( allocator: Allocator, args: *std.process.ArgIterator, ) !Fetch { - var fetch_html: bool = false; - var fetch_markdown: bool = false; + var dump_mode: ?DumpFormat = null; var withbase: bool = false; var url: ?[:0]const u8 = null; var common: Common = .{}; var strip: dump.Opts.Strip = .{}; while (args.next()) |opt| { - if (std.mem.eql(u8, "--html", opt) or std.mem.eql(u8, "--dump", opt)) { - fetch_html = true; - continue; - } + if (std.mem.eql(u8, "--dump", opt)) { + const str = args.next() orelse { + log.fatal(.app, "missing argument value", .{ .arg = "--dump" }); + return error.InvalidArgument; + }; - if (std.mem.eql(u8, "--markdown", opt)) { - fetch_markdown = true; + dump_mode = std.meta.stringToEnum(DumpFormat, str) orelse { + log.fatal(.app, "invalid option choice", .{ .arg = "--dump", .value = str }); + return error.InvalidArgument; + }; continue; } @@ -641,8 +635,7 @@ fn parseFetchArgs( return .{ .url = url.?, - .html = fetch_html, - .markdown = fetch_markdown, + .dump_mode = dump_mode, .strip = strip, .common = common, .withbase = withbase, diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 1904c3ba..97face0f 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -37,7 +37,7 @@ const IS_DEBUG = @import("builtin").mode == .Debug; pub const FetchOpts = struct { wait_ms: u32 = 5000, dump: dump.RootOpts, - markdown: bool = false, + dump_mode: ?Config.DumpFormat = null, writer: ?*std.Io.Writer = null, }; pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { @@ -96,10 +96,11 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { _ = session.wait(opts.wait_ms); const writer = opts.writer orelse return; - if (opts.markdown) { - try markdown.dump(page.window._document.asNode(), .{}, writer, page); - } else { - try dump.root(page.window._document, opts.dump, writer, page); + if (opts.dump_mode) |mode| { + switch (mode) { + .html => try dump.root(page.window._document, opts.dump, writer, page), + .markdown => try markdown.dump(page.window._document.asNode(), .{}, writer, page), + } } try writer.flush(); } diff --git a/src/main.zig b/src/main.zig index 25257533..f9470419 100644 --- a/src/main.zig +++ b/src/main.zig @@ -107,11 +107,11 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { }, .fetch => |opts| { const url = opts.url; - log.debug(.app, "startup", .{ .mode = "fetch", .html = opts.html, .url = url, .snapshot = app.snapshot.fromEmbedded() }); + log.debug(.app, "startup", .{ .mode = "fetch", .dump_mode = opts.dump_mode, .url = url, .snapshot = app.snapshot.fromEmbedded() }); var fetch_opts = lp.FetchOpts{ .wait_ms = 5000, - .markdown = opts.markdown, + .dump_mode = opts.dump_mode, .dump = .{ .strip = opts.strip, .with_base = opts.withbase, @@ -120,7 +120,7 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { var stdout = std.fs.File.stdout(); var writer = stdout.writer(&.{}); - if (opts.html or opts.markdown) { + if (opts.dump_mode != null) { fetch_opts.writer = &writer.interface; } From 66d9eaee782ec66f39ed1960526825aa02d5771f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 18 Feb 2026 11:01:00 +0900 Subject: [PATCH 11/17] Simplify block element rendering in Markdown --- src/browser/markdown.zig | 62 +++++++++++++++++++++------------------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index 92be5f92..94da0e71 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -45,6 +45,27 @@ const State = struct { last_char_was_newline: bool = true, }; +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, + else => false, + }; +} + +fn shouldAddSpacing(tag: Element.Tag) bool { + return switch (tag) { + .p, .h1, .h2, .h3, .h4, .h5, .h6, .blockquote, .pre, .table => true, + else => false, + }; +} + +fn ensureNewline(state: *State, writer: *std.Io.Writer) !void { + if (!state.last_char_was_newline) { + try writer.writeByte('\n'); + state.last_char_was_newline = true; + } +} + pub fn dump(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) !void { _ = opts; var state = State{}; @@ -98,26 +119,16 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag // --- Opening Tag Logic --- // Ensure block elements start on a new line (double newline for paragraphs etc) - switch (tag) { - .p, .div, .section, .article, .header, .footer, .nav, .aside, .h1, .h2, .h3, .h4, .h5, .h6, .ul, .ol, .blockquote, .pre, .table, .hr => { - if (!state.in_table) { - if (!state.last_char_was_newline) { - try writer.writeByte('\n'); - state.last_char_was_newline = true; - } - if (tag == .p or tag == .h1 or tag == .h2 or tag == .h3 or tag == .h4 or tag == .h5 or tag == .h6 or tag == .blockquote or tag == .pre or tag == .table) { - // Add an extra newline for spacing between blocks - try writer.writeByte('\n'); - } - } - }, - .li, .tr => { - if (!state.last_char_was_newline) { + if (isBlock(tag)) { + if (!state.in_table) { + try ensureNewline(state, writer); + if (shouldAddSpacing(tag)) { + // Add an extra newline for spacing between blocks try writer.writeByte('\n'); - state.last_char_was_newline = true; } - }, - else => {}, + } + } else if (tag == .li or tag == .tr) { + try ensureNewline(state, writer); } // Prefixes @@ -323,17 +334,10 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag } // Post-block newlines - switch (tag) { - .p, .div, .section, .article, .header, .footer, .nav, .aside, .h1, .h2, .h3, .h4, .h5, .h6, .ul, .ol, .blockquote, .table => { - if (!state.in_table) { - if (!state.last_char_was_newline) { - try writer.writeByte('\n'); - state.last_char_was_newline = true; - } - } - }, - .tr => {}, // Handled explicitly in closing tag logic - else => {}, + if (isBlock(tag)) { + if (!state.in_table) { + try ensureNewline(state, writer); + } } } From dc0fb9ed8a053bdb956a69e18eb7d3b2fe75ea10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 18 Feb 2026 11:04:12 +0900 Subject: [PATCH 12/17] Remove unused import in markdown --- src/browser/markdown.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index 94da0e71..2473adc5 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -17,10 +17,10 @@ // along with this program. If not, see . const std = @import("std"); + const Page = @import("Page.zig"); -const Node = @import("webapi/Node.zig"); const Element = @import("webapi/Element.zig"); -const Slot = @import("webapi/element/html/Slot.zig"); +const Node = @import("webapi/Node.zig"); pub const Opts = struct { // Options for future customization (e.g., dialect) From c6e0c6d096e4fa5a94dab5d97ab2b0c273a91fcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 18 Feb 2026 11:11:30 +0900 Subject: [PATCH 13/17] Simplify tests in markdown --- src/browser/markdown.zig | 244 ++++++++++++--------------------------- 1 file changed, 72 insertions(+), 172 deletions(-) diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index 2473adc5..cf322bf4 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -180,7 +180,6 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag .td, .th => { // Note: leading pipe handled by previous cell closing or tr opening state.last_char_was_newline = false; - // Add spacing try writer.writeByte(' '); }, .blockquote => { @@ -255,7 +254,7 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag state.last_char_was_newline = false; } } - return; // Void element + return; }, else => {}, } @@ -370,17 +369,13 @@ fn renderText(text: []const u8, state: *State, writer: *std.Io.Writer) anyerror! var it = std.mem.tokenizeAny(u8, text, " \t\n\r"); var first = true; while (it.next()) |word| { - // If this is the first word we're writing in this sequence... if (first) { - // ...and we didn't just write a newline... if (!state.last_char_was_newline) { - // ...check if the original text had leading whitespace. if (text.len > 0 and std.ascii.isWhitespace(text[0])) { try writer.writeByte(' '); } } } else { - // Between words always add space try writer.writeByte(' '); } @@ -398,27 +393,9 @@ fn renderText(text: []const u8, state: *State, writer: *std.Io.Writer) anyerror! } fn escapeMarkdown(writer: *std.Io.Writer, text: []const u8) !void { - // Escaping: \ ` * _ { } [ ] ( ) # + - ! | < > for (text) |c| { switch (c) { - '\\', - '`', - '*', - '_', - '{', - '}', - '[', - ']', - '(', - ')', - '#', - '+', - '-', - '!', - '|', - '<', - '>', - => { + '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '!', '|', '<', '>' => { try writer.writeByte('\\'); try writer.writeByte(c); }, @@ -434,185 +411,108 @@ fn writeIndentation(level: usize, writer: *std.Io.Writer) anyerror!void { } } -test "markdown: basic" { +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(); const doc = page.window._document; const div = try doc.createElement("div", null, page); - try div.asNode().setTextContent("Hello world", page); + try page.parseHtmlAsChildren(div.asNode(), html); var aw: std.Io.Writer.Allocating = .init(testing.allocator); defer aw.deinit(); try dump(div.asNode(), .{}, &aw.writer, page); - try testing.expectString("Hello world\n", aw.written()); + try testing.expectString(expected, aw.written()); +} + +test "markdown: basic" { + try testMarkdownHTML("Hello world", "Hello world\n"); } test "markdown: whitespace" { - const testing = @import("../testing.zig"); - const page = try testing.test_session.createPage(); - defer testing.test_session.removePage(); - const doc = page.window._document; - - const div = try doc.createElement("div", null, page); - - const s1 = try doc.createElement("span", null, page); - try s1.asNode().setTextContent("A", page); - const s2 = try doc.createElement("span", null, page); - try s2.asNode().setTextContent("B", page); - - _ = try div.asNode().appendChild(s1.asNode(), page); - // Add text node with space - const txt = try page.createTextNode(" "); - _ = try div.asNode().appendChild(txt, page); - _ = try div.asNode().appendChild(s2.asNode(), page); - - var aw = std.Io.Writer.Allocating.init(testing.allocator); - defer aw.deinit(); - try dump(div.asNode(), .{}, &aw.writer, page); - - try testing.expectString("A B\n", aw.written()); + try testMarkdownHTML("A B", "A B\n"); } test "markdown: escaping" { - const testing = @import("../testing.zig"); - const page = try testing.test_session.createPage(); - defer testing.test_session.removePage(); - const doc = page.window._document; - - const div = try doc.createElement("div", null, page); - - const p = try doc.createElement("p", null, page); - try p.asNode().setTextContent("# Not a header", page); - _ = try div.asNode().appendChild(p.asNode(), page); - - var aw: std.Io.Writer.Allocating = .init(testing.allocator); - defer aw.deinit(); - try dump(div.asNode(), .{}, &aw.writer, page); - - try testing.expectString("\n\\# Not a header\n", aw.written()); + try testMarkdownHTML("

# Not a header

", "\n\\# Not a header\n"); } test "markdown: strikethrough" { - const testing = @import("../testing.zig"); - const page = try testing.test_session.createPage(); - defer testing.test_session.removePage(); - const doc = page.window._document; - - const div = try doc.createElement("div", null, page); - - const s = try doc.createElement("s", null, page); - try s.asNode().setTextContent("deleted", page); - _ = try div.asNode().appendChild(s.asNode(), page); - - var aw: std.Io.Writer.Allocating = .init(testing.allocator); - defer aw.deinit(); - try dump(div.asNode(), .{}, &aw.writer, page); - - try testing.expectString("~~deleted~~\n", aw.written()); + try testMarkdownHTML("deleted", "~~deleted~~\n"); } test "markdown: task list" { - const testing = @import("../testing.zig"); - const page = try testing.test_session.createPage(); - defer testing.test_session.removePage(); - const doc = page.window._document; - - const div = try doc.createElement("div", null, page); - - const input1 = try doc.createElement("input", null, page); - try input1.setAttributeSafe(comptime .wrap("type"), .wrap("checkbox"), page); - try input1.setAttributeSafe(comptime .wrap("checked"), .wrap(""), page); - _ = try div.asNode().appendChild(input1.asNode(), page); - - const input2 = try doc.createElement("input", null, page); - try input2.setAttributeSafe(comptime .wrap("type"), .wrap("checkbox"), page); - _ = try div.asNode().appendChild(input2.asNode(), page); - - var aw: std.Io.Writer.Allocating = .init(testing.allocator); - defer aw.deinit(); - try dump(div.asNode(), .{}, &aw.writer, page); - - try testing.expectString("[x] [ ] \n", aw.written()); + try testMarkdownHTML( + \\ + , "[x] [ ] \n"); } test "markdown: ordered list" { - const testing = @import("../testing.zig"); - const page = try testing.test_session.createPage(); - defer testing.test_session.removePage(); - const doc = page.window._document; - - const div = try doc.createElement("div", null, page); - - const ol = try doc.createElement("ol", null, page); - _ = try div.asNode().appendChild(ol.asNode(), page); - - const li1 = try doc.createElement("li", null, page); - try li1.asNode().setTextContent("First", page); - _ = try ol.asNode().appendChild(li1.asNode(), page); - - const li2 = try doc.createElement("li", null, page); - try li2.asNode().setTextContent("Second", page); - _ = try ol.asNode().appendChild(li2.asNode(), page); - - var aw: std.Io.Writer.Allocating = .init(testing.allocator); - defer aw.deinit(); - try dump(div.asNode(), .{}, &aw.writer, page); - - try testing.expectString("1. First\n2. Second\n", aw.written()); + try testMarkdownHTML( + \\
  1. First
  2. Second
+ , "1. First\n2. Second\n"); } test "markdown: table" { - const testing = @import("../testing.zig"); - const page = try testing.test_session.createPage(); - defer testing.test_session.removePage(); - const doc = page.window._document; - - const div = try doc.createElement("div", null, page); - - const table = try doc.createElement("table", null, page); - _ = try div.asNode().appendChild(table.asNode(), page); - - const thead = try doc.createElement("thead", null, page); - _ = try table.asNode().appendChild(thead.asNode(), page); - - const tr1 = try doc.createElement("tr", null, page); - _ = try thead.asNode().appendChild(tr1.asNode(), page); - - const th1 = try doc.createElement("th", null, page); - try th1.asNode().setTextContent("Head 1", page); - _ = try tr1.asNode().appendChild(th1.asNode(), page); - - const th2 = try doc.createElement("th", null, page); - try th2.asNode().setTextContent("Head 2", page); - _ = try tr1.asNode().appendChild(th2.asNode(), page); - - const tbody = try doc.createElement("tbody", null, page); - _ = try table.asNode().appendChild(tbody.asNode(), page); - - const tr2 = try doc.createElement("tr", null, page); - _ = try tbody.asNode().appendChild(tr2.asNode(), page); - - const td1 = try doc.createElement("td", null, page); - try td1.asNode().setTextContent("Cell 1", page); - _ = try tr2.asNode().appendChild(td1.asNode(), page); - - const td2 = try doc.createElement("td", null, page); - try td2.asNode().setTextContent("Cell 2", page); - _ = try tr2.asNode().appendChild(td2.asNode(), page); - - var aw: std.Io.Writer.Allocating = .init(testing.allocator); - defer aw.deinit(); - try dump(div.asNode(), .{}, &aw.writer, page); - - const expected = + try testMarkdownHTML( + \\ + \\
Head 1Head 2
Cell 1Cell 2
+ , \\ \\| Head 1 | Head 2 | \\|---|---| \\| Cell 1 | Cell 2 | \\ - ; - try testing.expectString(expected, aw.written()); + ); +} + +test "markdown: nested lists" { + try testMarkdownHTML( + \\
  • Parent
    • Child
+ , + \\- Parent + \\ - Child + \\ + ); +} + +test "markdown: blockquote" { + try testMarkdownHTML("
Hello world
", "\n> Hello world\n"); +} + +test "markdown: links" { + try testMarkdownHTML("Lightpanda", "[Lightpanda](https://lightpanda.io)\n"); +} + +test "markdown: images" { + try testMarkdownHTML("\"Logo\"", "![Logo](logo.png)\n"); +} + +test "markdown: headings" { + try testMarkdownHTML("

Title

Subtitle

", + \\ + \\# Title + \\ + \\## Subtitle + \\ + ); +} + +test "markdown: code" { + try testMarkdownHTML( + \\

Use git push

+ \\
line 1
+        \\line 2
+ , + \\ + \\Use git push + \\ + \\``` + \\line 1 + \\line 2 + \\``` + \\ + ); } From 0ec4522f9e4dc73e64ddff3d3e323194e15cb8b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 18 Feb 2026 11:39:14 +0900 Subject: [PATCH 14/17] Update test with new --dump flag --- .github/workflows/e2e-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 6f9055cc..582a2892 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -224,7 +224,7 @@ jobs: - name: run hyperfine run: | - hyperfine --export-json=hyperfine.json --warmup 3 --runs 20 --shell=none "./lightpanda --dump http://127.0.0.1:1234/campfire-commerce/" + hyperfine --export-json=hyperfine.json --warmup 3 --runs 20 --shell=none "./lightpanda --dump html http://127.0.0.1:1234/campfire-commerce/" - name: stop http run: kill `cat WS.pid` From ce5dad722fd65deaea55b16396d7ee784d2997c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 18 Feb 2026 19:33:32 +0900 Subject: [PATCH 15/17] Make --dump format optional and improve markdown rendering --- .github/workflows/e2e-test.yml | 2 +- src/Config.zig | 20 +++++++------- src/browser/markdown.zig | 48 +++++++++++++++------------------- 3 files changed, 33 insertions(+), 37 deletions(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 582a2892..6f9055cc 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -224,7 +224,7 @@ jobs: - name: run hyperfine run: | - hyperfine --export-json=hyperfine.json --warmup 3 --runs 20 --shell=none "./lightpanda --dump html http://127.0.0.1:1234/campfire-commerce/" + hyperfine --export-json=hyperfine.json --warmup 3 --runs 20 --shell=none "./lightpanda --dump http://127.0.0.1:1234/campfire-commerce/" - name: stop http run: kill `cat WS.pid` diff --git a/src/Config.zig b/src/Config.zig index acb99eca..f67a72c9 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -560,15 +560,17 @@ fn parseFetchArgs( while (args.next()) |opt| { if (std.mem.eql(u8, "--dump", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = "--dump" }); - return error.InvalidArgument; - }; - - dump_mode = std.meta.stringToEnum(DumpFormat, str) orelse { - log.fatal(.app, "invalid option choice", .{ .arg = "--dump", .value = str }); - return error.InvalidArgument; - }; + var peek_args = args.*; + if (peek_args.next()) |next_arg| { + if (std.meta.stringToEnum(DumpFormat, next_arg)) |mode| { + dump_mode = mode; + _ = args.next(); + } else { + dump_mode = .html; + } + } else { + dump_mode = .html; + } continue; } diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index cf322bf4..1ba9a294 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -38,7 +38,6 @@ const State = struct { in_pre: bool = false, pre_node: ?*Node = null, in_code: bool = false, - in_blockquote: bool = false, in_table: bool = false, table_row_index: usize = 0, table_col_count: usize = 0, @@ -75,7 +74,7 @@ pub fn dump(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) !void } } -fn render(node: *Node, state: *State, writer: *std.Io.Writer, page: *Page) anyerror!void { +fn render(node: *Node, state: *State, writer: *std.Io.Writer, page: *Page) error{WriteFailed}!void { switch (node._type) { .document, .document_fragment => { try renderChildren(node, state, writer, page); @@ -100,14 +99,14 @@ fn render(node: *Node, state: *State, writer: *std.Io.Writer, page: *Page) anyer } } -fn renderChildren(parent: *Node, state: *State, writer: *std.Io.Writer, page: *Page) anyerror!void { +fn renderChildren(parent: *Node, state: *State, writer: *std.Io.Writer, page: *Page) !void { var it = parent.childrenIterator(); while (it.next()) |child| { try render(child, state, writer, page); } } -fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Page) anyerror!void { +fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Page) !void { const tag = el.getTag(); // Skip hidden/metadata elements @@ -184,7 +183,6 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag }, .blockquote => { try writer.writeAll("> "); - state.in_blockquote = true; state.last_char_was_newline = false; }, .pre => { @@ -241,18 +239,24 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag }, .anchor => { 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.writeByte(')'); state.last_char_was_newline = false; + return; }, .input => { - if (el.getAttributeSafe(comptime .wrap("type"))) |t| { - if (std.mem.eql(u8, t, "checkbox")) { - if (el.hasAttributeSafe(comptime .wrap("checked"))) { - try writer.writeAll("[x] "); - } else { - try writer.writeAll("[ ] "); - } - state.last_char_was_newline = false; + const input = el.as(Element.Html.Input); + if (input._input_type == .checkbox) { + if (input._checked) { + try writer.writeAll("[x] "); + } else { + try writer.writeAll("[ ] "); } + state.last_char_was_newline = false; } return; }, @@ -266,14 +270,6 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag // Suffixes switch (tag) { - .anchor => { - try writer.writeAll("]("); - if (el.getAttributeSafe(comptime .wrap("href"))) |href| { - try writer.writeAll(href); - } - try writer.writeByte(')'); - state.last_char_was_newline = false; - }, .pre => { if (!state.last_char_was_newline) { try writer.writeByte('\n'); @@ -302,9 +298,7 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag try writer.writeAll("~~"); state.last_char_was_newline = false; }, - .blockquote => { - state.in_blockquote = false; - }, + .blockquote => {}, .ul, .ol => { if (state.list_depth > 0) state.list_depth -= 1; }, @@ -340,7 +334,7 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag } } -fn renderText(text: []const u8, state: *State, writer: *std.Io.Writer) anyerror!void { +fn renderText(text: []const u8, state: *State, writer: *std.Io.Writer) !void { if (text.len == 0) return; if (state.in_pre) { @@ -395,7 +389,7 @@ fn renderText(text: []const u8, state: *State, writer: *std.Io.Writer) anyerror! fn escapeMarkdown(writer: *std.Io.Writer, text: []const u8) !void { for (text) |c| { switch (c) { - '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '!', '|', '<', '>' => { + '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '!', '|' => { try writer.writeByte('\\'); try writer.writeByte(c); }, @@ -404,7 +398,7 @@ fn escapeMarkdown(writer: *std.Io.Writer, text: []const u8) !void { } } -fn writeIndentation(level: usize, writer: *std.Io.Writer) anyerror!void { +fn writeIndentation(level: usize, writer: *std.Io.Writer) !void { var i: usize = 0; while (i < level) : (i += 1) { try writer.writeAll(" "); From d264ff28016bc0dcb17996a399d7f2e794c727b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 18 Feb 2026 22:48:46 +0900 Subject: [PATCH 16/17] Use attributes for checkbox rendering --- src/browser/markdown.zig | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index 1ba9a294..7c3707ee 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -249,14 +249,15 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag return; }, .input => { - const input = el.as(Element.Html.Input); - if (input._input_type == .checkbox) { - if (input._checked) { - try writer.writeAll("[x] "); - } else { - try writer.writeAll("[ ] "); + if (el.getAttributeSafe(comptime .wrap("type"))) |type_attr| { + if (std.ascii.eqlIgnoreCase(type_attr, "checkbox")) { + if (el.getAttributeSafe(comptime .wrap("checked"))) |_| { + try writer.writeAll("[x] "); + } else { + try writer.writeAll("[ ] "); + } + state.last_char_was_newline = false; } - state.last_char_was_newline = false; } return; }, From 92f131bbe460d246f8fc0b7f871874c22c0334bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 18 Feb 2026 23:02:10 +0900 Subject: [PATCH 17/17] Inline writeIndentation helper --- src/browser/markdown.zig | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index 7c3707ee..fcbac478 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -152,7 +152,7 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag }, .li => { const indent = if (state.list_depth > 0) state.list_depth - 1 else 0; - try writeIndentation(indent, writer); + for (0..indent) |_| try writer.writeAll(" "); if (state.list_depth > 0) { const current_list = &state.list_stack[state.list_depth - 1]; @@ -399,13 +399,6 @@ fn escapeMarkdown(writer: *std.Io.Writer, text: []const u8) !void { } } -fn writeIndentation(level: usize, writer: *std.Io.Writer) !void { - var i: usize = 0; - while (i < level) : (i += 1) { - try writer.writeAll(" "); - } -} - fn testMarkdownHTML(html: []const u8, expected: []const u8) !void { const testing = @import("../testing.zig"); const page = try testing.test_session.createPage();