YAML dialect
@pbuilder/sdk/yaml edits .yaml and .yml files through named ops that address a node by
path. The ops change only the addressed node, so comments, key order, anchors, tags, and styles
survive the edit, which they would not if you rewrote the whole file with replaceContent. The
handle works like every other dialect handle
(one live AST, one write per flush); see Modify
for that model.
import * as yaml from "@pbuilder/sdk/yaml";
// compose.yaml before:// services:// web:// image: nodeawait yaml .find("compose.yaml") .setValue(["services", "web", "ports"], ["3000:3000"]) .setComment(["services", "web"], "Frontend");// -> services:// -> # Frontend// -> web:// -> image: node// -> ports:// -> - 3000:3000The dialect has 25 named ops: the 15 structural ops it shares with the JSON dialect, plus ops for comments, anchors and aliases, presentation, and documents.
Paths and values
Section titled “Paths and values”find(path)accepts only.yamland.yml. The basename must end in one of those extensions after a non-empty name. Any other path throws synchronously, before any op runs.- A path is an array of segments. A
stringnames a mapping key, anumberindexes a sequence, and[]is the document root:["services", "web", "ports", 0]. Keys with dots or slashes need no escaping. A segment that is neither a string nor a non-negative integer rejects. - Values are typed
YamlValueand checked at runtime. A value is JSON data:null, booleans, finite numbers, strings, arrays, and plain objects.undefined, functions,NaNandInfinity, class instances such asDate, and cyclic values reject before the file changes, and the error names the path inside the value. Dates, binary, and custom-tagged values are reachable through.modify(). - Reads follow aliases; writes do not. An op that would write through an alias rejects and names it. Edit the anchored node instead.
- Every rejection is all-or-nothing. The error message starts with
dialect operation failed:, and the Session emits no directive for the file.
Multi-document files
Section titled “Multi-document files”Every op except addDocument and removeDocument takes an optional last argument
{ document?: number } that selects one document of a ----separated file. It defaults to 0,
and an out-of-range index rejects.
a: 1 # keep spacing---b: 2await yaml.find("config.yaml").setValue(["b"], 3, { document: 1 });a: 1 # keep spacing---b: 3An empty file is a stream with zero documents. The ops that create missing parents (setValue,
merge, appendItem, updateValue) create document 0 on it; the other ops that take
{ document } reject.
How the file is printed
Section titled “How the file is printed”- A document that no op touched prints byte-identical to its source, including spacing such
as
a: 1above. - A touched document is re-serialized by the
yamllibrary. It keeps comments, key order, anchors, aliases, tags, and scalar and collection styles. New content uses the document’s detected indentation (two spaces when none is detected), sequence indentation, and flow-collection padding. - The library normalizes some spelling in a touched document:
- long folded (
>) scalars are joined onto one line; - number spellings such as
0x1Fprint as0x1f; - explicit
? keyentries becomekey: value; - a trailing document comment gains a blank line before it.
- long folded (
- A UTF-8 BOM, CRLF line endings, and a missing trailing newline round-trip.
- A file with a YAML syntax error, or an alias to an undefined anchor, fails to parse.
Directives (%YAML, %TAG) and blank-line spacing have no named op. Edit them through
.modify().
Structural ops
Section titled “Structural ops”These are the 15 ops of the JSON dialect, with the same signatures and
the same rules. Read “object” as mapping and “array” as sequence. Each one also takes the
{ document } option as its last argument.
| Op | Call | Before | After |
|---|---|---|---|
setValue(path, value, { before?, after? }) |
setValue(["port"], 8080) |
port: 80 |
port: 8080 |
addKey(path, value, { before?, after? }) |
addKey(["pkg", "private"], true) |
pkg: {name: app} |
pkg: {name: app, private: true} |
removeKey(path) |
removeKey(["x", "b"]) |
x: {a: 1, b: 2} |
x: {a: 1} |
renameKey(path, newKey) |
renameKey(["x", "b"], "beta") |
x: {a: 1, b: 2} |
x: {a: 1, beta: 2} |
insertItem(path, index, value) |
insertItem(["x"], 1, "b") |
x: [a, c] |
x: [a, b, c] |
appendItem(path, value, { unique? }) |
appendItem(["plugins"], "b", { unique: true }) |
plugins: [a] |
plugins: [a, b] |
removeItem(path, index) |
removeItem(["x"], 0) |
x: [a, b] |
x: [b] |
removeValue(path, value) |
removeValue(["x"], "a") |
x: [a, b, a] |
x: [b] |
replaceItem(path, index, value) |
replaceItem(["x"], 0, "z") |
x: [a, b] |
x: [z, b] |
moveItem(path, from, to) |
moveItem(["x"], 0, 2) |
x: [1, 2, 3] |
x: [2, 3, 1] |
merge(path, object) |
merge(["a"], { y: 2 }) |
a: {x: 1} |
a: {x: 1, y: 2} |
updateValue(path, fn) |
updateValue(["version"], (v) => (typeof v === "number" ? v + 1 : 1)) |
version: 1 |
version: 2 |
moveValue(from, to) |
moveValue(["a", "x"], ["b", "x"]) |
a: {x: 1}b: {} |
a: {}b: {x: 1} |
copyValue(from, to) |
copyValue(["a"], ["b"]) |
a: &x hello |
a: &x hellob: hello |
sortKeys(path, { recursive? }) |
sortKeys(["x"]) |
x: {b: 1, a: 2} |
x: {a: 2, b: 1} |
What YAML adds to the shared rules:
- Missing parents are created as mappings, never sequences. A missing parent addressed by an index rejects.
- A replaced node keeps its comments and anchor, and a scalar replaced by one of the same type
keeps its quoting.
replaceItemkeeps the item’s comment:- a # firstbecomes- z # first. removeKeyremoves the comment above the key together with the key.moveValuecarries comments, anchors, and tags with the node. A move that would leave an alias before its anchor rejects.copyValuecopies the resolved data, not the anchor. The copy in the table has no&x.sortKeysmoves each key’s comments with it.
A longer merge shows how comments survive: two mappings merge recursively, anything else
(sequences included) is replaced, and untouched keys keep their comments.
a: x: 1 # keep x y: - 1b: trueawait yaml.find("config.yaml").merge([], { a: { y: [2], z: 3 } });a: x: 1 # keep x y: - 2 z: 3b: trueComments
Section titled “Comments”setComment(path, comment, { position? })
Section titled “setComment(path, comment, { position? })”position is "before" (the default: # lines above the node) or "inline" (after the node on
the same line). The op replaces any comment already at that position. A multi-line comment
writes one # line per line, and an identical comment changes nothing. [] addresses the
document: "before" is its leading comment, and it has no inline position.
port: 80await yaml.find("server.yaml").setComment(["port"], "HTTP port");# HTTP portport: 80removeComment(path, { position? })
Section titled “removeComment(path, { position? })”Removes the comment at that position. No comment there is a no-op.
// server.yaml before: port: 80 # httpawait yaml.find("server.yaml").removeComment(["port"], { position: "inline" });// -> port: 80Anchors and aliases
Section titled “Anchors and aliases”setAnchor(path, name)
Section titled “setAnchor(path, name)”Puts &name on the node at path. It rejects a name with spaces or flow indicators (,[]{}),
a name another node already carries, and a path whose value is an alias. Setting the same anchor
is a no-op; setting a different one renames the anchor and re-points its aliases.
setAlias(path, anchor)
Section titled “setAlias(path, anchor)”Sets the value at path to *anchor, creating missing parents like setValue. It rejects when
no node carries anchor, and when the alias would come before its anchor in document order.
base: image: nodeweb: {}await yaml.find("compose.yaml").setAnchor(["base"], "base").setAlias(["web"], "base");base: &base image: nodeweb: *baseremoveAnchor(path)
Section titled “removeAnchor(path)”Removes the anchor and replaces every alias to it with a copy of the value, so the data does not change. A node without an anchor is a no-op.
base: &base image: nodeweb: *baseawait yaml.find("compose.yaml").removeAnchor(["base"]);base: image: nodeweb: image: nodeaddMergeKey(path, anchor)
Section titled “addMergeKey(path, anchor)”Adds <<: *anchor as the first entry of the mapping at path. It rejects when no node carries
anchor, when the anchored node is not a mapping, and when the value at path is not a mapping.
A merge key already present for that anchor is a no-op. A second, different merge source turns
the entry into <<: [*a, *b].
base: &base image: nodeweb: port: 80await yaml.find("compose.yaml").addMergeKey(["web"], "base");base: &base image: nodeweb: <<: *base port: 80Presentation
Section titled “Presentation”setStyle(path, style)
Section titled “setStyle(path, style)”Collections take "block" or "flow". String scalars take "plain", "single", "double",
"literal", or "folded". Only the presentation changes; the parsed data stays equal. The op
rejects:
- a collection style on a scalar, or a scalar style on a collection;
- a quoted or block style on a non-string;
"plain"on a string that cannot be written plain, such as'123'or'x: y';- an alias, which has no style.
// config.yaml before: x: [1, 2]await yaml.find("config.yaml").setStyle(["x"], "block");// -> x:// -> - 1// -> - 2setTag(path, tag)
Section titled “setTag(path, tag)”Sets the node’s tag and keeps its value and style; null removes the tag. It accepts local tags
(!Ref), core tags (!!str), verbatim tags (!<tag:example.com,2026:x>), and named handles
declared by a %TAG directive. Anything else rejects. Setting the current tag is a no-op.
bucket: my-bucketawait yaml.find("template.yaml").setTag(["bucket"], "!Ref");bucket: !Ref my-bucketDocuments
Section titled “Documents”These two ops take no { document } option; they work on the file’s list of documents.
addDocument(value, { index? })
Section titled “addDocument(value, { index? })”Inserts a document holding value at index, by default at the end. index must be in
0..length.
// config.yaml before: a: 1await yaml.find("config.yaml").addDocument({ b: 2 });// -> a: 1// -> ---// -> b: 2removeDocument(index)
Section titled “removeDocument(index)”Removes the document at index. An out-of-range index rejects. The documents that stay keep
their bytes.
// config.yaml before: a: 1, ---, b: 2await yaml.find("config.yaml").removeDocument(1);// -> a: 1Custom edits with .modify()
Section titled “Custom edits with .modify()”For anything the named ops do not cover, including directives and blank-line spacing, use
.modify(fn). The callback receives the live YamlStream: its documents array holds one
yaml Document instance per document in the file, the same objects the named ops edit. The
module exports the library as astLibrary, so you do not need to add yaml to your own
dependencies. An empty file has documents: [].
This factory marks the file as YAML 1.2 and puts a blank line before the port key:
name: appport: 80import { find, astLibrary } from "@pbuilder/sdk/yaml";
export default async () => { await find("server.yaml").modify((stream) => { const document = stream.documents[0]; if (document === undefined || !astLibrary.isMap(document.contents)) return;
document.directives!.yaml.explicit = true; for (const pair of document.contents.items) { if (astLibrary.isScalar(pair.key) && pair.key.value === "port") { pair.key.spaceBefore = true; } } });};%YAML 1.2---name: app
port: 80An edit made through .modify() counts as touching its document, so that document is
re-serialized with the normalizations listed in
How the file is printed. The general rules for custom edits (operate
only on the AST you are handed, .modify() is not a sandbox) are in
Modify.
Going further
Section titled “Going further”- JSON dialect: the full rules of the 15 structural ops.
- AST libraries: which library and version back each dialect.
- The SDK’s YAML dialect guide.