pofile-tspofile-ts

i18n Workflow Helpers

Utilities for common i18n workflows

Beyond parsing and serialization, pofile-ts includes utilities for common i18n workflows. All helpers are tree-shakeable — only import what you need.

Comment Processing

Split multiline comments into individual lines for PO format output. Useful when extracting comments from source code:

import { splitMultilineComments } from "pofile-ts"

// Source comments often contain newlines
splitMultilineComments(["Line1\nLine2", "Line3"])
// → ["Line1", "Line2", "Line3"]

// Handles Windows/Mac line endings
splitMultilineComments(["First\r\nSecond"])
// → ["First", "Second"]

// Trims whitespace, filters empty lines
splitMultilineComments(["  Line1\n\n  Line2  "])
// → ["Line1", "Line2"]

Default Headers

Create properly formatted PO headers with sensible defaults:

import { createDefaultHeaders } from "pofile-ts"

const headers = createDefaultHeaders({
  language: "de",
  generator: "my-tool"
})

// Includes: POT-Creation-Date, MIME-Version, Content-Type, etc.
// Plural-Forms is auto-generated from CLDR when language is set:
// → "Plural-Forms: nplurals=2; plural=(n != 1);"

// To use a custom Plural-Forms header:
const custom = createDefaultHeaders({
  language: "de",
  pluralForms: "nplurals=2; plural=(n > 1);"
})

// To omit Plural-Forms entirely:
const noPluralForms = createDefaultHeaders({
  language: "de",
  pluralForms: false
})

Catalog Conversion

Convert between simple key-value catalogs and PO items — with full plural support:

import { catalogToItems, itemsToCatalog } from "pofile-ts"

// Simple catalog format
const catalog = {
  Hello: { translation: "Hallo" },
  "{count} item": {
    translation: ["{count} Element", "{count} Elemente"],
    pluralSource: "{count} items"
  }
}

const items = catalogToItems(catalog)
const backToCatalog = itemsToCatalog(items)

Message ID Generation

Generate stable, collision-resistant IDs from message content. Uses SHA-256 with Base64URL encoding — 281 trillion possibilities, practically zero collisions even at 1M messages:

import { generateMessageId, generateMessageIdSync } from "pofile-ts"

// Async (works in browsers and Node.js)
const id = await generateMessageId("Hello {name}", "greeting")
// → "Kj9xMnPq" (8-char Base64URL)

// Sync (Node.js only, faster)
const syncId = generateMessageIdSync("Hello {name}")

Reference Utilities

Parse and format source file references with proper path normalization:

import { parseReference, formatReference, createReference } from "pofile-ts"

parseReference("src/App.tsx:42")
// → { file: "src/App.tsx", line: 42 }

formatReference({ file: "src/App.tsx", line: 42 })
// → "src/App.tsx:42"

// Validates relative paths, normalizes Windows backslashes
createReference("src\\components\\Button.tsx", 10)
// → { file: "src/components/Button.tsx", line: 10 }

Plural-Forms Header

Get the Plural-Forms header string directly for a locale:

import { getPluralFormsHeader } from "pofile-ts"

getPluralFormsHeader("de") // → "nplurals=2; plural=(n != 1);"
getPluralFormsHeader("pl") // → "nplurals=4; plural=(n != 1);"
getPluralFormsHeader("ar") // → "nplurals=6; plural=(n != 1);"
getPluralFormsHeader("zh") // → "nplurals=1; plural=0;"

This is a convenience wrapper around createDefaultHeaders({ language })["Plural-Forms"].

Note: The plural expression is a simplified fallback. For runtime plural selection, use getPluralFunction(locale) which uses Intl.PluralRules for CLDR-accurate results.

Plural Categories (CLDR)

Get CLDR plural categories for any locale — useful for building ICU messages or validating translations.

Native Intl.PluralRules

pofile-ts uses the browser's native Intl.PluralRules API for all plural handling. This means:

  • Zero bundle size for plural data — the browser provides CLDR
  • Always up-to-date with the browser's CLDR version
  • CSP-safe — no eval or Function() needed
