npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@mkorun/quad

v0.1.3

Published

quad — HTML formatter and normalizer for build pipelines

Readme

quad — HTML Formatter & Normalizer

Context-aware HTML formatter and normalizer for build pipelines. Takes raw HTML from template engines and produces consistently indented, HTML5-normalized markup, without altering DOM-relevant content (elements, attributes, text, comments).

Synchronous. Zero dependencies. ESM-only. Formats and normalizes. Most sync, zero-dependency HTML tools only format (pretty-print); quad also normalizes to HTML5 conventions (void-element syntax, attribute quoting, tag-name case) — see Design scope for how it compares to js-beautify and Prettier.

Installation

bun add @mkorun/quad
# or
npm install @mkorun/quad

Quick Start

import htmlFmt from '@mkorun/quad'

const html = htmlFmt(rawHtml)
// with options:
const html = htmlFmt(rawHtml, { indentation: '  ', lineWrap: 120 })

Options

import type { HtmlFmtOptions } from '@mkorun/quad'

interface HtmlFmtOptions {
  indentation?: string    // indent string per level (default: '  ')
  lineWrap?: number       // max line length before attributes wrap (default: 120)
  stripComments?: boolean // remove HTML comments from output (default: false)
}

stripComments: true removes all <!-- … --> comments from the output. Useful for production builds where comments (build markers, template notes, etc.) should not be sent to the browser. Has no effect on <script>, <style>, or <pre> content.


Design scope

quad is a build-time formatter — it sits inside a static site generator's build pipeline, not in a developer's editor.

The HTML quad formats is never hand-edited. There is no style discussion to settle among teammates. The sole goal is readable output for humans who inspect build artifacts.

This is a different problem than what Prettier and js-beautify solve:

| | quad | js-beautify | Prettier | |--|:---------:|:-----------:|:--------:| | Primary target | Build pipeline | Editor / Build | Editor / CI | | Dependencies (package.json) | 0 | 5 | 0 | | npm install footprint | ~61 KB | ~8.2 MB (20 packages) | ~9.7 MB | | API | sync | sync | async (Promise) | | RE2-compatible | Yes | Yes | No | | Text wrapping | Yes | Yes | No | | HTML normalization | Yes | No | Partial |

Dependency count isn't the whole story: Prettier lists zero runtime dependencies (it bundles everything internally) but is still ~9.7 MB on disk once installed; js-beautify's 5 direct dependencies pull in 20 packages and ~8.2 MB transitively. Numbers are actual npm install output in an empty project, not each package's own tarball size — reproduce with mkdir t && cd t && npm init -y && npm install <package> && du -sh node_modules. js-beautify is a pure pretty-printer — it passes through whatever it receives and does not correct spec violations. Prettier does normalize some constructs but requires async and is not designed for build-pipeline integration. quad is the only tool in this space that combines formatting with normalization in a sync, zero-dependency, sub-100-KB package.

Minification is out of scope — quad adds whitespace for readability, not removes it. For a production pipeline that needs minimal output, the two concerns compose cleanly:

const formatted = htmlFmt(rawHtml)          // readable dev artifact
const minified  = htmlMin(htmlFmt(rawHtml)) // production output

For structurally valid input, quad's output remains valid HTML5 and works as input for HTML minifiers. Malformed or unbalanced input is tolerated without hanging or throwing, but quad is a formatter/normalizer, not an HTML validator or structural repair tool.

Two deliberate style choices that differ from Prettier and js-beautify:

Closing block tags stay with their content. When all content fits on one line, the closing tag stays at the end — <p>Some <strong>bold</strong> text.</p> rather than putting </p> on its own line. Compact and unambiguous.

Text content is word-wrapped at lineWrap (default 120 characters). Prettier leaves prose on a single line and lets browsers reflow; quad targets readability of the build artifact.


Features

Pretty formatting

Indents nested elements, collapses whitespace inside tags, normalizes self-closing syntax:

<!-- input -->
<html><head><meta charset="utf-8"></head><body><p>Hello</p></body></html>

<!-- output -->
<html>

<head>
  <meta charset="utf-8">
</head>

<body>
  <p>Hello</p>
</body>

</html>

Tag normalization

  • Collapses whitespace inside tags: <div class="foo" > → <div class="foo">
  • Void elements (<br>, <img>, <input>, <meta>, <link>, …) never increase indent depth; trailing slashes removed (see Normalization)

Long tag wrapping

Tags with many attributes whose opening tag exceeds lineWrap (default 120 characters) are split across lines, aligned to the tag name:

