html source

← html · API reference · version 0.1.3

3 file(s), 136 lines. This is the source the published version ships, and it is loft — the library is written in the same language you call it from.

Public items

A worked example is a call site the author chose as the thing to read first. Call sites are every line naming the item, found mechanically over the source with comments excluded — so an item with no worked example is not an item nobody uses.

ItemDefinedWorked exampleCall sites
escape_html fn21HTM-0029 10 11 12 13 18 … (15 total)

src/html.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// html — escape text so it is safe inside HTML markup.

// HTML-escape the standard five entities for safe interpolation
// into HTML output:
//
//   &  →  &
//   <  →  &lt;
//   >  →  &gt;
//   "  →  &quot;
//   '  →  &#x27;
//
// Use whenever user-supplied text gets dropped into an HTML
// element body or attribute value.  Each character is replaced
// once, in one pass, so the entities it writes are not escaped
// again within the call — but a second call escapes them.
// Example: @HTM-001 — escape once, where the text enters markup.
// Example: @HTM-002 — a single-quoted attribute stays closed.
pub fn escape_html(self: text) -> text {
  out = "";
  for c in self {
    if c == '&' {
      out += "&amp;";
    } else if c == '<' {
      out += "&lt;";
    } else if c == '>' {
      out += "&gt;";
    } else if c == '"' {
      out += "&quot;";
    } else if c == '\'' {
      out += "&#x27;";
    } else {
      out += "{c}";
    }
  }
  out
}
#pure

tests/01-escape.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// escape_html: each entity, the one-pass rule, text that needs no escaping.

use html;

fn test_basic_entities() {
    assert("<".escape_html() == "&lt;",   "lt");
    assert(">".escape_html() == "&gt;",   "gt");
    assert("&".escape_html() == "&amp;",  "amp");
    assert("\"".escape_html() == "&quot;", "quot");
    assert("'".escape_html() == "&#x27;", "apos");
}

fn test_safe_chars_pass_through() {
    s = "Hello, World! 123 _-./";
    assert(s.escape_html() == s, "safe chars unchanged");
}

fn test_empty() {
    assert("".escape_html() == "", "empty");
}

fn test_amp_first() {
    // & must be escaped FIRST so the substituted entity strings
    // don't get re-escaped.  `<` → `&lt;` already starts with `&`,
    // and naive ordering would turn that into `&amp;lt;`.
    assert("<".escape_html() == "&lt;", "lt not double-escaped");
    // Existing entity strings DO get double-escaped (intentional —
    // we treat input as raw text, not pre-escaped).
    assert("&amp;".escape_html() == "&amp;amp;", "&amp; double-escaped");
}

fn test_attribute_safe() {
    // The five entities make the output safe for both element
    // bodies AND attribute values (with either quote style).
    s = "<img src=\"x\" alt='y'>";
    expected = "&lt;img src=&quot;x&quot; alt=&#x27;y&#x27;&gt;";
    assert(s.escape_html() == expected, "attribute-style mixed quotes");
}

fn test_unicode_passes_through() {
    // Non-ASCII characters don't need escaping in UTF-8 HTML.
    s = "→ snowman ☃";
    assert(s.escape_html() == s, "unicode passes through");
}

fn test_xss_payload() {
    // The classic <script> payload — safe after escape.
    payload = "<script>alert('x')</script>";
    out = payload.escape_html();
    assert(!out.starts_with("<"), "no leading <");
    assert(out.find("<script") == null, "no live script tag");
}

fn main() {
    test_basic_entities();
    test_safe_chars_pass_through();
    test_empty();
    test_amp_first();
    test_attribute_safe();
    test_unicode_passes_through();
    test_xss_payload();
    println("escape-html: ok");
}

tests/02-worked-examples.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Worked examples for `html`.  Each test is a call-site contract the signature
// `escape_html(self: text) -> text` cannot show, cited from the function with
// `// Example: @HTM-00N`.
use html::*;

// @HTM-001 — escape ONCE, at the point the text enters markup.  Escaping is not
// idempotent: an already-escaped `&amp;` escapes again to `&amp;amp;`, which a browser
// shows as the literal text "&amp;".  So keep the raw text in your data and escape where
// it is written into HTML — never store the escaped form and escape it again on output.
fn test_escaping_twice_shows_the_entity() {
  once = "Tom & Jerry".escape_html();
  assert(once == "Tom &amp; Jerry", "once: {once}");
  twice = once.escape_html();
  assert(twice == "Tom &amp;amp; Jerry", "twice is visible as '&amp;' on the page: {twice}");
}

// @HTM-002 — one escape serves every HTML text context: an element body, and an
// attribute value in EITHER quoting style.  The apostrophe is escaped too, so a value
// placed between single quotes cannot close its attribute and start another.
fn test_a_single_quoted_attribute_stays_closed() {
  hostile = "x' onmouseover='alert(1)";
  attr = "<a title='{hostile.escape_html()}'>";
  assert(attr == "<a title='x&#x27; onmouseover=&#x27;alert(1)'>", "{attr}");
  quotes = 0;
  for c in attr { if c == '\'' { quotes += 1; } }
  assert(quotes == 2, "only the attribute's own two quotes remain: {quotes}");
}