markdown: separate flex and grid items in the dump

Children of a `display: flex`/`grid` container are separate boxes however
inline their tags are, so `Title<b>Aug 04 2026</b>` inside a flex `<a>` ran
together as `Title**Aug 04 2026**`. StyleManager now keeps the display kind
(none/flex/grid/other) instead of a display:none bool, and the dumper puts a
space between the rendered items of such a container, skipping the
whitespace between them like layout does.
This commit is contained in:
Adrià Arrufat committed 2026-08-28 14:52:37 +02:00
1 parent fa1b9ab03d
commit 81530d8a22
3 files changed
+138 -54

No files matched your search

+60 -33
View File
@@ -459,7 +459,7 @@ fn addRawRule(self: *StyleManager, build_arena: Allocator, selector_text: []cons
const name = decl.name;
const val = decl.value;
if (std.ascii.eqlIgnoreCase(name, "display")) {
props.display_none = std.ascii.eqlIgnoreCase(val, "none");
props.display = Display.parse(val);
} else if (std.ascii.eqlIgnoreCase(name, "visibility")) {
props.visibility_hidden = std.ascii.eqlIgnoreCase(val, "hidden") or std.ascii.eqlIgnoreCase(val, "collapse");
} else if (std.ascii.eqlIgnoreCase(name, "opacity")) {
@@ -611,8 +611,13 @@ pub fn isHidden(self: *StyleManager, el: *Element, cache: ?*VisibilityCache, opt
/// Honors the UA stylesheet rules per HTML Rendering §15.3.1 "Hidden elements"
/// via `isElementHidden`.
pub fn hasDisplayNone(self: *StyleManager, el: *Element, comptime access: InlineAccess) bool {
self.rebuildIfDirty() catch return false;
return self.isElementHidden(el, .{}, access);
return self.display(el, access) == .none;
}
/// Own property, no ancestor walk; honors the UA hidden-element rules.
pub fn display(self: *StyleManager, el: *Element, comptime access: InlineAccess) Display {
self.rebuildIfDirty() catch return .other;
return self.resolve(el, .{}, access).display orelse .other;
}
/// Computed display:none coming only from inline style or an author stylesheet
@@ -676,25 +681,34 @@ pub fn hasVisibilityHiddenInherited(self: *StyleManager, el: *Element) bool {
/// Check if a single element (not ancestors) is hidden.
fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOptions, comptime access: InlineAccess) bool {
return self.resolve(el, options, access).hidden();
}
const Resolved = struct {
display: ?Display = null,
visibility_hidden: ?bool = null,
opacity_zero: ?bool = null,
fn hidden(self: Resolved) bool {
return self.display == .none or (self.visibility_hidden orelse false) or (self.opacity_zero orelse false);
}
};
/// A hiding inline value returns early, so only `hidden()` is exact then.
fn resolve(self: *StyleManager, el: *Element, options: CheckVisibilityOptions, comptime access: InlineAccess) Resolved {
// Track best match per property (value + priority)
// Initialize priority to INLINE_PRIORITY for properties we don't care about - this makes
// the loop naturally skip them since no stylesheet rule can have priority >= INLINE_PRIORITY
var display_none: ?bool = null;
var r: Resolved = .{};
var display_priority: u64 = 0;
var visibility_hidden: ?bool = null;
var visibility_priority: u64 = 0;
var opacity_zero: ?bool = null;
var opacity_priority: u64 = 0;
// Check inline styles FIRST - they use INLINE_PRIORITY so no stylesheet can beat them
if (options.check_display) {
if (inlineValue(el, comptime .wrap("display"), access, self.frame)) |value| {
if (std.ascii.eqlIgnoreCase(value, "none")) {
return true; // Early exit for hiding value
}
display_none = false;
r.display = Display.parse(value);
if (r.display == .none) return r;
display_priority = INLINE_PRIORITY;
}
} else {
@@ -705,9 +719,10 @@ fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOp
if (options.check_visibility) {
if (inlineValue(el, comptime .wrap("visibility"), access, self.frame)) |value| {
if (std.ascii.eqlIgnoreCase(value, "hidden") or std.ascii.eqlIgnoreCase(value, "collapse")) {
return true;
r.visibility_hidden = true;
return r;
}
visibility_hidden = false;
r.visibility_hidden = false;
visibility_priority = INLINE_PRIORITY;
}
} else {
@@ -720,9 +735,10 @@ fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOp
if (options.check_opacity) {
if (inlineValue(el, comptime .wrap("opacity"), access, self.frame)) |value| {
if (std.ascii.eqlIgnoreCase(value, "0")) {
return true;
r.opacity_zero = true;
return r;
}
opacity_zero = false;
r.opacity_zero = false;
opacity_priority = INLINE_PRIORITY;
}
} else {
@@ -730,16 +746,14 @@ fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOp
}
if (display_priority == INLINE_PRIORITY and visibility_priority == INLINE_PRIORITY and opacity_priority == INLINE_PRIORITY) {
return false;
return r;
}
// Helper to check a single rule
const Ctx = struct {
display_none: *?bool,
r: *Resolved,
display_priority: *u64,
visibility_hidden: *?bool,
visibility_priority: *u64,
opacity_zero: *?bool,
opacity_priority: *u64,
el: *Element,
frame: *Frame,
@@ -763,7 +777,7 @@ fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOp
}
// Logic for property dominance
const dominated = (props.display_none == null or p <= ctx.display_priority.*) and
const dominated = (props.display == null or p <= ctx.display_priority.*) and
(props.visibility_hidden == null or p <= ctx.visibility_priority.*) and
(props.opacity_zero == null or p <= ctx.opacity_priority.*);
@@ -771,16 +785,16 @@ fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOp
if (matchesSelector(ctx.el, selector, ctx.frame)) {
// Update best priorities
if (props.display_none != null and p > ctx.display_priority.*) {
ctx.display_none.* = props.display_none;
if (props.display != null and p > ctx.display_priority.*) {
ctx.r.display = props.display;
ctx.display_priority.* = p;
}
if (props.visibility_hidden != null and p > ctx.visibility_priority.*) {
ctx.visibility_hidden.* = props.visibility_hidden;
ctx.r.visibility_hidden = props.visibility_hidden;
ctx.visibility_priority.* = p;
}
if (props.opacity_zero != null and p > ctx.opacity_priority.*) {
ctx.opacity_zero.* = props.opacity_zero;
ctx.r.opacity_zero = props.opacity_zero;
ctx.opacity_priority.* = p;
}
}
@@ -788,11 +802,9 @@ fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOp
}
};
const ctx = Ctx{
.display_none = &display_none,
.r = &r,
.display_priority = &display_priority,
.visibility_hidden = &visibility_hidden,
.visibility_priority = &visibility_priority,
.opacity_zero = &opacity_zero,
.opacity_priority = &opacity_priority,
.el = el,
.frame = self.frame,
@@ -826,11 +838,11 @@ fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOp
// `<div class="x" hidden>` must report visible.
if (options.check_display and options.ua_display_none and display_priority == 0) {
if (matchesUaDisplayNoneRule(el)) {
display_none = true;
r.display = .none;
}
}
return (display_none orelse false) or (visibility_hidden orelse false) or (opacity_zero orelse false);
return r;
}
/// Check if an element has pointer-events:none.
@@ -1048,7 +1060,7 @@ fn extractVisibilityProperties(style: *CSSStyleProperties) VisibilityProperties
const decl = style.asCSSStyleDeclaration();
if (decl.findProperty(comptime .wrap("display"))) |property| {
props.display_none = property._value.eqlSliceIgnoreCase("none");
props.display = Display.parse(property._value.str());
}
if (decl.findProperty(comptime .wrap("visibility"))) |property| {
@@ -1123,15 +1135,30 @@ fn matchesSelector(el: *Element, selector: Selector.Selector, frame: *Frame) boo
return SelectorList.matches(node, selector, node, frame);
}
/// Only the values the dumpers act on; everything else is `other`.
pub const Display = enum {
none,
flex,
grid,
other,
fn parse(value: []const u8) Display {
if (std.ascii.eqlIgnoreCase(value, "none")) return .none;
if (std.ascii.eqlIgnoreCase(value, "flex") or std.ascii.eqlIgnoreCase(value, "inline-flex")) return .flex;
if (std.ascii.eqlIgnoreCase(value, "grid") or std.ascii.eqlIgnoreCase(value, "inline-grid")) return .grid;
return .other;
}
};
const VisibilityProperties = struct {
display_none: ?bool = null,
display: ?Display = null,
visibility_hidden: ?bool = null,
opacity_zero: ?bool = null,
pointer_events_none: ?bool = null,
// return true if any field in VisibilityProperties is not null
fn isRelevant(self: VisibilityProperties) bool {
return self.display_none != null or
return self.display != null or
self.visibility_hidden != null or
self.opacity_zero != null or
self.pointer_events_none != null;
+77 -21
View File
@@ -19,6 +19,7 @@
const std = @import("std");
const Frame = @import("Frame.zig");
const StyleManager = @import("StyleManager.zig");
const URL = @import("URL.zig");
const Node = @import("webapi/Node.zig");
@@ -102,13 +103,19 @@ fn isSignificantText(node: *Node) bool {
// Own state only; the dump root is exempt so a scoped dump of a hidden
// subtree still renders it.
fn isVisibleElement(el: *Element, frame: *Frame) bool {
return visibleDisplay(el, frame) != null;
}
/// Null when the element doesn't render.
fn visibleDisplay(el: *Element, frame: *Frame) ?StyleManager.Display {
const tag = el.getTag();
if (tag.isMetadata() or tag == .svg) return false;
if (frame._style_manager.hasDisplayNone(el, .scan)) return false;
if (tag.isMetadata() or tag == .svg) return null;
const display = frame._style_manager.display(el, .scan);
if (display == .none) return null;
if (el.getAttributeSafe(comptime .wrap("aria-hidden"))) |v| {
if (std.ascii.eqlIgnoreCase(v, "true")) return false;
if (std.ascii.eqlIgnoreCase(v, "true")) return null;
}
return true;
return display;
}
fn getAnchorLabel(el: *Element) ?[]const u8 {
@@ -167,7 +174,7 @@ const Context = struct {
fn render(self: *Context, node: *Node) error{WriteFailed}!void {
switch (node._type) {
.document, .document_fragment => {
try self.renderChildren(node);
try self.renderChildren(node, false);
},
.element => {
try self.renderElement(node.subtype(Node.Element));
@@ -187,19 +194,44 @@ const Context = struct {
}
}
fn renderChildren(self: *Context, parent: *Node) !void {
/// `boxed`: flex/grid items are separate boxes however inline their tags
/// are, and the whitespace between them doesn't render.
fn renderChildren(self: *Context, parent: *Node, boxed: bool) error{WriteFailed}!void {
var it = parent.childrenIterator();
var separate = false;
while (it.next()) |child| {
try self.render(child);
if (!boxed) {
try self.render(child);
continue;
}
if (child.is(Element)) |el| {
if (self.skips(el, self.force_slot)) continue;
if (separate and !el.getTag().isBlock() and !self.state.last_char_was_newline) {
try self.writer.writeByte(' ');
}
try self.renderElement(el);
} else if (child.is(Node.CData.Text)) |_| {
const text = std.mem.trim(u8, child.subtype(Node.CData).getData().str(), &std.ascii.whitespace);
if (text.len == 0) continue;
if (separate and !self.state.last_char_was_newline) try self.writer.writeByte(' ');
try self.renderText(text);
} else continue;
separate = true;
}
}
fn skips(self: *Context, el: *Element, force_slot: bool) bool {
if (el.asNode() != self.root and !isVisibleElement(el, self.frame)) return true;
if (dump_html.shouldStripElement(el, self.strip, self.frame)) return true;
return !force_slot and el.getAttributeSafe(comptime .wrap("slot")) != null;
}
// Render a <slot>'s assigned light-DOM nodes, or its own children as
// fallback. Same as dump's dumpSlotContent.
fn renderSlotContent(self: *Context, slot: *Slot) !void {
const assigned = slot.assignedNodes(null, self.frame) catch return;
if (assigned.len == 0) {
return self.renderChildren(slot.asNode());
return self.renderChildren(slot.asNode(), false);
}
for (assigned) |node| {
// ensures that we don't skip this element when rending it.
@@ -215,16 +247,10 @@ const Context = struct {
const tag = el.getTag();
if (el.asNode() != self.root and !isVisibleElement(el, self.frame)) return;
const display = visibleDisplay(el, self.frame) orelse if (el.asNode() == self.root) StyleManager.Display.other else return;
if (dump_html.shouldStripElement(el, self.strip, self.frame)) return;
if (!force_slot) {
if (el.getAttributeSafe(comptime .wrap("slot")) != null) {
// This element has a slot attribute, and we aren't forcing slot
// rendering (i.e. this is the light-DOM), skip it.
return;
}
}
if (!force_slot and el.getAttributeSafe(comptime .wrap("slot")) != null) return;
const boxed = display == .flex or display == .grid;
// Ensure block elements start on a new line
if (tag.isBlock() and !self.state.in_table) {
@@ -348,7 +374,7 @@ const Context = struct {
const href = if (href_raw) |h| URL.resolve(frame.local_arena, frame.base(), h, .{ .encoding = frame.charset }) catch h else null;
if (info.has_block) {
try self.renderChildren(el.asNode());
try self.renderChildren(el.asNode(), boxed);
if (href) |h| {
if (!self.state.last_char_was_newline) try self.writer.writeByte('\n');
try self.writer.writeByte('[');
@@ -367,7 +393,7 @@ const Context = struct {
}
try self.writer.writeByte('[');
if (info.has_visible) {
try self.renderChildren(el.asNode());
try self.renderChildren(el.asNode(), boxed);
} else {
try self.writer.writeAll(label orelse "");
}
@@ -404,9 +430,9 @@ const Context = struct {
// early-return tags above can never be valid shadow hosts, so only this
// generic path needs the check.
if (el.hostedShadowRoot(self.frame)) |shadow| {
try self.renderChildren(shadow.asNode());
try self.renderChildren(shadow.asNode(), boxed);
} else {
try self.renderChildren(el.asNode());
try self.renderChildren(el.asNode(), boxed);
}
switch (tag) {
@@ -617,6 +643,36 @@ test "browser.markdown: table" {
);
}
test "browser.markdown: flex and grid items are separated" {
try testMarkdownHTML(
\\<a href="/p" style="display:flex">Title<b>Aug 04 2026</b></a>
, "[Title **Aug 04 2026**](http://localhost/p)\n");
try testMarkdownHTML(
\\<div style="display:grid"><span>a</span><span>b</span> <span style="display:none">x</span><span>c</span></div>
, "a b c\n");
try testMarkdownHTML(
\\<div style="display:inline-flex"> lead <b>x</b> tail </div>
, "lead **x** tail\n");
try testMarkdownHTML(
\\<div style="display:flex"><div>a</div><div>b</div></div>
, "a\nb\n");
}
test "browser.markdown: flex from a stylesheet" {
var page = try testing.pageTest("markdown_flex.html", .{});
defer page.close();
const frame = page.frame().?;
var aw: std.Io.Writer.Allocating = .init(testing.arena_allocator);
try dump(frame.window._document.asNode(), .{}, &aw.writer, frame);
try testing.expectString(
\\[Title **Aug 04 2026**](http://127.0.0.1:9582/p)
\\
\\Title**date**
\\
, aw.written());
}
test "browser.markdown: nested lists" {
try testMarkdownHTML(
\\<ul><li>Parent<ul><li>Child</li></ul></li></ul>
+1
View File
@@ -0,0 +1 @@
<!DOCTYPE html><html><head><style>.row{display:flex}</style></head><body><a href="/p" class="row">Title<b>Aug 04 2026</b></a><p>Title<b>date</b></p></body></html>