Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "js-yaml",
"version": "5.2.2",
"version": "5.2.3",
"description": "YAML 1.2 parser and serializer",
"keywords": [
"yaml",
Expand Down
23 changes: 15 additions & 8 deletions src/ast/presenter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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.
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down
78 changes: 78 additions & 0 deletions test/core/ast/presenter.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading