Tired of the incomplete smart quote abilities offered by 2024’s JavaScript libraries, I created my own. I christened the library “punctilio”—the “precise observance of formalities.” As of publication, punctilio is the best library for prettifying text.
punctilio (n.): precise observance of formalities.
Pretty good at making your text pretty. The most feature-complete and reliable English typography package. punctilio transforms plain ascii into typographically correct Unicode, even across html element boundaries. Try it live at turntrout.com/punctilio.
Smart quotes · Em / en dashes · Ellipses · Math symbols · Legal symbols · Arrows · Primes · Fractions · Superscripts · Ligatures · Non-breaking spaces · html-aware · Markdown support · Bri’ish, German, and French localisation support
import { transform } from "punctilio";transform(`"It's a beautiful thing, the destruction of words..." -- 1984`);// → “It’s a beautiful thing, the destruction of words…”—1984
punctilio accepts three input formats: text, Markdown, and html. Use it as a library, a CLI, a pre-commit hook, or—for a zero-friction install in any project that already runs Prettier—a Prettier plugin.
As far as I can tell, punctilio is the most reliable and feature-complete. I built punctilio for my website. I wrote1 and sharpened the core RegExes sporadically over several months, exhaustively testing edge cases. Eventually, I decided to spin off the functionality into its own package.
I tested punctilio against smartypants 0.2.2, tipograph 0.7.4, smartquotes 2.3.2, typograf 7.7.0, and retext-smartypants 6.2.0.2 These other packages have spotty feature coverage and inconsistent impact on text. For example, smartypants mishandles quotes after em dashes (though quite hard to see in GitHub’s font) and lacks multiplication sign support.
Input
smartypants
punctilio
5x5
5x5 (✗)
5×5 (✓)
My benchmark.mjs measures how well libraries handle a wide range of scenarios. The benchmark normalizes stylistic differences (e.g. non-breaking vs regular space, British vs American dash spacing) for fair comparison.
Package
Passed (of 151)
punctilio
150 (99%)
tipograph
89 (59%)
smartquotes
77 (51%)
smartypants
72 (48%)
retext-smartypants
69 (46%)
typograf
64 (42%)
Note on benchmark construction: I assembled the initial cases myself. I then sought out cases where punctilio failed and competitors succeeded, and improved punctilio to succeed there as well. The benchmark may nonetheless remain biased towards punctilio.
Setting aside the benchmark, punctilio’s test suite runs at 100% branch coverage with well over a thousand tests, including edge cases derived from competitor libraries (smartquotes,retext-smartypants,typograf) and the Standard Ebooks typography manual. The 100% figure isn’t hand-maintained: Jest’s coverageThreshold requires 100% branches, functions, lines, and statements, CI fails below that, and the coverage badge above is regenerated from the actual coverage report on every push to main. I also verify that all transformations are stable when applied multiple times. Uses recheck to statically verify the absence of inefficient RegEx patterns.
Perhaps the most innovative feature of the library is that it properly handles doms! Other typography libraries take one of two approaches, both with drawbacks.
String-based libraries (like smartypants) transform plain text but are unaware of html structure. If you flatten text from "<em>"Wait</em>" into "Wait", transform the text so that it has smart quotes (“Wait”), and then try to convert back—you’ve lost track of where the </em> belongs.
Ast-based libraries (like rehype-retext) process each text node individually, preserving structure but losing cross-node information. A quote that opens inside <em>"Wait</em> and closes outside it " spans two text nodes. Processed independently, the library can’t tell whether the final " is opening or closing, because it never sees both at once.
punctilio introduces separation boundaries to get the best of both worlds:
Flatten the parent container’s contents to a string, delimiting element boundaries with a two-character private-use Unicode sentinel (U+E000 U+E001) to avoid unintended matches.
Every RegEx allows (and preserves) these characters, treating them as boundaries of a “permeable membrane” through which contextual information flows. For example, .U+E000.. still becomes …U+E000.
Rehydrate the html ast. For all k, set element k’s text content to the segment starting at separator occurrence k.
import { transform, DEFAULT_SEPARATOR } from "punctilio";transform(`"Wait${DEFAULT_SEPARATOR}"`);// → `“Wait”${DEFAULT_SEPARATOR}`// The separator doesn’t block the information that this should be an end-quote!
For rehype / unified pipelines, use the built-in plugin which handles the separator logic automatically:
import rehypePunctilio from "punctilio/rehype";unified() .use(rehypeParse) .use(rehypePunctilio) .use(rehypeStringify) .process('<p><em>"Wait</em>..." -- she said</p>');// → <p><em>“Wait</em>…”—she said</p>// The opening quote inside <em> and the closing quote outside it// are both resolved correctly across the element boundary.
For Markdown asts via remark, use remarkPunctilio which applies the same separator technique to preserve inline element boundaries, or use transformMarkdown for a simpler Markdown-to-Markdown pipeline.
For manual dom walking or custom transforms, use transformElement from punctilio/rehype.
punctilio doesn’t enable all transformations by default. Fractions and degrees tend to match too aggressively (perfectly applying the degree transformation requires semantic meaning). Superscript letters and punctuation ligatures have spotty font support. Furthermore, ligatures = true can change the meaning of text by collapsing question and exclamation marks.
Markdown sinks (remarkPunctilio, transformMarkdown, the Prettier plugin, and the CLI for Markdown files) default nbsp to false instead, because invisible U+00A0 characters in Markdown source files break grep and Ctrl+F. Pass nbsp: true (or the --nbsp CLI flag) to opt in.
The rehype plugin accepts additional options. Elements matching any skipTags tag name or carrying any skipClasses class are left untransformed (values shown are the defaults for skipTags):
By default the plugin transforms text only inside a curated allowlist of prose-bearing elements (plus custom elements). Set transformAllElements: true to invert the model and transform text inside every element except those in skipTags / skipClasses and the form-value elements <textarea> and <input> (whose text is a literal control value, not prose). <select> is skipped as a container, but its <option> labels are still transformed. skipTags and skipClasses continue to take precedence in this mode.
For finer-grained control, shouldSkipText opts specific text nodes out of transformation without skipping their enclosing element. The predicate receives the text node and its ancestor chain (root first, nearest last); returning true leaves the node’s value untouched. shouldSkipText runs after element-level skipping—it is never called for text inside an already-skipped element.
rehypePunctilio({ // Skip anchor text that equals its href (URL-like link text). shouldSkipText: (textNode, ancestors) => { const parent = ancestors[ancestors.length - 1]; if (parent?.tagName !== "a") return false; const href = parent.properties?.href; return typeof href === "string" && href === textNode.value; },});
Drop punctilio into any project that already uses Prettier—typography fixes ride along on every Prettier run, with no extra build step. The plugin extends Prettier’s Markdown parser, so Prettier keeps owning whitespace and Markdown layout while punctilio rewrites the prose inside.
Code spans, fenced code blocks, and inline html are left untouched. The plugin currently transforms Markdown (*.md, *.mdx via the Markdown parser); for html files, use the CLI or the rehype plugin below.
punctilio README.md # print formatted output to stdoutpunctilio --write README.md 'docs/**/*.md' # format in place; globs expand internallypunctilio --check README.md # exit 1 if it would change anythingecho '"Hi" -- there' | punctilio - --type md
Two caveats before pointing the CLI (or the pre-commit hook below) at an existing repo. First, the Markdown path re-serializes the whole document through remark-stringify, so the first run may produce formatting diffs beyond typography—escaping, list markers, and link syntax get normalized. Like Prettier, the output then stays stable on subsequent runs. If you already use Prettier, the Prettier plugin avoids this double-formatting entirely, since Prettier keeps owning the printing.
Second, non-breaking spaces (U+00A0) are invisible in source files: they render fine, but they aren’t regular spaces, so Ctrl+F and grep matches against the source can miss. Markdown files therefore default to nbsp off; pass --nbsp (or set nbsp: true in config) to opt in. Html files keep the default of nbsp on; pass --no-nbsp to keep them ascii-spaced.
Fully general prime mark conversion (e.g. 5'10" → 5′10″) requires semantic understanding to distinguish from closing quotes (e.g. "Term 1" should produce closing quotes). punctilio heuristically tracks quote balance to distinguish a prime after a number from a closing quote ("Term 1"). Simpler libraries like tipograph 0.7.4 make more mistakes here.
Periods and commas go outside quotation marks (“Hello,” she said.)
Spaced en-dashes between words (word—word)
The german style uses low-9 quotes: „double“ (U+201E / U+201C) and ‚single‘ (U+201A / U+2018).
Punctuation outside quotes
The french style uses guillemets padded with a narrow no-break space (U+202F), per Unicode cldr’s fr locale and the Imprimerie nationale’s Lexique des règles typographiques: « Bonjour ».
Single quotes remain as curly quotes
Punctuation outside quotes
Setting either style to none skips the entire transform category: punctuationStyle: 'none' preserves straight quotes, apostrophes, and prime marks; dashStyle: 'none' preserves all hyphens, number ranges, date ranges, and minus signs.
punctilio is idempotent by design: transform(transform(text)) always equals transform(text). Set checkIdempotency: true to verify this on every call (off by default, since the check doubles the work); punctilio’s own test suite runs with it enabled.
Use classifyApostrophes(text) to distinguish apostrophes from closing single quotes. It returns text with apostrophes as U+02BC (modifier letter apostrophe) and closing quotes as U+2019 (right single quotation mark). Per the Unicode Standard,transform() and niceQuotes() use U+2019 for both in their output.