HTML::EscapeUtils
DivergentEscape five characters, unescape 2222 named and numeric entities — and throw on any entity it does not recognise.
- Version
0.0.3zef:demanuel- Depends
JSON::Fast- License
- EUPL-1.2
- Its own test suite
- 1 file, green
- Checked
- 2026-09-15 against Raku++ 3.28.0 and Rakudo 2026.08
Install it #
$ rakupp install HTML::EscapeUtilszef install HTML::EscapeUtils writes the same store; either installer leaves the module usable by both engines.
What it is for #
Putting user text into an HTML document safely, and reading entity-encoded text back. Two subs, a bundled 2222-entry codepoint table, and nothing else.
Escaping #
use HTML::EscapeUtils;
my $raw = q{<a href="x">Tom & Jerry's</a>};
say 'raw : ', $raw;
say 'escaped : ', escape($raw);
say '';
say 'exactly five characters are escaped:';
for '&', '<', '>', '"', "'", '/', '`', '=', ' ' -> $c {
say sprintf(' %-4s -> %s', $c.raku, escape($c).raku);
}
say '';
say '& is substituted first, so double-escaping does not occur and the';
say 'round trip is lossless:';
say ' unescape(escape($raw)) eq $raw : ', unescape(escape($raw)) eq $raw;raw : <a href="x">Tom & Jerry's</a>
escaped : <a href="x">Tom & Jerry's</a>
exactly five characters are escaped:
"\&" -> "\&"
"<" -> "\<"
">" -> "\>"
"\"" -> "\""
"'" -> "\'"
"/" -> "/"
"`" -> "`"
"=" -> "="
" " -> " "
& is substituted first, so double-escaping does not occur and the
round trip is lossless:
unescape(escape($raw)) eq $raw : TrueThere is no separate attribute-value routine — no escape-attr, no :attr flag. escape is what you have for both text and attributes.
use HTML::EscapeUtils;
my $payload = q{x" onmouseover="alert(1)};
say 'double-quoted : <a title="', escape($payload), '">';
my $single = q{x' onmouseover='alert(1)};
say 'single-quoted : <a title=', "'", escape($single), "'", '>';
say 'unquoted : <a title=', escape('x onmouseover=alert(1)'), '>';
say '';
say 'the first two are safe; the third is a live attribute injection.';
say 'ALWAYS quote your attribute values — this escape does not remove';
say 'spaces, backticks or equals signs, and an unquoted attribute needs';
say 'all three handled.';double-quoted : <a title="x" onmouseover="alert(1)">
single-quoted : <a title='x' onmouseover='alert(1)'>
unquoted : <a title=x onmouseover=alert(1)>
the first two are safe; the third is a live attribute injection.
ALWAYS quote your attribute values — this escape does not remove
spaces, backticks or equals signs, and an unquoted attribute needs
all three handled.Unescaping #
use HTML::EscapeUtils;
for '&', '<', ' ', '©', '…', '&',
'A', 'A', '—', '😀', '&;;', '&' -> $e {
my $r = try unescape($e);
say sprintf(' %-12s -> %s', $e.raku, $! ?? 'threw' !! $r.raku);
}
say '';
say 'the table has 2222 entries including 752 uppercase spellings, the';
say 'quantifier on the trailing semicolon eats every one of them, and an';
say 'entity with no semicolon is left alone.'; "\&" -> "\&"
"\<" -> "<"
"\ " -> " "
"\©" -> "©"
"\…" -> "…"
"\&" -> "\&"
"\A" -> "A"
"\A" -> "A"
"\—" -> "—"
"\😀" -> "😀"
"\&;;" -> "\&"
"\&" -> "\&"
the table has 2222 entries including 752 uppercase spellings, the
quantifier on the trailing semicolon eats every one of them, and an
entity with no semicolon is left alone.The one thing to know #
unescape throws on any entity it does not recognise — with a type-check error that says nothing about entities.
use HTML::EscapeUtils;
for '¬anentity;', '&123;', '&;', 'plain text', 'a & b' -> $s {
my $r = try unescape($s);
say sprintf(' %-18s -> %s', $s.raku, $! ?? 'threw ' ~ $!.^name !! $r.raku);
}
say '';
say 'the internal replace does return $match if %codepoints{$match}:!exists;';
say '— returning the Match from a --> Str sub, so the RETURN type-check';
say 'fires.';
say '';
say 'you cannot run unescape over arbitrary HTML. One unknown entity, one';
say 'stray &…;, even a bare &;, and it explodes.';
say '';
say 'guard it:';
sub safe-unescape(Str $s) {
$s.subst(/ '&' <-[&;\s]>+ ';' /, { (try unescape($/.Str)) // $/.Str }, :g)
}
say ' safe-unescape("& &nope; <") = ',
safe-unescape('& &nope; <').raku; "\¬anentity;" -> threw X::TypeCheck::Return
"\&123;" -> threw X::TypeCheck::Return
"\&;" -> threw X::TypeCheck::Return
"plain text" -> "plain text"
"a \& b" -> "a \& b"
the internal replace does return $match if %codepoints{$match}:!exists;
— returning the Match from a --> Str sub, so the RETURN type-check
fires.
you cannot run unescape over arbitrary HTML. One unknown entity, one
stray &…;, even a bare &;, and it explodes.
guard it:
safe-unescape("& &nope; <") = "\& \&nope; <"Cost #
use HTML::EscapeUtils;
say 'unescape re-slurps and re-parses the 64 KB resource on EVERY call,';
say 'so the cost tracks the number of CALLS, not the size of the input.';
say '';
my $many = '&' x 200;
my $t0 = now;
unescape($many);
my $one-call = now - $t0;
$t0 = now;
unescape('&') for ^200;
my $many-calls = now - $t0;
say ' one call over 200 entities : ', $one-call < 1 ?? 'under a second' !! 'slow';
say ' 200 calls over one entity : ',
$many-calls > $one-call * 20 ?? 'more than 20x slower' !! 'comparable';
say '';
say 'batch your text into one call.';unescape re-slurps and re-parses the 64 KB resource on EVERY call,
so the cost tracks the number of CALLS, not the size of the input.
one call over 200 entities : under a second
200 calls over one entity : more than 20x slower
batch your text into one call.Where the two engines differ #
One case, and it is a silent wrong answer. The entity regex is :i, so A with a capital X matches, and the numeric conversion is then chr("0" ~ "X41"). Rakudo raises Cannot convert string to number; Raku++ answers U+0002.
use HTML::EscapeUtils;
say 'the lowercase form is fine on both:';
say ' unescape("A") = ', unescape('A').raku;
say '';
say 'the uppercase-X form is a Rakudo throw and a Raku++ U+0002. Reject';
say 'it before you get there:';
sub numeric-ok(Str $s) { $s !~~ / '&#' <[X]> / }
for 'A', 'A' -> $e {
say sprintf(' %-10s acceptable ? %s', $e.raku, numeric-ok($e));
}
say '';
say 'and note what escape does NOT do: no numeric-reference escaping is';
say 'available at all, so if a consumer needs ' rather than '';
say 'you have to build it yourself.';the lowercase form is fine on both:
unescape("A") = "A"
the uppercase-X form is a Rakudo throw and a Raku++ U+0002. Reject
it before you get there:
"\A" acceptable ? True
"\A" acceptable ? False
and note what escape does NOT do: no numeric-reference escaping is
available at all, so if a consumer needs ' rather than '
you have to build it yourself.