Curly Message Format

September 20, 2026 · View on GitHub

Version 3 — Stable

Format nameCurly Message Format
Machine-readable identifiercurly-message
Versioned identifiercurly-message-3
StatusStable

Version 3 is stable. The body of this document is normative: it states what a conforming implementation must do. Within curly-message-3, what a message resolves to is settled — a message written against this document resolves the same way against every later revision of it, and an amendment that would change that belongs to a later version of the format rather than to this one. The promise is about messages: a caller that supplied none wrote nothing for this document to settle, and what an implementation answers where there is no message to resolve is outside it. What a later revision may add is what leaves the messages written today alone: a modifier under a name no earlier revision defined, a conformance level an implementation opts into, and wording that states more precisely what the body already required.

This document states version 3 and nothing else. Three documents beside it carry what it does not, and none of them is part of this specification: CHANGELOG.md records every revision, MIGRATIONS.md is the path from an earlier version of the format, and RULINGS.md says why a rule reads as it does.

1. Scope

This document specifies the Curly Message Format: a syntax for translatable messages in which values are substituted into double-curly placeholders, and a placeholder may carry a modifier, a list of options and a fallback.

It specifies the message syntax and the result of resolving a message against a payload, props and a locale (section 4). It does not specify a host API, a file format for message catalogues, an id-namespacing scheme, or how an implementation reports diagnostics.

It describes a message as text to be resolved. A companion document, CST.md, describes the same message as text to be shown: a concrete syntax tree over the grammar of section 6, for a tool that highlights, completes or lints a message rather than resolving it. It adds nothing this document requires, and an implementation conforms without offering a tree.

The format is deliberately small. It has no plural categories and no gender selection. Formatting that depends on a locale is delegated to the host platform's internationalization facilities. A placeholder may hold a placeholder in an option value, and there it is one construct holding another rather than an argument syntax: what is nested is written in the message, never supplied with the payload (sections 12, 14.1).

2. Conformance

The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT and MAY are to be interpreted as described in RFC 2119.

An implementation conforms to this specification at one or more levels:

LevelSectionsRequirement
Core4-10, 11 (excluding 11.2 and 11.3), 12, 13, 14Grammar, escaping, whitespace, resolution, the fallback chain, what a modifier receives and returns, the comparison modifiers, nesting and its limits, security properties and error behavior.
Intl11.2The locale-dependent formatting modifiers number, date, ago and currency.
Extensions11.3Host-defined modifiers.

Core is REQUIRED. An implementation MUST report which levels it satisfies. An implementation that does not satisfy Intl MUST treat the formatting modifier names as unknown modifiers (section 11.4), not as ordinary option keys: an implementation that lacks those modifiers has not defined them, and a host is free to define them itself, because a host's own configuration overrides the format's (section 11.3).

Conformance is tested by the implementation-independent fixture set @curly-message/conformance, which targets a stated version of this document. That set is developed in the conformance/ directory of this document's repository and released from it.

An implementation states the levels it satisfies and the limits it permits to that set through the adapter of section 14.3.

3. Terminology

message : Text that may contain placeholders. A message a host wrote as something else is converted to text before anything reads it (sections 4 and 5).

payload : A mapping from string keys to entries, supplied by the caller, from which placeholder values are resolved. An entry is a value, or the value's own configuration (section 4.1).

props : Caller-supplied formatting properties for the formatting modifiers, grouped by modifier name. Distinct from the payload: the payload carries data, props carry presentation. Distinct from an option, which is written in the placeholder: props are supplied by the caller (section 11.2).

locale : A language tag identifying the target language, used by the formatting modifiers.

id (of a message) : The identifier under which a message was requested. Nothing in resolution reads it; reports name it, so that a report says which message went looking (section 14.3).

placeholder : A {{ … }} construct within a message that section 6 derives as one. A construct it does not derive is literal text (section 9.1).

selector : The leading part of a placeholder: a payload key and a modifier, both optional (section 9.1).

option : A key:value pair within a placeholder, offered to the modifier for selection.

inline default : The option-shaped segment (section 6) whose key is default. It is not an option; it is the placeholder's own fallback (section 9.3).

absent : A value is absent when the payload has no own entry for the key, or when the entry it owns is the host's undefined. A value that is present but empty, zero, false or the host's null is not absent; a present value that no conversion can describe is treated as absent (section 9.2).

4. Data model

Resolving a message takes four inputs — a message, a payload, props and a locale — and produces a string. A fifth, the message's id, is optional and reaches no step of resolution: reports name it (section 14.3), and nothing else reads it.

A caller that supplies no message — in a host with an undefined, a message that is the host's undefined — has supplied nothing to resolve, and the resolution is the empty string. So has one whose message is present but that no conversion below can describe, the same way such a value is absent (section 9.2). Nothing else fails to exist: a message that exists resolves normally, even when it is empty, and one that is zero, false or the host's null resolves as the text this section converts it to. What a host shows in the place of a message its catalogue does not hold is the host's own, which section 1 declines along with the catalogue itself.

Everything the format carries is text. A payload value MAY be of any host type, but it reaches a modifier, an option comparison and the output as text, never at the type it was authored with.

A value that is a plain object or a plain array MUST be converted using the host's JSON serialization, so that a structured value survives into a host-defined modifier that reads it back. Every other value MUST be converted using the host's ordinary string conversion, so a date, a pattern, a set or a class instance keeps whatever text it describes itself as.

Plain object and plain array MUST both be read narrowly, and by the value's own type: a value of the type a host offers for arbitrary keyed data, or of the type it offers for an arbitrary ordered sequence, carrying no meaning beyond the entries it holds. A type an application declared for data of its own — a class, a struct, a record, a sequence derived from the host's own — is not one, and neither is a type the host offers for something more specific than data, so a date, a pattern and a set convert as strings however the host would serialize them. Where a host offers no such type it holds no value of that shape, and what one would have carried converts as a string like everything else.

In ECMAScript that reading is the prototype: an object whose prototype is the running realm's Object.prototype, or null, and an array whose prototype is the running realm's Array.prototype. A value built in another realm has a prototype of its own by that test, whatever it was authored as, and so does one of a type an application derived; both convert like every other value.

The narrow reading is what keeps a value authored as a date instance formattable by date (section 11.2), and section 4.1 recognizes a wrapper by the same reading.

A value that no conversion can describe — a serialization that raises, yields nothing or reaches the conversion limit of section 13, a string conversion that raises — MUST be treated as absent, MUST NOT raise, and SHOULD be reported (section 14.2).

How many times a value is converted while a message resolves is itself bounded (section 13).

The text a value's conversion produces is data, and nothing removes escape sequences from it (section 7). A backslash it carries is a backslash, so a serialization reaches the output parsable as the format it was made in. A message is the exception, because it is not a value the walk reads but the text the walk is over: section 5 converts it before anything reads it, and what the conversion produced is then the message's own text.

Wherever a value is compared numerically, the implementation MUST convert it using the host's ordinary numeric conversion, and a conversion that does not yield a number MUST be treated as a failed comparison, never as an error.

An implementation MUST return a string, including when the message it was given was not one.

4.1 Value wrappers

A payload entry MAY be the value's own configuration instead of the value.

An entry is a wrapper when it is a plain object, owns at least one key, and every own key is one of value, default and props. Anything else is a value — including a plain object that owns any other key alongside them.

{ value: 1 }                  wrapper, value 1
{ value: 1, default: 'D' }    wrapper
{ default: 'D' }              wrapper, no value
{ props: { number: … } }      wrapper, no value
{ value: 1, unit: 'kg' }      value  ->  {"value":1,"unit":"kg"}
{}                            value  ->  {}

A wrapper's value is the placeholder's value (section 9.2), its default joins the fallback chain (section 10), and its props is the topmost layer of formatting properties (section 11.2).

Unwrapping happens exactly once: a wrapper's value is a value, never itself a wrapper. A wrapper that owns no value key, or whose value is the host's undefined, has no value.