<div class="card card-highlighted extra-padding"
     data-tracking-id="header-nav-cta-x"
     data-analytics-category="navigation">
  Card content
</div>

Inline elements

The following elements are treated as inline — no line break before them, no indentation increment, surrounding spaces preserved:

a abbr b bdi bdo cite code data del dfn em i ins kbd mark q rb rp rt rtc ruby s samp small span strong sub sup time u var

<!-- input -->
<p>Some <em>italic</em> and <strong>bold</strong> text.</p>

<!-- output — inline elements preserved, spaces kept, no extra indentation -->
<p>Some <em>italic</em> and <strong>bold</strong> text.</p>

Context-aware blocks

<script>, <style>, <pre>, <textarea>, and <svg> content is never tokenized as HTML — quad recognizes their opening tag (case-insensitively) and scans everything up to the matching closing tag as one opaque block, so embedded <, >, />, or comment-like text inside is never misparsed. What happens to that block's whitespace differs by kind:

| Block | Content | Indentation | |-------|---------|--------------| | <!-- ... --> | Preserved verbatim | Placed on its own line at the current depth | | <script>...</script> | Preserved verbatim, except one formatting boundary newline next to each tag | None — reindentation could change multi-line template-literal values | | <style>...</style> | Preserved verbatim, with the same boundary-newline exception | None — CSS content is opaque | | <pre>...</pre> | Preserved verbatim | None — inner tags, spaces, newlines untouched | | <textarea>...</textarea> | Preserved verbatim | None — including all whitespace | | <svg>...</svg> | Preserved verbatim | None — inner markup untouched |

<pre>, <textarea>, and <svg> inner content is preserved character-for-character. Script and style content is also opaque and never reindented or reflowed; quad only normalizes one boundary newline adjacent to each tag so formatting remains idempotent without accumulating blank lines.

Normalization

HTML5 uses a different syntax than XHTML for void elements and attribute quoting. quad enforces HTML5 conventions:

Trailing slash removal — void elements do not use self-closing syntax in HTML5:

<!-- input -->
<br /><img src="photo.jpg" /><meta charset="utf-8" />

<!-- output -->
<br><img src="photo.jpg"><meta charset="utf-8">

Non-void self-closing elements (e.g. SVG <path />) are left unchanged.

Attribute quoting — all attribute values are normalized to double quotes:

<!-- input -->
<meta charset=utf-8>
<input type='text' maxlength=100>

<!-- output -->
<meta charset="utf-8">
<input type="text" maxlength="100">

Boolean attributes without values (disabled, checked, required) are left unchanged — they are already valid HTML5.

Tag name lowercasing — HTML5 tag names are case-insensitive, but lowercase is the convention:

<!-- input -->
<DIV><P>Hello <STRONG>world</STRONG></P></DIV>

<!-- output -->
<div>
  <p>Hello <strong>world</strong></p>
</div>

Attribute names are intentionally not lowercased — SVG attributes like viewBox and preserveAspectRatio are case-sensitive and must be preserved verbatim.

Fix tags

Whitespace artifacts from template rendering are cleaned up before formatting:

  • < div> → <div> (space after <)
  • <div > → <div> (space before >)

If a second, unquoted < turns up before the tag's own closing > (e.g. an unencoded < in prose like a < b</p>), quad backs off and treats the original < as literal text instead of speculatively swallowing everything up to some unrelated later >. This bailout narrows the ambiguity but doesn't remove it — see Known Limitations for the cases it doesn't catch.


Performance

~3.3–3.6× faster than js-beautify on typical build output (measured on Bun v1.3.14; varies run to run within that range — reproduce it yourself rather than trust a single number):

git clone https://github.com/mkorun/quad
cd quad && bun install && bun run bench

bun run bench uses only fixtures shipped in this repo (no external or private content), so the comparison is reproducible by anyone who clones it. Representative run:

| Sample | quad | js-beautify | Factor | |--------|----------:|------------:|-------:| | hmd-testseite (14.5 KB) | 852 µs | 2475 µs | ×2.9 | | feature-coverage (3.3 KB) | 140 µs | 796 µs | ×5.7 | | edge-cases (1.7 KB) | 90 µs | 499 µs | ×5.5 | | avg | 361 µs | 1257 µs | ×3.5 |

Prettier is intentionally not benchmarked — it has no synchronous API, ships ~9.7 MB of bundled parser code, and is not designed for build-pipeline use. js-beautify is the relevant comparison: same weight class, same build-friendly API. It has 5 dependencies and only formats — no normalization. quad does both and is still faster, at a fraction of the footprint of either.

