Skip to content

`@palamedes/core`

@palamedes/core owns the app-facing i18n instance and the macro entry point package names.

@palamedes/core

Exports

The package root is the compatibility entrypoint. Its generated table below is the canonical complete list. Runtime exports are importable values; Type-only exports must use a TypeScript type import. Run pnpm check:core-api-reference after building packages to verify the source, declarations, ESM, and CJS surface.

ExportKind
buildChoiceMessageRuntime
CatalogMessageType-only
CatalogMessagesType-only
ChoiceComponentPropsType-only
ChoiceKindType-only
CompiledCatalogMessagesType-only
CompiledMessageType-only
CompiledMessageBranchType-only
CompiledMessageBranchesType-only
CompiledMessageRuntimeType-only
createCompiledMessageRuntimeRuntime
createI18nRuntime
CreateI18nOptionsType-only
DEFAULT_LOCALERuntime
defineCompiledCatalogRuntime
ExecutableMessageRendererType-only
formatMessageArgumentRuntime
formatMessagePatternRuntime
MessageChoiceNodeType-only
MessageFormatErrorInfoType-only
MessageFormattedArgumentNodeType-only
MessageLiteralNodeType-only
MessageMetadataType-only
MessageNodeType-only
MessageTagNodeType-only
MessageTextNodeType-only
MessageValuesType-only
MessageVariableNodeType-only
MissingMessageInfoType-only
PalamedesI18nType-only
parseMessagePatternRuntime
PluralPropsType-only
replacePoundPlaceholdersRuntime
ReportedMessageErrorType-only
resolveChoiceRuntime
ResolvedChoiceType-only
SelectOrdinalPropsType-only
SelectPropsType-only
stringifyValueRuntime

The parser-free @palamedes/core/compiled entrypoint exports createI18n(), the CompiledPalamedesI18n instance type, the compiled catalog ABI and types, and the Intl formatting helpers. Its load() accepts only CompiledCatalogMessages. It omits formatMessagePattern(), parseMessagePattern(), and the parser implementation. Use it when all loaded catalogs are generated by Palamedes:

import { createI18n, type CompiledCatalogMessages } from "@palamedes/core/compiled"

declare module "*.po" {
  export const messages: CompiledCatalogMessages
}

Its load() rejects unbranded hand-written string catalogs. Use the package-root factory below when runtime ICU strings are intentional.

When a parser-free instance misses a compiled catalog entry, it can return a retained metadata.message as readable source text, but it deliberately does not parse or interpolate that ICU pattern. This keeps the compiled entry free of the parser. Use the package-root entry when a source fallback itself must be formatted, and attach onMissing to measure catalog/code skew rather than adding a default console logger.

createI18n(options?)

Creates an in-memory i18n instance. It starts with DEFAULT_LOCALE ("en"). The optional locale setting overrides that initial locale for catalog lookup, message formatting, and telemetry before any later activate() call. Set the optional timeZone to an IANA identifier (for example "Europe/Berlin") to format ICU date and time arguments consistently during server rendering and client hydration. Invalid or empty identifiers throw RangeError during creation. Without it, the host's default time zone remains in effect. Optional telemetry hooks receive missing-message and runtime formatting failures without changing the source-message fallback behavior.

interface CreateI18nOptions {
  locale?: string
  timeZone?: string
  onMissing?: (info: MissingMessageInfo) => void
  onError?: (info: MessageFormatErrorInfo) => void
}
import { createI18n } from "@palamedes/core"

const i18n = createI18n({
  timeZone: "Europe/Berlin",
  onMissing(info) {
    reportMetric("palamedes.missing", info)
  },
  onError(info) {
    captureException(info.error)
  },
})
i18n.load("de", {
  "Hello {name}": "Hallo {name}",
})
i18n.activate("de")

const label = i18n._("Hello {name}", { name: "Ada" })

Date values and timestamps are instants. Date-only ISO strings such as "2026-06-12" are parsed by JavaScript as UTC before Palamedes renders them in timeZone, so they can display as the prior or following calendar day in some zones. Pass an explicit local-time value when the input is a calendar date.

PalamedesI18n

interface PalamedesI18n {
  readonly locale: string
  readonly timeZone?: string
  _(id: string, values?, metadata?): string
  load(locale: string, messages: CatalogMessages | CompiledCatalogMessages): void
  activate(locale: string): void
  getMessage(id: string, metadata?: MessageMetadata): string
  getMessageNodes(id: string, metadata?: MessageMetadata): MessageNode[]
  parsePattern?(pattern: string): MessageNode[]
  renderMessage?<TResult>(id, values, runtime, metadata?): TResult
  reportError(info: ReportedMessageError): void
}

