/** * @import {Node, Point, Position} from 'unist' * @import {Options as MessageOptions} from 'vfile-message' * @import {Compatible, Data, Map, Options, Value} from 'vfile' */ /** * @typedef {object & {type: string, position?: Position | undefined}} NodeLike */ import {VFileMessage} from 'vfile-message' import {minpath} from '#minpath' import {minproc} from '#minproc' import {urlToPath, isUrl} from '#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} */ // Prevent calling `cwd` (which could be expensive) if it’s not needed; // the empty string will be overridden in the next block. this.cwd = 'cwd' in options ? '' : minproc.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 field = order[index] // Note: we specifically use `in` instead of `hasOwnProperty` to accept // `vfile`s too. if ( field in options && options[field] !== undefined && options[field] !== null ) { // @ts-expect-error: TS doesn’t understand basic reality. this[field] = field === 'history' ? [...options[field]] : options[field] } } /** @type {string} */ let field // Set non-path related properties. for (field in options) { // @ts-expect-error: fine to set other things. if (!order.includes(field)) { // @ts-expect-error: fine to set other things. this[field] = options[field] } } } /** * Get the basename (including extname) (example: `'index.min.js'`). * * @returns {string | undefined} * Basename. */ get basename() { return typeof this.path === 'string' ? minpath.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 = minpath.join(this.dirname || '', basename) } /** * Get the parent path (example: `'~'`). * * @returns {string | undefined} * Dirname. */ get dirname() { return typeof this.path === 'string' ? minpath.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 = minpath.join(dirname || '', this.basename) } /** * Get the extname (including dot) (example: `'.js'`). * * @returns {string | undefined} * Extname. */ get extname() { return typeof this.path === 'string' ? minpath.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 = minpath.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' ? minpath.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 = minpath.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(minpath.sep)) { throw new Error( '`' + name + '` cannot be a path: did not expect `' + minpath.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 ) }