diff --git a/src/html/elements/title.zig b/src/html/elements/title.zig index d4b9354..0f56204 100644 --- a/src/html/elements/title.zig +++ b/src/html/elements/title.zig @@ -1,4 +1,8 @@ const std = @import("std"); +const Allocator = std.mem.Allocator; +const root = @import("../../root.zig"); +const Span = root.Span; +const Ast = @import("../Ast.zig"); const Element = @import("../Element.zig"); pub const title: Element = .{ @@ -9,7 +13,12 @@ pub const title: Element = .{ }, .meta = .{ .categories_superset = .{ .metadata = true } }, .attributes = .static, - .content = .model, + .content = .{ + .custom = .{ + .validate = validateContent, + .completions = completionsContent, + }, + }, .desc = \\The `` HTML element defines the document's title that is \\shown in a browser's title bar or a page's tab. It only contains @@ -17,6 +26,172 @@ pub const title: Element = .{ \\plain text. \\ \\ - [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/title) - \\ - [HTML Spec](https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-title-element) + \\ - [HTML Spec](https://html.spec.whatwg.org/multipage/semantics.html#the-title-element) , }; + +/// Validates the content model for `<title>`. +/// Per WHATWG spec ยง 4.2.2: Content model is "Text that is not inter-element whitespace." +/// This means: +/// - No element children allowed (already handled by `.content = .{ .text = true }`) +/// - No comments allowed +/// - Text content must not be only whitespace +fn validateContent( + gpa: Allocator, + nodes: []const Ast.Node, + seen_attrs: *std.StringHashMapUnmanaged(Span), + seen_ids: *std.StringHashMapUnmanaged(Span), + errors: *std.ArrayListUnmanaged(Ast.Error), + src: []const u8, + parent_idx: u32, +) error{OutOfMemory}!void { + _ = seen_attrs; + _ = seen_ids; + + const parent = nodes[parent_idx]; + const parent_span = parent.span(src); + + var has_non_whitespace_text = false; + var child_idx = parent.first_child_idx; + while (child_idx != 0) { + const child = nodes[child_idx]; + defer child_idx = child.next_idx; + + switch (child.kind) { + .comment => { + // Comments are not allowed inside <title> + try errors.append(gpa, .{ + .tag = .{ + .invalid_nesting = .{ + .span = parent_span, + .reason = "comments are not allowed inside <title>", + }, + }, + .main_location = child.span(src), + .node_idx = child_idx, + }); + }, + .text => { + // Check if text contains non-whitespace characters + const text = child.span(src).slice(src); + for (text) |c| { + if (c != ' ' and c != '\t' and c != '\n' and c != '\r') { + has_non_whitespace_text = true; + break; + } + } + }, + else => { + // Element children are rejected by the content model (.text = true) + // but we still need to validate them through modelRejects + if (!title.model.content.overlaps(child.model.categories)) { + try errors.append(gpa, .{ + .tag = .{ + .invalid_nesting = .{ + .span = parent_span, + }, + }, + .main_location = child.span(src), + .node_idx = child_idx, + }); + } + }, + } + } + + // <title> must contain text that is not inter-element whitespace + if (!has_non_whitespace_text) { + try errors.append(gpa, .{ + .tag = .{ + .invalid_nesting = .{ + .span = parent_span, + .reason = "<title> must contain non-whitespace text", + }, + }, + .main_location = parent_span, + .node_idx = parent_idx, + }); + } +} + +fn completionsContent( + arena: Allocator, + ast: Ast, + src: []const u8, + parent_idx: u32, + offset: u32, +) error{OutOfMemory}![]const Ast.Completion { + // <title> only accepts text content, no element completions + _ = arena; + _ = ast; + _ = src; + _ = parent_idx; + _ = offset; + return &.{}; +} + +test "title element rejects comments" { + const case = + \\<!DOCTYPE html><html><head><title><!-- comment --> + ; + + const ast = try Ast.init(std.testing.allocator, case, .html, false); + defer ast.deinit(std.testing.allocator); + + // Should have an error for the comment inside + try std.testing.expect(ast.errors.len > 0); + + // Check that one of the errors is about invalid nesting in <title> + var found_title_error = false; + for (ast.errors) |err| { + switch (err.tag) { + .invalid_nesting => |nesting| { + if (std.mem.indexOf(u8, nesting.reason, "comment") != null) { + found_title_error = true; + break; + } + }, + else => {}, + } + } + try std.testing.expect(found_title_error); +} + +test "title element rejects whitespace-only content" { + const case = + \\<!DOCTYPE html><html><head><title> + ; + + const ast = try Ast.init(std.testing.allocator, case, .html, false); + defer ast.deinit(std.testing.allocator); + + // Should have an error for whitespace-only content + try std.testing.expect(ast.errors.len > 0); + + // Check that one of the errors is about non-whitespace text requirement + var found_whitespace_error = false; + for (ast.errors) |err| { + switch (err.tag) { + .invalid_nesting => |nesting| { + if (std.mem.indexOf(u8, nesting.reason, "whitespace") != null) { + found_whitespace_error = true; + break; + } + }, + else => {}, + } + } + try std.testing.expect(found_whitespace_error); +} + +test "title element accepts valid text content" { + const case = + \\My Page Title + ; + + const ast = try Ast.init(std.testing.allocator, case, .html, false); + defer ast.deinit(std.testing.allocator); + + // Should have no errors + try std.testing.expectEqual(@as(usize, 0), ast.errors.len); +}