1use crate::token::Span;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum HtmlToken {
26 StartTag {
29 name: String,
30 attrs: Vec<HtmlAttr>,
31 self_closing: bool,
32 span: Span,
33 },
34 EndTag { name: String, span: Span },
36 Text { text: String, span: Span },
38 Brace { inner: String, span: Span },
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct HtmlAttr {
45 pub name: String,
46 pub value: Option<AttrValue>,
48 pub span: Span,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum AttrValue {
54 Static(String),
56 Parts(Vec<AttrPart>),
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum AttrPart {
63 Text(String),
64 Brace { inner: String, span: Span },
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum HtmlErrorKind {
69 UnterminatedTag,
71 UnterminatedAttributeValue,
73 UnterminatedBrace,
75 StrayCloseBrace,
77 UnterminatedComment,
79 ForbiddenElement(String),
81 BadAttributeName(String),
83 BadTagName,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct HtmlError {
89 pub kind: HtmlErrorKind,
90 pub span: Span,
91}
92
93impl HtmlError {
94 pub fn message(&self) -> String {
96 match &self.kind {
97 HtmlErrorKind::UnterminatedTag => "This tag never closes — I reached the end of the view still inside `<…`. Finish it with `>` (or `/>` for an element with no children).".into(),
98 HtmlErrorKind::UnterminatedAttributeValue => "This attribute value opens a quote that never closes. Close it with the same quote, e.g. `class=\"card\"`.".into(),
99 HtmlErrorKind::UnterminatedBrace => "This `{` opens a hole or template tag that never closes. Every `{…}` needs its `}` — inside it, LOGOS strings are quoted, so a `}` in a string does not count.".into(),
100 HtmlErrorKind::StrayCloseBrace => "This `}` closes nothing. To show a literal brace, write it as a string hole: `{\"}\"}`.".into(),
101 HtmlErrorKind::UnterminatedComment => "This `<!--` comment never reaches its `-->`.".into(),
102 HtmlErrorKind::ForbiddenElement(name) => match name.as_str() {
103 "script" => "A view cannot contain `<script>`: behavior belongs in `Actions:` (the compiler generates the browser code). Write the logic as an action and attach it with `on-click={…}`.".into(),
104 "style" => "A view cannot contain `<style>`: put the rules in the component's `Style:` section — they are scoped to this component automatically.".into(),
105 _ => "A view is a fragment, never a whole document: leave out `<!doctype>` and `<html>`. The page shell comes from the layout the page `{extends}`.".into(),
106 },
107 HtmlErrorKind::BadAttributeName(found) => format!("`{found}` cannot start an attribute. Attributes are `name`, `name=\"value\"`, `name={{expression}}`, or a directive like `on-click={{action}}`."),
108 HtmlErrorKind::BadTagName => "A `<` must start a tag name (`<div>`, `<TodoRow>`), an end tag (`</div>`) or a comment (`<!-- -->`). To show a literal `<`, write `<`.".into(),
109 }
110 }
111}
112
113pub const VOID_ELEMENTS: &[&str] = &[
115 "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
116 "track", "wbr",
117];
118
119pub fn is_void(name: &str) -> bool {
120 VOID_ELEMENTS.iter().any(|v| v.eq_ignore_ascii_case(name))
121}
122
123pub const HTML_ELEMENTS: &[&str] = &[
126 "a",
127 "abbr",
128 "address",
129 "area",
130 "article",
131 "aside",
132 "audio",
133 "b",
134 "base",
135 "bdi",
136 "bdo",
137 "blockquote",
138 "body",
139 "br",
140 "button",
141 "canvas",
142 "caption",
143 "cite",
144 "code",
145 "col",
146 "colgroup",
147 "data",
148 "datalist",
149 "dd",
150 "del",
151 "details",
152 "dfn",
153 "dialog",
154 "div",
155 "dl",
156 "dt",
157 "em",
158 "embed",
159 "fieldset",
160 "figcaption",
161 "figure",
162 "footer",
163 "form",
164 "h1",
165 "h2",
166 "h3",
167 "h4",
168 "h5",
169 "h6",
170 "head",
171 "header",
172 "hgroup",
173 "hr",
174 "html",
175 "i",
176 "iframe",
177 "img",
178 "input",
179 "ins",
180 "kbd",
181 "label",
182 "legend",
183 "li",
184 "link",
185 "main",
186 "map",
187 "mark",
188 "math",
189 "menu",
190 "meta",
191 "meter",
192 "nav",
193 "noscript",
194 "object",
195 "ol",
196 "optgroup",
197 "option",
198 "output",
199 "p",
200 "picture",
201 "pre",
202 "progress",
203 "q",
204 "rp",
205 "rt",
206 "ruby",
207 "s",
208 "samp",
209 "script",
210 "search",
211 "section",
212 "select",
213 "slot",
214 "small",
215 "source",
216 "span",
217 "strong",
218 "style",
219 "sub",
220 "summary",
221 "sup",
222 "svg",
223 "table",
224 "tbody",
225 "td",
226 "template",
227 "textarea",
228 "tfoot",
229 "th",
230 "thead",
231 "time",
232 "title",
233 "tr",
234 "track",
235 "u",
236 "ul",
237 "var",
238 "video",
239 "wbr",
240];
241
242pub fn is_html_element(name: &str) -> bool {
243 HTML_ELEMENTS.iter().any(|e| e.eq_ignore_ascii_case(name))
244}
245
246pub fn is_selector_shaped(target: &str) -> bool {
250 let t = target.trim();
251 t.starts_with('#') || t.starts_with('.') || t.starts_with('[') || is_html_element(t)
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub enum EventModifier {
258 Behaviour(Behaviour),
260 Key(KeyName),
262 System(SystemKey),
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268pub enum Behaviour {
269 Prevent,
271 Stop,
273 Once,
275 Capture,
277 Passive,
279}
280
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum KeyName {
284 Named(&'static str),
286 Char(char),
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub enum SystemKey {
293 Ctrl,
294 Alt,
295 Shift,
296 Meta,
297}
298
299pub const BEHAVIOUR_MODIFIERS: &[(&str, Behaviour)] = &[
301 ("prevent", Behaviour::Prevent),
302 ("stop", Behaviour::Stop),
303 ("once", Behaviour::Once),
304 ("capture", Behaviour::Capture),
305 ("passive", Behaviour::Passive),
306];
307
308pub const KEY_MODIFIERS: &[(&str, &str)] = &[
312 ("enter", "Enter"),
313 ("escape", "Escape"),
314 ("space", " "),
315 ("tab", "Tab"),
316 ("backspace", "Backspace"),
317 ("delete", "Delete"),
318 ("arrowup", "ArrowUp"),
319 ("arrowdown", "ArrowDown"),
320 ("arrowleft", "ArrowLeft"),
321 ("arrowright", "ArrowRight"),
322 ("home", "Home"),
323 ("end", "End"),
324 ("pageup", "PageUp"),
325 ("pagedown", "PageDown"),
326];
327
328pub const SYSTEM_KEY_MODIFIERS: &[(&str, SystemKey)] = &[
330 ("ctrl", SystemKey::Ctrl),
331 ("alt", SystemKey::Alt),
332 ("shift", SystemKey::Shift),
333 ("meta", SystemKey::Meta),
334];
335
336pub const KEY_EVENTS: &[&str] = &["keydown", "keyup", "keypress"];
338
339pub fn is_key_event(event: &str) -> bool {
341 KEY_EVENTS.iter().any(|e| e.eq_ignore_ascii_case(event))
342}
343
344impl EventModifier {
345 pub fn classify(word: &str) -> Option<EventModifier> {
350 fn row<T: Copy>(table: &[(&'static str, T)], word: &str) -> Option<T> {
351 table
352 .iter()
353 .find(|(w, _)| w.eq_ignore_ascii_case(word))
354 .map(|(_, v)| *v)
355 }
356 if let Some(b) = row(BEHAVIOUR_MODIFIERS, word) {
357 return Some(EventModifier::Behaviour(b));
358 }
359 if let Some(k) = row(KEY_MODIFIERS, word) {
360 return Some(EventModifier::Key(KeyName::Named(k)));
361 }
362 if let Some(s) = row(SYSTEM_KEY_MODIFIERS, word) {
363 return Some(EventModifier::System(s));
364 }
365 let mut chars = word.chars();
366 match (chars.next(), chars.next()) {
367 (Some(c), None) if !c.is_control() && !c.is_whitespace() => {
368 Some(EventModifier::Key(KeyName::Char(c)))
369 }
370 _ => None,
371 }
372 }
373
374 pub fn word(&self) -> String {
376 match self {
377 EventModifier::Behaviour(b) => b.word().to_string(),
378 EventModifier::Key(KeyName::Named(key)) => KEY_MODIFIERS
379 .iter()
380 .find(|(_, k)| k == key)
381 .map(|(w, _)| w.to_string())
382 .unwrap_or_else(|| key.to_ascii_lowercase()),
383 EventModifier::Key(KeyName::Char(c)) => c.to_string(),
384 EventModifier::System(s) => s.word().to_string(),
385 }
386 }
387
388 pub fn key_of(modifiers: &[EventModifier]) -> Option<String> {
391 modifiers.iter().find_map(|m| match m {
392 EventModifier::Key(k) => Some(k.event_key()),
393 _ => None,
394 })
395 }
396
397 pub fn host_listen_bits(modifiers: &[EventModifier]) -> u8 {
401 modifiers
402 .iter()
403 .filter_map(|m| match m {
404 EventModifier::Behaviour(Behaviour::Once) => None,
405 EventModifier::Behaviour(b) => Some(b.listen_bit()),
406 EventModifier::Key(_) | EventModifier::System(_) => None,
407 })
408 .fold(0, |bits, bit| bits | bit)
409 }
410
411 pub fn every_word() -> impl Iterator<Item = &'static str> {
413 BEHAVIOUR_MODIFIERS
414 .iter()
415 .map(|(w, _)| *w)
416 .chain(KEY_MODIFIERS.iter().map(|(w, _)| *w))
417 .chain(SYSTEM_KEY_MODIFIERS.iter().map(|(w, _)| *w))
418 }
419}
420
421impl Behaviour {
422 pub fn word(self) -> &'static str {
423 BEHAVIOUR_MODIFIERS
424 .iter()
425 .find(|(_, b)| *b == self)
426 .map(|(w, _)| *w)
427 .unwrap_or("")
428 }
429
430 pub fn listen_bit(self) -> u8 {
433 match self {
434 Behaviour::Prevent => 1 << 0,
435 Behaviour::Stop => 1 << 1,
436 Behaviour::Once => 1 << 2,
437 Behaviour::Capture => 1 << 3,
438 Behaviour::Passive => 1 << 4,
439 }
440 }
441}
442
443impl SystemKey {
444 pub fn word(self) -> &'static str {
445 SYSTEM_KEY_MODIFIERS
446 .iter()
447 .find(|(_, k)| *k == self)
448 .map(|(w, _)| *w)
449 .unwrap_or("")
450 }
451}
452
453impl KeyName {
454 pub fn event_key(&self) -> String {
456 match self {
457 KeyName::Named(k) => k.to_string(),
458 KeyName::Char(c) => c.to_string(),
459 }
460 }
461}
462
463#[derive(Debug, Clone, PartialEq, Eq)]
465pub enum ModifierError {
466 Unknown {
468 word: String,
469 suggestion: Option<&'static str>,
470 },
471 KeyOnNonKeyEvent { word: String, event: String },
473 TwoKeys { first: String, second: String },
475 SystemKeyNotDelivered { word: String },
478 PreventOnPassive { event: String },
481}
482
483impl ModifierError {
484 pub fn message(&self) -> String {
485 let taught = "The qualifiers are `.prevent`, `.stop`, `.once`, `.capture`, `.passive`, and on a \
486 key event one key: `.enter`, `.escape`, `.space`, `.tab`, `.backspace`, `.delete`, \
487 `.arrowup`/`.arrowdown`/`.arrowleft`/`.arrowright`, `.home`, `.end`, `.pageup`, \
488 `.pagedown`, or a single character (`.a`).";
489 match self {
490 ModifierError::Unknown { word, suggestion: Some(s) } => format!(
491 "`.{word}` is not an event qualifier — did you mean `.{s}`? {taught}"
492 ),
493 ModifierError::Unknown { word, suggestion: None } => {
494 format!("`.{word}` is not an event qualifier. {taught}")
495 }
496 ModifierError::KeyOnNonKeyEvent { word, event } => format!(
497 "`.{word}` names a key, and `{event}` is not a key event, so the handler could \
498 never match. Put key qualifiers on `on-keydown`, `on-keyup` or `on-keypress`: \
499 `on-keydown.{word}={{…}}`."
500 ),
501 ModifierError::TwoKeys { first, second } => format!(
502 "`.{first}` and `.{second}` both name a key, and a handler answers one key. Write \
503 one handler per key: `on-keydown.{first}={{…}}` and `on-keydown.{second}={{…}}`."
504 ),
505 ModifierError::SystemKeyNotDelivered { word } => format!(
506 "`.{word}` waits for a modifier key to be held, and no tier delivers modifier-key \
507 state to a handler yet, so this handler could never be told the key was down. \
508 Handle the key on its own (`on-keydown.enter={{…}}`)."
509 ),
510 ModifierError::PreventOnPassive { event } => format!(
511 "`.prevent` and `.passive` contradict each other on one handler: a passive listener \
512 promises never to cancel the event, so a browser ignores `.prevent` on it and the \
513 default action runs anyway. Did you mean `on-{event}.prevent={{…}}` (cancel the \
514 default action) or `on-{event}.passive={{…}}` (never cancel, so the browser acts \
515 without waiting for the handler)?"
516 ),
517 }
518 }
519}
520
521pub fn classify_modifiers(
523 event: &str,
524 words: &[&str],
525) -> Result<Vec<EventModifier>, (usize, ModifierError)> {
526 let mut out: Vec<EventModifier> = Vec::with_capacity(words.len());
527 for (at, word) in words.iter().enumerate() {
528 let Some(m) = EventModifier::classify(word) else {
529 let suggestion = crate::suggest::did_you_mean(word, EventModifier::every_word());
530 return Err((
531 at,
532 ModifierError::Unknown {
533 word: word.to_string(),
534 suggestion,
535 },
536 ));
537 };
538 match m {
539 EventModifier::System(_) => {
540 return Err((
541 at,
542 ModifierError::SystemKeyNotDelivered {
543 word: word.to_string(),
544 },
545 ))
546 }
547 EventModifier::Key(_) if !is_key_event(event) => {
548 return Err((
549 at,
550 ModifierError::KeyOnNonKeyEvent {
551 word: word.to_string(),
552 event: event.to_string(),
553 },
554 ))
555 }
556 EventModifier::Key(k) => {
557 if let Some(EventModifier::Key(prior)) =
558 out.iter().find(|p| matches!(p, EventModifier::Key(_)))
559 {
560 if *prior != k {
561 return Err((
562 at,
563 ModifierError::TwoKeys {
564 first: EventModifier::Key(*prior).word(),
565 second: word.to_string(),
566 },
567 ));
568 }
569 }
570 }
571 EventModifier::Behaviour(b @ (Behaviour::Prevent | Behaviour::Passive)) => {
572 let other = if b == Behaviour::Prevent {
573 Behaviour::Passive
574 } else {
575 Behaviour::Prevent
576 };
577 if out.contains(&EventModifier::Behaviour(other)) {
578 return Err((
579 at,
580 ModifierError::PreventOnPassive {
581 event: event.to_string(),
582 },
583 ));
584 }
585 }
586 EventModifier::Behaviour(Behaviour::Stop | Behaviour::Once | Behaviour::Capture) => {}
587 }
588 if !out.contains(&m) {
589 out.push(m);
590 }
591 }
592 Ok(out)
593}
594
595pub fn is_raw_text(name: &str) -> bool {
607 name.eq_ignore_ascii_case("style") || name.eq_ignore_ascii_case("script")
608}
609
610pub const BOOLEAN_ATTRIBUTES: &[&str] = &[
617 "allowfullscreen",
618 "async",
619 "autofocus",
620 "autoplay",
621 "checked",
622 "controls",
623 "default",
624 "defer",
625 "disabled",
626 "formnovalidate",
627 "hidden",
628 "inert",
629 "ismap",
630 "itemscope",
631 "loop",
632 "multiple",
633 "muted",
634 "novalidate",
635 "open",
636 "playsinline",
637 "readonly",
638 "required",
639 "reversed",
640 "selected",
641];
642
643pub fn is_boolean_attribute(name: &str) -> bool {
644 BOOLEAN_ATTRIBUTES
645 .iter()
646 .any(|v| v.eq_ignore_ascii_case(name))
647}
648
649pub const TEXT_ESCAPES: &[(char, &str)] = &[('&', "&"), ('<', "<"), ('>', ">")];
660
661pub const ATTR_ESCAPES: &[(char, &str)] = &[
666 ('&', "&"),
667 ('<', "<"),
668 ('>', ">"),
669 ('"', """),
670];
671
672pub fn escape_text(s: &str) -> String {
684 escape_with(s, TEXT_ESCAPES)
685}
686
687pub fn escape_attr(s: &str) -> String {
689 escape_with(s, ATTR_ESCAPES)
690}
691
692fn escape_with(s: &str, table: &[(char, &str)]) -> String {
693 let mut out = String::with_capacity(s.len());
694 for c in s.chars() {
695 match table.iter().find(|(k, _)| *k == c) {
696 Some((_, replacement)) => out.push_str(replacement),
697 None => out.push(c),
698 }
699 }
700 out
701}
702
703pub const URL_ATTRIBUTES: &[&str] = &[
704 "href",
705 "src",
706 "action",
707 "formaction",
708 "poster",
709 "data",
710 "cite",
711 "ping",
712 "background",
713];
714
715pub fn is_url_attribute(name: &str) -> bool {
716 URL_ATTRIBUTES.iter().any(|v| v.eq_ignore_ascii_case(name))
717}
718
719pub const EXECUTABLE_SCHEMES: &[&str] = &["javascript:", "data:", "vbscript:"];
724
725pub fn is_executable_url(url: &str) -> bool {
733 let flat: String = url
734 .chars()
735 .filter(|c| !c.is_whitespace() && !c.is_control())
736 .collect::<String>()
737 .to_ascii_lowercase();
738 EXECUTABLE_SCHEMES.iter().any(|s| flat.starts_with(s))
739}
740
741pub fn is_component_name(name: &str) -> bool {
744 name.chars().next().is_some_and(|c| c.is_ascii_uppercase())
745}
746
747pub fn tokenize(src: &str) -> Result<Vec<HtmlToken>, HtmlError> {
749 let mut out = Vec::new();
750 let b = src.as_bytes();
751 let mut i = 0;
752 while i < b.len() {
753 match b[i] {
754 b'<' => {
755 if src[i..].starts_with("<!--") {
756 match src[i + 4..].find("-->") {
757 Some(k) => i = i + 4 + k + 3,
758 None => {
759 return Err(HtmlError {
760 kind: HtmlErrorKind::UnterminatedComment,
761 span: Span::new(i, src.len()),
762 })
763 }
764 }
765 } else if src[i..].starts_with("<!") {
766 let name: String = src[i + 2..]
767 .chars()
768 .take_while(|c| c.is_ascii_alphabetic())
769 .collect::<String>()
770 .to_ascii_lowercase();
771 let name = if name.is_empty() {
772 "doctype".to_string()
773 } else {
774 name
775 };
776 let span = Span::new(i, (i + 2 + name.len()).min(src.len()));
777 return Err(HtmlError {
778 kind: HtmlErrorKind::ForbiddenElement(name),
779 span,
780 });
781 } else if src[i..].starts_with("</") {
782 let (tok, next) = end_tag(src, i)?;
783 out.push(tok);
784 i = next;
785 } else {
786 let (tok, next) = start_tag(src, i)?;
787 out.push(tok);
788 i = next;
789 }
790 }
791 b'{' => {
792 let end = scan_brace(src, i)?;
793 out.push(HtmlToken::Brace {
794 inner: src[i + 1..end - 1].to_string(),
795 span: Span::new(i, end),
796 });
797 i = end;
798 }
799 b'}' => {
800 return Err(HtmlError {
801 kind: HtmlErrorKind::StrayCloseBrace,
802 span: Span::new(i, i + 1),
803 })
804 }
805 _ => {
806 let start = i;
807 while i < b.len() && b[i] != b'<' && b[i] != b'{' && b[i] != b'}' {
808 i += 1;
809 }
810 out.push(HtmlToken::Text {
811 text: decode_entities(&src[start..i]),
812 span: Span::new(start, i),
813 });
814 }
815 }
816 }
817 Ok(out)
818}
819
820pub fn scan_brace(src: &str, open: usize) -> Result<usize, HtmlError> {
823 debug_assert_eq!(src.as_bytes().get(open), Some(&b'{'));
824 let b = src.as_bytes();
825 let mut depth = 0usize;
826 let mut i = open;
827 while i < b.len() {
828 match b[i] {
829 b'"' => {
830 i += 1;
831 while i < b.len() && b[i] != b'"' {
832 if b[i] == b'\\' {
833 i += 1;
834 }
835 i += 1;
836 }
837 if i >= b.len() {
838 return Err(HtmlError {
839 kind: HtmlErrorKind::UnterminatedBrace,
840 span: Span::new(open, open + 1),
841 });
842 }
843 }
844 b'{' => depth += 1,
845 b'}' => {
846 depth -= 1;
847 if depth == 0 {
848 return Ok(i + 1);
849 }
850 }
851 _ => {}
852 }
853 i += 1;
854 }
855 Err(HtmlError {
856 kind: HtmlErrorKind::UnterminatedBrace,
857 span: Span::new(open, open + 1),
858 })
859}
860
861fn is_name_start(c: char) -> bool {
862 c.is_ascii_alphabetic()
863}
864
865fn is_name_char(c: char) -> bool {
866 c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ':' || c == '.'
867}
868
869fn end_tag(src: &str, at: usize) -> Result<(HtmlToken, usize), HtmlError> {
870 let mut i = at + 2;
872 let name_start = i;
873 let mut chars = src[i..].char_indices().peekable();
874 if !chars.peek().is_some_and(|(_, c)| is_name_start(*c)) {
875 return Err(HtmlError {
876 kind: HtmlErrorKind::BadTagName,
877 span: Span::new(at, (at + 2).min(src.len())),
878 });
879 }
880 for (k, c) in src[i..].char_indices() {
881 if is_name_char(c) {
882 i = name_start + k + c.len_utf8();
883 } else {
884 break;
885 }
886 }
887 let name = src[name_start..i].to_string();
888 while i < src.len() && src.as_bytes()[i].is_ascii_whitespace() {
889 i += 1;
890 }
891 if i < src.len() && src.as_bytes()[i] == b'>' {
892 Ok((
893 HtmlToken::EndTag {
894 name,
895 span: Span::new(at, i + 1),
896 },
897 i + 1,
898 ))
899 } else {
900 Err(HtmlError {
901 kind: HtmlErrorKind::UnterminatedTag,
902 span: Span::new(at, src.len().min(i.max(at + 2))),
903 })
904 }
905}
906
907fn start_tag(src: &str, at: usize) -> Result<(HtmlToken, usize), HtmlError> {
908 let b = src.as_bytes();
909 let mut i = at + 1;
910 let name_start = i;
911 if !src[i..].chars().next().is_some_and(is_name_start) {
912 return Err(HtmlError {
913 kind: HtmlErrorKind::BadTagName,
914 span: Span::new(at, (at + 1).min(src.len())),
915 });
916 }
917 for (k, c) in src[i..].char_indices() {
918 if is_name_char(c) {
919 i = name_start + k + c.len_utf8();
920 } else {
921 break;
922 }
923 }
924 let name = src[name_start..i].to_string();
925 let lower = name.to_ascii_lowercase();
926 if lower == "script" || lower == "style" {
927 return Err(HtmlError {
928 kind: HtmlErrorKind::ForbiddenElement(lower),
929 span: Span::new(at, i),
930 });
931 }
932 let mut attrs = Vec::new();
933 let mut self_closing = false;
934 loop {
935 while i < b.len() && b[i].is_ascii_whitespace() {
936 i += 1;
937 }
938 if i >= b.len() {
939 return Err(HtmlError {
940 kind: HtmlErrorKind::UnterminatedTag,
941 span: Span::new(at, src.len()),
942 });
943 }
944 if src[i..].starts_with("/>") {
945 self_closing = true;
946 i += 2;
947 break;
948 }
949 if b[i] == b'>' {
950 i += 1;
951 break;
952 }
953 if b[i] == b'/' {
954 i += 1;
956 continue;
957 }
958 let attr_start = i;
960 while i < b.len()
961 && !b[i].is_ascii_whitespace()
962 && b[i] != b'='
963 && b[i] != b'>'
964 && b[i] != b'/'
965 && b[i] != b'<'
966 && b[i] != b'{'
967 && b[i] != b'}'
968 && b[i] != b'"'
969 && b[i] != b'\''
970 {
971 i += 1;
972 }
973 if i == attr_start {
974 let bad: String = src[i..].chars().take(1).collect();
975 return Err(HtmlError {
976 kind: HtmlErrorKind::BadAttributeName(bad),
977 span: Span::new(i, (i + 1).min(src.len())),
978 });
979 }
980 let attr_name = src[attr_start..i].to_string();
981 let mut j = i;
983 while j < b.len() && b[j].is_ascii_whitespace() {
984 j += 1;
985 }
986 let value = if j < b.len() && b[j] == b'=' {
987 j += 1;
988 while j < b.len() && b[j].is_ascii_whitespace() {
989 j += 1;
990 }
991 if j >= b.len() {
992 return Err(HtmlError {
993 kind: HtmlErrorKind::UnterminatedTag,
994 span: Span::new(at, src.len()),
995 });
996 }
997 let (value, next) = attr_value(src, j)?;
998 i = next;
999 Some(value)
1000 } else {
1001 None
1002 };
1003 attrs.push(HtmlAttr {
1004 name: attr_name,
1005 value,
1006 span: Span::new(attr_start, i),
1007 });
1008 }
1009 let void = !is_component_name(&name) && is_void(&name);
1019 Ok((
1020 HtmlToken::StartTag {
1021 name,
1022 attrs,
1023 self_closing: self_closing || void,
1024 span: Span::new(at, i),
1025 },
1026 i,
1027 ))
1028}
1029
1030fn attr_value(src: &str, at: usize) -> Result<(AttrValue, usize), HtmlError> {
1032 let b = src.as_bytes();
1033 match b[at] {
1034 q @ (b'"' | b'\'') => {
1035 let mut i = at + 1;
1036 let mut parts: Vec<AttrPart> = Vec::new();
1037 let mut text_start = i;
1038 loop {
1039 if i >= b.len() {
1040 return Err(HtmlError {
1041 kind: HtmlErrorKind::UnterminatedAttributeValue,
1042 span: Span::new(at, src.len()),
1043 });
1044 }
1045 if b[i] == q {
1046 if i > text_start {
1047 parts.push(AttrPart::Text(decode_entities(&src[text_start..i])));
1048 }
1049 let is_static = parts.iter().all(|p| matches!(p, AttrPart::Text(_)));
1050 let value = if is_static {
1051 let s: String = parts
1052 .into_iter()
1053 .map(|p| match p {
1054 AttrPart::Text(t) => t,
1055 _ => unreachable!(),
1056 })
1057 .collect();
1058 AttrValue::Static(s)
1059 } else {
1060 AttrValue::Parts(parts)
1061 };
1062 return Ok((value, i + 1));
1063 }
1064 if b[i] == b'{' {
1065 if i > text_start {
1066 parts.push(AttrPart::Text(decode_entities(&src[text_start..i])));
1067 }
1068 let end = scan_brace(src, i)?;
1069 parts.push(AttrPart::Brace {
1070 inner: src[i + 1..end - 1].to_string(),
1071 span: Span::new(i, end),
1072 });
1073 i = end;
1074 text_start = i;
1075 continue;
1076 }
1077 if b[i] == b'\n' && src[at + 1..i].contains('>') {
1078 return Err(HtmlError {
1080 kind: HtmlErrorKind::UnterminatedAttributeValue,
1081 span: Span::new(at, i),
1082 });
1083 }
1084 i += 1;
1085 }
1086 }
1087 b'{' => {
1088 let end = scan_brace(src, at)?;
1089 Ok((
1090 AttrValue::Parts(vec![AttrPart::Brace {
1091 inner: src[at + 1..end - 1].to_string(),
1092 span: Span::new(at, end),
1093 }]),
1094 end,
1095 ))
1096 }
1097 _ => {
1098 let mut i = at;
1099 while i < b.len()
1103 && !b[i].is_ascii_whitespace()
1104 && b[i] != b'>'
1105 && !b[i..].starts_with(b"/>")
1106 {
1107 i += 1;
1108 }
1109 Ok((AttrValue::Static(decode_entities(&src[at..i])), i))
1110 }
1111 }
1112}
1113
1114const C1_REPLACEMENTS: [char; 32] = [
1127 '\u{20AC}', '\u{FFFD}', '\u{201A}', '\u{0192}', '\u{201E}', '\u{2026}', '\u{2020}', '\u{2021}',
1128 '\u{02C6}', '\u{2030}', '\u{0160}', '\u{2039}', '\u{0152}', '\u{FFFD}', '\u{017D}', '\u{FFFD}',
1129 '\u{FFFD}', '\u{2018}', '\u{2019}', '\u{201C}', '\u{201D}', '\u{2022}', '\u{2013}', '\u{2014}',
1130 '\u{02DC}', '\u{2122}', '\u{0161}', '\u{203A}', '\u{0153}', '\u{FFFD}', '\u{017E}', '\u{0178}',
1131];
1132
1133fn numeric_digits(digits: &str, radix: u32) -> Option<u32> {
1141 if digits.is_empty() || !digits.chars().all(|c| c.is_digit(radix)) {
1142 return None;
1143 }
1144 Some(u32::from_str_radix(digits, radix).unwrap_or(u32::MAX))
1145}
1146
1147fn numeric_reference(code: u32) -> char {
1154 match code {
1155 0 => '\u{FFFD}',
1156 0x80..=0x9F => C1_REPLACEMENTS[(code - 0x80) as usize],
1157 0xD800..=0xDFFF => '\u{FFFD}',
1158 c if c > 0x10FFFF => '\u{FFFD}',
1159 c => char::from_u32(c).unwrap_or('\u{FFFD}'),
1160 }
1161}
1162
1163pub fn decode_entities(s: &str) -> String {
1164 if !s.contains('&') {
1165 return s.to_string();
1166 }
1167 let mut out = String::with_capacity(s.len());
1168 let mut rest = s;
1169 while let Some(amp) = rest.find('&') {
1170 out.push_str(&rest[..amp]);
1171 let after = &rest[amp + 1..];
1172 match after.find(';') {
1173 Some(semi) if semi <= super::entities::LONGEST_NAME + 2 => {
1174 let name = &after[..semi];
1175 let decoded: Option<String> = if let Some(hex) =
1179 name.strip_prefix("#x").or_else(|| name.strip_prefix("#X"))
1180 {
1181 numeric_digits(hex, 16).map(|c| numeric_reference(c).to_string())
1182 } else if let Some(dec) = name.strip_prefix('#') {
1183 numeric_digits(dec, 10).map(|c| numeric_reference(c).to_string())
1184 } else {
1185 super::entities::named(name).map(str::to_string)
1186 };
1187 match decoded {
1188 Some(text) => {
1189 out.push_str(&text);
1190 rest = &after[semi + 1..];
1191 }
1192 None => {
1193 out.push('&');
1194 rest = after;
1195 }
1196 }
1197 }
1198 _ => {
1199 out.push('&');
1200 rest = after;
1201 }
1202 }
1203 }
1204 out.push_str(rest);
1205 out
1206}