Repository: eemeli/yaml
Pull request #336 (2021-11-15 00:09:34)

Add commentString option
Related: #322, cc @leipert

With this, it's possible to define a custom stringifier for comments, using a pretty simple API:

```ts
type ToStringOptions = {
  /**
   * Comment stringifier.
   * Output should be valid for the current schema.
   *
   * By default, empty comment lines are left empty,
   * lines consisting of a single space are replaced by `#`,
   * and all other lines are prefixed with a `#`.
   */
  commentString?: (comment: string) => string,
  ...
}
```

This has at least three potential uses:
1. Folding long comment lines.
2. Ensuring that a space always follows the `#`.
3. Using the library to emit non-YAML comments, such as `/* foo */`.

The default value is rather compact:

```ts
const commentString = (comment: string) => comment.replace(/^(?!$)(?: $)?/gm, '#')
```


Patch:
diff --git a/docs/03_options.md b/docs/03_options.md
index 322d745..7615a93 100644
--- a/docs/03_options.md
+++ b/docs/03_options.md
@@ -71,13 +71,14 @@ The `!!value` and `!!yaml` types are not supported.
 
 Used by: `parse()`, `parseDocument()`, `parseAllDocuments()`, `stringify()`, `new Composer()`, `new Document()`, and `doc.setSchema()`
 
