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.

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" });

// 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"
}
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")

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

try:
    transform(input, locale="xx")
except PolytypoError as error:
    error.code  # "POLYTYPO_UNKNOWN_LOCALE"
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",
    })
}
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")

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
<?php
use Polytypo\Polytypo;
use Polytypo\PolytypoException;

$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']);

try {
    Polytypo::transform($input, ['locale' => 'xx']);
} catch (PolytypoException $error) {
    $error->errorCode; // 'POLYTYPO_UNKNOWN_LOCALE'
}

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

Options

Option Type Default Notes
locale string Required, no exceptions. Unknown locale throws POLYTYPO_UNKNOWN_LOCALE rather than falling back to English. Resolution (de-ATde) follows spec/rules/locale-resolution.md.
mode "text" | "html" | "markdown" "text" Selects the adapter. html parses with parse5, markdown with micromark; both run the same rule pipeline only on prose text nodes, never inside tags, attributes, code spans or code blocks.
dialect "commonmark" | "mdx" Required when mode is "markdown", with no default — omitting it throws POLYTYPO_INVALID_DIALECT. Dialect detection is forbidden by design: one <https://…> autolink is valid CommonMark and invalid MDX, so a heuristic would silently reclassify content. Ignored for the other two modes.
rules Partial<Record<RuleId, boolean>> all on Opt-out only — { dashes: false }. Disabling a rule removes it from the pipeline; it never reorders the rest. true is a no-op. An unrecognised rule id throws POLYTYPO_UNKNOWN_RULE.

Error codes

All seven are part of the contract in every runtime; messages are English and are not.

Code Thrown when
POLYTYPO_UNKNOWN_LOCALE locale is missing or not in the spec's locale registry.
POLYTYPO_INVALID_MODE mode is set to something other than text, html or markdown.
POLYTYPO_INVALID_DIALECT mode: "markdown" without a dialect, or an unrecognised one.
POLYTYPO_UNKNOWN_RULE A key in rules that is not one of the nine rule ids.
POLYTYPO_MALFORMED_LOCALE_DATA Internal — a locale's data failed its own schema at load time.
POLYTYPO_RULE_CONTRACT Internal — a rule emitted an edit outside its contract (out of bounds, overlapping, wrong ruleId). Should never surface; if it does, it's a bug in this project, not your input.
POLYTYPO_MALFORMED_INPUT Reachable only when mode: "markdown" and dialect: "mdx" — MDX embeds JavaScript, so an unterminated JSX element or a broken {…} expression can fail to parse. html parsing is recovery-based and never throws this; plain CommonMark has no syntax errors at all. The underlying parser error is wrapped either way, so no dependency's error type reaches the public surface.

Wiring it into a build step

transform() is a plain pure function, so it drops into whatever already touches your content at build time.

// 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. Any rule can be switched 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 planif there is onefails.
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 take an en dash, unspaced.
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
dont
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.

Ten 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, for macOS and Windows. 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.