Reference

One function. No configuration, no globals, no I/O.

transform(input, options) takes a string and a locale and returns a string. It is pure: no filesystem, no network, no clock, no ambient config, no module-level state. Locale is required — an unknown locale throws rather than silently falling back to English.

Getting started

  • Installation Install polytypo for JavaScript, Python, Go, Ruby or PHP and make a first call.
  • Choosing a mode Which mode to call for which input, and what goes wrong when a string is passed to the wrong one.

API

  • transform The one function polytypo is built around: its signature in all five runtimes, what it returns, what it throws, and the purity it guarantees.
  • Options Every polytypo option, its type and default, what it requires, and which error an invalid value raises.
  • analyze The same pipeline as transform, reporting what it would change instead of changing it, with code-point offsets into your input.
  • Error codes The eight stable polytypo error codes, when each is thrown, and why the code, not the message, is the contract.

Modes

  • Modes The four ways polytypo reads a document, what each one treats as prose, and how to choose between them.
  • Text mode The default mode. The whole string is prose, there is no parser and no skip list, so everything in it is typeset.
  • HTML mode Typeset the prose in an HTML document or fragment while tags, attributes, code, scripts and character references come back byte for byte.
  • Markdown mode Typeset the prose of a CommonMark or MDX document, leaving code, links, HTML and frontmatter exactly as written.
  • Markdown dialects Why markdown mode requires an explicit dialect, what commonmark and mdx each mean, and what happens when the document does not parse.
  • Markdown skip list Exactly which parts of a Markdown document markdown mode leaves untouched, and which parts it typesets.
  • Frontmatter How markdown mode treats a YAML or TOML frontmatter block, how to typeset named fields with frontmatterKeys, and how the block is located.
  • MDX How markdown mode handles MDX: JSX elements and attributes, expressions, ESM statements, and documents that fail to parse.
  • YAML mode Typeset the prose values of named keys in a YAML file, with a specified scan instead of a parser, and everything else returned byte for byte.
  • YAML keys How the keys option selects values in yaml mode: exact matching, any depth, no paths or globs, and which lines the scan reads as keys.
  • Quoting and scalar forms Which YAML value forms yaml mode typesets, which it leaves untouched, and why a colon or hash inside a plain scalar is treated as a boundary.

Rules

  • spaces The spaces rule collapses runs of ordinary spaces, removes spaces before punctuation and inside brackets, and leaves indentation, hard breaks and emoticons alone.
  • ellipsis The ellipsis rule turns runs of three or more dots into the single character U+2026 and writes the two-dot form after ? and ! in locales that use it.
  • ranges The opt-in ranges rule turns a hyphen between two numbers into the locale's range dash, with the guards that protect dates, phone numbers and decimals.
  • Dashes The dashes rule sets the parenthetical dash in the form each locale prescribes, and leaves compound hyphens, numbers, ranges and arrows alone.
  • Hyphen The hyphen rule binds the hyphen in listed Russian and Ukrainian forms with a non-breaking hyphen so a line break cannot split the word.
  • Quotes How the quotes rule turns straight and foreign quotation marks into each locale's primary and secondary pairs, resolves nesting, and leaves unmatched marks alone.
  • Apostrophe The apostrophe rule turns the straight apostrophe into U+2019 in contractions, elisions, possessives and decade abbreviations, and leaves foot marks and primes alone.
  • symbols The symbols rule converts (c), (r) and (tm) to the copyright, registered and trademark signs, between-number x to the multiplication sign, and +/- before a digit to a plus-minus sign.
  • No-break spaces How the nbsp rule converts and inserts U+00A0 and U+202F per locale, with the ten lists it reads, the narrowNbsp option and what it leaves alone.

Concepts

  • The span model How html, markdown and yaml modes show the rules only the prose of a document, and why spans are joined with boundary markers instead of processed alone.
  • Round trip In html, markdown and yaml modes the output is your input with a few substring replacements applied, and nothing is re-serialised.
  • Idempotency Why transform(transform(x)) equals transform(x) is a contract in every runtime, and what it means for text that is already typeset.
  • Rule order The fixed order in which the nine polytypo rules run, why each sits where it does, why ranges is off by default, and how options never reorder them.
  • Locale resolution How the locale option becomes exactly one locale: exact match, alias, one region strip, and an error otherwise, never a fallback to English.
  • Invisible characters The four characters polytypo inserts that you cannot see, U+00A0, U+202F, U+2011 and U+2060, what inserts each, how to spot them, and the narrowNbsp option.

