index.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643
  1. /**
  2. * @import {Node, Point, Position} from 'unist'
  3. * @import {Options as MessageOptions} from 'vfile-message'
  4. * @import {Compatible, Data, Map, Options, Value} from 'vfile'
  5. */
  6. /**
  7. * @typedef {object & {type: string, position?: Position | undefined}} NodeLike
  8. */
  9. import {VFileMessage} from 'vfile-message'
  10. import {minpath} from '#minpath'
  11. import {minproc} from '#minproc'
  12. import {urlToPath, isUrl} from '#minurl'
  13. /**
  14. * Order of setting (least specific to most), we need this because otherwise
  15. * `{stem: 'a', path: '~/b.js'}` would throw, as a path is needed before a
  16. * stem can be set.
  17. */
  18. const order = /** @type {const} */ ([
  19. 'history',
  20. 'path',
  21. 'basename',
  22. 'stem',
  23. 'extname',
  24. 'dirname'
  25. ])
  26. export class VFile {
  27. /**
  28. * Create a new virtual file.
  29. *
  30. * `options` is treated as:
  31. *
  32. * * `string` or `Uint8Array` — `{value: options}`
  33. * * `URL` — `{path: options}`
  34. * * `VFile` — shallow copies its data over to the new file
  35. * * `object` — all fields are shallow copied over to the new file
  36. *
  37. * Path related fields are set in the following order (least specific to
  38. * most specific): `history`, `path`, `basename`, `stem`, `extname`,
  39. * `dirname`.
  40. *
  41. * You cannot set `dirname` or `extname` without setting either `history`,
  42. * `path`, `basename`, or `stem` too.
  43. *
  44. * @param {Compatible | null | undefined} [value]
  45. * File value.
  46. * @returns
  47. * New instance.
  48. */
  49. constructor(value) {
  50. /** @type {Options | VFile} */
  51. let options
  52. if (!value) {
  53. options = {}
  54. } else if (isUrl(value)) {
  55. options = {path: value}
  56. } else if (typeof value === 'string' || isUint8Array(value)) {
  57. options = {value}
  58. } else {
  59. options = value
  60. }
  61. /* eslint-disable no-unused-expressions */
  62. /**
  63. * Base of `path` (default: `process.cwd()` or `'/'` in browsers).
  64. *
  65. * @type {string}
  66. */
  67. // Prevent calling `cwd` (which could be expensive) if it’s not needed;
  68. // the empty string will be overridden in the next block.
  69. this.cwd = 'cwd' in options ? '' : minproc.cwd()
  70. /**
  71. * Place to store custom info (default: `{}`).
  72. *
  73. * It’s OK to store custom data directly on the file but moving it to
  74. * `data` is recommended.
  75. *
  76. * @type {Data}
  77. */
  78. this.data = {}
  79. /**
  80. * List of file paths the file moved between.
  81. *
  82. * The first is the original path and the last is the current path.
  83. *
  84. * @type {Array<string>}
  85. */
  86. this.history = []
  87. /**
  88. * List of messages associated with the file.
  89. *
  90. * @type {Array<VFileMessage>}
  91. */
  92. this.messages = []
  93. /**
  94. * Raw value.
  95. *
  96. * @type {Value}
  97. */
  98. this.value
  99. // The below are non-standard, they are “well-known”.
  100. // As in, used in several tools.
  101. /**
  102. * Source map.
  103. *
  104. * This type is equivalent to the `RawSourceMap` type from the `source-map`
  105. * module.
  106. *
  107. * @type {Map | null | undefined}
  108. */
  109. this.map
  110. /**
  111. * Custom, non-string, compiled, representation.
  112. *
  113. * This is used by unified to store non-string results.
  114. * One example is when turning markdown into React nodes.
  115. *
  116. * @type {unknown}
  117. */
  118. this.result
  119. /**
  120. * Whether a file was saved to disk.
  121. *
  122. * This is used by vfile reporters.
  123. *
  124. * @type {boolean}
  125. */
  126. this.stored
  127. /* eslint-enable no-unused-expressions */
  128. // Set path related properties in the correct order.
  129. let index = -1
  130. while (++index < order.length) {
  131. const field = order[index]
  132. // Note: we specifically use `in` instead of `hasOwnProperty` to accept
  133. // `vfile`s too.
  134. if (
  135. field in options &&
  136. options[field] !== undefined &&
  137. options[field] !== null
  138. ) {
  139. // @ts-expect-error: TS doesn’t understand basic reality.
  140. this[field] = field === 'history' ? [...options[field]] : options[field]
  141. }
  142. }
  143. /** @type {string} */
  144. let field
  145. // Set non-path related properties.
  146. for (field in options) {
  147. // @ts-expect-error: fine to set other things.
  148. if (!order.includes(field)) {
  149. // @ts-expect-error: fine to set other things.
  150. this[field] = options[field]
  151. }
  152. }
  153. }
  154. /**
  155. * Get the basename (including extname) (example: `'index.min.js'`).
  156. *
  157. * @returns {string | undefined}
  158. * Basename.
  159. */
  160. get basename() {
  161. return typeof this.path === 'string'
  162. ? minpath.basename(this.path)
  163. : undefined
  164. }
  165. /**
  166. * Set basename (including extname) (`'index.min.js'`).
  167. *
  168. * Cannot contain path separators (`'/'` on unix, macOS, and browsers, `'\'`
  169. * on windows).
  170. * Cannot be nullified (use `file.path = file.dirname` instead).
  171. *
  172. * @param {string} basename
  173. * Basename.
  174. * @returns {undefined}
  175. * Nothing.
  176. */
  177. set basename(basename) {
  178. assertNonEmpty(basename, 'basename')
  179. assertPart(basename, 'basename')
  180. this.path = minpath.join(this.dirname || '', basename)
  181. }
  182. /**
  183. * Get the parent path (example: `'~'`).
  184. *
  185. * @returns {string | undefined}
  186. * Dirname.
  187. */
  188. get dirname() {
  189. return typeof this.path === 'string'
  190. ? minpath.dirname(this.path)
  191. : undefined
  192. }
  193. /**
  194. * Set the parent path (example: `'~'`).
  195. *
  196. * Cannot be set if there’s no `path` yet.
  197. *
  198. * @param {string | undefined} dirname
  199. * Dirname.
  200. * @returns {undefined}
  201. * Nothing.
  202. */
  203. set dirname(dirname) {
  204. assertPath(this.basename, 'dirname')
  205. this.path = minpath.join(dirname || '', this.basename)
  206. }
  207. /**
  208. * Get the extname (including dot) (example: `'.js'`).
  209. *
  210. * @returns {string | undefined}
  211. * Extname.
  212. */
  213. get extname() {
  214. return typeof this.path === 'string'
  215. ? minpath.extname(this.path)
  216. : undefined
  217. }
  218. /**
  219. * Set the extname (including dot) (example: `'.js'`).
  220. *
  221. * Cannot contain path separators (`'/'` on unix, macOS, and browsers, `'\'`
  222. * on windows).
  223. * Cannot be set if there’s no `path` yet.
  224. *
  225. * @param {string | undefined} extname
  226. * Extname.
  227. * @returns {undefined}
  228. * Nothing.
  229. */
  230. set extname(extname) {
  231. assertPart(extname, 'extname')
  232. assertPath(this.dirname, 'extname')
  233. if (extname) {
  234. if (extname.codePointAt(0) !== 46 /* `.` */) {
  235. throw new Error('`extname` must start with `.`')
  236. }
  237. if (extname.includes('.', 1)) {
  238. throw new Error('`extname` cannot contain multiple dots')
  239. }
  240. }
  241. this.path = minpath.join(this.dirname, this.stem + (extname || ''))
  242. }
  243. /**
  244. * Get the full path (example: `'~/index.min.js'`).
  245. *
  246. * @returns {string}
  247. * Path.
  248. */
  249. get path() {
  250. return this.history[this.history.length - 1]
  251. }
  252. /**
  253. * Set the full path (example: `'~/index.min.js'`).
  254. *
  255. * Cannot be nullified.
  256. * You can set a file URL (a `URL` object with a `file:` protocol) which will
  257. * be turned into a path with `url.fileURLToPath`.
  258. *
  259. * @param {URL | string} path
  260. * Path.
  261. * @returns {undefined}
  262. * Nothing.
  263. */
  264. set path(path) {
  265. if (isUrl(path)) {
  266. path = urlToPath(path)
  267. }
  268. assertNonEmpty(path, 'path')
  269. if (this.path !== path) {
  270. this.history.push(path)
  271. }
  272. }
  273. /**
  274. * Get the stem (basename w/o extname) (example: `'index.min'`).
  275. *
  276. * @returns {string | undefined}
  277. * Stem.
  278. */
  279. get stem() {
  280. return typeof this.path === 'string'
  281. ? minpath.basename(this.path, this.extname)
  282. : undefined
  283. }
  284. /**
  285. * Set the stem (basename w/o extname) (example: `'index.min'`).
  286. *
  287. * Cannot contain path separators (`'/'` on unix, macOS, and browsers, `'\'`
  288. * on windows).
  289. * Cannot be nullified (use `file.path = file.dirname` instead).
  290. *
  291. * @param {string} stem
  292. * Stem.
  293. * @returns {undefined}
  294. * Nothing.
  295. */
  296. set stem(stem) {
  297. assertNonEmpty(stem, 'stem')
  298. assertPart(stem, 'stem')
  299. this.path = minpath.join(this.dirname || '', stem + (this.extname || ''))
  300. }
  301. // Normal prototypal methods.
  302. /**
  303. * Create a fatal message for `reason` associated with the file.
  304. *
  305. * The `fatal` field of the message is set to `true` (error; file not usable)
  306. * and the `file` field is set to the current file path.
  307. * The message is added to the `messages` field on `file`.
  308. *
  309. * > 🪦 **Note**: also has obsolete signatures.
  310. *
  311. * @overload
  312. * @param {string} reason
  313. * @param {MessageOptions | null | undefined} [options]
  314. * @returns {never}
  315. *
  316. * @overload
  317. * @param {string} reason
  318. * @param {Node | NodeLike | null | undefined} parent
  319. * @param {string | null | undefined} [origin]
  320. * @returns {never}
  321. *
  322. * @overload
  323. * @param {string} reason
  324. * @param {Point | Position | null | undefined} place
  325. * @param {string | null | undefined} [origin]
  326. * @returns {never}
  327. *
  328. * @overload
  329. * @param {string} reason
  330. * @param {string | null | undefined} [origin]
  331. * @returns {never}
  332. *
  333. * @overload
  334. * @param {Error | VFileMessage} cause
  335. * @param {Node | NodeLike | null | undefined} parent
  336. * @param {string | null | undefined} [origin]
  337. * @returns {never}
  338. *
  339. * @overload
  340. * @param {Error | VFileMessage} cause
  341. * @param {Point | Position | null | undefined} place
  342. * @param {string | null | undefined} [origin]
  343. * @returns {never}
  344. *
  345. * @overload
  346. * @param {Error | VFileMessage} cause
  347. * @param {string | null | undefined} [origin]
  348. * @returns {never}
  349. *
  350. * @param {Error | VFileMessage | string} causeOrReason
  351. * Reason for message, should use markdown.
  352. * @param {Node | NodeLike | MessageOptions | Point | Position | string | null | undefined} [optionsOrParentOrPlace]
  353. * Configuration (optional).
  354. * @param {string | null | undefined} [origin]
  355. * Place in code where the message originates (example:
  356. * `'my-package:my-rule'` or `'my-rule'`).
  357. * @returns {never}
  358. * Never.
  359. * @throws {VFileMessage}
  360. * Message.
  361. */
  362. fail(causeOrReason, optionsOrParentOrPlace, origin) {
  363. // @ts-expect-error: the overloads are fine.
  364. const message = this.message(causeOrReason, optionsOrParentOrPlace, origin)
  365. message.fatal = true
  366. throw message
  367. }
  368. /**
  369. * Create an info message for `reason` associated with the file.
  370. *
  371. * The `fatal` field of the message is set to `undefined` (info; change
  372. * likely not needed) and the `file` field is set to the current file path.
  373. * The message is added to the `messages` field on `file`.
  374. *
  375. * > 🪦 **Note**: also has obsolete signatures.
  376. *
  377. * @overload
  378. * @param {string} reason
  379. * @param {MessageOptions | null | undefined} [options]
  380. * @returns {VFileMessage}
  381. *
  382. * @overload
  383. * @param {string} reason
  384. * @param {Node | NodeLike | null | undefined} parent
  385. * @param {string | null | undefined} [origin]
  386. * @returns {VFileMessage}
  387. *
  388. * @overload
  389. * @param {string} reason
  390. * @param {Point | Position | null | undefined} place
  391. * @param {string | null | undefined} [origin]
  392. * @returns {VFileMessage}
  393. *
  394. * @overload
  395. * @param {string} reason
  396. * @param {string | null | undefined} [origin]
  397. * @returns {VFileMessage}
  398. *
  399. * @overload
  400. * @param {Error | VFileMessage} cause
  401. * @param {Node | NodeLike | null | undefined} parent
  402. * @param {string | null | undefined} [origin]
  403. * @returns {VFileMessage}
  404. *
  405. * @overload
  406. * @param {Error | VFileMessage} cause
  407. * @param {Point | Position | null | undefined} place
  408. * @param {string | null | undefined} [origin]
  409. * @returns {VFileMessage}
  410. *
  411. * @overload
  412. * @param {Error | VFileMessage} cause
  413. * @param {string | null | undefined} [origin]
  414. * @returns {VFileMessage}
  415. *
  416. * @param {Error | VFileMessage | string} causeOrReason
  417. * Reason for message, should use markdown.
  418. * @param {Node | NodeLike | MessageOptions | Point | Position | string | null | undefined} [optionsOrParentOrPlace]
  419. * Configuration (optional).
  420. * @param {string | null | undefined} [origin]
  421. * Place in code where the message originates (example:
  422. * `'my-package:my-rule'` or `'my-rule'`).
  423. * @returns {VFileMessage}
  424. * Message.
  425. */
  426. info(causeOrReason, optionsOrParentOrPlace, origin) {
  427. // @ts-expect-error: the overloads are fine.
  428. const message = this.message(causeOrReason, optionsOrParentOrPlace, origin)
  429. message.fatal = undefined
  430. return message
  431. }
  432. /**
  433. * Create a message for `reason` associated with the file.
  434. *
  435. * The `fatal` field of the message is set to `false` (warning; change may be
  436. * needed) and the `file` field is set to the current file path.
  437. * The message is added to the `messages` field on `file`.
  438. *
  439. * > 🪦 **Note**: also has obsolete signatures.
  440. *
  441. * @overload
  442. * @param {string} reason
  443. * @param {MessageOptions | null | undefined} [options]
  444. * @returns {VFileMessage}
  445. *
  446. * @overload
  447. * @param {string} reason
  448. * @param {Node | NodeLike | null | undefined} parent
  449. * @param {string | null | undefined} [origin]
  450. * @returns {VFileMessage}
  451. *
  452. * @overload
  453. * @param {string} reason
  454. * @param {Point | Position | null | undefined} place
  455. * @param {string | null | undefined} [origin]
  456. * @returns {VFileMessage}
  457. *
  458. * @overload
  459. * @param {string} reason
  460. * @param {string | null | undefined} [origin]
  461. * @returns {VFileMessage}
  462. *
  463. * @overload
  464. * @param {Error | VFileMessage} cause
  465. * @param {Node | NodeLike | null | undefined} parent
  466. * @param {string | null | undefined} [origin]
  467. * @returns {VFileMessage}
  468. *
  469. * @overload
  470. * @param {Error | VFileMessage} cause
  471. * @param {Point | Position | null | undefined} place
  472. * @param {string | null | undefined} [origin]
  473. * @returns {VFileMessage}
  474. *
  475. * @overload
  476. * @param {Error | VFileMessage} cause
  477. * @param {string | null | undefined} [origin]
  478. * @returns {VFileMessage}
  479. *
  480. * @param {Error | VFileMessage | string} causeOrReason
  481. * Reason for message, should use markdown.
  482. * @param {Node | NodeLike | MessageOptions | Point | Position | string | null | undefined} [optionsOrParentOrPlace]
  483. * Configuration (optional).
  484. * @param {string | null | undefined} [origin]
  485. * Place in code where the message originates (example:
  486. * `'my-package:my-rule'` or `'my-rule'`).
  487. * @returns {VFileMessage}
  488. * Message.
  489. */
  490. message(causeOrReason, optionsOrParentOrPlace, origin) {
  491. const message = new VFileMessage(
  492. // @ts-expect-error: the overloads are fine.
  493. causeOrReason,
  494. optionsOrParentOrPlace,
  495. origin
  496. )
  497. if (this.path) {
  498. message.name = this.path + ':' + message.name
  499. message.file = this.path
  500. }
  501. message.fatal = false
  502. this.messages.push(message)
  503. return message
  504. }
  505. /**
  506. * Serialize the file.
  507. *
  508. * > **Note**: which encodings are supported depends on the engine.
  509. * > For info on Node.js, see:
  510. * > <https://nodejs.org/api/util.html#whatwg-supported-encodings>.
  511. *
  512. * @param {string | null | undefined} [encoding='utf8']
  513. * Character encoding to understand `value` as when it’s a `Uint8Array`
  514. * (default: `'utf-8'`).
  515. * @returns {string}
  516. * Serialized file.
  517. */
  518. toString(encoding) {
  519. if (this.value === undefined) {
  520. return ''
  521. }
  522. if (typeof this.value === 'string') {
  523. return this.value
  524. }
  525. const decoder = new TextDecoder(encoding || undefined)
  526. return decoder.decode(this.value)
  527. }
  528. }
  529. /**
  530. * Assert that `part` is not a path (as in, does not contain `path.sep`).
  531. *
  532. * @param {string | null | undefined} part
  533. * File path part.
  534. * @param {string} name
  535. * Part name.
  536. * @returns {undefined}
  537. * Nothing.
  538. */
  539. function assertPart(part, name) {
  540. if (part && part.includes(minpath.sep)) {
  541. throw new Error(
  542. '`' + name + '` cannot be a path: did not expect `' + minpath.sep + '`'
  543. )
  544. }
  545. }
  546. /**
  547. * Assert that `part` is not empty.
  548. *
  549. * @param {string | undefined} part
  550. * Thing.
  551. * @param {string} name
  552. * Part name.
  553. * @returns {asserts part is string}
  554. * Nothing.
  555. */
  556. function assertNonEmpty(part, name) {
  557. if (!part) {
  558. throw new Error('`' + name + '` cannot be empty')
  559. }
  560. }
  561. /**
  562. * Assert `path` exists.
  563. *
  564. * @param {string | undefined} path
  565. * Path.
  566. * @param {string} name
  567. * Dependency name.
  568. * @returns {asserts path is string}
  569. * Nothing.
  570. */
  571. function assertPath(path, name) {
  572. if (!path) {
  573. throw new Error('Setting `' + name + '` requires `path` to be set too')
  574. }
  575. }
  576. /**
  577. * Assert `value` is an `Uint8Array`.
  578. *
  579. * @param {unknown} value
  580. * thing.
  581. * @returns {value is Uint8Array}
  582. * Whether `value` is an `Uint8Array`.
  583. */
  584. function isUint8Array(value) {
  585. return Boolean(
  586. value &&
  587. typeof value === 'object' &&
  588. 'byteLength' in value &&
  589. 'byteOffset' in value
  590. )
  591. }