The payload's root default entry is an ordinary value, never a wrapper.

Recognition is exact rather than opportunistic because a payload cannot be constrained: message ids are namespaced dotted segments, and payload values are frequently plain objects taken straight from an API. { value: 1, unit: 'kg' } is data, and it must stay data.

Exactness is not enough on its own, because { value: … } is itself a common shape and a caller that does not control what its payload holds cannot rule one out. An implementation SHOULD therefore offer a way to turn recognition off for a resolution. With it off an entry of that shape is a value like any other and converts as one (section 4), which is where a caller holding untrusted data passes it (section 14.1). Recognition is on where the caller says nothing: a payload written for this section reads as it is written.

5. Interpolation model

A message is resolved in one walk. Section 6 parses it once into the text it is written with and the placeholders it declares; the walk then emits that text and, for each placeholder, what section 9 resolves it to.

The walk reaches the outermost placeholders first. A placeholder written inside an option value (section 12) is reached only where the enclosing placeholder selects the option that holds it, so an option the modifier passes over is never rendered: a placeholder in it is not resolved, a payload entry it names is not read, and a modifier it names is not called.

A message reaches that walk as text: it is converted by section 4's rules before anything reads it, not after everything has. A host that wrote its message as something other than a string therefore gets it parsed and unescaped like any other.

