@netfeez/yaml
v0.2.0
Published
An environment-agnostic YAML document editor for TypeScript.
Maintainers
Readme
@netfeez/yaml
YAML is a document. Treat it like one.
An environment-agnostic TypeScript library for editing YAML documents without throwing away the parts you didn't change.
Parse. Edit. Synchronize. Serialize.Installation
npm install @netfeez/yamlWhy?
Most YAML tooling follows this model:
flowchart LR
YAML["YAML"] --> Parse["Parse"]
Parse --> JS["JavaScript value"]
JS --> Stringify["Stringify"]
Stringify --> Output["YAML"]That works perfectly when YAML is simply a serialization format.
But configuration files are not just data.
They contain:
- comments
- formatting
- quoting choices
- document structure
- metadata
- information that exists for humans, not programs
If you parse a configuration file into a plain JavaScript object and serialize it again, much of that information is gone.
@netfeez/yaml takes a different approach:
flowchart LR
YAML["YAML document"] --> Parser["Parser"]
Parser --> AST["Editable document"]
AST --> Values["Values"]
AST --> Comments["Comments"]
AST --> Format["Formatting"]
AST --> Metadata["Source metadata"]
Values --> Edit["Edit"]
Comments --> Edit
Format --> Edit
Metadata --> Edit
Edit --> Serializer["Serializer"]
Serializer --> Output["YAML document"]The YAML document itself remains the source of truth.
The difference
Consider:
# Application server
server:
host: localhost
port: 8080 # HTTP portYou only want to change the port.
With a traditional parse → stringify workflow:
YAML
↓
JavaScript object
↓
modify port
↓
generate new YAMLyou are asking the serializer to reconstruct a document it never actually knew.
With @netfeez/yaml:
YAML
↓
Document
↓
modify port
↓
Document
↓
YAMLthe original document is still there.
const document = Compiler.parse(source);
document.get('server.port')!.value = 9090;
const result = Compiler.dump(document);The result can remain:
# Application server
server:
host: localhost
port: 9090 # HTTP portYou changed the value. The document stayed a document.
Features
- Editable YAML document model
- Lossless round-tripping for supported syntax
- Comments preserved
- Leading comment blocks preserved
- Inline comments preserved
- Existing source formatting preserved where possible
- Direct node editing
- Structural synchronization
- Deep path access
- Automatic intermediate container creation
- TypeScript-first API
- Environment-agnostic runtime
- Structured parsing errors
- No DOM dependency
- No filesystem dependency
- No runtime-specific dependency
Quick start
import Compiler from '@netfeez/yaml';
const document = Compiler.parse(`
# Application configuration
server:
host: localhost
port: 8080 # HTTP port
database:
host: db.internal
port: 5432
`);
document.get('server.port')!.value = 9090;
document.set('server.timeout', 30);
document.delete('database.port');
console.log(Compiler.dump(document));Editing the document
Read
const port = document.get('server.port');
console.log(port?.value);Deep paths are supported:
document.get('dependencies.0.name');Change
document.get('server.port')!.value = 9090;Existing node metadata remains attached to the node.
port: 8080 # HTTP portbecomes:
port: 9090 # HTTP portAdd
document.set('server.timeout', 30);Nested paths work as well:
document.set(
'build.targets.production.output',
'./dist'
);Missing intermediate containers are created automatically.
For sequences, missing indexes are padded with null.
Delete
document.delete('server.timeout');Only the target node is removed. Empty parent containers are not automatically garbage-collected.
Comments are part of the document
Comments are not treated as disposable text.
They belong to the document model.
const node = document.get('server.port')!;
console.log(node.lead);
console.log(node.inline);Leading comments are stored in lead:
node.lead.push(
'# Connection settings',
'# Used by the application server.'
);Inline comments are stored in inline:
node.inline = '# HTTP port';This makes comments editable too:
flowchart LR
Node["YAML node"]
Node --> Value["value"]
Node --> Lead["lead"]
Node --> Inline["inline"]
Node --> Dirty["dirty"]
Lead --> CommentBlock["Leading comments"]
Inline --> Comment["Inline comment"]For example:
const node = document.set('server.timeout', 30);
node.lead.push(
'# Connection timeout',
'# Value is expressed in seconds.'
);
node.inline = '# Optional';Produces:
# Connection timeout
# Value is expressed in seconds.
server.timeout: 30 # Optionalcompile() vs dump()
These operations intentionally have different purposes.
flowchart LR
AST["YAML document"]
AST --> Compile["compile()"]
Compile --> JS["Plain JavaScript value"]
AST --> Dump["dump()"]
Dump --> YAML["YAML document"]compile()
Use compile() when you want the data:
const value = Compiler.compile(document);For:
server:
host: localhost
port: 8080the result is:
{
server: {
host: 'localhost',
port: 8080
}
}dump()
Use dump() when you want the document:
const yaml = Compiler.dump(document);dump() serializes the editable document model rather than reconstructing YAML from a plain JavaScript object.
That distinction is fundamental to the library.
Synchronization
Sometimes you already have the desired JavaScript structure and want to update an existing document to match it.
That's what Compiler.apply() is for.
const document = Compiler.parse(`
server:
host: localhost # Host
port: 8080 # Port
`);
Compiler.apply(document, {
server: {
host: 'example.com',
port: 9090
}
});The synchronizer reconciles the existing tree:
flowchart LR
Existing["Existing document"] --> Sync["Synchronizer"]
Value["JavaScript value"] --> Sync
Sync --> Reconciled["Reconciled document"]
Reconciled --> Serializer["Serializer"]
Serializer --> YAML["Updated YAML"]The result can preserve the existing comments:
server:
host: example.com # Host
port: 9090 # PortThis is different from replacing the entire node.
Direct replacement
document.get('server')!.value = {
host: 'example.com',
port: 9090
};A structured replacement can create a new child tree. Metadata attached to the old children therefore may not survive.
Synchronization
Compiler.apply(document, {
server: {
host: 'example.com',
port: 9090
}
});apply() performs structural reconciliation and reuses compatible existing nodes where possible.
In short:
value = ...means:
Replace this node's value.
While:
Compiler.apply(...)means:
Make this existing document match this value while preserving what can be preserved.
A complete example
import Compiler from '@netfeez/yaml';
const document = Compiler.parse(`
# Application configuration
server:
host: localhost
port: 8080 # HTTP port
database:
host: localhost
port: 5432 # PostgreSQL
`);
document.get('server.port')!.value = 9090;
document.set('server.timeout', 30);
Compiler.apply(document, {
server: {
host: '0.0.0.0',
port: 9090,
timeout: 60
},
database: {
host: 'db.internal',
port: 5432
}
});
console.log(Compiler.dump(document));The important part is not merely the final YAML.
It is that the document was edited rather than regenerated.
Supported YAML
@netfeez/yaml intentionally implements a focused subset of YAML 1.2 aimed at block-style configuration files.
Scalars
- Plain scalars
- Single-quoted scalars
- Double-quoted scalars
null- Booleans
- Decimal numbers
- Hexadecimal numbers
- Octal numbers
- Floating-point numbers
- Exponents
.inf.nan
Structures
Nested mappings
Nested sequences
Plain and quoted keys
Empty flow collections
{}[]
Block scalars
literal: |
line one
line two
folded: >
line one
line twoChomping indicators are supported:
literal: |-
content
literal: |+
contentand:
folded: >-
content
folded: >+
contentDocument metadata
- Leading comments
- Inline comments
---...
Unsupported YAML
The parser intentionally rejects constructs outside its supported document model.
These produce a YamlError with:
error.unsupported === trueCurrently unsupported:
Directives
%YAML%TAG
Multiple documents
Anchors
&anchor
Aliases
*alias
Tags
!tag
Non-empty flow collections
Multiline plain scalars
Multiline quoted scalars
Explicit indentation indicators in block scalars
CRLF input
For example:
defaults: &defaults
port: 8080
server:
<<: *defaultsis intentionally rejected instead of being partially interpreted.
The goal is predictable document editing, not maximum YAML feature coverage.
Errors
Parsing and validation failures use YamlError.
try {
Compiler.parse(source);
} catch (error) {
if (error instanceof YamlError) {
console.error(error.message);
console.error(error.line);
console.error(error.unsupported);
console.error(error.status);
}
}Errors expose:
{
line: number | null;
unsupported: boolean;
status: 422;
}This allows applications to distinguish invalid input from valid YAML constructs that are intentionally outside the supported subset.
Architecture
The library is deliberately split into small layers:
flowchart TD
Source["YAML source"]
Parser["Parser"]
AST["AST / Document"]
Adapter["Adapter"]
Synchronizer["Synchronizer"]
Serializer["Serializer"]
Compiler["Compiler facade"]
Source --> Parser
Parser --> AST
AST --> Adapter
Adapter --> JS["JavaScript value"]
AST --> Synchronizer
JSValue["New JavaScript value"] --> Synchronizer
Synchronizer --> AST
AST --> Serializer
Serializer --> Output["YAML source"]
Compiler --> Parser
Compiler --> Adapter
Compiler --> Synchronizer
Compiler --> SerializerThe main modules are:
| Module | Responsibility |
| ----------------- | ------------------------------------- |
| AST.ts | Editable tree and node model (documents stay in Document.ts) |
| Document.ts | Document root and self-service API (dump/compile/comments/apply) |
| Parser.ts | YAML → document model |
| Serializer.ts | Document model → YAML |
| ScalarUtils.ts | Scalar resolution and quoting |
| Adapter.ts | AST → JavaScript values |
| Synchronizer.ts | Structural reconciliation |
| Compiler.ts | High-level public API |
| YamlError.ts | Structured errors |
The document model
At its core, the library keeps two things together:
flowchart LR
Document["Document"]
Document --> Structure["Structure"]
Document --> Metadata["Document metadata"]
Structure --> Maps["Maps"]
Structure --> Lists["Lists"]
Structure --> Scalars["Scalars"]
Metadata --> Comments["Comments"]
Metadata --> Formatting["Formatting"]
Metadata --> Source["Source information"]This is what allows the serializer to distinguish between:
Something that changedand:
Something that never needed to be touchedThat distinction is the foundation of the library.
Public API
The high-level API is intentionally small:
Compiler.parse(source);
Compiler.dump(document);
Compiler.compile(document);
Compiler.comments(document);
Compiler.apply(document, value);A parsed document is also self-sufficient, so the Compiler is optional for serialization work:
const document = Compiler.parse(source);
document.dump(); // YAML text
document.compile(); // plain JavaScript value
document.comments(); // leading comment blocks keyed by path
document.apply(value);// structure-preserving synchronizationAnd the document itself provides editing operations:
document.get(path);
document.set(path, value);
document.delete(path);The goal is to keep the API surface small while making the underlying document model powerful enough for configuration tooling.
Design philosophy
@netfeez/yaml is not trying to be another:
YAML → object → YAMLlibrary.
It is closer to:
YAML
↓
Editable document
↓
Targeted modifications
↓
YAMLThe distinction matters.
A configuration file is often simultaneously:
- machine-readable data
- human-readable documentation
- structured source code
- a collection of conventions and formatting decisions
Throwing that information away just because a program needs to change one value is unnecessary.
So the central rule is simple:
Change what you need. Preserve what you don't.
Development
Install dependencies:
npm installRun the test suite:
npm testThe test suite covers:
- AST behavior
- Parsing
- Serialization
- Compilation
- Synchronization
- Comments
- Document editing
License
Apache-2.0
