Reference
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.
| Option | Type | Default | Notes |
|---|---|---|---|
| locale | string | — |
Required, no exceptions. Unknown locale throws POLYTYPO_UNKNOWN_LOCALE rather
than falling back to English. Resolution (de-AT → de) 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.
|
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.
|
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
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.
| Rule | Locale | In | Out | What it does |
|---|---|---|---|---|
| spaces | en-US | Hello , world ! | Hello, world! | Collapse repeated spaces, strip the space before punctuation. |
| ellipsis | en-US | Wait... what? | Wait… what? | Three dots become U+2026. |
| ellipsis | ru | Что?... | Что?.. | Russian keeps the abbreviated form after terminal punctuation. |
| dashes | en-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. |
| dashes | en-US | chapters 3-5 and pp. 34-36 | chapters 3-5 and pp. 34-36 | Numeric and date ranges take an en dash, unspaced. |
| hyphen | ru | Достал из-под стола | Достал из‑под стола | Morphological hyphens bound with U+2011 so they cannot break. |
| quotes | de-DE | "Er sagte 'nein' zu mir", notierte sie. | „Er sagte ‚nein‘ zu mir“, notierte sie. | Primary and secondary quotes, with nesting resolved. |
| apostrophe | en-US | don't | don’t | Straight apostrophe to U+2019, contractions intact. |
| symbols | en-US | Copyright (c) 2026, 1920x1080 | Copyright © 2026, 1920×1080 | (c) (r) (tm) and the multiplication sign between numerals. |
| nbsp | fr | Ç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
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.
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.
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.
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.
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.
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
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.
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.
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.
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.