🚀 DAY 5 OF LAUNCH WEEK:Introducing Webhook Events for Alert Changes.Learn more
Socket
Book a DemoInstallSign in
Socket

mfml

Package Overview
Dependencies
Maintainers
1
Versions
5
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

mfml

The ICU MessageFormat + XML/HTML compiler and runtime that makes your translations tree-shakeable.

latest
Source
npmnpm
Version
0.0.5
Version published
Maintainers
1
Created
Source

MFML


The ICU MessageFormat + XML/HTML compiler and runtime that makes your i18n messages tree-shakeable.

  • TypeScript-first.
  • Tree-shakeable: colocates messages with the code that uses them.
  • Integrates with any translation management system.
  • Highly customizable.
  • First-class React support.
  • Zero dependencies.
  • XSS-resilient: no dangerous HTML rendering.
  • Just 2 kB gzipped.

npm install --save-prod mfml

🔰 Quick start

⚛️ Rendering

⚙️ Devtool

🛠️ Configuration

🪵 Tokenizing messages

🌲 Parsing messages

🎯 Motivation

Quick start

Put your i18n messages in messages.json, grouped by locale:

{
  "en": {
    "greeting": "Hello, <b>{name}</b>!"
  },
  "ru": {
    "greeting": "Привет, <b>{name}</b>!"
  }
}

Put your config in mfml.config.js:

import { defineConfig } from 'mfml/compiler';
import messages from './messages.json' with { type: 'json' };

export default defineConfig({
  messages,
});

Compile messages:

npx mfml

This would create the @mfml/messages npm package in node_modules directory. You can configure the package name and the output directory to your liking.

In your application code, import message functions to produce formatted text:

import { renderToString } from 'mfml';
import { greeting } from '@mfml/messages';

renderToString({
  message: greeting,
  values: { name: 'Bob' },
  locale: 'en',
});
// ⮕ 'Hello, Bob!'

Or render messages with React:

import { Message, MessageLocaleProvider } from 'mfml/react';
import { greeting } from '@mfml/messages';

export const App = () => (
  <MessageLocaleProvider value={'en-US'}>
    <Message
      message={greeting}
      values={{ name: 'Bob' }}
    />
  </MessageLocaleProvider>
);

This renders markup with HTML elements:

Hello, <b>Bob</b>!

Now, your bundler would do all the heavy lifting and colocate message functions with components that import them in the same chunk.

Syntax overview

The MFML syntax is a hybrid of the ICU MessageFormat syntax and XML/HTML.

ICU MessageFormat is a templating syntax designed for internationalized messages. It allows developers to insert variables and handle pluralization, gender, and selection logic in a locale-aware way. MFML supports all ICU MessageFormat features and allows you to customize and extend them.

Here's the basic argument syntax:

{name}

Enable formatting by specifying data type and style:

{age, number, integer}

Select over argument categories:

