Skip to the text

Docs Project

Changelog

Versions follow semver while the package is below 1.0.0: a minor release may change the output of the engine or the API (every entry says whether it can change output you already have), a patch release fixes a bug without changing output you rely on. If you depend on exact output, pin a tag and read this file before you move it.

0.11.0 (2026-10-22)

Changes

0.10.0

New: an option

New: two rules

Changes

Tooling and docs

0.9.0

Changes

Tooling and docs

0.8.0

Fixes

New: a rule

Tooling and docs

0.7.0

Fixes

Tooling and docs

0.6.0

0.5.0

Fixes

React

Tooling and docs

0.4.1

0.4.0

0.3.0

0.2.1

Performance only: no change to output, except the one case named in the last bullet.

Speed

0.2.0

Fixes that change output come first; everything new is opt-in or an option, so output you already have changes only where an entry says so.

Fixes

New: 20 languages

bg cs da el es et fi ga hr hu it lt lv mt nl pt ro sk sl sv join en uk de pl fr ch: all 24 official languages of the EU, Ukrainian and Swiss usage. Each is one pack, src/core/lang/<code>.ts, with quote marks and nesting, sentence dash, orphans, abbreviations, % spacing, number format and cited sources; a generated page per language is in docs/languages/. None has been read by a native speaker (reviewed: false); read the page before relying on one. Lang and LANGS are now derived from the packs, so a pack is the only place a language is named. Can change existing output: yes, for pages that set lang="es" (or any new code) on rehype or HTML text: that code used to fall back to English and now selects a real profile.

New: options

New: nine rules, off by default

Switch on with enable (or --enable): space.before-punctuation, space.after-comma, punctuation.double, space.trailing, symbol.plusminus, symbol.fraction, symbol.degree, symbol.implies, number.group (locale thousands separators from the pack: 1,000,000 en, no-break space uk/fr, . de). Their limits are in docs/limits.md. Existing output: unchanged unless you enable them. Ordinals are not done.

Speed

1 MB of English prose in text mode: 324 ms to 33 ms (typograf: 59 ms). Cause: regular expressions that open with a lookbehind are tried at every position; rules now find a literal first and read the character before it (src/core/scan.ts), skip when a cheap trigger (Rule.when) is absent, and the quote rule returns early and rebuilds in one pass. A differential run against the previous engine (26 languages, both registers, both quote styles, opt-in rules on and off, text, HTML, Markdown and hast, output and explain records) found no difference, and src/snapshot.test.ts keeps a digest per language. One residual difference, on Bun only: with symbol.fraction on, ill-formed UTF-16 (a lone high surrogate right after a fraction, then a character at U+E000 or above) no longer gets its ½. Can change existing output: no, for well-formed text.

Tooling

bun run compare (differential run against a git ref), bun run snapshot and docs:languages; a loose speed budget in src/speed.test.ts; the benchmark has 250 scored cases for 26 languages and more competitors (still a separate project in bench/).

0.1.1

0.1.0

The first release as a project of its own. The engine was developed inside another repository; everything under “Before 0.1.0” is that history, by date. Nothing about the output changed in the move.

Before 0.1.0

2026-10-20

German quotations in a row are stable

German output (»a« und »b«) is now read the same way on a second run. Before, the « that ends one quotation and the » that opens the next were taken for a pair, so running the engine again (for example typo --check after typo --write) changed the text. In German the pairs are now »…« and ›…‹, each read by where the marks stand, and a text written «…» is read by position too. First-run output on ordinary text is unchanged (checked on 57,024 combinations of text, language and register); the difference is only in text that was already typeset, or that holds two quotations in a row. Can change existing output: yes, for German text with two adjacent quotations that had been run through the engine twice.

Hanging punctuation is sent inline, so it runs as the page is parsed

New: hangScript() (from ./dom), <HangScript /> and useHangingPunctuation (from ./react), and an early prop on TypographyRoot (default true). hangPunctuation was rewritten to be self-contained so its source can be sent to the page.

