@sdxc/messageformat
Unicode MessageFormat 2 parser and formatter shaped like Intl.MessageFormat
- Installs with
- @sdxc/result
- Used by
- auth-saas
- Source
- packages/messageformat
Unicode MessageFormat 2 parser and formatter shaped like Intl.MessageFormat.
Installation
npm add @sdxc/messageformat
parse returns @sdxc/result values, which
installs alongside this package.
Usage
Format A Message
import { MessageFormat } from "@sdxc/messageformat";
let message = new MessageFormat("en", "{$count :number} new posts");
message.format({ count: 1200 }); // "1,200 new posts"
A message is parsed once, in the constructor, and formats any number of times. Instances are immutable, so one can be shared by every request that formats it.
Choose A Variant
let message = new MessageFormat(
"en",
`.input {$count :number}
.match $count
0 {{You have no notifications}}
one {{You have {$count} notification}}
* {{You have {$count} notifications}}`,
);
message.format({ count: 0 }); // "You have no notifications"
message.format({ count: 1 }); // "You have 1 notification"
message.format({ count: 7 }); // "You have 7 notifications"
An exact numeric key beats a plural category, which beats *. Categories come from
Intl.PluralRules
for the message's locale; select=ordinal switches to ordinal rules and select=exact to exact
keys only.
Render Markup
let message = new MessageFormat("en", "Read {#link href=$url}the guide{/link}.");
message.format({ url: "/guide" }); // "Read the guide."
message.formatToParts({ url: "/guide" });
// [
// { type: "text", value: "Read " },
// { type: "markup", kind: "open", source: "#link", name: "link", options: { href: "/guide" } },
// { type: "text", value: "the guide" },
// { type: "markup", kind: "close", source: "/link", name: "link" },
// { type: "text", value: "." },
// ]
Handle Errors
let message = new MessageFormat("en", "Hello {$name}!", { bidiIsolation: "none" });
message.format({}, (error) => report(error)); // "Hello {$name}!"
A placeholder that fails to resolve formats as its fallback, such as {$name}, and the error
goes to onError, so a broken translation shows a visible marker and never throws at render
time.
Check A Message Without Throwing
import { parse } from "@sdxc/messageformat";
import { isFailure } from "@sdxc/result";
let result = parse("{$count :number");
if (isFailure(result)) console.error(result.error.type, result.error.start); // "syntax-error" 15
API
new MessageFormat(locales, source, options?)
Compiles source (MessageFormat 2 syntax, or a MessageData object) for locales (a BCP 47
tag, a list of them, or undefined for the runtime default). Throws a MessageError on a
syntax or data model error, as the proposal specifies. Options:
bidiIsolation:"compatibility"(default) wraps each placeholder whose direction may differ from the message's in Unicode isolate controls (U+2066 to U+2069);"none"leaves the output untouched.dir: the message's base direction,"ltr","rtl"or"auto"; defaults to the direction of the first locale's script.functions: custom functions by name,"ns:name"included. They take precedence over the defaults.localeMatcher:"best fit"(default) or"lookup".
format(values?, onError?)
Formats to a string. Markup formats as the empty string; a failed placeholder formats as
{source}. Without onError, errors are discarded: the fallback text in the output is the only
sign of them, and formatting never throws. Pass onError to log or collect them.
formatToParts(values?, onError?)
Formats to an array of parts:
{ type: "text", value }for literal text.{ type: "bidiIsolation", value }around a placeholder that needs isolation.{ type: "markup", kind, source, name, id?, options? }for markup;kindis"open","standalone"or"close".{ type: "string", source, locale, dir?, id?, value }for:stringand plain text values.{ type: "number", source, locale, dir?, id?, parts }for:numberand:integer, wherepartsareIntl.NumberFormatparts.{ type: "fallback", source }for a placeholder that failed.{ type: "unknown", source, value }for a variable that is neither a string nor a number.
The u:id option sets id on a placeholder's parts, and u:dir sets its direction and
isolates it.
resolvedOptions()
Returns { bidiIsolation, dir, functions, localeMatcher } as the instance settled on;
functions holds the custom functions passed in.
parse(source)
Parses and validates MessageFormat 2 source into its data model, returning a Result. The
failure is a MessageError whose type is syntax-error (with start at the offending
offset) or a data model error: variant-key-mismatch, missing-fallback-variant,
missing-selector-annotation, duplicate-declaration, duplicate-option-name or
duplicate-variant.
MessageError
The error every stage reports. type names the error as the MessageFormat 2 specification
does, source is the failed placeholder's fallback representation, start a syntax error's
offset. Formatting reports unresolved-variable, unknown-function, bad-operand,
bad-option, bad-selector and not-formattable; an error a custom function throws that is
not a MessageError arrives as function-error with the original as cause.
Default Functions
:stringformats its operand as text and selects the key equal to it.:numberformats throughIntl.NumberFormatand selects exact numeric keys, then plural categories. Options:select(plural,ordinal,exact; literal only),signDisplay,useGrouping(auto,always,never,min2),minimumIntegerDigits,minimumFractionDigits,maximumFractionDigits,minimumSignificantDigits,maximumSignificantDigits,trailingZeroDisplay,roundingPriority,roundingIncrementandroundingMode.:integertruncates its operand, then formats and selects like:number, readingselect,signDisplay,useGrouping,minimumIntegerDigitsandmaximumSignificantDigits.
An unannotated variable holding a number formats as :number, and one holding a string as
:string. A string operand of a numeric function must be a JSON-style number, such as 4.2 or
-1e3.
Types
MessageFunction:(context, options, input?) => MessageValue.contextis{ locales, dir, source }; literals arrive as strings, local variables as theMessageValuetheir declaration resolved to, and external variables as given. Throwing formats the placeholder as its fallback.MessageValue:{ type, locale, dir, source, options?, selectKeys?, toParts?, toString?, valueOf? }. A value formats when it hastoPartsandtoString, and selects when it hasselectKeys, which returns the matching keys, best first.MessageDataand its members (PatternMessage,SelectMessage,Declaration,Variant,Pattern,Expression,Markup,Literal,VariableRef,FunctionRef,Options,Attributes): the MessageFormat 2 data model.MessagePartand its members, one per part shape listed above.
Pattern: A Custom Function
import { MessageError, MessageFormat } from "@sdxc/messageformat";
import type { MessageFunction } from "@sdxc/messageformat";
let list: MessageFunction = (context, options, input) => {
if (!Array.isArray(input)) {
throw new MessageError("bad-operand", "Expected an array", { source: context.source });
}
let type = options.type === "or" ? "disjunction" : "conjunction";
let value = new Intl.ListFormat(context.locales, { type }).format(input.map(String));
return {
type: "list",
locale: context.locales[0] ?? "und",
dir: "auto",
source: context.source,
toParts: () => [{ type: "list", source: context.source, value }],
toString: () => value,
};
};
let message = new MessageFormat("en", "Invited: {$people :list}", {
bidiIsolation: "none",
functions: { list },
});
message.format({ people: ["Ana", "Ben", "Cy"] }); // "Invited: Ana, Ben, and Cy"
Pattern: Folding Markup Into Elements
import { MessageFormat } from "@sdxc/messageformat";
import type { MessagePart } from "@sdxc/messageformat";
interface Node {
tag?: string;
children: Array<Node | string>;
}
function toTree(parts: MessagePart[]): Node {
let root: Node = { children: [] };
let stack = [root];
for (let part of parts) {
let parent = stack[stack.length - 1] ?? root;
if (part.type === "markup" && "kind" in part) {
if (part.kind === "close") stack.pop();
else {
let node: Node = { tag: part.name, children: [] };
parent.children.push(node);
if (part.kind === "open") stack.push(node);
}
} else if (part.type !== "bidiIsolation" && "value" in part) {
parent.children.push(String(part.value));
} else if ("parts" in part && part.parts) {
parent.children.push(part.parts.map((item) => item.value).join(""));
}
}
return root;
}
let message = new MessageFormat("en", "{#b}{$n :number}{/b} new posts", { bidiIsolation: "none" });
toTree(message.formatToParts({ n: 3 }));
// { children: [{ tag: "b", children: ["3"] }, " new posts"] }