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 ``.
+/// 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
+ try errors.append(gpa, .{
+ .tag = .{
+ .invalid_nesting = .{
+ .span = parent_span,
+ .reason = "comments are not allowed inside ",
+ },
+ },
+ .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,
+ });
+ }
+ },
+ }
+ }
+
+ // 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 = " 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 {
+ // only accepts text content, no element completions
+ _ = arena;
+ _ = ast;
+ _ = src;
+ _ = parent_idx;
+ _ = offset;
+ return &.{};
+}
+
+test "title element rejects comments" {
+ const case =
+ \\
+ ;
+
+ 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
+ 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 =
+ \\
+ ;
+
+ 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);
+}