diff --git a/crates/html/src/lib.rs b/crates/html/src/lib.rs
index a425f3d..69bda58 100644
--- a/crates/html/src/lib.rs
+++ b/crates/html/src/lib.rs
@@ -4,6 +4,7 @@
//! and a simplified tree builder for constructing DOM trees from tokens.
mod entities;
+pub mod speculative;
mod tokenizer;
mod tree_builder;
diff --git a/crates/html/src/speculative.rs b/crates/html/src/speculative.rs
new file mode 100644
index 0000000..d6c57aa
--- /dev/null
+++ b/crates/html/src/speculative.rs
@@ -0,0 +1,1016 @@
+//! Speculative HTML parsing: off-main-thread preload scanning.
+//!
+//! When the main parser blocks on script execution, a speculative tokenizer
+//! runs on a background thread to discover preloadable resources (``,
+//! ``.
+ ScriptData,
+ /// Saw `<` inside script data.
+ ScriptDataLt,
+ /// Accumulating potential `/script>` end tag inside script data.
+ ScriptDataEndTag,
+}
+
+/// Lightweight preload scanner that extracts resource URLs from HTML.
+///
+/// This is intentionally simplified: it does not handle all the edge cases
+/// of the full HTML5 tokenizer. It is conservative — it may miss some
+/// resources, but it will not produce false positives that cause incorrect
+/// behaviour.
+pub struct PreloadScanner {
+ input: Vec,
+ pos: usize,
+ state: ScanState,
+ /// The tag name being accumulated.
+ tag_name: String,
+ /// Current attribute name being accumulated.
+ attr_name: String,
+ /// Current attribute value being accumulated.
+ attr_value: String,
+ /// Collected attributes for the current tag.
+ attrs: Vec<(String, String)>,
+ /// Buffer for script end tag detection.
+ end_tag_buf: String,
+ /// Discovered preload URLs.
+ preloads: Vec,
+}
+
+impl PreloadScanner {
+ /// Create a new preload scanner for the given input.
+ pub fn new(input: &str) -> Self {
+ PreloadScanner {
+ input: input.chars().collect(),
+ pos: 0,
+ state: ScanState::Data,
+ tag_name: String::new(),
+ attr_name: String::new(),
+ attr_value: String::new(),
+ attrs: Vec::new(),
+ end_tag_buf: String::new(),
+ preloads: Vec::new(),
+ }
+ }
+
+ /// Run the scanner to completion, returning discovered preload URLs.
+ pub fn scan(&mut self) -> Vec {
+ while self.pos < self.input.len() {
+ self.step();
+ }
+ std::mem::take(&mut self.preloads)
+ }
+
+ /// Also produce a buffered token stream using the full tokenizer.
+ ///
+ /// This runs the full HTML5 tokenizer on the same input in addition to
+ /// the preload scan, buffering all tokens for potential reuse.
+ pub fn scan_with_tokens(&mut self) -> (Vec, Vec) {
+ // Run the preload scan.
+ let preloads = self.scan();
+
+ // Run the full tokenizer to buffer tokens.
+ let input_str: String = self.input.iter().collect();
+ let mut tokenizer = crate::Tokenizer::new(&input_str);
+ let mut tokens = Vec::new();
+ loop {
+ let token = tokenizer.next_token();
+ if token == Token::Eof {
+ break;
+ }
+ tokens.push(token);
+ }
+
+ (preloads, tokens)
+ }
+
+ /// Advance the scanner by one character.
+ fn step(&mut self) {
+ let ch = self.input[self.pos];
+ match self.state {
+ ScanState::Data => self.scan_data(ch),
+ ScanState::TagOpen => self.scan_tag_open(ch),
+ ScanState::EndTag => self.scan_end_tag(ch),
+ ScanState::TagName => self.scan_tag_name(ch),
+ ScanState::BeforeAttr => self.scan_before_attr(ch),
+ ScanState::AttrName => self.scan_attr_name(ch),
+ ScanState::AfterAttrName => self.scan_after_attr_name(ch),
+ ScanState::BeforeAttrValue => self.scan_before_attr_value(ch),
+ ScanState::AttrValueDQ => self.scan_attr_value_dq(ch),
+ ScanState::AttrValueSQ => self.scan_attr_value_sq(ch),
+ ScanState::AttrValueUQ => self.scan_attr_value_uq(ch),
+ ScanState::AfterAttrValue => self.scan_after_attr_value(ch),
+ ScanState::Comment => self.scan_comment(ch),
+ ScanState::CommentDash => self.scan_comment_dash(ch),
+ ScanState::CommentDashDash => self.scan_comment_dash_dash(ch),
+ ScanState::ScriptData => self.scan_script_data(ch),
+ ScanState::ScriptDataLt => self.scan_script_data_lt(ch),
+ ScanState::ScriptDataEndTag => self.scan_script_data_end_tag(ch),
+ }
+ }
+
+ fn scan_data(&mut self, ch: char) {
+ if ch == '<' {
+ self.state = ScanState::TagOpen;
+ }
+ self.pos += 1;
+ }
+
+ fn scan_tag_open(&mut self, ch: char) {
+ if ch == '/' {
+ self.state = ScanState::EndTag;
+ self.end_tag_buf.clear();
+ self.pos += 1;
+ } else if ch == '!' {
+ // Could be comment: `
"#;
+ let mut scanner = PreloadScanner::new(html);
+ let preloads = scanner.scan();
+ assert_eq!(preloads.len(), 1);
+ assert_eq!(preloads[0].url, "/visible.png");
+ }
+
+ #[test]
+ fn scan_ignores_link_without_stylesheet_rel() {
+ let html = r#""#;
+ let mut scanner = PreloadScanner::new(html);
+ let preloads = scanner.scan();
+ assert_eq!(preloads.len(), 0);
+ }
+
+ #[test]
+ fn scan_handles_single_quoted_attrs() {
+ let html = "
";
+ let mut scanner = PreloadScanner::new(html);
+ let preloads = scanner.scan();
+ assert_eq!(preloads.len(), 1);
+ assert_eq!(preloads[0].url, "/photo.jpg");
+ }
+
+ #[test]
+ fn scan_handles_unquoted_attrs() {
+ let html = "
";
+ let mut scanner = PreloadScanner::new(html);
+ let preloads = scanner.scan();
+ assert_eq!(preloads.len(), 1);
+ assert_eq!(preloads[0].url, "/photo.jpg");
+ }
+
+ #[test]
+ fn scan_handles_self_closing_tags() {
+ let html = r#"
"#;
+ let mut scanner = PreloadScanner::new(html);
+ let preloads = scanner.scan();
+ assert_eq!(preloads.len(), 1);
+ assert_eq!(preloads[0].url, "/photo.jpg");
+ }
+
+ #[test]
+ fn scan_case_insensitive_tag_names() {
+ let html = r#"
"#;
+ let mut scanner = PreloadScanner::new(html);
+ let preloads = scanner.scan();
+ assert_eq!(preloads.len(), 2);
+ }
+
+ #[test]
+ fn scan_with_tokens_produces_valid_tokens() {
+ let html = r#"Hello
"#;
+ let mut scanner = PreloadScanner::new(html);
+ let (preloads, tokens) = scanner.scan_with_tokens();
+ assert_eq!(preloads.len(), 0);
+ // Should have StartTag(p), Character(Hello), EndTag(p)
+ assert!(tokens.len() >= 3);
+ }
+
+ #[test]
+ fn speculative_parser_basic_flow() {
+ let mut spec = SpeculativeParser::new();
+
+ let html = r#"
"#;
+ spec.speculate(html, 100);
+
+ let results = spec.collect_results();
+ assert!(results.is_some());
+ let preloads = results.unwrap();
+ assert_eq!(preloads.len(), 2);
+ assert_eq!(preloads[0].url, "/main.css");
+ assert_eq!(preloads[1].url, "/hero.jpg");
+
+ // Should have buffered tokens.
+ assert!(spec.has_buffered_tokens());
+
+ spec.shutdown();
+ }
+
+ #[test]
+ fn speculative_parser_invalidation() {
+ let mut spec = SpeculativeParser::new();
+
+ let html = r#"
"#;
+ spec.speculate(html, 0);
+
+ // Simulate document.write invalidating speculation.
+ spec.invalidate();
+ assert!(spec.is_invalidated());
+ assert!(!spec.has_buffered_tokens());
+
+ // Results should be discarded.
+ let results = spec.collect_results();
+ assert!(results.is_none());
+
+ spec.shutdown();
+ }
+
+ #[test]
+ fn speculative_parser_token_replay() {
+ let mut spec = SpeculativeParser::new();
+
+ let html = r#"Hello
"#;
+ spec.speculate(html, 0);
+ let _ = spec.collect_results();
+
+ // Replay buffered tokens.
+ let mut replayed = Vec::new();
+ while let Some(token) = spec.next_buffered_token() {
+ replayed.push(token);
+ }
+ assert!(!replayed.is_empty());
+
+ spec.shutdown();
+ }
+
+ #[test]
+ fn speculative_parser_no_tokens_after_invalidation() {
+ let mut spec = SpeculativeParser::new();
+
+ let html = r#"Hello
"#;
+ spec.speculate(html, 0);
+ let _ = spec.collect_results();
+
+ spec.invalidate();
+ assert!(spec.next_buffered_token().is_none());
+
+ spec.shutdown();
+ }
+
+ #[test]
+ fn speculation_hit_no_document_write() {
+ // Simulates: main parser encounters