diff --git a/src/browser/StyleManager.zig b/src/browser/StyleManager.zig
index 5e7d2548e..445f16a2d 100644
--- a/src/browser/StyleManager.zig
+++ b/src/browser/StyleManager.zig
@@ -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
// `
` 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;
diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig
index da701b028..1032e467e 100644
--- a/src/browser/markdown.zig
+++ b/src/browser/markdown.zig
@@ -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
'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(
+ \\TitleAug 04 2026
+ , "[Title **Aug 04 2026**](http://localhost/p)\n");
+ try testMarkdownHTML(
+ \\ab xc
+ , "a b c\n");
+ try testMarkdownHTML(
+ \\ lead x tail
+ , "lead **x** tail\n");
+ try testMarkdownHTML(
+ \\
+ , "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(
\\
diff --git a/src/browser/tests/markdown_flex.html b/src/browser/tests/markdown_flex.html
new file mode 100644
index 000000000..e3b0f04a0
--- /dev/null
+++ b/src/browser/tests/markdown_flex.html
@@ -0,0 +1 @@
+TitleAug 04 2026Titledate