Skip to main content

logicaffeine_language/lgx/
html.rs

1//! The LGX HTML tokenizer — real markup for the `View:` section of a component.
2//!
3//! Flat and pure: a raw view body becomes start/end tags, text runs and `{…}` brace groups,
4//! each with the byte span it came from. Nothing here knows the LOGOS expression grammar
5//! (a brace group's inner text is handed to the template parser, which hands holes to the
6//! real expression parser) and nothing here builds a tree (nesting, whitespace collapse and
7//! template tags live in [`super::template`]). What it does own:
8//!
9//! - tag and attribute syntax: quoted, unquoted and valueless attributes; `/>`; the HTML
10//!   void elements, which never take an end tag;
11//! - `{…}` groups in text and inside attribute values — scanned STRING- and NESTING-aware,
12//!   so a LOGOS interpolation inside a hole (`{greet("{x}")}`) stays one group and a quoted
13//!   `"}"` cannot end one;
14//! - character references (`&`, `©`, `A`) in text and static attribute values;
15//! - `<!-- -->` comments (dropped);
16//! - the refusals a component template must make: `<script>`, `<style>` and a doctype are
17//!   not a view's business (styles live in `Style:`, the document shell in a layout).
18//!
19//! Errors carry the span of the offending construct and a message that names the fix.
20
21use crate::token::Span;
22
23/// One flat token of a view body.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum HtmlToken {
26    /// `<name attr…>` or `<name …/>`. `self_closing` is true for an explicit `/>` AND for
27    /// every HTML void element (`<br>`, `<input>`, …), which never has an end tag.
28    StartTag {
29        name: String,
30        attrs: Vec<HtmlAttr>,
31        self_closing: bool,
32        span: Span,
33    },
34    /// `</name>`.
35    EndTag { name: String, span: Span },
36    /// A text run with character references decoded and whitespace kept VERBATIM.
37    Text { text: String, span: Span },
38    /// A `{…}` group in text position: `inner` is the text between the braces.
39    Brace { inner: String, span: Span },
40}
41
42/// One attribute of a start tag.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct HtmlAttr {
45    pub name: String,
46    /// `None` for a valueless attribute (`disabled`).
47    pub value: Option<AttrValue>,
48    pub span: Span,
49}
50
51/// An attribute's value.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum AttrValue {
54    /// No `{…}` inside — a plain string (character references decoded).
55    Static(String),
56    /// Text and brace groups interleaved (`"badge {tone}"`), or a bare `{expr}`.
57    Parts(Vec<AttrPart>),
58}
59
60/// One piece of a dynamic attribute value.
61#[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    /// A `<…` that never reaches its `>`.
70    UnterminatedTag,
71    /// A quoted attribute value that never closes.
72    UnterminatedAttributeValue,
73    /// A `{` with no matching `}`.
74    UnterminatedBrace,
75    /// A `}` with no `{` before it.
76    StrayCloseBrace,
77    /// A `<!--` that never reaches `-->`.
78    UnterminatedComment,
79    /// `<script>`, `<style>` or a doctype inside a view.
80    ForbiddenElement(String),
81    /// An attribute with no name (`<p =x>`), or a name containing a forbidden character.
82    BadAttributeName(String),
83    /// `<` followed by something that cannot start a tag (`<1>`, `< a>`, `<>`).
84    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    /// The Socratic explanation: what went wrong and what to write instead.
95    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 `&lt;`.".into(),
109        }
110    }
111}
112
113/// The HTML void elements: they never take an end tag.
114pub 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
123/// The HTML elements, as the WHATWG HTML standard names them (plus the `svg` and `math` roots of
124/// the foreign content it embeds).
125pub 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
246/// Whether `target` reads as a CSS selector for a mount point rather than a file path: an id
247/// (`#root`), a class (`.app`), an attribute test (`[data-app]`), or exactly an element name
248/// (`main`, `body`).
249pub 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/// What an `on-<event>.<modifier>` qualifier asks for. Every tier reads a handler's modifiers
255/// through this type; the words themselves live only in the tables below.
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub enum EventModifier {
258    /// How the browser treats the event once a listener has it.
259    Behaviour(Behaviour),
260    /// The one key a keyboard handler answers.
261    Key(KeyName),
262    /// A modifier key that must be held while the event happens.
263    System(SystemKey),
264}
265
266/// The behavioural qualifiers: they shape what happens to the event, not which event it is.
267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268pub enum Behaviour {
269    /// `.prevent` — the browser's default action (a form posting, a link following) does not run.
270    Prevent,
271    /// `.stop` — the event does not propagate to the element's ancestors.
272    Stop,
273    /// `.once` — the listener answers the first event and is then removed.
274    Once,
275    /// `.capture` — the listener runs in the capture phase, before the target's own listeners.
276    Capture,
277    /// `.passive` — the listener promises not to prevent the default action.
278    Passive,
279}
280
281/// A key a keyboard qualifier names: one from [`KEY_MODIFIERS`], or a single printable character.
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum KeyName {
284    /// A named key, held as its `KeyboardEvent.key` spelling (`"Enter"`, `" "` for space).
285    Named(&'static str),
286    /// A printable character — `on-keydown.a` answers the `a` key.
287    Char(char),
288}
289
290/// The system keys a qualifier may require to be held.
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub enum SystemKey {
293    Ctrl,
294    Alt,
295    Shift,
296    Meta,
297}
298
299/// `(qualifier, behaviour)` — the behavioural modifiers.
300pub 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
308/// `(qualifier, KeyboardEvent.key)` — the named keys a keyboard handler may wait for. The second
309/// column is what a browser reports for the key and what a driving `Press` names; the two are
310/// compared case-insensitively.
311pub 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
328/// `(qualifier, key)` — the system keys.
329pub const SYSTEM_KEY_MODIFIERS: &[(&str, SystemKey)] = &[
330    ("ctrl", SystemKey::Ctrl),
331    ("alt", SystemKey::Alt),
332    ("shift", SystemKey::Shift),
333    ("meta", SystemKey::Meta),
334];
335
336/// The events that carry a key. A key qualifier on any other event could never match.
337pub const KEY_EVENTS: &[&str] = &["keydown", "keyup", "keypress"];
338
339/// Whether `event` is one of [`KEY_EVENTS`].
340pub fn is_key_event(event: &str) -> bool {
341    KEY_EVENTS.iter().any(|e| e.eq_ignore_ascii_case(event))
342}
343
344impl EventModifier {
345    /// Classify one qualifier word, or `None` when the catalogue does not know it.
346    ///
347    /// Named qualifiers match case-insensitively, as HTML attribute names do. Any other single
348    /// printable character is the key of that character.
349    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    /// The qualifier as it is written after the `.`.
375    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    /// The key a handler with these modifiers waits for, as a browser reports it — `None` for a
389    /// handler that answers every key.
390    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    /// The bits a host's listen registration carries for these modifiers (`Behaviour::listen_bit`)
398    /// — every behaviour a host dispatches by. `.once` is not among them: a host never retires a
399    /// listener on its own, and the lowering keeps `.once` as compiled LOGOS.
400    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    /// Every qualifier the catalogue names, for a did-you-mean.
412    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    /// The bit a host's listen registration carries for this behaviour — the order of the table
431    /// above, one bit each, the same bits `semantics::dom`'s `LISTEN_*` names.
432    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    /// The key as `KeyboardEvent.key` spells it.
455    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/// Why a handler's qualifiers cannot be accepted.
464#[derive(Debug, Clone, PartialEq, Eq)]
465pub enum ModifierError {
466    /// A word the catalogue does not name, with the closest one it does.
467    Unknown {
468        word: String,
469        suggestion: Option<&'static str>,
470    },
471    /// A key or system-key qualifier on an event that carries no key.
472    KeyOnNonKeyEvent { word: String, event: String },
473    /// Two different keys on one handler, which waits for one.
474    TwoKeys { first: String, second: String },
475    /// A system-key qualifier. No tier delivers modifier-key state, so the handler could never
476    /// be told the key was held.
477    SystemKeyNotDelivered { word: String },
478    /// `.prevent` and `.passive` on one handler. A passive listener promises never to cancel the
479    /// event, so a browser silently ignores its `.prevent` and runs the default action anyway.
480    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
521/// Classify every qualifier of one `on-<event>.<…>` directive against the catalogue.
522pub 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
595/// The HTML RAW TEXT elements: their content is not markup, and escaping it CORRUPTS it.
596///
597/// ★ A `<style>` HOLDING `&gt;` IS A STYLESHEET THE BROWSER THROWS AWAY. HTML's parser reads the
598/// content of `<style>` and `<script>` verbatim — character references are NOT decoded there — so
599/// a serializer that escapes text the way it escapes everything else turns the child combinator
600/// `.list > li` into `.list &gt; li`, which is not a selector. The browser discards that rule and
601/// every declaration in it, silently: the page renders, the styling is simply absent, and nothing
602/// anywhere says why.
603///
604/// Same shape as [`is_void`] one function up — a rule HTML keys on the element NAME, which a
605/// serializer cannot infer from the content it is holding, held once so both renderers ask it.
606pub fn is_raw_text(name: &str) -> bool {
607    name.eq_ignore_ascii_case("style") || name.eq_ignore_ascii_case("script")
608}
609
610/// The HTML BOOLEAN attributes: their state is their PRESENCE, and the value is ignored.
611///
612/// `disabled="false"` disables the control in every browser there is — so a page that renders a
613/// Bool `false` into one of these as a string ships the opposite of what its author wrote, and
614/// nothing about the page looks wrong. Held as a list rather than guessed from the value,
615/// because HTML keys the rule on the attribute NAME.
616pub 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
649/// The attributes whose value is a URL the BROWSER will follow, fetch or submit to.
650///
651/// These are the positions where a string stops being data and becomes a navigation the browser
652/// performs on the author's behalf — so they are the positions where a scheme matters. Held as a
653/// list for the same reason [`BOOLEAN_ATTRIBUTES`] is: HTML keys the rule on the attribute NAME,
654/// and guessing from the value is how `disabled="false"` disabled a control.
655/// The characters that mean something to an HTML parser in TEXT content.
656///
657/// ★ THREE, NOT FOUR — the quote is not one of them. A `"` between tags is an ordinary character;
658/// only inside an attribute value, where a quote is what ENDS the value, does it have to go.
659pub const TEXT_ESCAPES: &[(char, &str)] = &[('&', "&amp;"), ('<', "&lt;"), ('>', "&gt;")];
660
661/// The same, plus the quote that delimits an attribute value.
662///
663/// `&` is first in both tables and must stay first: replacing `<` before `&` would re-escape the
664/// `&` of the `&lt;` just written.
665pub const ATTR_ESCAPES: &[(char, &str)] = &[
666    ('&', "&amp;"),
667    ('<', "&lt;"),
668    ('>', "&gt;"),
669    ('"', "&quot;"),
670];
671
672/// Escape `s` for HTML TEXT content.
673///
674/// ★ THE ONE DEFINITION. There were FOUR — `escape_text` and `escape_attr` in `semantics::dom`,
675/// `htmlEscape` in `web.lg`, and `html_escape_literal` in the lowering — and they disagreed about
676/// the quote, so a text hole holding one **served `&quot;` and painted `"`**. Both look identical
677/// in a browser, which is exactly why it survived: the difference is in the bytes, and the bytes
678/// are what every cross-tier comparison in this codebase rests on. An escaper is also not a place
679/// to hold four opinions; the next disagreement would not be a cosmetic one.
680///
681/// It lives here beside `VOID_ELEMENTS` and `URL_ATTRIBUTES` for the same reason they do: this is
682/// the module every renderer already asks about HTML's own rules.
683pub fn escape_text(s: &str) -> String {
684    escape_with(s, TEXT_ESCAPES)
685}
686
687/// Escape `s` for an HTML ATTRIBUTE value.
688pub 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
719/// Schemes a browser EXECUTES rather than fetches. A URL carrying one is script, not a location.
720///
721/// `data:` is here because a `data:text/html` document runs in the origin that opened it, which is
722/// the same capability `javascript:` gives with more steps.
723pub const EXECUTABLE_SCHEMES: &[&str] = &["javascript:", "data:", "vbscript:"];
724
725/// Whether `url` would execute if a browser followed it.
726///
727/// ★ IT NORMALISES THE WAY A BROWSER DOES, WHICH IS THE WHOLE DIFFICULTY. A browser strips leading
728/// whitespace and control characters before reading the scheme, and compares it case-insensitively
729/// — so `JaVaScRiPt:`, a leading space, and a tab or newline spliced INSIDE the word all reach the
730/// same place as the plain spelling. A check that compared the raw text would pass every one of
731/// them while looking correct, which is worse than no check at all.
732pub 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
741/// Whether `name` names a component use (`<TodoRow>`) rather than an HTML element: LGX
742/// components are Capitalized, HTML tags are lowercase.
743pub fn is_component_name(name: &str) -> bool {
744    name.chars().next().is_some_and(|c| c.is_ascii_uppercase())
745}
746
747/// Tokenize a raw view body.
748pub 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
820/// Scan the `{…}` group opening at byte `open`; returns the index just past its `}`.
821/// String-aware (a `"…"` may contain braces; `\"` is an escaped quote) and nesting-aware.
822pub 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    // `</` name ws* `>`
871    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            // A lone `/` inside a tag (`<a//>`): skip it like browsers do.
955            i += 1;
956            continue;
957        }
958        // Attribute name.
959        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        // Optional `= value`.
982        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    // ★ A COMPONENT IS NOT AN HTML ELEMENT, and the void check was the one place that did not ask.
1010    // `Link` lowercases to `link`, a real void element, so `<Link href={u}>Home</Link>` was marked
1011    // self-closing: the children were dropped and the author was then told that `<Link>` is a void
1012    // element — about a component they declared themselves. `Link` is what every router calls its
1013    // navigation component; `Input`, `Img`, `Meta`, `Source`, `Track`, `Area`, `Col`, `Embed`,
1014    // `Base`, `Param`, `Br`, `Hr` and `Wbr` collide the same way.
1015    //
1016    // `is_component_name` is the line the rest of the tokenizer already draws — it is how
1017    // `<TodoRow/>` composes instead of emitting a `<todorow>` tag.
1018    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
1030/// Parse an attribute value starting at `at` (just past `=` and whitespace).
1031fn 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                    // A quote that swallowed the rest of the tag and the line: report precisely.
1079                    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            // ★ `/>` IS COMPARED AS BYTES: `i` advances byte by byte, so `src[i..]` panicked
1100            // inside any multi-byte character — `<p class=café>` took the whole compiler down.
1101            // Both bytes are ASCII, so the byte comparison is exact. See `lgx_non_ascii_lock`.
1102            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
1114/// Decode HTML character references. Unknown or malformed references are kept verbatim.
1115/// The HTML numeric-character-reference replacement table for `0x80..=0x9F`, indexed by
1116/// `code - 0x80`.
1117///
1118/// ★ THE NUMBER IS NOT THE CODE POINT IN THIS RANGE. `&#128;` is `€` (U+20AC), not U+0080, because
1119/// the range is a legacy of documents that declared windows-1252 and wrote the raw byte — and it is
1120/// not an exotic corner: `&#147;`/`&#148;` (curly quotes) and `&#151;` (an em dash) are what a word
1121/// processor pastes, so this arrives in ordinary page copy.
1122///
1123/// Handing the number straight to `char::from_u32` SUCCEEDS here and yields an invisible C1
1124/// control, so the page rendered a character nobody typed and nothing anywhere reported it. The
1125/// five slots windows-1252 leaves unassigned are `U+FFFD` rather than the control.
1126const 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
1133/// The value of a numeric reference's digits, or `None` when they are not digits at all.
1134///
1135/// ★ THE TWO FAILURES ARE NOT THE SAME FAILURE, and collapsing them is how `&#99999999999999;`
1136/// came to render as its own markup. Digits that are not digits (`&#xZZ;`) mean the author never
1137/// wrote a reference, so the text stands as typed; digits that OVERFLOW mean they wrote one that
1138/// denotes nothing, which is `U+FFFD`. Saturating rather than failing keeps that distinction, since
1139/// every value past the last code point already answers `U+FFFD`.
1140fn 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
1147/// The character a NUMERIC reference denotes, by the HTML standard's rules rather than by
1148/// `char::from_u32` alone.
1149///
1150/// Every failure here answers `U+FFFD` — a zero, a surrogate, a value past the last code point —
1151/// because the author DID write a reference and the standard says what a malformed one renders as.
1152/// Answering `None` and falling through to "not an entity" showed the reader the markup itself.
1153fn 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                // A NAMED reference resolves against the standard's own list; a NUMERIC one is
1176                // computed. Both answer text, because 93 of the named ones stand for more than a
1177                // single code point.
1178                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}