FormsCategoriesLanguages
1otherChinese, Japanese, Korean, Vietnamese, Thai
2one, otherEnglish, German, Dutch, Swedish, Danish
3one, many, otherFrench, Spanish, Portuguese, Italian, Catalan
3one, few, otherCroatian, Serbian, Bosnian
3zero, one, otherLatvian
4one, two, few, otherSlovenian
4one, few, many, otherRussian, Ukrainian, Polish, Czech, Lithuanian
5one, two, few, many, otherIrish, Maltese
6zero, one, two, few, many, otherArabic, Welsh

Usage

import { getPluralCategories, getPluralCount, getPluralFunction } from "pofile-ts"

// Get categories for a locale
getPluralCategories("de") // → ["one", "other"]
getPluralCategories("ru") // → ["one", "few", "many", "other"]
getPluralCategories("pl") // → ["one", "few", "many", "other"]
getPluralCategories("ar") // → ["zero", "one", "two", "few", "many", "other"]

// Count of plural forms
getPluralCount("de") // → 2
getPluralCount("ru") // → 4
getPluralCount("ar") // → 6

// Get plural selector function
const selectPlural = getPluralFunction("ru")
selectPlural(1) // → 0 (one)
selectPlural(2) // → 1 (few)
selectPlural(5) // → 2 (many)
selectPlural(21) // → 0 (one) — CLDR-correct!
selectPlural(22) // → 1 (few)

Locale Normalization

Both underscore and hyphen formats are supported:

getPluralCategories("pt_BR") // → ["one", "many", "other"]
getPluralCategories("pt-BR") // → ["one", "many", "other"]

Limitations & Browser Support

Browser/Node.js Requirements:

  • All modern browsers (Chrome 63+, Firefox 58+, Safari 13+, Edge 79+)
  • Node.js 14+ (full ICU build required for all locales)
  • Deno, Bun, and Cloudflare Workers all support Intl.PluralRules

Locale Fallback: Unknown locales fall back to the default CLDR root rules (["one", "other"]). This matches Intl.PluralRules behavior.

Plural-Forms Header: When using createDefaultHeaders({ language }), the generated Plural-Forms header uses a simplified expression (n != 1) for languages with 3+ forms. This is because the exact gettext expression cannot be derived from Intl.PluralRules. The nplurals count is always correct.

For runtime plural selection, use getPluralFunction() — it uses Intl.PluralRules directly and is always CLDR-accurate.

ICU Conversion

Convert Gettext plurals to ICU MessageFormat — perfect for modern i18n libraries like Lingui or FormatJS:

import { gettextToIcu, normalizeToIcu, icuToGettextSource, parsePo } from "pofile-ts"

// Convert a single plural item
const item = {
  msgid: "One item",
  msgid_plural: "{count} items",
  msgstr: ["Ein Artikel", "{count} Artikel"]
}

gettextToIcu(item, { locale: "de" })
// → "{count, plural, one {Ein Artikel} other {{count} Artikel}}"

// Convert an entire PO file
const po = parsePo(content)
const normalized = normalizeToIcu(po, { locale: "de" })

// All plural items now have ICU in msgstr[0]
normalized.items[0].msgstr[0]
// → "{count, plural, one {Ein Artikel} other {{count} Artikel}}"

Octothorpe Expansion

By default, # is replaced with {varname} for better readability in TMS tools:

// With # in source (e.g. from Lingui)
const item = { msgstr: ["# Artikel", "# Artikel"], ... }

gettextToIcu(item, { locale: "de" })
// → "{count, plural, one {{count} Artikel} other {{count} Artikel}}"

// To preserve #, set expandOctothorpe: false
gettextToIcu(item, { locale: "de", expandOctothorpe: false })
// → "{count, plural, one {# Artikel} other {# Artikel}}"

Extract from ICU

Extract msgid/msgid_plural from an ICU plural string:

icuToGettextSource("{count, plural, one {# item} other {# items}}")
// → { msgid: "{count} item", msgid_plural: "{count} items", pluralVariable: "count" }