parsePattern() is a parse-only adapter capability exposed by the package-root createI18n(). Unlike getMessageNodes(), its argument is always treated as a raw ICU pattern and never as a catalog key. It is optional so custom and older instances remain compatible; the parser-free @palamedes/core/compiled factory deliberately omits it. React and Solid use the capability for lazy fallback patterns emitted by generated message functions.

getMessageNodes() remains in the shared instance type for compatibility, but the parser-free @palamedes/core/compiled factory throws when it is called. Use its direct compiled-message rendering path instead; code that needs parse trees must use the package-root compatibility entrypoint.

renderMessage() executes a generated function directly against a host result renderer and applies the same telemetry and fallback behavior as _(). First-party React and Solid adapters use this path when present, then fall back to getMessageNodes() for older or custom instances. reportError() remains for adapters that render outside the instance.

replacePoundPlaceholders(value, numericValue, locale?) replaces # markers in already-resolved choice text using the instance-independent cached Intl.NumberFormat; framework renderers use it so pound formatting shares core's formatter cache.

getMessageNodes() returns the parsed message as a MessageNode[] tree. For generated functions it reconstructs the ICU pattern and parses it only when this compatibility API is called. First-party adapters render functions directly and neither parse ICU nor allocate this tree.

A custom renderer must handle every MessageNode variant, including MessageLiteralNode:

type MessageLiteralNode = {
  type: "literal"
  value: string
}

