Docs Guides
Contributing
Everything here runs from the project folder with Bun.
Checks
bun test src apps/playground apps/website resources # every test; this is the package's `test` script
bun run typecheck # tsc -p tsconfig.json; tests are typechecked too
bun test does not typecheck; bun run typecheck does, and covers src, apps/playground, apps/website, scripts and resources (the include of tsconfig.json).
Lint and format are Biome, configured in biome.json: bunx biome ci . checks, and CI (.github/workflows/release.yml) runs it before the tests; bunx biome check --write . fixes what it can.
When you change a rule or its examples, regenerate the rules page and commit it with the change:
bun run docs:rules # writes docs/rules.md
bun run docs:languages # writes docs/languages/*.md from the packs
Where things are
| Path | What it holds |
|---|---|
src/core/ |
The engine: typography.ts (typographic, explain, typesetBlock, keepAvoided), rules.ts (the RULES table), config.ts (TypographyConfig, defineConfig, checkConfig), quotes.ts, dashes.ts, spaces.ts, marks.ts, extra.ts (the opt-in rules), numbers.ts (digit grouping, number.group), phone.ts (phone.bind), currency.ts (the currency codes and signs), glyphs.ts (what each rule can insert, read by avoid), scan.ts (helpers that read the character before a match instead of a lookbehind), zones.ts (text set aside), lang/ (one pack per language, and the registry), profiles.ts (the tables derived from the packs), hang.ts (splitHangs), diff.ts |
src/text, src/html, src/markdown, src/rehype |
The modes, each built on typesetBlock |
src/dom, src/react |
Hanging punctuation, framework-free and React |
src/cli |
The typo command; run.ts takes an Io object so it can be tested without a process |
scripts/gen-rules-doc.ts |
Generates docs/rules.md |
resources/sources.ts |
The sources registry. bun run docs:sources generates resources/sources.md, resources/sources.html (decisions, with a filter) and resources/bibliography.html (every source in one table: author, link, what it is, where it is used); open the HTML ones in a browser |
scripts/gen-languages-doc.ts |
Generates docs/languages/<code>.md and docs/languages/index.md from the packs |
resources/ |
The sources registry (below) and the pages generated from it; not part of the package |
apps/bench/ |
The benchmark: a separate project with its own dependencies; sources, cases and RESULTS.md tracked, node_modules and lockfile not (see below) |
apps/playground/ |
The web page (its own package.json for dev/build; uses the root’s vite) |
apps/website/ |
The website: landing, docs, articles, playground; see ui.md. The docs it publishes are the files listed in DOC_FILES in apps/website/build/content.ts (apps/website/content/docs/getting-started.md, docs/api.md, cli.md, rules.md, languages.md, limits.md, practices.md, contributing.md and CHANGELOG.md, in that order) plus the generated docs/languages/*.md; README.md, ui.md, backlog.md and ideas.md are not published |
docs/ |
These pages. rules.md and languages/ are generated; the others are written by hand |
Adding a rule
- Write the function in the file that fits:
dashes.ts,spaces.tsormarks.ts(orquotes.tsfor quotation). It takes the text and, if it needs it, a language or context, and returns the text. - Add one entry to
RULESinsrc/core/rules.ts, in the place in the order where it should run:{ id, category, about, apply }, anddefaultOn: falseif the rule must run only when asked for (enable,--enable, a checkbox in the playground). Several places are reserved for planned rules (the comment block aboveRULES;src/core/rules-order.test.tspins the order and checks a planned rule against its place). The id isgroup.name, or one word for a rule that stands alone.aboutis the one-line description thattypo --rulesprints anddocs/rules.mdshows.categoryis one ofRULE_CATEGORIES(tradition,symbols,math,data; see rules.md): what the rule is for, shown as a column bytypo --rulesanddocs/rules.mdand as a group in the playground.tscandsrc/core/rules.category.test.tsfail on a built-in rule without one. - Do not add a step inside
typographic(). It only loops overRULES, which is what makes a rule switchable (disable) and reportable (explain,onChange). - Add examples to
EXAMPLESinscripts/gen-rules-doc.ts(at least one, each of which must change its text when only that rule runs), thenbun run docs:rules. For adefaultOn: falserule the example is run with the rule enabled.src/docs.test.tsfails if a rule has no examples, if an example changes nothing, or ifdocs/rules.mdis out of date. - Add the characters the rule can insert to the table in
src/core/glyphs.ts(FIXED, whichruleGlyphsreads) (an empty string for a rule that only removes text).src/core/glyphs.test.tsfails if a rule has no entry, and it proves on your examples that the rule changes nothing when its own characters are inavoid. A character that comes from a language pack (a quote mark, a digit separator, a dash) is read from the pack there, not listed by hand. - Add an entry for the rule to
RULE_SOURCESinresources/sources.ts(sources read, why, what others do instead, confidence), thenbun run docs:sources. Read every source before you enter it; a rule with no source read isgeneral-knowledge.resources/sources.test.tsfails if the entry is missing or the page is stale. - Add tests in
src/core/typography.test.ts. Run the property tests (below): a new rule must not lose or add a letter or digit, and must be stable on its own output. An opt-in rule also gets a vocabulary of its own insrc/properties.optin.test.ts. - Update limits.md if the rule has a known limit, and add an entry to ../CHANGELOG.md saying whether it can change existing output (a new rule almost always can).
A custom list (rules option) is built with addRule(RULES, rule, { before: 'space.number' }); it never changes RULES, and disable/enable are checked against the list in use.
The order is part of the behaviour. For example, dash.range runs before dash.sentence so a spaced range is not read as a break in the sentence.
Adding a language
A language is one object, a pack, and no code names a language: rules read the pack’s fields.
- Create
src/core/lang/<code>.tsexporting the pack (as const satisfies LangPack, the type is insrc/core/lang/pack.ts). - Add it to
PACKSinsrc/core/lang/index.ts: one import, one list entry.Lang,LANGS,PROFILESand the other tables inprofiles.ts, the command line, the rehype plugin and the playground all follow from the list. - Create
src/core/lang/<code>.test.ts: callwellFormed(pack, '<code>.test.ts')frompack-checks.tsand add golden tests for what the pack sets. Golden tests for a language live in that file, not intypography.test.ts. - Run
bun run docs:languagesand commitdocs/languages/<code>.mdandindex.md;src/lang-docs.test.tsfails if a page differs from its pack. Never edit them by hand. - If a rule acts on the new language, add an examples row to
scripts/gen-rules-doc.tsand runbun run docs:rules. - Add a long example in the language to
LONGinapps/playground/examples.ts(the playground’s “Long example”;tscfails while it is missing, andapps/playground/examples.test.tschecks that it gives the rules something to do). - Add a line for the language to
RISKY_LETTERSinapps/website/font-checker/letters.ts: the letters of the language that fonts most often lack, or an empty string.apps/website/font-checker/letters.test.tsfails while it is missing. - Update the hand-written overview languages.md.
The pack:
| Field | Meaning |
|---|---|
code, name, sample |
The code (Lang is the union of every pack’s code), the name shown in the playground (German (Deutsch)), and a sentence for the playground’s “Short example” (the benchmark and the corpus also use it). The longer text lives in apps/playground/examples.ts. |
quotes |
Quote marks per nesting level, [open, close][]; deeper nesting starts over. |
literary |
The same for the literary register, if it differs. |
orphans |
A global regex with one capture group: one-letter words kept with the next word. |
shortWords |
Two- and three-letter words kept with the next word in a title, for the opt-in rule space.short-word. Lower case, each once; the rule matches the capitalised form too. Editorial by nature (no guide read lists them), so say so in notes. |
abbreviations |
{ together, bind, page }, short lists, only from a style guide. |
sentenceDash, sentenceDashLiterary |
The spaced dash of a break in the sentence, when it is not an em dash. |
percentSpace |
A no-break space between a number and a % typed against it (5% becomes 5~%). |
currencySpace |
A no-break space between a number and a € typed after it (49€ becomes 49~€); €49 is never touched. Set it apart from percentSpace: a style guide may space % and write €30 or only the code EUR. |
dialogueDash |
{ mark, attached? }: a paragraph opening with a dash is a line of dialogue, in both registers; the dash, and no space after it if attached. Only where a style guide says the language writes dialogue that way. |
turnedPairs |
Quote pairs written turned the other way (German »…«), read by position; they replace the usual pair of that shape. |
elisions |
Words that start with an apostrophe for dropped letters ('em, 'til); n reads rock ’n’ roll. The five packs other than English list English’s, because the list was read for every language before packs existed; dropping it is an output change. |
openers |
Characters that may stand before an opening quote mark, besides a space and a bracket (Spanish ¿¡). |
sameMarkQuotes |
The language opens and closes with the same mark (Swedish ”…”). Carried and documented only: the engine does not act on it yet. |
numbers |
{ group, decimal }. |
reviewed, sources, notes |
Whether a native speaker has read the pack; the style guides it came from; what a reader should know. A pack that is reviewed must cite sources. |
Abbreviation lists and the like come from a style guide, not from memory; say in sources which, and keep reviewed: false until a native reader has seen the pack.
Tests
- Golden tests sit beside each module (
*.test.ts). - Property tests (
src/properties.test.ts) run on seeded random strings built from characters the rules care about, for every language and register: no letter or digit is lost or added; a second pass changes nothing, on text as it is written; addresses, emails and code spans come back exactly; HTML tags, attributes and<pre>/<code>are kept byte for byte; Markdown code is kept; the text mode keeps the number of lines; and nothing throws. A failure prints the input, so it can be turned into a golden case. - The boundary test (
src/boundary.test.ts) scanssrcandapps/playgroundand fails if a file imports from outside the package (a relative path that leaves it, a monorepo alias, a workspace package) or from anything butreact,react/jsx-runtimeandbun:test. Test files may also importnode:modules and the unified/remark/rehype dev dependencies; the command line may importnode:modules. The playground’svite.config.tsis not scanned.scripts/andresources/are not scanned either, so keep them to imports from../srcandnode:. - The docs tests (
src/docs.test.tsfor the rules page,src/lang-docs.test.tsfor the language pages) are described above. - Quote style (
src/core/quote-style.test.ts,src/properties.quotestyle.test.ts):quoteStyle: 'us'goldens and the properties ofproperties.test.tsrun for every language. The generators there are copied, not shared, so the seeded streams ofproperties.test.tsdo not move.
Checking a speed change
A change that is meant to make the engine faster must change no output and no explain/onChange record. Two tools prove it, both fed by one seeded corpus (scripts/corpus.ts: a fuzz vocabulary of every mark the rules care about, fixed prose in every language, quote soups).
git tag speed-baseline <ref> # once, before the speed work: a plain tag on the engine as it was (not `git tag -a`)
bun run compare # this checkout against the git ref `speed-baseline` (the frozen engine)
bun run compare <ref> --n 20000 # against another ref; --slice N runs all N strings through every combination (slow)
bun run snapshot # ONLY for an intentional output change or a new language: rewrites src/snapshot.digests.json
cd apps/bench && bun run profile # where the time goes, rule by rule
bun scripts/stress.ts [srcDir] # every rule on long runs of one character at 20k, 40k and 80k: flags quadratic cost
bun run compareextractssrc/of the base ref into a temp directory withgit archive, then, for every language, register (display,literary),quoteStyle(language,us), rule set (default; every opt-in rule enabled) and mode (typographicwithexplain, text, HTML, Markdown, hast), compares the output and every change record (rule id,from,to, order), the rule table and a thrown error. It prints the first ten differences with input, mode, language and both results, exits 1 on any difference, and ends with a timing table (best of 3, on a 1 MB paragraph and on 1000 paragraphs of 1 KB). Languages that exist here but not in the base are skipped. Expect0 differences.src/snapshot.test.ts(part ofbun test src apps/playground apps/website resources) holds a SHA-256 per language and variant insrc/snapshot.digests.jsonand fails on any change. A language without a digest fails withrun: bun run snapshot. Never runbun run snapshotto make a speed change pass: a speed change must pass without it. The same test mutates a temp copy ofsrc/to prove that both tools notice a changed regex and a changed rule attribution.- What they cannot see: the React hooks and
HangText, the DOM measuring, the CLI, inputs far outside the corpus (very long runs of one mark, megabyte inputs with changes, lone surrogates, text in scripts the rules do not know), and timing noise on a busy machine. Run the whole suite as well.
The benchmark
The benchmark is a separate project in apps/bench/ with its own package.json and dependencies. Its sources, cases, package.json, tsconfig.json and RESULTS.md are tracked; node_modules and the lockfile are not (comparison.html, the comparison page, is tracked as well). To run it: cd apps/bench && bun install && bun run bench. Its own checks: cd apps/bench && bun run typecheck.
cd apps/bench
bun run bench # every tool over every case; writes RESULTS.md
bun run latency # time per call on a paragraph
bun run profile # our own rules one by one, with their share of the time
bun run features # the same English inputs through every tool
bun run bench runs this package, typograf, typopo, richtypo, tipograph, smartypants and retext-smartypants over the same style-neutral checks and writes every case, check and output to RESULTS.md, with a speed table. The cases were written by the package author, so read them in RESULTS.md and contest them. Do not tune a rule to a case: a case says what is right, not what one tool produces. Commit RESULTS.md when it changes, and update the numbers in limits.md and backlog.md if the speed table moved. What the other scripts showed, and what to build because of it, is in backlog.md.
The playground
bun run playground # vite dev server for apps/playground/
bun run playground:build # static site in apps/playground/dist
A client-side page: paste text, HTML or Markdown, pick the language, register, quote style (language or us), the characters to avoid (avoid; under each rule switch the characters it can insert are struck through while one of them is avoided) and how a no-break space is written, and see the result, a word-level diff of what changed with what each rule did, a switch for each rule (every rule starts on, the opt-in ones too; “All rules on” goes back to that), and a preview with hanging punctuation at an amount set with a slider that stops at 0, 30%, 50% and 100% of the mark ( through --hang-amount); HTML is previewed in a sandboxed frame where no script runs, and the page marks and measures its opening punctuation from outside. “Short example” inserts the pack’s sample; “Long example” inserts a longer text in the language, in the chosen format, from apps/playground/examples.ts. The “Config” panel shows the switches as a config (TypographyConfig JSON, for package.json or typo --config) and applies a pasted one, checked with checkConfig (apps/playground/config-io.ts). Nothing is sent anywhere. The page imports from ../../src and is covered by the boundary test. Its markup and CSS follow ui.md.
Releasing
Versions are semver while below 1.0.0.
- A minor release (0.1.0 to 0.2.0) may change the output of the engine or the API. Any entry in the changelog marked “Can change existing output: yes” needs a minor release.
- A patch release (0.1.0 to 0.1.1) fixes a bug without changing output you rely on.
From 1.0.0 the output stays out of the major version: the engine’s output is not its API, and a better rule or a corrected language pack is a minor release (a patch if it fixes a bug). A major release is for a breaking change to the API: a removed or renamed export, option or rule id, or a changed signature.
To release:
- Move the new entries of
CHANGELOG.mdunder a heading## X.Y.Z (date). - Set
versioninpackage.json(typo --versionreads it). - Run
bun test src apps/playground apps/website resources,bun run typecheck,bun run docs:rules,bun run docs:languagesand, ifsrc/dom/hang.tschanged,bun run gen:script, and commit with the messagerelease: X.Y.Z. - Push or merge to
main..github/workflows/release.ymlruns the tests and creates the plain tagvX.Y.Zfromversioninpackage.jsonif it does not exist yet. Do not tag by hand; if you must, use a plaingit tag vX.Y.Z(not-a, see below) andgit push origin vX.Y.Z.
Using it from another project
- Depend on a release tag over SSH:
"@mokhniuk/typography": "git+ssh://git@github.com/<owner>/typography.git#vX.Y.Z"(it needs SSH access to the repository). The consumer takes a new version only when it changes the tag. - Tag releases with a plain
git tag vX.Y.Z, notgit tag -a: Bun 1.3 cannot resolve an annotated tag by name, and it cannot resolve a version range (#semver:) either. - Bun keeps its own copy of a git repository and does not see tags pushed after it made that copy. If the consumer’s install says “no commit matching”, run
bun pm cache rmonce and retry. A consumer that already installed a tag keeps working:bun.lockholds the commit. - The package ships
.tssources with no build step. A consumer needs Bun or a TypeScript-aware bundler. If the consumer’s dev server shows an old copy after an update, clear its bundler cache (for Vite,node_modules/.vite). reactis an optional peer dependency (>=18), needed only by the./reactentry.
Something wrong or unclear here? Report an issue.