Round-Trip Conversions

When converting ICU → Gettext → ICU, use expandOctothorpe: false on both sides to preserve the # placeholder:

import { gettextToIcu, icuToGettextSource } from "pofile-ts"

const originalIcu = "{count, plural, one {# item} other {# items}}"

// ICU → Gettext (for TMS export)
const gettext = icuToGettextSource(originalIcu, { expandOctothorpe: false })
// → { msgid: "# item", msgid_plural: "# items", pluralVariable: "count" }

// Gettext → ICU (for catalog import)
const backToIcu = gettextToIcu(
  { msgid: gettext.msgid, msgid_plural: gettext.msgid_plural, msgstr: ["# Artikel", "# Artikel"] },
  { locale: "de", pluralVariable: gettext.pluralVariable, expandOctothorpe: false }
)
// → "{count, plural, one {# Artikel} other {# Artikel}}"

Why expandOctothorpe?

Option# becomes...Use case
expandOctothorpe: true{count} explicitTMS tools that don't understand #
expandOctothorpe: false# preservedRound-trips, Lingui-style catalogs

The default is true because most Translation Management Systems display # as literal text, which confuses translators.

Complex Locales

Works with all CLDR locales, including complex Slavic and Arabic plurals:

// Russian (4 forms) — CLDR-correct "many" for 0, 5-19, 100...
gettextToIcu(ruItem, { locale: "ru" })
// → "{count, plural, one {файл} few {файла} many {файлов} other {файла}}"

// Polish (4 forms)
gettextToIcu(plItem, { locale: "pl" })
// → "{count, plural, one {plik} few {pliki} many {plików} other {pliki}}"

// Arabic (6 forms)
gettextToIcu(arItem, { locale: "ar" })
// → "{count, plural, zero {...} one {...} two {...} few {...} many {...} other {...}}"

ICU Parser

Parse and analyze ICU MessageFormat strings. Useful for extracting variables, validating syntax, or building translation tools.

Why a Custom Parser?

The parser is optimized for bundle size and typical i18n use cases:

LibraryGzipped
@formatjs/icu-messageformat-parser~9KB
pofile-ts ICU parser~2KB

Limitations

Trade-offs for smaller bundle size:

  • ICU MessageFormat v1 only — no MF2 syntax
  • No source location tracking — messages are typically single-line anyway
  • Styles/skeletons as opaque strings — stored but not parsed (e.g., ::currency/EUR)
  • Modern JS only — no IE11 polyfills

Basic Usage

import { parseIcu, validateIcu, extractVariables } from "pofile-ts"

// Parse ICU message to AST
const result = parseIcu("{count, plural, one {# item} other {# items}}")
if (result.success) {
  console.log(result.ast) // Array of AST nodes
}

// Validate syntax
const validation = validateIcu("{broken, plural, one {x}}")
console.log(validation.valid) // false
console.log(validation.errors) // [{ kind: "SYNTAX_ERROR", message: "..." }]

// Extract variable names
extractVariables("{name} has {count} messages")
// → ["name", "count"]

Supported Syntax

The parser supports the full ICU MessageFormat v1 specification:

// Simple arguments
parseIcu("{name}")

// Formatted arguments
parseIcu("{n, number}")
parseIcu("{d, date, short}")
parseIcu("{t, time, medium}")

// Number/date skeletons (stored as opaque strings)
parseIcu("{n, number, ::currency/EUR}")

// Plural
parseIcu("{n, plural, offset:1 =0 {none} one {# item} other {# items}}")

// Select
parseIcu("{gender, select, male {He} female {She} other {They}}")

// Selectordinal
parseIcu("{n, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}")

// Tags (for rich text / JSX)
parseIcu("Click <link>here</link> to continue")

// Escaping: '' → ' and '{text}' → literal text
parseIcu("It''s {name}''s turn") // "It's {name}'s turn"

Extended Format Types (pofile-ts Extensions)

Go beyond standard ICU — pofile-ts extends the format syntax with four powerful new types that leverage native browser Intl APIs. Zero additional bundle size, full localization support out of the box:

