/** * @typedef {import('unist').Node} Node * @typedef {import('unist').Point} Point * @typedef {import('unist').Position} Position * @typedef {import('vfile-message').Options} MessageOptions * @typedef {import('../index.js').Data} Data * @typedef {import('../index.js').Value} Value */ /** * @typedef {object & {type: string, position?: Position | undefined}} NodeLike * * @typedef {Options | URL | VFile | Value} Compatible * Things that can be passed to the constructor. * * @typedef VFileCoreOptions * Set multiple values. * @property {string | null | undefined} [basename] * Set `basename` (name). * @property {string | null | undefined} [cwd] * Set `cwd` (working directory). * @property {Data | null | undefined} [data] * Set `data` (associated info). * @property {string | null | undefined} [dirname] * Set `dirname` (path w/o basename). * @property {string | null | undefined} [extname] * Set `extname` (extension with dot). * @property {Array | null | undefined} [history] * Set `history` (paths the file moved between). * @property {URL | string | null | undefined} [path] * Set `path` (current path). * @property {string | null | undefined} [stem] * Set `stem` (name without extension). * @property {Value | null | undefined} [value] * Set `value` (the contents of the file). * * @typedef Map * Raw source map. * * See: * . * @property {number} version * Which version of the source map spec this map is following. * @property {Array} sources * An array of URLs to the original source files. * @property {Array} names * An array of identifiers which can be referenced by individual mappings. * @property {string | undefined} [sourceRoot] * The URL root from which all sources are relative. * @property {Array | undefined} [sourcesContent] * An array of contents of the original source files. * @property {string} mappings * A string of base64 VLQs which contain the actual mappings. * @property {string} file * The generated file this source map is associated with. * * @typedef {Record & VFileCoreOptions} Options * Configuration. * * A bunch of keys that will be shallow copied over to the new file. * * @typedef {Record} ReporterSettings * Configuration for reporters. */ /** * @template [Settings=ReporterSettings] * Options type. * @callback Reporter * Type for a reporter. * @param {Array} files * Files to report. * @param {Settings} options * Configuration. * @returns {string} * Report. */ import {VFileMessage} from 'vfile-message' import {path} from 'vfile/do-not-use-conditional-minpath' import {proc} from 'vfile/do-not-use-conditional-minproc' import {urlToPath, isUrl} from 'vfile/do-not-use-conditional-minurl' /** * Order of setting (least specific to most), we need this because otherwise * `{stem: 'a', path: '~/b.js'}` would throw, as a path is needed before a * stem can be set. */ const order = /** @type {const} */ ([ 'history', 'path', 'basename', 'stem', 'extname', 'dirname' ]) export class VFile { /** * Create a new virtual file. * * `options` is treated as: * * * `string` or `Uint8Array` — `{value: options}` * * `URL` — `{path: options}` * * `VFile` — shallow copies its data over to the new file * * `object` — all fields are shallow copied over to the new file * * Path related fields are set in the following order (least specific to * most specific): `history`, `path`, `basename`, `stem`, `extname`, * `dirname`. * * You cannot set `dirname` or `extname` without setting either `history`, * `path`, `basename`, or `stem` too. * * @param {Compatible | null | undefined} [value] * File value. * @returns * New instance. */ constructor(value) { /** @type {Options | VFile} */ let options if (!value) { options = {} } else if (isUrl(value)) { options = {path: value} } else if (typeof value === 'string' || isUint8Array(value)) { options = {value} } else { options = value } /* eslint-disable no-unused-expressions */ /** * Base of `path` (default: `process.cwd()` or `'/'` in browsers). * * @type {string} */ this.cwd = proc.cwd() /** * Place to store custom info (default: `{}`). * * It’s OK to store custom data directly on the file but moving it to * `data` is recommended. * * @type {Data} */ this.data = {} /** * List of file paths the file moved between. * * The first is the original path and the last is the current path. * * @type {Array} */ this.history = [] /** * List of messages associated with the file. * * @type {Array} */ this.messages = [] /** * Raw value. * * @type {Value} */ this.value // The below are non-standard, they are “well-known”. // As in, used in several tools. /** * Source map. * * This type is equivalent to the `RawSourceMap` type from the `source-map` * module. * * @type {Map | null | undefined} */ this.map /** * Custom, non-string, compiled, representation. * * This is used by unified to store non-string results. * One example is when turning markdown into React nodes. * * @type {unknown} */ this.result /** * Whether a file was saved to disk. * * This is used by vfile reporters. * * @type {boolean} */ this.stored /* eslint-enable no-unused-expressions */ // Set path related properties in the correct order. let index = -1 while (++index < order.length) { const prop = order[index] // Note: we specifically use `in` instead of `hasOwnProperty` to accept // `vfile`s too. if ( prop in options && options[prop] !== undefined && options[prop] !== null ) { // @ts-expect-error: TS doesn’t understand basic reality. this[prop] = prop === 'history' ? [...options[prop]] : options[prop] } } /** @type {string} */ let prop // Set non-path related properties. for (prop in options) { // @ts-expect-error: fine to set other things. if (!order.includes(prop)) { // @ts-expect-error: fine to set other things. this[prop] = options[prop] } } } /** * Get the basename (including extname) (example: `'index.min.js'`). * * @returns {string | undefined} * Basename. */ get basename() { return typeof this.path === 'string' ? path.basename(this.path) : undefined } /** * Set basename (including extname) (`'index.min.js'`). * * Cannot contain path separators (`'/'` on unix, macOS, and browsers, `'\'` * on windows). * Cannot be nullified (use `file.path = file.dirname` instead). * * @param {string} basename * Basename. * @returns {undefined} * Nothing. */ set basename(basename) { assertNonEmpty(basename, 'basename') assertPart(basename, 'basename') this.path = path.join(this.dirname || '', basename) } /** * Get the parent path (example: `'~'`). * * @returns {string | undefined} * Dirname. */ get dirname() { return typeof this.path === 'string' ? path.dirname(this.path) : undefined } /** * Set the parent path (example: `'~'`). * * Cannot be set if there’s no `path` yet. * * @param {string | undefined} dirname * Dirname. * @returns {undefined} * Nothing. */ set dirname(dirname) { assertPath(this.basename, 'dirname') this.path = path.join(dirname || '', this.basename) } /** * Get the extname (including dot) (example: `'.js'`). * * @returns {string | undefined} * Extname. */ get extname() { return typeof this.path === 'string' ? path.extname(this.path) : undefined } /** * Set the extname (including dot) (example: `'.js'`). * * Cannot contain path separators (`'/'` on unix, macOS, and browsers, `'\'` * on windows). * Cannot be set if there’s no `path` yet. * * @param {string | undefined} extname * Extname. * @returns {undefined} * Nothing. */ set extname(extname) { assertPart(extname, 'extname') assertPath(this.dirname, 'extname') if (extname) { if (extname.codePointAt(0) !== 46 /* `.` */) { throw new Error('`extname` must start with `.`') } if (extname.includes('.', 1)) { throw new Error('`extname` cannot contain multiple dots') } } this.path = path.join(this.dirname, this.stem + (extname || '')) } /** * Get the full path (example: `'~/index.min.js'`). * * @returns {string} * Path. */ get path() { return this.history[this.history.length - 1] } /** * Set the full path (example: `'~/index.min.js'`). * * Cannot be nullified. * You can set a file URL (a `URL` object with a `file:` protocol) which will * be turned into a path with `url.fileURLToPath`. * * @param {URL | string} path * Path. * @returns {undefined} * Nothing. */ set path(path) { if (isUrl(path)) { path = urlToPath(path) } assertNonEmpty(path, 'path') if (this.path !== path) { this.history.push(path) } } /** * Get the stem (basename w/o extname) (example: `'index.min'`). * * @returns {string | undefined} * Stem. */ get stem() { return typeof this.path === 'string' ? path.basename(this.path, this.extname) : undefined } /** * Set the stem (basename w/o extname) (example: `'index.min'`). * * Cannot contain path separators (`'/'` on unix, macOS, and browsers, `'\'` * on windows). * Cannot be nullified (use `file.path = file.dirname` instead). * * @param {string} stem * Stem. * @returns {undefined} * Nothing. */ set stem(stem) { assertNonEmpty(stem, 'stem') assertPart(stem, 'stem') this.path = path.join(this.dirname || '', stem + (this.extname || '')) } // Normal prototypal methods. /** * Create a fatal message for `reason` associated with the file. * * The `fatal` field of the message is set to `true` (error; file not usable) * and the `file` field is set to the current file path. * The message is added to the `messages` field on `file`. * * > 🪦 **Note**: also has obsolete signatures. * * @overload * @param {string} reason * @param {MessageOptions | null | undefined} [options] * @returns {never} * * @overload * @param {string} reason * @param {Node | NodeLike | null | undefined} parent * @param {string | null | undefined} [origin] * @returns {never} * * @overload * @param {string} reason * @param {Point | Position | null | undefined} place * @param {string | null | undefined} [origin] * @returns {never} * * @overload * @param {string} reason * @param {string | null | undefined} [origin] * @returns {never} * * @overload * @param {Error | VFileMessage} cause * @param {Node | NodeLike | null | undefined} parent * @param {string | null | undefined} [origin] * @returns {never} * * @overload * @param {Error | VFileMessage} cause * @param {Point | Position | null | undefined} place * @param {string | null | undefined} [origin] * @returns {never} * * @overload * @param {Error | VFileMessage} cause * @param {string | null | undefined} [origin] * @returns {never} * * @param {Error | VFileMessage | string} causeOrReason * Reason for message, should use markdown. * @param {Node | NodeLike | MessageOptions | Point | Position | string | null | undefined} [optionsOrParentOrPlace] * Configuration (optional). * @param {string | null | undefined} [origin] * Place in code where the message originates (example: * `'my-package:my-rule'` or `'my-rule'`). * @returns {never} * Never. * @throws {VFileMessage} * Message. */ fail(causeOrReason, optionsOrParentOrPlace, origin) { // @ts-expect-error: the overloads are fine. const message = this.message(causeOrReason, optionsOrParentOrPlace, origin) message.fatal = true throw message } /** * Create an info message for `reason` associated with the file. * * The `fatal` field of the message is set to `undefined` (info; change * likely not needed) and the `file` field is set to the current file path. * The message is added to the `messages` field on `file`. * * > 🪦 **Note**: also has obsolete signatures. * * @overload * @param {string} reason * @param {MessageOptions | null | undefined} [options] * @returns {VFileMessage} * * @overload * @param {string} reason * @param {Node | NodeLike | null | undefined} parent * @param {string | null | undefined} [origin] * @returns {VFileMessage} * * @overload * @param {string} reason * @param {Point | Position | null | undefined} place * @param {string | null | undefined} [origin] * @returns {VFileMessage} * * @overload * @param {string} reason * @param {string | null | undefined} [origin] * @returns {VFileMessage} * * @overload * @param {Error | VFileMessage} cause * @param {Node | NodeLike | null | undefined} parent * @param {string | null | undefined} [origin] * @returns {VFileMessage} * * @overload * @param {Error | VFileMessage} cause * @param {Point | Position | null | undefined} place * @param {string | null | undefined} [origin] * @returns {VFileMessage} * * @overload * @param {Error | VFileMessage} cause * @param {string | null | undefined} [origin] * @returns {VFileMessage} * * @param {Error | VFileMessage | string} causeOrReason * Reason for message, should use markdown. * @param {Node | NodeLike | MessageOptions | Point | Position | string | null | undefined} [optionsOrParentOrPlace] * Configuration (optional). * @param {string | null | undefined} [origin] * Place in code where the message originates (example: * `'my-package:my-rule'` or `'my-rule'`). * @returns {VFileMessage} * Message. */ info(causeOrReason, optionsOrParentOrPlace, origin) { // @ts-expect-error: the overloads are fine. const message = this.message(causeOrReason, optionsOrParentOrPlace, origin) message.fatal = undefined return message } /** * Create a message for `reason` associated with the file. * * The `fatal` field of the message is set to `false` (warning; change may be * needed) and the `file` field is set to the current file path. * The message is added to the `messages` field on `file`. * * > 🪦 **Note**: also has obsolete signatures. * * @overload * @param {string} reason * @param {MessageOptions | null | undefined} [options] * @returns {VFileMessage} * * @overload * @param {string} reason * @param {Node | NodeLike | null | undefined} parent * @param {string | null | undefined} [origin] * @returns {VFileMessage} * * @overload * @param {string} reason * @param {Point | Position | null | undefined} place * @param {string | null | undefined} [origin] * @returns {VFileMessage} * * @overload * @param {string} reason * @param {string | null | undefined} [origin] * @returns {VFileMessage} * * @overload * @param {Error | VFileMessage} cause * @param {Node | NodeLike | null | undefined} parent * @param {string | null | undefined} [origin] * @returns {VFileMessage} * * @overload * @param {Error | VFileMessage} cause * @param {Point | Position | null | undefined} place * @param {string | null | undefined} [origin] * @returns {VFileMessage} * * @overload * @param {Error | VFileMessage} cause * @param {string | null | undefined} [origin] * @returns {VFileMessage} * * @param {Error | VFileMessage | string} causeOrReason * Reason for message, should use markdown. * @param {Node | NodeLike | MessageOptions | Point | Position | string | null | undefined} [optionsOrParentOrPlace] * Configuration (optional). * @param {string | null | undefined} [origin] * Place in code where the message originates (example: * `'my-package:my-rule'` or `'my-rule'`). * @returns {VFileMessage} * Message. */ message(causeOrReason, optionsOrParentOrPlace, origin) { const message = new VFileMessage( // @ts-expect-error: the overloads are fine. causeOrReason, optionsOrParentOrPlace, origin ) if (this.path) { message.name = this.path + ':' + message.name message.file = this.path } message.fatal = false this.messages.push(message) return message } /** * Serialize the file. * * > **Note**: which encodings are supported depends on the engine. * > For info on Node.js, see: * > . * * @param {string | null | undefined} [encoding='utf8'] * Character encoding to understand `value` as when it’s a `Uint8Array` * (default: `'utf-8'`). * @returns {string} * Serialized file. */ toString(encoding) { if (this.value === undefined) { return '' } if (typeof this.value === 'string') { return this.value } const decoder = new TextDecoder(encoding || undefined) return decoder.decode(this.value) } } /** * Assert that `part` is not a path (as in, does not contain `path.sep`). * * @param {string | null | undefined} part * File path part. * @param {string} name * Part name. * @returns {undefined} * Nothing. */ function assertPart(part, name) { if (part && part.includes(path.sep)) { throw new Error( '`' + name + '` cannot be a path: did not expect `' + path.sep + '`' ) } } /** * Assert that `part` is not empty. * * @param {string | undefined} part * Thing. * @param {string} name * Part name. * @returns {asserts part is string} * Nothing. */ function assertNonEmpty(part, name) { if (!part) { throw new Error('`' + name + '` cannot be empty') } } /** * Assert `path` exists. * * @param {string | undefined} path * Path. * @param {string} name * Dependency name. * @returns {asserts path is string} * Nothing. */ function assertPath(path, name) { if (!path) { throw new Error('Setting `' + name + '` requires `path` to be set too') } } /** * Assert `value` is an `Uint8Array`. * * @param {unknown} value * thing. * @returns {value is Uint8Array} * Whether `value` is an `Uint8Array`. */ function isUint8Array(value) { return Boolean( value && typeof value === 'object' && 'byteLength' in value && 'byteOffset' in value ) }