Guide
A practical walkthrough of Lima syntax, the References Extension, resource limits, and the JavaScript/TypeScript API.
A practical walkthrough of Lima syntax, the References Extension, resource limits, and the JavaScript/TypeScript API. This guide is descriptive, not normative — Lima Core 1.0 and Lima References 1.0 are the source of truth. Where this guide and a spec disagree, the spec wins.
Installation
npm install @limaformat/lima
bun add @limaformat/lima
Quick example
import { parse } from '@limaformat/lima'
const result = parse(`
title: Hello World
published: 2024-03-01
draft: false
tags:
- javascript
- webdev
`)
// {
// title: 'Hello World',
// published: 2024-03-01T00:00:00.000Z, // a Date
// draft: false,
// tags: ['javascript', 'webdev']
// }
parse accepts the raw Lima content between the frontmatter delimiters ( ---) — stripping those delimiters is the caller’s job, Lima never sees them.
Syntax
Key names
[a-zA-Z0-9_][a-zA-Z0-9_:\-]*
An unquoted key starts with a letter, digit, or underscore, followed by any combination of letters, digits, underscores, hyphens, or colons.
| Format | Example | Valid unquoted |
|---|---|---|
| camelCase | firstName |
yes |
| snake_case | first_name |
yes |
| kebab-case | first-name |
yes |
| digit start | 1st, 42 |
yes |
| colon-namespaced | og:title |
yes |
| spaces, other characters | first name |
needs quoting |
Since unquoted keys may themselves contain colons, the key/value separator is the first : (colon + exactly one space) or :\n that isn’t inside a quoted key:
og:title: Hello # key = 'og:title', value = 'Hello'
a:b:c: value # key = 'a:b:c', value = 'value'
Quoted keys — single or double quotes, for keys with spaces or other characters an unquoted key can’t hold. Delimiters are stripped from the output key:
'first name': Alice # → { 'first name': 'Alice' }
"display name": Bob # → { 'display name': 'Bob' }
'': empty string key # → { '': 'empty string key' }
Double-quoted keys decode the same backslash escapes as double-quoted string values (below). Single-quoted
keys are literal. The separator must immediately follow the closing quote —
"key" : value
(space before the colon) is rejected at the top level (throws in strict mode, skipped as an unrecognised line
in non-strict). Enforcement is less consistent for a quoted key nested inside a block or flow mapping — don’t
rely on a space there being caught either way; simply avoid it.
Strings
Unquoted:
title: My Article
Double-quoted — suppresses type coercion, decodes escapes:
title: "Lima: A Primer" # → 'Lima: A Primer' (colon is safe inside quotes)
count: "42" # → '42' (string, not number)
flag: "true" # → 'true' (string, not boolean)
| Escape | Result |
|---|---|
\\ |
backslash |
\" |
double quote |
\/ |
forward slash |
\n \r \t |
newline / CR / tab |
\b \f |
backspace / form feed |
\uXXXX |
Unicode BMP code point (4 hex digits) |
\UXXXXXXXX |
Unicode supplementary code point (8 hex digits) |
\xXX |
Latin-1 code point (2 hex digits) |
An unrecognised escape (including \0 — there is no null-byte escape) is left intact in non-strict mode and throws in strict mode.
Single-quoted — also suppresses coercion, but has exactly one special sequence: \' for a literal quote. Everything else, including backslashes, is literal — 'a\\b' is the four characters a\\b, not a\b.
Comments in unquoted values — # starts a comment; \# keeps a literal #. Only the immediately preceding backslash counts:
link: https://example.com/page#section # → 'https://example.com/page'
link: https://example.com/page\#section # → 'https://example.com/page#section'
#
inside a single- or double-quoted string is never a comment — comment stripping only ever applies to unquoted
values.
Multi-line strings require an explicit | marker — plain indented continuation lines without | are not joined automatically:
description: |
Line one.
Line two.
A line belongs to the block scalar as long as its indentation is strictly greater than the introducing key’s —
including lines starting with
# (they’re literal content, not comments, as long as they’re still indented further than the key).
Merge two lines into one with ^^ at the end of a line, inside a | block only — it has no special meaning elsewhere:
description: |
This is a very long sentence that ^^
continues on the next line as one.
There is no YAML-style > folded scalar — | + ^^ covers the same need with less ambiguity.
Types
| Value | Type |
|---|---|
null, ~, (empty) |
null |
true, false (case-sensitive) |
Boolean |
42, 3.14, .5 |
Number |
0xFF, 0o77, 0b1010 |
String (not parsed as numbers) |
01, 007, +42, 1. |
String (not parsed as numbers) |
2024-03-01, 2024-03-01T09:00:00Z |
Date |
01.03.2024 14:33 |
Date |
2024/03/01 14:33 |
Date |
| anything else | String |
Leading zeros, an explicit +
sign, a trailing bare decimal point, and hex/octal/binary literals are all deliberately kept as strings — no
silent reinterpretation.
Dates are always UTC, second precision, no milliseconds:
| Form | Example |
|---|---|
| ISO date only | 2024-03-01 |
| ISO date + time | 2024-03-01T09:00, ...T09:00:00, ...T09:00:00Z, ...T09:00+02:00 |
German ( DD.MM.YYYY, no offset) |
01.03.2024, 01.03.2024 14:33 |
Slash ( YYYY/MM/DD, no offset) |
2024/03/01, 2024/03/01 14:33 |
ISO forms require the T separator — a space between date and time is not
accepted for ISO forms. German and slash forms require a space between date and time, and never carry a
timezone offset (an offset there falls through to a string, not an error). An ISO offset is applied and
converted to UTC:
published: 2024-03-01T09:00:00+02:00
# → Date, 2024-03-01T07:00:00.000Z
Years run 0001–9999. An out-of-range calendar value ( 2024-02-30
) or an offset that pushes the resulting UTC year outside that range falls back to a string in non-strict
mode, or throws in strict mode.
Arrays
Block sequence — dash-prefixed, one item per line:
tags:
- javascript
- webdev
A bare - with nothing after it is null:
values:
-
- hello
# → [null, 'hello']
Flow sequence — comma-separated in brackets, same type coercion rules, same result as the block form:
tags: [javascript, webdev, open-source]
values: [1, true, hello]
values: ["42", "true", hello] # quoted items skip coercion → ['42', 'true', 'hello']
Arrays of objects — multiple keys per item, indented block style:
authors:
- name: Alice
affiliation: MIT
- name: Bob
affiliation: Stanford
Flow mapping — inline objects, useful as array items or map values:
menu:
- {name: Home, url: /, weight: 1}
- {name: About, url: /about, weight: 2}
Array items can be scalars or mappings, never another array or another flow collection nested inside a flow collection — Lima intentionally doesn’t support array-of-arrays or flow nesting deeper than one level (see Appendix A).
Maps
author:
name: Alice
email: alice@example.com
Nested to any depth (up to the nesting limit), and inline values / nested maps can be freely mixed at the same level:
params:
weight: 1
social:
twitter: alice
draft: false
Tabs
Only tabs in leading indentation — before the first non-whitespace character on a line — are normalised to two spaces. A tab that appears within scalar content, quoted or not, is left exactly as written:
a:
b: 1 # → { a: { b: 1 } } — leading tab normalised to indentation
c: x y # → { c: 'x\ty' } — tab inside the value is preserved literally
Comments
title: My Article # this is a comment
Stripped from single-line values. Multi-line ( |) block scalars don’t process comments the same way — see Multi-line strings above for the indentation-based rule that applies there instead.
References (optional extension)
Everything in this section is the References Extension, layered on top of Core. A Core-only parser ( parseCore) treats ($key) and (%key) as plain strings — nothing below applies unless you call parse / parseReferences.
Document reference — ($key) or a dotted path ($a.b.c):
total: 42
count: ($total) # → 42 (number, not string — type preserved)
Partial reference — (%key), resolved against a partials object you pass in:
author: (%defaultAuthor)
parse(frontmatter, {
partials: { defaultAuthor: { name: 'Alice', email: 'alice@example.com' } }
})
Pure reference vs. interpolation — a value that is exactly one reference token (after trimming) preserves the target’s original type. Anything else — surrounding text, or more than one token — is interpolation and always produces a string:
firstName: Alice
fullName: ($firstName) ($lastName) # interpolation → 'Alice Wonderland'
greeting: Hello ($firstName)! # interpolation → 'Hello Alice!'
A reference inside a quoted string is inactive — literal text, never resolved:
title: "($key)" # NOT a reference — stays the literal string "($key)"
Forward references work , in both modes — Lima resolves in two passes, so a reference to a key defined later in the document still resolves. Unresolvable references are left as the literal token string in non-strict mode, or throw in strict mode.
The one-hop limit — read this before relying on chained references. Lima resolves at most one hop. A reference to a reference is not followed transitively:
a: ($b)
b: ($c)
c: 42
Result: a stays the literal string '($b)'; b resolves to 42; c is 42
. This is intentional, not a bug — Lima is a data format, not an evaluation system. Design frontmatter so
references point directly at their final value, not at another reference.
Partial values are never traversed further — a reference-looking string inside a partial’s value is always literal, even after the partial is inserted.
Resource limits
Checked in both strict and non-strict mode; exceeding any of these throws RESOURCE_LIMIT in both modes (there is no silent-fallback variant for resource limits).
| Limit | Value |
|---|---|
| Document size | 65,536 bytes (UTF-8) |
| Key length | 128 code points |
| Scalar length | 16,384 code points |
| Nesting depth | 16 |
| Top-level keys | 128 |
| Partials: count / name length / value depth / total nodes | 128 / 128 / 16 / 4,096 |
| References result: total node count | 65,536 |
Strict mode
Most malformed values are recoverable in non-strict mode — parsing falls back to a string, null, or skips the offending content, and reports what it can via onWarning. A handful of conditions throw in both modes, because there is no safe fallback to recover to: the resource limits
above, flow nesting deeper than one level (Core §10.1 itself), and — once References is layered on top —
several reference/interpolation/partial conditions (below). Check the “Non-strict” column of each table; don’t
assume it always means “never throws”.
| Condition | Non-strict | Strict | |
|---|---|---|---|
| Duplicate key | warn + last value wins | throw | |
| Indented freetext without `\ | ` | null |
throw |
| Invalid date (calendar validation) | string fallback | throw | |
| Float overflow / non-zero underflow to zero | string fallback | throw | |
Flow mapping missing : |
string fallback | throw | |
| Flow nesting deeper than one level | throw | throw | |
Unclosed [ or { |
string fallback | throw | |
| Trailing comma in flow sequence/mapping | ignored | throw | |
| Empty flow element | null / skipped |
throw | |
| Unknown or malformed escape sequence | left intact | throw | |
| Unterminated quoted string | string fallback | throw | |
| Inconsistent indentation | skipped | throw | |
| Space before colon in a quoted block key | skipped | throw | |
| Content after a closing quote on an inline value | string fallback | throw |
References adds (Core §10.1 + References §7):
| Condition | Non-strict | Strict |
|---|---|---|
| Unresolved reference after both phases | left as token string | throw |
| Mapping value used in interpolation | throw | throw |
| Array/nested-array element in interpolation | throw | throw |
| Scalar limit exceeded after interpolation/copy | throw | throw |
| Nesting depth exceeded after reference insertion | throw | throw |
| Invalid partial value | throw | throw |
API
import { parseCore, type CoreOptions } from '@limaformat/lima'
import { parse, parseReferences, type ParseOptions } from '@limaformat/lima'
parseCore and parse (an alias for parseReferences) both take (input: string, options?) => T, T defaulting to Record<string, unknown>
— pass your own interface for a typed result. This is a compile-time assertion only, not runtime validation:
Lima does not check that the parsed data actually matches
T
, it just tells TypeScript to treat the return value as that type. Validate separately (e.g. with
zod or a manual check) if the input isn’t trusted:
interface PostMeta {
title: string
published: Date
draft: boolean
tags: string[]
}
const meta = parse<PostMeta>(frontmatter)
meta.title.toUpperCase() // TypeScript knows title is a string
parseCore(input, options?: CoreOptions) — Core only. ($key) / (%key) are plain strings.
| Option | Type | Default |
|---|---|---|
strict |
boolean |
false |
onWarning |
(diagnostic) => void |
undefined |
parse / parseReferences(input, options?: ParseOptions) — Core plus the References Extension.
| Option | Type | Default |
|---|---|---|
strict |
boolean |
false |
onWarning |
(diagnostic) => void |
undefined |
partials |
Record<string, unknown> |
{} |
Duplicate keys never go to console.warn — or any other implicit output channel. Diagnostics are only ever delivered through onWarning; without one, they’re silently discarded:
parse(frontmatter, {
onWarning: (d) => console.warn(d.message, 'at line', d.line),
})
Strict mode throws a plain Error with a descriptive .message on the conditions listed above — this is the guaranteed contract (Core §11.3):
try {
const meta = parse(frontmatter, { strict: true })
} catch (e) {
console.error((e as Error).message)
}
Internally the thrown object also carries a .code, .line
, and other structured fields, but that richer shape isn’t currently exported as a named type from the package
— don’t rely on
instanceof
against a class you can’t import, or on the extra fields being present in a future version, until that’s a
documented, stable export.
Implementation notes
- Zero runtime dependencies.
- Avoids backtracking-specific regex features. Every pattern in the implementation avoids
lookahead, lookbehind, backreferences, and nested-quantifier constructions — the grammar doesn’t require a backtracking engine, the same property linear-time engines like RE2 require. Not a claim of immunity to slow input in general, just that the grammar itself doesn’t force worst-case regex behaviour.
- Speed. Measured against
js-yamlon Bun, on representativefrontmatter documents: reproduce it yourself with
bun run bench:vs-yamlfromjs/. Numbers are runtime-specific — don’t assume they transfer proportionally to other JavaScript engines. - Conformance corpus. Every claim about parsing behaviour in this guide
is backed by cases in the conformance corpus, checked against both this implementation and (where applicable) any future one.
Where to go next
- Lima Core 1.0 and
Lima References 1.0 — the normative specs; this guide simplifies, they decide.
-
Repository README — why Lima exists, the case against
YAML, security rationale.