-| Name             | Type                                                   | Default                                  | Description                                                                                                                                                             |
-| ---------------- | ------------------------------------------------------ | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| customTags       | `Tag[] ⎮ function`                                     |                                          | Array of [additional tags](#custom-data-types) to include in the schema                                                                                                 |
-| merge            | `boolean`                                              | 1.1:&nbsp;`true` 1.2:&nbsp;`false`       | Enable support for `<<` merge keys. Default value depends on YAML version.                                                                                              |
-| resolveKnownTags | `boolean`                                              | `true`                                   | When using the `'core'` schema, support parsing values with these explicit [YAML 1.1 tags]: `!!binary`, `!!omap`, `!!pairs`, `!!set`, `!!timestamp`. By default `true`. |
-| schema           | `'core' ⎮ 'failsafe' ⎮` `'json' ⎮ 'yaml-1.1' ⎮ string` | 1.1:&nbsp;`'yaml-1.1` 1.2:&nbsp;`'core'` | The base schema to use. Default value depends on YAML version. If using a custom value, `customTags` must be an array of tags.                                          |
-| sortMapEntries   | `boolean ⎮` `(a, b: Pair) => number`                   | `false`                                  | When stringifying, sort map entries. If `true`, sort by comparing key values using the native less-than `<` operator.                                                   |
+| Name             | Type                                 | Default                                  | Description                                                                                                                                                                                                         |
+| ---------------- | ------------------------------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| compat           | `string ⎮ Tag[] ⎮ null`              | `null`                                   | When parsing, warn about compatibility issues with the given schema. When stringifying, use scalar styles that are parsed correctly by the `compat` schema as well as the actual schema.                            |
+| customTags       | `Tag[] ⎮ function`                   |                                          | Array of [additional tags](#custom-data-types) to include in the schema                                                                                                                                             |
+| merge            | `boolean`                            | 1.1:&nbsp;`true` 1.2:&nbsp;`false`       | Enable support for `<<` merge keys. Default value depends on YAML version.                                                                                                                                          |
+| resolveKnownTags | `boolean`                            | `true`                                   | When using the `'core'` schema, support parsing values with these explicit [YAML 1.1 tags]: `!!binary`, `!!omap`, `!!pairs`, `!!set`, `!!timestamp`. By default `true`.                                             |
+| schema           | `string`                             | 1.1:&nbsp;`'yaml-1.1` 1.2:&nbsp;`'core'` | The base schema to use. Default value depends on YAML version. Built-in support is provided for `'core'`, `'failsafe'`, `'json'`, and `'yaml-1.1'`. If using another value, `customTags` must be an array of tags. |
+| sortMapEntries   | `boolean ⎮` `(a, b: Pair) => number` | `false`                                  | When stringifying, sort map entries. If `true`, sort by comparing key values using the native less-than `<` operator.                                                                                               |
 
 [yaml 1.1 tags]: https://yaml.org/type/
 
@@ -147,6 +148,7 @@ Used by: `stringify()` and `doc.toString()`
 | Name                           | Type                             | Default   | Description                                                                                                                                                                                                                   |
 | ------------------------------ | -------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
 | blockQuote                     | `boolean ⎮ 'folded' ⎮ 'literal'` | `true`    | Use block quote styles for scalar values where applicable. Set to `false` to disable block quotes completely.                                                                                                                 |
+| commentString                  | `(comment: string) => string`    |           | Output should be valid for the current schema. By default, empty comment lines are left empty, lines consisting of a single space are replaced by `#`, and all other lines are prefixed with a `#`.                           |
 | defaultKeyType                 | `Type ⎮ null`                    | `null`    | If not `null`, overrides `defaultStringType` for implicit key values.                                                                                                                                                         |
 | defaultStringType              | `Type`                           | `'PLAIN'` | The default type of string literal used to stringify values.                                                                                                                                                                  |
 | directives                     | `boolean ⎮ null`                 | `null`    | Include directives in the output. If `true`, at least the document-start marker `---` is always included. If `false`, no directives or marker is ever included. If `null`, directives and marker may be included if required. |
diff --git a/src/compose/compose-scalar.ts b/src/compose/compose-scalar.ts
index 48e918d..052df9b 100644
--- a/src/compose/compose-scalar.ts
+++ b/src/compose/compose-scalar.ts
@@ -27,7 +27,9 @@ export function composeScalar(
   const tag =
     tagToken && tagName
       ? findScalarTagByName(ctx.schema, value, tagName, tagToken, onError)
-      : findScalarTagByTest(ctx.schema, value, token.type === 'scalar')
+      : token.type === 'scalar'
+      ? findScalarTagByTest(ctx, value, token, onError)
+      : ctx.schema[SCALAR]
 
   let scalar: Scalar
   try {
@@ -84,11 +86,28 @@ function findScalarTagByName(
   return schema[SCALAR]
 }
 
-function findScalarTagByTest(schema: Schema, value: string, apply: boolean) {
-  if (apply) {
-    for (const tag of schema.tags) {
-      if (tag.default && tag.test?.test(value)) return tag
+function findScalarTagByTest(
+  { directives, schema }: ComposeContext,
+  value: string,
+  token: FlowScalar,
+  onError: ComposeErrorHandler
+) {
+  const tag =
+    (schema.tags.find(
+      tag => tag.default && tag.test?.test(value)
+    ) as ScalarTag) || schema[SCALAR]
+
+  if (schema.compat) {
+    const compat =
+      schema.compat.find(tag => tag.default && tag.test?.test(value)) ||
+      schema[SCALAR]
+    if (tag.tag !== compat.tag) {
+      const ts = directives.tagString(tag.tag)
+      const cs = directives.tagString(compat.tag)
+      const msg = `Value may be parsed as either ${ts} or ${cs}`
+      onError(token, 'TAG_RESOLVE_FAILED', msg, true)
     }
   }
-  return schema[SCALAR]
+
+  return tag
 }
diff --git a/src/compose/resolve-block-map.ts b/src/compose/resolve-block-map.ts
index 759f0bd..622ad9d 100644
--- a/src/compose/resolve-block-map.ts
+++ b/src/compose/resolve-block-map.ts
@@ -6,6 +6,7 @@ import type { ComposeContext, ComposeNode } from './compose-node.js'
 import type { ComposeErrorHandler } from './composer.js'
 import { resolveProps } from './resolve-props.js'
 import { containsNewline } from './util-contains-newline.js'
+import { flowIndentCheck } from './util-flow-indent-check.js'
 import { mapIncludes } from './util-map-includes.js'
 
 const startColMsg = 'All mapping items must start at the same column'
@@ -64,6 +65,7 @@ export function resolveBlockMap(
     const keyNode = key
       ? composeNode(ctx, key, keyProps, onError)
       : composeEmptyNode(ctx, keyStart, start, null, keyProps, onError)
+    if (ctx.schema.compat) flowIndentCheck(bm.indent, key, onError)
 
     if (mapIncludes(ctx, map.items, keyNode))
       onError(keyStart, 'DUPLICATE_KEY', 'Map keys must be unique')
@@ -100,6 +102,7 @@ export function resolveBlockMap(
       const valueNode = value
         ? composeNode(ctx, value, valueProps, onError)
         : composeEmptyNode(ctx, offset, sep, null, valueProps, onError)
+      if (ctx.schema.compat) flowIndentCheck(bm.indent, value, onError)
       offset = valueNode.range[2]
       const pair = new Pair(keyNode, valueNode)
       if (ctx.options.keepSourceTokens) pair.srcToken = collItem
diff --git a/src/compose/resolve-block-seq.ts b/src/compose/resolve-block-seq.ts
index 4af69b3..82b4347 100644
--- a/src/compose/resolve-block-seq.ts
+++ b/src/compose/resolve-block-seq.ts
@@ -3,6 +3,7 @@ import type { BlockSequence } from '../parse/cst.js'
 import type { ComposeContext, ComposeNode } from './compose-node.js'
 import type { ComposeErrorHandler } from './composer.js'
 import { resolveProps } from './resolve-props.js'
+import { flowIndentCheck } from './util-flow-indent-check.js'
 
 export function resolveBlockSeq(
   { composeNode, composeEmptyNode }: ComposeNode,
@@ -40,6 +41,7 @@ export function resolveBlockSeq(
     const node = value
       ? composeNode(ctx, value, props, onError)
       : composeEmptyNode(ctx, offset, start, null, props, onError)
+    if (ctx.schema.compat) flowIndentCheck(bs.indent, value, onError)
     offset = node.range[2]
     seq.items.push(node)
   }
diff --git a/src/compose/util-flow-indent-check.ts b/src/compose/util-flow-indent-check.ts
new file mode 100644
index 0000000..eb55719
--- /dev/null
+++ b/src/compose/util-flow-indent-check.ts
@@ -0,0 +1,21 @@
+import { Token } from '../parse/cst'
+import { ComposeErrorHandler } from './composer'
+import { containsNewline } from './util-contains-newline'
+
+export function flowIndentCheck(
+  indent: number,
+  fc: Token | null | undefined,
+  onError: ComposeErrorHandler
+) {
+  if (fc?.type === 'flow-collection') {
+    const end = fc.end[0]
+    if (
+      end.indent === indent &&
+      (end.source === ']' || end.source === '}') &&
+      containsNewline(fc)
+    ) {
+      const msg = 'Flow end indicator should be more indented than parent'
+      onError(end, 'BAD_INDENT', msg, true)
+    }
+  }
+}
diff --git a/src/options.ts b/src/options.ts
index 2a112d7..4055aa4 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -85,6 +85,15 @@ export type DocumentOptions = {
 }
 
 export type SchemaOptions = {
+  /**
+   * When parsing, warn about compatibility issues with the given schema.
+   * When stringifying, use scalar styles that are parsed correctly
+   * by the `compat` schema as well as the actual schema.
+   *
+   * Default: `null`
+   */
+  compat?: string | Tags | null
+
   /**
    * Array of additional tags to include in the schema, or a function that may
    * modify the schema's base tag array.
@@ -209,6 +218,16 @@ export type ToStringOptions = {
    */
   blockQuote?: boolean | 'folded' | 'literal'
 
+  /**
+   * Comment stringifier.
+   * Output should be valid for the current schema.
+   *
+   * By default, empty comment lines are left empty,
+   * lines consisting of a single space are replaced by `#`,
+   * and all other lines are prefixed with a `#`.
+   */
+  commentString?: (comment: string) => string
+
   /**
    * The default type of string literal used to stringify implicit key values.
    * Output may use other types if required to fully represent the value.
diff --git a/src/schema/Schema.ts b/src/schema/Schema.ts
index 0f4b72d..8ebeb37 100644
--- a/src/schema/Schema.ts
+++ b/src/schema/Schema.ts
@@ -11,6 +11,7 @@ const sortMapEntriesByKey = (a: Pair<any>, b: Pair<any>) =>
   a.key < b.key ? -1 : a.key > b.key ? 1 : 0
 
 export class Schema {
+  compat: Array<CollectionTag | ScalarTag> | null
   knownTags: Record<string, CollectionTag | ScalarTag>
   merge: boolean
   name: string
@@ -23,12 +24,18 @@ export class Schema {
   [SEQ]: CollectionTag
 
   constructor({
+    compat,
     customTags,
     merge,
     resolveKnownTags,
     schema,
     sortMapEntries
   }: SchemaOptions) {
+    this.compat = Array.isArray(compat)
+      ? getTags(compat, 'compat')
+      : compat
+      ? getTags(null, compat)
+      : null
     this.merge = !!merge
     this.name = schema || 'core'
     this.knownTags = resolveKnownTags ? coreKnownTags : {}
diff --git a/src/stringify/stringify.ts b/src/stringify/stringify.ts
index f516592..d692959 100644
--- a/src/stringify/stringify.ts
+++ b/src/stringify/stringify.ts
@@ -11,6 +11,7 @@ import {
 import type { Scalar } from '../nodes/Scalar.js'
 import type { ToStringOptions } from '../options.js'
 import type { CollectionTag, ScalarTag } from '../schema/types.js'
+import { stringifyComment } from './stringifyComment.js'
 import { stringifyString } from './stringifyString.js'
 
 export type StringifyContext = {
@@ -40,6 +41,7 @@ export const createStringifyContext = (
   options: Object.assign(
     {
       blockQuote: true,
+      commentString: stringifyComment,
       defaultKeyType: null,
       defaultStringType: 'PLAIN',
       directives: null,
diff --git a/src/stringify/stringifyCollection.ts b/src/stringify/stringifyCollection.ts
index d92f13c..7521e61 100644
--- a/src/stringify/stringifyCollection.ts
+++ b/src/stringify/stringifyCollection.ts
@@ -1,7 +1,7 @@
 import { Collection } from '../nodes/Collection.js'
 import { isNode, isPair } from '../nodes/Node.js'
 import { stringify, StringifyContext } from './stringify.js'
-import { addComment, stringifyComment } from './stringifyComment.js'
+import { indentComment, lineComment } from './stringifyComment.js'
 
 interface StringifyCollectionOptions {
   blockItemPrefix: string
@@ -34,8 +34,11 @@ function stringifyBlockCollection(
     onComment
   }: StringifyCollectionOptions
 ) {
-  const { indent } = ctx
-  ctx = Object.assign({}, ctx, { indent: itemIndent, type: null })
+  const {
+    indent,
+    options: { commentString }
+  } = ctx
+  const itemCtx = Object.assign({}, ctx, { indent: itemIndent, type: null })
 
   let chompKeep = false // flag for the preceding node's status
   const lines: string[] = []
@@ -44,24 +47,24 @@ function stringifyBlockCollection(
     let comment: string | null = null
     if (isNode(item)) {
       if (!chompKeep && item.spaceBefore) lines.push('')
-      addCommentBefore(lines, item.commentBefore, chompKeep)
+      addCommentBefore(ctx, lines, item.commentBefore, chompKeep)
       if (item.comment) comment = item.comment
     } else if (isPair(item)) {
       const ik = isNode(item.key) ? item.key : null
       if (ik) {
         if (!chompKeep && ik.spaceBefore) lines.push('')
-        addCommentBefore(lines, ik.commentBefore, chompKeep)
+        addCommentBefore(ctx, lines, ik.commentBefore, chompKeep)
       }
     }
 
     chompKeep = false
     let str = stringify(
       item,
-      ctx,
+      itemCtx,
       () => (comment = null),
       () => (chompKeep = true)
     )
-    str = addComment(str, itemIndent, comment)
+    if (comment) str += lineComment(str, itemIndent, commentString(comment))
     if (chompKeep && comment) chompKeep = false
     lines.push(blockItemPrefix + str)
   }
@@ -78,7 +81,7 @@ function stringifyBlockCollection(
   }
 
   if (comment) {
-    str += '\n' + stringifyComment(comment, indent)
+    str += '\n' + indentComment(commentString(comment), indent)
     if (onComment) onComment()
   } else if (chompKeep && onChompKeep) onChompKeep()
 
@@ -90,9 +93,17 @@ function stringifyFlowCollection(
   ctx: StringifyContext,
   { flowChars, itemIndent, onComment }: StringifyCollectionOptions
 ) {
-  const { indent, indentStep } = ctx
+  const {
+    indent,
+    indentStep,
+    options: { commentString }
+  } = ctx
   itemIndent += indentStep
-  ctx = Object.assign({}, ctx, { indent: itemIndent, inFlow: true, type: null })
+  const itemCtx = Object.assign({}, ctx, {
+    indent: itemIndent,
+    inFlow: true,
+    type: null
+  })
 
   let reqNewline = false
   let linesAtValue = 0
@@ -102,13 +113,13 @@ function stringifyFlowCollection(
     let comment: string | null = null
     if (isNode(item)) {
       if (item.spaceBefore) lines.push('')
-      addCommentBefore(lines, item.commentBefore, false)
+      addCommentBefore(ctx, lines, item.commentBefore, false)
       if (item.comment) comment = item.comment
     } else if (isPair(item)) {
       const ik = isNode(item.key) ? item.key : null
       if (ik) {
         if (ik.spaceBefore) lines.push('')
-        addCommentBefore(lines, ik.commentBefore, false)
+        addCommentBefore(ctx, lines, ik.commentBefore, false)
         if (ik.comment) reqNewline = true
       }
 
@@ -122,9 +133,9 @@ function stringifyFlowCollection(
     }
 
     if (comment) reqNewline = true
-    let str = stringify(item, ctx, () => (comment = null))
+    let str = stringify(item, itemCtx, () => (comment = null))
     if (i < items.length - 1) str += ','
-    str = addComment(str, itemIndent, comment)
+    if (comment) str += lineComment(str, itemIndent, commentString(comment))
     if (!reqNewline && (lines.length > linesAtValue || str.includes('\n')))
       reqNewline = true
     lines.push(str)
@@ -151,23 +162,21 @@ function stringifyFlowCollection(
   }
 
   if (comment) {
-    str += comment.includes('\n')
-      ? '\n' + stringifyComment(comment, indent)
-      : ` #${comment}`
+    str += lineComment(str, commentString(comment), indent)
     if (onComment) onComment()
   }
   return str
 }
 
 function addCommentBefore(
+  { indent, options: { commentString } }: StringifyContext,
   lines: string[],
   comment: string | null | undefined,
   chompKeep: boolean
 ) {
   if (comment && chompKeep) comment = comment.replace(/^\n+/, '')
   if (comment) {
-    if (/^\n+$/.test(comment)) comment = comment.substring(1)
-    for (const line of comment.match(/^.*$/gm) as string[])
-      lines.push(line === ' ' ? '#' : line ? `#${line}` : '')
+    const ic = indentComment(commentString(comment), indent)
+    lines.push(ic.trimStart()) // Avoid double indent on first line
   }
 }
diff --git a/src/stringify/stringifyComment.ts b/src/stringify/stringifyComment.ts
index f088c63..ef47926 100644
--- a/src/stringify/stringifyComment.ts
+++ b/src/stringify/stringifyComment.ts
@@ -1,18 +1,18 @@
-export const stringifyComment = (comment: string, indent: string) =>
-  /^\n+$/.test(comment)
-    ? comment.substring(1)
-    : comment.replace(/^(?!$)(?: $)?/gm, `${indent}#`)
+/**
+ * Stringifies a comment.
+ *
+ * Empty comment lines are left empty,
+ * lines consisting of a single space are replaced by `#`,
+ * and all other lines are prefixed with a `#`.
+ */
+export const stringifyComment = (str: string) => str.replace(/^(?!$)(?: $)?/gm, '#')
 
-export function addComment(
-  str: string,
-  indent: string,
-  comment?: string | null
-) {
-  return !comment
-    ? str
-    : comment.includes('\n')
-    ? `${str}\n` + stringifyComment(comment, indent)
-    : str.endsWith(' ')
-    ? `${str}#${comment}`
-    : `${str} #${comment}`
+export function indentComment(comment: string, indent: string) {
+  if (/^\n+$/.test(comment)) return comment.substring(1)
+  return indent ? comment.replace(/^(?! *$)/gm, indent) : comment
 }
+
+export const lineComment = (str: string, indent: string, comment: string) =>
+  comment.includes('\n')
+    ? '\n' + indentComment(comment, indent)
+    : (str.endsWith(' ') ? '' : ' ') + comment
diff --git a/src/stringify/stringifyDocument.ts b/src/stringify/stringifyDocument.ts
index 4b0ac4b..ebcf4e4 100644
--- a/src/stringify/stringifyDocument.ts
+++ b/src/stringify/stringifyDocument.ts
@@ -6,7 +6,7 @@ import {
   stringify,
   StringifyContext
 } from './stringify.js'
-import { addComment, stringifyComment } from './stringifyComment.js'
+import { indentComment, lineComment } from './stringifyComment.js'
 
 export function stringifyDocument(
   doc: Readonly<Document>,
@@ -22,19 +22,25 @@ export function stringifyDocument(
     } else if (doc.directives.marker) hasDirectives = true
   }
   if (hasDirectives) lines.push('---')
+
+  const ctx: StringifyContext = createStringifyContext(doc, options)
+  const { commentString } = ctx.options
+
   if (doc.commentBefore) {
     if (lines.length !== 1) lines.unshift('')
-    lines.unshift(stringifyComment(doc.commentBefore, ''))
+    const cs = commentString(doc.commentBefore)
+    lines.unshift(indentComment(cs, ''))
   }
 
-  const ctx: StringifyContext = createStringifyContext(doc, options)
   let chompKeep = false
   let contentComment = null
   if (doc.contents) {
     if (isNode(doc.contents)) {
       if (doc.contents.spaceBefore && hasDirectives) lines.push('')
-      if (doc.contents.commentBefore)
-        lines.push(stringifyComment(doc.contents.commentBefore, ''))
+      if (doc.contents.commentBefore) {
+        const cs = commentString(doc.contents.commentBefore)
+        lines.push(indentComment(cs, ''))
+      }
       // top-level block scalars need to be indented if followed by a comment
       ctx.forceBlockIndent = !!doc.comment
       contentComment = doc.contents.comment
@@ -46,7 +52,8 @@ export function stringifyDocument(
       () => (contentComment = null),
       onChompKeep
     )
-    if (contentComment) body = addComment(body, '', contentComment)
+    if (contentComment)
+      body += lineComment(body, '', commentString(contentComment))
     if (
       (body[0] === '|' || body[0] === '>') &&
       lines[lines.length - 1] === '---'
@@ -63,7 +70,7 @@ export function stringifyDocument(
   if (dc) {
     if ((!chompKeep || contentComment) && lines[lines.length - 1] !== '')
       lines.push('')
-    lines.push(stringifyComment(dc, ''))
+    lines.push(indentComment(commentString(dc), ''))
   }
   return lines.join('\n') + '\n'
 }
diff --git a/src/stringify/stringifyPair.ts b/src/stringify/stringifyPair.ts
index 70955fe..4c87086 100644
--- a/src/stringify/stringifyPair.ts
+++ b/src/stringify/stringifyPair.ts
@@ -2,7 +2,7 @@ import { isCollection, isNode, isScalar, isSeq } from '../nodes/Node.js'
 import type { Pair } from '../nodes/Pair.js'
 import { Scalar } from '../nodes/Scalar.js'
 import { stringify, StringifyContext } from './stringify.js'
-import { addComment, stringifyComment } from './stringifyComment.js'
+import { indentComment, lineComment } from './stringifyComment.js'
 
 export function stringifyPair(
   { key, value }: Readonly<Pair>,
@@ -15,7 +15,7 @@ export function stringifyPair(
     doc,
     indent,
     indentStep,
-    options: { indentSeq, simpleKeys }
+    options: { commentString, indentSeq, simpleKeys }
   } = ctx
   let keyComment = (isNode(key) && key.comment) || null
   if (simpleKeys) {
@@ -64,22 +64,32 @@ export function stringifyPair(
       return explicitKey ? `? ${str}` : str
     }
   } else if ((allNullValues && !simpleKeys) || (value == null && explicitKey)) {
-    if (keyCommentDone) keyComment = null
-    if (chompKeep && !keyComment && onChompKeep) onChompKeep()
-    return addComment(`? ${str}`, ctx.indent, keyComment)
+    str = `? ${str}`
+    if (keyComment && !keyCommentDone) {
+      str += lineComment(str, ctx.indent, commentString(keyComment))
+    } else if (chompKeep && onChompKeep) onChompKeep()
+    return str
   }
 
   if (keyCommentDone) keyComment = null
-  str = explicitKey
-    ? `? ${addComment(str, ctx.indent, keyComment)}\n${indent}:`
-    : addComment(`${str}:`, ctx.indent, keyComment)
+  if (explicitKey) {
+    if (keyComment)
+      str += lineComment(str, ctx.indent, commentString(keyComment))
+    str = `? ${str}\n${indent}:`
+  } else {
+    str = `${str}:`
+    if (keyComment)
+      str += lineComment(str, ctx.indent, commentString(keyComment))
+  }
 
   let vcb = ''
   let valueComment = null
   if (isNode(value)) {
     if (value.spaceBefore) vcb = '\n'
-    if (value.commentBefore)
-      vcb += `\n${stringifyComment(value.commentBefore, ctx.indent)}`
+    if (value.commentBefore) {
+      const cs = commentString(value.commentBefore)
+      vcb += `\n${indentComment(cs, ctx.indent)}`
+    }
     valueComment = value.comment
   } else if (value && typeof value === 'object') {
     value = doc.createNode(value)
@@ -116,13 +126,15 @@ export function stringifyPair(
     const flow = valueStr[0] === '[' || valueStr[0] === '{'
     if (!flow || valueStr.includes('\n')) ws = `\n${ctx.indent}`
   } else if (valueStr === '' || valueStr[0] === '\n') ws = ''
+  str += ws + valueStr
 
   if (ctx.inFlow) {
     if (valueCommentDone && onComment) onComment()
-    return str + ws + valueStr
-  } else {
-    if (valueCommentDone) valueComment = null
-    if (chompKeep && !valueComment && onChompKeep) onChompKeep()
-    return addComment(str + ws + valueStr, ctx.indent, valueComment)
+  } else if (valueComment && !valueCommentDone) {
+    str += lineComment(str, ctx.indent, commentString(valueComment))
+  } else if (chompKeep && onChompKeep) {
+    onChompKeep()
   }
+
+  return str
 }
diff --git a/src/stringify/stringifyString.ts b/src/stringify/stringifyString.ts
index b9b0476..23a4a39 100644
--- a/src/stringify/stringifyString.ts
+++ b/src/stringify/stringifyString.ts
@@ -6,6 +6,7 @@ import {
   FOLD_FLOW,
   FOLD_QUOTED
 } from './foldFlowLines.js'
+import type { CollectionTag, ScalarTag } from '../schema/types.js'
 import type { StringifyContext } from './stringify.js'
 
 interface StringifyScalar {
@@ -170,7 +171,7 @@ function blockString(
   onComment?: () => void,
   onChompKeep?: () => void
 ) {
-  const { lineWidth, blockQuote } = ctx.options
+  const { blockQuote, commentString, lineWidth } = ctx.options
   // 1. Block can't end in whitespace unless the last line is non-empty.
   // 2. Strings consisting of only whitespace are best rendered explicitly.
   if (!blockQuote || /\n[\t ]+$/.test(value) || /^\s*$/.test(value)) {
@@ -236,7 +237,7 @@ function blockString(
   let header =
     (literal ? '|' : '>') + (startWithSpace ? indentSize : '') + chomp
   if (comment) {
-    header += ' #' + comment.replace(/ ?[\r\n]+/g, ' ')
+    header += ' ' + commentString(comment.replace(/ ?[\r\n]+/g, ' '))
     if (onComment) onComment()
   }
 
@@ -306,14 +307,10 @@ function plainString(
   // booleans get parsed with those types in v1.2 (e.g. '42', 'true' & '0.9e-3'),
   // and others in v1.1.
   if (actualString) {
-    for (const tag of ctx.doc.schema.tags) {
-      if (
-        tag.default &&
-        tag.tag !== 'tag:yaml.org,2002:str' &&
-        tag.test?.test(str)
-      )
-        return quotedString(value, ctx)
-    }
+    const test = (tag: CollectionTag | ScalarTag) =>
+      tag.default && tag.tag !== 'tag:yaml.org,2002:str' && tag.test?.test(str)
+    const { compat, tags } = ctx.doc.schema
+    if (tags.some(test) || compat?.some(test)) return quotedString(value, ctx)
   }
   return implicitKey
     ? str


Test patch:
diff --git a/tests/compat.ts b/tests/compat.ts
new file mode 100644
index 0000000..7fe9d95
--- /dev/null
+++ b/tests/compat.ts
@@ -0,0 +1,87 @@
+import { parseDocument, stringify } from 'yaml'
+
+describe('composer compatibility warnings', () => {
+  test('disabled by default', () => {
+    let doc = parseDocument('x: true\ny: off\n')
+    expect(doc.errors).toHaveLength(0)
+    expect(doc.warnings).toHaveLength(0)
+    expect(doc.toJS()).toEqual({ x: true, y: 'off' })
+
+    doc = parseDocument('x: true\ny: off\n', { version: '1.1' })
+    expect(doc.errors).toHaveLength(0)
+    expect(doc.warnings).toHaveLength(0)
+    expect(doc.toJS()).toEqual({ x: true, true: false })
+  })
+
+  test("schema: 'core', compat: 'yaml-1.1'", () => {
+    const doc = parseDocument('x: true\ny: off\n', { compat: 'yaml-1.1' })
+    expect(doc.errors).toHaveLength(0)
+    expect(doc.warnings).toMatchObject([
+      { code: 'TAG_RESOLVE_FAILED', pos: [8, 9] },
+      { code: 'TAG_RESOLVE_FAILED', pos: [11, 14] }
+    ])
+    expect(doc.toJS()).toEqual({ x: true, y: 'off' })
+  })
+
+  test("schema: 'yaml-1.1', compat: 'core'", () => {
+    const doc = parseDocument('x: true\ny: off\n', {
+      schema: 'yaml-1.1',
+      compat: 'core'
+    })
+    expect(doc.errors).toHaveLength(0)
+    expect(doc.warnings).toMatchObject([
+      { code: 'TAG_RESOLVE_FAILED', pos: [8, 9] },
+      { code: 'TAG_RESOLVE_FAILED', pos: [11, 14] }
+    ])
+    expect(doc.toJS()).toEqual({ x: true, true: false })
+  })
+
+  test('single-line flow collection', () => {
+    const doc = parseDocument('foo: { x: 42 }\n', { compat: 'core' })
+    expect(doc.errors).toHaveLength(0)
+    expect(doc.warnings).toHaveLength(0)
+  })
+
+  test('flow collection with end at parent map indent', () => {
+    let doc = parseDocument('x: {\n  y: 42\n}', { compat: 'core' })
+    expect(doc.errors).toHaveLength(0)
+    expect(doc.warnings).toMatchObject([{ code: 'BAD_INDENT', pos: [13, 14] }])
+
+    doc = parseDocument('x:\n {\n  y: 42\n }', { compat: 'core' })
+    expect(doc.errors).toHaveLength(0)
+    expect(doc.warnings).toHaveLength(0)
+  })
+
+  test('flow collection with end at parent seq indent', () => {
+    let doc = parseDocument('- {\n  y: 42\n}', { compat: 'core' })
+    expect(doc.errors).toHaveLength(0)
+    expect(doc.warnings).toMatchObject([{ code: 'BAD_INDENT', pos: [12, 13] }])
+
+    doc = parseDocument('- {\n  y: 42\n }', { compat: 'core' })
+    expect(doc.errors).toHaveLength(0)
+    expect(doc.warnings).toHaveLength(0)
+  })
+})
+
+describe('stringify compatible values', () => {
+  test('disabled by default', () => {
+    let res = stringify({ x: true, y: 'off' })
+    expect(res).toBe('x: true\ny: off\n')
+
+    res = stringify({ x: true, y: 'off' }, { version: '1.1' })
+    expect(res).toBe('x: true\n"y": "off"\n')
+  })
+
+  test("schema: 'core', compat: 'yaml-1.1'", () => {
+    const res = stringify({ x: true, y: 'off' }, { compat: 'yaml-1.1' })
+    expect(res).toBe('x: true\n"y": "off"\n')
+  })
+
+  test("schema: 'yaml-1.1', compat: 'json'", () => {
+    const res = stringify(
+      { x: true, y: 'off' },
+      { schema: 'yaml-1.1', compat: 'json' }
+    )
+    expect(res).toBe('"x": true\n"y": "off"\n')
+  })
+})
diff --git a/tests/doc/comments.js b/tests/doc/comments.js
index a8b627e..5b04485 100644
--- a/tests/doc/comments.js
+++ b/tests/doc/comments.js
@@ -426,6 +426,22 @@ describe('stringify comments', () => {
           #c5
         `)
     })
+
+    test('custom stringifier', () => {
+      const doc = YAML.parseDocument('- a\n- b\n')
+      doc.contents.commentBefore = 'c0'
+      doc.contents.items[0].commentBefore = 'c1'
+      doc.contents.items[1].commentBefore = 'c2\nc3'
+      const commentString = str => str.replace(/^/gm, '// ')
+      expect(doc.toString({ commentString })).toBe(source`
+        // c0
+        // c1
+        - a
+        // c2
+        // c3
+        - b
+      `)
+    })
   })
 
   describe('map entry comments', () => {