From 3218db7ec8cc6a813c2429eae0f798085e3b9453 Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Sun, 13 Sep 2026 07:56:37 -0400 Subject: [PATCH 1/2] feat!: Add doc.createMergePair() + do not duck-type << string keys as merge keys --- docs/01_intro.md | 3 ++- docs/04_documents.md | 1 + docs/05_content_nodes.md | 10 +++++++++- src/doc/Document.ts | 27 ++++++++++++++++++++++++++ src/nodes/Pair.ts | 9 ++++----- src/nodes/addPairToJSMap.ts | 12 ++++-------- src/schema/yaml-1.1/merge.ts | 18 ++++-------------- tests/doc/anchors.ts | 30 +++++++++-------------------- tests/doc/types.ts | 37 +++++++++++++++++++++++++++--------- 9 files changed, 88 insertions(+), 59 deletions(-) diff --git a/docs/01_intro.md b/docs/01_intro.md index 195969cd..b63a7b2f 100644 --- a/docs/01_intro.md +++ b/docs/01_intro.md @@ -95,7 +95,8 @@ import { - [`new YAMLSeq()`](#collections) - [`doc.createAlias(node, name?): Alias`](#creating-nodes) - [`doc.createNode(value, options?): Node`](#creating-nodes) -- [`doc.createPair(key, value): Pair`](#creating-nodes) +- [`doc.createPair(key, value, options?): Pair`](#creating-nodes) +- [`doc.createMergePair(value, options?): Pair`](#creating-nodes) - [`visit(node, visitor)`](#finding-and-modifying-nodes) - [`visitAsync(node, visitor)`](#finding-and-modifying-nodes) diff --git a/docs/04_documents.md b/docs/04_documents.md index 69cc4bd6..6f934766 100644 --- a/docs/04_documents.md +++ b/docs/04_documents.md @@ -106,6 +106,7 @@ which is expected to always contain a `YAMLMap`, `YAMLSeq`, or `Scalar` value. | createAlias(node: Node, name?: string) | `Alias` | Create a new `Alias` node, adding the required anchor for `node`. If `name` is empty, a new anchor name will be generated. | | createNode(value, options?) | `Node` | Recursively wrap any input with appropriate `Node` containers. See [Creating Nodes](#creating-nodes) for more information. | | createPair(key, value, options?) | `Pair` | Recursively wrap `key` and `value` into a `Pair` object. See [Creating Nodes](#creating-nodes) for more information. | +| createMergePair(value, options?) | `Pair` | Create a YAML 1.1 `<<` merge `Pair` object. See [Creating Nodes](#creating-nodes) for more information. | | get(key) | `Node ⎮ Pair` | Returns top-level collection item at `key`, or `undefined` if not found. | | getPair(key) | `Pair` | Returns top-level collection pair at `key`, or `undefined` if not found. | | set(key) | `void` | Sets a value in this document's top-level collection. For `!!set`, `value` is ignored. | diff --git a/docs/05_content_nodes.md b/docs/05_content_nodes.md index c598123a..08e7981e 100644 --- a/docs/05_content_nodes.md +++ b/docs/05_content_nodes.md @@ -253,7 +253,15 @@ To construct a `YAMLSeq` or `YAMLMap`, use `new Document()` or `doc.createNode() Once created, normal array operations may be used to modify the `items` array. New `Pair` objects may created either by importing the class from `yaml` and using its `new Pair(key, value)` constructor, or by using the `doc.createPair(key, value, options?)` method. -The latter will recursively wrap the `key` and `value` as nodes, and accepts the same options as `doc.createNode()` +The latter will recursively wrap the `key` and `value` as nodes, and accepts the same options as `doc.createNode()`. + +#### `doc.createMergePair(value, options?): Pair` + +To create YAML 1.1 `<<` merge keys, use `doc.createMergePair(value, options?)` on a Document that supports them, +such as one created with `{ merge: true }` or `{ schema: 'yaml-1.1' }` as options, +or parsed from a YAML 1.1 document source. + +Keys that are otherwise created with the string value `<<` will be quoted to disambiguate them from merge keys. ## Finding and Modifying Nodes diff --git a/src/doc/Document.ts b/src/doc/Document.ts index 090aca10..f1a66070 100644 --- a/src/doc/Document.ts +++ b/src/doc/Document.ts @@ -1,5 +1,6 @@ import type { YAMLError, YAMLWarning } from '../errors.ts' import { Alias } from '../nodes/Alias.ts' +import { isNode } from '../nodes/identity.ts' import { Pair } from '../nodes/Pair.ts' import type { Scalar } from '../nodes/Scalar.ts' import { ToJSContext } from '../nodes/toJS.ts' @@ -16,6 +17,7 @@ import type { ToStringOptions } from '../options.ts' import { Schema } from '../schema/Schema.ts' +import { merge } from '../schema/yaml-1.1/merge.ts' import { stringifyDocument } from '../stringify/stringifyDocument.ts' import { anchorNames, findNewAnchor } from './anchors.ts' import { applyReviver } from './applyReviver.ts' @@ -220,6 +222,31 @@ export class Document { return pair } + /** + * Create a YAML 1.1 `<<` merge pair with the provided value. + * + * Throws if the current schema does not support merge keys, + * or if the value has an invalid shape. + */ + createMergePair( + value: unknown, + options?: CreateNodeOptions + ): Pair, Alias | YAMLMap | YAMLSeq> { + if (!this.schema.tags.includes(merge)) + throw new Error('Merge tags are not supported in this Document') + const v = + !options && isNode(value) ? value : this.createNode(value, options) + if ( + v instanceof Alias || + v instanceof YAMLMap || + (v instanceof YAMLSeq && + v.every(a => a instanceof Alias || a instanceof YAMLMap)) + ) + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return new Pair(merge.resolve(), v) + throw new Error('Invalid merge pair value') + } + /** * Returns item at `key`, or `undefined` if not found. */ diff --git a/src/nodes/Pair.ts b/src/nodes/Pair.ts index 13b20d6d..12d590c1 100644 --- a/src/nodes/Pair.ts +++ b/src/nodes/Pair.ts @@ -6,6 +6,7 @@ import { stringifyPair } from '../stringify/stringifyPair.ts' import { addPairToJSMap } from './addPairToJSMap.ts' import type { ToJSContext } from './toJS.ts' import type { Node, NodeOf, Primitive } from './types.ts' +import type { MapLike } from './YAMLMap.ts' export class Pair< K extends Primitive | Node = Primitive | Node, @@ -32,12 +33,10 @@ export class Pair< return new Pair(key, value) } - toJS( - doc: Document, - ctx: ToJSContext - ): ReturnType { + toJS(doc: Document, ctx: ToJSContext): MapLike { const pair = ctx.mapAsMap ? new Map() : {} - return addPairToJSMap(doc, ctx, pair, this, true) + addPairToJSMap(doc, ctx, pair, this, true) + return pair } toString( diff --git a/src/nodes/addPairToJSMap.ts b/src/nodes/addPairToJSMap.ts index af80f8a4..d8dc757b 100644 --- a/src/nodes/addPairToJSMap.ts +++ b/src/nodes/addPairToJSMap.ts @@ -1,6 +1,5 @@ import type { Document, DocValue } from '../doc/Document.ts' import { warn } from '../log.ts' -import { addMergeToJSMap, isMergeKey } from '../schema/yaml-1.1/merge.ts' import { createStringifyContext } from '../stringify/stringify.ts' import type { Pair } from './Pair.ts' import type { ToJSContext } from './toJS.ts' @@ -13,12 +12,10 @@ export function addPairToJSMap( map: MapLike, { key, value }: Pair, isPlainObject: boolean -): MapLike { - if ('addToJSMap' in key) key.addToJSMap?.(doc, ctx, map, value, isPlainObject) - // TODO: Should drop this special case for bare << handling - else if (isMergeKey(doc, key)) - addMergeToJSMap(doc, ctx, map, value, isPlainObject) - else { +): void { + if ('addToJSMap' in key && typeof key.addToJSMap === 'function') { + key.addToJSMap(doc, ctx, map, value, isPlainObject) + } else { const jsKey = key.toJS(doc, ctx) if (map instanceof Map) { map.set(jsKey, value ? value.toJS(doc, ctx) : value) @@ -41,7 +38,6 @@ export function addPairToJSMap( else map[stringKey] = jsValue } } - return map } function stringifyKey( diff --git a/src/schema/yaml-1.1/merge.ts b/src/schema/yaml-1.1/merge.ts index 7eaf6e90..5dc3d0a7 100644 --- a/src/schema/yaml-1.1/merge.ts +++ b/src/schema/yaml-1.1/merge.ts @@ -17,29 +17,19 @@ const MERGE_KEY = '<<' export const merge: ScalarTag & { identify(value: unknown): boolean + resolve(): Scalar test: (value: string) => boolean } = { identify: value => - value === MERGE_KEY || - (typeof value === 'symbol' && value.description === MERGE_KEY), + typeof value === 'symbol' && value.description === MERGE_KEY, default: 'key', tag: 'tag:yaml.org,2002:merge', test: str => str === MERGE_KEY, - resolve: () => - Object.assign(new Scalar(Symbol(MERGE_KEY)), { - addToJSMap: addMergeToJSMap - }), + resolve: () => Object.assign(new Scalar(Symbol(MERGE_KEY)), { addToJSMap }), stringify: () => MERGE_KEY } -export const isMergeKey = (doc: Document, key: unknown): boolean => - (merge.identify(key) || - (key instanceof Scalar && - (!key.type || key.type === Scalar.PLAIN) && - merge.identify(key.value))) && - Boolean(doc.schema.tags.some(tag => tag.tag === merge.tag && tag.default)) - -export function addMergeToJSMap( +function addToJSMap( doc: Document, ctx: ToJSContext, map: MapLike, diff --git a/tests/doc/anchors.ts b/tests/doc/anchors.ts index db98b352..eea915c5 100644 --- a/tests/doc/anchors.ts +++ b/tests/doc/anchors.ts @@ -304,11 +304,10 @@ describe('merge <<', () => { const doc = parseDocument>('[{ a: A }, { b: B }]', { merge: true }) - const [a, b] = doc.value - const merge = doc.createPair('<<', doc.createAlias(a)) - b.set(merge) - expect(doc.toJS()).toMatchObject([{ a: 'A' }, { a: 'A', b: 'B' }]) - expect(String(doc)).toBe('[ &a1 { a: A }, { b: B, <<: *a1 } ]\n') + const merge = doc.createMergePair({ c: 'C' }) + doc.value[1].set(merge) + expect(doc.toJS()).toMatchObject([{ a: 'A' }, { b: 'B', c: 'C' }]) + expect(String(doc)).toBe('[ { a: A }, { b: B, <<: { c: C } } ]\n') }) test('using customTags:["merge"]', () => { @@ -316,18 +315,7 @@ describe('merge <<', () => { customTags: ['merge'] }) const [a, b] = doc.value - const merge = doc.createPair('<<', doc.createAlias(a)) - b.set(merge) - expect(doc.toJS()).toMatchObject([{ a: 'A' }, { a: 'A', b: 'B' }]) - expect(String(doc)).toBe('[ &a1 { a: A }, { b: B, <<: *a1 } ]\n') - }) - - test('symbol value', () => { - const doc = parseDocument>('[{ a: A }, { b: B }]', { - merge: true - }) - const [a, b] = doc.value - const merge = doc.createPair(Symbol('<<'), doc.createAlias(a)) + const merge = doc.createMergePair(doc.createAlias(a)) b.set(merge) expect(doc.toJS()).toMatchObject([{ a: 'A' }, { a: 'A', b: 'B' }]) expect(String(doc)).toBe('[ &a1 { a: A }, { b: B, <<: *a1 } ]\n') @@ -339,7 +327,7 @@ describe('merge <<', () => { }) const [a, b] = doc.value const alias = doc.createAlias(a, 'AA') - const merge = doc.createPair('<<', alias) + const merge = doc.createMergePair(alias) b.set(merge) expect(doc.toJS()).toMatchObject([{ a: 'A' }, { a: 'A', b: 'B' }]) expect(() => doc.toJS({ maxAliasCount: 0 })).toThrow(ReferenceError) @@ -355,7 +343,7 @@ describe('merge <<', () => { const b = doc.get(2) as YAMLMap seq.anchor = 'SEQ' seq.push(doc.createAlias(a, 'AA')) - b.set(doc.createPair('<<', doc.createAlias(seq))) + b.set(doc.createMergePair(doc.createAlias(seq))) expect(doc.toJS()).toMatchObject([ { a: 'A' }, @@ -369,14 +357,14 @@ describe('merge <<', () => { }) test('require map node', () => { - const doc = parseDocument>>( + const doc = parseDocument>>( '[{ a: A }, { b: B }]', { merge: true } ) const alias = doc.createAlias(doc.get(0)!.get('a')) - doc.get(1)!.set(doc.createPair('<<', alias)) + doc.get(1)!.set(doc.createMergePair(alias)) expect(String(doc)).toBe('[ { a: &a1 A }, { b: B, <<: *a1 } ]\n') expect(() => doc.toJS()).toThrow( 'Merge sources must be maps or map aliases' diff --git a/tests/doc/types.ts b/tests/doc/types.ts index 8186e7b6..075b0bdf 100644 --- a/tests/doc/types.ts +++ b/tests/doc/types.ts @@ -926,10 +926,15 @@ date (00:00:00Z): 2002-12-14\n`) }) }) - describe('!!merge', () => { + describe.for([ + { merge: true }, + { merge: true, resolveKnownTags: false }, + { schema: 'yaml-1.1' as const }, + { version: '1.1' as const } + ])('!!merge with %o', options => { test('alias', () => { const src = '- &a { a: A, b: B }\n- { <<: *a, b: X }\n' - const doc = parseDocument(src, { version: '1.1' }) + const doc = parseDocument(src, options) expect(doc.toJS()).toMatchObject([ { a: 'A', b: 'B' }, { a: 'A', b: 'X' } @@ -940,7 +945,7 @@ date (00:00:00Z): 2002-12-14\n`) test('alias sequence', () => { const src = '- &a { a: A, b: B, c: C }\n- &b { a: X }\n- { <<: [ *b, *a ], b: X }\n' - const doc = parseDocument(src, { version: '1.1' }) + const doc = parseDocument(src, options) expect(doc.toJS()).toMatchObject([ { a: 'A', b: 'B' }, { a: 'X' }, @@ -951,9 +956,9 @@ date (00:00:00Z): 2002-12-14\n`) test('explicit creation', () => { const src = '- { a: A, b: B }\n- { b: X }\n' - const doc = parseDocument>(src, { version: '1.1' }) + const doc = parseDocument>(src, options) const alias = doc.createAlias(doc.get(0)!, 'a') - doc.get(1)!.set(doc.createPair('<<', alias)) + doc.get(1)!.set(doc.createMergePair(alias)) expect(doc.toString()).toBe('- &a { a: A, b: B }\n- { b: X, <<: *a }\n') expect(doc.toJS()).toMatchObject([ { a: 'A', b: 'B' }, @@ -961,17 +966,31 @@ date (00:00:00Z): 2002-12-14\n`) ]) }) - test('creation by duck typing', () => { + test('Creation by duck typing results in quoted "<<" key', () => { const src = '- { a: A, b: B }\n- { b: X }\n' - const doc = parseDocument>(src, { version: '1.1' }) + const doc = parseDocument>(src, options) const alias = doc.createAlias(doc.get(0)!, 'a') doc.get(1)!.set(doc.createPair('<<', alias)) - expect(doc.toString()).toBe('- &a { a: A, b: B }\n- { b: X, <<: *a }\n') + expect(doc.toString()).toBe('- &a { a: A, b: B }\n- { b: X, "<<": *a }\n') expect(doc.toJS()).toMatchObject([ { a: 'A', b: 'B' }, - { a: 'A', b: 'X' } + { b: 'X', '<<': { a: 'A' } } ]) }) + + test('Using an explicit !!str tag on << makes it not a merge key', () => { + const src = '!!str <<: { x: 1 }' + const doc = parseDocument>(src, options) + expect(doc.toJS()).toMatchObject({ '<<': { x: 1 } }) + expect(doc.toString()).toBe('!!str "<<": { x: 1 }\n') + }) + + test('An explicit !!merge tag is retained', () => { + const src = '!!merge <<: { x: 1 }' + const doc = parseDocument>(src, options) + expect(doc.toJS()).toMatchObject({ x: 1 }) + expect(doc.toString()).toBe('!!merge <<: { x: 1 }\n') + }) }) }) From fb71eca10f19b3a2c05523913bc9dab124d3419e Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Mon, 14 Sep 2026 09:54:32 -0400 Subject: [PATCH 2/2] Add MergeKey class (extends Scalar) --- src/doc/Document.ts | 8 ++++---- src/nodes/Scalar.ts | 2 +- src/schema/types.ts | 2 +- src/schema/yaml-1.1/merge.ts | 17 +++++++++++++++-- 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/doc/Document.ts b/src/doc/Document.ts index f1a66070..1f024a5a 100644 --- a/src/doc/Document.ts +++ b/src/doc/Document.ts @@ -17,7 +17,7 @@ import type { ToStringOptions } from '../options.ts' import { Schema } from '../schema/Schema.ts' -import { merge } from '../schema/yaml-1.1/merge.ts' +import { MergeKey } from '../schema/yaml-1.1/merge.ts' import { stringifyDocument } from '../stringify/stringifyDocument.ts' import { anchorNames, findNewAnchor } from './anchors.ts' import { applyReviver } from './applyReviver.ts' @@ -231,8 +231,8 @@ export class Document { createMergePair( value: unknown, options?: CreateNodeOptions - ): Pair, Alias | YAMLMap | YAMLSeq> { - if (!this.schema.tags.includes(merge)) + ): Pair> { + if (this.schema.tags.every(tag => tag.tag !== 'tag:yaml.org,2002:merge')) throw new Error('Merge tags are not supported in this Document') const v = !options && isNode(value) ? value : this.createNode(value, options) @@ -243,7 +243,7 @@ export class Document { v.every(a => a instanceof Alias || a instanceof YAMLMap)) ) // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return new Pair(merge.resolve(), v) + return new Pair(new MergeKey(), v) throw new Error('Invalid merge pair value') } diff --git a/src/nodes/Scalar.ts b/src/nodes/Scalar.ts index 016b2891..257a49a4 100644 --- a/src/nodes/Scalar.ts +++ b/src/nodes/Scalar.ts @@ -74,7 +74,7 @@ export class Scalar implements NodeBase { */ declare addToJSMap?: ( doc: Document, - ctx: ToJSContext | undefined, + ctx: ToJSContext, map: MapLike, value: unknown, isPlainObject: boolean diff --git a/src/schema/types.ts b/src/schema/types.ts index 2fe428e3..1adafe53 100644 --- a/src/schema/types.ts +++ b/src/schema/types.ts @@ -93,7 +93,7 @@ export interface CollectionTag extends TagBase { createNode: (nc: NodeCreator, value: unknown) => Node /** - * The `Node` child class that implements this tag. + * The class that implements this tag. * If set, used to select this tag when stringifying. */ nodeClass?: { diff --git a/src/schema/yaml-1.1/merge.ts b/src/schema/yaml-1.1/merge.ts index 5dc3d0a7..c464634c 100644 --- a/src/schema/yaml-1.1/merge.ts +++ b/src/schema/yaml-1.1/merge.ts @@ -17,7 +17,7 @@ const MERGE_KEY = '<<' export const merge: ScalarTag & { identify(value: unknown): boolean - resolve(): Scalar + resolve(): MergeKey test: (value: string) => boolean } = { identify: value => @@ -25,10 +25,23 @@ export const merge: ScalarTag & { default: 'key', tag: 'tag:yaml.org,2002:merge', test: str => str === MERGE_KEY, - resolve: () => Object.assign(new Scalar(Symbol(MERGE_KEY)), { addToJSMap }), + resolve: () => new MergeKey(), stringify: () => MERGE_KEY } +/** + * A YAML 1.1 `!!merge` key + * + * Using a unique symbol as the value allows for + * multiple instances in a map. + */ +export class MergeKey extends Scalar { + constructor() { + super(Symbol(MERGE_KEY)) + this.addToJSMap = addToJSMap + } +} + function addToJSMap( doc: Document, ctx: ToJSContext,