On this page:
1.1 HTML particulars
1.2 Comparing with included Racket functions
1.3 Comparing with xexpr->html
1.4 Comparing with HTML Tidy
1.5 Probing and prodding
proof
debug

1 Crunchy details🔗

This package includes an extensive set of unit tests. In addition to preventing regressions, these nicely illustrate the printer’s expected behavior in a variety of edge cases.

1.1 HTML particulars🔗

Escaping special characters: Any <, > and & characters in string elements are escaped, and any symbols or integers in element position are converted to character entities:

> (display (xexpr->html5 '(p "Entities: " nbsp 65)))

<p>Entities: &nbsp;&#65;</p>

> (display (xexpr->html5 '(p "Escaping < > &")))

<p>Escaping &lt; &gt; &amp;</p>

In attribute values, the " character is escaped in addition to <, > and & characters:

> (display (xexpr->html5 '(p [[data-desc "Escaping \" < > &"]] "Foo")))

<p data-desc="Escaping &quot; &lt; &gt; &amp;">Foo</p>

The contents of <style> and <script> tags are never escaped or wrapped; the contents of <pre> tags are escaped, but never wrapped.

> (display
   (xexpr->html5 '(body (style "/* No escaping! & < > \" */")
                        (script "/* No escaping! & < > \" */")
                        (pre "Escaping! & < > \""))))

<body>

  <style>/* No escaping! & < > " */</style>

  <script>/* No escaping! & < > " */</script>

  <pre>Escaping! &amp; &lt; &gt; "</pre>

</body>

Nothing is ever added inside a <pre> tag either. When its content ends with a line break, the closing tag is placed at the start of the next line rather than at the current indent, because indentation there would become part of the preformatted text. The closing tags of <script> and <style> are indented as usual:

> (display
   (xexpr->html5 '(body (script "console.log(1);\n")
                        (pre "one\ntwo\n"))))

<body>

  <script>console.log(1);

  </script>

  <pre>one

two

</pre>

</body>

The printer can handle XML comment and cdata elements. Comments are line-wrapped and indented like everything else. CDATA content is never modified or escaped.

> (define com (comment "Behold, a hidden comment & < >"))
> (define cd (cdata #f #f "<![CDATA[Also some of this & < > ]]>"))
> (display
   (xexpr->html5 #:wrap 20 `(body (article (h1 "Title" ,com) (p ,cd " foo")))))

<body>

  <article>

    <h1>

    Title<!--Behold,

    a hidden comment

    & < >--></h1>

    <p>

    <![CDATA[Also some of this & < > ]]>

    foo</p>

  </article>

</body>

The printer recognizes custom elements as long as their names are spec-conformant. Custom elements are always wrapped/indented in the same way as “flow”-type tags like <article> and <section>:

A custom element must start with a lowercase letter and include at least one hyphen. See the definition of valid custom element names in the HTML Living Standard for more details.

> (display
   (xexpr->html5 '(article (sale-price (p "$250")))))

<article>

  <sale-price>

    <p>$250</p>

  </sale-price>

</article>

Differences from XML/XHTML: Attributes which the HTML5 spec identifies as boolean attributes are printed using the HTML5 “short” syntax. So, for example when '(disabled "true") is supplied as an attribute, it is printed as disabled rather than disabled="" or disabled="disabled".

> (display
   (xexpr->html5 '(label (input [[type "checkbox"] [disabled ""]]) " Cheese")))

<label><input type="checkbox" disabled> Cheese</label>

HTML elements which cannot have content (void elements) are ended with > (rather than with /> as in XML):

> (display (xexpr->html5 '(div (img [[src "cat.webp"]]))))

<div>

  <img src="cat.webp">

</div>

> (display (xexpr->html5 '(head (meta [[charset "UTF-8"]]))))

<head>

  <meta charset="UTF-8">

</head>

1.2 Comparing with included Racket functions🔗

Racket already includes a few functions for printing X-expressions in string form. These work just fine for generic XML markup; but for use as HTML content, the markup they generate can be incorrect or suboptimal.

In particular, all three of these functions will escape <, > and & characters inside <script> and <style> tags, which is likely to introduce JavaScript and CSS errors.

The xexpr->string function is the simplest. It does not offer line wrapping or indentation:

> (xexpr->string '(body (main (script "3 > 2"))))

"<body><main><script>3 &gt; 2</script></main></body>"

The display-xml/content function (in combination with xexpr->xml) offers options for indentation, but the docs warn that in HTML applications additional whitespace may be introduced. It does not support wrapping lines beyond a maximum width.

; Will render incorrectly as "Hello World"
; due to the added line break
> (display-xml/content
   (xexpr->xml '(body (article (p (b "Hello") (i "World")))))
   #:indentation 'scan)

<body>

  <article>

    <p>

      <b>Hello</b>

      <i>World</i>

    </p>

  </article>

</body>

; HTML5 printer will leave lines long
; rather than add significant whitespace
> (display
   (xexpr->html5 #:wrap 20
                 '(body (article (p (b "Hello") (i "World"))))))

<body>

  <article>

    <p>

    <b>Hello</b><i>World</i></p>

  </article>

</body>

The write-xexpr function has the same shortcomings as those already mentioned, and comes with its own very odd optional line wrapping scheme: adding a line break before the closing > of every opening tag.

> (write-xexpr '(body (article (p (b "Hello") (i "World")))))

<body><article><p><b>Hello</b><i>World</i></p></article></body>

> (write-xexpr '(body (article (p (b "Hello") (i "World"))))
               #:insert-newlines? #t)

<body

><article

><p

><b

>Hello</b><i

>World</i></p></article></body>

1.3 Comparing with xexpr->html🔗

The txexpr package includes xexpr->html, which correctly avoids escaping special characters inside <script> and <style> tags. Its HTML output will always be correct and faithful to the input, but since it performs no wrapping or indentation, the output can be difficult to read without additional processing.

> (define xp '(html
               (head
                (style "/* < > & */"))
               (body
                (section (h1 "Beginning"))
                (section (h1 "End")))))
> (display (xexpr->html xp))

<html><head><style>/* < > & */</style></head><body><section><h1>Beginning</h1></section><section><h1>End</h1></section></body></html>

> (display (xexpr->html5 xp))

<!DOCTYPE html>

<html>

  <head>

    <style>/* < > & */</style>

  </head>

  <body>

    <section>

      <h1>Beginning</h1>

    </section>

    <section>

      <h1>End</h1>

    </section>

  </body>

</html>

1.4 Comparing with HTML Tidy🔗

The HTML Tidy console application has been the best available tool for linting, correcting and formatting HTML markup since its creation in 1994. Its original purpose was to correct errors in HTML files written by hand in text editors.

Tidy is a much more comprehensive tool than this one and much more configurable. It always produces correctly line-wrapped and indented HTML, though this is only part of its functionality.

There are a few significant differences between Tidy and this package:

  • HTML Tidy generally tries to repair its input in addition to formatting it. It moves <style> tags into the <head>, inserts a missing <title>, discards elements that are not allowed where they appear, removes empty elements such as an empty <figcaption>, converts character entities to literal characters, and normalizes attribute values. In contrast, xexpr->html5 never alters the document: its formatted output always contains exactly the elements, attributes and text it is given. Structure that is not valid HTML, such as a <div> inside a <p>, is printed as-is.

  • HTML Tidy still counts line width by characters rather than graphemes, so it may wrap lines earlier than necessary when they contain emoji or other multi-byte graphemes.

  • HTML Tidy has numerous configuration options for adjusting the output formatting and for pruning the output (such as removing empty elements that could otherwise have content). xexpr->html5 offers very few options for customizing the output, focusing instead on providing a reasonable set of defaults, and avoiding any meaningful transformation of the structure of the HTML input.

Note that MacOS ships with an old version of HTML Tidy, but it’s too old for use with modern HTML.

This package includes unit tests which compare its output against that of HTML Tidy in some cases. When tests are run (including at the time of package installation), it will search for a version of Tidy version 5.8.0 or newer, first in the HTML_TIDY_PATH environment variable, then in the current PATH; if found, these unit tests will be run normally. Otherwise, the tests will pass without any comparison actually being made.

1.5 Probing and prodding🔗

 (require html-printer/debug) package: html-printer-lib

I lied at the beginning of these docs when I said this package only provides a single function. Here are a couple more, though they will only be interesting to people who really want to kick the tires.

procedure

(proof x [#:wrap wrap])  void?

  x : xexpr?
  wrap : exact-positive-integer? = 20

procedure

(debug x [#:wrap wrap #:show phases])  void?

  x : xexpr?
  wrap : exact-positive-integer? = 20
  phases : (listof (or/c 'expr 'tokens 'printer))
   = '(expr tokens printer)
Used for a close visual inspection of line wrapping and indentation, proof displays the result of (xexpr->html5 x #:wrap wrap) but with a column rule at the top and whitespace characters made visible:

> (proof '(p "Chaucer, Rabelais and " (em "Balzac!")))

----|----1----|----2----|----3----|

<p>Chaucer,·Rabelais¶

and¶

<em>Balzac!</em></p>¶

The debug function does the same thing but spits out an ungodly amount of gross logging on (current-error-port), for use in debugging the printing algorithm. (Note that all logging activity is disabled by default because of its huge performance penalty, but it gets temporarily enabled during calls to debug by way of parameterize.) The logging comes in three phases, and phases selects which are shown: 'expr logs the walk over the X-expression, one indented line per element; 'tokens logs the stream of printer tokens that the walk produces, broken into lines where the output breaks; and 'printer logs the line-wrapping printer’s state at every token, with each decision to break a line and the arithmetic behind it.

> (debug '(p "Chaucer, Rabelais and " (em "Balzac!")))

----|----1----|----2----|----3----|

<p>Chaucer,·Rabelais¶

and¶

<em>Balzac!</em></p>¶

EXPR xexpr->html5  wrap=20 add-breaks?=#t

EXPR block  tag=p parent=top prev-block?=#f

EXPR   string  str="Chaucer, Rabelais and " parent=block prev-block?=#f

EXPR   inline  tag=em parent=block prev-block?=#f

EXPR     string  str="Balzac!" parent=inline prev-block?=#f

EXPR   /inline  tag=em popped?=#f

EXPR /block  tag=p

TOKENS newline

TOKENS "<p>" softbreak "Chaucer," space "Rabelais" space "and" space "<em>" "Balzac!" "</em>" "</p>" newline

PRT newline              col=1   ind=0  pend=none      cluster=0

PRT "<p>"                col=1   ind=0  pend=none      cluster=0

PRT softbreak            col=1   ind=0  pend=none      cluster=3

PRT   commit "<p>" (3)  at line start

PRT "Chaucer,"           col=4   ind=0  pend=softbreak cluster=0

PRT space                col=4   ind=0  pend=softbreak cluster=8

PRT   commit "Chaucer," (8)  col 4 + sep 0 + 8 ends at 11 <= 20

PRT "Rabelais"           col=12  ind=0  pend=space     cluster=0

PRT space                col=12  ind=0  pend=space     cluster=8

PRT   commit "Rabelais" (8)  col 12 + sep 1 + 8 ends at 20 <= 20

PRT "and"                col=21  ind=0  pend=space     cluster=0

PRT space                col=21  ind=0  pend=space     cluster=3

PRT   commit "and" (3)  col 21 + sep 1 + 3 ends at 24 > 20  BREAK

PRT   ── line ended at col 20

PRT "<em>"               col=4   ind=0  pend=space     cluster=0

PRT "Balzac!"            col=4   ind=0  pend=space     cluster=4

PRT "</em>"              col=4   ind=0  pend=space     cluster=11

PRT "</p>"               col=4   ind=0  pend=space     cluster=16

PRT newline              col=4   ind=0  pend=space     cluster=20

PRT   commit "<em>Balzac!</em></p>" (20)  col 4 + sep 1 + 20 ends at 24 > 20  BREAK

PRT   ── line ended at col 3

PRT   ── line ended at col 20

PRT flush                col=1   ind=0  pend=none      cluster=0