// List formatting (Intl.ListFormat)
parseIcu("{items, list}") // conjunction: "Alice, Bob, and Charlie"
parseIcu("{options, list, disjunction}") // "red, blue, or green"
parseIcu("{parts, list, unit}") // "1, 2, 3"

// Duration formatting (Intl.DurationFormat)
parseIcu("{elapsed, duration}") // "2 hours, 30 minutes"
parseIcu("{time, duration, short}") // "2 hr, 30 min"
parseIcu("{playback, duration, digital}") // "2:30:00"

// Relative time formatting (Intl.RelativeTimeFormat)
parseIcu("{days, ago, day}") // "in 3 days" or "3 days ago"
parseIcu("{hours, ago, hour short}") // "in 2 hr."

// Display names (Intl.DisplayNames)
parseIcu("{lang, name, language}") // "en" → "English"
parseIcu("{country, name, region}") // "DE" → "Germany"
parseIcu("{code, name, currency}") // "EUR" → "Euro"

Runtime compilation for these types uses native Intl APIs:

import { compileIcu } from "pofile-ts"

const listFn = compileIcu("{authors, list}", { locale: "en" })
listFn({ authors: ["Alice", "Bob", "Charlie"] })
// → "Alice, Bob, and Charlie"

const agoFn = compileIcu("{days, ago, day}", { locale: "de" })
agoFn({ days: -2 })
// → "vor 2 Tagen"

const nameFn = compileIcu("{lang, name, language}", { locale: "de" })
nameFn({ lang: "en" })
// → "Englisch"

These are pofile-ts extensions to ICU MessageFormat. Other ICU implementations may not recognize these types. Use them when you control both parsing and compilation (e.g., in a single-codebase i18n setup).

Browser Support

pofile-ts uses native Intl APIs for all formatting. Here's the complete list:

Standard ICU Formats:

FormatIntl APIBaselineLinks
{n, plural}Intl.PluralRules2018MDN · CanIUse
{n, number}Intl.NumberFormat2017MDN · CanIUse
{d, date}Intl.DateTimeFormat2017MDN · CanIUse
{t, time}Intl.DateTimeFormat2017MDN · CanIUse

Extended Formats (pofile-ts):

FormatIntl APIBaselineLinks
{x, list}Intl.ListFormat2021MDN · CanIUse
{x, ago}Intl.RelativeTimeFormat2020MDN · CanIUse
{x, name}Intl.DisplayNames2021MDN · CanIUse
{x, duration}Intl.DurationFormat2025MDN · CanIUse

All APIs are part of the Baseline standard. The oldest required API (Intl.DurationFormat) reached Baseline in March 2025. For older browsers, pofile-ts provides a JSON fallback for duration.

Case-Insensitive Keywords

ICU keywords are case-insensitive per the spec. All of these are valid:

parseIcu("{n, plural, one {#} other {#}}") // ✅ lowercase
parseIcu("{n, PLURAL, one {#} other {#}}") // ✅ uppercase
parseIcu("{n, selectOrdinal, one {#st} other {#th}}") // ✅ camelCase
parseIcu("{n, SelectOrdinal, one {#st} other {#th}}") // ✅ PascalCase
parseIcu("{d, DATE, short}") // ✅ also works

This matches the ICU4J / ICU4C reference implementations.

Variable Analysis

Extract detailed information about variables in a message:

import { extractVariableInfo, compareVariables } from "pofile-ts"

// Get variable details including type and style
extractVariableInfo("{count, number, currency} on {date, date, short}")
// → [
//   { name: "count", type: "number", style: "currency" },
//   { name: "date", type: "date", style: "short" }
// ]

// Compare source and translation variables
compareVariables(
  "Hello {name}, you have {count} messages",
  "Hallo {userName}, du hast {count} Nachrichten"
)
// → { missing: ["name"], extra: ["userName"], isMatch: false }

Detection Helpers

Quick checks without full parsing:

import { hasPlural, hasSelect, hasSelectOrdinal, hasIcuSyntax } from "pofile-ts"

