diff --git a/Tests/LibWeb/Tokenizer/expected/attributes.txt b/Tests/LibWeb/Tokenizer/expected/attributes.txt
new file mode 100644
index 0000000000..131fc9114a
--- /dev/null
+++ b/Tests/LibWeb/Tokenizer/expected/attributes.txt
@@ -0,0 +1,4 @@
+StartTag
[id="main" class="container" data-value="foo" hidden=""] @0:1-0:54
+EndTag
@0:57-0:60
+Character U+000A @1:0
+EndOfFile @1:0
diff --git a/Tests/LibWeb/Tokenizer/expected/basic-tags.txt b/Tests/LibWeb/Tokenizer/expected/basic-tags.txt
new file mode 100644
index 0000000000..7d4b063aba
--- /dev/null
+++ b/Tests/LibWeb/Tokenizer/expected/basic-tags.txt
@@ -0,0 +1,8 @@
+StartTag [] @0:1-0:5
+StartTag [] @0:7-0:11
+EndTag @0:14-0:18
+StartTag [] @0:20-0:24
+EndTag @0:27-0:31
+EndTag @0:34-0:38
+Character U+000A @1:0
+EndOfFile @1:0
diff --git a/Tests/LibWeb/Tokenizer/expected/cdata.txt b/Tests/LibWeb/Tokenizer/expected/cdata.txt
new file mode 100644
index 0000000000..8cf3392373
--- /dev/null
+++ b/Tests/LibWeb/Tokenizer/expected/cdata.txt
@@ -0,0 +1,3 @@
+Comment "[CDATA[some cdata content]]" @0:9-0:30
+Character U+000A @1:0
+EndOfFile @1:0
diff --git a/Tests/LibWeb/Tokenizer/expected/character-references.txt b/Tests/LibWeb/Tokenizer/expected/character-references.txt
new file mode 100644
index 0000000000..b58e5d2f41
--- /dev/null
+++ b/Tests/LibWeb/Tokenizer/expected/character-references.txt
@@ -0,0 +1,11 @@
+Character U+0026 '&' @0:5
+Character U+003C '<' @0:9
+Character U+003E '>' @0:13
+Character U+0022 '"' @0:19
+Character U+0027 ''' @0:25
+Character U+0041 'A' @0:30
+Character U+0041 'A' @0:36
+Character U+22F6 @0:45
+Character U+00AC @0:50
+Character U+000A @1:0
+EndOfFile @1:0
diff --git a/Tests/LibWeb/Tokenizer/expected/comments.txt b/Tests/LibWeb/Tokenizer/expected/comments.txt
new file mode 100644
index 0000000000..311483d8a4
--- /dev/null
+++ b/Tests/LibWeb/Tokenizer/expected/comments.txt
@@ -0,0 +1,10 @@
+Comment " simple comment " @0:0-0:23
+Character U+000A @1:0
+Comment "" @1:0-1:7
+Character U+000A @2:0
+Comment "-" @2:0-2:8
+Character U+000A @3:0
+Comment " multi
+line comment " @3:0-4:16
+Character U+000A @5:0
+EndOfFile @5:0
diff --git a/Tests/LibWeb/Tokenizer/expected/doctype.txt b/Tests/LibWeb/Tokenizer/expected/doctype.txt
new file mode 100644
index 0000000000..06e5d95d17
--- /dev/null
+++ b/Tests/LibWeb/Tokenizer/expected/doctype.txt
@@ -0,0 +1,5 @@
+DOCTYPE name="html" @0:11-0:15
+Character U+000A @1:0
+DOCTYPE name="html" public_id="-//W3C//DTD HTML 4.01//EN" system_id="http://www.w3.org/TR/html4/strict.dtd" @1:11-1:90
+Character U+000A @2:0
+EndOfFile @2:0
diff --git a/Tests/LibWeb/Tokenizer/expected/nested-tags.txt b/Tests/LibWeb/Tokenizer/expected/nested-tags.txt
new file mode 100644
index 0000000000..e04b4b8e8e
--- /dev/null
+++ b/Tests/LibWeb/Tokenizer/expected/nested-tags.txt
@@ -0,0 +1,14 @@
+StartTag [] @0:1-0:4
+StartTag
[] @0:6-0:7
+Character U+006F 'o' @0:9
+Character U+006E 'n' @0:10
+Character U+0065 'e' @0:11
+EndTag
@0:13-0:14
+StartTag
[] @0:16-0:17
+Character U+0074 't' @0:19
+Character U+0077 'w' @0:20
+Character U+006F 'o' @0:21
+EndTag
@0:23-0:24
+EndTag
@0:27-0:30
+Character U+000A @1:0
+EndOfFile @1:0
diff --git a/Tests/LibWeb/Tokenizer/expected/numeric-char-refs.txt b/Tests/LibWeb/Tokenizer/expected/numeric-char-refs.txt
new file mode 100644
index 0000000000..78dab39cdb
--- /dev/null
+++ b/Tests/LibWeb/Tokenizer/expected/numeric-char-refs.txt
@@ -0,0 +1,12 @@
+Character U+0009 @0:4
+Character U+000A @0:9
+Character U+000D @0:14
+Character U+0020 ' ' @0:19
+Character U+007F @0:25
+Character U+20AC @0:31
+Character U+0178 @0:37
+Character U+FFFD @0:41
+Character U+10FFFF @0:51
+Character U+FFFD @0:61
+Character U+000A @1:0
+EndOfFile @1:0
diff --git a/Tests/LibWeb/Tokenizer/expected/self-closing.txt b/Tests/LibWeb/Tokenizer/expected/self-closing.txt
new file mode 100644
index 0000000000..125601644b
--- /dev/null
+++ b/Tests/LibWeb/Tokenizer/expected/self-closing.txt
@@ -0,0 +1,5 @@
+StartTag
[] @0:1-0:4
+StartTag
[] @0:6-0:10
+StartTag
[src="x.png"] @0:12-0:28
+Character U+000A @1:0
+EndOfFile @1:0
diff --git a/Tests/LibWeb/Tokenizer/expected/text-content.txt b/Tests/LibWeb/Tokenizer/expected/text-content.txt
new file mode 100644
index 0000000000..b75c9ce2e9
--- /dev/null
+++ b/Tests/LibWeb/Tokenizer/expected/text-content.txt
@@ -0,0 +1,17 @@
+StartTag [] @0:1-0:2
+Character U+0048 'H' @0:4
+Character U+0065 'e' @0:5
+Character U+006C 'l' @0:6
+Character U+006C 'l' @0:7
+Character U+006F 'o' @0:8
+Character U+002C ',' @0:9
+Character U+0020 ' ' @0:10
+Character U+0077 'w' @0:11
+Character U+006F 'o' @0:12
+Character U+0072 'r' @0:13
+Character U+006C 'l' @0:14
+Character U+0064 'd' @0:15
+Character U+0021 '!' @0:16
+EndTag
@0:18-0:19
+Character U+000A @1:0
+EndOfFile @1:0
diff --git a/Tests/LibWeb/Tokenizer/input/attributes.html b/Tests/LibWeb/Tokenizer/input/attributes.html
new file mode 100644
index 0000000000..cb42a95b60
--- /dev/null
+++ b/Tests/LibWeb/Tokenizer/input/attributes.html
@@ -0,0 +1 @@
+
diff --git a/Tests/LibWeb/Tokenizer/input/basic-tags.html b/Tests/LibWeb/Tokenizer/input/basic-tags.html
new file mode 100644
index 0000000000..30e84fdd6e
--- /dev/null
+++ b/Tests/LibWeb/Tokenizer/input/basic-tags.html
@@ -0,0 +1 @@
+
diff --git a/Tests/LibWeb/Tokenizer/input/cdata.html b/Tests/LibWeb/Tokenizer/input/cdata.html
new file mode 100644
index 0000000000..6eab34b634
--- /dev/null
+++ b/Tests/LibWeb/Tokenizer/input/cdata.html
@@ -0,0 +1 @@
+
diff --git a/Tests/LibWeb/Tokenizer/input/character-references.html b/Tests/LibWeb/Tokenizer/input/character-references.html
new file mode 100644
index 0000000000..d977a81305
--- /dev/null
+++ b/Tests/LibWeb/Tokenizer/input/character-references.html
@@ -0,0 +1 @@
+&<>"'AA⋶¬
diff --git a/Tests/LibWeb/Tokenizer/input/comments.html b/Tests/LibWeb/Tokenizer/input/comments.html
new file mode 100644
index 0000000000..6bba73561a
--- /dev/null
+++ b/Tests/LibWeb/Tokenizer/input/comments.html
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/Tests/LibWeb/Tokenizer/input/doctype.html b/Tests/LibWeb/Tokenizer/input/doctype.html
new file mode 100644
index 0000000000..4d258d505c
--- /dev/null
+++ b/Tests/LibWeb/Tokenizer/input/doctype.html
@@ -0,0 +1,2 @@
+
+
diff --git a/Tests/LibWeb/Tokenizer/input/nested-tags.html b/Tests/LibWeb/Tokenizer/input/nested-tags.html
new file mode 100644
index 0000000000..45a403cb53
--- /dev/null
+++ b/Tests/LibWeb/Tokenizer/input/nested-tags.html
@@ -0,0 +1 @@
+
diff --git a/Tests/LibWeb/Tokenizer/input/numeric-char-refs.html b/Tests/LibWeb/Tokenizer/input/numeric-char-refs.html
new file mode 100644
index 0000000000..8804713f5b
--- /dev/null
+++ b/Tests/LibWeb/Tokenizer/input/numeric-char-refs.html
@@ -0,0 +1 @@
+
diff --git a/Tests/LibWeb/Tokenizer/input/self-closing.html b/Tests/LibWeb/Tokenizer/input/self-closing.html
new file mode 100644
index 0000000000..5a4101f0a8
--- /dev/null
+++ b/Tests/LibWeb/Tokenizer/input/self-closing.html
@@ -0,0 +1 @@
+
diff --git a/Tests/LibWeb/Tokenizer/input/text-content.html b/Tests/LibWeb/Tokenizer/input/text-content.html
new file mode 100644
index 0000000000..7ce535433d
--- /dev/null
+++ b/Tests/LibWeb/Tokenizer/input/text-content.html
@@ -0,0 +1 @@
+Hello, world!
diff --git a/Utilities/CMakeLists.txt b/Utilities/CMakeLists.txt
index a600054758..b41553d48c 100644
--- a/Utilities/CMakeLists.txt
+++ b/Utilities/CMakeLists.txt
@@ -20,6 +20,7 @@ if (WIN32)
endif()
ladybird_utility(test262-runner SOURCES test262-runner.cpp LIBS LibJS LibFileSystem LibGC)
+ladybird_utility(dump-html-tokens SOURCES dump-html-tokens.cpp LIBS LibMain LibWeb)
if (CMAKE_SYSTEM_NAME STREQUAL "Linux")
include(CheckCSourceCompiles)
diff --git a/Utilities/dump-html-tokens.cpp b/Utilities/dump-html-tokens.cpp
new file mode 100644
index 0000000000..6f5a5ef055
--- /dev/null
+++ b/Utilities/dump-html-tokens.cpp
@@ -0,0 +1,155 @@
+/*
+ * Copyright (c) 2026-present, the Ladybird developers.
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+using Web::HTML::HTMLToken;
+using Web::HTML::HTMLTokenizer;
+
+static String format_position(HTMLToken::Position const& pos)
+{
+ return MUST(String::formatted("{}:{}", pos.line, pos.column));
+}
+
+static void dump_token(HTMLToken const& token)
+{
+ switch (token.type()) {
+ case HTMLToken::Type::DOCTYPE: {
+ auto const& doctype = token.doctype_data();
+ out("DOCTYPE");
+ if (!doctype.missing_name)
+ out(" name=\"{}\"", doctype.name);
+ if (!doctype.missing_public_identifier)
+ out(" public_id=\"{}\"", doctype.public_identifier);
+ if (!doctype.missing_system_identifier)
+ out(" system_id=\"{}\"", doctype.system_identifier);
+ if (doctype.force_quirks)
+ out(" force_quirks");
+ outln(" @{}-{}", format_position(token.start_position()), format_position(token.end_position()));
+ break;
+ }
+ case HTMLToken::Type::StartTag: {
+ if (token.is_self_closing())
+ out("StartTag <{}/> [", token.tag_name());
+ else
+ out("StartTag <{}> [", token.tag_name());
+ bool first = true;
+ token.for_each_attribute([&](auto& attribute) {
+ if (!first)
+ out(" ");
+ first = false;
+ out("{}=\"{}\"", attribute.local_name, attribute.value);
+ return IterationDecision::Continue;
+ });
+ outln("] @{}-{}", format_position(token.start_position()), format_position(token.end_position()));
+ break;
+ }
+ case HTMLToken::Type::EndTag:
+ outln("EndTag {}> @{}-{}", token.tag_name(), format_position(token.start_position()), format_position(token.end_position()));
+ break;
+ case HTMLToken::Type::Comment:
+ outln("Comment \"{}\" @{}-{}", token.comment(), format_position(token.start_position()), format_position(token.end_position()));
+ break;
+ case HTMLToken::Type::Character: {
+ auto code_point = token.code_point();
+ if (code_point >= 0x20 && code_point < 0x7F)
+ outln("Character U+{:04X} '{:c}' @{}", code_point, static_cast(code_point), format_position(token.start_position()));
+ else
+ outln("Character U+{:04X} @{}", code_point, format_position(token.start_position()));
+ break;
+ }
+ case HTMLToken::Type::EndOfFile:
+ outln("EndOfFile @{}", format_position(token.start_position()));
+ break;
+ case HTMLToken::Type::Invalid:
+ VERIFY_NOT_REACHED();
+ }
+}
+
+ErrorOr ladybird_main(Main::Arguments arguments)
+{
+ StringView file_path;
+ StringView initial_state_name;
+ bool silent = false;
+ int iterations = 1;
+
+ Core::ArgsParser args_parser;
+ args_parser.set_general_help(
+ "Tokenize HTML and dump tokens in a canonical format. "
+ "Use --silent and --iterations for perf work on the tokenizer itself.");
+ args_parser.add_positional_argument(file_path, "Path to HTML file (or - for stdin)", "file", Core::ArgsParser::Required::No);
+ args_parser.add_option(initial_state_name, "Initial tokenizer state", "initial-state", 's', "state");
+ args_parser.add_option(silent, "Don't print tokens (for benchmarking)", "silent", 'q');
+ args_parser.add_option(iterations, "Run the tokenizer N times and report timing", "iterations", 'n', "count");
+ args_parser.parse(arguments);
+
+ if (iterations < 1) {
+ warnln("--iterations must be at least 1");
+ return 1;
+ }
+
+ Optional initial_state;
+ if (!initial_state_name.is_empty()) {
+#define __ENUMERATE_TOKENIZER_STATE(s) \
+ if (initial_state_name == #s##sv) \
+ initial_state = HTMLTokenizer::State::s;
+ ENUMERATE_TOKENIZER_STATES
+#undef __ENUMERATE_TOKENIZER_STATE
+ if (!initial_state.has_value()) {
+ warnln("Unknown tokenizer state: '{}'", initial_state_name);
+ return 1;
+ }
+ }
+
+ ByteBuffer input_data;
+ if (file_path.is_empty() || file_path == "-"sv) {
+ auto stdin_file = TRY(Core::File::standard_input());
+ input_data = TRY(stdin_file->read_until_eof());
+ } else {
+ auto file = TRY(Core::File::open(file_path, Core::File::OpenMode::Read));
+ input_data = TRY(file->read_until_eof());
+ }
+
+ StringView input { input_data };
+
+ auto timer = Core::ElapsedTimer::start_new();
+ u64 total_tokens = 0;
+
+ for (int i = 0; i < iterations; i++) {
+ HTMLTokenizer tokenizer { input, "UTF-8"sv };
+ if (initial_state.has_value())
+ tokenizer.switch_to(initial_state.value());
+
+ while (true) {
+ auto maybe_token = tokenizer.next_token();
+ if (!maybe_token.has_value())
+ break;
+ ++total_tokens;
+ if (!silent && i == 0)
+ dump_token(maybe_token.value());
+ if (maybe_token->is_end_of_file())
+ break;
+ }
+ }
+
+ if (iterations > 1 || silent) {
+ auto elapsed_ms = timer.elapsed_milliseconds();
+ warnln("input={}B iterations={} tokens/iter={} total={}ms ({:.3f}ms/iter)",
+ input_data.size(),
+ iterations,
+ total_tokens / iterations,
+ elapsed_ms,
+ static_cast(elapsed_ms) / iterations);
+ }
+
+ return 0;
+}