@effected/yaml

中文文档
0.30.0发布于 2026/11/10MITTypeScriptES Module构建来源已验证近 30 天下载 1.1万

YAML parsing, editing, formatting and linting as Effect schemas, with no IO and no dependency beyond effect and the kit's own position helpers.

原始使用文档
文档目录10 节
  1. Why @effected/yaml
  2. Install
  3. Quick start
  4. Hostile input fails typed
  5. Comments survive an edit
  6. Lint and autofix
  7. Migrating from Prettier
  8. Emitting for a YAML 1.1 consumer
  9. Features
  10. License

@effected/yaml

npm License: MIT Node.js %3E%3D24.11.0 TypeScript 7.0

YAML 1.2 parsing, editing, formatting and linting expressed as Effect schemas and pure functions. Parse a single document or a multi-document stream into plain values or an offset-preserving AST, resolve anchors and aliases, read and preserve per-node comments, strip comments, compute byte-minimal edits, format, modify by path, walk a document as a Stream of AST events or positioned tokens, lint it against a rule catalog with surgical autofix — inferring the lint config from files you already have — and decode straight into a validated domain schema.

Pre-1.0.0. This package is part of the @effected/* kit, built on stable Effect v4 (effect ~4.1.0) and still in 0.x development. Stable Effect makes a kit 1.0.0 possible, not automatic. To keep your effect and @effect/* versions on the line the kit is built and tested against, install @effected/pnpm-plugin-effected.

Stability: unstable. This package's API surface is not yet considered complete and may change across 0.x releases. Pin an exact version — even a package marked stable before 1.0.0 can introduce a breaking change by accident, and an exact pin turns that into a type-check error rather than a runtime surprise. Full policy: release strategy.

Why @effected/yaml

YAML is where untrusted text meets production systems: CI pipeline definitions, Kubernetes manifests and config files that arrive from a pull request, an API payload or a user's home directory. The format is also large enough that a parser has real attack surface. An anchor that references an anchor that references an anchor — the "billion laughs" bomb — is a few hundred bytes of YAML that expands into gigabytes of nodes, and a deeply nested flow collection is a few kilobytes that overflows a recursive-descent parser's stack.

This package treats that as a first-class requirement rather than a footnote. An alias-expansion budget bounds the number of materialized nodes, a depth cap bounds collection nesting on both the parse and the stringify side, and both fire into the typed error channel. Hostile input produces a YamlParseError carrying structured YamlDiagnostic values with codes and positions; it never produces a RangeError, an unhandled defect or an exhausted heap.

The rest follows from the same discipline. Parsing recovers from errors and aggregates every diagnostic into one failure rather than throwing on the first. Modifications are computed as edits against the original bytes, so comments and layout survive a change. Everything is a pure function or a schema: no IO, no services and no runtime dependency other than effect and the dependency-free @effected/source (the line index every reported position comes from) — the lexer, CST parser, composer and stringifier are vendored into the package with attribution rather than taken as a dependency. It is the largest package in the repo, and it earns that by owning its engine.

Install

npm install @effected/yaml effect
pnpm add @effected/yaml effect

Requires Node.js >=24.11.0. effect v4 is a peer dependency; the package itself adds no other runtime dependencies.

All @effected/* packages are ESM-only: the exports maps publish only import conditions, so require() — including tools that resolve in CJS mode — fails with Node's ERR_PACKAGE_PATH_NOT_EXPORTED rather than loading a CJS build that does not exist. Import from an ES module.

Quick start

Compose your schema with Yaml.schema to decode YAML straight into a validated domain value:

import { Yaml } from "@effected/yaml";
import { Effect, Schema } from "effect";

const Config = Schema.Struct({ port: Schema.Number });
const ConfigFromYaml = Yaml.schema(Config);

const program = Effect.gen(function* () {
  return yield* Schema.decodeUnknownEffect(ConfigFromYaml)("port: 3000 # dev server");
});

Effect.runPromise(program).then(console.log);
// { port: 3000 }

Yaml.stringify goes the other way, failing typed on circular references rather than throwing:

import { Yaml } from "@effected/yaml";
import { Effect } from "effect";

Effect.runPromise(Yaml.stringify({ port: 3000, hosts: ["a", "b"] })).then(console.log);
// port: 3000
// hosts:
// - a
// - b

Both directions have a synchronous counterpart that returns a Result for callers that cannot await an Effect, a vitest.config.ts being the motivating case. Yaml.parse is defined in terms of Yaml.parseResult, so the two forms cannot disagree about what a document means:

import { Yaml } from "@effected/yaml";
import { Result } from "effect";

const result = Yaml.parseResult("port: 3000 # dev server");
console.log(Result.isSuccess(result) ? result.success : result.failure);
// { port: 3000 }

Hostile input fails typed

An alias bomb — nested anchors whose expansion multiplies at every level — is bounded by an expansion budget and surfaces as a YamlParseError, not as an out-of-memory kill:

import { Yaml } from "@effected/yaml";
import { Effect } from "effect";

const bomb = [
  "a: &a [x,x,x,x,x,x,x,x,x]",
  "b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]",
  // ...further levels, each multiplying the one before
  "z: [*g,*g,*g,*g,*g,*g,*g,*g,*g]",
].join("\n");

Effect.runPromise(Effect.result(Yaml.parse(bomb))).then(console.log);
// Failure with YamlParseError, whose `diagnostics` carry:
// { code: "AliasCountExceeded", message: "Alias expansion exceeded budget of ... nodes" }

Collection nesting past the depth cap behaves the same way, yielding a NestingDepthExceeded diagnostic instead of a stack overflow, and Yaml.stringify caps the mirror-image recursion when encoding a value back to text. The guarantee is the same everywhere: every fallible entry point carries a real error channel, and nothing reaches your process as a defect.

Comments survive an edit

A YAML file a human maintains is mostly explanation, and the explanation is the part a round-trip usually loses. YamlFormat computes its edits against the original bytes, so changing a value rewrites the value and leaves the header block, the trailing # LTS, the blank line and the indentation where they were. Setting preserveComments: false is how you ask for the other behavior:

import { YamlFormat, YamlFormattingOptions } from "@effected/yaml";

const source = 'node: "20.11.0" # LTS\n\n# why the port\nport: 3000\n';

console.log(YamlFormat.formatToString(source));
// node: "20.11.0" # LTS
//
// # why the port
// port: 3000

console.log(YamlFormat.formatToString(source, undefined, YamlFormattingOptions.make({ preserveComments: false })));
// node: "20.11.0"
// port: 3000

Comments are readable as data too. Every node class — YamlScalar, YamlMap, YamlSeq and YamlAlias — carries commentBefore (the own-line block above the node), comment (strictly the trailing comment on the node's own line) and spaceBefore (a blank line came first). YamlPair carries none of them: an entry is its key and its value, and the comments live on those two nodes. YamlDocument uses the same two names as the nodes do, commentBefore for a header block sitting ahead of a --- marker and comment for the trailing block, and YamlVisitor tags every Comment event with placement: "leading" | "trailing".

Which node a comment lands on follows one rule. An own-line comment above an entry leads that entry's key node, setting commentBefore and — when a blank line came first — spaceBefore. A trailing comment belongs to the last node on its line: in a: 1 # t the value ends the line, so # t is the value's comment; in push: # only main the value renders below, so the comment is the key's. Two slots per entry rather than one is what lets push: # only main and an own-line comment above the value both round-trip byte-intact, instead of one spelling being rewritten into the other. A header block with no --- marker to separate it from the item stream leads the first entry's key by the same rule; one after the marker leads the root node.

Lint and autofix

YamlLint.run checks a string against a rule catalog and returns positioned diagnostics; YamlLint.fix applies the fixes that can be expressed as surgical edits and returns the fixed text. Both are pure and synchronous. This is the lint engine, not a runner — no file discovery, no config-file loading, nothing written to disk:

import { YamlLint, YamlLintConfig } from "@effected/yaml";
import { Result } from "effect";

const source = "# pinned\nname:  demo   \nport: yes\n";

for (const d of YamlLint.run(source, YamlLint.builtins, YamlLintConfig.default)) {
  console.log(`${d.line}:${d.character} ${d.severity} ${d.rule} ${d.message}`);
}
// 1:5 error colon-spacing Too many spaces after ":" (2 > 1)
// 1:11 error trailing-spaces Trailing whitespace
// 2:6 error truthy Truthy value "yes" is not in the allowed spellings

const fixed = YamlLint.fix(source, YamlLint.builtins, YamlLintConfig.default);
console.log(Result.isSuccess(fixed) ? fixed.success : "fatal parse error — not safely fixable");
// # pinned
// name: demo
// port: "yes"

Positions are zero-based throughout, so the first finding above sits on the second line of the source. Fixes route through YamlEdit.applyAll rather than a reformat, so an autofix cannot move a comment it was not asked to touch, and a rule whose repair would need reflowing the document ships without a fix at all. YamlLintConfig.default and YamlLintConfig.relaxed are the presets; compose your own by spreading a preset's rules into YamlLintConfig.make. Config validation is rule-aware for the built-in catalog — a misspelled option key on a built-in rule or an attempt to switch off the always-on parse-validity rule fails schema validation instead of being quietly ignored — while an unknown (custom) rule id passes through with its options treated as opaque, left for the custom rule itself to validate. Custom rules are plain array concatenation — YamlLint.run(text, [...YamlLint.builtins, myRule], config) — and configuring myRule in that config works precisely because of the pass-through.

A repo with no lint config yet can infer one from what its files already do. YamlLint.inferLenient observes a text's style — quote type, indentation width, document markers and the rest — and returns the config the dominant style implies, plus the residual: the diagnostics that config still produces on the same text.

import { YamlLint } from "@effected/yaml";

const workflow = ["---", "name: ci", "on:", "  push:", "    branches: ['main']", "jobs:", "  build:", "    runs-on: ubuntu-latest", ""].join("\n");

const { config, residual } = YamlLint.inferLenient(workflow, YamlLint.builtins);
console.log(config.rules["quoted-strings"], config.rules["document-start"], config.rules.indentation);
// { quoteType: 'single' } { present: true } { spaces: 2 }

for (const d of residual) console.log(`${d.line}:${d.character} ${d.rule} ${d.message}`);
// 2:0 truthy Truthy value "on" is not in the allowed spellings

YamlLint.inferStrict is the same step in Result form: it fails with a structured YamlStyleConflictError — naming every conflicting dimension, all observed spellings, counts and positions — when the observed style disagrees with itself, instead of picking a winner. Across many files, compose the primitives instead of the conveniences: YamlLint.observe each file, merge the StyleEvidence values with StyleEvidence.combine (evidence is a monoid, so the order does not matter), then resolve once with YamlLint.resolveStrict or YamlLint.resolveLenient. Dimensions never observed fall back to the base config rather than failing. Policy rules with no detectable style — truthy, key-duplicates — stay default-driven, as do line-length and empty-lines, whose observations are floor-only: the longest line proves the limit is at least that long, not what it should be, so the evidence is carried but never resolved into config. A custom rule participates by implementing the optional infer hook on YamlRule.

Migrating from Prettier

YamlFormat.formatToString(text, undefined, options) replaces a prettier --write over YAML files. The option mapping:

PrettierYamlFormattingOptions
tabWidthindent
singleQuotequoteStyle: "single" | "double" + requoteScalars: true
indented block sequences (always on)indentSequences: true
printWidth / proseWrapno analog — see below
comments (best-effort)preserveComments: true (the default)

Two defaults differ from Prettier's output and are worth setting explicitly. The stringify default is quoteStyle: "single" for byte-compatibility with the kit's legacy form, so if you ran Prettier with its own default (singleQuote: false), set quoteStyle: "double". Likewise indentSequences defaults to false — the flat legacy form with the - markers at the key's column — while Prettier always indented block sequences one level, so an ex-Prettier consumer almost certainly wants indentSequences: true.

The requoteScalars: true half of the singleQuote row is what buys full parity. By default, quoteStyle on the format path governs only quotes the stringifier introduces — a scalar already quoted in your source keeps its own quote style, so formatting a: 'x' with quoteStyle: "double" leaves 'x' untouched, where Prettier would have rewritten it to "x". With requoteScalars: true (default false), quoteStyle applies to already-quoted source scalars too. A re-quote happens only when it provably preserves the parsed value: single→double applies proper double-quote escaping, and double→single is skipped when the value carries characters single quotes cannot express. Plain scalars stay plain, block scalars stay block, and tagged, anchored and multi-line scalars are never touched. One interaction to watch: quoteStyle omitted defaults to "single", so requoteScalars: true on its own converts "x" to 'x' — an ex-Prettier consumer who ran with singleQuote: false wants quoteStyle: "double" alongside it.

printWidth and proseWrap have no analog here: lineWidth folding is a value-path feature only (Yaml.stringify / Yaml.stringifyResult), and the option is deliberately inert on the format path by contract — YamlFormat never reflows scalar content (the lineWidth TSDoc on YamlStringifyOptions pins this). Comments, which Prettier handled best-effort, are preserved exactly by default.

Multi-document streams — a Kubernetes manifest, or the two-document pnpm-lock.yaml pnpm 11 writes under configDependencies — format whole: every document is re-emitted in order with its own ---/... framing and comments, so no document is ever dropped. Document detection is CST-level, so a --- inside a block scalar or quoted string is content and formats normally. The formatter leaves input byte-identical rather than corrupting it in exactly two cases: a fatal parse error in any document, and a stream carrying %YAML/%TAG directives (which the emitter cannot re-emit faithfully). Validate a whole stream with Yaml.parseAllResult, which fails typed when any document carries a fatal diagnostic; YamlFormat.modify stays single-document, since a path names no particular document of a stream.

Emitting for a YAML 1.1 consumer

YAML 1.2's Core Schema resolves fewer plain scalars to non-strings than YAML 1.1 does. A plain on or yes round-trips as a string under this package's own parser but becomes a boolean the moment a YAML 1.1 consumer — js-yaml, PyYAML, libyaml, the yaml npm package's YAML 1.1 schema, most CI systems' native YAML support — reads it back: the "Norway problem". quoteCompat: "yaml-1.1" closes that gap on the way out, quoting every plain scalar a 1.1 resolver would re-type: the extended booleans (y/yes/no/on/off and case variants), 1.1 timestamps, sexagesimal numbers (1:30), underscore-grouped numbers (1_000) and the octal/hex/binary integer forms (0777, 0x_FF, 0b1010_0111). It is strictly additive — nothing the 1.2 rules already quote loses a quote, and no string outside that list gains one — and composes with quoteStyle for the quote character and lineWidth for folding:

import { Yaml, YamlStringifyOptions } from "@effected/yaml";
import { Effect } from "effect";

const frontmatter = { node: "on", released: "2024-01-15", port: "yes" };
const options = YamlStringifyOptions.make({ lineWidth: 0, quoteCompat: "yaml-1.1", quoteStyle: "double" });

Effect.runPromise(Yaml.stringify(frontmatter, options)).then(console.log);
// node: "on"
// released: "2024-01-15"
// port: "yes"

That combination — lineWidth: 0 (no folding), quoteCompat: "yaml-1.1", quoteStyle: "double" — is the shape to reach for when stringifying frontmatter destined for YAML-1.1 tooling: Markdown frontmatter parsers and most static-site generators still resolve on the older rules. The option applies on both the value path (Yaml.stringify, Yaml.stringifyResult) and the node path (YamlDocument#stringify, YamlFormat), and scalars carrying an explicit tag are exempt, exactly like the 1.2 type-conflict check it extends.

Features

  • Yaml.parse / Yaml.parseAll — error-recovery parsing of a single document or a ----separated stream into plain values, resolving anchors and aliases and aggregating every diagnostic into one YamlParseError.
  • Yaml.stringify — serialize a plain value back to YAML, failing typed with YamlStringifyError on circular references or on excessively deep nesting; a block-mapping key too long to render on one line spills into explicit-key form (? key / : value) so strict parsers still accept the output; quoteCompat: "yaml-1.1" additionally quotes plain scalars a YAML 1.1 resolver would re-type.
  • Yaml.parseResult / Yaml.parseAllResult / Yaml.stringifyResult — the pure synchronous counterparts, returning a Result instead of an Effect for config-time callers that cannot await; each runs the same engine call as its Effect variant, so the two forms cannot diverge, and a fatal diagnostic, a duplicate key, an alias bomb or a circular reference still fails typed rather than throwing. parseAllResult doubles as a whole-stream validity check: it fails when any document in the stream is invalid.
  • Yaml.stripComments — quote-aware comment removal that keeps line numbers stable, or every byte offset stable when given a replacement character.
  • Yaml.equals / Yaml.equalsValue — semantic equality that ignores comments, whitespace, formatting and mapping key order while keeping sequence order significant.
  • Yaml.schema / Yaml.fromString / Yaml.YamlFromString / Yaml.allFromString — string→domain schema factories that decode a single document or a whole stream into a validated Effect Schema value.
  • YamlNode — an offset-preserving AST (YamlScalar, YamlMap, YamlSeq, YamlPair, YamlAlias) for locating and reading nodes by position or path; every node class except YamlPair carries its own commentBefore, comment and spaceBefore, and a pair is just its key and value.
  • YamlDocument — the full parsed AST plus the recovered errors and warnings arrays, so a partially valid document is still inspectable, and the header and trailing comment blocks as commentBefore / comment, the same two names the node classes use.
  • YamlEdit / YamlFormat — compute byte-minimal edit arrays for formatting and path-based modification, so a change preserves every comment and byte you did not touch; preserveComments: false opts out, and opt-in requoteScalars applies quoteStyle to scalars already quoted in the source, semantics-preserving or skipped. format handles multi-document streams whole, re-emitting every document and its framing; modify is single-document by contract (a path names no particular document of a stream) and fails typed with MultiDocumentStream rather than guessing.
  • YamlVisitor — walk a parsed document as a Stream of a tagged-enum event union, with Stream.take early termination on large inputs and a placement on every comment event.
  • YamlToken / YamlTokens — the positioned token stream the lint rules read: tokenize is a synchronous Result that always succeeds, surfacing lexical problems as error-kind tokens, and stream derives an Effect Stream from the same walk.
  • YamlLint — run, fix and the builtins catalog: 14 rules covering parse validity, line length, trailing whitespace, blank lines, end-of-file newline, document markers, duplicate keys, quoting, truthy spellings, comment and colon and hyphen spacing, and indentation. Plus config inference for adopting the linter on an existing corpus: observe produces mergeable StyleEvidence, resolveStrict fails typed with YamlStyleConflictError on conflicting spellings, resolveLenient picks the dominant style, and inferStrict / inferLenient do it in one step per text.
  • YamlLintConfig / YamlLintRule / YamlLintDiagnostic — the default and relaxed presets, a config schema validated against the rule catalog, and the model a custom rule implements, including the optional infer hook that lets a custom rule participate in config inference.
  • YamlParseError / YamlStringifyError / YamlModificationError — tagged errors carrying structured, positional YamlDiagnostic arrays rather than opaque messages.

License

MIT

版本与发布记录

@effected/yaml 共发布 38 个版本,首次发布于 2026/07/17;过去 12 个月发布了 38 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.30.0latest2026/11/102 天前
0.29.02026/11/067 天前
0.28.02026/11/0310 天前
0.27.02026/11/0112 天前
0.26.02026/10/2915 天前
0.25.12026/10/2618 天前

关于 @effected/yaml 的常见问题

@effected/yaml 是什么?

YAML parsing, editing, formatting and linting as Effect schemas, with no IO and no dependency beyond effect and the kit's own position helpers.

如何安装 @effected/yaml?

在项目目录运行 npm install @effected/yaml,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:effect(~4.1.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@effected/yaml 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@effected/yaml 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@effected/yaml 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=24.11.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@effected/yaml 可以商用吗?

@effected/yaml 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@effected/yaml 的下载量有多少?

根据 npm 官方下载统计,@effected/yaml 在 2026/10/13 – 2026/11/11 这 30 天内被下载 11,162 次,日均约 372 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@effected/yaml 还在维护吗?

最新版本 0.30.0 发布于 2026/11/10(2 天前);至今共发布 38 个版本,其中过去 12 个月发布了 38 个;首次发布于 2026/07/17。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。