diff --git a/CHANGELOG.md b/CHANGELOG.md index 22847c76..119300e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [5.2.3] - 2026-08-01 + +### Fixed +- Prevent prototype fallback when resolving tags and mapping entries, #782. +- Resolve `!!timestamp` years 0000-0099 correctly, #775. +- Preserve implicit null mapping values before document markers and reject + unpaired mapping event streams, #784. +- Preserve folded scalar values with tab-indented lines when round-tripping a + parsed AST through `present()`; `dump()` and loading are unaffected, #780. + + ## [5.2.2] - 2026-07-24 ### Fixed @@ -682,6 +693,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - First public release +[5.2.3]: https://github.com/nodeca/js-yaml/compare/5.2.2...5.2.3 [5.2.2]: https://github.com/nodeca/js-yaml/compare/5.2.1...5.2.2 [5.2.1]: https://github.com/nodeca/js-yaml/compare/5.2.0...5.2.1 [5.2.0]: https://github.com/nodeca/js-yaml/compare/5.1.0...5.2.0 diff --git a/package.json b/package.json index 2808f296..27493efa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "js-yaml", - "version": "5.2.2", + "version": "5.2.3", "description": "YAML 1.2 parser and serializer", "keywords": [ "yaml", diff --git a/src/ast/presenter.ts b/src/ast/presenter.ts index 6a424b87..e73d7f9c 100644 --- a/src/ast/presenter.ts +++ b/src/ast/presenter.ts @@ -406,7 +406,7 @@ function chooseScalarStyle (state: PresenterState, string: string, layout: Retur hasFoldableLine = hasFoldableLine || // Foldable line = too long, and not more-indented. (i - previousLineBreak - 1 > lineWidth && - string[previousLineBreak + 1] !== ' ') + !isMoreIndented(string[previousLineBreak + 1])) previousLineBreak = i } } else if (!isPrintable(char)) { @@ -418,7 +418,7 @@ function chooseScalarStyle (state: PresenterState, string: string, layout: Retur // in case the end is missing a \n hasFoldableLine = hasFoldableLine || (shouldTrackWidth && (i - previousLineBreak - 1 > lineWidth && - string[previousLineBreak + 1] !== ' ')) + !isMoreIndented(string[previousLineBreak + 1]))) } // Although every style can represent \n without escaping, prefer block styles // for multiline, since they're more readable and they don't add empty lines. @@ -523,7 +523,7 @@ function blockHeader (string: string, indentPerLevel: number) { // a blank line (two breaks). Encode each run of p literal `\n` as p+1 breaks and // indent the following content line so the continuation isn't read as a new node // (a bare break would yield invalid "deficient indentation" output). -// `foldBlockScalar` can't be reused here: it treats a leading space as a +// `foldBlockScalar` can't be reused here: it treats a leading white space as a // "more-indented" line and suppresses the doubling, which a flow scalar must not. function encodeFlowBreaks (string: string, indent: number) { let nextLF = string.indexOf('\n') @@ -552,6 +552,13 @@ function dropEndingNewline (string: string) { return string[string.length - 1] === '\n' ? string.slice(0, -1) : string } +// A more-indented line is one starting with white space: YAML 1.2.2 [175] +// s-nb-spaced-text, whose [33] s-white is a space *or a tab*. Matches the +// parser's test in getBlockValue(). +function isMoreIndented (char: string) { + return char === ' ' || char === '\t' +} + // Note: a long line without a suitable break point will exceed the width limit. // Pre-conditions: every char in str isPrintable, str.length > 0, width > 0. function foldBlockScalar (string: string, width: number) { @@ -567,7 +574,7 @@ function foldBlockScalar (string: string, width: number) { lineRe.lastIndex = nextLF let result = foldLine(string.slice(0, nextLF), width) // If we haven't reached the first content line yet, don't add an extra \n. - let prevMoreIndented = string[0] === '\n' || string[0] === ' ' + let prevMoreIndented = string[0] === '\n' || isMoreIndented(string[0]) let moreIndented // rest of the lines @@ -576,7 +583,7 @@ function foldBlockScalar (string: string, width: number) { const prefix = match[1] const line = match[2] - moreIndented = (line[0] === ' ') + moreIndented = line !== '' && isMoreIndented(line[0]) result += prefix + ((!prevMoreIndented && !moreIndented && line !== '') ? '\n' : '') + foldLine(line, width) @@ -591,10 +598,10 @@ function foldBlockScalar (string: string, width: number) { // otherwise settles for the shortest line over the limit. // NB. More-indented lines *cannot* be folded, as that would add an extra \n. function foldLine (line: string, width: number) { - if (line === '' || line[0] === ' ') return line + if (line === '' || isMoreIndented(line[0])) return line - // Since a more-indented line adds a \n, breaks can't be followed by a space. - const breakRe = / [^ ]/g // note: the match index will always be <= length-2. + // Since a more-indented line adds a \n, breaks can't be followed by white space. + const breakRe = / [^ \t]/g // note: the match index will always be <= length-2. let match // start is an inclusive index. end, curr, and next are exclusive. let start = 0 diff --git a/test/core/ast/presenter.test.mjs b/test/core/ast/presenter.test.mjs index e151f99d..9bfd284b 100644 --- a/test/core/ast/presenter.test.mjs +++ b/test/core/ast/presenter.test.mjs @@ -128,6 +128,84 @@ describe('ast presenter', () => { assert.equal(present(documents, { schema: CORE_SCHEMA }), '[{a: [1, 2], b: "x\\ny"}]\n') }) + // [source, value] — every source is already the canonical rendering of its + // value, so re-presenting it must reproduce it byte for byte. + + it('keeps a tab-indented line in a folded scalar more-indented', () => { + const cases = [ + // tab-indented line first, last, and between two folded lines + ['k: >\n \t\n detected\n', '\t\ndetected\n'], + ['k: >\n detected\n \tdeep\n', 'detected\n\tdeep\n'], + ['k: >\n a\n \tdeep\n b\n', 'a\n\tdeep\nb\n'], + // controls: literal style and space indentation were always correct + ['k: |\n a\n \tdeep\n b\n', 'a\n\tdeep\nb\n'], + ['k: >\n a\n deep\n b\n', 'a\n deep\nb\n'] + ] + + for (const [source, value] of cases) { + assert.deepEqual(load(source, { schema: CORE_SCHEMA }), { k: value }) + assert.equal(presentParsed(source), source) + } + }) + + it('never folds a tab-indented line over the width limit', () => { + const longTab = `\t${'word '.repeat(20).trim()}` + const source = `k: >\n ${longTab}\n tail\n` + + assert.deepEqual(load(source, { schema: CORE_SCHEMA }), { k: `${longTab}\ntail\n` }) + assert.equal(presentParsed(source), source) + }) + + it('does not fold a folded scalar at a space before a tab', () => { + // Folding here would start the next line with a tab, changing the value. + const spacedTab = `${'a'.repeat(90)} \tzzz` + const source = `k: >\n ${spacedTab}\n` + + assert.deepEqual(load(source, { schema: CORE_SCHEMA }), { k: `${spacedTab}\n` }) + assert.equal(presentParsed(source), source) + }) + + // The presenter re-derives the block header and the fold points from the value, + // so a slip there silently changes the value instead of the formatting. + // Bytes aren't asserted — a source may legitimately use a form the presenter + // wouldn't pick (`>2` vs `>`); values have no such exceptions. + // Alphabet = one char per branch of getBlockValue(); the stretched copy is what + // pushes lines over the width limit, the only place folding happens. + it('preserves block scalar values through an AST round-trip', () => { + let bodies = [''] + let sweep = [] + + for (let length = 0; length < 4; length++) { + bodies = bodies.flatMap(body => ['a', ' ', '\t', '\n'].map(char => body + char)) + sweep = sweep.concat(bodies) + } + + sweep = sweep.concat(sweep.map(body => body.replaceAll('a', 'a'.repeat(90)))) + + let checked = 0 + + for (const body of sweep) { + for (const header of ['|', '|-', '|+', '>', '>-', '>+']) { + const indented = body.split('\n').map(line => line === '' ? '' : ` ${line}`).join('\n') + const source = `k: ${header}\n${indented}\n` + let value + + // Not every generated body makes a valid scalar. + try { + value = load(source, { schema: CORE_SCHEMA }) + } catch { + continue + } + + checked++ + assert.deepEqual(load(presentParsed(source), { schema: CORE_SCHEMA }), value, JSON.stringify(source)) + } + } + + // Guards against the sweep quietly emptying out and passing on nothing. + assert.ok(checked > 1000, `only ${checked} sources parsed`) + }) + it('propagates seqNoIndent to nested sequences', () => { const documents = jsToAst([{ items: [{ a: 1 }] }], CORE_SCHEMA)