@osdlc/yaml-splice
v0.3.1
Published
Edit top-level fields of a YAML mapping in place, on bytes: set, remove or rename a field, and every byte outside the edited span stays as it was, including comments, key order, quoting, a BOM and line endings. The yaml-splice Rust crate, built to WebAsse
Maintainers
Readme
@osdlc/yaml-splice
Edit top-level fields of a YAML mapping in place, on bytes. Set, remove or
rename a field, and every byte outside the edited span stays as it was:
comments, key order, quoting, a byte-order mark and line endings. It runs the
yaml-splice Rust crate as WebAssembly, so it has no runtime dependencies and
no native code.
Why
A program that changes one value in a YAML file usually parses the file and writes it out again. The values come back right, but the text does not: comments move or vanish, quotes change, keys are reordered, and CRLF becomes LF. In version control the whole file shows as changed, and the person who wrote it loses their layout.
This package changes only the bytes of the field you edit. Everything else is copied from the input. If an edit cannot be made that way, it refuses with a typed error instead of guessing.
It is for programs that write YAML that people also edit by hand: Markdown frontmatter, config files, and tools that sync metadata into notes.
When not to use it:
- To read values. Use a YAML parser, such as
yaml. - To edit nested values. Only top-level keys of a mapping are addressed; a
Setreplaces a key's whole value. - In a browser or through a bundler such as Vite. This release loads under Node.js and Bun only (see Runtimes and platforms).
Install
npm install @osdlc/yaml-splicepnpm add @osdlc/yaml-splice and yarn add @osdlc/yaml-splice work too.
ESM only. Node.js 22 or later, or Bun.
Quick start
import assert from 'node:assert/strict'
import { editYaml } from '@osdlc/yaml-splice'
const source = new TextEncoder().encode("title: 'Draft' # shown in the header\nstatus: draft\n")
const out = editYaml(source, [
{ Set: { key: 'status', value: 'done' } },
{ Rename: { from: 'title', to: 'name' } },
])
assert.equal(new TextDecoder().decode(out.bytes), "name: 'Draft' # shown in the header\nstatus: done\n")
assert.equal(out.changed, true)More in examples/: setting values, removing and renaming in one batch, and
handling refusals.
API
editYaml(source, edits)takes the document as UTF-8 bytes (Uint8Array) and a list ofFieldEdits, and returns anEditOutcome: the newbytesandchanged. It throws anEditErrorwhen it refuses.FieldEditis one edit:{ Set: { key, value } },{ Remove: { key } }or{ Rename: { from, to } }.EditValueis the value aSetwrites: any JSON-shaped value. Maps and lists are written as block YAML under the key.EditOutcomeholdsbytesandchanged. When no value changed,bytesequals the input andchangedisfalse, so a caller can skip the write.EditErroris the refusal. Itscode(anEditErrorCode) says why, andnameis the same code.span, anEditSpan, is the byte range[start, end)in the input that the refusal is about, when there is one.EditErrorCodeis one ofInvalidEdit,InvalidUtf8,MalformedYaml,UnsupportedDocument,RootNotMapping,AmbiguousKey,MissingKey,KeyConflict,UnrenderableValueandInvalidResult. A minor release may add a code, so give aswitchover it adefaultbranch.- Any other error
editYamlthrows is not a refusal and is passed through as is. ARuntimeErrorfrom the WebAssembly module means it crashed; do not keep calling it as if the edit had been refused. EditSpanis{ start, end }, in bytes.RootNotMapping,AmbiguousKeyandKeyConflictalways carry one;MalformedYamlandUnsupportedDocumentcarry one when the parser knows the location.
Each export has TSDoc in the source. Your editor shows it.
Behaviour
- The edits apply in order, and each one sees the result of the ones before it. The batch is atomic: if any edit is refused, nothing is returned.
- Keys match case-insensitively:
Titleandtitleare the same key. Both sides are lowercased and nothing else is normalized. Two existing keys that differ only in case make an edit of either oneAmbiguousKey. Seton a missing key appends it after the last top-level entry.Removeof a missing key changes nothing.Renameof a missing key isMissingKey; a rename onto an existing key isKeyConflict.- New lines use the document's first line ending (LF or CRLF). A BOM stays at byte zero.
NaNand±Infinityanywhere in aSetvalue are refused asUnrenderableValuebefore any byte is written.- An edit the editor cannot read is refused as
InvalidEdit: aSetwith novalue(orvalue: undefined), an unknown tag, or aSetvalue nested more than 124 levels deep. The last is the JSON decoder's recursion limit (serde_json's 128 levels, less the levels the edit list itself takes). - Each edit is checked against what it asked for: the addressed key must read
back as set, removed or renamed, and no other key may change value or
order. If not, the batch is refused as
InvalidResultinstead of returning a damaged document.
How it compares
yamlkeeps comments through its Document API, but writes the whole document out again, so quoting, spacing and line endings can change. Use it to read and to build YAML; use this package to change a value in a file a person edits.yaml-diff-patchandyawn-yamlkeep comments and most formatting, but do not promise that the bytes outside the edit are unchanged, and have no typed refusals.@osdlc/frontmatter-losslesssplits a Markdown note into its frontmatter and body. The two compose: split the note, edit the YAML bytes here, and put the note back together.
The Rust crate yaml-splice is the
implementation. This package runs it as WebAssembly, and its tests run the
crate's conformance corpus.
Stability
The package is 0.x, so any minor release may break the API. Within that, each export is stable or experimental. A breaking change to a stable export is listed in the CHANGELOG under "Breaking". Any release may change or remove an experimental export.
The experimental exports:
- none
Tested claims
| Claim | Test |
|---|---|
| Comments, key order and quoting outside the edit are kept | corpus fixture a_changed_value_keeps_comments_key_order_and_quoting |
| A BOM is kept | corpus fixture a_bom_is_preserved_and_outside_every_span |
| CRLF and mixed line endings are kept | corpus fixtures crlf_line_endings_survive_outside_the_edited_span, mixed_line_endings_are_not_normalized |
| An edit that changes no value returns the input bytes | tests/index.test.ts, "a semantic no-op …" |
| Non-finite numbers are refused before any write | tests/index.test.ts, "a non-finite number in a Set value" |
| EditErrorCode lists every refusal the crate has | tests/index.test.ts, "EditErrorCode" |
| A malformed or too-deeply nested edit is InvalidEdit | tests/index.test.ts, "an edit the editor cannot read" |
| An error that is not a refusal is passed through | tests/index.test.ts, "an error that is not a refusal" |
Runtimes and platforms
Node.js 22 or later, and Bun. The WebAssembly glue is built for Node.js: it
reads the .wasm file with fs from beside itself. So the package does not
load in a browser, in Deno without Node compatibility, or through a bundler
such as Vite. The built package is tested on Node.js 22 and 24, and the test
suite runs on Vitest under Node. It has no native code and no platform-specific
code, so it runs on any operating system Node.js runs on. Its tests run on
Linux and macOS.
Licence
MIT OR Apache-2.0, at your option. See LICENSE-MIT and LICENSE-APACHE.