A fairness note on this number: the two tools don't do exactly the same amount of work. js-beautify actively restructures <script>/<style>/<svg> content (re-formats JS/CSS, reindents nested SVG markup); quad deliberately treats that content as opaque and copies it verbatim (see Context-aware blocks). On fixtures with a lot of embedded script/style/SVG, that gives quad a work-based head start that has nothing to do with tokenizer speed. Measuring the same fixtures with those blocks emptied out narrows the factor to ~3.1–3.7× — still a real, consistent advantage, just not quite as dramatic as the headline number on documents that happen to be light on embedded script/style/SVG.


Known Limitations

  • Inline elements without surrounding text (<p><em>…</em></p>) are placed directly after the opening block tag — no <p>\n <em>. Inline elements do not increment the indent counter.
  • Whitespace-sensitive element classification follows HTML defaults, not page CSS. quad cannot know that a stylesheet changes an element from inline to block (or vice versa), so adjacency preservation follows the element's normal HTML rendering category. Source whitespace is never inferred from CSS.
  • Unbalanced HTML (more closing than opening tags) is caught via Math.max(0, indentationCount - 1) — no crash, but indentation may be off at the affected point. Typically occurs with Markdown-generated output (e.g. <dl><dt><dd> on one line).
  • Nested same-name raw blocks (e.g. a string literal containing the text </script> inside a <script> element) end the block at that first occurrence — this matches how browsers actually tokenize HTML (<script> content is not parsed for further tags, the first literal </script> always ends it), so it's spec-correct behavior, not a quad-specific quirk.
  • Comment contents are not reformatted. A multi-line <!-- ... --> block is emitted as one unit with its original internal line breaks and indentation kept exactly as written. Comments between inline content stay attached so they cannot introduce or remove visible word spacing; comments in block layout may be repositioned with the surrounding indentation.
  • <script>/<style>/<svg> inner content is excluded from the automated comparison against js-beautify (see differential/) — js-beautify actively restructures JS/CSS and reformats SVG markup, while quad deliberately treats these blocks as opaque. Element structure and attributes are still compared; quad's own raw-content preservation contract is covered directly by unit tests.
  • An unencoded < immediately followed by a letter in prose can still be misread as a tag start — the stray-< bailout (see Fix tags) narrows this, it doesn't eliminate it. Two cases remain: (1) a bare < + letter with no other tag anywhere in the rest of the input never triggers the bailout, since there's no second < for it to find (a < b → a <b); (2) an unquoted > appearing before the next < is read as that tag's own closing >, so a < b > c becomes a <b> c — a real, recognized <b> (bold) tag gets fabricated, not just an unterminated one. Both require unencoded </> in prose text, which valid HTML avoids (&lt;/&gt; are the correct encodings); real documents also almost always have further markup after any given point, which is what the bailout relies on. Not something observed in quad's own test corpus, but a real gap in the heuristic, not just a theoretical one.

Untrusted input

quad is designed for build-pipeline use — it formats HTML that you generated yourself from your own templates. It is synchronous and has no timeout: a pathological input could theoretically block the thread indefinitely. (quad's own lexer guarantees forward progress on every character — no known input hangs quad itself — but "no known input" is not a safety guarantee for content you don't control.)

If you need to format HTML from an untrusted source (user-submitted content, scraped pages, third-party APIs), run quad inside a Worker thread with a timeout. quad is ESM-only, so the worker needs its own file — require() on an ESM package inside an eval: true Worker (a common pattern for CJS packages) will not work here.

The examples below target the compiled worker (worker.mjs), since plain Node — quad's only stated runtime requirement is Node ≥18 — can't execute a .ts file directly without a loader. Compile it as part of your build (tsc, esbuild, ...), or if you're running under Bun or a TS-loader setup (tsx, ts-node/esm, ...), point the Worker/Piscina target at worker.ts instead — those can load TypeScript natively.

// worker.ts — compiled to worker.mjs, a real, separate file loaded by the Worker below
import { parentPort, workerData } from 'node:worker_threads'
import htmlFmt from '@mkorun/quad'

parentPort!.postMessage(htmlFmt(workerData))
// main.ts
import { Worker } from 'node:worker_threads'

function htmlFmtSafe(html: string, timeoutMs = 5000): Promise<string> {
  return new Promise((resolve, reject) => {
    const worker = new Worker(new URL('./worker.mjs', import.meta.url), { workerData: html })

    const timer = setTimeout(() => {
      worker.terminate()
      reject(new Error('htmlFmt timed out'))
    }, timeoutMs)

    worker.on('message', result => { clearTimeout(timer); resolve(result) })
    worker.on('error', err => { clearTimeout(timer); reject(err) })
  })
}

