diff --git a/CHANGELOG.md b/CHANGELOG.md index 119300e4..9ad195f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,36 @@ 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.3.0] - 2026-08-14 + +This release focuses on reworking the documentation and making small +architectural improvements before moving forward. + +### Added +- Added completely new documentation. +- Exported `DUMP_SCHEMA`, the default schema used by the dumper. +- Added `YAMLException.throwAt()` for throwing an error at a source position. + +### Changed +- Changed flat constant exports to grouped exports: `EVENT_ID`, `SCALAR_STYLE`, + `COLLECTION_STYLE`, and `CHOMPING_MODE`, along with their value types. The old + exports are still preserved, but deprecated. +- Made `identify` mandatory for custom tag definitions. Use + `identify: () => false` for load-only tags. + +### Deprecated +- Deprecated flat constant exports. Use grouped ones instead. + +### Removed +- Removed the `MERGE_KEY` export (not used anymore after last fixes). + +### Fixed +- Validate `<<` sequence items at merge time, so aliased merge sources are + checked too. +- Resolve `<<` outside of a mapping key as the plain string `'<<'`, matching + v4, instead of leaking an internal symbol into the result. + + ## [5.2.3] - 2026-08-01 ### Fixed @@ -171,7 +201,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [4.0.0] - 2021-01-03 ### Changed -- Check migration guide in [docs](docs/) for details of all breaking changes. +- Check migration guide for details of all breaking changes. - Breaking: "unsafe" tags `!!js/function`, `!!js/regexp`, `!!js/undefined` are moved to [js-yaml-js-types](https://github.com/nodeca/js-yaml-js-types) package. - Breaking: removed `safe*` functions. Use `load`, `loadAll`, `dump` @@ -693,6 +723,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - First public release +[5.3.0]: https://github.com/nodeca/js-yaml/compare/5.2.3...5.3.0 [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 diff --git a/README.md b/README.md index b1415f68..f55e27fe 100644 --- a/README.md +++ b/README.md @@ -1,211 +1,47 @@ -JS-YAML - YAML 1.2 parser / writer for JavaScript -================================================= +# js-yaml [![CI](https://github.com/nodeca/js-yaml/actions/workflows/ci.yml/badge.svg)](https://github.com/nodeca/js-yaml/actions/workflows/ci.yml) [![NPM version](https://img.shields.io/npm/v/js-yaml.svg)](https://www.npmjs.org/package/js-yaml) -__[Online Demo](https://nodeca.github.io/js-yaml/)__ +> YAML 1.2 parser and serializer for JavaScript. +__[Online demo](https://nodeca.github.io/js-yaml/)__ -A fast and complete [YAML](https://yaml.org/) parser and writer for JavaScript. -Supports both the 1.2 and 1.1 specs, and passes the entire -[YAML Test Suite](https://github.com/yaml/yaml-test-suite). +- Supports the YAML 1.2 and YAML 1.1 specifications. +- Passes the entire [YAML Test Suite](https://github.com/yaml/yaml-test-suite). -Installation ------------- +> [!NOTE] +> If you are upgrading from v4, see the [v5 migration guide](docs/migrate_v4_to_v5.md). -``` -npm install js-yaml -``` +### [Documentation >>](https://nodeca.github.io/js-yaml/doc/) -Upgrading from v4? See the [v5 migration guide](docs/migrate_v4_to_v5.md). +##### Install + +```bash +npm install js-yaml +``` -API ---- -Here we cover the most useful methods. If you need advanced details (such as -creating your own tags), see the [examples](examples/) for more info. +##### Usage -``` javascript +```js import { load } from 'js-yaml' -import { readFileSync } from 'node:fs' -// Get document, or throw exception on error try { - const doc = load(readFileSync('example.yml', 'utf8')) - console.log(doc) + const document = load('greeting: hello') + console.log(document.greeting) } catch (e) { - console.log(e) + console.error(e) } ``` +```js +import { dump } from 'js-yaml' -### load (string [ , options ]) - -Parses `string` as a single YAML document. Throws `YAMLException` on error. -This function **does not** understand multi-document or empty sources; it throws -an exception on those. - -> [!WARNING] -> When processing untrusted input, see the -> [security considerations](docs/safety.md). - -options: - -- `filename` _(default: null)_ - string to be used as a file path in error - messages. -- `schema` _(default: `CORE_SCHEMA`)_ - specifies a schema to use. - - `FAILSAFE_SCHEMA` - only strings, arrays and plain objects. - - `JSON_SCHEMA` - all JSON-supported types. - - `CORE_SCHEMA` - a superset of `JSON_SCHEMA`, accepting more notations for - the same types. - - `YAML11_SCHEMA` - adds the legacy YAML 1.1 types (`!!binary`, `!!timestamp`, - `!!omap`, `!!pairs`, `!!set`, merge keys `<<`, and the broader 1.1 scalar - notations). -- `json` _(default: false)_ - compatibility with `JSON.parse` behaviour. If - `true`, duplicate keys in a mapping override values rather than throwing an - error. -- `maxDepth` _(default: 100)_ - limits the nesting depth for collections (does - not take aliases into account). -- `maxTotalMergeKeys` _(default: 10000)_ - limits the total number of keys - processed by merge (`<<`) across one `load()` / `loadAll()` call. Set to `-1` - to disable. -- `maxAliases` _(default: -1)_ - limits the number of alias nodes (`*ref`) per - document. Set to `0` to reject all aliases, or to `-1` for no limit. - -> [!NOTE] -> -> The default `CORE_SCHEMA` comes without the `!!merge` tag. You can easily -> enable it if needed: -> -> ``` javascript -> import { load, CORE_SCHEMA, mergeTag } from 'js-yaml' -> -> load(data, { schema: CORE_SCHEMA.withTags(mergeTag) }) -> ``` - -> [!WARNING] -> -> The default `mapTag` is `{}`-object based and does not allow complex keys -> (objects, arrays and so on). That's an intentional choice for convenience. -> Also, non-string scalar keys, such as `null`, numbers or booleans, are -> converted to strings. -> -> In the rare cases where you really need complex keys, use `realMapTag` in the -> schema instead. It stores any key exactly as provided, at the cost of less -> convenient access. - -See [examples](examples/) for advanced customization approaches. - - -### loadAll (string [, options ]) - -Same as `load()`, but understands multi-document sources. Returns an array of -documents. - -``` javascript -import { loadAll } from 'js-yaml' - -console.log(loadAll(data)) -``` - - -### dump (object [ , options ]) - -Serializes `object` as a YAML document. By default it can dump every supported -YAML type, so it throws an exception if you try to dump regexps or functions. -However, you can disable exceptions by setting the `skipInvalid` option to -`true`. - -options: - -- `indent` _(default: 2)_ - indentation width to use (in spaces). -- `flowLevel` _(default: -1)_ - nesting level at which collections switch from - block to flow style (`-1` means never). -- `seqNoIndent` _(default: false)_ - when `true`, does not add an indentation - level to array elements, `␣␣- 1` => `- 1`. -- `seqInlineFirst` _(default: true)_ - when `true`, allows a nested collection - to start on the same line after `-`, `-\n - 1` => `- - 1`. -- `skipInvalid` _(default: false)_ - do not throw on invalid types (such as a - function in the schema). Invalid mapping pairs and sequence items are skipped; - `undefined` sequence items are serialized as `null`. -- `schema` _(default: a `YAML11_SCHEMA`-based schema)_ - specifies a schema to - use. -- `sortKeys` _(default: `false`)_ - if `true`, sort keys when dumping YAML. If a - function, use the function to sort the keys. -- `lineWidth` _(default: `80`)_ - sets the max line width. Set `-1` for unlimited - width. -- `noRefs` _(default: `false`)_ - if `true`, don't convert duplicate objects into - references; inline them instead. -- `quoteStyle` _(`single` or `double`, default: `single`)_ - quoting style to use - when a string needs quotes. -- `forceQuotes` _(default: `false`)_ - if `true`, quote all non-key strings, - using `quoteStyle`. -- `flowBracketPadding` _(default: `false`)_ - add spaces inside flow collection - brackets, `{a: 1}` => `{ a: 1 }`. -- `flowSkipCommaSpace` _(default: `false`)_ - omit the space after commas in - flow collections, `[1, 2]` => `[1,2]`. -- `flowSkipColonSpace` _(default: `false`)_ - omit the space after `:` in flow - mappings, `{a: 1}` => `{a:1}`. -- `quoteFlowKeys` _(default: `false`)_ - quote flow mapping keys, `{a: 1}` => - `{"a": 1}`. -- `tagBeforeAnchor` _(default: `false`)_ - print an explicit tag before an - anchor, `&ref_0 !!set` => `!!set &ref_0`. -- `transform` - a function `(documents: Document[]) => void` that can mutate the - generated AST before it is rendered. - -See [examples](examples/) for advanced customization approaches. - - -Supported YAML types --------------------- - -The list of standard YAML tags and corresponding JavaScript types. See also -[YAML tag discussion](https://pyyaml.org/wiki/YAMLTagDiscussion) and -[YAML types repository](https://yaml.org/type/). - -``` -!!null '' # null -!!bool 'true' # bool -!!int '3...' # number -!!float '3.14...' # number -!!str '...' # string -!!seq [ ... ] # array -!!map { ... } # object (or Map) -``` - -The types below are only available in `YAML11_SCHEMA` (not in the default -`CORE_SCHEMA`): - -``` -!!binary '...base64...' # Uint8Array -!!timestamp 'YYYY-...' # date -!!set { ... } # Set - -# Legacy YAML 1.1 compatibility only; these types cannot be dumped. -!!omap [ ... ] # array of key-value pairs -!!pairs [ ... ] # array of array pairs -``` - -To preserve complex keys in the first position of a `!!pairs` item, replace -the default object-based map with `realMapTag` in the schema. - -**JavaScript-specific tags** - -See [js-yaml-js-types](https://github.com/nodeca/js-yaml-js-types) for -extra types. - - -CLI ---- - -This can be useful sometimes for a quick check. - -``` -npx js-yaml -h +const source = dump({ greeting: 'hello' }) +console.log(source) ``` -Note: the CLI script comes with minimal options, and there are no big plans to -extend it. +[More usage examples](docs/usage.md). diff --git a/docs/custom_tags.md b/docs/custom_tags.md new file mode 100644 index 00000000..241841e7 --- /dev/null +++ b/docs/custom_tags.md @@ -0,0 +1,207 @@ +--- +title: Custom tags +category: Documents +--- + +# Custom tags + +## Custom sequence and mapping tags + +Use `defineSequenceTag()` when a value is represented by positional items, and +`defineMappingTag()` when it is represented by named fields. + +```javascript +import { CORE_SCHEMA, defineMappingTag, defineSequenceTag, dump, load } from 'js-yaml' + +class Point { + constructor (x = 0, y = 0, z = 0) { + this.x = x; this.y = y; this.z = z + } +} + +class Space { + constructor (height = 0, width = 0, points = []) { + this.height = height; this.width = width; this.points = points + } +} + +const schema = CORE_SCHEMA.withTags( + defineSequenceTag('!point', { + create: () => new Point(), + addItem: (point, value, index) => { + if (index === 0) point.x = value + else if (index === 1) point.y = value + else if (index === 2) point.z = value + else throw new Error('!point expects exactly 3 items') + }, + identify: value => value instanceof Point, + represent: point => [point.x, point.y, point.z] + }), + + defineMappingTag('!space', { + create: () => new Space(), + addPair: (space, key, value) => { + if (key === 'height') space.height = value + else if (key === 'width') space.width = value + else if (key === 'points') space.points = value + return '' + }, + has: () => false, + keys: space => Object.keys(space), + get: (space, key) => space[key], + identify: value => value instanceof Space, + represent: space => new Map([ + ['height', space.height], + ['width', space.width], + ['points', space.points] + ]) + }) +) + +// Load and dump custom tags. +const source = ` +spaces: + - !space + height: 1000 + width: 1000 + points: + - !point [10, 43, 23] + - !point [165, 0, 50] +` + +try { + const value = load(source, { schema }) + + value.spaces[0] instanceof Space // true + value.spaces[0].points[0] instanceof Point // true + + console.log(dump(value, { schema, flowLevel: 3 })) +} catch (e) { + console.error(e) +} +``` + +Output: + +```yaml +spaces: + - !space + height: 1000 + width: 1000 + points: [!point [10, 43, 23], !point [165, 0, 50]] +``` + +## Immutable custom tags + +To produce immutable values, build them in two stages. First, collect their +contents in a mutable carrier. Then use `finalize` to create the actual object +from the completed carrier: + +```javascript +import { CORE_SCHEMA, defineSequenceTag, load } from 'js-yaml' + +class ImmutablePoint { + constructor (coordinates) { + this.coordinates = Object.freeze([...coordinates]); Object.freeze(this) + } +} + +const schema = CORE_SCHEMA.withTags(defineSequenceTag('!point', { + create: () => [], + addItem: (carrier, item) => { + if (typeof item !== 'number') return '!point coordinates must be numbers' + carrier.push(item) + }, + finalize: carrier => { + if (carrier.length !== 2) throw new Error('!point expects exactly 2 coordinates') + return new ImmutablePoint(carrier) + }, + identify: value => value instanceof ImmutablePoint, + represent: point => point.coordinates +})) + +try { + const point = load('!point [10, 20]', { schema }) + console.log(point) +} catch (e) { + console.error(e) +} +``` + +Output: + +```text +ImmutablePoint { coordinates: [ 10, 20 ] } +``` + +## Unknown custom tags + +If tag names are not known in advance, set `matchByTagPrefix` on one definition +for each YAML node kind. Use `representTagName` to preserve the matched tag name +when dumping. + +```javascript +import { CORE_SCHEMA, defineMappingTag, defineScalarTag, defineSequenceTag, dump, load } from 'js-yaml' + +class TaggedValue { + constructor (tagName, nodeKind, value) { + this.tagName = tagName; this.nodeKind = nodeKind; this.value = value + } +} + +const schema = CORE_SCHEMA.withTags( + defineScalarTag('!', { + matchByTagPrefix: true, + resolve: (source, _isExplicit, tagName) => new TaggedValue(tagName, 'scalar', source), + identify: value => value instanceof TaggedValue && value.nodeKind === 'scalar', + representTagName: value => value.tagName, + represent: value => value.value + }), + + defineSequenceTag('!', { + matchByTagPrefix: true, + create: tagName => new TaggedValue(tagName, 'sequence', []), + addItem: (tagged, item) => { tagged.value.push(item) }, + identify: value => value instanceof TaggedValue && value.nodeKind === 'sequence', + representTagName: value => value.tagName, + represent: value => value.value + }), + + defineMappingTag('!', { + matchByTagPrefix: true, + create: tagName => new TaggedValue(tagName, 'mapping', new Map()), + addPair: (tagged, key, value) => { + tagged.value.set(key, value) + return '' + }, + has: (tagged, key) => tagged.value.has(key), + keys: tagged => tagged.value.keys(), + get: (tagged, key) => tagged.value.get(key), + identify: value => value instanceof TaggedValue && value.nodeKind === 'mapping', + representTagName: value => value.tagName, + represent: value => value.value + }) +) + +// Load and dump arbitrary tags. +const source = ` +scalar: !unknown_scalar_tag foo bar +sequence: !unknown_sequence_tag [1, 2, 3] +mapping: !unknown_mapping_tag { foo: 1, bar: 2 } +` + +try { + const value = load(source, { schema }) + console.log(dump(value, { schema, flowLevel: 1 })) +} catch (e) { + console.error(e) +} +``` + +Output: + +```yaml +scalar: !unknown_scalar_tag foo bar +sequence: !unknown_sequence_tag [1, 2, 3] +mapping: !unknown_mapping_tag {foo: 1, bar: 2} +``` diff --git a/examples/transform.mjs b/docs/drafts/transform.mjs similarity index 100% rename from examples/transform.mjs rename to docs/drafts/transform.mjs diff --git a/docs/migrate_v3_to_v4.md b/docs/migrate_v3_to_v4.md index f1e910a4..dfa3e04d 100644 --- a/docs/migrate_v3_to_v4.md +++ b/docs/migrate_v3_to_v4.md @@ -15,8 +15,12 @@ yaml.safeDump(obj) js-yaml v4: ```js -yaml.load(str) -yaml.loadAll(str) +try { + yaml.load(str) + yaml.loadAll(str) +} catch (e) { + console.error(e) +} yaml.dump(obj) ``` @@ -28,8 +32,12 @@ yaml.dump(obj) js-yaml v3: ```js -yaml.load(str) -yaml.loadAll(str) +try { + yaml.load(str) + yaml.loadAll(str) +} catch (e) { + console.error(e) +} yaml.dump(obj) ``` @@ -38,8 +46,12 @@ js-yaml v4: ```js let schema = yaml.DEFAULT_SCHEMA.extend(require('js-yaml-js-types').all) -yaml.load(str, { schema }) -yaml.loadAll(str, { schema }) +try { + yaml.load(str, { schema }) + yaml.loadAll(str, { schema }) +} catch (e) { + console.error(e) +} yaml.dump(obj, { schema }) ``` @@ -85,10 +97,15 @@ let data = '0123456789' // typeof data === 'string' str = require('js-yaml@3').dump('0123456789') -data = require('js-yaml@4').load(str) -// data will be 123456789 -// typeof data === 'number' +try { + data = require('js-yaml@4').load(str) + + // data will be 123456789 + // typeof data === 'number' +} catch (e) { + console.error(e) +} ``` You can check for these patterns in your data using regexp like this: diff --git a/docs/migrate_v4_to_v5.md b/docs/migrate_v4_to_v5.md index 5cd537e1..420b2870 100644 --- a/docs/migrate_v4_to_v5.md +++ b/docs/migrate_v4_to_v5.md @@ -1,13 +1,18 @@ +--- +title: Migrate to v5 +category: Documents +--- + # Migration guide from `js-yaml@4` to `js-yaml@5` - [Base](#base) -- [`load` / `loadAll`](#load--loadall) +- [load / loadAll](#load--loadall) - [Removed options](#removed-options) - [Empty input throws](#empty-input-throws) - [Schema](#schema) - [Mapping keys](#mapping-keys) - - [`!!set`](#set) -- [`dump`](#dump) + - [!!set](#set) +- [dump](#dump) - [Removed options](#removed-options-1) - [Replacing `styles`](#replacing-styles) - [Custom types](#custom-types) @@ -24,12 +29,20 @@ swap the import and you're done. ```js // v4 const yaml = require('js-yaml') -yaml.load(source) +try { + yaml.load(source) +} catch (e) { + console.error(e) +} yaml.dump(data) // v5 import { load, dump } from 'js-yaml' -load(source) +try { + load(source) +} catch (e) { + console.error(e) +} dump(data) ``` @@ -45,7 +58,11 @@ instead: ```js import * as yaml from 'js-yaml' -yaml.load(source) +try { + yaml.load(source) +} catch (e) { + console.error(e) +} yaml.dump(data) ``` @@ -57,7 +74,7 @@ Exports are now flat. The `types` namespace, the `Type` class and `DEFAULT_SCHEMA` are gone, and internal `js-yaml/lib/...` imports no longer resolve. If you used any of those, read on. -## `load` / `loadAll` +## load / loadAll ### Removed options @@ -83,7 +100,11 @@ In practice the most common compatibility difference is the missing `!!merge` ```js import { load, CORE_SCHEMA, mergeTag } from 'js-yaml' -load(source, { schema: CORE_SCHEMA.withTags(mergeTag) }) +try { + load(source, { schema: CORE_SCHEMA.withTags(mergeTag) }) +} catch (e) { + console.error(e) +} ``` If you need the legacy YAML 1.1 types (`!!timestamp`, `!!binary`, `!!set`) or @@ -92,7 +113,11 @@ its slightly different int/float/boolean syntax, pass `YAML11_SCHEMA`: ```js import { load, YAML11_SCHEMA } from 'js-yaml' -load(source, { schema: YAML11_SCHEMA }) +try { + load(source, { schema: YAML11_SCHEMA }) +} catch (e) { + console.error(e) +} ``` To register custom tags on a schema, `Schema.extend()` is now @@ -109,7 +134,11 @@ You can restore the old behavior, though it's not recommended: ```js import { load, CORE_SCHEMA, legacyMapTag } from 'js-yaml' -load(source, { schema: CORE_SCHEMA.withTags(legacyMapTag) }) +try { + load(source, { schema: CORE_SCHEMA.withTags(legacyMapTag) }) +} catch (e) { + console.error(e) +} ``` Alternatively, get real `Map` instances with no key restrictions — then it's @@ -118,19 +147,27 @@ your job to handle them: ```js import { load, CORE_SCHEMA, realMapTag } from 'js-yaml' -load(source, { schema: CORE_SCHEMA.withTags(realMapTag) }) +try { + load(source, { schema: CORE_SCHEMA.withTags(realMapTag) }) +} catch (e) { + console.error(e) +} ``` -### `!!set` +### !!set The YAML 1.1 `!!set` tag now produces a `Set` instead of an object of `null`s: ```js -load('!!set { one, two }', { schema: YAML11_SCHEMA }) -// Set { 'one', 'two' } +try { + load('!!set { one, two }', { schema: YAML11_SCHEMA }) + // Set { 'one', 'two' } +} catch (e) { + console.error(e) +} ``` -## `dump` +## dump By default dump now uses `YAML11_SCHEMA`, slightly extended with YAML 1.2 `0o...` ints and exponent-only floats. This guarantees safe quoting for all YAML @@ -185,6 +222,8 @@ both loading and dumping, and you register it via `schema.withTags(...)`. The model is different, not just renamed: instead of one `construct(data)`, the collection tags build incrementally (`create` + `addItem` / `addPair`), scalars return the value or `NOT_RESOLVED`, and `instanceOf` becomes `identify`. +`identify` is required because it explicitly controls whether the tag may be +selected when dumping. For a load-only tag, use `identify: () => false`. ```js // v4 @@ -225,7 +264,5 @@ const pointTag = defineSequenceTag('!point', { An anchored tag with a temporary carrier cannot recursively alias itself, because its result does not exist until `finalize` returns. Such input throws. -This only sketches the shape. See -[examples/custom_tags.mjs](../examples/custom_tags.mjs) for the full method set -and [examples/custom_tags_immutable.mjs](../examples/custom_tags_immutable.mjs) -for carrier finalization and its recursive-alias limitation. +This only sketches the shape. See [Custom tags](custom_tags.md) for complete +documentation. diff --git a/docs/safety.md b/docs/safety.md index d7771218..47f1bf34 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -1,3 +1,8 @@ +--- +title: Safety +category: Main +--- + # Safety notes for untrusted input The YAML spec by design allows compact documents that produce objects which are @@ -47,9 +52,13 @@ function guardNodeCount(root, limit) { } } -const data = yaml.load(input) -guardNodeCount(data, 100000) -const json = JSON.stringify(data) +try { + const data = yaml.load(input) + guardNodeCount(data, 100000) + const json = JSON.stringify(data) +} catch (e) { + console.error(e) +} ``` Note: aliases that point to the same node are counted every time they appear diff --git a/docs/schemas_info.md b/docs/schemas_info.md new file mode 100644 index 00000000..fb2cd09c --- /dev/null +++ b/docs/schemas_info.md @@ -0,0 +1,25 @@ +--- +title: Schemas info +category: Schemas +--- + +# Schemas + +Schemas were introduced in YAML 1.2 as a convenient way to define a set of +tags. + +YAML 1.2 Schemas: + +- {@link CORE_SCHEMA} (default) +- {@link JSON_SCHEMA} +- {@link FAILSAFE_SCHEMA} + +Additional Schemas: + +- {@link YAML11_SCHEMA} — YAML 1.1 did not define schemas; + this convenience schema provides the corresponding set of tags. +- {@link DUMP_SCHEMA} — combines all supported YAML 1.1 and YAML 1.2 type + variants for better compatibility. + +You will usually use one of the preferred schemas above and customize it with +{@link Schema.withTags | Schema.withTags()}. diff --git a/docs/tags_info.md b/docs/tags_info.md new file mode 100644 index 00000000..bcae0362 --- /dev/null +++ b/docs/tags_info.md @@ -0,0 +1,17 @@ +--- +title: Tags info +category: Tags +--- + +# Tags + +Tags contain implementations for all supported YAML versions and schemas. +Since tag rules may differ slightly between versions and schemas, a single +YAML tag can have several variants. + +Since predefined schemas are available, tags are usually needed only when: + +- you need to add {@link mergeTag} or replace {@link mapTag} with + {@link realMapTag}; +- you want to quickly customize an existing tag without defining it from + scratch. diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 00000000..2255230d --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,164 @@ +--- +title: Usage examples +category: Main +--- + +# Usage examples + +## Load + +### Basic usage + +Read files explicitly as UTF-8 and pass the filename to `load()` so parse +errors identify the input source: + +```javascript +import { readFileSync } from 'node:fs' +import { load } from 'js-yaml' + +const filename = 'config.yml' + +try { + const source = readFileSync(filename, 'utf8') + const config = load(source, { filename }) + + console.log(`Starting ${config.service.name} on port ${config.service.port}`) +} catch (e) { + console.error(e instanceof Error ? e.message : String(e)) + process.exitCode = 1 +} +``` + +For a stream containing several YAML documents, use `loadAll()`: + +```javascript +import { loadAll } from 'js-yaml' + +try { + const documents = loadAll(` +--- +name: api +port: 8080 +--- +name: worker +concurrency: 4 +`) + + console.log(documents) +} catch (e) { + console.error(e) +} +``` + +Unlike `load()`, `loadAll()` accepts empty and multi-document streams. It +returns an array; an empty stream produces an empty array. + +### Schema customization + +The most frequent cases are: + +- enabling merge keys; +- replacing the default object maps with native `Map` instances. + +Here is how to do that: + +```javascript +import { CORE_SCHEMA, mergeTag, realMapTag } from 'js-yaml' + +const schema = CORE_SCHEMA.withTags(mergeTag, realMapTag) +``` + +### Alternate map tag + +For the most robust object-based mappings, use objects without a prototype and +accept only string keys: + +```javascript +import { CORE_SCHEMA, load, mapTag } from 'js-yaml' + +const schema = CORE_SCHEMA.withTags({ + ...mapTag, + create: () => Object.create(null), + addPair: (container, key, value) => { + if (typeof key !== 'string') return 'object-based map supports only string keys' + container[key] = value + return '' + }, + has: (container, key) => typeof key === 'string' && key in container, + get: (container, key) => { + if (typeof key !== 'string' || !(key in container)) return null + return container[key] + } +}) + +try { + const config = load('{ enabled: true, level: 2 }', { schema }) +} catch (e) { + console.error(e) +} +``` + +This is not the default because objects without a prototype break many common +usage examples and equality checks. Still, consider using `realMapTag` instead. + +## Dump + +### Basic usage + +`dump()` returns a YAML string with a trailing newline: + +```javascript +import { dump } from 'js-yaml' + +const config = { + service: { name: 'api', ports: [8080, 8081] }, + logging: { level: 'info' } +} + +const output = dump(config, { lineWidth: 100 }) + +console.log(output) +``` + +Unsupported values, such as functions, cause an exception by default. Use +`skipInvalid: true` only when silently dropping those values is intentional. + +### Formatting scalar values + +Use `DUMP_SCHEMA` as the base so its compatibility and quoting rules remain +active. + +```javascript +import { DUMP_SCHEMA, boolYaml11Tag, dump, nullYaml11Tag } from 'js-yaml' + +// Instead of defining a new tag, we override a single method of clone +// in one line. That's compact and simple. +const schema = DUMP_SCHEMA.withTags( + { ...boolYaml11Tag, represent: value => value ? 'TRUE' : 'FALSE' }, + { ...nullYaml11Tag, represent: () => '' } +) + +const output = dump({ + enabled: true, + archived: false, + parent: null +}, { schema }) + +console.log(output) +``` + +Output: + +```yaml +enabled: TRUE +archived: FALSE +parent: +``` + +## CLI + +The CLI is intentionally minimal, with no plans to extend its feature set. + +```shell +npx js-yaml -h +``` diff --git a/examples/custom_tags.mjs b/examples/custom_tags.mjs deleted file mode 100644 index a97cc132..00000000 --- a/examples/custom_tags.mjs +++ /dev/null @@ -1,81 +0,0 @@ -import assert from 'node:assert/strict' -import { CORE_SCHEMA, defineMappingTag, defineSequenceTag, dump, load } from 'js-yaml' - -class Point { - constructor (x = 0, y = 0, z = 0) { - this.x = x - this.y = y - this.z = z - } -} - -class Space { - constructor (height = 0, width = 0, points = []) { - this.height = height - this.width = width - this.points = points - } -} - -const schema = CORE_SCHEMA.withTags( - defineSequenceTag('!point', { - create: () => new Point(), - addItem: (point, value, index) => { - if (index === 0) point.x = value - else if (index === 1) point.y = value - else if (index === 2) point.z = value - else throw new Error('!point expects exactly 3 items') - }, - identify: value => value instanceof Point, - represent: point => [point.x, point.y, point.z] - }), - - defineMappingTag('!space', { - create: () => new Space(), - addPair: (space, key, value) => { - if (key === 'height') space.height = value - else if (key === 'width') space.width = value - else if (key === 'points') space.points = value - return '' - }, - has: () => false, - keys: space => Object.keys(space), - get: (space, key) => space[key], - identify: value => value instanceof Space, - represent: space => new Map([ - ['height', space.height], - ['width', space.width], - ['points', space.points] - ]) - }) -) - -const source = ` -spaces: - - !space - height: 1000 - width: 1000 - points: - - !point [10, 43, 23] - - !point [165, 0, 50] -` - -assert.deepStrictEqual(load(source, { schema }), { - spaces: [ - new Space(1000, 1000, [ - new Point(10, 43, 23), - new Point(165, 0, 50) - ]) - ] -}) - -const actual = dump(load(source, { schema }), { schema, flowLevel: 3 }) - -const expected = `spaces: - - !space - height: 1000 - width: 1000 - points: [!point [10, 43, 23], !point [165, 0, 50]] -` - -assert.strictEqual(actual, expected) diff --git a/examples/custom_tags_immutable.mjs b/examples/custom_tags_immutable.mjs deleted file mode 100644 index c471a41d..00000000 --- a/examples/custom_tags_immutable.mjs +++ /dev/null @@ -1,44 +0,0 @@ -import assert from 'node:assert/strict' -import { CORE_SCHEMA, defineSequenceTag, dump, load } from 'js-yaml' - -// Immutable values cannot be populated item by item. Build a mutable carrier -// first, then turn the completed carrier into the final value. -class ImmutablePoint { - constructor (coordinates) { - this.coordinates = Object.freeze([...coordinates]) - Object.freeze(this) - } -} - -const schema = CORE_SCHEMA.withTags(defineSequenceTag('!point', { - create: () => [], - addItem: (carrier, item) => { carrier.push(item) }, - finalize: carrier => { - if (carrier.length !== 2) throw new Error('!point expects exactly 2 coordinates') - return new ImmutablePoint(carrier) - }, - identify: value => value instanceof ImmutablePoint, - represent: point => point.coordinates -})) - -const source = ` -point: &point !point [10, 20] -samePoint: *point -` - -const value = load(source, { schema }) - -assert.deepStrictEqual(value.point, new ImmutablePoint([10, 20])) -assert.strictEqual(value.samePoint, value.point) -assert.equal(dump(value.point, { schema }), '!point\n- 10\n- 20\n') -assert.throws( - () => load('!point [10]', { schema }), - /!point expects exactly 2 coordinates/ -) - -// A recursive alias needs the final object before finalize() can create it, so -// recursive aliases are intentionally rejected for tags that use finalize(). -assert.throws( - () => load('&point !point [*point]', { schema }), - /recursive alias "point" is not supported for tag !point because it uses finalize\(\)/ -) diff --git a/examples/format_scalars.mjs b/examples/format_scalars.mjs deleted file mode 100644 index 05ac7049..00000000 --- a/examples/format_scalars.mjs +++ /dev/null @@ -1,25 +0,0 @@ -import assert from 'node:assert/strict' -import { CORE_SCHEMA, boolCoreTag, dump, intCoreTag, nullCoreTag } from 'js-yaml' - -const schema = CORE_SCHEMA.withTags( - // Instead of defining a new tag, we override a single method of clone - // in one line. That's compact and simple. - { ...boolCoreTag, represent: value => value ? 'TRUE' : 'FALSE' }, - { ...intCoreTag, represent: value => value >= 0 ? `0x${value.toString(16)}` : `-0x${(-value).toString(16)}` }, - { ...nullCoreTag, represent: () => '' } -) - -const actual = dump({ - enabled: true, - archived: false, - mask: 255, - parent: null -}, { schema }) - -const expected = `enabled: TRUE -archived: FALSE -mask: 0xff -parent: -` - -assert.strictEqual(actual, expected) diff --git a/examples/map.mjs b/examples/map.mjs deleted file mode 100644 index 29cc3788..00000000 --- a/examples/map.mjs +++ /dev/null @@ -1,45 +0,0 @@ -import assert from 'node:assert/strict' -import { CORE_SCHEMA, dump, load, mapTag, realMapTag } from 'js-yaml' - -const realMapSchema = CORE_SCHEMA.withTags(realMapTag) - -// Use a realMapTag when need complex keys, not just strings. -const result = load('{ foo: 1, bar: 2 }: value', { schema: realMapSchema }) - -assert.deepStrictEqual(result, new Map([ - [ - new Map([ - ['foo', 1], - ['bar', 2] - ]), - 'value' - ] -])) - -// Object without prototype is more safe in theory. But it can break a low of -// assert.deepStrictEqual checks at user side. So, by default, use `{}` Object. -// But you can enforce more strict objects, if you wish. -const noprotoMapSchema = CORE_SCHEMA.withTags({ - ...mapTag, - create: () => Object.create(null), - addPair: (container, key, value) => { - if (key !== null && typeof key === 'object') { - return 'object-based map does not support complex keys' - } - container[key] = value // safe to write anything for such objects - return '' - } -}) - -const result2 = load('{ enabled: true, level: 2 }', { schema: noprotoMapSchema }) - -assert.strictEqual(Object.getPrototypeOf(result2), null) -assert.deepStrictEqual(result2, Object.assign(Object.create(null), { - enabled: true, - level: 2 -})) - -assert.strictEqual(dump(result2, { schema: noprotoMapSchema }), -`enabled: true -level: 2 -`) diff --git a/examples/merge.mjs b/examples/merge.mjs deleted file mode 100644 index cf450a06..00000000 --- a/examples/merge.mjs +++ /dev/null @@ -1,26 +0,0 @@ -import assert from 'node:assert/strict' -import { CORE_SCHEMA, load, mergeTag } from 'js-yaml' - -// Merge keys are not part of CORE_SCHEMA by default, but can be enabled -// explicitly when you need YAML's `<<` merge feature. - -const source = ` -defaults: &defaults - adapter: postgres - host: localhost - -development: - <<: *defaults - database: app_development -` -assert.deepStrictEqual(load(source, { schema: CORE_SCHEMA.withTags(mergeTag) }), { - defaults: { - adapter: 'postgres', - host: 'localhost' - }, - development: { - adapter: 'postgres', - host: 'localhost', - database: 'app_development' - } -}) diff --git a/examples/unknown_tags.mjs b/examples/unknown_tags.mjs deleted file mode 100644 index faa34a09..00000000 --- a/examples/unknown_tags.mjs +++ /dev/null @@ -1,72 +0,0 @@ -import assert from 'node:assert/strict' -import { CORE_SCHEMA, defineMappingTag, defineScalarTag, defineSequenceTag, dump, load } from 'js-yaml' - -class TaggedValue { - constructor (tagName, nodeKind, value) { - this.tagName = tagName - this.nodeKind = nodeKind - this.value = value - } -} - -const schema = CORE_SCHEMA.withTags( - defineScalarTag('!', { - matchByTagPrefix: true, - resolve: (source, _isExplicit, tagName) => new TaggedValue(tagName, 'scalar', source), - identify: value => value instanceof TaggedValue && value.nodeKind === 'scalar', - representTagName: value => value.tagName, - represent: value => value.value - }), - - defineSequenceTag('!', { - matchByTagPrefix: true, - create: tagName => new TaggedValue(tagName, 'sequence', []), - addItem: (tagged, item) => { - tagged.value.push(item) - }, - identify: value => value instanceof TaggedValue && value.nodeKind === 'sequence', - representTagName: value => value.tagName, - represent: value => value.value - }), - - defineMappingTag('!', { - matchByTagPrefix: true, - create: tagName => new TaggedValue(tagName, 'mapping', new Map()), - addPair: (tagged, key, value) => { - tagged.value.set(key, value) - return '' - }, - has: (tagged, key) => tagged.value.has(key), - keys: tagged => tagged.value.keys(), - get: (tagged, key) => tagged.value.get(key), - identify: value => value instanceof TaggedValue && value.nodeKind === 'mapping', - representTagName: value => value.tagName, - represent: value => value.value - }) -) - -const source = ` -scalar: !unknown_scalar_tag foo bar -sequence: !unknown_sequence_tag [1, 2, 3] -mapping: !unknown_mapping_tag { foo: 1, bar: 2 } -` - -const loaded = load(source, { schema }) - -assert.deepStrictEqual(loaded, { - scalar: new TaggedValue('!unknown_scalar_tag', 'scalar', 'foo bar'), - sequence: new TaggedValue('!unknown_sequence_tag', 'sequence', [1, 2, 3]), - mapping: new TaggedValue('!unknown_mapping_tag', 'mapping', new Map([ - ['foo', 1], - ['bar', 2] - ])) -}) - -const actual = dump(loaded, { schema, flowLevel: 1 }) - -const expected = `scalar: !unknown_scalar_tag foo bar -sequence: !unknown_sequence_tag [1, 2, 3] -mapping: !unknown_mapping_tag {foo: 1, bar: 2} -` - -assert.strictEqual(actual, expected) diff --git a/package.json b/package.json index 3af61d3c..abd554a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "js-yaml", - "version": "5.2.3", + "version": "5.3.0", "description": "YAML 1.2 parser and serializer", "keywords": [ "yaml", @@ -54,14 +54,14 @@ "lint": "eslint .", "lint-fix": "eslint . --fix", "type-check": "tsc --noEmit", - "test": "npm run lint && npm run build && npm run type-check && npm run test:examples && npm run spec:get && node --test 'test/core/**/*.test.mjs' 'test/spec/*.test.mjs'", - "test:examples": "node --test 'examples/*.mjs'", + "test": "npm run lint && npm run build && npm run type-check && npm run spec:get && node --test 'test/core/**/*.test.mjs' 'test/spec/*.test.mjs'", "test:spec": "npm run build && npm run spec:get && node test/spec/spec.test.mjs", "spec:get": "node support/get-yaml-test-suite.mjs", "spec:update": "node support/get-yaml-test-suite.mjs update", "coverage": "npm run build && npm run spec:get && c8 --include 'dist/js-yaml.mjs' --include 'src/**' -r text -r html -r lcov node --test 'test/core/**/*.test.mjs' 'test/spec/*.test.mjs'", "build": "node support/build-dist.mjs", - "demo": "npm run lint && node support/build_demo.mjs && typedoc", + "demo": "npm run lint && node support/build_demo.mjs && npm run doc", + "doc": "typedoc", "demo:publish": "npm run demo && gh-pages -d demo -f", "benchmark:deps": "npm install --prefix benchmark/extra/", "prepack": "npm test && npm run build && npm run demo", diff --git a/src/ast/from_events.ts b/src/ast/from_events.ts index a802faa4..1ecfe38a 100644 --- a/src/ast/from_events.ts +++ b/src/ast/from_events.ts @@ -4,18 +4,9 @@ // original styles, tags and anchors, so parsed YAML can be re-dumped faithfully. import { - EVENT_ALIAS, - EVENT_DOCUMENT, - EVENT_MAPPING, - EVENT_POP, - EVENT_SCALAR, - EVENT_SEQUENCE, - SCALAR_STYLE_PLAIN, - SCALAR_STYLE_SINGLE_QUOTED, - SCALAR_STYLE_DOUBLE_QUOTED, - SCALAR_STYLE_LITERAL_BLOCK, - SCALAR_STYLE_FOLDED_BLOCK, - COLLECTION_STYLE_FLOW, + EVENT_ID, + SCALAR_STYLE, + COLLECTION_STYLE, type Event, type MappingEvent, type ScalarEvent, @@ -23,7 +14,6 @@ import { } from '../parser/events.ts' import { getScalarValue } from '../parser/parser_scalar.ts' import { type Schema } from '../schema.ts' -import { NOT_RESOLVED } from '../tag.ts' import { Style, type Node, @@ -56,7 +46,10 @@ type Frame = DocumentFrame | SequenceFrame | MappingFrame /** @category AST */ interface FromEventsOptions { + /** Source text referenced by offsets in `events`. */ source: string + + /** Schema used to resolve implicit scalar tags. */ schema: Schema } @@ -89,37 +82,24 @@ function anchorName (state: FromEventsState, event: ScalarEvent | SequenceEvent : state.source.slice(event.anchorStart, event.anchorEnd) } -// Tag name carried by an empty/plain scalar with no explicit tag: the first -// implicit scalar resolver that accepts the text, falling back to str. Mirrors -// the implicit branch of `constructScalar`, but we only want the tag name. -function implicitScalarTagName (state: FromEventsState, source: string) { - const { schema } = state - const candidates = schema.implicitScalarByFirstChar.get(source.charAt(0)) ?? - schema.implicitScalarAnyFirstChar - for (const tag of candidates) { - if (tag.resolve(source, false, tag.tagName) !== NOT_RESOLVED) return tag.tagName - } - return schema.defaultScalarTag.tagName -} - function buildScalar (state: FromEventsState, event: ScalarEvent): ScalarNode { const value = getScalarValue(state.source, event) const raw = rawTag(state, event) const style = new Style() switch (event.style) { - case SCALAR_STYLE_SINGLE_QUOTED: style.singleQuoted = true; break - case SCALAR_STYLE_DOUBLE_QUOTED: style.doubleQuoted = true; break - case SCALAR_STYLE_LITERAL_BLOCK: style.literal = true; break - case SCALAR_STYLE_FOLDED_BLOCK: style.folded = true; break + case SCALAR_STYLE.SINGLE_QUOTED: style.singleQuoted = true; break + case SCALAR_STYLE.DOUBLE_QUOTED: style.doubleQuoted = true; break + case SCALAR_STYLE.LITERAL_BLOCK: style.literal = true; break + case SCALAR_STYLE.FOLDED_BLOCK: style.folded = true; break } let tag: string if (raw !== '') { style.tagged = true tag = raw - } else if (event.style === SCALAR_STYLE_PLAIN) { - tag = implicitScalarTagName(state, value) + } else if (event.style === SCALAR_STYLE.PLAIN) { + tag = state.schema.resolveImplicitScalarTag(value).tag.tagName } else { tag = state.schema.defaultScalarTag.tagName } @@ -134,7 +114,7 @@ function buildCollection ( ): { tag: string, style: Style, anchor?: string } { const raw = rawTag(state, event) const style = new Style() - if (event.style === COLLECTION_STYLE_FLOW) style.flow = true + if (event.style === COLLECTION_STYLE.FLOW) style.flow = true let tag: string if (raw === '') { @@ -162,7 +142,11 @@ function addNode (state: FromEventsState, node: Node) { } } -/** @category AST */ +/** + * Builds an AST from parser events + * + * @category AST + */ function eventsToAst (events: Event[], options: FromEventsOptions): Document[] { const state: FromEventsState = { source: options.source, @@ -178,7 +162,7 @@ function eventsToAst (events: Event[], options: FromEventsOptions): Document[] { state.position = eventPosition(event) switch (event.type) { - case EVENT_DOCUMENT: { + case EVENT_ID.DOCUMENT: { const doc: Document = { contents: null, explicitStart: event.explicitStart, @@ -189,32 +173,32 @@ function eventsToAst (events: Event[], options: FromEventsOptions): Document[] { break } - case EVENT_SCALAR: + case EVENT_ID.SCALAR: addNode(state, buildScalar(state, event)) break - case EVENT_SEQUENCE: { + case EVENT_ID.SEQUENCE: { const { tag, style, anchor } = buildCollection(state, event, 'tag:yaml.org,2002:seq') const node: SequenceNode = { kind: 'sequence', tag, style, anchor, items: [] } state.frames.push({ kind: 'sequence', node }) break } - case EVENT_MAPPING: { + case EVENT_ID.MAPPING: { const { tag, style, anchor } = buildCollection(state, event, 'tag:yaml.org,2002:map') const node: MappingNode = { kind: 'mapping', tag, style, anchor, items: [] } state.frames.push({ kind: 'mapping', node, key: null }) break } - case EVENT_ALIAS: { + case EVENT_ID.ALIAS: { const name = state.source.slice(event.anchorStart, event.anchorEnd) const node: AliasNode = { kind: 'alias', tag: '', style: new Style(), anchor: name } addNode(state, node) break } - case EVENT_POP: { + case EVENT_ID.POP: { const frame = state.frames.pop()! if (frame.kind === 'mapping' && frame.key) { throw new Error('incomplete mapping pair in event stream') diff --git a/src/ast/from_js.ts b/src/ast/from_js.ts index fb44ce83..5dcf0a00 100644 --- a/src/ast/from_js.ts +++ b/src/ast/from_js.ts @@ -17,7 +17,13 @@ import { /** @category AST */ interface FromJsOptions { + /** Inlines duplicate objects instead of converting them into references. */ noRefs?: boolean + + /** + * Skips unrepresentable values instead of throwing. Invalid mapping pairs + * and sequence items are skipped; `undefined` sequence items become `null`. + */ skipInvalid?: boolean } @@ -67,9 +73,9 @@ function matchTag (state: FromJsState, object: unknown): { tag: TagDefinition, t for (let index = 0, length = state.representTypes.length; index < length; index += 1) { const { tag, implicitTag } = state.representTypes[index] - if (tag.identify && tag.identify(object)) { + if (tag.identify(object)) { let tagName: string - if (tag.matchByTagPrefix && tag.representTagName) { + if (tag.matchByTagPrefix) { tagName = tag.representTagName(object) } else { tagName = tag.tagName @@ -151,8 +157,9 @@ function build (state: FromJsState, object: unknown): Node | typeof INVALID { } /** - * A JS value is one YAML document. An unrepresentable root becomes an empty - * document, which the presenter renders as an empty string. + * Convert JS object to AST. A JS value is one YAML document. An unrepresentable + * root becomes an empty document, which the presenter renders as an empty + * string. * * @category AST */ diff --git a/src/ast/nodes.ts b/src/ast/nodes.ts index b6f9569f..e770cf4a 100644 --- a/src/ast/nodes.ts +++ b/src/ast/nodes.ts @@ -4,8 +4,16 @@ import { type DocumentDirective } from '../parser/events.ts' -/** @category Nodes */ +/** + * Style bitfields, defined as a class only to initialize their defaults. + * + * Manually assigned styles are hints; the presenter may use a fallback when + * needed to produce valid YAML. + * + * @category Nodes + */ class Style { + /** Whether to print the node's tag explicitly. */ tagged = false flow = false singleQuoted = false diff --git a/src/ast/presenter.ts b/src/ast/presenter.ts index a8a0d566..108d3f84 100644 --- a/src/ast/presenter.ts +++ b/src/ast/presenter.ts @@ -4,7 +4,6 @@ import { YAMLException } from '../common/exception.ts' import { tagNameShort } from '../common/tagname.ts' import { type Schema } from '../schema.ts' -import { NOT_RESOLVED, type ScalarTagDefinition } from '../tag.ts' import { type Node, type Document, @@ -59,6 +58,7 @@ ESCAPE_SEQUENCES[0x2029] = '\\P' /** @category AST */ interface PresenterOptions { + /** Schema used when selecting a safe scalar style. */ schema: Schema /** @@ -166,7 +166,6 @@ const DEFAULT_PRESENTER_OPTIONS: Required> = { interface PresenterState extends Required { defaultScalarTagName: string - implicitResolvers: readonly ScalarTagDefinition[] } function nodeTagShort (node: Node) { @@ -181,8 +180,7 @@ function createPresenterState (options: PresenterOptions): PresenterState { return { ...opts, - defaultScalarTagName: opts.schema.defaultScalarTag.tagName, - implicitResolvers: opts.schema.implicitScalarTags + defaultScalarTagName: opts.schema.defaultScalarTag.tagName } } @@ -244,18 +242,6 @@ function scalarLayout (state: PresenterState, level: number) { return { indent, blockIndent, lineWidth } } -function resolveImplicitTag (state: PresenterState, str: string) { - for (let index = 0, length = state.implicitResolvers.length; index < length; index += 1) { - const tagDefinition = state.implicitResolvers[index] - - if (tagDefinition.resolve(str, false, tagDefinition.tagName) !== NOT_RESOLVED) { - return tagDefinition.tagName - } - } - - return state.defaultScalarTagName -} - // [33] s-white ::= s-space | s-tab function isWhitespace (c: number) { return c === CHAR_SPACE || c === CHAR_TAB @@ -563,7 +549,7 @@ function resolveScalarStyle (state: PresenterState, node: ScalarNode, // An empty scalar is safe when its tag is explicit or resolves back to the // node tag (notably, the default null representation). A real empty string // does neither and therefore remains quoted. - if (node.style.tagged || resolveImplicitTag(state, string) === node.tag) return STYLE_PLAIN + if (node.style.tagged || state.schema.resolveImplicitScalarTag(string).tag.tagName === node.tag) return STYLE_PLAIN return state.quoteStyle === 'double' ? STYLE_DOUBLE : STYLE_SINGLE } @@ -575,7 +561,8 @@ function resolveScalarStyle (state: PresenterState, node: ScalarNode, // Plain writes no tag, so it round-trips only if the bare text resolves back // to the node's tag (or the tag gets printed explicitly). Else downgrade. // Downgrade to the preferred quote style here. - if (style === STYLE_PLAIN && !node.style.tagged && resolveImplicitTag(state, string) !== node.tag) { + if (style === STYLE_PLAIN && !node.style.tagged && + state.schema.resolveImplicitScalarTag(string).tag.tagName !== node.tag) { return state.quoteStyle === 'double' ? STYLE_DOUBLE : STYLE_SINGLE } return style @@ -1052,7 +1039,7 @@ function writeDocumentDirectives (doc: Document) { } /** - * Documents → text, including the trailing newline. + * Build YAML from AST. * * @category AST */ diff --git a/src/ast/visit.ts b/src/ast/visit.ts index 24aaa7a6..326c191b 100644 --- a/src/ast/visit.ts +++ b/src/ast/visit.ts @@ -9,11 +9,21 @@ import { // Returned by a visitor to control the walk; anything else (incl. `undefined`) // descends as usual. -/** @category other */ -const VISIT_BREAK = Symbol('visit:break') // stop the whole traversal -/** @category other */ -const VISIT_SKIP = Symbol('visit:skip') // don't descend into this node's children +/** + * Return from a visitor to stop the whole traversal. + * + * @category AST + */ +const VISIT_BREAK = Symbol('visit:break') + +/** + * Return from a visitor to skip the current node's children. + * + * @category AST + */ +const VISIT_SKIP = Symbol('visit:skip') +/** @inline */ type VisitControl = typeof VISIT_BREAK | typeof VISIT_SKIP | undefined | void /** diff --git a/src/common/exception.ts b/src/common/exception.ts index 9cfb409c..7464940b 100644 --- a/src/common/exception.ts +++ b/src/common/exception.ts @@ -20,11 +20,20 @@ function formatError (exception: YAMLException, compact?: boolean) { return `${exception.reason} ${where}` } -/** @category Main */ +/** + * A YAML error. Unlike an ordinary `Error`, it adds a source snippet showing + * the location of the problem to the error message, when available. + * + * @category Main + */ class YAMLException extends Error { reason: string mark?: SnippetMark + /** + * Optional `mark` contains source snippet data. Usually, use + * {@link YAMLException.throwAt} instead of passing it directly. + */ constructor (reason: string, mark?: SnippetMark) { super() @@ -40,40 +49,45 @@ class YAMLException extends Error { } } + /** + * Returns the formatted error, omitting the source snippet in compact mode. + */ toString (compact?: boolean) { return `${this.name}: ${formatError(this, compact)}` } -} -// Build a YAMLException with a source snippet and throw it. `source` is the -// raw input text (no parser sentinel); `position` is an offset into it. -function throwErrorAt (source: string, position: number, message: string, filename = ''): never { - let line = 0 - let lineStart = 0 - - for (let index = 0; index < position; index++) { - const ch = source.charCodeAt(index) - - if (ch === 0x0A/* LF */) { - line++ - lineStart = index + 1 - } else if (ch === 0x0D/* CR */) { - line++ - if (source.charCodeAt(index + 1) === 0x0A/* LF */) index++ - lineStart = index + 1 + /** + * Builds a YAMLException with a source snippet and throws it. `source` is + * the raw input text; `position` is an offset into it. + */ + static throwAt (source: string, position: number, message: string, filename = ''): never { + let line = 0 + let lineStart = 0 + + for (let index = 0; index < position; index++) { + const ch = source.charCodeAt(index) + + if (ch === 0x0A/* LF */) { + line++ + lineStart = index + 1 + } else if (ch === 0x0D/* CR */) { + line++ + if (source.charCodeAt(index + 1) === 0x0A/* LF */) index++ + lineStart = index + 1 + } } - } - const mark: SnippetMark = { - name: filename, - buffer: source, - position, - line, - column: position - lineStart - } + const mark: SnippetMark = { + name: filename, + buffer: source, + position, + line, + column: position - lineStart + } - mark.snippet = makeSnippet(mark) - throw new YAMLException(message, mark) + mark.snippet = makeSnippet(mark) + throw new YAMLException(message, mark) + } } -export { YAMLException, throwErrorAt } +export { YAMLException } diff --git a/src/dump.ts b/src/dump.ts index 0932bf78..9f266eae 100644 --- a/src/dump.ts +++ b/src/dump.ts @@ -1,4 +1,4 @@ -import { YAML11_SCHEMA, type Schema } from './schema.ts' +import { DUMP_SCHEMA, type Schema } from './schema.ts' import { jsToAst } from './ast/from_js.ts' import { visit, VISIT_SKIP } from './ast/visit.ts' import { type Document } from './ast/nodes.ts' @@ -8,18 +8,13 @@ import { type PresenterOptions } from './ast/presenter.ts' import { pick } from './common/object.ts' -import { NOT_RESOLVED } from './tag.ts' -import { intCoreTag } from './tag/scalar/int_core.ts' -import { intYaml11Tag } from './tag/scalar/int_yaml11.ts' -import { floatCoreTag } from './tag/scalar/float_core.ts' -import { floatYaml11Tag } from './tag/scalar/float_yaml11.ts' /** @category Main */ interface DumpOptions extends Omit { /** * Schema to use. * - * @defaultValue A {@link YAML11_SCHEMA}-based schema. + * @defaultValue {@link DUMP_SCHEMA} */ schema?: Schema @@ -50,28 +45,9 @@ interface DumpOptions extends Omit { transform?: (documents: Document[]) => void } -// YAML 1.1 misses YAML 1.2 `0o...` ints and exponent-only floats. -// Combine resolvers so all possible collisions are quoted. -const DEFAULT_DUMP_SCHEMA = YAML11_SCHEMA.withTags( - { - ...intYaml11Tag, - resolve: (source, isExplicit, tagName) => { - const result = intYaml11Tag.resolve(source, isExplicit, tagName) - return result === NOT_RESOLVED ? intCoreTag.resolve(source, isExplicit, tagName) : result - } - }, - { - ...floatYaml11Tag, - resolve: (source, isExplicit, tagName) => { - const result = floatYaml11Tag.resolve(source, isExplicit, tagName) - return result === NOT_RESOLVED ? floatCoreTag.resolve(source, isExplicit, tagName) : result - } - } -) - const DEFAULT_DUMP_OPTIONS: Required = { ...DEFAULT_PRESENTER_OPTIONS, - schema: DEFAULT_DUMP_SCHEMA, + schema: DUMP_SCHEMA, skipInvalid: false, noRefs: false, flowLevel: -1, @@ -81,7 +57,7 @@ const DEFAULT_DUMP_OPTIONS: Required = { // Options that need the JS value (tags, format, dedup) go to `jsToAst`; purely // presentational ones go to `present`. /** - * Serializes `object` as a YAML document. By default it can dump every + * Serializes JS object as a YAML document. By default it can dump every * supported YAML type, so it throws an exception if you try to dump regexps or * functions. However, you can disable exceptions by setting the * {@link DumpOptions.skipInvalid} option to `true`. diff --git a/src/index.ts b/src/index.ts index 52ae5a5f..ca2fd7ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,12 +3,12 @@ export { FAILSAFE_SCHEMA, JSON_SCHEMA, CORE_SCHEMA, - YAML11_SCHEMA + YAML11_SCHEMA, + DUMP_SCHEMA } from './schema.ts' export { NOT_RESOLVED, - MERGE_KEY, defineScalarTag, defineSequenceTag, defineMappingTag, @@ -52,22 +52,15 @@ export { dump, type DumpOptions } from './dump.ts' export { YAMLException } from './common/exception.ts' export { - EVENT_DOCUMENT, - EVENT_SEQUENCE, - EVENT_MAPPING, - EVENT_SCALAR, - EVENT_ALIAS, - EVENT_POP, - SCALAR_STYLE_PLAIN, - SCALAR_STYLE_SINGLE_QUOTED, - SCALAR_STYLE_DOUBLE_QUOTED, - SCALAR_STYLE_LITERAL_BLOCK, - SCALAR_STYLE_FOLDED_BLOCK, - COLLECTION_STYLE_BLOCK, - COLLECTION_STYLE_FLOW, - CHOMPING_CLIP, - CHOMPING_STRIP, - CHOMPING_KEEP, + EVENT_ID, + SCALAR_STYLE, + COLLECTION_STYLE, + CHOMPING_MODE, + type EventId, + type ScalarStyle, + type CollectionStyle, + type ChompingMode, + type DocumentDirective, type DocumentEvent, type SequenceEvent, @@ -112,3 +105,40 @@ export { type MappingNode, type AliasNode } from './ast/nodes.ts' + +// Deprecated compatibility exports + +import { EVENT_ID, SCALAR_STYLE, COLLECTION_STYLE, CHOMPING_MODE } from './parser/events.ts' + +/** @deprecated Use `EVENT_ID.DOCUMENT` instead. @internal */ +export const EVENT_DOCUMENT = EVENT_ID.DOCUMENT +/** @deprecated Use `EVENT_ID.SEQUENCE` instead. @internal */ +export const EVENT_SEQUENCE = EVENT_ID.SEQUENCE +/** @deprecated Use `EVENT_ID.MAPPING` instead. @internal */ +export const EVENT_MAPPING = EVENT_ID.MAPPING +/** @deprecated Use `EVENT_ID.SCALAR` instead. @internal */ +export const EVENT_SCALAR = EVENT_ID.SCALAR +/** @deprecated Use `EVENT_ID.ALIAS` instead. @internal */ +export const EVENT_ALIAS = EVENT_ID.ALIAS +/** @deprecated Use `EVENT_ID.POP` instead. @internal */ +export const EVENT_POP = EVENT_ID.POP +/** @deprecated Use `SCALAR_STYLE.PLAIN` instead. @internal */ +export const SCALAR_STYLE_PLAIN = SCALAR_STYLE.PLAIN +/** @deprecated Use `SCALAR_STYLE.SINGLE_QUOTED` instead. @internal */ +export const SCALAR_STYLE_SINGLE_QUOTED = SCALAR_STYLE.SINGLE_QUOTED +/** @deprecated Use `SCALAR_STYLE.DOUBLE_QUOTED` instead. @internal */ +export const SCALAR_STYLE_DOUBLE_QUOTED = SCALAR_STYLE.DOUBLE_QUOTED +/** @deprecated Use `SCALAR_STYLE.LITERAL_BLOCK` instead. @internal */ +export const SCALAR_STYLE_LITERAL_BLOCK = SCALAR_STYLE.LITERAL_BLOCK +/** @deprecated Use `SCALAR_STYLE.FOLDED_BLOCK` instead. @internal */ +export const SCALAR_STYLE_FOLDED_BLOCK = SCALAR_STYLE.FOLDED_BLOCK +/** @deprecated Use `COLLECTION_STYLE.BLOCK` instead. @internal */ +export const COLLECTION_STYLE_BLOCK = COLLECTION_STYLE.BLOCK +/** @deprecated Use `COLLECTION_STYLE.FLOW` instead. @internal */ +export const COLLECTION_STYLE_FLOW = COLLECTION_STYLE.FLOW +/** @deprecated Use `CHOMPING_MODE.CLIP` instead. @internal */ +export const CHOMPING_CLIP = CHOMPING_MODE.CLIP +/** @deprecated Use `CHOMPING_MODE.STRIP` instead. @internal */ +export const CHOMPING_STRIP = CHOMPING_MODE.STRIP +/** @deprecated Use `CHOMPING_MODE.KEEP` instead. @internal */ +export const CHOMPING_KEEP = CHOMPING_MODE.KEEP diff --git a/src/load.ts b/src/load.ts index 642d73a5..ed136840 100644 --- a/src/load.ts +++ b/src/load.ts @@ -15,6 +15,7 @@ import { /** @category Main */ interface LoadOptions extends ParserOptions, Omit {} +/** @inline */ type LoadAllIterator = (document: unknown) => void const DEFAULT_LOAD_OPTIONS: Required = { @@ -76,20 +77,15 @@ function loadAll ( * error. This function does not understand multi-document or empty sources; it * throws an exception on those. * - * > [!WARNING] - * > When processing untrusted input, see the - * > [security considerations](../docs/safety.md). - * * > [!NOTE] - * > The default {@link CORE_SCHEMA} comes without the `!!merge` tag. You can - * > easily enable it if needed. - * - * > [!WARNING] - * > The default {@link mapTag} is `{}`-object based and does not allow complex - * > keys (objects, arrays and so on). That's an intentional choice for - * > convenience. Also, non-string scalar keys, such as `null`, numbers or - * > booleans, are converted to strings. For non-string keys use - * > {@link realMapTag} instead (it uses native JS `Map`). + * > 1. When processing untrusted input, see the + * > [security considerations](../docs/safety.md). + * > 2. All exceptions MUST be caught, not just {@link YAMLException}. + * > 3. The default {@link CORE_SCHEMA} comes without the `!!merge` tag. You can + * > easily enable it if needed. + * > 4. The default {@link mapTag} is `{}`-object based, with known limitations + * > (see description). For full compatibility use {@link realMapTag} + * > instead (it uses native JS `Map`). * * @example * Enable {@link mergeTag} and {@link realMapTag}: @@ -97,7 +93,11 @@ function loadAll ( * ```javascript * import { load, CORE_SCHEMA, mergeTag, realMapTag } from 'js-yaml' * - * load(data, { schema: CORE_SCHEMA.withTags(mergeTag, realMapTag) }) + * try { + * load(data, { schema: CORE_SCHEMA.withTags(mergeTag, realMapTag) }) + * } catch (e) { + * console.error(e) + * } * ``` * * @category Main diff --git a/src/parser/constructor.ts b/src/parser/constructor.ts index 2d8f4369..f821c291 100644 --- a/src/parser/constructor.ts +++ b/src/parser/constructor.ts @@ -1,11 +1,6 @@ import { - EVENT_ALIAS, - EVENT_DOCUMENT, - EVENT_MAPPING, - EVENT_POP, - EVENT_SCALAR, - EVENT_SEQUENCE, - SCALAR_STYLE_PLAIN, + EVENT_ID, + SCALAR_STYLE, type Event, type TagHandlers, type MappingEvent, @@ -15,17 +10,18 @@ import { import { getScalarValue } from './parser_scalar.ts' import { CORE_SCHEMA, type Schema } from '../schema.ts' import { - MERGE_KEY, NOT_RESOLVED, type MappingTagDefinition, type ScalarTagDefinition, type SequenceTagDefinition } from '../tag.ts' -import { YAMLException, throwErrorAt } from '../common/exception.ts' +import { YAMLException } from '../common/exception.ts' import { tagNameFull } from '../common/tagname.ts' const NO_RANGE = -1 +const MERGE_TAG_NAME = 'tag:yaml.org,2002:merge' + interface DocumentFrame { kind: 'document' position: number @@ -40,10 +36,6 @@ interface SequenceFrame { tag: SequenceTagDefinition anchor: Anchor | null index: number - // True when this sequence is the source list of a `<<` merge (`<<: [...]`). - // Each element is validated as a mapping on arrival; the materialized list is - // then delivered to the target mapping, which folds the elements in. - merge: boolean } interface MappingFrame { @@ -55,6 +47,8 @@ interface MappingFrame { key: unknown keyPosition: number hasKey: boolean + // The key slot drops its tag, but `<<` is recognized by tag, not by value. + keyIsMerge: boolean // Keys brought in by a merge that an explicit pair is still allowed to // override. Lazily allocated: stays null for mappings without `<<`. overridable: Set | null @@ -77,6 +71,7 @@ interface Anchor { /** @category Events */ interface ConstructorOptions { + /** Source text referenced by offsets in `events`. */ source: string filename?: string @@ -128,6 +123,9 @@ interface ConstructorState extends Required { position: number frames: Frame[] anchors: Map + // Mapping tag each sequence element was built with, keyed by the element + // itself. Needed by `<<` merge, which sees only the finished element values. + nodeTags: Map> tagHandlers: TagHandlers totalMergeKeys: number aliasCount: number @@ -142,7 +140,7 @@ function eventPosition (event: Event) { } function throwError (state: ConstructorState, message: string): never { - throwErrorAt(state.source, state.position, message, state.filename) + YAMLException.throwAt(state.source, state.position, message, state.filename) } function finalizeCollection ( @@ -155,7 +153,7 @@ function finalizeCollection ( return tag.finalize(carrier) } catch (error) { if (error instanceof YAMLException) throw error - throwErrorAt( + YAMLException.throwAt( state.source, position, error instanceof Error ? error.message : String(error), @@ -164,34 +162,6 @@ function finalizeCollection ( } } -function lookupTag ( - exact: Record, - prefix: readonly T[], - tagName: string -): T | undefined { - const exactTag = exact[tagName] - if (exactTag) return exactTag - - for (const tag of prefix) { - if (tagName.startsWith(tag.tagName)) return tag - } - - return undefined -} - -function findExplicitTag ( - state: ConstructorState, - exact: Record, - prefix: readonly T[], - tagName: string, - nodeKind: T['nodeKind'] -) { - const tag = lookupTag(exact, prefix, tagName) - if (tag) return tag - - throwError(state, `unknown ${nodeKind} tag !<${tagName}>`) -} - function constructScalar ( state: ConstructorState, event: ScalarEvent @@ -206,7 +176,7 @@ function constructScalar ( if (rawTag === '!') return { value: source, tag: strTag } const tagName = tagNameFull(rawTag, state.tagHandlers) - const scalarTag = lookupTag(state.schema.exact.scalar, state.schema.prefix.scalar, tagName) + const scalarTag = state.schema.lookupScalarTag(tagName) if (scalarTag) { const result = scalarTag.resolve(source, true, tagName) @@ -222,8 +192,8 @@ function constructScalar ( // by the parser as a scalar event, since there is no collection syntax to key // off. Resolve it here by the explicit tag's kind into an empty collection. const collectionTagDef = - lookupTag(state.schema.exact.mapping, state.schema.prefix.mapping, tagName) ?? - lookupTag(state.schema.exact.sequence, state.schema.prefix.sequence, tagName) + state.schema.lookupMappingTag(tagName) ?? + state.schema.lookupSequenceTag(tagName) if (collectionTagDef) { if (source !== '') { @@ -240,27 +210,17 @@ function constructScalar ( throwError(state, `unknown scalar tag !<${tagName}>`) } - if (event.style === SCALAR_STYLE_PLAIN) { - // charAt(0) (not source[0]) yields '' for an empty source, which is the key - // the null tag declares; source[0] would be undefined and miss that bucket. - const candidates = state.schema.implicitScalarByFirstChar.get(source.charAt(0)) ?? - state.schema.implicitScalarAnyFirstChar - for (const tag of candidates) { - const result = tag.resolve(source, false, tag.tagName) - if (result !== NOT_RESOLVED) return { value: result, tag } - } + if (event.style === SCALAR_STYLE.PLAIN) { + return state.schema.resolveImplicitScalarTag(source) } return { value: strTag.resolve(source, false, strTag.tagName), tag: strTag } } -function collectionTag ( +function collectionTagName ( state: ConstructorState, event: SequenceEvent | MappingEvent, - exact: Record, - prefix: readonly Tag[], - defaultTagName: string, - nodeKind: Tag['nodeKind'] + defaultTagName: string ) { const rawTag = event.tagStart === NO_RANGE ? '' @@ -269,10 +229,7 @@ function collectionTag ? defaultTagName : tagNameFull(rawTag, state.tagHandlers) - return { - tagName, - tag: findExplicitTag(state, exact, prefix, tagName, nodeKind) - } + return tagName } // A merge source must be a mapping; every mapping tag exposes the read side. @@ -297,9 +254,9 @@ function mergeKeys (state: ConstructorState, frame: MappingFrame, source: unknow } // The value of a `<<` key: either a mapping (fold its keys) or a sequence of -// mappings (fold each). A merge sequence has already had every element validated -// as a mapping on arrival (see addValue), and its elements were built by the -// target's own mapping tag, so they are read back with it. +// mappings (fold each). Sequence elements arrive as bare values, so the tag each +// was built with comes from `nodeTags`; a miss means it is not a mapping (a +// scalar, a nested sequence, or a value some sequence tag synthesized itself). function mergeSource (state: ConstructorState, frame: MappingFrame, source: unknown, sourceTag: AnyTag) { state.position = frame.keyPosition @@ -307,7 +264,11 @@ function mergeSource (state: ConstructorState, frame: MappingFrame, source: unkn mergeKeys(state, frame, source, sourceTag) } else if (sourceTag.nodeKind === 'sequence' && Array.isArray(source)) { for (const element of source) { - mergeKeys(state, frame, element, frame.tag) + const elementTag = state.nodeTags.get(element) + if (!elementTag) { + throwError(state, 'cannot merge mappings; the provided source object is unacceptable') + } + mergeKeys(state, frame, element, elementTag) } } else { throwError(state, 'cannot merge mappings; the provided source object is unacceptable') @@ -318,7 +279,7 @@ function addMappingValue (state: ConstructorState, frame: MappingFrame, key: unk state.position = frame.keyPosition // `<<` is intercepted before dedup, so a repeated merge key is allowed. - if (key === MERGE_KEY) { + if (frame.keyIsMerge) { mergeSource(state, frame, value, tag) return } @@ -339,13 +300,9 @@ function addValue (state: ConstructorState, value: unknown, tag: AnyTag) { frame.value = value frame.hasValue = true } else if (frame.kind === 'sequence') { - if (frame.merge) { - // Element of a `<<: [...]` list: validate it is a mapping, then collect - // it like any other item for the target to fold in. - if (!isMappingTag(tag)) { - throwError(state, 'cannot merge mappings; the provided source object is unacceptable') - } - } + // Any element may later be folded in by a `<<` merge, which by then has no + // way to tell what built it. + if (isMappingTag(tag)) state.nodeTags.set(value, tag) const err = frame.tag.addItem(frame.value, value, frame.index++) if (err) throwError(state, err) } else if (frame.hasKey) { @@ -357,6 +314,7 @@ function addValue (state: ConstructorState, value: unknown, tag: AnyTag) { frame.key = value frame.keyPosition = state.position frame.hasKey = true + frame.keyIsMerge = tag.tagName === MERGE_TAG_NAME } } @@ -380,7 +338,12 @@ function storeAnchor ( return null } -/** @category Events */ +/** + * Constructs JavaScript documents directly from parser events, without an + * intermediate AST. + * + * @category Events + */ function constructFromEvents (events: Event[], options: ConstructorOptions): unknown[] { const state: ConstructorState = { ...DEFAULT_CONSTRUCTOR_OPTIONS, @@ -391,6 +354,7 @@ function constructFromEvents (events: Event[], options: ConstructorOptions): unk position: 0, frames: [], anchors: new Map(), + nodeTags: new Map(), tagHandlers: Object.create(null), totalMergeKeys: 0, aliasCount: 0 @@ -401,8 +365,9 @@ function constructFromEvents (events: Event[], options: ConstructorOptions): unk state.position = eventPosition(event) switch (event.type) { - case EVENT_DOCUMENT: + case EVENT_ID.DOCUMENT: state.anchors = new Map() + state.nodeTags = new Map() state.aliasCount = 0 state.tagHandlers = Object.create(null) for (const directive of event.directives) { @@ -411,64 +376,50 @@ function constructFromEvents (events: Event[], options: ConstructorOptions): unk state.frames.push({ kind: 'document', position: state.position, value: undefined, hasValue: false }) break - case EVENT_SCALAR: { + case EVENT_ID.SCALAR: { const { value, tag } = constructScalar(state, event) storeAnchor(state, event, value, tag, true) addValue(state, value, tag) break } - case EVENT_SEQUENCE: { - const definition = collectionTag( - state, - event, - state.schema.exact.sequence, - state.schema.prefix.sequence, - 'tag:yaml.org,2002:seq', - 'sequence' - ) - const value = definition.tag.create(definition.tagName) - const anchor = storeAnchor(state, event, value, definition.tag, definition.tag.carrierIsResult) - - // `<<: [...]` — the parent mapping is waiting on a merge key, so this - // sequence is a list of merge sources: its elements must be mappings. - // It is still built and delivered as a normal value; the target folds it. - const parent = state.frames[state.frames.length - 1] - const merge = parent !== undefined && parent.kind === 'mapping' && - parent.hasKey && parent.key === MERGE_KEY + case EVENT_ID.SEQUENCE: { + const tagName = collectionTagName(state, event, 'tag:yaml.org,2002:seq') + const tag = state.schema.lookupSequenceTag(tagName) + if (!tag) throwError(state, `unknown sequence tag !<${tagName}>`) + + const value = tag.create(tagName) + const anchor = storeAnchor(state, event, value, tag, tag.carrierIsResult) state.frames.push({ - kind: 'sequence', position: state.position, value, tag: definition.tag, anchor, index: 0, merge + kind: 'sequence', position: state.position, value, tag, anchor, index: 0 }) break } - case EVENT_MAPPING: { - const definition = collectionTag( - state, - event, - state.schema.exact.mapping, - state.schema.prefix.mapping, - 'tag:yaml.org,2002:map', - 'mapping' - ) - const value = definition.tag.create(definition.tagName) - const anchor = storeAnchor(state, event, value, definition.tag, definition.tag.carrierIsResult) + case EVENT_ID.MAPPING: { + const tagName = collectionTagName(state, event, 'tag:yaml.org,2002:map') + const tag = state.schema.lookupMappingTag(tagName) + if (!tag) throwError(state, `unknown mapping tag !<${tagName}>`) + + const value = tag.create(tagName) + const anchor = storeAnchor(state, event, value, tag, tag.carrierIsResult) state.frames.push({ kind: 'mapping', position: state.position, value, - tag: definition.tag, + tag, anchor, key: undefined, keyPosition: state.position, hasKey: false, + keyIsMerge: false, overridable: null }) break } - case EVENT_ALIAS: { + case EVENT_ID.ALIAS: { if (state.maxAliases !== -1 && ++state.aliasCount > state.maxAliases) { throwError(state, `aliases exceeded maxAliases (${state.maxAliases})`) } @@ -485,7 +436,7 @@ function constructFromEvents (events: Event[], options: ConstructorOptions): unk break } - case EVENT_POP: { + case EVENT_ID.POP: { const frame = state.frames.pop()! if (frame.kind === 'mapping' && frame.hasKey) { diff --git a/src/parser/events.ts b/src/parser/events.ts index e65ec77b..b6d059ff 100644 --- a/src/parser/events.ts +++ b/src/parser/events.ts @@ -1,55 +1,48 @@ -/** @category other */ -const EVENT_DOCUMENT = 1 -/** @category other */ -const EVENT_SEQUENCE = 2 -/** @category other */ -const EVENT_MAPPING = 3 -/** @category other */ -const EVENT_SCALAR = 4 -/** @category other */ -const EVENT_ALIAS = 5 -/** @category other */ -const EVENT_POP = 6 - -type EventType = - typeof EVENT_DOCUMENT | typeof EVENT_SEQUENCE | typeof EVENT_MAPPING | - typeof EVENT_SCALAR | typeof EVENT_ALIAS | typeof EVENT_POP - -/** @category Nodes */ -const SCALAR_STYLE_PLAIN = 1 -/** @category Nodes */ -const SCALAR_STYLE_SINGLE_QUOTED = 2 -/** @category Nodes */ -const SCALAR_STYLE_DOUBLE_QUOTED = 3 -/** @category Nodes */ -const SCALAR_STYLE_LITERAL_BLOCK = 4 -/** @category Nodes */ -const SCALAR_STYLE_FOLDED_BLOCK = 5 +/** @category Events */ +const EVENT_ID = { + DOCUMENT: 1, + SEQUENCE: 2, + MAPPING: 3, + SCALAR: 4, + ALIAS: 5, + POP: 6 +} as const -type ScalarStyle = - typeof SCALAR_STYLE_PLAIN | typeof SCALAR_STYLE_SINGLE_QUOTED | - typeof SCALAR_STYLE_DOUBLE_QUOTED | typeof SCALAR_STYLE_LITERAL_BLOCK | - typeof SCALAR_STYLE_FOLDED_BLOCK +/** @category Events */ +type EventId = typeof EVENT_ID[keyof typeof EVENT_ID] /** @category Nodes */ -const COLLECTION_STYLE_BLOCK = 1 -/** @category Nodes */ -const COLLECTION_STYLE_FLOW = 2 +const SCALAR_STYLE = { + PLAIN: 1, + SINGLE_QUOTED: 2, + DOUBLE_QUOTED: 3, + LITERAL_BLOCK: 4, + FOLDED_BLOCK: 5 +} as const -type CollectionStyle = - typeof COLLECTION_STYLE_BLOCK | typeof COLLECTION_STYLE_FLOW +/** @category Nodes */ +type ScalarStyle = typeof SCALAR_STYLE[keyof typeof SCALAR_STYLE] /** @category Nodes */ -const CHOMPING_CLIP = 1 +const COLLECTION_STYLE = { + BLOCK: 1, + FLOW: 2 +} as const + /** @category Nodes */ -const CHOMPING_STRIP = 2 +type CollectionStyle = typeof COLLECTION_STYLE[keyof typeof COLLECTION_STYLE] + /** @category Nodes */ -const CHOMPING_KEEP = 3 +const CHOMPING_MODE = { + CLIP: 1, + STRIP: 2, + KEEP: 3 +} as const -type Chomping = - typeof CHOMPING_CLIP | typeof CHOMPING_STRIP | typeof CHOMPING_KEEP +/** @category Nodes */ +type ChompingMode = typeof CHOMPING_MODE[keyof typeof CHOMPING_MODE] -/** @category other */ +/** @category Events */ type DocumentDirective = { kind: 'yaml', version: string } | { kind: 'tag', handle: string, prefix: string } @@ -58,7 +51,7 @@ type TagHandlers = Record /** @category Events */ interface DocumentEvent { - type: typeof EVENT_DOCUMENT + type: typeof EVENT_ID.DOCUMENT explicitStart: boolean explicitEnd: boolean directives: DocumentDirective[] @@ -66,7 +59,7 @@ interface DocumentEvent { /** @category Events */ interface SequenceEvent { - type: typeof EVENT_SEQUENCE + type: typeof EVENT_ID.SEQUENCE start: number anchorStart: number anchorEnd: number @@ -77,7 +70,7 @@ interface SequenceEvent { /** @category Events */ interface MappingEvent { - type: typeof EVENT_MAPPING + type: typeof EVENT_ID.MAPPING start: number anchorStart: number anchorEnd: number @@ -86,9 +79,13 @@ interface MappingEvent { style: CollectionStyle } -/** @category Events */ +/** + * A scalar whose decoded value can be read with {@link getScalarValue}. + * + * @category Events + */ interface ScalarEvent { - type: typeof EVENT_SCALAR + type: typeof EVENT_ID.SCALAR valueStart: number valueEnd: number anchorStart: number @@ -96,24 +93,32 @@ interface ScalarEvent { tagStart: number tagEnd: number style: ScalarStyle - chomping: Chomping + chomping: ChompingMode indent: number fast: boolean } /** @category Events */ interface AliasEvent { - type: typeof EVENT_ALIAS + type: typeof EVENT_ID.ALIAS anchorStart: number anchorEnd: number } -/** @category Events */ +/** + * Closes the most recently opened document, sequence, or mapping. + * + * @category Events + */ interface PopEvent { - type: typeof EVENT_POP + type: typeof EVENT_ID.POP } -/** @category Events */ +/** + * Source ranges are zero-based and end-exclusive; `-1` means absent. + * + * @category Events + */ type Event = DocumentEvent | SequenceEvent | @@ -123,31 +128,16 @@ type Event = PopEvent export { - EVENT_DOCUMENT, - EVENT_SEQUENCE, - EVENT_MAPPING, - EVENT_SCALAR, - EVENT_ALIAS, - EVENT_POP, - - SCALAR_STYLE_PLAIN, - SCALAR_STYLE_SINGLE_QUOTED, - SCALAR_STYLE_DOUBLE_QUOTED, - SCALAR_STYLE_LITERAL_BLOCK, - SCALAR_STYLE_FOLDED_BLOCK, - - COLLECTION_STYLE_BLOCK, - COLLECTION_STYLE_FLOW, - - CHOMPING_CLIP, - CHOMPING_STRIP, - CHOMPING_KEEP, - - type EventType, + EVENT_ID, + SCALAR_STYLE, + COLLECTION_STYLE, + CHOMPING_MODE, + + type EventId, type ScalarStyle, type CollectionStyle, + type ChompingMode, - type Chomping, type DocumentDirective, type TagHandlers, type DocumentEvent, diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 5de7c11b..35bf033f 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -1,28 +1,16 @@ import { - EVENT_DOCUMENT, - EVENT_SEQUENCE, - EVENT_MAPPING, - EVENT_SCALAR, - EVENT_ALIAS, - EVENT_POP, - SCALAR_STYLE_PLAIN, - SCALAR_STYLE_SINGLE_QUOTED, - SCALAR_STYLE_DOUBLE_QUOTED, - SCALAR_STYLE_LITERAL_BLOCK, - SCALAR_STYLE_FOLDED_BLOCK, - COLLECTION_STYLE_BLOCK, - COLLECTION_STYLE_FLOW, - CHOMPING_CLIP, - CHOMPING_STRIP, - CHOMPING_KEEP, + EVENT_ID, + SCALAR_STYLE, + COLLECTION_STYLE, + CHOMPING_MODE, type Event, type ScalarStyle, type CollectionStyle, - type Chomping, + type ChompingMode, type DocumentDirective, type TagHandlers } from './events.ts' -import { throwErrorAt } from '../common/exception.ts' +import { YAMLException } from '../common/exception.ts' const NO_RANGE = -1 const HAS_OWN = Object.prototype.hasOwnProperty @@ -113,7 +101,7 @@ function addDocumentEvent ( explicitEnd: boolean ) { state.events.push({ - type: EVENT_DOCUMENT, + type: EVENT_ID.DOCUMENT, explicitStart, explicitEnd, directives: state.directives @@ -130,7 +118,7 @@ function addSequenceEvent ( style: CollectionStyle ) { state.events.push({ - type: EVENT_SEQUENCE, + type: EVENT_ID.SEQUENCE, start, anchorStart, anchorEnd, @@ -150,7 +138,7 @@ function addMappingEvent ( style: CollectionStyle ) { state.events.push({ - type: EVENT_MAPPING, + type: EVENT_ID.MAPPING, start, anchorStart, anchorEnd, @@ -162,13 +150,13 @@ function addMappingEvent ( function insertFlowPairMappingEvent (state: ParserState, snapshot: ParserSnapshot) { state.events.splice(snapshot.eventsLength, 0, { - type: EVENT_MAPPING, + type: EVENT_ID.MAPPING, start: snapshot.position, anchorStart: NO_RANGE, anchorEnd: NO_RANGE, tagStart: NO_RANGE, tagEnd: NO_RANGE, - style: COLLECTION_STYLE_FLOW + style: COLLECTION_STYLE.FLOW }) } @@ -181,12 +169,12 @@ function addScalarEvent ( tagStart: number, tagEnd: number, style: ScalarStyle, - chomping: Chomping = CHOMPING_CLIP, + chomping: ChompingMode = CHOMPING_MODE.CLIP, indent = -1, fast = false ) { state.events.push({ - type: EVENT_SCALAR, + type: EVENT_ID.SCALAR, valueStart, valueEnd, anchorStart, @@ -206,14 +194,14 @@ function addAliasEvent ( anchorEnd: number ) { state.events.push({ - type: EVENT_ALIAS, + type: EVENT_ID.ALIAS, anchorStart, anchorEnd }) } function addPopEvent (state: ParserState) { - state.events.push({ type: EVENT_POP }) + state.events.push({ type: EVENT_ID.POP }) } function addEmptyScalarEvent (state: ParserState) { @@ -225,7 +213,7 @@ function addEmptyScalarEvent (state: ParserState) { NO_RANGE, NO_RANGE, NO_RANGE, - SCALAR_STYLE_PLAIN + SCALAR_STYLE.PLAIN ) } @@ -259,7 +247,7 @@ function restoreState (state: ParserState, snapshot: ParserSnapshot) { } function throwError (state: ParserState, message: string): never { - throwErrorAt(state.input.slice(0, state.length), state.position, message, state.filename) + YAMLException.throwAt(state.input.slice(0, state.length), state.position, message, state.filename) } function isEol (c: number) { @@ -536,7 +524,7 @@ function readSingleQuotedScalar (state: ParserState, nodeIndent: number, props: const end = state.position state.position++ - addScalarEvent(state, start, end, props.anchorStart, props.anchorEnd, props.tagStart, props.tagEnd, SCALAR_STYLE_SINGLE_QUOTED, CHOMPING_CLIP, -1, simple) + addScalarEvent(state, start, end, props.anchorStart, props.anchorEnd, props.tagStart, props.tagEnd, SCALAR_STYLE.SINGLE_QUOTED, CHOMPING_MODE.CLIP, -1, simple) return true } @@ -570,7 +558,7 @@ function readDoubleQuotedScalar (state: ParserState, nodeIndent: number, props: if (ch === 0x22/* " */) { const end = state.position state.position++ - addScalarEvent(state, start, end, props.anchorStart, props.anchorEnd, props.tagStart, props.tagEnd, SCALAR_STYLE_DOUBLE_QUOTED, CHOMPING_CLIP, -1, simple) + addScalarEvent(state, start, end, props.anchorStart, props.anchorEnd, props.tagStart, props.tagEnd, SCALAR_STYLE.DOUBLE_QUOTED, CHOMPING_MODE.CLIP, -1, simple) return true } @@ -612,13 +600,13 @@ function readDoubleQuotedScalar (state: ParserState, nodeIndent: number, props: function readBlockScalar (state: ParserState, parentIndent: number, props: NodeProperties) { const ch = state.input.charCodeAt(state.position) - let chomping: Chomping = CHOMPING_CLIP + let chomping: ChompingMode = CHOMPING_MODE.CLIP let indent = -1 let detectedIndent = false if (ch !== 0x7C/* | */ && ch !== 0x3E/* > */) return false - const style = ch === 0x7C/* | */ ? SCALAR_STYLE_LITERAL_BLOCK : SCALAR_STYLE_FOLDED_BLOCK + const style = ch === 0x7C/* | */ ? SCALAR_STYLE.LITERAL_BLOCK : SCALAR_STYLE.FOLDED_BLOCK state.position++ while (state.input.charCodeAt(state.position) !== 0) { @@ -626,8 +614,8 @@ function readBlockScalar (state: ParserState, parentIndent: number, props: NodeP const digit = fromDecimalCode(current) if (current === 0x2B/* + */ || current === 0x2D/* - */) { - if (chomping !== CHOMPING_CLIP) throwError(state, 'repeat of a chomping mode identifier') - chomping = current === 0x2B/* + */ ? CHOMPING_KEEP : CHOMPING_STRIP + if (chomping !== CHOMPING_MODE.CLIP) throwError(state, 'repeat of a chomping mode identifier') + chomping = current === 0x2B/* + */ ? CHOMPING_MODE.KEEP : CHOMPING_MODE.STRIP state.position++ } else if (digit >= 0) { if (digit === 0) { @@ -823,7 +811,7 @@ function readPlainScalar (state: ParserState, nodeIndent: number, nodeContext: N if (end === start) return false checkPrintable(state, start, end) - addScalarEvent(state, start, end, props.anchorStart, props.anchorEnd, props.tagStart, props.tagEnd, SCALAR_STYLE_PLAIN, CHOMPING_CLIP, -1, !multiline) + addScalarEvent(state, start, end, props.anchorStart, props.anchorEnd, props.tagStart, props.tagEnd, SCALAR_STYLE.PLAIN, CHOMPING_MODE.CLIP, -1, !multiline) return true } @@ -891,9 +879,9 @@ function readFlowCollection (state: ParserState, nodeIndent: number, props: Node const terminator = isMapping ? 0x7D/* } */ : 0x5D/* ] */ if (isMapping) { - addMappingEvent(state, start, props.anchorStart, props.anchorEnd, props.tagStart, props.tagEnd, COLLECTION_STYLE_FLOW) + addMappingEvent(state, start, props.anchorStart, props.anchorEnd, props.tagStart, props.tagEnd, COLLECTION_STYLE.FLOW) } else { - addSequenceEvent(state, start, props.anchorStart, props.anchorEnd, props.tagStart, props.tagEnd, COLLECTION_STYLE_FLOW) + addSequenceEvent(state, start, props.anchorStart, props.anchorEnd, props.tagStart, props.tagEnd, COLLECTION_STYLE.FLOW) } state.position++ @@ -975,7 +963,7 @@ function readBlockSequence (state: ParserState, nodeIndent: number, props: NodeP return false } - addSequenceEvent(state, state.position, props.anchorStart, props.anchorEnd, props.tagStart, props.tagEnd, COLLECTION_STYLE_BLOCK) + addSequenceEvent(state, state.position, props.anchorStart, props.anchorEnd, props.tagStart, props.tagEnd, COLLECTION_STYLE.BLOCK) while (state.input.charCodeAt(state.position) === 0x2D/* - */ && isWsOrEolOrEnd(state.input.charCodeAt(state.position + 1))) { if (state.firstTabInLine !== -1) { @@ -1035,7 +1023,7 @@ function readBlockMapping (state: ParserState, nodeIndent: number, flowIndent: n if ((ch === 0x3F/* ? */ || ch === 0x3A/* : */) && isWsOrEolOrEnd(following)) { if (!mappingOpened) { - addMappingEvent(state, state.position, props.anchorStart, props.anchorEnd, props.tagStart, props.tagEnd, COLLECTION_STYLE_BLOCK) + addMappingEvent(state, state.position, props.anchorStart, props.anchorEnd, props.tagStart, props.tagEnd, COLLECTION_STYLE.BLOCK) mappingOpened = true } @@ -1084,7 +1072,7 @@ function readBlockMapping (state: ParserState, nodeIndent: number, flowIndent: n if (!mappingOpened) { restoreState(state, beforeKey) - addMappingEvent(state, beforeKey.position, props.anchorStart, props.anchorEnd, props.tagStart, props.tagEnd, COLLECTION_STYLE_BLOCK) + addMappingEvent(state, beforeKey.position, props.anchorStart, props.anchorEnd, props.tagStart, props.tagEnd, COLLECTION_STYLE.BLOCK) mappingOpened = true // The key, the `:` and the space after it were already validated // above, before the rollback. Re-reading the same input cannot @@ -1207,7 +1195,7 @@ function parseNode ( const mappingIndent = state.position - state.lineStart if (readBlockMapping(state, mappingIndent, flowIndent, props) && - state.events[fallbackState.eventsLength]?.type === EVENT_MAPPING) { + state.events[fallbackState.eventsLength]?.type === EVENT_ID.MAPPING) { state.depth-- return true } @@ -1271,7 +1259,7 @@ function parseNode ( restoreState(state, propertyStart) if (readBlockMapping(state, propertyIndent, flowIndent, emptyProperties()) && - state.events[fallbackState.eventsLength]?.type === EVENT_MAPPING) { + state.events[fallbackState.eventsLength]?.type === EVENT_ID.MAPPING) { hasContent = true } else { restoreState(state, fallbackState) @@ -1303,7 +1291,7 @@ function parseNode ( props.anchorEnd, props.tagStart, props.tagEnd, - SCALAR_STYLE_PLAIN + SCALAR_STYLE.PLAIN ) hasContent = true } @@ -1424,7 +1412,7 @@ function readDocument (state: ParserState) { } const documentEvent = state.events[documentEventIndex] - if (documentEvent?.type === EVENT_DOCUMENT) documentEvent.explicitEnd = explicitEnd + if (documentEvent?.type === EVENT_ID.DOCUMENT) documentEvent.explicitEnd = explicitEnd addPopEvent(state) @@ -1435,7 +1423,11 @@ function readDocument (state: ParserState) { } } -/** @category Events */ +/** + * Parses YAML into a flat event stream referencing source text by offsets. + * + * @category Events + */ function parseEvents (input: string, options: ParserOptions): Event[] { const length = input.length const state: ParserState = { @@ -1455,7 +1447,7 @@ function parseEvents (input: string, options: ParserOptions): Event[] { } const nullpos = input.indexOf('\0') - if (nullpos !== -1) throwErrorAt(input, nullpos, 'null byte is not allowed in input', state.filename) + if (nullpos !== -1) YAMLException.throwAt(input, nullpos, 'null byte is not allowed in input', state.filename) if (state.input.charCodeAt(state.position) === 0xFEFF) state.position++ diff --git a/src/parser/parser_scalar.ts b/src/parser/parser_scalar.ts index a0e5718c..021f16c8 100644 --- a/src/parser/parser_scalar.ts +++ b/src/parser/parser_scalar.ts @@ -1,10 +1,6 @@ import { - SCALAR_STYLE_SINGLE_QUOTED, - SCALAR_STYLE_DOUBLE_QUOTED, - SCALAR_STYLE_LITERAL_BLOCK, - SCALAR_STYLE_FOLDED_BLOCK, - CHOMPING_STRIP, - CHOMPING_KEEP, + SCALAR_STYLE, + CHOMPING_MODE, type ScalarEvent } from './events.ts' @@ -272,16 +268,20 @@ function getBlockValue ( emptyLines = 0 } - if (chomping === CHOMPING_KEEP) { + if (chomping === CHOMPING_MODE.KEEP) { result += '\n'.repeat(didReadContent ? 1 + emptyLines : emptyLines) - } else if (chomping !== CHOMPING_STRIP) { + } else if (chomping !== CHOMPING_MODE.STRIP) { if (didReadContent) result += '\n' } return result } -/** @category Events */ +/** + * Decodes the scalar referenced by event offsets in `input`. + * + * @category Events + */ function getScalarValue (input: string, scalar: ScalarEvent): string { if (scalar.valueStart === NO_RANGE) return '' @@ -293,13 +293,13 @@ function getScalarValue (input: string, scalar: ScalarEvent): string { if (scalar.fast) return input.slice(valueStart, valueEnd) switch (scalar.style) { - case SCALAR_STYLE_SINGLE_QUOTED: + case SCALAR_STYLE.SINGLE_QUOTED: return getSingleQuotedValue(input, valueStart, valueEnd) - case SCALAR_STYLE_DOUBLE_QUOTED: + case SCALAR_STYLE.DOUBLE_QUOTED: return getDoubleQuotedValue(input, valueStart, valueEnd) - case SCALAR_STYLE_LITERAL_BLOCK: + case SCALAR_STYLE.LITERAL_BLOCK: return getBlockValue(input, valueStart, valueEnd, scalar.indent, scalar.chomping, false) - case SCALAR_STYLE_FOLDED_BLOCK: + case SCALAR_STYLE.FOLDED_BLOCK: return getBlockValue(input, valueStart, valueEnd, scalar.indent, scalar.chomping, true) default: return getPlainValue(input, valueStart, valueEnd) diff --git a/src/schema.ts b/src/schema.ts index 969ecec9..23051722 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,4 +1,5 @@ import { + NOT_RESOLVED, type MappingTagDefinition, type ScalarTagDefinition, type SequenceTagDefinition, @@ -77,9 +78,14 @@ function compileTags (tags: readonly TagDefinition[]) { return result } -/** @category Schema */ +/** + * Controls tag resolution when loading and type selection when dumping. + * + * @category Schemas + */ class Schema { readonly tags: readonly TagDefinition[] + /** @internal */ readonly implicitScalarTags: readonly ScalarTagDefinition[] /** @@ -90,12 +96,14 @@ class Schema { * (resolvers that declared no first-char constraint, so they apply to any * first character). */ - readonly implicitScalarByFirstChar: ReadonlyMap - readonly implicitScalarAnyFirstChar: readonly ScalarTagDefinition[] + private readonly implicitScalarByFirstChar: ReadonlyMap + private readonly implicitScalarAnyFirstChar: readonly ScalarTagDefinition[] /** * The default scalar tag (`!!str`), resolved once so the composer's fallback * for unresolved plain scalars avoids a keyed lookup per scalar. + * + * @internal */ readonly defaultScalarTag: ScalarTagDefinition @@ -104,11 +112,14 @@ class Schema { * value is identified by its default tag, the tag is implicit and not * printed. Undefined if the schema does not define them (then such values * can't be dumped). + * + * @internal */ readonly defaultSequenceTag: SequenceTagDefinition | undefined + /** @internal */ readonly defaultMappingTag: MappingTagDefinition | undefined - readonly exact: TagDefinitionMap - readonly prefix: TagDefinitionListMap + private readonly exact: TagDefinitionMap + private readonly prefix: TagDefinitionListMap constructor (tags: readonly TagDefinition[]) { const compiledTags = compileTags(tags) @@ -170,6 +181,68 @@ class Schema { this.prefix = prefix } + /** @internal */ + lookupScalarTag (tagName: string): ScalarTagDefinition | undefined { + const exactTag = this.exact.scalar[tagName] + if (exactTag) return exactTag + + for (const tag of this.prefix.scalar) { + if (tagName.startsWith(tag.tagName)) return tag + } + + return undefined + } + + /** @internal */ + lookupSequenceTag (tagName: string): SequenceTagDefinition | undefined { + const exactTag = this.exact.sequence[tagName] + if (exactTag) return exactTag + + for (const tag of this.prefix.sequence) { + if (tagName.startsWith(tag.tagName)) return tag + } + + return undefined + } + + /** @internal */ + lookupMappingTag (tagName: string): MappingTagDefinition | undefined { + const exactTag = this.exact.mapping[tagName] + if (exactTag) return exactTag + + for (const tag of this.prefix.mapping) { + if (tagName.startsWith(tag.tagName)) return tag + } + + return undefined + } + + /** @internal */ + resolveImplicitScalarTag (source: string): { value: unknown, tag: ScalarTagDefinition } { + const candidates = this.implicitScalarByFirstChar.get(source.charAt(0)) ?? + this.implicitScalarAnyFirstChar + + for (const tag of candidates) { + const value = tag.resolve(source, false, tag.tagName) + if (value !== NOT_RESOLVED) return { value, tag } + } + + const tag = this.defaultScalarTag + return { value: tag.resolve(source, false, tag.tagName), tag } + } + + /** + * Creates a new schema with the specified tags added. If a tag already + * exists, it is replaced by the specified tag. + * + * @example + * + * ```javascript + * import { CORE_SCHEMA, mergeTag, realMapTag } from 'js-yaml' + * + * const schema = CORE_SCHEMA.withTags(mergeTag, realMapTag) + * ``` + */ withTags (...tags: Array): Schema { let flatTags: TagDefinition[] = [] for (const tag of tags) flatTags = flatTags.concat(tag) @@ -178,14 +251,23 @@ class Schema { } } -/** @category Schema */ +/** + * The YAML 1.2 Failsafe Schema: strings, sequences, and mappings. + * + * @category Schemas + */ const FAILSAFE_SCHEMA = new Schema([ strTag, seqTag, mapTag ]) -/** @category Schema */ +/** + * The YAML 1.2 JSON Schema. It uses JSON scalar forms while retaining YAML + * collection syntax. + * + * @category Schemas + */ const JSON_SCHEMA = new Schema([ ...FAILSAFE_SCHEMA.tags, nullJsonTag, @@ -204,10 +286,14 @@ const JSON_SCHEMA = new Schema([ * ```javascript * import { load, CORE_SCHEMA, mergeTag } from 'js-yaml' * - * load(data, { schema: CORE_SCHEMA.withTags(mergeTag) }) + * try { + * load(data, { schema: CORE_SCHEMA.withTags(mergeTag) }) + * } catch (e) { + * console.error(e) + * } * ``` * - * @category Schema + * @category Schemas */ const CORE_SCHEMA = new Schema([ ...FAILSAFE_SCHEMA.tags, @@ -217,7 +303,11 @@ const CORE_SCHEMA = new Schema([ floatCoreTag ]) -/** @category Schema */ +/** + * YAML 1.1-compatible schema. + * + * @category Schemas + */ const YAML11_SCHEMA = new Schema([ ...FAILSAFE_SCHEMA.tags, nullYaml11Tag, @@ -232,13 +322,39 @@ const YAML11_SCHEMA = new Schema([ setTag ]) +/** + * The dumper schema for maximum compatibility. It combines all supported type + * variants from YAML 1.1 and YAML 1.2 so strings matching any of them are + * quoted. This makes the generated YAML more compatible with other parsers. + * + * The schema is based on YAML 1.1, but extends `!!int` and `!!float` to accept + * both YAML 1.1 and Core Schema forms, since Core Schema supports some forms + * that YAML 1.1 does not. + * + * @category Schemas + */ +const DUMP_SCHEMA = YAML11_SCHEMA.withTags( + { + ...intYaml11Tag, + resolve: (source, isExplicit, tagName) => { + const result = intYaml11Tag.resolve(source, isExplicit, tagName) + return result === NOT_RESOLVED ? intCoreTag.resolve(source, isExplicit, tagName) : result + } + }, + { + ...floatYaml11Tag, + resolve: (source, isExplicit, tagName) => { + const result = floatYaml11Tag.resolve(source, isExplicit, tagName) + return result === NOT_RESOLVED ? floatCoreTag.resolve(source, isExplicit, tagName) : result + } + } +) + export { Schema, FAILSAFE_SCHEMA, JSON_SCHEMA, CORE_SCHEMA, YAML11_SCHEMA, - - type TagDefinitionMap, - type TagDefinitionListMap + DUMP_SCHEMA } diff --git a/src/tag.ts b/src/tag.ts index ca95e20b..cc222010 100644 --- a/src/tag.ts +++ b/src/tag.ts @@ -1,68 +1,138 @@ -/** @category other */ +/** + * Returned by a scalar resolver when the source does not match its tag. + * + * @category Tags + */ const NOT_RESOLVED: unique symbol = Symbol('NOT_RESOLVED') -/** @category other */ -const MERGE_KEY: unique symbol = Symbol('MERGE_KEY') -type ScalarRepresent = (data: any) => string -type SequenceRepresent = (data: any) => ArrayLike -type MappingRepresent = (data: any) => Map - -type IdentifyFn = (data: any) => boolean -type RepresentTagNameFn = (data: any) => string +/** + * Options for {@link defineScalarTag}. + * + * @category Tags + */ +interface ScalarTagOptions { + /** + * Whether this tag participates in resolving plain scalars without an + * explicit tag. Default: `false`. + */ + implicit?: boolean -/** @category Tags */ -interface ScalarTagDefinition { - tagName: string - nodeKind: 'scalar' - implicit: boolean - matchByTagPrefix: boolean + /** + * Whether explicit tag names are matched by prefix instead of exact equality. + * Default: `false`. + */ + matchByTagPrefix?: boolean /** - * Set of `source.charAt(0)` keys for which - * {@link ScalarTagDefinition.resolve} may succeed (a superset of - * what it really matches). A key is either a single character or '' (empty + * Set of `source.charAt(0)` keys for which `resolve` may succeed (a superset + * of what it really matches). A key is either a single character or '' (empty * source). `null` means "no constraint, always try". Used by the composer to * dispatch implicit scalars by first character without running every resolver. */ - implicitFirstChars: readonly string[] | null + implicitFirstChars?: readonly string[] | null /** - * `isExplicit` is true for an explicit tag (`!!tag`), false for implicit plain - * scalar resolution. + * Construct a value from scalar text, or return {@link NOT_RESOLVED} when it + * is invalid for this tag. `isExplicit` is true for an explicit tag and + * `tagName` is the actual matched name. */ resolve: (source: string, isExplicit: boolean, tagName: string) => Result | typeof NOT_RESOLVED - identify: IdentifyFn | null /** - * A scalar's printed form is text, so - * {@link ScalarTagDefinition.represent} always yields a string. + * Selects this tag for a JavaScript value when dumping. Use `() => false` + * for load-only tags. + */ + identify: (data: any) => boolean + + /** + * A scalar's printed form is text, so `represent` always yields a string. * The factory supplies a `String(data)` default when a tag omits it. */ - represent: ScalarRepresent - representTagName: RepresentTagNameFn | null + represent?: (data: any) => string + + /** Return the tag name to emit for a prefix-matching tag. Defaults to `tagName`. */ + representTagName?: (data: any) => string } -/** @category Tags */ -interface SequenceTagDefinition { +/** + * Normalized scalar tag returned by {@link defineScalarTag}. + * + * @category Tags + */ +interface ScalarTagDefinition extends Required> { + /** Tag name used for schema lookup. */ tagName: string - nodeKind: 'sequence' - implicit: false - matchByTagPrefix: boolean + + /** YAML node kind handled by this tag. */ + nodeKind: 'scalar' +} + +/** + * Options for {@link defineSequenceTag}. + * + * @category Tags + */ +interface SequenceTagOptions { + /** + * Whether explicit tag names are matched by prefix instead of exact equality. + * Default: `false`. + */ + matchByTagPrefix?: boolean + + /** Create the carrier used while constructing a sequence. */ create: (tagName: string) => Carrier + + /** Add an item to the carrier. Return a non-empty error message to reject it. */ addItem: (carrier: Carrier, item: unknown, index: number) => void | string - finalize: (carrier: Carrier) => Result - carrierIsResult: boolean - identify: IdentifyFn | null - represent: SequenceRepresent - representTagName: RepresentTagNameFn | null + + /** Convert the completed carrier to the result. Defaults to the identity function. */ + finalize?: (carrier: Carrier) => Result + + /** + * Selects this tag for a JavaScript value when dumping. Use `() => false` + * for load-only tags. + */ + identify: (data: any) => boolean + + /** Return the array-like contents to dump. Defaults to the identity function. */ + represent?: (data: any) => ArrayLike + + /** Return the tag name to emit for a prefix-matching tag. Defaults to `tagName`. */ + representTagName?: (data: any) => string } -/** @category Tags */ -interface MappingTagDefinition { +/** + * Normalized sequence tag returned by {@link defineSequenceTag}. + * + * @category Tags + */ +interface SequenceTagDefinition extends Required> { + /** Tag name used for schema lookup. */ tagName: string - nodeKind: 'mapping' + + /** YAML node kind handled by this tag. */ + nodeKind: 'sequence' + + /** Sequence tags do not participate in implicit scalar resolution. */ implicit: false - matchByTagPrefix: boolean + + /** Whether the carrier is also the final result (`finalize` was omitted). */ + carrierIsResult: boolean +} + +/** + * Options for {@link defineMappingTag}. + * + * @category Tags + */ +interface MappingTagOptions { + /** + * Whether explicit tag names are matched by prefix instead of exact equality. + * Default: `false`. + */ + matchByTagPrefix?: boolean + + /** Create the carrier used while constructing a mapping. */ create: (tagName: string) => Carrier /** @@ -72,78 +142,65 @@ interface MappingTagDefinition { */ addPair: (carrier: Carrier, key: unknown, value: unknown) => string - /** - * Read side, mirrors `Map` — defining a representation means defining how to - * read it back. {@link MappingTagDefinition.has} is the hot dedup probe - * (membership without fetching the value); - * {@link MappingTagDefinition.keys}/{@link MappingTagDefinition.get} - * are used only on the cold merge path (`<<`). - */ + /** Return whether the carrier contains a key, for duplicate and merge checks. */ has: (carrier: Carrier, key: unknown) => boolean + + /** Return the keys of a completed result for YAML merge processing. */ keys: (result: Result) => Iterable + + /** Return a value from a completed result for YAML merge processing. */ get: (result: Result, key: unknown) => unknown - finalize: (carrier: Carrier) => Result + + /** Convert the completed carrier to the result. Defaults to the identity function. */ + finalize?: (carrier: Carrier) => Result + + /** + * Selects this tag for a JavaScript value when dumping. Use `() => false` + * for load-only tags. + */ + identify: (data: any) => boolean + + /** Return the mapping entries to dump. Defaults to the identity function. */ + represent?: (data: any) => Map + + /** Return the tag name to emit for a prefix-matching tag. Defaults to `tagName`. */ + representTagName?: (data: any) => string +} + +/** + * Normalized mapping tag returned by {@link defineMappingTag}. + * + * @category Tags + */ +interface MappingTagDefinition extends Required> { + /** Tag name used for schema lookup. */ + tagName: string + + /** YAML node kind handled by this tag. */ + nodeKind: 'mapping' + + /** Mapping tags do not participate in implicit scalar resolution. */ + implicit: false + + /** Whether the carrier is also the final result (`finalize` was omitted). */ carrierIsResult: boolean - identify: IdentifyFn | null - represent: MappingRepresent - representTagName: RepresentTagNameFn | null } -/** @category Tags */ +/** + * Any normalized tag definition accepted by {@link Schema}. + * + * @category Tags + */ type TagDefinition = | ScalarTagDefinition | SequenceTagDefinition | MappingTagDefinition -/** @category Tags */ -interface ScalarTagOptions { - implicit?: boolean - matchByTagPrefix?: boolean - implicitFirstChars?: readonly string[] | null - resolve: ScalarTagDefinition['resolve'] - identify?: ScalarTagDefinition['identify'] - represent?: ScalarTagDefinition['represent'] - representTagName?: ScalarTagDefinition['representTagName'] -} - -type RepresentOptions = - | { - identify?: null - represent?: Represent - representTagName?: RepresentTagNameFn | null - } - | (Container extends Canonical - ? { - identify?: IdentifyFn | null - represent?: Represent - representTagName?: RepresentTagNameFn | null - } - : { - identify: IdentifyFn - represent: Represent - representTagName?: RepresentTagNameFn | null - }) - -/** @category Tags */ -type SequenceTagOptions = { - matchByTagPrefix?: boolean - create: SequenceTagDefinition['create'] - addItem: SequenceTagDefinition['addItem'] - finalize?: SequenceTagDefinition['finalize'] -} & RepresentOptions, SequenceRepresent> - -/** @category Tags */ -type MappingTagOptions = { - matchByTagPrefix?: boolean - create: MappingTagDefinition['create'] - addPair: MappingTagDefinition['addPair'] - has: MappingTagDefinition['has'] - keys: MappingTagDefinition['keys'] - get: MappingTagDefinition['get'] - finalize?: MappingTagDefinition['finalize'] -} & RepresentOptions, MappingRepresent> - -/** @category Tags */ +/** + * Create a normalized scalar tag definition. + * + * @category Tags + */ function defineScalarTag (tagName: string, options: ScalarTagOptions): ScalarTagDefinition { return { tagName, @@ -152,13 +209,17 @@ function defineScalarTag (tagName: string, options: ScalarTagOptions String(data)), - representTagName: options.representTagName ?? null + representTagName: options.representTagName ?? (() => tagName) } } -/** @category Tags */ +/** + * Create a normalized sequence tag definition. + * + * @category Tags + */ function defineSequenceTag (tagName: string, options: SequenceTagOptions): SequenceTagDefinition { const carrierIsResult = options.finalize === undefined @@ -171,13 +232,17 @@ function defineSequenceTag (tagName: string, options: addItem: options.addItem, finalize: options.finalize ?? (carrier => carrier as unknown as Result), carrierIsResult, - identify: options.identify ?? null, + identify: options.identify, represent: options.represent ?? (data => data as ArrayLike), - representTagName: options.representTagName ?? null + representTagName: options.representTagName ?? (() => tagName) } } -/** @category Tags */ +/** + * Create a normalized mapping tag definition. + * + * @category Tags + */ function defineMappingTag (tagName: string, options: MappingTagOptions): MappingTagDefinition { const carrierIsResult = options.finalize === undefined @@ -193,15 +258,14 @@ function defineMappingTag (tagName: string, options: get: options.get, finalize: options.finalize ?? (carrier => carrier as unknown as Result), carrierIsResult, - identify: options.identify ?? null, + identify: options.identify, represent: options.represent ?? (data => data as Map), - representTagName: options.representTagName ?? null + representTagName: options.representTagName ?? (() => tagName) } } export { NOT_RESOLVED, - MERGE_KEY, defineScalarTag, defineSequenceTag, defineMappingTag, @@ -212,8 +276,5 @@ export { type TagDefinition, type ScalarTagOptions, type SequenceTagOptions, - type MappingTagOptions, - type ScalarRepresent, - type SequenceRepresent, - type MappingRepresent + type MappingTagOptions } diff --git a/src/tag/mapping/legacy_map.ts b/src/tag/mapping/legacy_map.ts index 251fb629..1bff9b7c 100644 --- a/src/tag/mapping/legacy_map.ts +++ b/src/tag/mapping/legacy_map.ts @@ -1,8 +1,6 @@ import { defineMappingTag } from '../../tag.ts' import { isPlainObject } from '../../common/object.ts' -type StringMapping = Record - // Coerce a constructed key into the string identity a `{}` representation uses. // Returns null for a nested array key (an array element that is itself an // array), which would otherwise blow up exponentially when stringified via @@ -39,11 +37,11 @@ function normalizeKey (key: unknown): string | null { * @category Tags */ const legacyMapTag = defineMappingTag('tag:yaml.org,2002:map', { - create: (): StringMapping => ({}), + create: (): Record => ({}), identify: isPlainObject, // Dump side: wrap the plain object into the canonical `Map` form the writer // walks. Shallow — keys/values stay references to the originals. - represent: (o: StringMapping) => { + represent: (o: Record) => { const map = new Map() for (const key of Object.keys(o)) map.set(key, o[key]) return map @@ -76,4 +74,4 @@ const legacyMapTag = defineMappingTag('tag:yaml.org,2002:map', { } }) -export { legacyMapTag, isPlainObject, type StringMapping } +export { legacyMapTag, isPlainObject } diff --git a/src/tag/mapping/map.ts b/src/tag/mapping/map.ts index 50cfbec3..f51d172d 100644 --- a/src/tag/mapping/map.ts +++ b/src/tag/mapping/map.ts @@ -1,8 +1,6 @@ import { defineMappingTag } from '../../tag.ts' import { isPlainObject } from '../../common/object.ts' -type StringMapping = Record - /** * This is the default mapping implementation. It uses `{}` objects and has only * partial functionality due to language limitations. This choice was made @@ -25,11 +23,11 @@ type StringMapping = Record * @category Tags */ const mapTag = defineMappingTag('tag:yaml.org,2002:map', { - create: (): StringMapping => ({}), + create: (): Record => ({}), identify: isPlainObject, // Dump side: wrap the plain object into the canonical `Map` form the writer // walks. Shallow — keys/values stay references to the originals. - represent: (o: StringMapping) => { + represent: (o: Record) => { const map = new Map() for (const key of Object.keys(o)) map.set(key, o[key]) return map @@ -64,4 +62,4 @@ const mapTag = defineMappingTag('tag:yaml.org,2002:map', { } }) -export { mapTag, isPlainObject, type StringMapping } +export { mapTag, isPlainObject } diff --git a/src/tag/mapping/real_map.ts b/src/tag/mapping/real_map.ts index 32048ba9..c3925292 100644 --- a/src/tag/mapping/real_map.ts +++ b/src/tag/mapping/real_map.ts @@ -1,8 +1,6 @@ import { defineMappingTag } from '../../tag.ts' import { isPlainObject } from '../../common/object.ts' -type RealMapping = Map - /** * Recommended when non-string keys are actually needed. It uses native * JavaScript `Map` objects, so keys keep their constructed types instead of @@ -22,20 +20,24 @@ type RealMapping = Map * ```javascript * import { load, CORE_SCHEMA, realMapTag } from 'js-yaml' * - * load(data, { schema: CORE_SCHEMA.withTags(realMapTag) }) + * try { + * load(data, { schema: CORE_SCHEMA.withTags(realMapTag) }) + * } catch (e) { + * console.error(e) + * } * ``` * * @category Tags */ const realMapTag = defineMappingTag('tag:yaml.org,2002:map', { create: () => new Map(), - addPair: (container: RealMapping, key, value) => { + addPair: (container: Map, key, value) => { container.set(key, value) return '' }, - has: (container: RealMapping, key) => container.has(key), - keys: (container: RealMapping) => container.keys(), - get: (container: RealMapping, key) => container.get(key), + has: (container: Map, key) => container.has(key), + keys: (container: Map) => container.keys(), + get: (container: Map, key) => container.get(key), // Dump side: handle both a real `Map` and a plain object, so this tag fully // replaces the default map representation when dumping too. identify: (data) => data instanceof Map || isPlainObject(data), diff --git a/src/tag/mapping/set.ts b/src/tag/mapping/set.ts index 40f45d2e..67de992d 100644 --- a/src/tag/mapping/set.ts +++ b/src/tag/mapping/set.ts @@ -1,6 +1,10 @@ import { defineMappingTag } from '../../tag.ts' -/** @category Tags */ +/** + * The YAML 1.1 `!!set` tag, represented as a JavaScript `Set`. + * + * @category Tags + */ const setTag = defineMappingTag('tag:yaml.org,2002:set', { create: () => new Set(), identify: (data) => data instanceof Set, diff --git a/src/tag/scalar/binary.ts b/src/tag/scalar/binary.ts index 1cbb8195..cf96ad9b 100644 --- a/src/tag/scalar/binary.ts +++ b/src/tag/scalar/binary.ts @@ -23,7 +23,11 @@ function representYamlBinary (object: Uint8Array) { return btoa(binary) } -/** @category Tags */ +/** + * The `!!binary` tag, represented as a `Uint8Array`. + * + * @category Tags + */ const binaryTag = defineScalarTag('tag:yaml.org,2002:binary', { resolve: resolveYamlBinary, identify: (object) => Object.prototype.toString.call(object) === '[object Uint8Array]', diff --git a/src/tag/scalar/merge.ts b/src/tag/scalar/merge.ts index 1d49947b..35c1fee9 100644 --- a/src/tag/scalar/merge.ts +++ b/src/tag/scalar/merge.ts @@ -1,14 +1,22 @@ -import { defineScalarTag, MERGE_KEY, NOT_RESOLVED } from '../../tag.ts' +import { defineScalarTag, NOT_RESOLVED } from '../../tag.ts' -/** @category Tags */ +/** + * Enables merge keys in {@link CORE_SCHEMA} when added with + * {@link Schema.withTags}. + * + * @category Tags + */ const mergeTag = defineScalarTag('tag:yaml.org,2002:merge', { implicit: true, // source.charAt(0) over matched implicit inputs: '<' ('<<'). implicitFirstChars: ['<'], + // Merge semantics live in the tag, not in the value: the constructor acts on + // a key tagged `!!merge`, so `<<` anywhere else is just this string. resolve: (source, isExplicit) => { - if (source === '<<' || (isExplicit && source === '')) return MERGE_KEY + if (source === '<<' || (isExplicit && source === '')) return '<<' return NOT_RESOLVED - } + }, + identify: () => false }) export { mergeTag } diff --git a/src/tag/scalar/timestamp.ts b/src/tag/scalar/timestamp.ts index d01d28b3..eba0f5b5 100644 --- a/src/tag/scalar/timestamp.ts +++ b/src/tag/scalar/timestamp.ts @@ -86,7 +86,11 @@ function resolveYamlTimestamp (source: string) { return date } -/** @category Tags */ +/** + * The YAML 1.1 `!!timestamp` tag, represented as a JavaScript `Date`. + * + * @category Tags + */ const timestampTag = defineScalarTag('tag:yaml.org,2002:timestamp', { implicit: true, // Both patterns start with a 4-digit year, so source.charAt(0) is always a digit. diff --git a/src/tag/sequence/omap.ts b/src/tag/sequence/omap.ts index 3f858da7..302be815 100644 --- a/src/tag/sequence/omap.ts +++ b/src/tag/sequence/omap.ts @@ -1,14 +1,30 @@ import { defineSequenceTag } from '../../tag.ts' import { isPlainObject } from '../../common/object.ts' -interface OmapCarrier { - list: unknown[] - seen: Set -} - -/** @category Tags */ +/** + * Provided only for YAML 1.1 compatibility and supported by the loader only. + * JavaScript has no dedicated class to represent this type, so it cannot be + * identified and dumped. + * + * ```yaml + * !!omap + * - one: 1 + * - two: 2 + * ``` + * + * is loaded as + * + * ```javascript + * [ + * { one: 1 }, + * { two: 2 } + * ] + * ``` + * + * @category Tags + */ const omapTag = defineSequenceTag('tag:yaml.org,2002:omap', { - create: (): OmapCarrier => ({ list: [], seen: new Set() }), + create: (): { list: unknown[]; seen: Set } => ({ list: [], seen: new Set() }), addItem: (carrier, item) => { let key: unknown @@ -28,7 +44,8 @@ const omapTag = defineSequenceTag('tag:yaml.org,2002:omap', { carrier.list.push(item) return '' }, - finalize: (carrier): unknown[] => carrier.list + finalize: (carrier): unknown[] => carrier.list, + identify: () => false }) export { omapTag } diff --git a/src/tag/sequence/pairs.ts b/src/tag/sequence/pairs.ts index 2be51b72..de93407e 100644 --- a/src/tag/sequence/pairs.ts +++ b/src/tag/sequence/pairs.ts @@ -1,10 +1,29 @@ import { defineSequenceTag } from '../../tag.ts' -type Pair = [unknown, unknown] - -/** @category Tags */ +/** + * Provided only for YAML 1.1 compatibility and supported by the loader only. + * JavaScript has no dedicated class to represent this type, so it cannot be + * identified and dumped. + * + * ```yaml + * !!pairs + * - one: 1 + * - two: 2 + * ``` + * + * is loaded as + * + * ```javascript + * [ + * ['one', 1], + * ['two', 2] + * ] + * ``` + * + * @category Tags + */ const pairsTag = defineSequenceTag('tag:yaml.org,2002:pairs', { - create: () => [] as Pair[], + create: () => [] as [unknown, unknown][], addItem: (container, item) => { if (item instanceof Map) { if (item.size !== 1) return 'cannot resolve a pairs item' @@ -22,9 +41,10 @@ const pairsTag = defineSequenceTag('tag:yaml.org,2002:pairs', { if (keys.length !== 1) return 'cannot resolve a pairs item' - container.push([keys[0], object[keys[0]]] satisfies Pair) + container.push([keys[0], object[keys[0]]]) return '' - } + }, + identify: () => false }) export { pairsTag } diff --git a/support/demo_template/index.mjs b/support/demo_template/index.mjs index 26fb869f..6286c2d0 100644 --- a/support/demo_template/index.mjs +++ b/support/demo_template/index.mjs @@ -25,7 +25,8 @@ function decodeBase64 (str) { const SexyYamlTag = jsyaml.defineSequenceTag('!sexy', { create: () => [], - addItem: (container, item) => { container.push(`sexy ${item}`) } + addItem: (container, item) => { container.push(`sexy ${item}`) }, + identify: () => false }) const SEXY_SCHEMA = jsyaml.YAML11_SCHEMA.withTags(SexyYamlTag) diff --git a/support/demo_template/sample.mjs b/support/demo_template/sample.mjs index e26e3312..6c55d1e0 100644 --- a/support/demo_template/sample.mjs +++ b/support/demo_template/sample.mjs @@ -168,7 +168,8 @@ timestamp: # # var SexyYamlTag = jsyaml.defineSequenceTag('!sexy', { # create: function () { return []; }, -# addItem: function (container, item) { container.push('sexy ' + item); } +# addItem: function (container, item) { container.push('sexy ' + item); }, +# identify: function () { return false; } # }); # # var SEXY_SCHEMA = jsyaml.YAML11_SCHEMA.withTags(SexyYamlTag); diff --git a/support/typedoc_condensed_theme/condensed_theme.css b/support/typedoc_condensed_theme/condensed_theme.css new file mode 100644 index 00000000..a92ec2b7 --- /dev/null +++ b/support/typedoc_condensed_theme/condensed_theme.css @@ -0,0 +1,262 @@ +/* + * Keep TypeDoc's default layout and navigation. This file only adapts the + * content typography and removes duplicated declarations. + */ + +:root { + --cndnsd-font-serif: Charter, "Bitstream Charter", "Sitka Text", Cambria, serif; + --cndnsd-font-sans: Seravek, "Gill Sans Nova", Ubuntu, Calibri, "DejaVu Sans", source-sans-pro, sans-serif; + --cndnsd-font-mono: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, "DejaVu Sans Mono", monospace; + --cndnsd-target-animation: 0.65s cubic-bezier(0, 0, 0.1, 1) 0.1s cndnsd-target-fadein; + --cndnsd-target-color-faded: rgb(from var(--color-background-warning) r g b / 30%); +} + +/* ============================================================================= + * Base typography tweaks + * ========================================================================== */ + +h1, h2, h3, h4, h5, h6 { + font-family: var(--cndnsd-font-sans); + font-weight: 500; +} + +h1 { font-size: 1.5rem; } +h2 { font-size: 1.375rem; } +h3 { font-size: 1.25rem; } +h4, +h5, +h6 { + font-size: 1rem; +} + +code { + font-family: var(--cndnsd-font-mono); + background: oklch( + from var(--color-background-secondary) + calc(l - 0.025) c h + ); + border-radius: 3px; +} + +pre { + font-family: var(--cndnsd-font-mono); + margin: 0; + padding: .875rem; + background: var(--color-background-secondary); + border: 0; + border-radius: 6px; + font-size: 1rem; + line-height: 1.5; +} + +.col-content .tsd-typography pre code { + padding: 0; + background: none; +} + +/* Change default font for content only, don't touch side columns */ +.col-content { + font: 16px/1.5 var(--cndnsd-font-serif); +} + +/* ============================================================================= + * Content + * ========================================================================== */ + +.tsd-breadcrumb { + font-family: var(--cndnsd-font-sans); +} + +/* underline collapsible headers*/ +.col-content .tsd-accordion-summary { + padding-bottom: .5rem; + border-bottom: 1px solid var(--color-accent); +} +/* Fix "open" chevron offset after typography changes. TBH, that's an icon bug */ +.tsd-accordion[open] > .tsd-accordion-summary > svg:first-child { + translate: 0 4px; +} + +.tsd-panel { + margin-bottom: 1.5rem; +} + +/* + * Anchor links + */ + +.tsd-anchor-icon { + opacity: 0; + position: absolute; + top: 0; + right: 100%; + margin: 0; + font-family: var(--cndnsd-font-sans); + font-style: normal; + font-weight: 400; + line-height: inherit; + text-decoration: none; +} + +.tsd-anchor-link { + position: relative; +} + +/* Remove permalinks in useless places in stead of styling + * - modules page + * - deprecated blocks + * - default values + */ +.tsd-member-group:has(> .tsd-member-summaries) .tsd-anchor-icon, +.tsd-tag-deprecated .tsd-anchor-icon, +.tsd-tag-defaultValue .tsd-anchor-icon { + display: none; +} + +/* mouse over */ +.tsd-anchor-icon:hover, +.tsd-anchor-icon:focus-visible, +.tsd-anchor-link:hover .tsd-anchor-icon { + opacity: 1; +} + +/* fix permalink offset in markdown documents */ +.tsd-panel > h1, +.tsd-panel > h2, +.tsd-panel > h3 { + margin-inline: 0; + padding-inline: 0; +} + +/* + * Animate permalinks on page load + */ + +@keyframes cndnsd-target-fadein { + from { + background-color: var(--color-background); + } + 50% { + background-color: var(--color-background-warning); + } + to { + background-color: var(--cndnsd-target-color-faded); + } +} + +/* various permalinks for generated code docs */ +.tsd-member:target .tsd-signature, +.tsd-tag-example .tsd-anchor-link:target, +.tsd-accordion-summary:has(.tsd-anchor-link:target), +/* for markdown headers */ +.tsd-panel.tsd-typography > .tsd-anchor-link:target { + background-color: var(--cndnsd-target-color-faded); + animation: var(--cndnsd-target-animation); +} + +@media (prefers-reduced-motion) { + .col-content { + --cndnsd-target-animation: none; + } +} + +/* Header-like signatures (properties, methods) */ +.tsd-member .tsd-signature { + font-family: var(--cndnsd-font-mono); + padding: 0; + border: 0; + background: none; + font-size: 1rem; + line-height: 1.5; + overflow: visible; + white-space: pre-wrap; +} + +.cndnsd-call-signature .tsd-signature { + font-weight: 600; +} +.cndnsd-call-signature .tsd-signature > :not(.tsd-sources) { + font-weight: inherit; +} + +/* offset signature descriptions */ +.tsd-member > :not(.tsd-signature, .tsd-signatures, .tsd-anchor-icon), +.cndnsd-call-signature > :not(.tsd-signature) { + margin-left: 1.5rem; +} + +/* Page top signatures */ +.col-content > .tsd-panel :where(.tsd-signature), +.col-content > .tsd-signature { + font-family: var(--cndnsd-font-mono); + margin: 1rem 0; + padding: .875rem; + background: var(--color-background-secondary); + border: 0; + border-radius: 6px; + font-size: 1rem; + line-height: 1.5; + + overflow-x: auto; + overflow-wrap: normal; + white-space: pre; +} + +/* + * Source links + */ +.tsd-sources { + font: 1rem/1.5 var(--cndnsd-font-sans); + margin: 0; + white-space: normal; /* rollback for top */ +} +.tsd-sources a { + color: var(--color-link); + text-decoration: none; +} + +.tsd-member .tsd-signature { + display: flow-root; +} +/* Only the link moved up; implements/inherited notes share the same class. */ +.tsd-member .tsd-signature .tsd-sources { + float: right; + margin: 0 0 0 .75rem; +} +.tsd-member .tsd-signature .tsd-sources ul { + margin: 0; +} + +/* Restyle "default values" as one-line blocks of declaration. */ +.tsd-tag-defaultValue { + display: flex; + align-items: baseline; + column-gap: 1rem; + font-size: 0.875rem; + font-weight: 400; + margin: 1rem 0; +} +.tsd-tag-defaultValue * { + font-size: inherit; + font-weight: inherit; + margin: 0; +} + +/* + * Remove useless blocks + */ + +/* These ones duplicate info and just add noise to the page. */ +.tsd-index-group, +.tsd-type-declaration, +.tsd-returns-title, +.tsd-parameters-title { + display: none; +} + +/* Hide "Type Parameters" blocks without valuable info. */ +.tsd-panel:has(.tsd-type-parameter-list):not(:has(.tsd-comment)), +/* Hide call signature "Parameters" blocks without valuable info. */ +.tsd-parameters:not(:has(.tsd-comment)) { + display: none; +} diff --git a/support/typedoc_condensed_theme/condensed_theme_plugin.mjs b/support/typedoc_condensed_theme/condensed_theme_plugin.mjs new file mode 100644 index 00000000..55ed85f8 --- /dev/null +++ b/support/typedoc_condensed_theme/condensed_theme_plugin.mjs @@ -0,0 +1,245 @@ +/* + * Theme on top of the default one. Page layout and navigation are kept intact, + * only member presentation is changed: + * + * - source link is compact (`Source`) and moved into the signature line; + * - member `h3` heading is dropped, its anchor moves to the `section`; + * - permalink is a plain `§` instead of an icon; + * - own markup for signature and getter/setter lists; + * - page top keeps the default layout. + * + * The only place coupled to TypeDoc's markup is `findMemberSignature`. + */ + +import { cpSync } from 'node:fs' +import { join } from 'node:path' + +import { + DefaultTheme, + DefaultThemeRenderContext, + i18n, + JSX, + ReflectionKind, + RendererEvent +} from 'typedoc' + +const h = JSX.createElement + +/* ============================================================================= + * Source links and permalinks + * ========================================================================== */ + +function sourceLink (context, source) { + if (!source.url) return 'Source' + + const attributes = { href: source.url } + + if (context.options.getValue('sourceLinkExternal')) { + attributes.target = '_blank' + } + + return h('a', attributes, 'Source') +} + +function sourceLinks (context, item) { + if (!item.sources?.length) return null + + return h('aside', { class: 'tsd-sources' }, + h('ul', null, + item.sources.map(source => h('li', null, sourceLink(context, source))) + ) + ) +} + +// Hide sources for the duration of the default render: TypeDoc skips its own +// source links, but keeps implements/inherited/overrides notes untouched. +function renderWithoutSources (item, render) { + const saved = item.sources + + item.sources = undefined + try { + return render() + } finally { + item.sources = saved + } +} + +function anchorIcon (context, anchor) { + if (!anchor) return h(JSX.Fragment, null) + + return h('a', { + href: `#${anchor}`, + 'aria-label': i18n.theme_permalink(), + class: 'tsd-anchor-icon' + }, context.icons.anchor()) +} + +function addSignaturePermalink (context, signature, anchor) { + if (!anchor) return + + signature.props = { + ...signature.props, + class: `${signature.props.class} tsd-anchor-link` + } + signature.children.push(anchorIcon(context, anchor)) +} + +// TypeDoc renders the page top bypassing `member`, keep the default look there. +function isPageTop (context, reflection) { + return reflection === context.page.model +} + +// Coupled to TypeDoc's markup: the signature line of `memberDeclaration` output. +function findMemberSignature (element) { + return element?.children.find(child => + child?.props?.class?.split(' ').includes('tsd-signature') + ) +} + +/* ============================================================================= + * Signature lists + * ========================================================================== */ + +function signatureItem (context, signature, permalinkAnchor, itemClass) { + const anchor = context.getAnchor(signature) + const className = [ + context.getReflectionClasses(signature), + itemClass + ].filter(Boolean).join(' ') + const signatureElement = h('div', { + class: 'tsd-signature', + id: anchor + }, + sourceLinks(context, signature), + context.memberSignatureTitle(signature) + ) + + addSignaturePermalink(context, signatureElement, permalinkAnchor) + + return h('li', className ? { class: className } : null, signatureElement, + h('div', { class: 'tsd-description' }, + renderWithoutSources(signature, () => context.memberSignatureBody(signature)) + ) + ) +} + +function signatureList (context, declaration, items) { + const classes = context.getReflectionClasses(declaration) + + return h('ul', { + class: classes ? `tsd-signatures ${classes}` : 'tsd-signatures' + }, items) +} + +/* ============================================================================= + * Render context + * ========================================================================== */ + +class CondensedThemeRenderContext extends DefaultThemeRenderContext { + constructor (...args) { + super(...args) + + // In TypeDoc these renderers are fields, not prototype methods, so `super` + // can't reach them: overrides that augment the output capture the original + // here, while a full replacement is declared as a class field. + const renderMember = this.member + const renderMemberDeclaration = this.memberDeclaration + const renderMemberSignatures = this.memberSignatures + const renderMemberSources = this.memberSources + + this.memberSources = item => h(JSX.Fragment, null, + sourceLinks(this, item), + renderWithoutSources(item, () => renderMemberSources(item)) + ) + + this.memberDeclaration = declaration => { + if (isPageTop(this, declaration)) return renderMemberDeclaration(declaration) + + const element = renderWithoutSources(declaration, () => renderMemberDeclaration(declaration)) + const signature = findMemberSignature(element) + + if (!signature) return element + + const sources = sourceLinks(this, declaration) + + if (sources) signature.children.unshift(sources) + addSignaturePermalink(this, signature, this.getAnchor(declaration)) + + return element + } + + this.memberSignatures = declaration => { + if (isPageTop(this, declaration)) return renderMemberSignatures(declaration) + + return signatureList(this, declaration, declaration.signatures?.map((signature, index) => + signatureItem( + this, + signature, + index === 0 && this.getAnchor(declaration), + signature.kind === ReflectionKind.CallSignature && 'cndnsd-call-signature' + ) + )) + } + + this.member = item => { + const element = renderMember(item) + const anchor = this.getAnchor(item) + + element.children = element.children.filter(child => child?.tag !== 'h3') + element.props = { ...element.props, id: anchor } + + return element + } + } + + get icons () { + return { + ...super.icons, + anchor: () => h('span', { + 'aria-hidden': 'true', + class: 'cndnsd-anchor-symbol' + }, '§') + } + } + + memberGetterSetter = declaration => signatureList(this, declaration, [ + declaration.getSignature && signatureItem( + this, + declaration.getSignature, + this.getAnchor(declaration) + ), + declaration.setSignature && signatureItem( + this, + declaration.setSignature, + !declaration.getSignature && this.getAnchor(declaration) + ) + ]) +} + +/* ============================================================================= + * Theme and plugin + * ========================================================================== */ + +class CondensedTheme extends DefaultTheme { + ContextClass = CondensedThemeRenderContext + + constructor (renderer) { + super(renderer) + + this.owner.on(RendererEvent.END, event => { + cpSync( + join(import.meta.dirname, 'condensed_theme.css'), + join(event.outputDirectory, 'assets', 'condensed_theme.css') + ) + }) + } +} + +export default function condensedThemePlugin (app) { + app.renderer.hooks.on('head.end', context => h('link', { + rel: 'stylesheet', + href: context.relativeURL('assets/condensed_theme.css') + })) + + app.renderer.defineTheme('condensed', CondensedTheme) +} diff --git a/test/core/ast/from_events.test.mjs b/test/core/ast/from_events.test.mjs index 1bdbca34..b43fa474 100644 --- a/test/core/ast/from_events.test.mjs +++ b/test/core/ast/from_events.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import { CORE_SCHEMA, eventsToAst, - EVENT_SCALAR, + EVENT_ID, parseEvents } from 'js-yaml' @@ -11,7 +11,7 @@ describe('ast from_events', () => { it('rejects a mapping event stream with an unpaired key', () => { const source = 'key: value' const events = parseEvents(source, {}) - const valueIndex = events.findLastIndex(event => event.type === EVENT_SCALAR) + const valueIndex = events.findLastIndex(event => event.type === EVENT_ID.SCALAR) events.splice(valueIndex, 1) diff --git a/test/core/parser/constructor.test.mjs b/test/core/parser/constructor.test.mjs index b599dd4a..4fe8b204 100644 --- a/test/core/parser/constructor.test.mjs +++ b/test/core/parser/constructor.test.mjs @@ -2,7 +2,7 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' import { constructFromEvents, - EVENT_SCALAR, + EVENT_ID, parseEvents, YAMLException } from 'js-yaml' @@ -11,7 +11,7 @@ describe('constructor', () => { it('rejects a mapping event stream with an unpaired key', () => { const source = 'key: value' const events = parseEvents(source, {}) - const valueIndex = events.findLastIndex(event => event.type === EVENT_SCALAR) + const valueIndex = events.findLastIndex(event => event.type === EVENT_ID.SCALAR) events.splice(valueIndex, 1) diff --git a/test/core/parser/parser.test.mjs b/test/core/parser/parser.test.mjs index 2d20f0b0..f75c419f 100644 --- a/test/core/parser/parser.test.mjs +++ b/test/core/parser/parser.test.mjs @@ -1,6 +1,6 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' -import { EVENT_SCALAR, getScalarValue, loadAll, parseEvents } from 'js-yaml' +import { EVENT_ID, getScalarValue, loadAll, parseEvents } from 'js-yaml' describe('parser', () => { it('keeps an implicit null mapping value before a document marker', () => { @@ -11,7 +11,7 @@ describe('parser', () => { for (const [source, expected] of samples) { const values = parseEvents(source, {}) - .filter(event => event.type === EVENT_SCALAR) + .filter(event => event.type === EVENT_ID.SCALAR) .map(event => getScalarValue(source, event)) assert.deepEqual(values, expected) diff --git a/test/core/tags/custom.test.mjs b/test/core/tags/custom.test.mjs index 87f1fc8b..58e9f3eb 100644 --- a/test/core/tags/custom.test.mjs +++ b/test/core/tags/custom.test.mjs @@ -118,14 +118,16 @@ describe('tags', () => { it('custom tag with multiple node kinds', () => { const multiSchema = CORE_SCHEMA.withTags([ defineScalarTag('!Include', { - resolve: (obj) => obj + resolve: (obj) => obj, + identify: () => false }), defineMappingTag('!Include', { create: () => ({}), addPair: (container, key, value) => { container[String(key)] = value }, has: (container, key) => Object.hasOwn(container, String(key)), keys: (container) => Object.keys(container), - get: (container, key) => container[String(key)] + get: (container, key) => container[String(key)], + identify: () => false }) ]) @@ -136,12 +138,16 @@ describe('tags', () => { it('matches exact tags before tag prefixes', () => { const prefixTag = prefix => defineScalarTag(prefix, { matchByTagPrefix: true, - resolve: (value, _isExplicit, tag) => ({ prefix, tag, value }) + resolve: (value, _isExplicit, tag) => ({ prefix, tag, value }), + identify: () => false }) const prefixSchema = CORE_SCHEMA.withTags([ prefixTag('!foo'), prefixTag('!'), - defineScalarTag('!foo', { resolve: value => ({ exact: true, value }) }) + defineScalarTag('!foo', { + resolve: value => ({ exact: true, value }), + identify: () => false + }) ]) assert.deepEqual( @@ -310,7 +316,8 @@ describe('tags', () => { it('does not call the placeholder finalizer when the carrier is the result', () => { const tag = defineSequenceTag('!identity', { create: () => [], - addItem: (carrier, item) => { carrier.push(item) } + addItem: (carrier, item) => { carrier.push(item) }, + identify: () => false }) tag.finalize = () => { throw new Error('placeholder finalizer was called') } diff --git a/test/core/tags/merge.test.mjs b/test/core/tags/merge.test.mjs index 6316b722..3989fc37 100644 --- a/test/core/tags/merge.test.mjs +++ b/test/core/tags/merge.test.mjs @@ -88,6 +88,74 @@ foo: bar ) }) + it('throws on a string item in an aliased merge sequence', () => { + const src = ` +a: &x [abc] +<<: *x +` + assert.throws(() => load(src, { schema: YAML11_SCHEMA }), /cannot merge mappings/) + }) + + it('throws on a sequence item in an aliased merge sequence', () => { + const src = ` +a: &x [[p, q]] +<<: *x +` + assert.throws(() => load(src, { schema: YAML11_SCHEMA }), /cannot merge mappings/) + }) + + it('merges an aliased source', () => { + assert.deepStrictEqual( + load('a: &x {p: 1}\n<<: *x\n', { schema: YAML11_SCHEMA }), + { a: { p: 1 }, p: 1 } + ) + assert.deepStrictEqual( + load('a: &x [{p: 1}]\n<<: *x\n', { schema: YAML11_SCHEMA }), + { a: [{ p: 1 }], p: 1 } + ) + }) + + // `!!set` is a mapping in YAML terms, but its constructed value is a `Set`, + // so a merge item must be read with its own tag (`!!map` here) instead of the + // target's. + it('merges a sequence source when target and item mapping tags differ', () => { + const src = ` +--- !!set +<<: [ { a: null } ] +` + assert.deepStrictEqual(load(src, { schema: YAML11_SCHEMA }), new Set(['a'])) + }) + + describe('`<<` outside of a mapping key', () => { + it('as a mapping value', () => { + assert.deepStrictEqual(load('foo: <<\n', { schema: YAML11_SCHEMA }), { foo: '<<' }) + }) + + it('as a sequence item', () => { + assert.deepStrictEqual(load('- <<\n', { schema: YAML11_SCHEMA }), ['<<']) + }) + + it('as a document root', () => { + assert.deepStrictEqual(load('<<\n', { schema: YAML11_SCHEMA }), '<<') + }) + + it('inside a nested mapping', () => { + assert.deepStrictEqual(load('a: {b: <<}\n', { schema: YAML11_SCHEMA }), { a: { b: '<<' } }) + }) + + it('as an explicit !!merge tag on an empty node', () => { + assert.deepStrictEqual(load('foo: !!merge\n', { schema: YAML11_SCHEMA }), { foo: '<<' }) + }) + + it('quoted, never a merge key', () => { + assert.deepStrictEqual(load('foo: "<<"\n', { schema: YAML11_SCHEMA }), { foo: '<<' }) + }) + + it('still merges in the key position', () => { + assert.deepStrictEqual(load('<<: {p: 1}\n', { schema: YAML11_SCHEMA }), { p: 1 }) + }) + }) + it('Resolving explicit !!merge on empty node', () => { assert.doesNotThrow(() => load('? !!merge\n: []', { schema: CORE_SCHEMA.withTags(mergeTag) })) }) diff --git a/test/core/units/dump-options.test.mjs b/test/core/units/dump-options.test.mjs index d8edfe8f..9e9f198e 100644 --- a/test/core/units/dump-options.test.mjs +++ b/test/core/units/dump-options.test.mjs @@ -1,12 +1,22 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' -import { dump, load, JSON_SCHEMA, CORE_SCHEMA, defineMappingTag, realMapTag, YAMLException } from 'js-yaml' +import { + dump, + load, + JSON_SCHEMA, + CORE_SCHEMA, + DUMP_SCHEMA, + defineMappingTag, + realMapTag, + YAMLException +} from 'js-yaml' describe('dump options', () => { it('schema — decides which plain scalars need quoting', () => { // The default dump schema is YAML 1.1, where `yes` is a boolean, so the // string must be quoted; JSON_SCHEMA has no such collision. assert.equal(dump('yes'), "'yes'\n") + assert.equal(dump('yes', { schema: DUMP_SCHEMA }), "'yes'\n") assert.equal(dump('yes', { schema: JSON_SCHEMA }), 'yes\n') }) diff --git a/test/core/units/schema.test.mjs b/test/core/units/schema.test.mjs index 7fd4711f..9c0a1d3c 100644 --- a/test/core/units/schema.test.mjs +++ b/test/core/units/schema.test.mjs @@ -7,7 +7,8 @@ describe('schema (coverage)', () => { const badTag = defineScalarTag('!bad', { implicit: true, matchByTagPrefix: true, - resolve: () => 'x' + resolve: () => 'x', + identify: () => false }) assert.throws(() => new Schema([badTag]), /Implicit scalar tags cannot match by tag prefix/) diff --git a/test/spec/spec.test.mjs b/test/spec/spec.test.mjs index dc364099..6fbf0875 100644 --- a/test/spec/spec.test.mjs +++ b/test/spec/spec.test.mjs @@ -14,17 +14,9 @@ import { eventsToAst, present, visit, - EVENT_DOCUMENT, - EVENT_SEQUENCE, - EVENT_MAPPING, - EVENT_SCALAR, - EVENT_ALIAS, - EVENT_POP, - SCALAR_STYLE_SINGLE_QUOTED, - SCALAR_STYLE_DOUBLE_QUOTED, - SCALAR_STYLE_LITERAL_BLOCK, - SCALAR_STYLE_FOLDED_BLOCK, - COLLECTION_STYLE_FLOW, + EVENT_ID, + SCALAR_STYLE, + COLLECTION_STYLE, CORE_SCHEMA, strTag, seqTag, @@ -130,10 +122,10 @@ function tagHandlersFromDirectives (directives) { } function scalarStyleMarker (style) { - if (style === SCALAR_STYLE_SINGLE_QUOTED) return "'" - if (style === SCALAR_STYLE_DOUBLE_QUOTED) return '"' - if (style === SCALAR_STYLE_LITERAL_BLOCK) return '|' - if (style === SCALAR_STYLE_FOLDED_BLOCK) return '>' + if (style === SCALAR_STYLE.SINGLE_QUOTED) return "'" + if (style === SCALAR_STYLE.DOUBLE_QUOTED) return '"' + if (style === SCALAR_STYLE.LITERAL_BLOCK) return '|' + if (style === SCALAR_STYLE.FOLDED_BLOCK) return '>' return ':' } @@ -145,34 +137,34 @@ function actualTreeLines (input) { let tagHandlers = Object.create(null) for (const event of events) { - if (event.type === EVENT_DOCUMENT) { + if (event.type === EVENT_ID.DOCUMENT) { tagHandlers = tagHandlersFromDirectives(event.directives) lines.push(event.explicitStart ? '+DOC ---' : '+DOC') stack.push(event) - } else if (event.type === EVENT_SEQUENCE) { - const style = event.style === COLLECTION_STYLE_FLOW ? ' []' : '' + } else if (event.type === EVENT_ID.SEQUENCE) { + const style = event.style === COLLECTION_STYLE.FLOW ? ' []' : '' const props = formatProperties(input, event, tagHandlers) lines.push(`+SEQ${style} ${props}`.replace(/\s+/g, ' ').trimEnd()) stack.push(event) - } else if (event.type === EVENT_MAPPING) { - const style = event.style === COLLECTION_STYLE_FLOW ? ' {}' : '' + } else if (event.type === EVENT_ID.MAPPING) { + const style = event.style === COLLECTION_STYLE.FLOW ? ' {}' : '' const props = formatProperties(input, event, tagHandlers) lines.push(`+MAP${style} ${props}`.replace(/\s+/g, ' ').trimEnd()) stack.push(event) - } else if (event.type === EVENT_SCALAR) { + } else if (event.type === EVENT_ID.SCALAR) { const props = formatProperties(input, event, tagHandlers) const value = escapeTreeValue(getScalarValue(input, event)) lines.push(`=VAL ${props}${scalarStyleMarker(event.style)}${value}`) - } else if (event.type === EVENT_ALIAS) { + } else if (event.type === EVENT_ID.ALIAS) { lines.push(`=ALI *${formatRange(input, event.anchorStart, event.anchorEnd)}`) - } else if (event.type === EVENT_POP) { + } else if (event.type === EVENT_ID.POP) { const opened = stack.pop() - if (opened?.type === EVENT_DOCUMENT) { + if (opened?.type === EVENT_ID.DOCUMENT) { lines.push(opened.explicitEnd ? '-DOC ...' : '-DOC') - } else if (opened?.type === EVENT_SEQUENCE) { + } else if (opened?.type === EVENT_ID.SEQUENCE) { lines.push('-SEQ') - } else if (opened?.type === EVENT_MAPPING) { + } else if (opened?.type === EVENT_ID.MAPPING) { lines.push('-MAP') } } diff --git a/typedoc.config.mjs b/typedoc.config.mjs new file mode 100644 index 00000000..cf4f7aaa --- /dev/null +++ b/typedoc.config.mjs @@ -0,0 +1,52 @@ +import { Converter } from 'typedoc' +import condensedThemePlugin from './support/typedoc_condensed_theme/condensed_theme_plugin.mjs' + +// Places CHANGELOG.md in Documents without adding TypeDoc frontmatter to it. +function changelogPlugin (app) { + app.converter.on(Converter.EVENT_CREATE_DOCUMENT, (_context, document) => { + if (document.name !== 'CHANGELOG') return + + document.frontmatter.category = 'Documents' + }) +} + +export default { + entryPoints: ['src/index.ts'], + projectDocuments: [ + 'docs/safety.md', + 'docs/usage.md', + 'docs/custom_tags.md', + 'docs/migrate_v4_to_v5.md', + 'docs/schemas_info.md', + 'docs/tags_info.md', + 'CHANGELOG.md' + ], + plugin: [changelogPlugin, condensedThemePlugin], + alwaysCreateEntryPointModule: false, + excludeInternal: true, + out: 'demo/doc', + theme: 'condensed', + includeVersion: true, + markdownLinkExternal: true, + sourceLinkExternal: true, + sourceLinkTemplate: 'https://github.com/nodeca/js-yaml/blob/{gitRevision:short}/{path}#L{line}', + navigationLinks: { + GitHub: 'https://github.com/nodeca/js-yaml' + }, + defaultCategory: 'missed (default)', + categoryOrder: [ + 'Main', + 'Documents', + 'Schemas', + 'Tags', + 'Events', + 'Nodes', + 'AST', + '*', + 'missed (default)' + ], + sort: ['source-order'], + navigation: { + includeCategories: true + } +} diff --git a/typedoc.json b/typedoc.json deleted file mode 100644 index b6e7964e..00000000 --- a/typedoc.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "$schema": "https://typedoc.org/schema.json", - "entryPoints": ["src/index.ts"], - "out": "demo/doc", - "includeVersion": true, - "markdownLinkExternal": true, - "sourceLinkExternal": true, - "sourceLinkTemplate": "https://github.com/nodeca/js-yaml/blob/{gitRevision:short}/{path}#L{line}", - "navigationLinks": { - "GitHub": "https://github.com/nodeca/js-yaml" - }, - "defaultCategory": "missed (default)", - "categoryOrder": [ - "Main", - "Schema", - "Tags", - "Events", - "Nodes", - "AST", - "*", - "other", - "missed (default)" - ], - "sort": ["source-order"], - "navigation": { - "includeCategories": true - } -}