Literal nodes carry text that ICU quoting escaped — the { in '{', the ' in '', the # in '#'. They render exactly like MessageTextNode; they are a separate variant only so the parse tree records that the text was quoted. A renderer that switches on "text", "variable", "formatted", "choice", and "tag" alone silently drops every escaped character. See Quoting and literal text for the quoting rules that produce these nodes.

load() merges messages into the locale catalog. The locale passed to createI18n({ locale }), or DEFAULT_LOCALE when omitted, is active immediately. activate() switches the locale used by _() and getMessage().

First-party catalog loaders call defineCompiledCatalog() around a single map during module evaluation. A value is either a constant string or a CompiledMessage function. The non-enumerable catalog brand tells load() that string entries are constants; function entries execute directly through Core, React, or Solid renderers.

Spreading a generated catalog drops the constant-string brand, so copied string entries fall back to lazy parsing in the package-root factory while function entries remain executable. The parser-free factory rejects the unbranded copy. JSON is not a transport for generated catalogs because JavaScript serialization omits functions; use source PO/FCL data or a hand-written string catalog when a serializable format is required.

Fallback order for getMessage(id, metadata):

  1. active catalog entry for id
  2. metadata.message
  3. id

Because the initial locale is active immediately, onMissing also runs before the first load() or activate() call when that locale has no matching catalog entry. Applications that intentionally use source messages for the default locale without loading its catalog should account for those default-locale events in their telemetry policy.

MessageMetadata

interface MessageMetadata {
  message?: string
  context?: string
  comment?: string
  reportMissing?: boolean
}

reportMissing: false suppresses onMissing for a single lookup. The runtime choice components use it because their synthesized source patterns are expected to miss the catalog in apps that never loaded matching entries.

The compiler emits this metadata alongside compact internal lookup ids so the runtime can fall back to the source message and report useful diagnostics. It is not a deferred authoring API. Author translations with t at the point where they are evaluated.

Locale Controls

@palamedes/core/locale exposes headless locale helpers used by the example matrix and reusable in apps:

  • parseAcceptLanguage(header): parses and quality-sorts Accept-Language tags, including base-language fallbacks.
  • buildLocaleSwitchItems(options): builds UI-agnostic switch items with labels, active state, locale, and test ids.
  • defineLocaleControls(config): binds locale resolution, deliberate-choice cookies, canonical URLs, and suggestion decisions for cookie, route, subdomain, and tld strategies. Host-carrying URLs from canonicalUrl and suggest are protocol-relative (//host/path) unless the config sets protocol (e.g. "https"), so HTTPS pages never link users to http://.

Macro Entry Point

Macros are imported from @palamedes/core/macro and must be compiled by a Palamedes plugin before runtime.

Supported macro names:

  • t
  • plural
  • select
  • selectOrdinal

All four macros resolve translations eagerly. They must therefore be used inside a function, method, or callback, after the application has activated the relevant i18n scope. Palamedes rejects these macros at module scope during transformation and extraction. Class field initializers, including instance fields, do not satisfy this syntactic rule; use a method or getter instead.

Runtime Formatting

formatMessagePattern() and the _() method returned by createI18n() support the formatter subset implemented by the Palamedes runtime:

  • {value, number}
  • {value, number, percent} and {value, number, integer}
  • {value, number, ::percent}, {value, number, ::integer}, and {value, number, ::currency/ISO}
  • {value, date} and {value, time}
  • {value, date, short|medium|long|full}
  • {value, time, short|medium|long|full}

The standalone helpers accept the same optional final time-zone argument as the instance runtime: formatMessagePattern(pattern, values?, locale?, timeZone?) and formatMessageArgument(format, value, style?, locale?, timeZone?). Include the zone in both server and client calls when these helpers contribute to hydrated output.

Currency formatting must use the ::currency/ISO skeleton form; bare currency/ISO is outside the supported runtime subset.

Catalog artifact compilation reports list, duration, ago, name, and other unsupported formatter kinds as errors. Unsupported styles on number, date, and time are warnings because the runtime currently falls back to the default Intl formatter for that argument type.

Plural and selectordinal arguments require a present, numeric value (numeric strings are accepted). A missing or non-numeric value throws instead of silently coercing to 0 — inside _()/getMessage() that error is reported through onError and rendering falls back to the source message.

Plural Offset

plural and selectordinal arguments accept ICU offset:N, where N is a non-negative integer. It exists for "and N others" sentences, where the number shown is smaller than the number counted:

{count, plural, offset:1 =0 {nobody else} one {# other person} other {# other people}}

Three rules, applied in this order:

  1. Exact =N keys match the raw value, before the offset is subtracted. With offset:1 and count = 1, the =1 branch matches.
  2. Plural categories (zeroother) select on value - offset. With offset:1 and count = 2, the category comes from 1, so English picks one.
  3. # inside the selected branch renders value - offset.

A negative or non-integer offset is rejected while the pattern is parsed, so a bad offset surfaces as a format error rather than a wrong count. select has no numeric operand and does not support offset: an offset: written into a select argument is read as an option key, never matches, and renders empty.

The same argument is written plural(count, { offset: 1, … }) with the macro and <Plural offset={1}> with the React and Solid components; both compile to the ICU form above, so the offset lives in the catalog and translators can rely on it.

Quoting and Literal Text

The runtime parser implements ICU's apostrophe quoting in its lenient form, so ordinary prose stays readable while {, }, and # remain escapable:

  • '' is always a literal apostrophe: Ada''s renders Ada's.
  • A single ' opens a quoted literal only when the next character is {, }, or — inside a plural or selectordinal branch, where # is syntax — #. Everything up to the closing ' is literal text.
  • Anywhere else, ' is just an apostrophe. don't, it's, and l'été need no escaping and render unchanged.
  • An unterminated quote auto-closes at the end of the pattern rather than throwing: Use '{name renders Use {name.

That makes '{' the way to emit a literal brace, and '{'/'}' the way to show placeholder syntax to the reader:

PatternRenders
Ada''s fileAda's file
don't panicdon't panic
'{'name'}'{name}
'{name}'{name}
Sale: 50'%'Sale: 50'%'
{n, plural, other {'#' rank}}# rank
Use '{nameUse {name

Quoted runs become MessageLiteralNode entries in getMessageNodes() output.

Application authors do not need to think about any of this. Runtime descriptors escape authored apostrophes, while extracted catalog identities keep natural prose such as Ada's file unchanged. The extractor only doubles an apostrophe when it directly precedes generated ICU syntax: t`L'${title}` therefore produces the catalog pattern L''{title} so the placeholder remains live. Policy-aware compiled keys keep the catalog and runtime spellings aligned. The rules above matter for translators hand-editing .po files, for catalogs imported from a TMS, and for patterns passed directly to formatMessagePattern() — all of which use standard ICU quoting.

One deliberate exception: a descriptor whose message is a string literalt({ message: "Hello {name}" }) — is the raw-ICU authoring surface. Placeholders are written literally, the quoting rules above apply verbatim (It''s {name}, L'{title} quotes the brace), and nothing is auto-escaped. A descriptor whose message is a template literal is authored text like a tagged template and is auto-escaped. The JSX message attribute — <Trans message="Hello {name}" /> — is the same raw-ICU surface and is left unescaped as well, unlike <Trans> children, which are authored text.

Two helpers back the host-adapter renderers and are public for custom adapters: resolveChoice(node, value, locale?) selects the branch of a parsed plural/select/selectordinal node (returning the branch nodes plus the operand for #), and stringifyValue(value) is the string renderer's value stringification, where Date values become deterministic ISO strings.