Guides

  • Running polytypo in a build step Typeset Markdown, MDX and HTML files at build time with a complete Node script, choosing locale, mode and dialect per file, with a check mode for CI.
  • Typesetting an MDX blog Process MDX posts with markdown mode and the mdx dialect, typeset frontmatter title and description, and know what is skipped, what is prose and what throws.
  • Typesetting prose in YAML config Use yaml mode on OpenAPI-style and config YAML, naming the keys that hold prose, and see how quoted, plain and block scalars are handled and what is skipped.

Runtimes

  • JavaScript and TypeScript Install polytypo from npm, call transform and analyze in JavaScript or TypeScript, read error codes, and see which modes and dialects the package covers.
  • Python Install polytypo from PyPI, call transform and analyze with keyword arguments, handle PolytypoError codes, and see which modes and dialects the package covers.
  • Go Install polytypo for Go, call Transform and Analyze with an Options struct, check *polytypo.Error codes, and see which modes and dialects the module covers.
  • Ruby Install the polytypo gem, call Polytypo.transform and Polytypo.analyze with keyword arguments, rescue Polytypo::Error codes, and see which modes the gem covers.
  • PHP Install polytypo with Composer, call Polytypo::transform and Polytypo::analyze with named arguments, catch PolytypoException codes, and see which modes PHP covers.
import { transform } from "polytypo";

const output = transform(input, { locale: "de" });

// mode "markdown" requires an explicit dialect; "text" and "html" ignore it
transform(input, { locale: "fr", mode: "markdown", dialect: "commonmark" });

// mode "yaml" requires the keys whose values hold prose; every other line comes back untouched
transform(input, { locale: "en-US", mode: "yaml", keys: ["description"] });

// opt out of a single rule; the order of the rest never changes
transform(input, { locale: "en-US", rules: { dashes: false } });

try {
  transform(input, { locale: "xx" });
} catch (error) {
  error.code; // "POLYTYPO_UNKNOWN_LOCALE"
}

Repo · Package

from polytypo import transform, PolytypoError

output = transform(input, locale="de")

# mode "markdown" requires an explicit dialect; "text" and "html" ignore it
transform(input, locale="fr", mode="markdown", dialect="commonmark")

# mode "yaml" requires the keys whose values hold prose; every other line comes back untouched
transform(input, locale="en-US", mode="yaml", keys=["description"])

transform(input, locale="en-US", rules={"dashes": False})

try:
    transform(input, locale="xx")
except PolytypoError as error:
    error.code  # "POLYTYPO_UNKNOWN_LOCALE"

Repo · Package

package main

import (
    "errors"
    "fmt"

    "github.com/polytypo/polytypo-go"
)

func main() {
    out, err := polytypo.Transform(input, polytypo.Options{Locale: "de"})
    if err != nil {
        var perr *polytypo.Error
        if errors.As(err, &perr) {
            _ = perr.Code // "POLYTYPO_UNKNOWN_LOCALE"
        }
    }
    fmt.Println(out)

    // Mode "markdown" requires an explicit Dialect; "text" and "html" ignore it
    _, _ = polytypo.Transform(input, polytypo.Options{
        Locale:  "fr",
        Mode:    "markdown",
        Dialect: "commonmark",
    })

    // Mode "yaml" requires the Keys whose values hold prose; every other line is untouched
    _, _ = polytypo.Transform(input, polytypo.Options{
        Locale: "en-US",
        Mode:   "yaml",
        Keys:   []string{"description"},
    })
}

Repo · Package

require "polytypo"

output = Polytypo.transform(input, locale: "de")

# mode "markdown" requires an explicit dialect; "text" and "html" ignore it
Polytypo.transform(input, locale: "fr", mode: "markdown", dialect: "commonmark")

# mode "yaml" requires the keys whose values hold prose; every other line comes back untouched
Polytypo.transform(input, locale: "en-US", mode: "yaml", keys: ["description"])

Polytypo.transform(input, locale: "en-US", rules: { "dashes" => false })

begin
  Polytypo.transform(input, locale: "xx")
rescue Polytypo::Error => error
  error.code # => "POLYTYPO_UNKNOWN_LOCALE"
end

Repo · Package

<?php
use Polytypo\Polytypo;
use Polytypo\PolytypoException;

$output = Polytypo::transform($input, 'de');

// mode 'markdown' requires an explicit dialect; 'text' and 'html' ignore it
// (markdown mode is not implemented by this runtime -- see spec/CONFORMANCE.md)
Polytypo::transform($input, 'fr', mode: 'markdown', dialect: 'commonmark');