{gender, select,
  male { He is a # }
  female { She is a # }
}

Pluralization is handled through argument categories as well:

You have {messageCount, plural,
  one { one message }
  other { # messages }
}

MFML supports XML/HTML tags and attributes:

Hello, <strong>{name}</strong>!

Arguments can be used where XML/HTML allows text:

<abbr title="Greetings to {name}">Hello, {name}!</abbr>

You can use your custom tags and setup a custom renderer to properly display them:

<Hint title="Final offer at {discount, percent} discount!">{price}</Hint>

Arguments

The most basic use case is argument placeholder replacement:

Hello, {name}!

Here, {name} is an argument that doesn't impose any formatting on its value. Spaces around the argument name are ignored, so this yields the same result:

Hello, {   name   }!

By default, during interpolation, the argument values are cast to string.

Types and styles

Argument values can be formatted during interpolation. Provide an argument type to select the formatter to use:

You have {count, number} messages.
                 ^^^^^^

Here, number is an argument type. By default, number type uses Intl.NumberFormat for formatting.

You can also provide a style for a formatter:

Download {progress, number, percent} complete.
                            ^^^^^^^

Default configuration provides following argument types and styles:

Argument typeArgument styleExample, en localeRequired value type
number1,000.99number or bigint
decimal1,000.99
integer1,000
percent75%
currency$1,000.00
date1/1/1970number or Date
short1/1/70
mediumJan 1, 1970
longJanuary 1, 1970
fullThursday, January 1, 1970
time12:00 AMnumber or Date
short12:00 AM
medium12:00:00 AM
long12:00:00 AM UTC
full12:00:00 AM Coordinated Universal Time
conjunctionA, B, and Cstring[]
narrowA, B, C
shortA, B, & C
longA, B, and C
disjunctionA, B, or Cstring[]
narrowA, B, or C
shortA, B, or C
longA, B, or C

Options

Instead of using a predefined style, you can provide a set of options for a formatter:

{propertyArea, number,
  style=unit
  unit=acre
  unitDisplay=long
}

Here style, unit and unitDisplay are options of the Intl.NumberFormat.

You can find the full list of options for number arguments and for date and time arguments on MDN.

Categories

Arguments can use categories for conditional rendering. For example, if you want to alter a message depending on a user's gender:

{gender, select,
  male {He}
  female {She}
  other {They}
}
sent you a message.

Value of the gender argument is used for selecting a specific category. If the value is "male" then argument placeholder is replaced with "He", if value is "female" then with "She", and for any other value "They" is rendered.

other is a special category: its value is used if no other category matches. If there's no matching category in select and no other category, the argument placeholder is replaced with an empty string.

It is recommended to always provide the other category as a fallback.

Pluralization

Numeric argument values can be pluralized in cardinal and ordinal fashion.

Use plural argument type for cardinal pluralization rules:

You have {messageCount, plural,
  one {one message}
  other {# messages}
}.

Following cardinal categories are supported with plural:

zero

This category is used for languages that have grammar specialized specifically for zero number of items. (Examples are Arabic and Latvian.)

one

This category is used for languages that have grammar specialized specifically for one (singular) item. Many languages, but not all, use this plural category. (Many popular Asian languages, such as Chinese and Japanese, do not use this category.)

two

This category is used for languages that have grammar specialized specifically for two (dual) items. (Examples are Arabic and Welsh.)

few

This category is used for languages that have grammar specialized specifically for a small number (paucal) of items. For some languages this is used for 2-4 items, for some 3-10 items, and other languages have even more complex rules.

many

This category is used for languages that have grammar specialized specifically for a larger number of items. (Examples are Arabic, Polish, and Russian.)

other

This category is used if the value doesn't match one of the other plural categories. Note that this is used for plural for languages (such as English) that have a simple "singular" versus "plural" dichotomy.

Use selectOrdinal argument type for ordinal pluralization rules:

You have finished {position, selectOrdinal,
  one {#st}
  two {#nd}
  few {#rd}
  other {#th}
}.

Following ordinal categories are supported with selectOrdinal:

zero

This category is used for languages that have grammar specialized specifically for zero number of items. (Examples are Arabic and Latvian.)

one

This category is used for languages that have grammar specialized specifically for one item. Many languages, but not all, use this plural category. (Many popular Asian languages, such as Chinese and Japanese, do not use this category.)

two

This category is used for languages that have grammar specialized specifically for two items. (Examples are Arabic and Welsh.)

few

This category is used for languages that have grammar specialized specifically for a small number of items. For some languages this is used for 2-4 items, for some 3-10 items, and other languages have even more complex rules.

many

This category is used for languages that have grammar specialized specifically for a larger number of items. (Examples are Arabic, Polish, and Russian.)

other

This category is used if the value doesn't match one of the other plural categories. Note that this is used for plural for languages (such as English) that have a simple "singular" versus "plural" dichotomy.

You can use the special token (#, aka octothorpe) as a placeholder inside a category. By default, both plural and selectOrdinal argument types format # using number argument type without style.

If you want apply a specific formatting, use an explicit argument instead of an octothorpe:

You have {messageCount, plural,
  one {one message}
  other {{messageCount, number, useGrouping=false} messages}
}.

Both plural and selectOrdinal support literal value matching. Prefix a value with an equals character =:

You have {messageCount, plural,
  =0 {no messages}
  one {one message}
  other {# messages}
}.

Render to string

Render messages as plain text using renderToString:

For example if you compiled a message:

{
  "en": {
    "greeting": "Hello, <b>{name}</b>!"
  }
}

Then you can render it to string:

import { renderToString } from 'mfml';
import { greeting } from '@mfml/messages';

renderToString({
  message: greeting,
  values: { name: 'Bob' },
  locale: 'en',
});
// ⮕ 'Hello, Bob!'

By default, renderToString doesn't render tags and outputs only their contents. It also ignores tags that aren't lowercase alpha: these are considered custom tags. You can change this behavior by providing a custom renderer:

import { defaultArgumentFormatter, defaultCategorySelector, type Renderer } from 'mfml';

const myRenderer: Renderer<string> = {
  renderElement(tagName, attributes, children) {
    return tagName === 'b' ? `__${children.join('')}__` : children.join('');
  },

  formatArgument: defaultArgumentFormatter,
  selectCategory: defaultCategorySelector,
};

renderToString({
  message: greeting,
  values: { name: 'Bob' },
  locale: 'en',
  renderer: myRenderer,
});
// ⮕ 'Hello, __Bob__!'

React integration

MFML provides a set of components to render compiled i18n message function with React.

For example if you compiled a message:

{
  "en": {
    "greeting": "Hello, <b>{name}</b>!"
  }
}

Then you can render it with React using <Message> component:

import { Message, MessageLocaleProvider } from 'mfml/react';
import { greeting } from '@mfml/messages';

export const App = () => (
  <MessageLocaleProvider value={'en'}>
    <Message
      message={greeting}
      values={{ name: 'Bob' }}
    />
  </MessageLocaleProvider>
);

This would output:

Hello, <b>Bob</b>!

If argument doesn't have a type specified, then you can pass an arbitrary React markup as its value:

<Message
  message={greeting}
  values={{ name: <Cool>Bob</Cool> }}
/>

This would output:

Hello, <b><Cool>Bob</Cool></b>!

Custom renderer

By default, <Message> only renders tags that are lowercase alpha. Other tags are considered custom tags. You can provide a custom renderer to support custom tags:

import { type ReactNode } from 'react';
import { defaultArgumentFormatter, defaultCategorySelector, type Renderer } from 'mfml';
import { createReactDOMElementRenderer, Message, MessageLocaleProvider, MessageRendererProvider } from 'mfml/react';

// 1️⃣ Create a custom component
function Hint(props: { text: string; children: ReactNode }) {
  return <div title={props.text}>{props.children}</div>;
}

// 2️⃣ Create a custom renderer
const myRenderer: Renderer<string> = {
  renderElement: createReactDOMElementRenderer({
    Hint,
  }),
  formatArgument: defaultArgumentFormatter,
  selectCategory: defaultCategorySelector,
};

export const App = () => (
  // 3️⃣ Provide renderer through the context
  <MessageRendererProvider value={myRenderer}>
    <MessageLocaleProvider value={'en'}>{/* Render messages here */}</MessageLocaleProvider>
  </MessageRendererProvider>
);

Now you can use <Hint> component in your i18n messages:

{
  "en": {
    "finalOffer": "Final offer: <Hint text='{discount, percent} discount!'>{price, currency}</Hint>"
  }
}

Render the compiled message function:

<Message
  message={finalOffer}
  values={{ discount: 0.2, price: 1000 }}
/>

This would output:

Final offer:
<div title='20% discount!'>$1,000</div>

Custom formatters

MFML provides the defaultArgumentFormatter that supports number, date, time, conjunction and disjunction argument types and corresponding styles.

To change how formatters are applied, you should create a custom renderer and provide it to renderToString or to <Message> component (as a prop or through a <MessageRendererProvider>).

Let's assume we've compiled the function which is called randomFact for the following message:

USA border length is {borderLength, unitMeter}.
                                    ^^^^^^^^^

Here, unitMeter is a custom type. Now, let's create a formatter that formats arguments with this type:

import { type ArgumentFormatter } from 'mfml';

const unitMeterArgumentFormatter: ArgumentFormatter = params => {
  if (params.type === 'unitMeter' && typeof params.value === 'number') {
    const numberFormat = new Intl.NumberFormat(params.locale, {
      style: 'unit',
      unit: 'meter',
    });

    return numberFormat.format(params.value);
  }

  return params.value;
};

Now, lets crete a string renderer that uses this formatter:

import { defaultArgumentFormatter, defaultCategorySelector, type Renderer } from 'mfml';

const myRenderer: Renderer<string> = {
  renderElement: (tagName, attributes, children) => children.join(''),

  // 🟡 Pass a custom formatter
  formatArgument: unitMeterArgumentFormatter,
  selectCategory: defaultCategorySelector,
};

Render a message to string using this custom renderer:

renderToString({
  message: randomFact,
  values: { borderLength: 8_891_000 },
  locale: 'en',

  // 🟡 Pass a custom renderer
  renderer: myRenderer,
});
// ⮕ 'USA border length is 8,891,000 m.'

Usually, you want multiple formatters to be available, so MFML provides factories that simplify formatter declarations:

import { combineArgumentFormatters, createNumberArgumentFormatter, createDateTimeArgumentFormatter } from 'mfml';

const myArgumentFormatter = combineArgumentFormatters([
  unitMeterArgumentFormatter,

  createNumberArgumentFormatter('number', 'decimal'),
  createNumberArgumentFormatter('number', 'integer', { maximumFractionDigits: 0 }),

  createDateTimeArgumentFormatter('date', 'short', { dateStyle: 'short' }),
]);

combineArgumentFormatters creates an argument formatter that sequentially applies each formatter from the list of formatters until one returns a formatted value. If none of the formatters returns a formatted value, then a value returned as-is.

Of you can fallback to a default formatter:

import { combineArgumentFormatters, defaultArgumentFormatter } from 'mfml';

const myArgumentFormatter = combineArgumentFormatters([
  unitMeterArgumentFormatter,
  defaultArgumentFormatter,
]);

Devtool

MFML provides the devtool for React DOM applications. To enable it, call enableDevtool anywhere in your client-side code (on the server-side it is a no-op):

import { enableDevtool } from 'mfml/react';
import { debugInfo } from '@mfml/messages/metadata';

enableDevtool(debugInfo);

Here @mfml/messages is a package generated by the mfml compiler. The exported debugInfo may have substantial size, so it is recommended to split it into a separate chunk:

import { enableDevtool } from 'mfml/react';

import('@mfml/messages/metadata').then(module => enableDevtool(module.debugInfo));

To use the devtool, press and hold the Alt key (or Option key on Mac), then hover over a rendered message text to reveal the related message information.

Configuration

When running mfml from the command line, MFML will automatically try to resolve a config file named mfml.config.js inside the cwd (other JS and TS extensions are also supported).

The most basic config file looks like this:

import { defineConfig } from 'mfml/compiler';

export default defineConfig({
  messages: {
    // Your i18n messages go here, grouped by locale
    en: {
      greeting: 'Hello',
    },
  },
});

You can also explicitly specify a config file to use with the --config CLI option (resolved relative to cwd):

mfml --config my-config.js

messages

Messages arranged by a locale.

{
  en: {
    greeting: 'Hello',
  },
  ru: {
    greeting: 'Привет',
  },
}

It is recommended to store messages in separate files on per-locale basis:

// en.json
{
  "greeting": "Hello"
}
// ru.json
{
  "greeting": "Привет"
}

Then import them in your configuration file:

// mfml.config.json
import { defineConfig } from 'mfml/compiler';
import en from './en.json' with { type: 'json' };
import ru from './ru.json' with { type: 'json' };

export default defineConfig({
  messages: {
    en,
    ru,
  },
});

outDir

Default: The directory that contains the config file.

The directory that contains node_modules where a package with compiled messages is written.

packageName

Default: @mfml/messages

The name of the package where compiled messages are stored.

Specify a custom package name:

export default defineConfig({
  packageName: 'my-messages',
  messages: {
    en: {
      greeting: 'Hello',
    },
  },
});

Import message functions from a custom package:

import { renderToString } from 'mfml';
import { greeting } from 'my-messages';

renderToString({
  message: greeting,
  locale: 'en',
});
// ⮕ 'Hello'

fallbackLocales

Default: {}

Mapping from a locale to a corresponding fallback locale.

For example, let's consider fallbackLocales set to:

{
  'en-US': 'en',
  'ru-RU': 'ru',
  'ru': 'en',
}

In this case:

  • If a message doesn't support the ru-RU locale, the compiler will look for the ru locale.

  • If the ru locale isn't supported either, the compiler will fall back to the en locale.

  • If the en locale also isn't supported, the message function will return null when called with the ru-RU locale.

It is safe to have loops in fallback locales:

{
  en: 'ru',
  ru: 'en',
}

This setup would result in a fallback to ru for messages that don't support en, and a fallback to en for messages that don't support ru.

preprocessors

Default: []

The array of callbacks that are run before the message is parsed as an MFML AST.

Here's how to transform Markdown messages before compilation:

import { marked } from 'marked';
import { defineConfig, type Preprocessor } from 'mfml/compiler';

const transformMarkdown: Preprocessor = params => marked.parse(params.text);

export default defineConfig({
  messages: {
    en: {
      greeting: '__Hello__',
    },
  },
  preprocessors: [transformMarkdown],
});

The compiled greeting message would have the following markup:

<p><strong>Hello</strong></p>

postprocessors

Default: []

The array of callbacks that are run after the message was parsed as an MFML AST.

Preprocessors can be used to validate messages, rename arguments, or for other AST-based transformations.

Here's how to rename all message arguments before compilation:

import { walkNode } from 'mfml';
import { defineConfig, type Postprocessor } from 'mfml/compiler';

const renameArguments: Postprocessor = params => {
  walkNode(params.messageNode, node => {
    if (node.nodeType === 'argument') {
      node.name = node.name.toLowerCase();
    }
  });

  return params.messageNode;
};

export default defineConfig({
  messages: {
    en: {
      greeting: '{NAME} is {AGE, number} years old',
      //          ^^^^ 🟡 Upper case argument names
    },
  },
  postprocessors: [renameArguments],
});

The compiled greeting message function would have the following signature:

function greeting(locale: string): MessageNode<{ name: unknown; age: number }> | null;
//                                               ^^^^ 🟡 Lower case argument names

renameMessage​Function

Default: messageKey => messageKey

Returns the name of the message function for the given message key.

export default defineConfig({
  messages: {
    en: {
      greeting: 'Hello',
    },
  },
  renameMessageFunction: messageKey => 'Bazinga_' + messageKey,
});

Import the message function with the altered name:

import { renderToString } from 'mfml';
import { Bazinga_greeting } from '@mfml/messages';

renderToString({
  locale: 'en',
  message: Bazinga_greeting,
});
// ⮕ 'Hello'

Message function names are always escaped; illegal characters are replaced with underscores.

Compilation fails if the same function name is generated for different message keys.

decodeText

Default: decodeXML

Decode the text content before it is pushed to an MFML AST node. Use this method to decode HTML entities.

By default, the compiler supports XML entities and numeric character references.

{
  "en": {
    "hotOrCold": "hot &gt; cold &#176;"
  }
}

Provide a custom decoder to support HTML entities:

import { decodeHTML } from 'speedy-entities';

export default defineConfig({
  messages: {
    en: {
      hello: '&CounterClockwiseContourIntegral;',
    },
  },
  decodeText: decodeHTML,
});

[!TIP]
Read more about speedy-entities, the fastest XML/HTML entity encoder/decoder in just 13 kB gzipped.

getArgumentTSType

Default: getIntlArgumentTSType

Returns the TypeScript type for a given argument.

export default defineConfig({
  messages: {
    en: {
      greeting: '{name} is {age, number} years old',
    },
  },

  getArgumentTSType(argumentNode) {
    if (argumentNode.typeNode?.value === 'number') {
      return 'number';
    }
    return 'string';
  },
});

This would produce a message function with the following signature:

function greeting(locale: string): MessageNode<{ name: string; age: number }> | null;

By default, getIntlArgumentTSType is used. It returns the TypeScript type of an argument that matches the requirements of Intl formats.

Argument typeTypeScript type
numbernumber | bigint
datenumber | Date
timenumber | Date
liststring[]
pluralnumber
selectOrdinalnumber
selectA union of category name literals. string & {} is used for the other category.

tokenizerOptions

Default: htmlTokenizerOptions

Options that define how MFML messages are tokenized. By default, forgiving HTML tokenizer options are used.

export default defineConfig({
  messages: {
    en: {
      greeting: '<h1>Hello<p>Dear diary',
    },
  },
  tokenizerOptions: {
    implicitlyClosedTags: {
      p: ['h1'],
    },
    isUnbalancedStartTagsImplicitlyClosed: true,
  },
});

The compiled message would be:

<h1>Hello</h1><p>Dear diary</p>

To partially override tokenizer options use spread:

import { defineConfig } from 'mfml/compiler';
import { htmlTokenizerOptions } from 'mfml/parser';

export default defineConfig({
  // messages: { … },
  tokenizerOptions: {
    ...htmlTokenizerOptions,
    isOctothorpeRecognized: false,
  },
});

Read more about tokenization in Tokenizing messages section.

Tokenizing messages

Create a tokenizer using createTokenizer to read message as a sequence of tokens:

import { createTokenizer } from 'mfml/parser';

const tokenizer = createTokenizer();

tokenizer.tokenize('Hello, <b>{name}</b>!', (token, startIndex, endIndex) => {
  // Handle tokens here
});

The callback is called with the following arguments:

tokenstartIndexendIndexCorresponding substring
'TEXT'07'Hello, '
'START_TAG_NAME'89'b'
'START_TAG_CLOSING'910'>'
'ARGUMENT_NAME'1115'name'
'ARGUMENT_CLOSING'1516'}'
'END_TAG_NAME'1819'b'
'TEXT'2021'!'

Tokens are guaranteed to be returned in correct order or a ParserError is thrown. Missing tokens can be inserted to restore the correct order if needed, depending on provided tokenizer option.

Create a tokenizer with a custom set of options:

const tokenizer = createTokenizer({
  isUnbalancedStartTagsImplicitlyClosed: true,
});

MFML provides a preconfigured forgiving HTML tokenizer htmlTokenizer and corresponding set of options htmlTokenizerOptions:

import { createTokenizer, htmlTokenizerOptions } from 'mfml/parser';

const tokenizer = createTokenizer(htmlTokenizerOptions);
// Same as htmlTokenizer

voidTags

Default: []

The list of tags that can't have any contents (since there's no end tag, no content can be put between the start tag and the end tag).

createTokenizer({
  voidTags: ['img', 'link', 'meta'],
});

See HTML5 Void Elements for more info.

rawTextTags

Default: []

The list of tags which content is interpreted as plain text. Tags inside raw text tag are treated as plain text.

createTokenizer({
  rawTextTags: ['script', 'style', 'plaintext'],
});

See HTML5 Raw Text Elements for more info.

implicitlyClosedTags

Default: {}

The map from a tag to a list of tags that must be closed if it is opened.

For example, in HTML p and h1 tags have the following semantics:

<p><h1></h1><p></p><h1></h1>
                   ^^^

To achieve this behavior, set this option to:

createTokenizer({
  implicitlyClosedTags: {
    // h1 implicitly closes p
    h1: ['p'],
  },
});

Use in conjunctions with isUnbalancedStartTags​ImplicitlyClosed.

implicitlyOpenedTags

Default: []

The list of tags for which a start tag is inserted if an unbalanced end tag is met. Otherwise, a ParserError is thrown.

You can ignore unbalanced end tags with isUnbalancedEndTagsIgnored.

For example, in HTML p tag follow this semantics:

</p><p></p>
            ^^^

To achieve this behavior, set this option to:

createTokenizer({
  implicitlyOpenedTags: ['p'],
});

isCaseInsensitive​Tags

Default: false

If true then [A-Z] characters are case-insensitive in tag names.

<em></EM><em></em>
                  ^^^

isSelfClosingTags​Recognized

Default: false

If true then self-closing tags are recognized, otherwise they are treated as start tags.

<link />
      ^

isUnbalancedStart​Tags​Implicitly​Closed

Default: false

If true then unbalanced start tags are forcefully closed. Otherwise, a ParserError is thrown.

<a><b></a><a><b></b></a>
                    ^^^

Use in conjunctions with isUnbalancedEndTagsIgnored.

isUnbalancedEndTags​Ignored

Default: false

If true then end tags that don't have a corresponding start tag are ignored. Otherwise, a ParserError is thrown.

<a></b></a><a></a>
    ^^^

Use in conjunctions with isUnbalancedStartTagsImplicitlyClosed.

isRawText​Interpolated

Default: false

If true then arguments are parsed inside rawTextTags.

Here's how to add a raw text tag:

createTokenizer({
  rawTextTags: ['plaintext'],
  isRawTextInterpolated: true,
});

An argument inside the <plaintext> tag is interpolated:

<plaintext>{name}</plaintext>
           ^^^^^^

isOctothorpe​Recognized

Default: false

If true then an octothorpe character # inside an argument category is replaced with the argument value.

{gender, select, male { He is a # } female { She is a # }}
                                                      ^

Parsing messages

Create a parser using createParser:

import { createParser, htmlTokenizer } from 'mfml/parser';

const parser = createParser({ tokenizer: htmlTokenizer });

Parser converts a message text into an AST by consuming tokens produced by a tokenizer. Read more about message tokenization in Tokenizing messages section.

Parse a message as an AST:

const messageNode = parser.parse('en', 'Hello, <b>{name}</b>!');

This returns a MessageNode instance that looks like this:

{
  nodeType: 'message',
  parentNode: null,
  locale: 'en',
  childNodes: [
    {
      nodeType: 'text',
      parentNode: { /* Cyclic reference to the message node */ },
      value: 'Hello, ',
      startIndex: 0,
      endIndex: 7,
    },
    {
      nodeType: 'element',
      parentNode: { /* Cyclic reference to the message node */ },
      tagName: 'b',
      attributeNodes: null,
      childNodes: [
        {
          nodeType: 'argument',
          parentNode: { /* Cyclic reference to the element node */ },
          name: 'name',
          typeNode: null,
          styleNode: null,
          optionNodes: null,
          categoryNodes: null,
          startIndex: 11,
          endIndex: 15,
        },
      ],
      startIndex: 8,
      endIndex: 9,
    },
    {
      nodeType: 'text',
      parentNode: { /* Cyclic reference to the message node */ },
      value: '!',
      startIndex: 20,
      endIndex: 21,
    },
  ],
}

MFML AST can be analyzed at runtime and rendered in any way you want. In Quick start section we showed how messages can be compiled into message functions. A message function returns an AST that describes a message:

import { greeting } from '@mfml/messages';

const messageNode = greeting('en-US');

Use walkNode to traverse AST:

import { walkNode } from 'mfml';

walkNode(messageNode, node => {
  if (node.nodeType === 'argument') {
    // Handle argument node
  }
});

Motivation

The main idea is that i18n messages are part of the code, and the application's code should be split into dynamically loaded chunks that include the required translations. When translations change, a new version of the application should be released.

Splitting the app code into chunks and loading those chunks at runtime is usually handled by a bundler. Modern i18n tools, such as i18next, rely on JSON files that are either baked into the application bundle or loaded asynchronously. This approach has several downsides:

  • You must split your messages into separate JSON files before bundling. Otherwise, clients will have to download all messages when the application starts.

  • You need to load messages manually when they're required.

  • Unused messages remain bundled and loaded unless you remove them manually.

  • Errors, such as missing interpolation parameters or invalid parameter types, are only caught at runtime.

All of the above is highly error-prone due to human factors.

MFML compiles messages into functions that can be imported as modules in your code. This approach offers multiple benefits:

  • Message functions are bundled and loaded together with your code, so you don't need to manage translation loading manually.

  • Message functions are tree-shakeable, ensuring that the chunks produced by the bundler contain only the translations actually used.

  • Message functions can be type-checked at compile time. This ensures you won't forget required parameters or use parameters of the wrong type. If a message key changes, the modules importing the corresponding message function will need to be updated — otherwise, the type checker or bundler will alert you that the imported function is missing.

  • Message functions use a runtime to render the message. The runtime can produce React elements, plain strings, or any other output format you need.

:octocat: :heart:

Keywords

i18n

FAQs

Package last updated on 28 Oct 2025

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts