Skip to main content

rama_http/protocols/html/
core.rs

1//! Private (internal) implementation of the HTML rendering primitives.
2//!
3//! This module is a lightly simplified, permanent fork of
4//! [`vy-core`](https://github.com/JonahLund/vy). The original `Either*`
5//! types have been removed in favour of [`rama_core::combinators::Either`]
6//! and friends (see [`super::either_impls`]). `no_std` support, `Cow`,
7//! `IpAddr`, etc. impls have been dropped — we have full `std`/`alloc`
8//! available and `rama-http` already provides richer ways of plugging in
9//! arbitrary values.
10
11#![expect(
12    clippy::allow_attributes,
13    reason = "vendored from `vy-core`: macro-internal `#[allow(non_snake_case)]` attrs whose underlying lint fires only for some tuple-arity expansions"
14)]
15
16use std::{
17    borrow::Cow,
18    fmt::{self, Write as _},
19};
20
21/// A type that can be rendered as a fragment of HTML.
22///
23/// This is the central trait of the HTML templating support. Built-in
24/// scalars (e.g. `&str`, `String`, `bool`, integers, floats) all
25/// implement it; new types can implement it either by returning a
26/// composition of other [`IntoHtml`] values, or — for "leaf" types —
27/// by overriding [`IntoHtml::escape_and_write`] directly.
28///
29/// # Examples
30///
31/// Compose nested HTML elements using macros:
32///
33/// ```ignore
34/// use rama_http::protocols::html::*;
35///
36/// struct Article { title: String, content: String, author: String }
37///
38/// impl IntoHtml for Article {
39///     fn into_html(self) -> impl IntoHtml {
40///         article!(
41///             h1!(self.title),
42///             p!(class = "content", self.content),
43///             footer!("Written by ", self.author),
44///         )
45///     }
46/// }
47/// ```
48///
49/// For leaf types, **return `self`** to terminate the rendering chain
50/// and override [`IntoHtml::escape_and_write`]:
51///
52/// ```ignore
53/// use rama_http::protocols::html::{IntoHtml, escape_into};
54///
55/// struct TextNode(String);
56///
57/// impl IntoHtml for TextNode {
58///     fn into_html(self) -> impl IntoHtml { self }
59///     fn escape_and_write(self, buf: &mut String) { escape_into(buf, &self.0); }
60///     fn size_hint(&self) -> usize { self.0.len() }
61/// }
62/// ```
63pub trait IntoHtml {
64    /// Convert this value into another [`IntoHtml`] value. Used for
65    /// composition; leaf types should return `self`.
66    fn into_html(self) -> impl IntoHtml;
67
68    /// Append the rendered (escaped) HTML to `buf`.
69    #[inline]
70    fn escape_and_write(self, buf: &mut String)
71    where
72        Self: Sized,
73    {
74        self.into_html().escape_and_write(buf);
75    }
76
77    /// Best-effort estimate of the rendered byte length, used to
78    /// pre-allocate the output buffer.
79    #[inline]
80    fn size_hint(&self) -> usize {
81        0
82    }
83
84    /// Render to a freshly allocated `String`.
85    fn into_string(self) -> String
86    where
87        Self: Sized,
88    {
89        let html = self.into_html();
90        let size = html.size_hint();
91        let mut buf = String::with_capacity(size + (size / 10));
92        html.escape_and_write(&mut buf);
93        buf
94    }
95}
96
97/// HTML-escape `input` into `output` (`&`, `<`, `>`, `"`, `'`).
98///
99/// Escaping `'` as `&#x27;` is required so that interpolating untrusted
100/// strings into single-quoted attribute contexts (e.g. `<input value='…'>`)
101/// is safe. `&apos;` is intentionally not used because it is not part of
102/// HTML4 and some older agents do not recognize it.
103#[inline]
104pub fn escape_into(output: &mut String, input: &str) {
105    let bytes = input.as_bytes();
106    let mut start = 0;
107    for (i, &b) in bytes.iter().enumerate() {
108        let replacement = match b {
109            b'&' => "&amp;",
110            b'<' => "&lt;",
111            b'>' => "&gt;",
112            b'"' => "&quot;",
113            b'\'' => "&#x27;",
114            _ => continue,
115        };
116        // Every escapable is ASCII, so `i` is a char boundary.
117        output.push_str(&input[start..i]);
118        output.push_str(replacement);
119        start = i + 1;
120    }
121    output.push_str(&input[start..]);
122}
123
124/// HTML-escape `value` into a byte buffer for a double-quoted attribute
125/// context, where only `&` and `"` need escaping. Bulk-copies the runs
126/// between escapables.
127pub(crate) fn escape_attr_value_into(output: &mut Vec<u8>, value: &[u8]) {
128    let mut start = 0;
129    for (i, &b) in value.iter().enumerate() {
130        let replacement: &[u8] = match b {
131            b'&' => b"&amp;",
132            b'"' => b"&quot;",
133            _ => continue,
134        };
135        output.extend_from_slice(&value[start..i]);
136        output.extend_from_slice(replacement);
137        start = i + 1;
138    }
139    output.extend_from_slice(&value[start..]);
140}
141
142/// HTML-escape `input`, returning a [`Cow::Borrowed`] of the original
143/// when nothing needs escaping — common in practice — and otherwise a
144/// freshly allocated [`Cow::Owned`] with the escaped form.
145#[inline]
146pub fn escape(input: &str) -> Cow<'_, str> {
147    // All escapables are ASCII, so a byte scan on UTF-8 is correct.
148    if !input
149        .bytes()
150        .any(|b| matches!(b, b'&' | b'<' | b'>' | b'"' | b'\''))
151    {
152        return Cow::Borrowed(input);
153    }
154    let mut output = String::with_capacity(input.len() + 8);
155    escape_into(&mut output, input);
156    Cow::Owned(output)
157}
158
159/// The longest entity name (without `&`/`;`) we attempt to decode; bounds the
160/// look-ahead for a terminating `;` so stray `&`s stay cheap.
161const MAX_ENTITY_LEN: usize = 32;
162
163/// Decode HTML character references in `input` — all numeric references
164/// (`&#169;`, `&#xA9;`) plus the common named ones (`&amp;`, `&mdash;`, …).
165///
166/// Returns [`Cow::Borrowed`] when there is nothing to decode. Unknown or
167/// malformed references are left verbatim. This is the companion to
168/// [`escape`]/[`escape_into`] for consumers of the raw, undecoded text the
169/// [`tokenizer`](super::tokenizer) emits.
170#[must_use]
171pub fn decode_entities(input: &str) -> Cow<'_, str> {
172    let Some(first) = input.find('&') else {
173        return Cow::Borrowed(input);
174    };
175    let mut out = String::with_capacity(input.len());
176    out.push_str(&input[..first]);
177    let mut rest = &input[first..];
178    loop {
179        // `rest` starts at an `&`.
180        let after = &rest[1..];
181        if let Some(semi) = after.find(';').filter(|&i| i <= MAX_ENTITY_LEN)
182            && let Some(ch) = decode_entity(&after[..semi])
183        {
184            out.push(ch);
185            rest = &after[semi + 1..];
186        } else {
187            out.push('&');
188            rest = after;
189        }
190        let Some(next) = rest.find('&') else {
191            out.push_str(rest);
192            break;
193        };
194        out.push_str(&rest[..next]);
195        rest = &rest[next..];
196    }
197    Cow::Owned(out)
198}
199
200/// Decode a single entity body (the bytes between `&` and `;`). `None` for an
201/// unknown name or out-of-range numeric reference (left verbatim by the caller).
202fn decode_entity(body: &str) -> Option<char> {
203    if let Some(num) = body.strip_prefix('#') {
204        let code = match num.strip_prefix(['x', 'X']) {
205            Some(hex) => u32::from_str_radix(hex, 16).ok()?,
206            None => num.parse::<u32>().ok()?,
207        };
208        return char::from_u32(code);
209    }
210    Some(match body {
211        "amp" => '&',
212        "lt" => '<',
213        "gt" => '>',
214        "quot" => '"',
215        "apos" => '\'',
216        "nbsp" => '\u{a0}',
217        "hellip" => '…',
218        "mdash" => '—',
219        "ndash" => '–',
220        "lsquo" => '\u{2018}',
221        "rsquo" => '\u{2019}',
222        "ldquo" => '\u{201C}',
223        "rdquo" => '\u{201D}',
224        "laquo" => '«',
225        "raquo" => '»',
226        "copy" => '©',
227        "reg" => '®',
228        "trade" => '™',
229        "deg" => '°',
230        "middot" | "bull" => '•',
231        "euro" => '€',
232        "pound" => '£',
233        "cent" => '¢',
234        "sect" => '§',
235        "times" => '×',
236        "divide" => '÷',
237        _ => return None,
238    })
239}
240
241/// Emit a `<?marker name="…">` processing instruction for use as a
242/// placeholder in a [Chrome declarative partial updates] shell. The name is
243/// HTML-escaped via [`escape_into`] on render.
244///
245/// [Chrome declarative partial updates]: https://developer.chrome.com/blog/declarative-partial-updates
246#[inline]
247pub fn marker<S: AsRef<str>>(name: S) -> Marker<S> {
248    Marker(name)
249}
250
251/// Renderer for [`marker`]. Holds the name by-value and writes the PI
252/// directly into the output buffer at render time.
253#[derive(Debug, Clone, Copy)]
254pub struct Marker<S>(pub S);
255
256impl<S: AsRef<str>> IntoHtml for Marker<S> {
257    #[inline]
258    fn into_html(self) -> impl IntoHtml {
259        self
260    }
261    fn escape_and_write(self, buf: &mut String) {
262        buf.push_str(r#"<?marker name=""#);
263        escape_into(buf, self.0.as_ref());
264        buf.push_str(r#"">"#);
265    }
266    fn size_hint(&self) -> usize {
267        // length of `<?marker name="">` + name; escape may grow it a bit.
268        17 + self.0.as_ref().len()
269    }
270}
271
272/// Emit a `<?start name="…">` processing instruction — the opening of the
273/// *range* form of declarative partial updates. Whatever HTML sits between
274/// `<?start name="x">` and the matching [`end`] (often a skeleton or
275/// spinner) is replaced wholesale when the `<template for="x">` arrives, so
276/// the placeholder content goes away on swap without any CSS bookkeeping.
277/// The name is HTML-escaped via [`escape_into`] on render.
278#[inline]
279pub fn start<S: AsRef<str>>(name: S) -> Start<S> {
280    Start(name)
281}
282
283/// Renderer for [`start`].
284#[derive(Debug, Clone, Copy)]
285pub struct Start<S>(pub S);
286
287impl<S: AsRef<str>> IntoHtml for Start<S> {
288    #[inline]
289    fn into_html(self) -> impl IntoHtml {
290        self
291    }
292    fn escape_and_write(self, buf: &mut String) {
293        buf.push_str(r#"<?start name=""#);
294        escape_into(buf, self.0.as_ref());
295        buf.push_str(r#"">"#);
296    }
297    fn size_hint(&self) -> usize {
298        // length of `<?start name="">` + name; escape may grow it a bit.
299        16 + self.0.as_ref().len()
300    }
301}
302
303/// Emit a `<?end>` processing instruction — the closing of a range opened
304/// by [`start`]. Takes no name: `<?end>` always closes the most recent
305/// unclosed `<?start>` at the same nesting level.
306#[inline]
307pub fn end() -> End {
308    End
309}
310
311/// Renderer for [`end`].
312#[derive(Debug, Clone, Copy)]
313pub struct End;
314
315impl IntoHtml for End {
316    #[inline]
317    fn into_html(self) -> impl IntoHtml {
318        self
319    }
320    fn escape_and_write(self, buf: &mut String) {
321        buf.push_str(r#"<?end>"#);
322    }
323    fn size_hint(&self) -> usize {
324        6 // length of `<?end>`
325    }
326}
327
328/// Wrapper that marks its inner value as already-escaped HTML — i.e. it
329/// will be written verbatim instead of going through [`escape_into`].
330///
331/// This is the type the macros emit for the static (literal) parts of a
332/// template; users normally only construct it explicitly when they want
333/// to splice trusted HTML into a template (e.g. an icon SVG).
334#[derive(Debug, Clone, Copy)]
335pub struct PreEscaped<T>(pub T);
336
337impl<T: fmt::Display> fmt::Display for PreEscaped<T> {
338    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
339        self.0.fmt(f)
340    }
341}
342
343impl IntoHtml for PreEscaped<&str> {
344    #[inline]
345    fn into_html(self) -> impl IntoHtml {
346        self
347    }
348    #[inline]
349    fn escape_and_write(self, buf: &mut String) {
350        buf.push_str(self.0);
351    }
352    #[inline]
353    fn size_hint(&self) -> usize {
354        self.0.len()
355    }
356}
357
358impl IntoHtml for PreEscaped<String> {
359    #[inline]
360    fn into_html(self) -> impl IntoHtml {
361        self
362    }
363    #[inline]
364    fn escape_and_write(self, buf: &mut String) {
365        buf.push_str(&self.0);
366    }
367    #[inline]
368    fn size_hint(&self) -> usize {
369        self.0.len()
370    }
371}
372
373impl IntoHtml for PreEscaped<char> {
374    #[inline]
375    fn into_html(self) -> impl IntoHtml {
376        self
377    }
378    #[inline]
379    fn escape_and_write(self, buf: &mut String) {
380        buf.push(self.0);
381    }
382    #[inline]
383    fn size_hint(&self) -> usize {
384        self.0.len_utf8()
385    }
386}
387
388impl IntoHtml for PreEscaped<Cow<'static, str>> {
389    #[inline]
390    fn into_html(self) -> impl IntoHtml {
391        self
392    }
393    #[inline]
394    fn escape_and_write(self, buf: &mut String) {
395        buf.push_str(&self.0);
396    }
397    #[inline]
398    fn size_hint(&self) -> usize {
399        self.0.len()
400    }
401}
402
403impl IntoHtml for PreEscaped<Box<str>> {
404    #[inline]
405    fn into_html(self) -> impl IntoHtml {
406        self
407    }
408    #[inline]
409    fn escape_and_write(self, buf: &mut String) {
410        buf.push_str(&self.0);
411    }
412    #[inline]
413    fn size_hint(&self) -> usize {
414        self.0.len()
415    }
416}
417
418// ---- scalar / std impls ----------------------------------------------------
419
420impl IntoHtml for &str {
421    #[inline]
422    fn into_html(self) -> impl IntoHtml {
423        self
424    }
425    #[inline]
426    fn escape_and_write(self, buf: &mut String) {
427        escape_into(buf, self)
428    }
429    #[inline]
430    fn size_hint(&self) -> usize {
431        self.len()
432    }
433}
434
435impl IntoHtml for char {
436    #[inline]
437    fn into_html(self) -> impl IntoHtml {
438        self
439    }
440    #[inline]
441    fn escape_and_write(self, buf: &mut String) {
442        escape_into(buf, self.encode_utf8(&mut [0; 4]));
443    }
444    #[inline]
445    fn size_hint(&self) -> usize {
446        self.len_utf8()
447    }
448}
449
450impl IntoHtml for String {
451    #[inline]
452    fn into_html(self) -> impl IntoHtml {
453        self
454    }
455    #[inline]
456    fn escape_and_write(self, buf: &mut String) {
457        escape_into(buf, &self)
458    }
459    #[inline]
460    fn size_hint(&self) -> usize {
461        self.len()
462    }
463}
464
465impl IntoHtml for &String {
466    #[inline]
467    fn into_html(self) -> impl IntoHtml {
468        self
469    }
470    #[inline]
471    fn escape_and_write(self, buf: &mut String) {
472        escape_into(buf, self)
473    }
474    #[inline]
475    fn size_hint(&self) -> usize {
476        self.len()
477    }
478}
479
480impl IntoHtml for Box<str> {
481    #[inline]
482    fn into_html(self) -> impl IntoHtml {
483        self
484    }
485    #[inline]
486    fn escape_and_write(self, buf: &mut String) {
487        escape_into(buf, &self)
488    }
489    #[inline]
490    fn size_hint(&self) -> usize {
491        self.len()
492    }
493}
494
495impl IntoHtml for Cow<'static, str> {
496    #[inline]
497    fn into_html(self) -> impl IntoHtml {
498        self
499    }
500    #[inline]
501    fn escape_and_write(self, buf: &mut String) {
502        escape_into(buf, self.as_ref())
503    }
504    #[inline]
505    fn size_hint(&self) -> usize {
506        self.as_ref().len()
507    }
508}
509
510impl IntoHtml for bool {
511    #[inline]
512    fn into_html(self) -> impl IntoHtml {
513        if self { "true" } else { "false" }
514    }
515    #[inline]
516    fn size_hint(&self) -> usize {
517        5
518    }
519}
520
521impl<T: IntoHtml> IntoHtml for Option<T> {
522    #[inline]
523    fn into_html(self) -> impl IntoHtml {
524        self
525    }
526    #[inline]
527    fn escape_and_write(self, buf: &mut String) {
528        if let Some(x) = self {
529            x.escape_and_write(buf)
530        }
531    }
532    #[inline]
533    fn size_hint(&self) -> usize {
534        match self {
535            Some(x) => x.size_hint(),
536            None => 0,
537        }
538    }
539}
540
541impl IntoHtml for () {
542    #[inline]
543    fn into_html(self) -> impl IntoHtml {
544        self
545    }
546    #[inline]
547    fn escape_and_write(self, _: &mut String) {}
548}
549
550impl<F: FnOnce(&mut String)> IntoHtml for F {
551    #[inline]
552    fn into_html(self) -> impl IntoHtml {
553        self
554    }
555    #[inline]
556    fn escape_and_write(self, buf: &mut String) {
557        (self)(buf)
558    }
559}
560
561impl<B: IntoHtml, I: ExactSizeIterator, F> IntoHtml for std::iter::Map<I, F>
562where
563    F: FnMut(I::Item) -> B,
564{
565    #[inline]
566    fn into_html(self) -> impl IntoHtml {
567        self
568    }
569    #[inline]
570    fn escape_and_write(self, buf: &mut String) {
571        let len = self.len();
572        for (i, x) in self.enumerate() {
573            if i == 0 {
574                buf.reserve(len * x.size_hint());
575            }
576            x.escape_and_write(buf);
577        }
578    }
579}
580
581impl<T: IntoHtml> IntoHtml for Vec<T> {
582    #[inline]
583    fn into_html(self) -> impl IntoHtml {
584        self
585    }
586    #[inline]
587    fn escape_and_write(self, buf: &mut String) {
588        for x in self {
589            x.escape_and_write(buf);
590        }
591    }
592    #[inline]
593    fn size_hint(&self) -> usize {
594        self.iter().map(IntoHtml::size_hint).sum()
595    }
596}
597
598impl<T: IntoHtml, const N: usize> IntoHtml for [T; N] {
599    #[inline]
600    fn into_html(self) -> impl IntoHtml {
601        self
602    }
603    #[inline]
604    fn escape_and_write(self, buf: &mut String) {
605        for x in self {
606            x.escape_and_write(buf);
607        }
608    }
609    #[inline]
610    fn size_hint(&self) -> usize {
611        self.iter().map(IntoHtml::size_hint).sum()
612    }
613}
614
615// ---- tuples ----------------------------------------------------------------
616
617macro_rules! impl_tuple {
618    ( ( $($i:ident,)+ ) ) => {
619        impl<$($i,)+> IntoHtml for ($($i,)+)
620        where
621            $($i: IntoHtml,)+
622        {
623            #[inline]
624            fn into_html(self) -> impl IntoHtml {
625                #[allow(non_snake_case)]
626                let ($($i,)+) = self;
627                ($($i.into_html(),)+)
628            }
629
630            #[inline]
631            fn escape_and_write(self, buf: &mut String) {
632                #[allow(non_snake_case)]
633                let ($($i,)+) = self;
634                $( $i.escape_and_write(buf); )+
635            }
636
637            #[inline]
638            fn size_hint(&self) -> usize {
639                #[allow(non_snake_case)]
640                let ($($i,)+) = self;
641                let mut n = 0;
642                $( n += $i.size_hint(); )+
643                n
644            }
645        }
646    };
647    ($f:ident) => {
648        impl_tuple!(($f,));
649    };
650    ($f:ident $($i:ident)+) => {
651        impl_tuple!(($f, $($i,)+));
652        impl_tuple!($($i)+);
653    };
654}
655
656impl_tuple!(A B C D E F G H I J K L M N O P Q R S T U V W X Y Z A_ B_ C_ D_ E_ F_ G_ H_ I_ J_ K_);
657
658// ---- numbers ---------------------------------------------------------------
659
660// Numeric impls. None of `Display` for these types can produce a character
661// that needs HTML-escaping, so we write directly into `buf`.
662macro_rules! via_display {
663    ($($ty:ty)*) => {
664        $(
665            impl IntoHtml for $ty {
666                #[inline]
667                fn into_html(self) -> impl IntoHtml { self }
668                #[inline]
669                fn escape_and_write(self, buf: &mut String) {
670                    _ = write!(buf, "{self}");
671                }
672            }
673        )*
674    };
675}
676
677via_display! { isize i8 i16 i32 i64 i128 usize u8 u16 u32 u64 u128 f32 f64 }