Can change existing output: yes, for React pages. TypographyRoot now renders an inline <script> (the length of hangScript(), about 2.4 kB) on the server and while hydrating, so a prerendered page changes. Pass early={false} to keep the old output, and do that on any page whose content security policy forbids inline scripts. The marks that start a wrapped line now hang at first paint instead of moving by their own width once the bundle has loaded.

A rehype plugin

New entry ./rehype: the default export rehypeTypography and typesetHast(tree, options). It reads an element’s lang, skips code and data-typography="off", and typesets a block at a time so a quotation pairs across a link. Existing output: unchanged (new entry; the sets of block and raw elements are now shared with the HTML mode, with no change).

German nested quotations are »outer ›inner‹ outer«

Before, a German quotation inside a quotation was set »outer „inner“ outer«, a mix no style guide uses. Now the inner level is ›…‹.

Can change existing output: yes, for German text with a nested quotation. Plain German quotations are unchanged.

Edge cases found by seeded property tests

So that a second run (typo --check after --write) reports nothing:

Can change existing output: yes, for the inputs named, which are unusual. A comparison with the previous commit over 64,980 combinations of real text, language, register and block start found no difference.

Dash profiles, abbreviations, % and €, minus, multiplication, lone quotes

Can change existing output: yes. A comparison on real text found only the intended differences (the German dash, lone quotes, % in French, minus, abbreviations).

Rules have ids; disable, explain and onChange

The engine is split into quotes, dashes, spaces, marks and zones and run from the RULES table in order. New API: RULES, RULE_IDS, the disable option (ids or groups; an unknown name throws), explain(text, lang, options) returning { output, changes }, the onChange option and the Change type. The text, HTML and Markdown modes and the command line (--disable, --rules, --explain, --json) take them too. Apostrophes now belong to their own rule, apostrophe.

Can change existing output: no. The split output was compared with the previous engine over 114,816 combinations of real text, language, register and block start, with no difference.

Plain-text mode and the typo command

New: typesetText (a paragraph at a time, keeping line breaks, indentation and blank lines) and the typo command (needs Bun): standard input or files, text, HTML or Markdown picked from the extension, --write, and --check (exit 1 if a file would change). Existing output: unchanged (new).

Addresses and code are protected; HTML and Markdown modes

Can change existing output: yes, for text that holds an address, an email or a code span, a year with a leading apostrophe, or an arrow with a plain space.

The engine becomes its own package

The code, which first lived inside the app’s internationalisation package, moved into a package of its own, with entries . (the rules), ./dom (the framework-free hanging measurer) and ./react. A boundary test fails if anything in the package imports from outside it. No behaviour change; the import path changed.

2026-10-18

A number binds only to a short word

space.number now binds a number to the word after it only when that word has at most nine letters (21 September, 15 people, 5 km), and no longer binds a month to its year (September 2026). A chain of bound words could outgrow a narrow column on a phone. Can change existing output: yes, wherever a number was followed by a longer word, or a month by a year, which now wrap freely.

The literary register

New: a second axis next to language, the register option (display or literary) and the Register type. display is the default and is unchanged. literary sets British English quotes (‘outer “inner” outer’) and a Ukrainian dialogue line, opened by a dash, with an em dash and a no-break space; other languages keep their display rules. In React, <TypographyRoot register="literary"> or <Typeset register=…>.

Can change existing output: no, unless you opt in.

2026-10-17

Ranges and hanging

The first rules

New: typographic(text, lang) and splitHangs(text, atStart). Quotation by language and nesting level («outer „inner“» for English and Ukrainian; »outer „inner“« for German, changed later, see 2026-10-20; and Polish, French and Swiss), dashes (a spaced hyphen is an em dash, a numeric range an en dash), the typographic apostrophe, the ellipsis, arrows and © ® ™, and no-break spaces after titles and initials, between a number and its unit or month, and after one-letter words per language. Plain TypeScript with no dependencies.

Something wrong or unclear here? Report an issue.