mod html;
#[cfg(test)]
mod tests;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::slice;
use std::str::FromStr;
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
use std::sync::{mpsc, Arc, Mutex};
use crate::color::{native_color, Theme};
use crate::image::{Image, ImageData, ImageSize};
use crate::opts::ResolvedTheme;
use crate::positioner::{Positioned, Row, Section, Spacer, DEFAULT_MARGIN};
use crate::text::{Text, TextBox};
use crate::utils::{markdown_to_html, Align};
use crate::{Element, ImageCache, InlyneEvent};
use html::{
attr::{self, PrefersColorScheme},
style::{self, FontStyle, FontWeight, Style, TextDecoration},
Attr, Element as InterpreterElement, TagName,
};
use comrak::Anchorizer;
use glyphon::FamilyOwned;
use html5ever::tendril::*;
use html5ever::tokenizer::{
BufferQueue, Tag, TagKind, Token, TokenSink, TokenSinkResult, Tokenizer, TokenizerOpts,
};
use wgpu::TextureFormat;
use winit::event_loop::EventLoopProxy;
use winit::window::Window;
use self::html::{picture, HeaderType, Picture};
struct State {
global_indent: f32,
element_stack: Vec<InterpreterElement>,
text_options: html::TextOptions,
span: Span,
inline_images: Option<(Row, usize)>,
pending_anchor: Option<String>,
pending_list_prefix: Option<String>,
anchorizer: Anchorizer,
}
impl State {
fn with_span_color(span_color: [f32; 4]) -> Self {
Self {
global_indent: 0.0,
element_stack: Vec::new(),
text_options: Default::default(),
span: Span::with_color(span_color),
inline_images: None,
pending_anchor: None,
pending_list_prefix: None,
anchorizer: Default::default(),
}
}
fn element_iter_mut(&mut self) -> slice::IterMut<'_, InterpreterElement> {
self.element_stack.iter_mut()
}
}
struct Span {
color: [f32; 4],
weight: FontWeight,
style: FontStyle,
decor: TextDecoration,
}
impl Span {
fn with_color(color: [f32; 4]) -> Self {
Self {
color,
weight: Default::default(),
style: Default::default(),
decor: Default::default(),
}
}
}
pub trait ImageCallback {
fn loaded_image(&self, src: String, image_data: Arc<Mutex<Option<ImageData>>>);
}
trait WindowInteractor {
fn finished_single_doc(&self);
fn request_redraw(&self);
fn image_callback(&self) -> Box<dyn ImageCallback + Send>;
}
struct EventLoopCallback(EventLoopProxy<InlyneEvent>);
impl ImageCallback for EventLoopCallback {
fn loaded_image(&self, src: String, image_data: Arc<Mutex<Option<ImageData>>>) {
let event = InlyneEvent::LoadedImage(src, image_data);
self.0.send_event(event).unwrap();
}
}
struct LiveWindow {
window: Arc<Window>,
event_proxy: EventLoopProxy<InlyneEvent>,
}
impl WindowInteractor for LiveWindow {
fn request_redraw(&self) {
self.window.request_redraw();
}
fn image_callback(&self) -> Box<dyn ImageCallback + Send> {
Box::new(EventLoopCallback(self.event_proxy.clone()))
}
fn finished_single_doc(&self) {
self.event_proxy
.send_event(InlyneEvent::PositionQueue)
.unwrap();
}
}
pub struct HtmlInterpreter {
element_queue: Arc<Mutex<VecDeque<Element>>>,
current_textbox: TextBox,
hidpi_scale: f32,
theme: Theme,
surface_format: TextureFormat,
state: State,
file_path: PathBuf,
pub should_queue: Arc<AtomicBool>,
stopped: bool,
first_pass: bool,
image_cache: ImageCache,
window: Box<dyn WindowInteractor + Send>,
color_scheme: Option<ResolvedTheme>,
}
impl HtmlInterpreter {
#[allow(clippy::too_many_arguments)]
pub fn new(
window: Arc<Window>,
element_queue: Arc<Mutex<VecDeque<Element>>>,
theme: Theme,
surface_format: TextureFormat,
hidpi_scale: f32,
file_path: PathBuf,
image_cache: ImageCache,
event_proxy: EventLoopProxy<InlyneEvent>,
color_scheme: Option<ResolvedTheme>,
) -> Self {
let live_window = LiveWindow {
window,
event_proxy,
};
Self::new_with_interactor(
element_queue,
theme,
surface_format,
hidpi_scale,
file_path,
image_cache,
Box::new(live_window),
color_scheme,
)
}
#[allow(clippy::too_many_arguments)]
fn new_with_interactor(
element_queue: Arc<Mutex<VecDeque<Element>>>,
theme: Theme,
surface_format: TextureFormat,
hidpi_scale: f32,
file_path: PathBuf,
image_cache: ImageCache,
window: Box<dyn WindowInteractor + Send>,
color_scheme: Option<ResolvedTheme>,
) -> Self {
Self {
window,
element_queue,
current_textbox: TextBox::new(Vec::new(), hidpi_scale),
hidpi_scale,
surface_format,
state: State::with_span_color(native_color(theme.code_color, &surface_format)),
theme,
file_path,
should_queue: Arc::new(AtomicBool::new(true)),
stopped: false,
first_pass: true,
image_cache,
color_scheme,
}
}
pub fn interpret_md(self, receiver: mpsc::Receiver<String>) {
let mut input = BufferQueue::new();
let span_color = self.native_color(self.theme.text_color);
let code_highlighter = self.theme.code_highlighter.clone();
let mut tok = Tokenizer::new(self, TokenizerOpts::default());
for md_string in receiver {
tracing::debug!(
"Received markdown for interpretation: {} bytes",
md_string.len()
);
if tok.sink.should_queue.load(AtomicOrdering::Relaxed) {
tok.sink.state = State::with_span_color(span_color);
tok.sink.current_textbox = TextBox::new(Vec::new(), tok.sink.hidpi_scale);
tok.sink.stopped = false;
let htmlified = markdown_to_html(&md_string, code_highlighter.clone());
input.push_back(
Tendril::from_str(&htmlified)
.unwrap()
.try_reinterpret::<fmt::UTF8>()
.unwrap(),
);
let _ = tok.feed(&mut input);
assert!(input.is_empty());
tok.end();
}
}
}
fn align_or_inherit(&self, maybe_align: Option<Align>) -> Option<Align> {
maybe_align.or_else(|| self.find_current_align())
}
fn find_current_align(&self) -> Option<Align> {
for element in self.state.element_stack.iter().rev() {
if let InterpreterElement::Div(Some(elem_align))
| InterpreterElement::Paragraph(Some(elem_align))
| InterpreterElement::Header(html::Header {
align: Some(elem_align),
..
}) = element
{
return Some(*elem_align);
}
}
None
}
#[must_use]
fn native_color(&self, color: u32) -> [f32; 4] {
native_color(color, &self.surface_format)
}
fn push_current_textbox(&mut self) {
if let Some((row, count)) = self.state.inline_images.take() {
if count == 0 {
self.push_element(row);
self.push_spacer();
} else {
self.state.inline_images = Some((row, count))
}
}
if !self.current_textbox.texts.is_empty() {
let mut empty = true;
for text in &self.current_textbox.texts {
if !text.text.trim().is_empty() {
empty = false;
break;
}
}
if !empty {
self.current_textbox.indent = self.state.global_indent;
let section = self.state.element_iter_mut().rev().find_map(|e| {
if let InterpreterElement::Details(section) = e {
Some(section)
} else {
None
}
});
if let Some(section) = section {
section
.elements
.push(Positioned::new(self.current_textbox.clone()));
} else {
self.push_element(self.current_textbox.clone());
}
}
}
self.current_textbox = TextBox::new(Vec::new(), self.hidpi_scale);
self.current_textbox.indent = self.state.global_indent;
}
fn push_spacer(&mut self) {
self.push_element(Spacer::invisible());
}
fn push_element<I: Into<Element>>(&mut self, element: I) {
self.element_queue.lock().unwrap().push_back(element.into());
if self.first_pass {
self.window.request_redraw()
}
}
fn push_image_from_picture(&mut self, pic: Picture) {
let align = pic.inner.align;
let src = pic.resolve_src(self.color_scheme).to_owned();
let align = align.unwrap_or_default();
let is_url = src.starts_with("http://") || src.starts_with("https://");
let mut image = match self.image_cache.lock().unwrap().get(&src) {
Some(image_data) if is_url => {
Image::from_image_data(image_data.clone(), self.hidpi_scale)
}
_ => Image::from_src(
src.clone(),
self.file_path.clone(),
self.hidpi_scale,
self.window.image_callback(),
)
.unwrap(),
}
.with_align(align);
if let Some(link) = self.state.text_options.link.last() {
image.set_link(link.clone())
}
if let Some(size) = pic.inner.size {
image = image.with_size(size);
}
if align == Align::Left {
if let Some((row, count)) = &mut self.state.inline_images {
row.elements.push(Positioned::new(image));
*count = 1;
} else {
self.state.inline_images = Some((Row::with_image(image, self.hidpi_scale), 1));
}
} else {
self.push_element(image);
self.push_spacer();
}
}
fn process_start_tag(&mut self, tag: Tag) {
let tag_name = match TagName::try_from(&tag.name) {
Ok(name) => name,
Err(name) => {
tracing::info!("Missing implementation for start tag: {name}");
return;
}
};
match tag_name {
TagName::BlockQuote => {
self.push_current_textbox();
self.state.text_options.block_quote += 1;
self.state.global_indent += DEFAULT_MARGIN / 2.;
self.current_textbox
.set_quote_block(self.state.text_options.block_quote);
}
TagName::TableHead | TagName::TableBody => {}
TagName::Table => {
self.push_spacer();
self.state.element_stack.push(InterpreterElement::table());
}
TagName::TableHeader => {
self.state.text_options.bold += 1;
let align = html::find_align(&tag.attrs);
self.current_textbox.set_align_or_default(align);
}
TagName::TableRow => self
.state
.element_stack
.push(InterpreterElement::table_row()),
TagName::TableDataCell => {
let align = html::find_align(&tag.attrs);
self.current_textbox.set_align_or_default(align);
}
TagName::Anchor => {
for attr in attr::Iter::new(&tag.attrs) {
match attr {
Attr::Href(link) => self.state.text_options.link.push(link),
Attr::Anchor(a) => self.current_textbox.set_anchor(a),
_ => {}
}
}
}
TagName::Small => self.state.text_options.small += 1,
TagName::Break => self.push_current_textbox(),
TagName::Underline => self.state.text_options.underline += 1,
TagName::Strikethrough => self.state.text_options.strike_through += 1,
TagName::Picture => {
let mut builder = Picture::builder();
if let Some(align) = self.align_or_inherit(None) {
builder.set_align(align);
}
self.state.element_stack.push(builder.into());
}
TagName::Source => {
let Some(InterpreterElement::Picture(builder)) =
self.state.element_stack.last_mut()
else {
return;
};
let mut media = None;
let mut src_set = None;
for attr in attr::Iter::new(&tag.attrs) {
match attr {
Attr::Media(m) => media = Some(m),
Attr::SrcSet(s) => src_set = Some(s),
_ => {}
}
}
let Some((media, src_set)) = media.zip(src_set) else {
tracing::info!("Skipping <source> tag. Missing either srcset or known media");
return;
};
match media {
PrefersColorScheme(ResolvedTheme::Dark) => builder.set_dark_variant(src_set),
PrefersColorScheme(ResolvedTheme::Light) => builder.set_light_variant(src_set),
}
}
TagName::Image => {
let apply_attrs = |builder: &mut picture::Builder, attr_iter: attr::Iter<'_>| {
for attr in attr_iter {
match attr {
Attr::Align(a) => builder.set_align(a),
Attr::Width(w) => builder.set_size(ImageSize::width(w)),
Attr::Height(h) => builder.set_size(ImageSize::height(h)),
Attr::Src(s) => builder.set_src(s),
_ => {}
}
}
};
if let Some(InterpreterElement::Picture(builder)) =
self.state.element_stack.last_mut()
{
apply_attrs(builder, attr::Iter::new(&tag.attrs));
} else {
let mut builder = Picture::builder();
if let Some(align) = self.align_or_inherit(None) {
builder.set_align(align);
}
apply_attrs(&mut builder, attr::Iter::new(&tag.attrs));
match builder.try_finish() {
Ok(pic) => self.push_image_from_picture(pic),
Err(err) => tracing::warn!("Invalid <img>: {err}"),
}
}
}
TagName::Div | TagName::Paragraph => {
self.push_current_textbox();
let anchor_name = self.state.pending_anchor.take();
if let Some(anchor) = anchor_name {
let anchorized = self.state.anchorizer.anchorize(anchor);
self.current_textbox.set_anchor(format!("#{anchorized}"));
}
let align = html::find_align(&tag.attrs);
if let Some(align) = self.align_or_inherit(align) {
self.current_textbox.set_align(align);
}
self.state.element_stack.push(match tag_name {
TagName::Div => InterpreterElement::Div(align),
TagName::Paragraph => InterpreterElement::Paragraph(align),
_ => unreachable!("Arm matches on Div and Paragraph"),
});
}
TagName::EmphasisOrItalic => self.state.text_options.italic += 1,
TagName::BoldOrStrong => self.state.text_options.bold += 1,
TagName::Code => self.state.text_options.code += 1,
TagName::ListItem => {
for attr in attr::Iter::new(&tag.attrs) {
self.state.pending_anchor = attr.to_anchor();
}
let iter = self.state.element_iter_mut();
let list = iter.rev().find_map(|elem| elem.as_mut_list()).unwrap();
if self.current_textbox.texts.is_empty() {
let prefix = match &mut list.ty {
html::ListType::Ordered(index) => {
*index += 1;
format!("{}. ", *index - 1)
}
html::ListType::Unordered => "· ".to_owned(),
};
self.state.pending_list_prefix = Some(prefix);
}
}
TagName::UnorderedList => {
self.push_current_textbox();
self.state.global_indent += DEFAULT_MARGIN / 2.;
self.state
.element_stack
.push(InterpreterElement::unordered_list());
}
TagName::OrderedList => {
let mut start_index = 1;
for attr in attr::Iter::new(&tag.attrs) {
if let Attr::Start(start) = attr {
start_index = start;
}
}
self.push_current_textbox();
self.state.global_indent += DEFAULT_MARGIN / 2.;
self.state
.element_stack
.push(InterpreterElement::ordered_list(start_index));
}
TagName::Header(header_type) => {
let mut align = html::find_align(&tag.attrs);
align = self.align_or_inherit(align);
self.push_current_textbox();
self.push_spacer();
if let html::HeaderType::H1 = header_type {
self.state.text_options.underline += 1;
}
self.state
.element_stack
.push(InterpreterElement::Header(html::Header::new(
header_type,
align,
)));
self.current_textbox.set_align_or_default(align);
}
TagName::PreformattedText => {
self.push_current_textbox();
let style_str = html::find_style(&tag.attrs).unwrap_or_default();
for style in style::Iter::new(&style_str) {
if let Style::BackgroundColor(color) = style {
let native_color = self.native_color(color);
self.current_textbox.set_background_color(native_color);
}
}
self.state.text_options.pre_formatted += 1;
self.current_textbox.set_code_block(true);
}
TagName::Span => {
let style_str = html::find_style(&tag.attrs).unwrap_or_default();
for style in style::Iter::new(&style_str) {
match style {
Style::Color(color) => {
self.state.span.color = native_color(color, &self.surface_format)
}
Style::FontWeight(weight) => self.state.span.weight = weight,
Style::FontStyle(style) => self.state.span.style = style,
Style::TextDecoration(decor) => self.state.span.decor = decor,
_ => {}
}
}
}
TagName::Input => {
let mut is_checkbox = false;
let mut is_checked = false;
for attr in attr::Iter::new(&tag.attrs) {
match attr {
Attr::IsCheckbox => is_checkbox = true,
Attr::IsChecked => is_checked = true,
_ => {}
}
}
if is_checkbox {
let _ = self.state.pending_list_prefix.take();
self.current_textbox.set_checkbox(is_checked);
self.state.element_stack.push(InterpreterElement::Input);
}
}
TagName::Details => {
self.push_current_textbox();
self.push_spacer();
let section = Section::bare(self.hidpi_scale);
*section.hidden.borrow_mut() = true;
self.state
.element_stack
.push(InterpreterElement::Details(section));
}
TagName::Summary => {
self.push_current_textbox();
self.state.element_stack.push(InterpreterElement::Summary);
}
TagName::HorizontalRuler => {
self.push_element(Spacer::visible());
}
TagName::Section => {}
}
}
fn process_end_tag(&mut self, tag: Tag) {
let tag_name = match TagName::try_from(&tag.name) {
Ok(name) => name,
Err(name) => {
tracing::info!("Missing implementation for end tag: {name}");
return;
}
};
match tag_name {
TagName::Underline => self.state.text_options.underline -= 1,
TagName::Strikethrough => self.state.text_options.strike_through -= 1,
TagName::Small => self.state.text_options.small -= 1,
TagName::TableHead | TagName::TableBody => {}
TagName::TableHeader => {
let iter = self.state.element_iter_mut();
let table = iter.rev().find_map(|elem| elem.as_mut_table()).unwrap();
table.push_header(self.current_textbox.clone());
self.current_textbox.texts.clear();
self.state.text_options.bold -= 1;
}
TagName::TableDataCell => {
let table_row = self.state.element_stack.last_mut();
if let Some(InterpreterElement::TableRow(ref mut row)) = table_row {
row.push(self.current_textbox.clone());
}
self.current_textbox.texts.clear();
}
TagName::TableRow => {
let table_row = self.state.element_stack.pop();
for mut element in self.state.element_iter_mut().rev() {
if let InterpreterElement::Table(table) = &mut element {
if let Some(InterpreterElement::TableRow(row)) = table_row {
if !row.is_empty() {
table.push_row(row);
}
break;
}
}
}
self.current_textbox.texts.clear();
}
TagName::Table => {
if let Some(InterpreterElement::Table(table)) = self.state.element_stack.pop() {
self.push_element(table);
self.push_spacer();
}
}
TagName::Anchor => {
self.state.text_options.link.pop();
}
TagName::Code => self.state.text_options.code -= 1,
TagName::Div | TagName::Paragraph => {
self.push_current_textbox();
if tag_name == TagName::Paragraph {
self.push_spacer();
}
self.state.element_stack.pop();
}
TagName::EmphasisOrItalic => self.state.text_options.italic -= 1,
TagName::BoldOrStrong => self.state.text_options.bold -= 1,
TagName::Header(header_type) => {
if header_type == HeaderType::H1 {
self.state.text_options.underline -= 1;
}
let anchor_name = self
.current_textbox
.texts
.iter()
.flat_map(|t| t.text.chars())
.collect();
let anchorized = self.state.anchorizer.anchorize(anchor_name);
self.current_textbox.set_anchor(format!("#{anchorized}"));
self.push_current_textbox();
self.push_spacer();
self.state.element_stack.pop();
}
TagName::ListItem => {
let _ = self.state.pending_anchor.take();
self.push_current_textbox();
}
TagName::Input => {
self.push_current_textbox();
self.state.element_stack.pop();
}
TagName::UnorderedList | TagName::OrderedList => {
self.push_current_textbox();
self.state.global_indent -= DEFAULT_MARGIN / 2.;
self.state.element_stack.pop();
if self.state.global_indent == 0. {
self.push_spacer();
}
}
TagName::PreformattedText => {
self.push_current_textbox();
self.push_spacer();
self.state.text_options.pre_formatted -= 1;
self.current_textbox.set_code_block(false);
}
TagName::BlockQuote => {
self.push_current_textbox();
self.state.text_options.block_quote -= 1;
self.state.global_indent -= DEFAULT_MARGIN / 2.;
self.current_textbox.clear_quote_block();
if self.state.global_indent == 0. {
self.push_spacer();
}
}
TagName::Span => {
let color = self.native_color(self.theme.code_color);
self.state.span = Span::with_color(color);
}
TagName::Details => {
self.push_current_textbox();
if let Some(InterpreterElement::Details(section)) = self.state.element_stack.pop() {
self.push_element(section);
}
self.push_spacer();
}
TagName::Summary => {
for mut element in self.state.element_iter_mut().rev() {
if let InterpreterElement::Details(section) = &mut element {
*section.summary = Some(Positioned::new(self.current_textbox.clone()));
self.current_textbox.texts.clear();
break;
}
}
self.state.element_stack.pop();
}
TagName::Picture => {
let picture_on_top = self
.state
.element_stack
.last()
.map_or(false, |e| e.is_picture());
if !picture_on_top {
tracing::warn!("Element stack is muddled");
return;
}
let Some(InterpreterElement::Picture(builder)) = self.state.element_stack.pop()
else {
unreachable!("Just checked");
};
match builder.try_finish() {
Ok(pic) => self.push_image_from_picture(pic),
Err(err) => tracing::warn!("Invalid <picture>: {err}"),
}
}
TagName::HorizontalRuler
| TagName::Break
| TagName::Image
| TagName::Section
| TagName::Source => {}
}
}
fn process_character_tokens(&mut self, mut str: String) {
let text_native_color = self.native_color(self.theme.text_color);
if str == "\n" {
if self.state.text_options.pre_formatted >= 1 {
self.current_textbox.texts.push(Text::new(
"\n".to_string(),
self.hidpi_scale,
text_native_color,
));
}
if let Some(last_text) = self.current_textbox.texts.last() {
if let Some(last_char) = last_text.text.chars().last() {
if !last_char.is_whitespace() {
self.current_textbox.texts.push(Text::new(
" ".to_string(),
self.hidpi_scale,
text_native_color,
));
}
}
}
if let Some((row, newline_counter)) = self.state.inline_images.take() {
if newline_counter == 0 {
self.push_element(row);
self.push_spacer();
} else {
self.state.inline_images = Some((row, newline_counter - 1));
}
}
} else if str.trim().is_empty() && self.state.text_options.pre_formatted == 0 {
if let Some(last_text) = self.current_textbox.texts.last() {
if let Some(last_char) = last_text.text.chars().last() {
if !last_char.is_whitespace() {
self.current_textbox.texts.push(Text::new(
" ".to_string(),
self.hidpi_scale,
text_native_color,
));
}
}
}
} else {
if self.current_textbox.texts.is_empty() && self.state.text_options.pre_formatted == 0 {
str = str.trim_start().to_owned();
}
let mut text = Text::new(str, self.hidpi_scale, text_native_color);
if let Some(prefix) = self.state.pending_list_prefix.take() {
if self.current_textbox.texts.is_empty() {
self.current_textbox.texts.push(
Text::new(prefix, self.hidpi_scale, text_native_color).make_bold(true),
);
}
}
if self.state.text_options.block_quote >= 1 {
self.current_textbox
.set_quote_block(self.state.text_options.block_quote);
}
if self.state.text_options.code >= 1 {
text = text
.with_color(self.state.span.color)
.with_family(FamilyOwned::Monospace);
if self.state.span.weight == FontWeight::Bold {
text = text.make_bold(true);
}
if self.state.span.style == FontStyle::Italic {
text = text.make_italic(true);
}
if self.state.span.decor == TextDecoration::Underline {
text = text.make_underlined(true);
}
}
for elem in self.state.element_stack.iter().rev() {
if let InterpreterElement::Header(header) = elem {
self.current_textbox.font_size = header.ty.text_size();
text = text.make_bold(true);
break;
}
}
if let Some(link) = self.state.text_options.link.last() {
text = text.with_link((*link).clone());
text = text.with_color(self.native_color(self.theme.link_color));
}
if self.state.text_options.bold >= 1 {
text = text.make_bold(true);
}
if self.state.text_options.italic >= 1 {
text = text.make_italic(true);
}
if self.state.text_options.underline >= 1 {
text = text.make_underlined(true);
}
if self.state.text_options.strike_through >= 1 {
text = text.make_striked(true);
}
if self.state.text_options.small >= 1 {
self.current_textbox.font_size = 12.;
}
self.current_textbox.texts.push(text);
}
}
}
impl TokenSink for HtmlInterpreter {
type Handle = ();
fn process_token(&mut self, token: Token, _line_number: u64) -> TokenSinkResult<()> {
if !self.should_queue.load(AtomicOrdering::Relaxed) {
self.stopped = true;
}
if self.stopped {
return TokenSinkResult::Continue;
}
match token {
Token::TagToken(tag) => match tag.kind {
TagKind::StartTag => self.process_start_tag(tag),
TagKind::EndTag => self.process_end_tag(tag),
},
Token::CharacterTokens(str) => self.process_character_tokens(str.to_string()),
Token::EOFToken => {
self.push_current_textbox();
self.should_queue.store(false, AtomicOrdering::Relaxed);
self.first_pass = false;
self.window.finished_single_doc();
}
Token::ParseError(err) => tracing::warn!("HTML parser emitted error: {err}"),
Token::DoctypeToken(_) | Token::CommentToken(_) | Token::NullCharacterToken => {}
}
TokenSinkResult::Continue
}
}