Message text is syntax. Payload text is data. What a placeholder resolves to is emitted as it stands. It is not parsed, so a {{ it contains opens no placeholder and a ; it contains ends no segment; and it is not unescaped, so a backslash it contains stands for itself. Escape sequences (section 7) are removed from the message's own text and from the names the message writes, where section 6 derives them, and from nothing else.

That is the whole of the model, and the rest of this document leans on it. The walk emits each part of the message once and reads nothing it has emitted, so there is no second pass to perform and nothing for one to find. What bounds the walk is section 13.

6. Grammar

The grammar is given in ISO/IEC 14977 EBNF. It is defined over Unicode code points.

message        = { escape | literal-char | placeholder } ;

placeholder    = "{{" , selector , { ";" , segment } , "}}" ;

selector       = key , [ ":" , modifier-name ] ;
segment        = option-key , [ ":" , value ] ;

key            = { text-unit } ;
option-key     = { text-unit } ;
modifier-name  = { value-unit } ;
value          = { value-unit | placeholder } ;

text-unit      = inner-escape | text-char ;
value-unit     = inner-escape | value-char ;

escape         = "\" , any-char ;
inner-escape   = "\" , inner-char ;

(* A code point stands for itself at any position where no complete escape and
   no complete placeholder begins; see note 7. *)
literal-char   = ? any code point at such a position ? ;

(* character classes *)
line-term      = ? U+000A | U+000D | U+2028 | U+2029 ? ;
whitespace     = ? line-term | U+0009 | U+000B | U+000C | U+0020 |
                   U+00A0 | U+1680 | U+2000 | U+2001 | U+2002 |
                   U+2003 | U+2004 | U+2005 | U+2006 | U+2007 |
                   U+2008 | U+2009 | U+200A | U+202F | U+205F |
                   U+3000 | U+FEFF ? ;
any-char       = ? any code point ? ;
inner-char     = ? any code point except line-term ? ;
text-char      = ? any code point except ":" ";" "\" line-term,
                   and not starting the sequence "{{" or "}}" ? ;
value-char     = ? any code point except ";" "\" line-term,
                   and not starting the sequence "{{" or "}}" ? ;

Notes on the grammar, all normative:

  1. A placeholder MUST NOT contain a line terminator. A {{ … }} construct spanning a line terminator is literal text, and escaping the terminator does not make it a placeholder: only inner-escape occurs inside one, and inner-char excludes every line terminator. A nested placeholder (note 10) is bound by the same characters, being a placeholder itself. Which substrings of a message are placeholders MUST NOT depend on the payload, or on what any placeholder resolves to (note 7), and what a placeholder resolved to is never read as message text at all (section 5).

  2. A key MAY contain a colon or a semicolon only as an escape sequence (\:, \;), and a brace only where it does not form a delimiter (note 6). A key MAY otherwise contain any character, including spaces, dots, digits, backslashes and non-ASCII text; it MUST NOT contain a line terminator in any form (note 1). Keys are compared by exact code-point equality after unescaping.

  3. The selector's colon is the first unescaped colon in the selector. Everything before it is the key; everything after it, up to the selector's end, is the modifier name. The name is not otherwise constrained: one this format does not specify and the host has not registered is a message error (section 11.4), not literal text. A colon with nothing after it names no modifier (section 8).

  4. An option's colon is the first unescaped colon in the segment. Everything after it, up to the segment's end, is the value. A value MAY therefore contain unescaped colons.

  5. A value MUST NOT contain an unescaped semicolon outside a placeholder it holds, which ends the segment. A semicolon within a nested placeholder is that placeholder's own separator and ends nothing in the segment around it (note 10).

  6. A backslash consumes the character that follows it, so a brace it consumed is text and cannot be half of a delimiter. {{ and }} are delimiters only where the two braces stand adjacent and neither has been consumed, which makes \{{v}}, {\{v}}, {{v}\} and {{v\}} all literal text: in each, one brace of a pair was taken by a backslash and the brace left over stands alone. To write either delimiter as literal text, escape both of its braces: \{\{, \}\}. Consuming one brace disturbs no other, so {{v\}}} is a placeholder whose key is v} — the backslash takes the first } and the remaining two close the placeholder, which is what lets a key end in a closing brace. A } that starts no pair needs no escape at all: {{a}b}} already names the key a}b. And {{\{v}} is a placeholder whose key is {v, its opening pair intact because the backslash took only the third brace.

  7. A message has exactly one derivation, and one left-to-right scan finds it. At each position at most one of escape and placeholder can begin — the first opens on \, the second on {{ — and a code point where neither completes is a literal-char. Both need what follows them. An escape needs a character to consume, so a backslash at the end of a message consumes nothing and is a literal-char itself. And a placeholder begins only where a complete one derives: the scan reads forward from the opening pair and either reaches a closing pair or does not. That reading is recursive, because an option value may hold a placeholder (note 10) and the inner construct must derive completely or the enclosing one does not derive. Where it does not, the opening brace is a literal-char and the scan resumes at the very next code point — one brace, not two — so {{a{{b}} is the literal text {{a followed by the placeholder {{b}}, while {{{{a}} is the literal text { followed by a placeholder whose key is {a.

    Inside a placeholder every boundary is forced by a character class rather than by choice: text-char excludes :, so the selector's colon is the first one no backslash consumed; text-char and value-char both exclude ;, so a segment ends at the first semicolon; and both exclude a code point that starts }}, so the closing pair is the first one left standing that no nested placeholder has claimed. Nothing in the scan consults the payload, or what any placeholder resolves to, so the same message always yields the same placeholders.

  8. The whitespace class is fixed by this document. It is the twenty-five code points enumerated above, and it is neither a host language's whitespace class nor a Unicode property; an implementation MUST NOT substitute either for it. Wherever this document says whitespace it means this class: in the escapable characters of section 7, in the significance rules of section 8, and in the blank text of section 11.2, which is a payload value, not message text. The class names line-term rather than repeating its four code points, so every line terminator is a member, and an amendment to line-term is an amendment to this class that reaches all three sections just named.

  9. No production requires whitespace. The grammar admits it wherever it admits text, and admits it only as text: the spaces in {{ value; }} derive as text-unit, which is what leaves that placeholder one derivation (note 7). Section 8 decides which whitespace a placeholder's parts keep, and section 7 decides which of it an escape sequence makes text.

  10. A placeholder derives inside an option value and nowhere else. value is the only production that admits one, so a {{ in a key, in an option key or in a modifier name opens no placeholder. That is deliberate: it keeps all three readable without a payload, which is what lets a tool answer which entries a message names and which modifiers it calls before anything is resolved (section 12).

    A {{ in a value must open a complete placeholder or the enclosing construct does not derive at all, because value-char excludes a code point that starts {{. There is no third reading in which the braces are content: to write a delimiter as text, escape both of its braces (note 6). So {{v; a:{{n}}; default:D}} is one placeholder holding another, while {{v; a:{{n}} is the literal text {{v; a: followed by the placeholder {{n}} (note 7).

    Nesting is not otherwise limited: a placeholder MAY hold a placeholder to any depth, and how deep a message nests is a fact about the message alone. What an implementation resolves is bounded by section 13, which bounds resolution and not derivation.

  11. A verdict is final. Whether a complete placeholder derives at a given position is a function of the message and that position alone. It does not depend on where the scan reached the position from, on how deeply the construct is nested, or on anything an implementation configures. A scan that has answered once for a position therefore answers the same way at every later attempt, and an implementation MAY record that answer.

    It should. Note 7 has the scan resume at the very next code point where a placeholder does not derive, so the attempts overlap; without the record, a message that nests deeply costs time growing faster than any polynomial in its length, and an attacker writes one in a few hundred characters. With it, the scan costs time proportional to the length of the message. Section 14.1 requires the bound, not the technique.

7. Escaping

A backslash cancels the structural meaning of the character that follows it, and the pair denotes that character as literal text. A character that has no structural meaning where it appears is unchanged by a preceding backslash: both characters stand, and the backslash denotes itself. A backslash at the end of a message has no character to cancel and denotes itself likewise.

The rule is uniform across the whole message string: a backslash consumes the character after it wherever it appears, and the same characters are escapable everywhere. A position decides reach, not membership — a line terminator is escapable like any other member of whitespace, but an escape sequence that carries one is never inside a placeholder (section 6, note 1).

The characters that carry structural meaning are :, ;, {, }, \ and the members of whitespace (section 6). So \:, \;, \{ and \} write those characters as text, \\ writes a single backslash, and an escaped space writes a space that the insignificance rules of section 8 will not take. Every member of whitespace escapes that way, the line terminators among them, so \ before one is an escape sequence like any other — though none carries a line terminator into a placeholder (section 6, note 1).

Escaping is defined at the level of the message string, not at the level of the file that carries it. A message stored in JSON must additionally satisfy JSON's own escaping rules, so a single format-level backslash is written as two characters in a JSON source file:

IntentIn the message stringIn a JSON catalogue
literal :\:"\\:"
literal ;\;"\\;"
literal {\{"\\{"
literal }\}"\\}"
literal {{\{\{"\\{\\{"
literal }}\}\}"\\}\\}"
literal \\\"\\\\"
a space trimming keeps\ followed by a space"\\ "

A literal backslash in front of a structural character is written by escaping both — \\\: yields \:.

Implementations MUST remove escape sequences from the message, and from nothing else. A sequence is removed where section 6 derives it — in the text of the message and in the names a placeholder writes — and it is removed once, when the message is parsed, before anything is resolved (section 5). Removing one leaves what the first paragraph of this section says it denotes: the escaped character alone where the backslash cancelled a structural meaning, and both characters where it cancelled none, so \a renders as \a.

That rule governs the message, not the spellings it reaches its parts by. A key and a modifier name are each matched by name against something a host wrote — a payload entry, a registered modifier — so each is compared by exact code-point equality against the name the single removal above left (section 6, note 2); nothing unescapes a name a second time. An option key is unescaped the same way, but it looks nothing up: it is compared against the value, and that comparison belongs to the modifier that performs it (section 11.1). All three are what their author wrote, not the spelling a reserved character forced. An option value is unescaped by the same single removal, and where an option key stands for its own value (section 9.4) that value is the message's own text and is unescaped with the rest of it.

Payload text is not escaped text. A value, a props value, a payload default, a wrapper's default and a modifier's return value are data, and an implementation MUST NOT remove escape sequences from any of them (sections 5, 14.1). A backslash a value carries is a backslash. That is what lets a value hold a Windows path, a regular expression or a serialization without the message's escaping rules reaching into it, and it is why the text section 4 produces is the text the output carries:

{ a: 'C:\U' }   serializes to  {"a":"C:\\U"}   and renders  {"a":"C:\\U"}

A caller that needs a machine-readable serialization in the result therefore needs to do nothing for it: what the conversion produced is what the output carries, parsable as the conversion made it. A host-defined modifier that reads a serialized value back (section 4) receives that same text.

8. Whitespace

Within a placeholder:

  • Whitespace surrounding the key is not significant.
  • Whitespace surrounding the modifier name is not significant.
  • Whitespace surrounding an option key or an inline default key is not significant.
  • Whitespace surrounding an option value or an inline default value is not significant. Whitespace inside one is.

These rules take the members of whitespace (section 6), and nothing else. A code point outside the class is ordinary text: a key padded with one is a different key, and an option value that holds only one is not empty.

Whitespace that an escape sequence claims is text, not padding: it belongs to the key, the option key or the value it appears in, and the rules above do not remove it (section 7).

These rules are read over the spelling, before anything is resolved. What is padding is decided by the characters the message writes, so a placeholder an option value holds is content wherever it stands, and the text it resolves to is never padding however it is spelled: in {{a; x: {{b}} {{c}} ;}} the space before {{b}} and the one after {{c}} are padding, the one between them is content, and a value of {{b}} that resolves to three spaces contributes three spaces. That is what keeps section 5's rule whole — a value cannot be trimmed by a rule it was never text for.

An option value that consists only of unescaped whitespace therefore trims away to nothing, so x: and x: are equivalent: both declare the empty string (section 9.4).

A modifier name or an option key that trims away to nothing, or that is empty to begin with, names nothing: the placeholder declares no modifier, and the segment declares no option. So {{v:}} is a plain substitution (section 9.5), and {{v;;}} and {{v; }} have no options.

Consequently {{value}}, {{value;}}, {{ value }} and {{ value; }} are equivalent.

9. Resolution

A placeholder resolves in the following order.

9.1 Parse

Split the placeholder into its selector and segments per section 6. There is no third state between a placeholder and text: a {{ … }} construct that section 6 does not derive as a placeholder is literal text, rendered as it stands, and it MUST NOT be used to look up any payload key or reach any later step of this section.

A selector MAY name no key. {{}}, {{ }} and {{:eq}} are placeholders with nothing to look up, so they resolve to the fallback chain (section 10), and they MUST NOT resolve the payload keys "", "null" or "undefined".

9.2 Look up the value

Look up the key in the payload.

The key is matched whole, by exact code-point equality after unescaping (section 6, note 2). It is a name and not a path: {{user.name}} names the payload key user.name, and a payload carrying a user entry with a name inside it owns no entry under that name, so the placeholder takes the fallback chain. No placeholder reaches inside a value either — one that is a plain object or a plain array resolves whole, as the text section 4 converts it to. That text is data: it is emitted as it stands, so a {{ it carries opens no placeholder and a backslash it carries is a backslash (sections 5, 7).

The lookup MUST consider only the payload's own entries. Members inherited from a prototype, class or base mapping MUST NOT resolve. In a host where mappings inherit members, constructor, toString and __proto__ are therefore absent unless the payload carries them as own entries. This is a security requirement, not an optimization (section 14).

A value is absent when there is no own entry for the key, or when that entry is the host's undefined — the host's own word for nothing here, which section 4.1 already reads that way in a wrapper's value. An undefined entry is absent, not a value no conversion could describe, so there is nothing to report (section 14.2). Nothing else is absent: a value of zero, empty string, false or the host's null MUST be treated as present. A value that is present but that no conversion can describe (section 4) is treated as absent.

If the entry is a wrapper (section 4.1), the value is the wrapper's value, and the wrapper's default and props join resolution as sections 10 and 11.2 describe. The own-property requirement applies to the wrapper's own keys too: a value, default or props that the wrapper only inherits MUST NOT resolve.

9.3 Determine the default

The inline default is the first segment whose key is exactly default, compared case-sensitively. The default it declares is that segment's value, read the way section 9.4 reads an option's: default:x declares x, default: declares the empty string, and default alone — no colon — declares the key itself, the text default.

default is also a reserved payload key: it is the fallback for every placeholder in the message that resolves to no value, and {{default}} reads that same fallback rather than a placeholder of its own.

The placeholder's default is the first link of the chain in section 10 that yields text. The payload's default takes precedence over the inline default:, and a wrapper's default takes precedence over both. This step names where the default comes from, not when the chain is walked: section 10 walks it only where the placeholder uses its result.

The inline default is an option value, so it may hold a placeholder (section 6, note 10) and it is read the way section 9.4 reads any option's value: only where the placeholder uses it. A default nobody falls back to is never rendered.

If no link yields text, the default is the empty string.

9.4 Collect the options

Every segment other than the inline default that names an option key is an option, in source order. A segment whose option key is empty or trims away to nothing (section 8) declares no option, with or without a value.

  • key:value yields that key and value.
  • key alone — no colon — yields the key as both key and value.
  • key: yields that key and the empty string. The colon declares a value, so a value that ends at the colon — or that is only unescaped whitespace (section 8) — is empty rather than absent.
  • Where two options share a key, the first MUST win wherever a modifier selects by key (section 11.1). Collection keeps every option in source order, duplicates included, so a host-defined modifier is handed the list as the placeholder wrote it.

Collecting an option does not read its value. An option's value is the text the message writes there with its escape sequences removed and the placeholders it holds resolved (section 6, note 10), and that text is produced only where something reads it — which, for a selection, is the one option the modifier chose (section 11). An option the modifier passes over costs nothing: no payload entry a placeholder in it names is read, no modifier such a placeholder names is called, and no report it would have made is made.

The reserved key default MUST NOT appear among the options.

9.5 Select the result

  • If the placeholder has no modifier and no options, it is a plain substitution: the result is the value, or the default (section 10) if the value is absent.
  • Otherwise it is a selection: the result is produced by the modifier (section 11), which is eq when no modifier is named.

Giving a placeholder that carries no modifier its first option therefore changes what it asks for, and not only what it may answer. {{v}} asks for the value; {{v; a:A;}} asks which option matches it, and where none does the result is the fallback chain (section 11.1) and never the value itself. Over the value RAW the first renders RAW and the second the empty string, or the declared default where there is one.

A placeholder that names no key has nothing to compare, so it resolves at section 9.1 and is neither a plain substitution nor a selection. A modifier name it carries is still subject to section 11.4: {{:zz}} resolves to the fallback chain and is a message error for naming a modifier nobody registered.

A selection that names a comparison modifier (section 11.1) and no options is a message error (section 14.2): the author asked which option matches and offered none. A formatting modifier selects nothing, so {{n:number}} is complete as it stands, and whether a host-defined modifier needs options is that modifier's own business. A host that registered its own modifier under a comparison's name (section 11.3) has replaced the comparison, so a placeholder naming it asks a host-defined modifier: {{v:eq}} over that registration is no message error.

An implementation SHOULD report that error, naming it missing-options (section 14.2), and the placeholder takes the fallback chain (section 10) as every message error does. What the placeholder declares is what makes the error, so the report does not turn on the payload: {{v:eq}} is reported over a payload that supplies v and over one that leaves it absent alike, though an absent value takes the chain before any modifier is asked (section 11.1). It turns on what answers to the name: the format's own eq is reported, and one a host registered in its place is not.

10. The fallback chain

A placeholder that resolves to no value takes the first of the following that yields text:

  1. the default of the wrapper the payload entry was, where it was one (section 4.1);
  2. the payload's own default entry;
  3. the inline default: of that placeholder;
  4. the empty string.

A link that is absent, or whose value no conversion can describe (section 4), is skipped.

The chain is read only where the placeholder uses its result. A placeholder that resolves to a value never reaches it, and a modifier that answers without its default leaves it unread, so a link nothing needed is neither converted (section 13) nor reported for a value it cannot describe (section 14.2). Nor is a placeholder inside link 3 resolved there (section 12).

The links are not all the same kind of text. Links 1 and 2 come from the payload and are data: each is emitted as it stands, unscanned and unescaped (sections 5, 7, 14.1). Link 3 is the message's own — an option value like any other — so its escape sequences are removed with the rest of the message's and a placeholder it holds is resolved where the chain reaches it (sections 7, 12).

A link yields text where it is present and can be described. The empty string is text: an inline default: declaring nothing, or a wrapper default holding the empty string, yields it and the chain stops there rather than stepping past.

The payload outranks the message: a message declares the default it was written with, and the application overrides that default where it needs to, so the more specific statement wins.

Over one message that declares its own default, the links rank as follows. The placeholder is a plain substitution (section 9.5), so it reaches the chain only where the value is absent, and the empty string in the second row is a value and not a missing one:

Hello, {{name; default:Guest;}}!

payload { name: 'Alice', default: 'Friend' }  ->  "Hello, Alice!"
payload { name: '', default: 'Friend' }       ->  "Hello, !"
payload { name: { default: 'You' },
          default: 'Friend' }                 ->  "Hello, You!"
payload { default: 'Friend' }                 ->  "Hello, Friend!"
payload {}                                    ->  "Hello, Guest!"

11. Modifiers

A modifier receives the value, the options, the default, the locale and the props, and returns an answer.

An option's value and the default reach it unread. Producing either is work — an option value may hold a placeholder (section 6, note 10), and the default is a chain to walk (section 10) — and a modifier that selects one option does not pay for the rest. So an implementation MUST NOT resolve a placeholder inside an option's value, MUST NOT call a modifier such a placeholder names, and MUST NOT walk the fallback chain, except where the modifier reads that value or that default. A comparison reads one option's value; a modifier that reads every option's value is entitled to, and pays for all of them.

The value and the default both reach the modifier as text (section 4): the default is the text the chain in section 10 resolved to, and the value is the text the payload entry converted to. No modifier sees a value at the type it was authored with.

A modifier's answer is a host value in turn, and becomes text by that same conversion: a structured answer serializes rather than collapsing to whatever the host calls an object, so a modifier writes the text a payload value of that shape would. Neither an answer no conversion can describe nor an answer that is nothing at all is an answer, and the placeholder takes the fallback chain (section 10) — the treatment a value that is not a value gets.

That text is data, like a payload value: it is emitted as it stands, never parsed and never unescaped (sections 5, 7, 14.1). A modifier that builds its answer out of the value it was handed therefore cannot build syntax with it, and a host extension is not a way around the rule that data is not syntax.

Modifier names are case-sensitive. eq is a modifier; EQ is not.

11.1 Comparison modifiers (Core)

NameSelects the first option whose key
eqequals the value, compared as text, case-insensitively
nediffers from the value, compared as text, case-insensitively
ltis greater than the value, comparing numerically
lteequals the value, compared as text, case-insensitively, otherwise as lt
gtis less than the value, comparing numerically
gteequals the value, compared as text, case-insensitively, otherwise as gt

Case-insensitively names one comparison: the option key and the value are each mapped to lower case by the host's ordinary, locale-independent case conversion, and the two results are compared by code-point equality. Section 7 defers the comparison here, and this is the whole of it: an implementation MUST NOT substitute a collation, a normalization or a case fold for it, and MUST NOT tailor the conversion to a locale. The comparison modifiers are Core (section 2), so they resolve alike in every locale and where no locale is available at all — a conversion a locale tailors reads I and i as one text in most languages and as two in Turkish, and a Core selection cannot turn on which. The mapping is not a case fold, and it does not always leave apart what a fold would join. The lower case of STRASSE is strasse and not straße, so an option keyed STRASSE does not select for the value straße. But Unicode gives some characters a single-character lower case that lands on another character, so U+212A KELVIN SIGN lowercases to k, U+1E9E LATIN CAPITAL LETTER SHARP S to ß, U+2126 OHM SIGN to ω and U+212B ANGSTROM SIGN to å, and an option keyed with either member of such a pair selects for a value spelled with the other. Those selections are required rather than an artifact of a host: the mapping is the one Unicode specifies, and an implementation MUST NOT narrow it to the ASCII letters.

An option key is compared against the value's own text (section 11), never against what another placeholder makes of it. The count and the noun are two placeholders, this format having no plural categories (section 1): {{n}} {{n; 1:file; default:files;}} renders 1 file over { n: 1 } and 5 files over { n: 5 }. At an English locale {{n:number}} {{n; 1,000:K; default:files;}} over { n: 1000 } renders 1,000 files: the grouping belongs to the placeholder that formats it, and the one that selects still compares against 1000, which the key 1,000 does not equal.

lt and lte MUST consider options in ascending key order; gt and gte in descending key order. Ordering is by numeric value of the key; an option whose key is not numeric MUST NOT be selected by a numeric comparison. Two keys spelled differently that carry the same numeric value — 2 and 2.0 — tie, and a tie MUST be broken by source order (section 9.4), so an ordering that is not stable does not conform. Implementations MUST NOT reorder the caller's option list observably.

lte and gte compare for equality first, over every option in source order (section 9.4), and reach the key order above only where that comparison selects nothing. The equality leg is a text comparison rather than a numeric one, so the option it selects need not carry a numeric key: {{v:lte; abc:X}} selects X for the value abc, which the numeric leg on its own passes over.

If no option is selected, the result is the fallback chain (section 10).

ne has no special handling for an absent value: like every other modifier, an absent value takes the fallback chain.

11.2 Formatting modifiers (Intl)

These delegate to the host's internationalization facilities and require a locale. If no locale is available, the result MUST be the empty string and not the fallback chain (section 10): a declared default does not stand in for a locale nobody supplied. A locale is not available where the caller supplied none, and where what it supplied is empty. One the caller did supply that the host then rejects is available but unusable, which is the formatting failure at the end of this section and takes the chain like any other. An implementation SHOULD report a locale that is not available, naming it missing-locale (section 14.2): the empty string is what such a placeholder renders, and the report is what says why it rendered nothing.

The locale is tested first, before the value is read: where none is available the result is the empty string whatever the value is, so the two rules below that send a placeholder to the fallback chain — a value that is blank, and an input the modifier cannot format — are reached only where a locale is. The test is reached in turn only where the placeholder reaches a modifier at all: an absent value (section 9.2) takes the chain before any modifier is asked, whether a locale is available or not. A placeholder whose value is absent therefore reports no missing locale, over a caller that supplied one and over a caller that supplied none alike.

NameInputFormats as
numbera numbera locale-formatted number, at most 2 fraction digits by default
datemilliseconds since the Unix epoch; failing that, text the host can parse as a datea locale-formatted date
agoa signed millisecond delta relative to now — negative is pasta locale-formatted relative time
currencya number, multiplied by a ratio property defaulting to 1a locale-formatted currency amount

number, ago and currency take a number only. Because every value arrives as text (section 4), date also accepts text the host's own date parsing understands; that is what keeps a value authored as a date instance formattable.

Text that is empty or consists only of whitespace (section 6) is not a number, whatever the host's numeric conversion makes of it, and is not a date either. No formatting modifier can format it, so the placeholder MUST take the fallback chain (section 10). The class is the one section 8 applies to message text, applied here to a payload value: an implementation MUST NOT substitute its host's own notion of a blank string. This governs formatting alone: a numeric comparison (section 11.1) converts blank text like any other text.

What makes any other value a number is the host's ordinary numeric conversion (section 4), applied to the whole of the text and yielding something finite. The conversion reads the whole value and not a leading part of it, so 12px is not a number; whitespace around a number is not part of it, so 12 is one; a sign and an exponent are part of it, so +12 and 1e3 are. An infinity is not a number a formatting modifier can format either, so {{v:number}} over Infinity takes the fallback chain while {{v:gt; 5:X}} still selects X for that same value; and currency applies the test a second time to the product of the value and its ratio, so a product that overflows to an infinity takes the chain too.

Beyond that the test is the host's, and deliberately so: it is the same conversion section 4 requires of a numeric comparison, and a second test written here would leave {{v:number}} and {{v:date}} disagreeing about what a number is. Only the conversion carries over: where it yields no number a comparison merely fails (section 4), while a formatting modifier resolves to the fallback chain. Two hosts whose conversions read different literal forms — a hexadecimal one, say — therefore differ over such a value, and a message that must format alike everywhere writes a decimal number.

date reads its value as a timestamp first: only a value that test does not read as a number reaches the host's date parsing. The two readings overlap, because a bare run of digits is both a count of milliseconds and a year, and the order is what settles which one a message gets. {{v:date}} over 2024 formats the 2024th millisecond after the epoch and not the first day of 2024; over 99 it formats the 99th and not a day in 1999. The order reaches past the digits, too: 2024 is a number with whitespace around it and +1000 is a signed one, so both are timestamps, though a host's date parsing reads a year in each. An implementation that parsed dates first would render a different date for the same payload, so this order is normative and not a tie a host may break.

Formatting properties are read from props under the modifier's own name, layered over implementation-configured defaults, which are layered over the defaults this section states — number's fraction digits and currency's ratio above, ago's format and numeric below. A wrapper's props (section 4.1) is layered over all of them.

A property is not an option (section 3): an option is a segment written in the placeholder and offered to the modifier for selection, and a formatting modifier selects nothing (section 9.5). The layers above are the only place a formatting modifier reads a property from, and a placeholder is not one of them, so a placeholder segment spelled like one is an option that reaches no layer.

Every layer composes per property: a layer overrides only the properties it names, and the properties it does not name keep whatever the layer beneath it gave them. A layer MUST NOT reset a property it does not name. A property a layer holds the host's null under is one it names: null is a value (section 3), and it is what reaches the formatting request. A property the layer holds the host's undefined under, like one it does not hold, is one it does not name.

implementation defaults   number: { maximumFractionDigits: 4, useGrouping: false }
props                     number: { useGrouping: true }
wrapper props             number: { maximumFractionDigits: 1 }
effective                 { maximumFractionDigits: 1, useGrouping: true }

Each layer is read from its own entries, the way section 9.2 reads the payload. A property a layer merely inherits MUST NOT reach the formatting request (section 14.1).

The two fraction digits number formats by default are a default maximum, not a cap. Where a layer names a minimumFractionDigits above it, the default maximum widens to reach that minimum instead of contradicting it; a layer that names the maximum itself decides it. The widening runs after the layers have composed, and it is the one place a property no layer named does not keep what the layer beneath it gave. A default that held at two would make a minimum above two unformattable, and the placeholder would take the fallback chain over properties the caller wrote deliberately.

currency formats in the currency style. That style is what the modifier is rather than one of the properties it layers, so a layer MUST NOT replace it; an implementation applies it over every layer.

The currency to format in is a property like any other: it comes from the layers above and from nowhere else, and a placeholder segment spelled like one is an option that reaches no layer. A message cannot name it, so where the host's facility requires one and no layer supplies it, the modifier cannot format its input and the placeholder takes the fallback chain by the last rule of this section. An amount's currency comes from the caller, never from the message.

ago selects a unit automatically unless one is named. The selection chooses among second, minute, hour, day, week, month and year. A unit is named by a format property in the layers above, holding one of those units, written in the singular or the plural, rather than a name from the host's whole relative-time vocabulary; the value auto, which is what a layer naming none leaves in place, is that automatic selection. Both the unit and the count are chosen from the magnitude of the delta, and the sign is applied to the result: a delta and its negation MUST select the same unit and the same count with opposite signs. A host rounding rule that takes a half in one direction — toward positive infinity, say — reads "half an hour from now" and "half an hour ago" as different distances, which no reader of a relative time expects.

A format property that names none of those units is a property the modifier cannot process: the placeholder resolves to the fallback chain (section 10), and the implementation SHOULD report the failure as failed-modifier (section 14.2), the way an input the modifier cannot format does by the last rule of this section. The unit names are compared exactly, so a spelling that differs by case names none of them: a placeholder whose format is YEAR takes the chain, where one whose format is year or years names the year.

The automatic selection climbs those units in order, each step a fixed multiple of the one below it: 1000 milliseconds to the second, 60 seconds to the minute, 60 minutes to the hour, 24 hours to the day, 7 days to the week, 13/3 weeks — four weeks and a third — to the month, and 12 months to the year. The delta is divided by 1000 and rounded to a whole count on its magnitude; that is the count of seconds, and the climb starts there whatever the count is, so a delta under half a second is a count of zero seconds. Each further step divides the count it was handed by that step's multiple and rounds it the same way, and is taken only where the rounded count is one or more in magnitude. The first step that rounds below one is not taken, the climb stops at the unit beneath it, and year is where it runs out in any case.

The rounding is applied at every step rather than once at the end, so a unit's count is the rounded count of the unit beneath it divided again, and the two do not always agree: three and a half days is four days before it is a week, and so formats as one week rather than as the half week the arithmetic alone gives, and twenty-six days is four weeks and then one month. A half rounds away from zero, so a step is taken as soon as the count beneath it reaches half that step's multiple: 30 seconds make a minute, 30 minutes an hour, 12 hours a day, 4 days a week, 3 weeks a month and 6 months a year. A named unit climbs the same ladder with the same rounding and stops at the unit it names whatever the count is there: format: 'day' at a delta of three hours is a count of zero days, and format: 'second' at a delta of 800 days is a count of 69 120 000 seconds.

ago asks the host for its non-numeric phrasing wherever the host has one, so a count of one day reads as "yesterday" in English rather than as "1 day ago", and the count of zero seconds a small delta rounds to reads as "now". The phrasing is a numeric property in the layers above, and where no layer names one it is auto — not whatever the host's own formatter would default to, which in ECMAScript is always, spelling every count out. A layer naming numeric decides it like any other property.

The properties are named in the vocabulary of ECMAScript's internationalization facilities. That vocabulary is this format's way of describing a request rather than a call into any one language: a host reads each name onto its own facility, and a caller that writes the same properties asks for the same formatting wherever it runs.

A property the host's facility cannot express is not an input the modifier cannot format. The modifier formats with the properties the facility does express, and the placeholder does not take the fallback chain on a property's account: a number formatted without the grouping a caller asked to suppress is still the number, and a fallback in its place is not. An implementation MUST document the properties it cannot express, and states them to the conformance set through the adapter of section 14.3, so that what it did not apply is observable rather than only written down.

Because the output of these modifiers depends on the host's locale data, an output string on its own tests that data as much as it tests this format. An implementation MAY expose the formatting request it makes — the operation, its properties and its input — to the conformance adapter (section 14.3); an implementation that does not exposes only the output, and an output differing from another implementation's by locale data alone is not a non-conformance.

A formatting modifier that cannot format its input MUST NOT raise; it resolves to the fallback chain and SHOULD report the failure.

11.3 Host-defined modifiers (Extensions)

A host MAY register additional modifiers. A host's own configuration overrides the format's, so a host-defined modifier MAY replace a modifier named in this specification.

What a host registers under a name MUST be a modifier. A name a message may write is one this specification names or one a host registered a modifier under, so registering anything else registers no modifier: it does not make the name one a message may write, and it does not replace a modifier already answering to it. Where nothing else answers to that name, a message writing it names a modifier nobody registered, and section 11.4 governs; where this specification names a modifier under it, that modifier answers as it did.

A host-defined modifier receives its properties the way a formatting modifier does (section 11.2): the props written under its own name, layered as that section layers them — the implementation-configured defaults, then the call's props, then the wrapper's props — with every layer overriding only the properties it names and every layer read from its own entries. It receives what the layers wrote under its own name and nothing they wrote under another's, and where no layer wrote under its name it receives no properties at all.

It receives the locale the caller supplied, verbatim: an empty locale reaches it empty, and where the caller supplied none it receives none. Section 11.2's test for a locale that is not available governs the modifiers that section names — the format defines those, so it must say what they do without one — and does not reach here. What a host-defined modifier needs of a locale is the host's own, and an implementation substitutes nothing for it: it supplies no locale of its own and does not collapse an empty one into none, so a modifier that treats the two differently can.

Because a host's own registration overrides the format's, a name a later version of this format defines costs a host that already registered its own modifier under that name nothing: its messages resolve as they did, and the only thing out of reach is the modifier the new version defines.

A host-defined modifier that raises MUST be contained: the placeholder resolves to the fallback chain and the failure SHOULD be reported.

11.4 Unknown modifiers

A modifier name that is neither specified nor registered is a message error (section 14.2). The placeholder resolves to the fallback chain. A name this document specifies only at a level an implementation does not satisfy is not specified for that implementation, so the formatting modifier names are unknown to one that does not satisfy Intl (section 2).

Implementations MUST NOT silently treat an unknown modifier as eq. A message written today as {{n:plural}} must not render as an equality selection now and silently change meaning when a later version of this format defines plural.

12. Nesting

A placeholder MAY hold a placeholder, in an option value and nowhere else (section 6, note 10). The inner one is part of the message, found by the same scan, and resolved by section 9 like any other — but only where the option that holds it is the one selected (sections 5, 11).

{{count:gt; 0:{{count:number;}} items; default:no items;}}

Over { count: 5 } the comparison selects the option keyed 0, that option's value is read, and reading it resolves {{count:number;}}, so the message renders 5 items at an English locale. Over { count: 0 } the comparison selects nothing, the default is read instead, and the inner placeholder is never resolved: no payload entry is read for it, no formatting modifier is called, and no report it would have made is made. Note also which construct the semicolon in {{count:number;}} belongs to — the inner placeholder writes it, so it is the inner one's separator and ends no segment of the outer.

A key, an option key and a modifier name hold no placeholder, so what a message looks up and what it calls are readable without a payload. A tool can answer which payload entries a message names and which modifiers it calls from the message alone, and its answer covers the nested placeholders as it covers the enclosing ones.

What a placeholder resolves to is never nested in anything. A value, a props value, a payload default, a wrapper's default and a modifier's answer are data (sections 5, 7, 14.1). A value holding the nine characters {{count}} renders those nine characters; a value holding ; ends no segment; a value holding }} closes nothing. So what a message resolves to cannot change what the message means, and no payload can reach a branch the message did not select for it, or write a construct the message did not spell.

{{state:eq; draft:{{note}}; live:Published; default:?;}}

Over { state: 'live', note: 'X; live:Leaked' } this renders Published, and note is never read at all: the option holding it is not the one the comparison selected, so the segment its text spells is never a segment.

How deeply a message nests is a fact about the message alone, and nothing in section 6 bounds it. What an implementation resolves is bounded by section 13.

13. Limits

Resolution is bounded. A payload is frequently attacker-influenced, and so sometimes is a message — a host that takes translations from its users takes message text from them — so neither may buy unbounded work.

An implementation MUST enforce all four of the following:

  • An output limit. At least 100 000 characters of placeholder result MUST be permitted, counted in the unit the host measures its strings in — a UTF-16 code unit in ECMAScript, so a character outside the Basic Multilingual Plane counts twice. What is counted is what the output carries, so each result is counted once: a placeholder written inside an option value reaches the output through the result of the placeholder around it and is counted there rather than again on its own. A placeholder whose result would carry the total past the limit resolves to the empty string instead, and the walk carries on. The message's own text is not counted against it and always renders: that text is the caller's and is bounded by the message it was read from, while a placeholder's result is bounded only by the payload. Section 5 builds the output once, so nothing is discarded and nothing is cut mid-character.
  • A read limit. At least 100 000 characters of value text MUST be permitted, in the same unit, counting every text a placeholder reads whether or not any of it reaches the output. Output alone bounds no work: a thousand placeholders that each read a hundred-thousand-character value and select nothing from it produce nothing and read a hundred million characters. A placeholder reached once the limit is spent resolves to the empty string, and the walk carries on — the limit is tested before the placeholder reads, so a placeholder that finds a budget and spends it past the limit still resolves, and the next one pays. Value text is what a placeholder reads from the payload: the value it names, and each link of its fallback chain the payload supplies (section 10). The message's own text is not value text — an option value and an inline default are bounded by the message they were read from — and neither is what a modifier answers with, which is the placeholder's result and is bounded by the output limit above.
  • A conversion limit. At least 100 000 nodes MUST be visited before a value's serialization is abandoned. A serialization that reaches the limit MUST be treated as a conversion that cannot describe the value (section 4).
  • A nesting limit. At least 8 levels of placeholder MUST be resolved (section 12), counting the outermost as the first. A placeholder nested deeper than an implementation resolves is a message error (section 14.2): it takes the fallback chain, and the walk carries on. Only a placeholder the walk reaches counts: an option the modifier passed over is never read, so however deeply it nests it costs nothing, reports nothing and reaches no limit (section 9.4).

These are minima. An implementation MAY permit more, and MUST document what it permits.

A placeholder reaches at most one of the first two. One reached once the read limit is spent resolves to the empty string and produces nothing, so it cannot also reach the output limit, and one that reaches the output limit had the read budget to be resolved. The conversion limit is not reached by a placeholder at all: it is met inside a conversion, and what the placeholder sees is a value no conversion can describe (section 4).

The nesting limit bounds resolution and not derivation. Section 6 admits nesting to any depth, and note 11 is what keeps the scan linear in the length of the message without a bound to lean on, so which constructs are placeholders stays a fact about the message and never about the implementation. Were it otherwise, two conforming implementations would disagree about where a placeholder ends: the content of a construct nested too deep would be read as further segments of the construct around it, a different option would be selected, and the braces the scan refused would stand in the output. A message error is contained instead — one placeholder takes its fallback chain, and every other part of the message resolves as written.

No limit here raises, and none of them ends the walk. On reaching one an implementation MUST resolve the placeholder that met it — to the empty string, or to its fallback chain for the nesting limit — MUST NOT raise, and SHOULD report that a limit was reached (section 14.2). The walk then carries on, so every part of the message no limit reached still renders. A single walk has nowhere to leave an unresolved placeholder, and an unresolved placeholder in the output is the message's own syntax reaching a reader.

The conversion limit bounds the work of producing a text, which the other three cannot: each of them measures a string that already exists. Serialization follows a shared reference again every time it meets one, so a value naming the same child twice at each of twenty-four levels holds twenty-five objects and describes sixteen million leaves — nothing circular, so nothing a serializer refuses. The limit is what a single conversion may spend: a value that visits more nodes than a resolvable output could hold is read as one no conversion can describe, which is where it takes the output limit's number from. The two do not measure each other, though — a serialization visits a member it then omits, so a value can visit any number of nodes and still describe itself in two characters — and the output limit cannot stand in for a bound on the work.

A node, for that count, is a value the walk visits: the value being converted, and then every member it reaches, counted once for each time it reaches one. Two things follow from counting visits rather than values. A member the walk visits and then omits is counted, so an object whose members all hold the host's undefined describes itself in two characters and still spends a node on each of them. And a value reached twice is counted twice, because the walk follows a shared reference again every time it meets one rather than recording where it has been — which is what makes the twenty-five objects above twenty-five distinct values and more than sixteen million visits.

One conversion is not a resolution. A value is read once for every placeholder that names it and reaches it, so a limit on a single conversion bounds a resolution only if its conversions cannot multiply with its reads: resolving a message MUST NOT convert a given value twice observably, and every later read of that value MUST answer with the text the first conversion produced — the answer that no conversion can describe it included (section 4). What a resolution spends converting is then set by the distinct values it reaches, not by the reads the message makes of them. A value the payload builds afresh on each read is a new value each time, and is converted each time.

Recognizing a wrapper (section 4.1) enumerates an entry's own keys, which is work that grows with the entry as a serialization's does, and an entry is read once for every placeholder that names it, so the same holds of recognition: resolving a message MUST NOT enumerate a given entry twice observably to recognize a wrapper, and every later read MUST answer with the verdict the first enumeration reached — the answer that the entry refused to be enumerated included, which is reported (section 14.2) at every read that meets it, as a value no conversion can describe is.

The requirement is written over conversion rather than over serialization because section 4 defines two ways to reach a text and a value takes whichever its type selects. The ordinary string conversion runs host code the same way a serialization does, and it can fail the same way, so bounding only the JSON path would leave a value that raises on conversion absent at one placeholder and present at the next within one resolution, with the reports following. A value whose conversion is not deterministic therefore answers every later read with the text the first read produced. An implementation need not record a value section 4 converts by the ordinary string conversion and whose conversion can neither run host code nor answer twice over: converting one again visits no node the conversion limit counts, runs no host code and answers what the first conversion answered, so the answer a resolution reads does not change with it. What a resolution spends can change with it — a host whose integers grow without bound converts a large one to text in time that grows with it, work no node of the conversion limit counts — so the exemption is written over the answer alone and leaves an implementation free to record such a value anyway. A value section 4 serializes is not exempt however plainly it is built: its walk is the work the limit bounds, and a resolution that walks it once per placeholder pays for it once per placeholder.

A resolution may begin while another is running: a host-defined modifier (section 11.3), a reporting handler (section 14.3) and any host code a conversion runs (section 4) all reach the implementation, and any of them may ask it to resolve a message. Each of the four limits belongs to the resolution that reached it, so a resolution begun inside another owns its own output and read budgets, its own nesting count, and its own record of what it has converted. Neither can reach a bound the other owns: an inner resolution that spends its whole output limit leaves the outer free to produce its own, and one whose message nests past its nesting limit says nothing about how deep the outer message may nest. A value both of them read is converted once for each of them, because the record the requirement above asks for belongs to a resolution and not to the implementation. And a report names the id the resolution it came from was given, not the id of the resolution around that one (section 14.3).

That nesting is not section 12's. A message holding a message is one resolution calling another through host code, where section 12's is one message holding a construct, and the nesting limit above counts the second and not the first. Nothing here bounds the first, and the four limits being per-resolution is exactly what leaves it unbounded. What ends a resolution that reaches itself without end is therefore the host's own limit on how deep it will go, not anything this document states. Reaching that limit is a failure inside host code the implementation called, and MUST be contained the way section 14.2 contains every other: the placeholder whose modifier or whose value did not come back resolves to its fallback chain (section 10), and the resolution around it MUST NOT raise. A reporting handler is the third of those callers and has no placeholder waiting on it: what a placeholder resolves to is settled by the condition being reported and not by the handler's answer (section 14.3). So a handler that does not come back leaves its report undelivered and the resolution that was reporting MUST carry on and MUST NOT raise. That holds for a handler that fails any other way too: a channel is where diagnostics go, and a message does not fail to render because one could not be logged.

A report SHOULD identify the unresolved text. A report MUST bound that text's length and MUST NOT emit line terminators from it, so that nothing it carries can forge additional log lines. The bound is written over the text a report carries rather than over where that text came from: under section 5 the text a report identifies is the placeholder the message spells rather than anything a placeholder resolved to, and a message is written in a catalogue by hands that are not the host's, so an implementation is held to the bound whether or not it carries payload-derived text as well. A cut that would fall between the two halves of a surrogate pair SHOULD fall before the pair instead, so that the excerpt ends on a whole character.

14. Security properties and error behavior

14.1 Security properties

A conforming implementation MUST provide all three of the following. They are requirements, not permissions.

Own-property lookup. Placeholder resolution MUST NOT reach inherited members (section 9.2). Without this, a message containing {{constructor}} or {{__proto__}} discloses host internals, and in hosts with mutable prototypes a polluted prototype changes the rendering of messages that never referenced it.

The requirement covers configuration as well as the payload: the modifier registry (sections 11.3, 11.4), the formatting layers of section 11.2 and the reporting channel (section 14.3) MUST be read from their own entries only. Nobody writes configuration onto a prototype, so an inherited props entry, an inherited modifier registration or an inherited reporting channel is somebody else's, and reading it lets a polluted prototype reformat, re-route or hijack a message whose caller configured none of it.

It covers the call's own inputs last of all. This document specifies no host API, so an implementation is free to receive the message, the payload, the props and the locale grouped in a single host container rather than as separate arguments; where it does, that container MUST be read from its own entries too. A container read through its prototype lets a polluted prototype supply the payload a caller passed none of, which is the whole of section 9.2's protection undone one level above the payload.

Data is not syntax. What a resolution reads from its caller MUST NOT be read as message text. A payload value, a props value, a payload default, a wrapper's default and a modifier's answer are data: an implementation MUST emit each as it stands, MUST NOT scan any of them for placeholders, and MUST NOT remove escape sequences from any of them (sections 5, 7, 11, 12).

Without this a payload writes the message. A value of {{apiKey}} reads a payload entry the message never named; a value of ; live:DELETED adds an option to a construct the message wrote, and so selects a branch its author never offered; a modifier's answer does either through the one extension point a host has. Each is a payload deciding what the message means, and this property is the whole of what keeps a translation catalogue from being an execution surface.

It bounds the work too, which is why the section after it is as short as it is: nothing a value contributes is ever read again, so there is no growth to bound and a resolution terminates because the message is finite.

Data is not configuration either, and this property does not make it so. A payload entry shaped like a wrapper (section 4.1) is read as one, and the props it carries join above the caller's own (section 11.2), so an untrusted value of that shape reconfigures every formatting and host-defined modifier the placeholder reaches without spelling any syntax at all. An implementation cannot tell such an entry from one a caller meant; a caller that passes untrusted data MUST NOT pass it where a wrapper is recognized, and section 4.1 is where recognition is turned off.

Bounded work. Section 13 MUST bound the work a resolution can be made to do, by a payload and by a message alike.

A payload buys work by being large and being read often, which the output and read limits bound, and by being expensive to describe, which the conversion limit bounds — a message carrying no placeholder at all reaches a conversion and nothing else, so the conversion limit is the only one that holds it. That limit holds one conversion; what keeps a message from buying as many of them as it has placeholders is section 13's requirement that a resolution convert a value once.

A message buys work by nesting, and no limit of section 13 can bound it, because the cost is paid deriving the message and none of those limits has anything to measure until there is output. An implementation MUST derive a message in time bounded by a polynomial in the length of that message. Section 6 note 11 is how: the scan resumes at the very next code point where a placeholder does not derive, so the attempts overlap, and an implementation that records the verdict it reached for a position costs time proportional to the message's length where one that does not costs time growing faster than any polynomial in it — a denial of service an attacker writes in a few hundred characters.

An implementation MUST NOT provide configuration that disables any of these properties.

14.2 Message errors

A message error is a defect in the message: an unknown modifier (11.4), a selection that names a comparison and no options (9.5), or a placeholder nested deeper than the implementation resolves (13).

On a message error an implementation MUST resolve the placeholder to the fallback chain (section 10), MUST NOT raise, and SHOULD report the error.

A value that is present but that no conversion can describe (section 4) is a defect in the payload rather than in the message. It is treated as absent, so the placeholder takes the fallback chain; the implementation MUST NOT raise and SHOULD report the condition. The same holds for a link of the fallback chain that is present and cannot be described.

So is an input a modifier cannot process (sections 11.2, 11.3). The value, the props and the locale a modifier is handed are the caller's, and so is a host-defined modifier that raised, so none of it is a defect the message's author repairs; the message can name no property a formatting modifier reads. The placeholder takes the fallback chain; the implementation MUST NOT raise and SHOULD report the failure.

The message itself is not such a link. A message that no conversion can describe is nothing to resolve (section 4), and a message nobody wrote is not a defect in the payload, so stepping past it is not a condition to report. Neither is the id: no step of resolution reads it, and it is a caller's input rather than a payload value, so section 4's reporting SHOULD does not reach it.

A report names the condition it describes with a code, and every code declares an origin: which of a resolution's inputs the defect is in, and so who repairs it. The origin message is a defect in the message, which the translator who wrote it repairs; payload is a defect in the payload, the props or the locale, which the code that supplied them repairs; limit is a bound of section 13 the resolution reached, which is a defect in neither input, because the message and the payload together asked for more work than the implementation permits, so what repairs it is asking for less or permitting more (section 13). The three are the taxonomy this section states, named: the message error and the payload defect above are the first two, and section 13's limits are the third.

The vocabulary is eight codes. unknown-modifier is a modifier name that is neither specified nor registered (section 11.4); missing-options is a selection that names one of this format's comparison modifiers, one no host replaced, and declares no option (section 9.5); nesting-limit is a placeholder nested deeper than the implementation resolves (section 13). Those three declare the origin message. The third is a limit and still declares message, because how deeply a message nests is a fact about the message alone (section 12): the payload asked for none of it, and what repairs it is rewriting the message or choosing an implementation that resolves further. failed-modifier is a modifier that cannot process its input (sections 11.2, 11.3); unserializable-value is a value no conversion can describe (section 4); missing-locale is a formatting modifier reached where no locale is available (section 11.2). Those three declare the origin payload. output-limit and read-limit are the two bounds of section 13 the message and the payload reach together, and both declare the origin limit. The conversion limit is not among them: a value whose serialization reaches it is a value no conversion can describe (section 4), and is reported as one.

An implementation that reports MUST name the condition with the code this section gives it, and where a report carries an origin it MUST be the one that code declares. The set is closed: this document defines no code beyond these eight, and a code a later version of this format defines declares its origin with it.

Rendering must not fail because one translation is wrong. A single malformed message must not take down the page that contains it.

14.3 Reports

This specification does not prescribe a reporting channel. Reports SHOULD identify the message id and the placeholder. Every condition this document names is met at a placeholder, limits included: a limit is reached while a particular placeholder is being resolved, and that is the one to name (section 13). Section 13's bounds on report content apply to every report, whatever text it carries.

An implementation that reports emits one report for each placeholder that met the condition, where the walk met it. A message naming an unknown modifier at three placeholders therefore reports three times. A placeholder is reached at most once (section 5), so it meets a condition at most once and is reported at most once, and a placeholder the walk never reaches — one in an option the modifier passed over — meets no condition and is reported not at all.

Reports are emitted in the order the walk meets the conditions. Section 5 resolves a message in one walk and section 9 fixes the steps a placeholder takes, so where neither of two reporting placeholders holds the other, the one the message writes first reports first; and where one holds the other, the inner reports while the outer's value is being read — after the outer's selector was resolved and before its result was produced. An implementation MUST NOT reorder reports by condition, by origin or by severity.

The count is therefore bounded by the message: a resolution emits at most one report per placeholder per condition, and a message holds fewer placeholders than it holds characters. Nothing a payload supplies multiplies that, which is what a model of repeated passes could not say. Each report's content is bounded separately, by section 13.

The conformance set observes reports through an adapter an implementation supplies for it; this document still prescribes no channel. The same adapter names the levels the implementation satisfies (section 2) and the limits it permits (section 13), which is what makes those two statements an implementation makes about itself observable: the set reads the levels to select the fixtures a level requires, and the limits to derive the cases that sit at a boundary, so an implementation that permits more than section 13's minima is exercised at the bounds it documents rather than at those minima.

Appendix: the reference implementation

@curly-message/parser is the reference implementation of this specification.

The format is specified independently of it. An implementation in any language that satisfies section 2 conforms, whether or not it shares any code with it.

A host library that wants this syntax adapts an implementation to its own calling convention. That adapter belongs to the host, and this document describes neither — the format is indifferent to which host, if any, an implementation is reached through.

License

This specification is published under the MIT License. See LICENSE.