hasPlural("{n, plural, one {#} other {#}}") // true
hasPlural("{n, selectordinal, one {#st} other {#th}}") // true (ordinal is also plural)

hasSelect("{g, select, male {He} other {They}}") // true

hasSelectOrdinal("{n, selectordinal, one {#st} other {#th}}") // true
hasSelectOrdinal("{n, plural, one {#} other {#}}") // false (cardinal, not ordinal)

hasIcuSyntax("{name}") // true (has argument)
hasIcuSyntax("Hello world") // false (plain text)

Note: selectordinal is internally stored as a plural node with pluralType: "ordinal". Use hasSelectOrdinal() to specifically detect ordinal plurals, or hasPlural() to detect both cardinal and ordinal.

AST Node Types

The parser produces an AST with string-based node types:

import type { IcuNodeType } from "pofile-ts"

// Node types are string literals for easy debugging:
type IcuNodeType =
  | "literal" // Plain text
  | "argument" // {name}
  | "number" // {n, number}
  | "date" // {d, date}
  | "time" // {t, time}
  | "select" // {g, select, ...}
  | "plural" // {n, plural, ...} or {n, selectordinal, ...}
  | "pound" // # in plural
  | "tag" // <b>...</b>

// Type checking is straightforward:
if (node.type === "plural") {
  console.log(node.pluralType) // "cardinal" | "ordinal"
}

Parser Options

parseIcu(message, {
  // Treat tags as literal text (default: false)
  ignoreTag: true,

  // Allow plural/select without 'other' clause (default: true = required)
  requiresOtherClause: false
})

ICU Compiler

Compile ICU messages to fast JavaScript functions. Instead of parsing ICU syntax at runtime, the compiler generates optimized functions that directly produce formatted strings — about 3× faster than intl-messageformat and 4× faster than Lingui.

Why Compile?

ApproachWhat happens at runtime
pofile-tsDirect function call → template literal
@linguiWalk AST array → apply plural rules → build string
intl-messageformatParse ICU → create AST → interpret

The difference is like native code vs. interpreter — we eliminate all runtime parsing and AST walking.

Single Message

import { compileIcu } from "pofile-ts"

// Compile a single ICU message to a function
const greet = compileIcu("Hello {name}!", { locale: "en" })
greet({ name: "World" }) // → "Hello World!"

// Full ICU support
const msg = compileIcu("{count, plural, one {# item} other {# items}}", { locale: "en" })
msg({ count: 1 }) // → "1 item"
msg({ count: 5 }) // → "5 items"

// Number and date formatting via Intl
const price = compileIcu("{amount, number, ::currency/EUR}", { locale: "de" })
price({ amount: 1234.5 }) // → "1.234,50 €" (German formatting)

Format Styles

pofile-ts includes 50+ built-in format styles for numbers, dates, times, and lists, plus support for custom styles with full Intl API options.

// Built-in styles — no configuration needed
compileIcu("{n, number, compact}", { locale: "en" })({ n: 2500000 }) // → "2.5M"
compileIcu("{size, number, megabyte}", { locale: "en" })({ size: 1.5 }) // → "1.5 MB"
compileIcu("{d, date, weekday}", { locale: "en" })({ d: new Date() }) // → "Monday"

// Dynamic currency
compileIcu("{price, number, currency}", { locale: "en" })({ price: 99.99, currency: "EUR" })
// → "€99.99"

// Custom styles with Intl options
compileIcu("{n, number, precise}", {
  locale: "en",
  numberStyles: { precise: { minimumFractionDigits: 2, maximumFractionDigits: 2 } }
})({ n: 3.1 })
// → "3.10"

// Pre-configured compiler for reuse
const compile = createIcuCompiler({
  locale: "de",
  numberStyles: { bytes: { style: "unit", unit: "byte", unitDisplay: "narrow" } }
})
compile("{size, number, bytes}")({ size: 1024 }) // → "1.024B"

→ Complete Format Styles Reference

See all 50+ built-in styles, custom style options, and examples in English, German, and Spanish.

