Spec version: 1.20.1 · locales: 27 · rules: 9.
Implementations
What it does
Input
She asked, "Isn't this the shop they call 'round the corner'?" ... We'd walked - nearly 3 km - just to find it closed. Copyright (c) 2026; the print measures 40x60 cm.
| Locale | Output |
|---|
Bulgarian (bg) | Тя попита: „Не е ли това магазинът, който наричат „на ъгъла“?“ … Вървяхме – почти 3 км – и беше затворен. Copyright © 2026, формат 40×60 см. |
Croatian (hr) | Pitala je: „Nije li to dućan koji zovu ‘onaj na uglu’?” … Hodali smo – gotovo 3 km – i bio je zatvoren. Copyright © 2026, format 40×60 cm. |
Czech (cs) | Zeptal se: „Není to ten obchod, kterému říkají ‚ten za rohem‘?“ … Šli jsme – skoro 3 km – a byl zavřený. Copyright © 2026, formát 40×60 cm. |
Dutch (nl) | Hij vroeg: “Is dit niet de winkel die ze ‘om de hoek’ noemen?” … We liepen – bijna 3 km – en hij was dicht. Copyright © 2026, formaat 40×60 cm. |
English (United Kingdom) (en-GB) | She asked, ‘Isn’t this the shop they call “round the corner”?’ … We’d walked – nearly 3 km – just to find it closed. Copyright © 2026; the print measures 40×60 cm. |
English (United States) (en-US) | She asked, “Isn’t this the shop they call ‘round the corner’?” … We’d walked—nearly 3 km—just to find it closed. Copyright © 2026; the print measures 40×60 cm. |
Finnish (fi) | Hän kysyi: ”Eikö tämä ole se ’nurkan’ kirjakauppa?” … Kävelimme – lähes 3 km – ja löysimme sen kiinni. Copyright © 2026; tulosteen koko on 40×60 cm, hinta nousi 10,5 %. |
French (Canada) (fr-CA) | Elle a dit : « Ce n’est pas la librairie qu’on appelle “le coin”, si? » … On a marché — presque 3 km — pour la trouver fermée. Copyright © 2026; le tirage fait 40×60 cm. |
French (France) (fr) | Elle a dit : « Ce n’est pas la librairie qu’on appelle “le coin”, si ? » … On a marché — presque 3 km — pour la trouver fermée. Copyright © 2026 ; le tirage fait 40×60 cm. |
German (Germany) (de-DE) | Sie fragte: „Ist das nicht der Laden namens ‚an der Ecke‘?“ … Wir liefen – fast 3 km – und fanden ihn leider geschlossen. Copyright © 2026, Format 40×60 cm, z. B. für Poster. |
German (Switzerland) (de-CH) | Sie fragte: «Ist das nicht der Laden namens ‹an der Ecke›?» … Wir liefen – fast 3 km – und fanden ihn leider geschlossen. Copyright © 2026, Format 40×60 cm, z. B. für Poster. |
Greek (el) | Ρώτησε: «Δεν είναι αυτό το μαγαζί “στη γωνία”;» … Περπατήσαμε —σχεδόν 3 χλμ— και το βρήκαμε κλειστό. Copyright © 2026, μέγεθος 40×60 cm. |
Every string above is real engine output, generated from promo/examples.json — not typed by hand.
Locales
| Locale | Language | Primary quotes | Secondary | Parenthetical dash | Range |
|---|
bg | Bulgarian | „…“ | „…“ | en dash, spaced | en dash, spaced |
hr | Croatian | „…” | ‘…’ | en dash, spaced | en dash, spaced |
cs | Czech | „…“ | ‚…‘ | en dash, spaced | en dash, unspaced |
nl | Dutch | “…” | ‘…’ | en dash, spaced | unchanged |
en-GB | English (United Kingdom) | ‘…’ | “…” | en dash, spaced | en dash, unspaced |
en-US | English (United States) | “…” | ‘…’ | em dash, unspaced | en dash, unspaced |
fi | Finnish | ”…” | ’…’ | en dash, spaced | en dash, unspaced |
fr-CA | French (Canada) | «…» + U+00A0 | “…” | em dash, spaced | unchanged |
fr | French (France) | «…» + U+00A0 | “…” | em dash, spaced | unchanged |
de-DE | German (Germany) | „…“ | ‚…‘ | en dash, spaced | en dash, unspaced |
de-CH | German (Switzerland) | «…» | ‹…› | en dash, spaced | en dash, unspaced |
el | Greek | «…» | “…” | em dash, spaced outside the pair | unchanged |
hu | Hungarian | „…” | »…« | en dash, spaced | en dash, unspaced |
it | Italian | «…» | “…” | em dash, spaced | unchanged |
|
Aliases resolve in the spec, never in a platform locale library: en → en-US, de → de-DE, pt → pt-PT. An unknown locale throws — there is no silent fallback to English.
Rules
| Order | Rule id | Default | What it does |
|---|
| 10 | spaces | on | Collapse repeated spaces, strip spaces before punctuation, normalize spacing inside brackets. |
| 20 | ellipsis | on | Three dots to U+2026; locale-dependent abbreviated forms after terminal punctuation. |
| 25 | ranges | off | Numeric/date range dash per locale convention (dash.range). |
| 30 | dashes | on | Parenthetical dash per locale convention (dash.parenthetical). |
| 35 | hyphen | on | Bind morphological hyphen forms with U+2011 (non-breaking hyphen) so they cannot be broken across lines. |
| 40 | quotes | on | Straight quotes to locale primary/secondary pairs with nesting resolution. |
| 50 | apostrophe | on | Remaining straight apostrophes to U+2019 without corrupting contractions. |
| 60 | symbols | on | (c)/(r)/(tm) to the corresponding signs; multiplication sign between numerals. |
| 70 | nbsp | on | Insert no-break and narrow no-break spaces per locale. |
Rule ids are public API. Renaming one is a breaking change. Order comes from
spec/rules/order.json, never registration order and never map iteration order.
Four of the characters these rules insert are invisible or rare: U+00A0 no-break space (most
locales), U+202F narrow no-break space (fr only, before ; ! ?), U+2011 non-breaking hyphen
(ru only, from hyphen) and U+2060 word joiner (around the dash of a bound range, and only with
ranges on). Fonts do not all carry the last three — a browser falls back to another face, while a
PDF renderer with a single embedded font can drop the glyph. rules: { hyphen: false } turns off
the only rule that emits U+2011, and ranges is off by default, so U+2060 appears only where it
was asked for.
Examples, by rule
| Rule | In | Out |
|---|
quotes | "He said 'no' to me," she noted. | “He said ‘no’ to me,” she noted. |
dashes | The plan - if there is one - fails. | The plan—if there is one—fails. |
dashes | chapters 3-5 and pp. 34-36 | chapters 3-5 and pp. 34-36 |
ranges (rules: { ranges: true }) | chapters 3-5 and pp. 34-36 | chapters 3–5 and pp. 34–36 |
ellipsis | Wait... what? | Wait… what? |
apostrophe | don't | don’t |
nbsp | It is 20 km to the coast | It is 20⍽km to the coast |
symbols | Copyright (c) 2026, 1920x1080 | Copyright © 2026, 1920×1080 |
⍽ is not in the output — it marks U+00A0 NO-BREAK SPACE, which is otherwise indistinguishable
from an ordinary space on this page. The nbsp rule's entire job is invisible, which is exactly why
spec/fixtures/ is mirrored by a CI-generated escaped \uXXXX file: a diff full of unannotated
U+00A0 and U+202F cannot be reviewed by a human.
Modes
| Mode | What it processes | Status |
|---|
text | The whole string | Implemented |
html | Text nodes only; skips code, pre, kbd, samp, var, script, style, textarea, all attributes and existing entities | Implemented |
markdown | Prose only; skips code spans, fenced blocks, link destinations, autolinks | Implemented (CommonMark; MDX is JS-only) |
yaml | Only the values of the mapping keys you name in the required keys option — plain, quoted and block scalars; the keys themselves and everything unnamed are left alone | Implemented |
In every mode the output is the input with a set of disjoint substring replacements applied, and
nothing else. The parser locates text; it never produces output.
text mode has no parser and no skip list, so everything in the string is prose to it: code gets
typeset, and in French a character reference such as & is broken by a no-break space before
its ;. A string that can contain markup or character references belongs in html mode, which
takes a fragment as readily as a whole document.
Each call sees only the string it is given, and rules stop reading context at its ends. A caller
that transforms a component tree one text node at a time loses that context at every node, so a
dash next to </strong> is left alone. Pass the whole fragment to html mode in one call instead.
One call also means one locale: language is never detected, so a document that mixes languages is
split by the caller.
Conformance
An implementation is polytypo if and only if it passes the conformance suite for the spec version
it claims. The suite lives in spec/fixtures/<locale>.json as flat in/out pairs written with
literal characters, mirrored by a CI-generated escaped file so that a diff of an invisible U+202F is
reviewable by a human.
Every fixture is also an idempotency case: the runner asserts transform(out) == out, passing the
case's own options on that second call. Cases are
tagged with a rule id, so a runtime can report partial conformance honestly instead of claiming all
of it.
Contributing
A behaviour change is a spec change first, code second.
spec/rules/<id>.md — the semantics, in prose, with worked examples
spec/rules/order.json — the rule's position and default
spec/locales/<code>.json — data only: literal strings, code points, lists, enums
spec/fixtures/<code>.json — cases, including an idempotency case
- Only then: the implementation
Locale claims are settled by citation, not preference. Every locale file carries a mandatory
sources array pointing at the normative reference — Duden, Imprimerie nationale, Kotus,
Språkrådet, Chicago, Мильчин. Where that reference is a paid standard nobody can read, the rule is
standardised on the prevailing form instead, and the sources entry says exactly that: a recorded
decision, labelled as one, never dressed up as a citation.
Licence
Source code: MIT. Brand assets (the mark, the wordmark and their lockups) are not covered by the
MIT licence — see brand/README.md. They may be used unmodified to refer to or credit this project,
and not as another project's identity, nor on goods for sale, without permission.