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 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" }

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"

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, hasIcuSyntax } from "pofile-ts"

hasPlural("{n, plural, one {#} other {#}}") // true
hasSelect("{g, select, male {He} other {They}}") // true
hasIcuSyntax("{name}") // true (has argument)
hasIcuSyntax("Hello world") // false (plain text)

AST Node Types

The parser produces an AST with these node types:

import { IcuNodeType } from "pofile-ts"

IcuNodeType.literal // Plain text
IcuNodeType.argument // {name}
IcuNodeType.number // {n, number}
IcuNodeType.date // {d, date}
IcuNodeType.time // {t, time}
IcuNodeType.plural // {n, plural, ...}
IcuNodeType.select // {g, select, ...}
IcuNodeType.pound // # in plural
IcuNodeType.tag // <b>...</b>

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 — 3× faster than Lingui and FormatJS.

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}", { locale: "de" })
price({ amount: 1234.5 }) // → "1.234,50" (German formatting)

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
Compilation72k ops/s1× same
Runtime810k ops/s3× faster4× faster
Catalog (200 msgs)~210k msg/s1× same

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