@pikku/paraglide
v0.12.7
Published
Paraglide tooling for pikku apps — typed enum-lookup maps from `enum__*` message keys, and the i18n-debug mask locale.
Readme
@pikku/paraglide
Paraglide tooling for pikku apps.
Typed enum-lookup maps
Apps must not resolve i18n keys dynamically (mKey('status.' + value)) — dynamic
lookups can't be type-checked or tree-shaken. Instead, enum-valued labels live under
a reserved enum__<group>__<member> namespace in the message catalog, and this package
generates a static, exhaustive lookup map from them:
// messages/en.json
{
"enum__health__idle": "Idle",
"enum__health__backlogged": "Backlogged",
"enum__health__flowing": "Flowing",
}generates src/i18n/i18n-enum.gen.ts:
// AUTO-GENERATED by @pikku/paraglide — do not edit.
import { m } from './messages.js'
import type { I18nString } from '@pikku/react'
export type I18nMessage = () => I18nString
export type EnumLabel<E extends string> = Record<E, I18nMessage>
export const health = {
idle: m.enum__health__idle,
backlogged: m.enum__health__backlogged,
flowing: m.enum__health__flowing,
} satisfies EnumLabel<'idle' | 'backlogged' | 'flowing'>
export type HealthKey = keyof typeof healthApp code then does a static, exhaustive lookup:
import { health } from './i18n/i18n-enum.gen.js'
const label = health[value]() // value: HealthKeyBecause the value is an I18nMessage (a () => I18nString accessor), the label resolves
at call time and tracks the active locale. Because the map is keyed by the member union,
adding a new enum member is a type error until the catalog has its
enum__health__<member> entry.
Reconciliation against the database
The database is the real source of truth for what an enum can be. When you point the
generator at the pikku CLI's generated DB enums module (enums.gen.ts — Postgres native
enums and SQLite CHECK (col IN (…)) constraints alike), each catalog group whose member
set exactly matches a DB enum is typed against that enum:
import type { HealthStatus } from '#pikku/db/enums.gen'
export const health = {
idle: m.enum__health__idle,
// …
} satisfies EnumLabel<HealthStatus> // ← the DB enum, not the catalog unionEnumLabel<HealthStatus> is Record<HealthStatus, …>, so the label map is the
reconciliation — no separate assertion:
- catalog drops a DB member, or
en.jsonis missing the key → them.enum__…reference or theRecordexhaustiveness failstsc, naming the gap; - a DB enum that has no catalog group → by default a label map is generated for it
referencing
enum__<table>_<column>__<member>keys, sotsctells you exactly which keys to add (setunmatchedDbEnums: 'warn'to only report instead); - a group with a member the DB doesn't have (a derived UI state) → a drift warning suggesting you make it a standalone message rather than an enum member.
Defining a label for an enum that's never rendered costs nothing — Paraglide compiles only the messages actually referenced, so unused labels are tree-shaken away.
Member naming
Members must be valid JS identifiers. Spell out leading digits (two_guests, not
2_guests); the generator quotes invalid members as a fallback but warns you to rename.
Vite plugin
Place it after paraglideVitePlugin (the generated file imports the compiled m).
It regenerates on catalog edits in dev and only writes on change, so it never loops HMR.
import { paraglideVitePlugin } from '@inlang/paraglide-js'
import { paraglideEnums } from '@pikku/paraglide/vite'
export default defineConfig({
plugins: [
paraglideVitePlugin({
project: './project.inlang',
outdir: './src/paraglide',
}),
paraglideEnums({
catalog: './messages/en.json',
outFile: './src/i18n/i18n-enum.gen.ts',
// optional: reconcile against the DB enum columns
enumsFile: './packages/functions/.pikku/db/enums.gen.ts',
}),
],
})enumsImport (the specifier the generated file uses to import the DB types) defaults to a
relative path from outFile to enumsFile; pass it explicitly to use a package mapping
like #pikku/db/enums.gen.
CLI
For CI / non-Vite flows, run right after paraglide-js compile:
# paraglide-enums <catalog.json> <out.gen.ts> [messagesImport] [enums.gen.ts]
paraglide-enums ./messages/en.json ./src/i18n/i18n-enum.gen.ts ./messages.js ./packages/functions/.pikku/db/enums.gen.tsThe i18n-debug mask locale
tsc catches an invalid message, and the @pikku/mantine I18nNode gate catches a raw
string literal on a gated prop. Neither sees a hardcoded string in plain JSX, an
aria-label, an alt, a document.title, or anything handed to a non-Mantine component.
i18n-debug covers that gap: render every message as block glyphs, and whatever is still readable on screen never went through a message.
████ ███████ ← a message
Save changes ← a hardcoded stringThe mask is a locale, not a runtime wrapper. Wrapping the m namespace so each
message pipes through a mask() touches every export, which is exactly what stops a
bundler tree-shaking unused messages; it also adds a check to every call and forces every
component to import m from the wrapper rather than from Paraglide. Masked text is just
text, and rendering different text per locale is what Paraglide already does.
Place the plugin before paraglideVitePlugin — it writes into the catalog directory
that Paraglide then compiles:
import { paraglideVitePlugin } from '@inlang/paraglide-js'
import { paraglideMaskLocale } from '@pikku/paraglide/vite'
export default defineConfig({
plugins: [
paraglideMaskLocale({
catalog: './messages/en.json',
locale: 'zz', // writes messages/zz.json
}),
paraglideVitePlugin({
project: './project.inlang',
outdir: './src/paraglide',
}),
],
})Switching is then one line in the locale bridge — see createLocaleStore in
@pikku/react, whose debugLocale option does exactly this:
overwriteGetLocale(() => (isI18nDebug() ? 'zz' : activeLocale))Two properties worth keeping:
- Keep the locale out of the app's own supported list. That list drives URL prefixes,
hreflang and any backend
localeparam, none of which should ever see it. - It costs the bundle effectively nothing. On a build the catalogue is deleted rather
than written, so Paraglide compiles the locale to aliases of the base locale — one
constper message, no duplicated strings.
{placeholders} are left intact: they are message inputs rather than copy, and mangling
one changes the compiled function's signature. Whitespace is left alone too, so masked
text keeps the shape of the original and a layout bug still looks like a layout bug.
