Website & documentation: limaformat.dev
LIMA Is Metadata Annotation — a YAML-familiar, deliberately bounded frontmatter format with a complete reference specification.
The name is a recursive backronym — LIMA contains itself, just like YAML (YAML Ain't Markup Language). Consider it a nod: Lima is a deliberate, focused subset of YAML, keeping what works and leaving out what doesn't. Fittingly, lima is also Esperanto for "bounded" or "limiting" (from limo, "boundary") — Lima is a deliberately bounded, precisely defined metadata language.
(Lima in running text, always — LIMA only when spelling out the backronym, as above. Full naming convention: Contributing.)
Lima is the part of YAML that frontmatter actually needs, with well-defined types and no surprises. It adds two things YAML doesn't have: references to document properties and to externally provided partials. Everything else is familiar.
---
title: My First Post
tags:
- javascript
- webdev
published: 2024-03-01
draft: false
---
npm install @limaformat/lima
# or
bun add @limaformat/limaParsing content between the fences — parse takes the raw Lima content, not the surrounding --- delimiters or the Markdown body:
import { parse } from '@limaformat/lima'
const meta = parse(`
title: My First Post
tags:
- javascript
- webdev
published: 2024-03-01
draft: false
`)
// { title: 'My First Post', tags: ['javascript', 'webdev'],
// published: 2024-03-01T00:00:00.000Z, draft: false }Parsing a whole Markdown file — splitting the fences off is the caller's job; Lima has no file-reading or fence-splitting API of its own. This is a plain, dependency-free recipe, not a bundled function — copy it, or use a library like front-matter/gray-matter for the splitting step and hand the extracted text to Lima's parse:
import { readFileSync } from 'node:fs'
import { parse } from '@limaformat/lima'
function splitFrontmatter(fileContent: string): { frontmatter: string; body: string } | null {
const match = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?([\s\S]*)$/.exec(fileContent)
if (!match) return null
return { frontmatter: match[1], body: match[2] }
}
const file = readFileSync('post.md', 'utf-8')
const split = splitFrontmatter(file)
if (!split) throw new Error('post.md has no --- frontmatter block')
const meta = parse(split.frontmatter)
// split.body is the Markdown content after the closing fenceThis recipe requires the opening --- on the file's literal first line and a ----only closing line; it does not recognise a completely empty frontmatter block with zero blank lines between the fences (---\n---\n, as opposed to ---\n\n---\n, which works). If this pattern turns out to be widely needed, an official extractFrontmatter (or similar) export is a reasonable future addition — proposed here, not implemented, since it's a new public API surface that deserves its own review rather than arriving as a side effect of a docs pass.
Full Core and References syntax, resource limits, strict mode, and the complete API: docs/guide.md. Migrating existing YAML frontmatter: docs/migrating-from-yaml.md.
Lima is small because it is specified, not specified because it is small. Three guarantees follow from that; the sizes further down are their evidence, not the point.
-
Nothing is lost without a word. In strict mode every content line belongs to a key, a block or a scalar, or it is an error at its line (Core 1.1). A typo, a stray tab or a construct Lima does not have is reported where it stands, with a hint where one helps:
description: > A folded text Lima: unexpected indentation at line 2: "A folded text" (">" is YAML's folded scalar; Lima writes "|" and continues a line with "^^")Strict mode checks a closed list of conditions (Core §10.1) and nothing else; non-strict mode skips what it cannot read, and the same list says what that is.
-
One answer in every language. TypeScript, Rust and Go implement the same specification independently. They pass the same 486 Core 1.2 and 144 References 2.1 conformance cases — values, error codes and source lines alike — and in every CI run they parse the same 100,000 random documents in both modes and must agree on every value, warning and error. A disagreement is a defect or a gap in the specification, and once decided it becomes a corpus case.
-
Types without guessing.
trueandfalseare the only booleans, socountry: NOstays the string"NO"— YAML 1.1's wider set is the well-known "Norway problem". Dates have fixed formats, without the locale ambiguity ofMM/DD/YYYYandDD/MM/YYYY; integers outside the safe range stay strings. There is one behaviour and no schema to choose: js-yaml's own newer default no longer resolves timestamps at all, unlike most existing frontmatter tooling. Document size, key and scalar length, nesting depth and key count are normative limits, checked in both modes.
- YAML — a large, feature-rich grammar. Frontmatter typically uses a small fraction of it, and the unused parts are exactly where implementations diverge from each other and where past CVEs have concentrated (see Security, below).
- TOML — solid, but its
[section]/key = valuesyntax is noticeably more verbose than indentation for nested frontmatter, and it has no equivalent of Lima's References. - JSON — no comments, every key and string must be quoted, no multi-line strings — workable for machines, awkward to hand-author and diff as frontmatter.
Lima is what you'd write if you just wanted key-value pairs that work, with a spec small enough to read in one sitting — no 92-page grammar, no implementation-defined corners.
Why does fifteen lines of frontmatter need a specification five times longer than JSON's or TOML's?
Appendix A lists 19 constructs Lima Core explicitly excludes. Checked individually against what each one is actually for: 7 are YAML-only constructs tied to well-documented complexity or security concerns (folded block scalars and chomping indicators, nested flow/sequence structures, anchors and aliases, tags, multi-document streams); 5 more remove specifically locale- or format-ambiguous date handling (including the two constructs that are ambiguous by definition without a locale: MM/DD/YYYY vs DD/MM/YYYY); the remaining 7 are Lima's own type-system and References-boundary decisions, not YAML-complexity avoidance as such. On top of that list, a few things never needed an Appendix A entry because the grammar never had them to begin with: YAML 1.1's wider implicit-boolean set, non-scalar mapping keys, merge keys (<<).
- A grammar expressible without regex backtracking. The TypeScript implementation's tokenizer uses 0 lookahead/lookbehind/backreference constructs — verifiably RE2-representable, the same property linear-time engines like Google's RE2 and Rust's
regexcrate require. Not a claim about immunity to slow input in general, just that the grammar itself doesn't force a backtracking engine the way some regex-heavy formats do.
None of this makes Lima a YAML replacement — it's deliberately scoped to frontmatter, not general-purpose data serialisation, and the constructs it leaves out are exactly the ones YAML-parsing frontmatter rarely needs in the first place. The trade-off is explicit, not hidden: see Appendix A for the full, reasoned list.
Two of YAML's excluded constructs map directly onto real, disclosed vulnerabilities — not hypothetical risk, documented CVEs:
- CVE-2020-1747 (PyYAML, CVSS 9.8 critical) — arbitrary code execution via the
!!python/object/newtag construct in untrusted YAML input. Lima has no tag system at all (Appendix A); there is no construct for this class of bug to attach to. - CVE-2019-11253 (Kubernetes API server) — denial of service via anchor/alias exponential expansion (a "YAML bomb": each alias re-expands its anchor's full content, nesting a handful of levels deep produces gigabytes from a few kilobytes of input). Lima has no anchors or aliases (Appendix A). Notably, published mitigation guidance for this exact CVE class recommends "parsers with intentionally limited capabilities like StrictYAML" — the same restricted-grammar approach Lima takes, independently arrived at.
This isn't "Lima is unhackable" — it's narrower and more honest than that: in Lima Core these two specific, named attack classes have no construct to exploit, by construction, because the constructs were never added rather than added-then-restricted. Lima's own resource limits (document size, scalar length, node count, nesting depth — Core §9, checked in both parse modes) are separate, defence-in-depth protection against oversized or pathologically nested input generally, independent of any single construct. References are the construct to watch: copying a value is expansion by another name. Since 0.7.1, resolving them counts what it copies and stops at the limit.
Worth being precise about the threat model this actually matters for: both CVEs above involve YAML parsing untrusted input (a public API accepting arbitrary submissions). For frontmatter you write yourself, that's not the threat you're facing — this section is most relevant if something in your pipeline parses frontmatter from a source you don't fully control (a CMS accepting user content, a multi-tenant platform, etc.), less so for a personal blog's own files.
Small as a consequence, not as the goal — and measured rather than claimed. Word count is the most robust metric here (line count depends on wrapping conventions); Lima Core is the fair implementation/spec comparison unit, since References is an optional convenience layer with no direct YAML equivalent. The three tables keep unlike measurements separate instead of filling cells with arbitrary JSON or TOML libraries. Implementation size is split into code and comments, counted separately rather than mixed into one figure — comment density is a matter of authoring style, not grammar complexity, and conflating the two would let whichever project comments less look artificially smaller.
Specification length:
| JSON (RFC 8259) | TOML | Lima Core 1.2 | YAML 1.2.2 |
|---|---|---|---|
| 3,998 words | 4,254 words | 10,444 words | 21,961 words |
TypeScript implementation:
| Lima Core 1.2 | js-yaml 5.2.3 load() path |
|
|---|---|---|
| Code | 8,869 words | 11,787 words |
| Comments | 5,373 words | 2,144 words |
Published npm package:
@limaformat/lima 0.8.1 |
js-yaml 5.2.3 |
|
|---|---|---|
| Packed tarball | 79.4 KB | 338 KB |
Both figures count the committed TypeScript source — not the bundled, minified, or generated dist/ output that ships — measured from code transitively reachable from the relevant public entry point rather than by whole-file boundaries. The js-yaml measurement covers its actual load() parse path (parser, constructor, schema, and tag resolvers), not serialization. Package size is necessarily measured for the complete published packages: Core and References always ship together in @limaformat/lima, so the Lima package figure is a conservative comparison against the Core-only implementation measurement.
The Lima Core 1.2 spec is ~2.1× shorter than YAML 1.2.2 (10,444 words vs. 21,961) and about 2.6× JSON's or 2.5× TOML's — it still documents type coercion, date parsing, resource limits, and a full strict-mode error catalogue that the simpler formats skip, and it still reads in one sitting.
js-yaml's load() path is ~11,787 words of TypeScript because it also contains support for anchors and aliases, merge keys (<<), multi-document streams, %YAML/%TAG directives, and 21 tag implementations across its 4 exposed schemas. Ordinary frontmatter does not require those features; tags and anchors/aliases are also the constructs implicated in the two concrete CVEs discussed below. Lima Core is about 8,869 words of code for the subset that's actually left, plus about 5,373 words of comments — comments are roughly 37.7% of the counted committed source, and many tie an implementation detail directly to a spec section. Being smaller is the easy part to measure; the part that matters is that the machinery that isn't there can't misbehave.
The complete @limaformat/lima 0.8.1 tarball — Core and References 2.1 — is ~4.3× smaller than js-yaml 5.2.3 (79.4 KB vs. 338 KB); part of that is scope, part is that js-yaml also ships pre-built browser bundles.
This isn't a case of counting favourably: separating "Core" from "References" in the implementation requires following actual imports, not just file boundaries. Shared files are divided by what each public entry point actually reaches — for instance, four References 2.0 §2.4 source-position helpers (stripCommentKeepEscapes, commentStart, physicalRaw, rawOffsetOf) that parseCore never executes count as References even though they live in shared parser files. The internally isolated, briefly-published References 1.0 code is excluded except for helpers actually imported by References 2.0. Generated dist/, tests, benchmarks, and dead code are excluded from both sides.
Reproduce the implementation counts from js/ with bun run wordcount -- --manifest scripts/wordcount.manifest.json --bucket lima-core-1.1 and bun run wordcount -- --manifest scripts/wordcount.manifest.json --bucket lima-references-2.0-additions. bun run bench:vs-yaml measures parse speed against js-yaml on realistic frontmatter — Bun-only numbers, don't assume they transfer proportionally to other JavaScript engines; bun src/run.ts (from compat/) reports where Lima and YAML actually diverge on the same input, not just how long each takes. Migrating existing YAML frontmatter to Lima: see docs/migrating-from-yaml.md, which uses this same divergence report as its evidence.
- Lima Core 1.2 specification — final (
docs/lima-core-1.0-spec.md, under its 1.0 name so that published links keep working) - Frozen Core 1.0 conformance baseline — a byte-identical 149-case 1.0.0 baseline, count-pinned and content-hash protected, with additive Core errata through 1.0.10 on top (
corpus/, design rationale indocs/corpus-design/) - Frozen Core 1.1 conformance corpus — 126 cases in
corpus/core-1.1/, pinned the same way, superseding 8 Core 1.0 cases (decision) - Core 1.2 conformance cases — 150 cases in
corpus/core-1.2/: keys in any script, and a case for every row of the table of what each kind of line does - Differential test in CI — TypeScript, Rust, and Go parse the same random documents (200,000 parses per implementation and run) and must agree on every value, warning and error (
docs/corpus-design/) - Lima References 2.1 specification — final with a 144-case additive corpus; TypeScript, Rust, and Go pass the complete suite through their public References 2.1 APIs (spec, coverage)
- TypeScript/JavaScript implementation — published as
@limaformat/lima - Rust implementation — published as
lima - Go implementation — published as
github.com/limaformat/lima/go
Lima Core is at 1.2. Core 1.0 was frozen; corrections shipped only as
additive errata revisions (through 1.0.10), driven by the conformance corpus
rather than any single implementation, and never altering the byte-frozen 1.0.0
baseline. Core 1.1 (2026-10-28) was the first change an erratum could not
carry: strict mode rejects a content line that belongs to no structure, which
Core 1.0 skipped in both modes, and it settles cases Core 1.0 left open, such as
a sequence at its key's own column, which changes some non-strict results too.
The 1.0 baseline stays frozen as it is; Core 1.1 supersedes eight of its cases
with its own (decision).
Core 1.2 (2026-10-30) lets an unquoted key be written in any script, and
gendered (größe, 題名, autor*in), with References 2.1 reaching every such
key, and states in one table what each kind of line does where it stands.
The runnable reference API in every implementation is Core 1.2 plus References 2.1.
An earlier References 1.0 was published briefly in mid-2026 and superseded
almost immediately by References 2.0, which is deliberately not
syntax-compatible with it. Its frozen specification
and 101-case corpus stay in the repository — the corpus still runs, protected by
the same content-hash manifests, as a regression guard on internal code — but
References 1.0 is not a target current implementations are asked to hit and its
($key) / (%key) syntax is not a public parsing entry point anywhere.
The normative specifications are the single source of truth for Lima's syntax and semantics:
- Lima Core 1.2 — syntax, types, and error behaviour.
- Lima References 2.1 — versioned syntax, partial mapping paths, and bounded transitive references.
The specifications are self-contained; no design-history documents are part of this repository. docs/guide.md is a non-normative walkthrough of Core 1.2 and References 2.1 — where it disagrees with a spec, the spec wins. The superseded References 1.0 specification stays archived in the repository (see Status above).
Lima ships an implementation-independent conformance test corpus so that every implementation can be verified against the same normative cases. TypeScript, Rust, and Go each pass all 486 Core 1.2 and 144 References 2.1 cases, plus a frozen 101-case References 1.0 regression corpus (see Status). The corpus itself lives in corpus/; its architecture, diagnostic model, and coverage matrix are documented in docs/corpus-design/README.md.
Package versions, corpus counts, word counts, package sizes, comparison ratios,
and the differential-test size are generated in docs/facts.lima.
After building js/, run bun scripts/facts.ts from the repository root to
refresh that file and every facts: marker in the READMEs. Run
bun scripts/facts.ts --check to verify them without updating tracked files.
This is a monorepo: implementations live alongside the specification and corpus they are validated against.
js/—@limaformat/limaon npm.rust/—limaon crates.io.go/—github.com/limaformat/lima/goon pkg.go.dev.
Issues and discussion are welcome; please read the specifications first, since they — not any single implementation — define correct Lima behaviour.
The specifications were written by hand. The three implementations were built largely with AI coding assistants (Claude Code and Codex CLI) working against the specs and the conformance corpus, with the two agents alternating between implementing a unit of work and independently reviewing it, plus separate external review passes. The correctness claims rest on the normative specs, the language-neutral corpus, and that review — not on who typed the code.
| Context | Spelling | Example |
|---|---|---|
| Running text, including sentence-initial position | Lima |
"Lima is a deliberate, focused subset of YAML" |
| Logo/wordmark | lima·format |
stays lowercase — a typographic choice, not a spelling rule |
| Spelling out the backronym | LIMA Is Metadata Annotation |
acronym letters capitalised, that's the point |
| Technical identifiers | lima |
npm package @limaformat/lima, crate lima, anchor IDs, ---lima fence |
Lima is a proper noun like Python or Rust, not a stylized-lowercase brand (no eBay/iPhone-style forced lowercase at sentence start) — the lowercase word lima already means something else in this README (Esperanto for "bounded", see above), so keeping the brand capitalised is what keeps it legible as a name rather than the common word. Technical identifiers stay lowercase because npm/crates.io/URL conventions require it, not because the brand does.