// mode 'yaml' requires the keys whose values hold prose; every other line comes back untouched
Polytypo::transform($input, 'en-US', mode: 'yaml', keys: ['description']);

// opt out of a single rule; the order of the rest never changes
Polytypo::transform($input, 'en-US', rules: ['dashes' => false]);

try {
    Polytypo::transform($input, 'xx');
} catch (PolytypoException $error) {
    $error->getErrorCode(); // 'POLYTYPO_UNKNOWN_LOCALE'
}

Repo · Package

Error codes are part of the contract in every runtime — full list below. Messages are English and are not part of the contract.

Options

locale is always required; dialect with mode: "markdown" and keys with mode: "yaml". Every option, its default and the error an invalid value raises is on the options page.

Error codes

All eight are part of the contract in every runtime; messages are English and are not. Each code, and when it is thrown, is on the error codes page.

What to pass in

A string that is prose from end to end goes to text; anything with tags goes to html; a Markdown file to markdown with its dialect; a YAML file to yaml with the keys of its prose fields. Choosing a mode covers what goes wrong with the wrong one, and invisible characters covers what the output contains that you cannot see.

Wiring it into a build step

transform() is a plain pure function, so it drops into whatever already touches your content at build time. Put it before your formatter: typography changes line lengths, since three dots become one character and no-break spaces are inserted, so a formatter that wraps prose should wrap the final text.

// e.g. a remark/unified plugin, or any step that reads .md/.mdx files
import { readFile, writeFile } from "node:fs/promises";
import { transform } from "polytypo";

const path = "content/posts/hello-world.mdx";
const source = await readFile(path, "utf8");
const dialect = path.endsWith(".mdx") ? "mdx" : "commonmark";

await writeFile(path, transform(source, { locale: "en-US", mode: "markdown", dialect }));
// transform(transform(x)) === transform(x) — safe to run on every build, not just once.
// wherever a rich-text field is persisted — a webhook handler, a save hook
import { transform } from "polytypo";

function sanitizeBody(html, locale) {
  // html parsing is recovery-based and never throws POLYTYPO_MALFORMED_INPUT — that code is
  // reachable only for markdown's mdx dialect, which embeds JavaScript. Nothing to catch here.
  return transform(html, { locale, mode: "html" });
}
// call it wherever untrusted or imported copy reaches a render — not on every keystroke
import { transform } from "polytypo";

function Byline({ text, locale }) {
  return <p>{transform(text, { locale })}</p>;
}

What it changes

Nine rules, in a fixed order, on every runtime.

Order is declared once in spec/rules/order.json and is identical everywhere — not registration order, not map-iteration order. Every rule except ranges is on by default. Any rule can be switched on or off; nothing else moves when you do.

RuleLocaleInOutWhat it does
spacesen-US
Hello , world !
Hello, world!
Collapse repeated spaces, strip the space before punctuation.
ellipsisen-US
Wait... what?
Wait… what?
Three dots become U+2026.
ellipsisru
Что?...
Что?..
Russian keeps the abbreviated form after terminal punctuation.
dashesen-US
The plan - if there is one - fails.
The plan—if there is one—fails.
Parenthetical dash, per locale: em tight, en spaced, em spaced.
dashesen-US
chapters 3-5 and pp. 34-36
chapters 3-5 and pp. 34-36
Numeric and date ranges are left alone — converting them is the separate ranges rule, off by default.
rangesen-US
chapters 3-5 and pp. 34-36
chapters 3⁠–⁠5 and pp. 34⁠–⁠36
Off by default; with rules: { ranges: true }, numeric ranges take an en dash held by word joiners.
hyphenru
Достал из-под стола
Достал из‑под стола
Morphological hyphens bound with U+2011 so they cannot break.
quotesde-DE
"Er sagte 'nein' zu mir", notierte sie.
„Er sagte ‚nein‘ zu mir“, notierte sie.
Primary and secondary quotes, with nesting resolved.
apostropheen-US
don't
don’t
Straight apostrophe to U+2019, contractions intact.
symbolsen-US
Copyright (c) 2026, 1920x1080
Copyright © 2026, 1920×1080
(c) (r) (tm) and the multiplication sign between numerals.
nbspfr
Ça va ? Bonjour !
Ça va ? Bonjour !
No-break and narrow no-break spaces, inserted per locale.

Examples are real engine output for the locale named in each row. Grey marks what was typed; red marks what the engine set instead. Try any of these live in the Playground.

Why this and not a regex

Because the fifth port is where regex-based typography dies.