For high-throughput scenarios, use a Worker pool (e.g. piscina) to avoid the per-call thread-start overhead (~5–10 ms):

// worker.ts — compiled to worker.mjs, see note above
import htmlFmt from '@mkorun/quad'
export default (html: string) => htmlFmt(html)
// main.ts
import Piscina from 'piscina'

const pool = new Piscina({ filename: new URL('./worker.mjs', import.meta.url).pathname })
const formatted = await pool.run(untrustedHtml)

A persistent pool reduces per-call overhead to ~0.5–1 ms (IPC serialization only). Note that the async API is a consequence of thread isolation — if your pipeline is entirely trusted, the sync API remains preferable.


RE2 compatibility

quad's regular expressions use only constructs supported by RE2. Tokenization itself is a plain character/index scanner — not regex-based at all, so it has no RE2 exposure by construction. The handful of remaining regexes (tab replacement, a dynamically-built </tagname\s*> raw-content terminator, a couple of literal-character replacements) use only character classes and anchors — no lookaheads, lookbehinds, or backreferences anywhere in the codebase, so there's no catastrophic-backtracking risk on any regex engine, RE2 or otherwise.

This matters if you ever run quad somewhere that specifically requires RE2 syntax (e.g. embedding it in a Go or Rust host via a JS engine backed by RE2, or a linter that flags non-RE2-safe patterns) — not because common edge/serverless platforms use RE2 themselves. Cloudflare Workers and Deno Deploy, for instance, both run on V8 (the same regex engine as Node and Chrome), not RE2 — they're mentioned here only as compatible target environments, not as evidence that RE2-compatibility was required to run on them.


Tests

bun test   # 139 tests: 131 unit tests + 8 differential/idempotency/extractor tests

| Group | Coverage | |-------|----------| | basic formatting | Indentation, structure, custom indent string | | void elements | br, img, input, meta, self-closing | | HTML comments | Content preserved, no spurious whitespace around --> | | stripComments | Comment removal without gluing adjacent words together | | script/style preservation | JS/CSS opaque and not reindented; template-literal content unchanged | | textarea preservation | Whitespace untouched, embedded tag-like text stays literal | | long tag wrapping | Attribute wrapping at lineWrap | | tag normalization | Whitespace collapse, self-closing syntax | | normalization | Trailing slash removal, attribute quoting, tag name lowercasing | | indentation clamp | No crash on unbalanced HTML | | multiline text content | Long text wraps, short text stays inline | | pre preservation | Content including inner tags preserved exactly | | svg preservation | Inner markup verbatim, self-closing children, no indent increment | | inline/phrasing elements | Spaces preserved around inline, void, replaced, and custom elements | | blank line fixes | No spurious blank lines before <hr>, <img>, after <script> | | quote-aware attribute parsing | >/quotes inside attribute values, no infinite loop on a stray / | | case-insensitive raw content | <SCRIPT>, <Style>, mixed case — still recognized as raw content | | leading/trailing text | Text-only input, text outside the outermost tag | | doctype | Recognized as a tag (any case), not absorbed into surrounding text | | malformed input | Missing >, missing closing tags, unterminated comments/raw blocks — no hang, no throw | | crash resistance | Real article HTML from Markdown output, plus full-document idempotency | | semantic equality vs. js-beautify | Same elements/attributes/text/comments — see differential/; layout differences excluded on purpose | | namespaced tag names | <svg:svg> doesn't get truncated at : and mistaken for raw content | | whitespace before a block-ish sensitive tag | button, label, svg, custom elements — source space preserved without inventing one at a layout-only block boundary | | unencoded "<" in prose | a < b stays literal text instead of being misread as a tag and swallowing the rest of the document; genuine < div>/</ div> template-whitespace normalization still works | | wrapping text after an inline element | <em>x</em>. Long sentence… — a text node that wraps keeps its first unbroken run attached to the current line; the wrap newline is never introduced at a junction that had no source whitespace |

Bold rows were added after external reviews found real bugs: the first found regressions in the previous regex-based tokenizer (quote-unaware > handling, the infinite loop, case-sensitive raw-content detection); a second review of the rewritten lexer found a namespaced-tag-name truncation bug and a whitespace-loss bug before non-inline sensitive tags; a downstream site review (0.1.3) found the wrapping path inventing a rendered space after an inline element. Each row's rewrite is what the corresponding test group now guards against regressing.


Part of the hadley ecosystem


License

ISC