Tags (Named, Numeric, React)

Tags are treated as functions that receive children and return formatted content:

// Named tags
const msg = compileIcu("Click <link>here</link> to continue", { locale: "en" })
msg({ link: (text) => `<a href="#">${text}</a>` })
// → "Click <a href="#">here</a> to continue"

// Numeric tags (Lingui-style)
const msg2 = compileIcu("Hello <0>World</0>!", { locale: "en" })
msg2({ 0: (text) => `**${text}**` })
// → "Hello **World**!"

// React components (returns array when tag returns object)
const msg3 = compileIcu("Read our <link>terms</link>", { locale: "en" })
const result = msg3({
  link: (children) => ({ type: Link, props: { to: "/terms" }, children })
})
// → ["Read our ", { type: Link, ... }, ""]
// Use with React.createElement or JSX

Catalog Compilation (Runtime)

Compile an entire translation catalog at once:

import { compileCatalog, itemsToCatalog, parsePo } from "pofile-ts"

const po = parsePo(poFileContent)
const catalog = itemsToCatalog(po.items)

const compiled = compileCatalog(catalog, { locale: "de" })

// Look up by messageId (8-char hash) or msgid
compiled.format("Xk9mLp2Q", { name: "Sebastian" })
compiled.has("Xk9mLp2Q") // true
compiled.keys() // ["Xk9mLp2Q", ...]
compiled.size // 42

Options:

compileCatalog(catalog, {
  locale: "de", // Required: for plural rules and Intl formatting
  useMessageId: true, // Use 8-char hash as key (default: true)
  strict: false // Throw on parse errors (default: false)
})

Static Code Generation (Build-time)

Generate JavaScript/TypeScript code at build time for zero-runtime parsing:

import { generateCompiledCode, parsePo, itemsToCatalog } from "pofile-ts"

const po = parsePo(poFileContent)
const catalog = itemsToCatalog(po.items)

const code = generateCompiledCode(catalog, {
  locale: "de",
  exportName: "messages"
})

// Write to file
fs.writeFileSync("compiled-de.ts", code)

Generated code looks like:

/**
 * Compiled messages for locale: de
 * Generated by pofile-ts
 */

const _pf = (n) => (n !== 1 ? 1 : 0)
const _nf = new Intl.NumberFormat("de")

export const messages = {
  Xk9mLp2Q: (v) => `Hallo ${v?.name ?? "{name}"}!`,
  Ym3nPq4R: (v) =>
    _pf(v?.count ?? 0) === 0
      ? `${_nf.format(v?.count ?? 0)} Artikel`
      : `${_nf.format(v?.count ?? 0)} Artikel`
}

Options:

generateCompiledCode(catalog, {
  locale: "de", // Required
  useMessageId: true, // Use 8-char hash as key (default: true)
  exportName: "messages", // Export variable name (default: "messages")
  strict: false // Throw on parse errors (default: false)
})

Gettext Plural Support

The compiler handles both ICU plurals in msgstr and classic Gettext plural arrays (msgstr[]):

// ICU plural in msgstr
const catalog1 = {
  Hello: { translation: "{count, plural, one {# Nachricht} other {# Nachrichten}}" }
}

// Gettext plural array (msgstr[0], msgstr[1], ...)
const catalog2 = {
  "{count} message": {
    translation: ["{count} Nachricht", "{count} Nachrichten"],
    pluralSource: "{count} messages"
  }
}

// Both compile to the same runtime behavior
const compiled1 = compileCatalog(catalog1, { locale: "de" })
const compiled2 = compileCatalog(catalog2, { locale: "de" })

Performance

Benchmarked against common i18n libraries:

Metricpofile-tsvs intl-messageformatvs @lingui (compiled)
Compilation409k ops/s7× faster
Runtime792k ops/s3× faster4× faster
Catalog (200 msgs)~1.35M/s7× faster

The key insight: Lingui compiles to an AST array that's still interpreted at runtime. pofile-ts compiles to actual JavaScript functions with template literals — no interpretation needed.

On this page