The spec is the product

Locale data, rule semantics and conformance fixtures live in one repository. An implementation is polytypo iff it passes the suite for the spec version it claims. Runtimes are replaceable; the spec is not.

No regex in the core

Go's RE2 has no lookbehind. A rule written as a clever regex in one language is a rule rewritten from scratch in the next. Rules are a single left-to-right scan over code points with explicit lookaround.

Idempotent by contract

transform(transform(x)) === transform(x), proven by property-based tests, not hoped for. Running it twice — in a CMS, in CI, in a build step — cannot corrupt text.

Every locale is cited

Duden, Imprimerie nationale, Kotus, Språkrådet, Chicago, Мильчин. A locale ships only as a triple: data, fixtures, citation. Disagreements are settled by source, not by preference.

24 correct beats fifty guessed

The competing library with fifty locales guesses most of them. When the choice is "add a language" or "make an existing one provably correct", this project picks correctness.

Invisible characters, on purpose

U+00A0, U+202F and U+2011 are the whole point of the nbsp and hyphen rules. Every demo on this site reveals them with an underline — hover a highlighted character to see exactly what it is.

Before the text ever reaches a build step

Type it right in the first place: Ilya Birman's Typography Layout.

polytypo fixes text that has already been written. It is a repair pass — useful in a CMS, a build step, a content pipeline. But the best em dash is the one you typed on purpose, and for that there is a better tool than any library: a keyboard layout that puts real typographic characters under real keys.

Ilya Birman's Typography Layout is what we recommend, without reservation. It leaves your usual layout alone and adds the correct characters on the right-hand modifier: em dash, en dash, the quotation marks of several languages, the ellipsis, the no-break space, degrees, arrows, currency signs. You stop thinking about it within a week, and then you never type -- again.

Typing beats fixing

A rule engine has to infer intent from context. Your fingers already know it. Every character typed correctly is a character no heuristic has to guess at.

They compose

Use the layout while writing, run polytypo over the result. Text that is already correct passes through unchanged — that is what idempotency means in practice.

Where each one wins

The layout works where a person types. polytypo works where nobody is typing at all: imported catalogues, user submissions, migrated content, other people's Markdown.

No affiliation, no arrangement — just the tool this project's author actually uses. Available for macOS and Windows at ilyabirman.net/typography-layout.

Embedding the badge

What the badge needs, and what it does when it doesn't get it.

The span you paste is empty; the script fills it in. So if the script never runs — a Content Security Policy, a blocker, a loading strategy that doesn't fire — the span stays empty and nothing appears. The script cannot report that itself, since it never ran: what you have is the browser's own evidence, a CSP violation or a failed request, so check that badge.js was requested and executed before you look anywhere else. Every failure the script can see it writes to the console, prefixed polytypo badge:.

badge.js does no I/O at all. Every language and both themes are inlined in the file, so rendering is synchronous and works offline once the file is loaded. Serving it needs script-src https://polytypo.dev and nothing else: no connect-src, because nothing is fetched, and no img-src, because the mark is inline SVG. The badge styles itself with inline style attributes, so a policy that blocks inline styles leaves it unstyled.

async in the snippet is about your own page's load order and nothing else: once the file is evaluated, rendering is a synchronous read from that table. An unknown data-polytypo-lang or data-polytypo-theme renders English and the light theme rather than throwing, so a locale code where a language code belongs (de-CH instead of de) shows up as an English badge — with a console warning naming the attribute, the value, and the languages it accepts.

data-polytypo-lang and data-polytypo-theme are read once, when the script runs — on DOMContentLoaded, or immediately if the document is already parsed. Changing an attribute afterwards does nothing by itself: there is no MutationObserver and no matchMedia listener in the file. The script exports the hook for that: set the attribute, then call PolytypoBadge.render(el) to re-render that one badge, or PolytypoBadge.init() to re-render every badge on the page.

The same applies to a span that appears after the script has run — a client-side route change, a component mounted later. It is not picked up on its own; render it, or call init() again once it is in the document. A run of init() that finds no badge at all says so in the console rather than doing nothing quietly.

Already using polytypo? Add our badge

Two lines: a script tag and a span. No per-embed network call. Pick a language and a theme, then copy the code into your footer or credits. Embedding notes cover Content Security Policy. Sites with the badge are listed on Sites using polytypo.

<script async src="https://polytypo.dev/badge.js"></script>
<span data-polytypo-lang="en" data-polytypo-theme="light"></span>

Search runs in your browser. Nothing is sent anywhere.