@jagu.cz/typopo-layer
v1.0.0
Published
Nuxt layer that fixes Czech/Slovak/English/German microtypography in i18n locale files at build time.
Readme
Nuxt Typopo Layer
A Nuxt layer that runs typopo over your @nuxtjs/i18n locale files
at build time — non-breaking spaces after single-letter prepositions, „Czech quotes“, real
ellipses, en dashes, × for dimensions — without ever touching the JSON in your repository.
pnpm add @jagu.cz/typopo-layer// nuxt.config.ts
export default defineNuxtConfig({
extends: ['@jagu.cz/typopo-layer'],
})That's the whole setup. Locales are picked up from your existing i18n.locales: cs, sk and
rue map to themselves, en to typopo's en-us, de to de-de, and a regional code (en-GB)
falls back to its base language. Anything typopo has no rules for is reported on startup and left
alone.
Why build time
The fixes belong in the rendered output, not in the repository:
- Running typopo in CI would mean committing its output back to the branch.
- Running it in the browser would leave the SSR and prerendered HTML — everything crawlers, og: scrapers and no-JS clients see — carrying the plain text, and the inserted non-breaking spaces re-wrap paragraphs, so lines would visibly jump after hydration.
So the layer rewrites the JSON as the bundlers load it. Your source files keep the plain text; the client and server bundles get the same fixed strings.
Looking at what it does
typopo-locales # report every string the build will change
typopo-locales --check # exit 1 if anything would change (for CI)
typopo-locales --write # bake the fixes into the source files (opt-in, never automatic)Add it to your package.json for convenience:
{
"scripts": {
"typopo": "typopo-locales"
}
}The report names the invisible characters ([NBSP], [NNBSP], …, –) — without that a
before/after pair looks identical in a terminal.
Which files and locales it reads comes from .nuxt/typopo.json, written by the module on every
dev/build/prepare, so the report matches the build exactly. On a fresh checkout with no .nuxt
yet it falls back to i18n/locales and maps locales by filename; --dir <path> and
--map cs=cs,en=en-us override both.
Configuration
Everything is optional.
export default defineNuxtConfig({
extends: ['@jagu.cz/typopo-layer'],
typopo: {
// Override the locale mapping. Values are typopo locales: cs | sk | en-us | de-de | rue.
locales: { en: 'en-us' },
// Where the locale JSON lives, relative to the project root.
// Defaults to @nuxtjs/i18n's own directory (i18n/locales).
langDir: 'i18n/locales',
// Leave strings containing `<` alone. On by default: typopo is not markup-aware and
// rewrites the quotes inside a tag, so `<a href="/x">` comes back as `<a href=„/x“>`.
skipMarkup: true,
},
})What it protects
Three things bite when you run typopo over vue-i18n messages, and the layer handles all three:
| Case | What would happen | What the layer does |
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| {'@'} literal interpolation | typopo curls the quotes to {’@’}, the vue-i18n compiler rejects the file, and every message in it renders as its bare key path | placeholders are masked while typopo runs |
| Strings containing markup | quotes inside a tag get curled and the link dies, silently | skipped, and listed in the CLI report |
| Multi-line messages | typopo treats a newline as a hard boundary: a capitalised preposition at the start of a line keeps its breakable space, and an en dash at a line end gets pulled onto the previous word (changes— including) | each line is fixed separately, then rejoined byte-identically |
Plural pipes (no items \| one item) need no special handling — typopo leaves them alone.
@nuxtjs/i18n 10.6 and optimizeMessageBundling
10.6 stopped putting messages through either bundler: it copies the locale files to
.nuxt/i18n-assets/, reads them off disk and serves them from an endpoint. Faster and smaller
(the release claims 38% off build time and 75% off build output) — but no plugin ever sees the
JSON, so typography fixes reach nothing.
The layer opts out for you, by setting i18n.experimental.optimizeMessageBundling: false
unless your project has chosen a value. Nothing to add to your nuxt.config, and on versions
without the flag it is an unknown key that is ignored. Verified on 10.6.0: with the opt-out the
served HTML carries the fixes, without it nothing is transformed and the layer says so.
The cost is that you don't get 10.6's message-bundling win while you use this layer. If you'd rather have the optimisation than the fixes in server-rendered HTML, take it back explicitly:
i18n: {
experimental: { optimizeMessageBundling: true },
}The layer then warns on every build that the fixes are client-only.
Known limitation: dev's first paint
The locale files are read by two bundlers — Vite for the client and the Vue SSR build, Nitro for
@nuxtjs/i18n's server-side messages — and the layer transforms both, so a production build is
consistent everywhere.
nuxt dev renders server-side through neither of them: the JSON reaches Node without passing a
plugin, so the first paint shows the plain text and the fixes appear as the client takes over.
Only dev is affected. When you are eyeballing where a line wraps in dev, judge it after hydration.
If nothing happens
The module warns loudly rather than shipping unfixed copy in silence:
no locale files were transformed— the path is wrong, or your i18n version has stopped handing the files to the bundlers. Checktypopo.langDir, and see the 10.6 section above.no typopo locale for xx— that locale ships unfixed; map it viatypopo.locales.optimizeMessageBundling is on— your project asked for the optimisation, so the fixes are in the client bundle only, not in the server-rendered HTML.… was already transformed by another plugin— a plugin-ordering regression; the layer skips rather than corrupting the messages.
Development
.playground is a regular Nuxt app with @nuxtjs/i18n and a locale file full of the awkward
cases above. It prints every message twice: as the browser lays it out, and with the invisible
characters escaped.
pnpm install
pnpm dev # boot .playground on http://localhost:3000
pnpm build # then: node .playground/.output/server/index.mjs
pnpm typopo # the CLI against the playground locales
pnpm lint
pnpm typecheckVerifying a change end to end means looking at the served bytes, not the source:
curl -s http://localhost:3000/ | grep -c $'\xc2\xa0'Distributing
Bump the version in package.json, confirm the files list, commit, then push a tag — CI
publishes to npm.
