index.js 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. "use strict"
  2. // builtin tooling
  3. const path = require("path")
  4. // internal tooling
  5. const joinMedia = require("./lib/join-media")
  6. const resolveId = require("./lib/resolve-id")
  7. const loadContent = require("./lib/load-content")
  8. const processContent = require("./lib/process-content")
  9. const parseStatements = require("./lib/parse-statements")
  10. function AtImport(options) {
  11. options = {
  12. root: process.cwd(),
  13. path: [],
  14. skipDuplicates: true,
  15. resolve: resolveId,
  16. load: loadContent,
  17. plugins: [],
  18. addModulesDirectories: [],
  19. ...options,
  20. }
  21. options.root = path.resolve(options.root)
  22. // convert string to an array of a single element
  23. if (typeof options.path === "string") options.path = [options.path]
  24. if (!Array.isArray(options.path)) options.path = []
  25. options.path = options.path.map(p => path.resolve(options.root, p))
  26. return {
  27. postcssPlugin: "postcss-import",
  28. Once(styles, { result, atRule }) {
  29. const state = {
  30. importedFiles: {},
  31. hashFiles: {},
  32. }
  33. if (styles.source && styles.source.input && styles.source.input.file) {
  34. state.importedFiles[styles.source.input.file] = {}
  35. }
  36. if (options.plugins && !Array.isArray(options.plugins)) {
  37. throw new Error("plugins option must be an array")
  38. }
  39. return parseStyles(result, styles, options, state, []).then(bundle => {
  40. applyRaws(bundle)
  41. applyMedia(bundle)
  42. applyStyles(bundle, styles)
  43. })
  44. function applyRaws(bundle) {
  45. bundle.forEach((stmt, index) => {
  46. if (index === 0) return
  47. if (stmt.parent) {
  48. const { before } = stmt.parent.node.raws
  49. if (stmt.type === "nodes") stmt.nodes[0].raws.before = before
  50. else stmt.node.raws.before = before
  51. } else if (stmt.type === "nodes") {
  52. stmt.nodes[0].raws.before = stmt.nodes[0].raws.before || "\n"
  53. }
  54. })
  55. }
  56. function applyMedia(bundle) {
  57. bundle.forEach(stmt => {
  58. if (!stmt.media.length) return
  59. if (stmt.type === "import") {
  60. stmt.node.params = `${stmt.fullUri} ${stmt.media.join(", ")}`
  61. } else if (stmt.type === "media")
  62. stmt.node.params = stmt.media.join(", ")
  63. else {
  64. const { nodes } = stmt
  65. const { parent } = nodes[0]
  66. const mediaNode = atRule({
  67. name: "media",
  68. params: stmt.media.join(", "),
  69. source: parent.source,
  70. })
  71. parent.insertBefore(nodes[0], mediaNode)
  72. // remove nodes
  73. nodes.forEach(node => {
  74. node.parent = undefined
  75. })
  76. // better output
  77. nodes[0].raws.before = nodes[0].raws.before || "\n"
  78. // wrap new rules with media query
  79. mediaNode.append(nodes)
  80. stmt.type = "media"
  81. stmt.node = mediaNode
  82. delete stmt.nodes
  83. }
  84. })
  85. }
  86. function applyStyles(bundle, styles) {
  87. styles.nodes = []
  88. // Strip additional statements.
  89. bundle.forEach(stmt => {
  90. if (["charset", "import", "media"].includes(stmt.type)) {
  91. stmt.node.parent = undefined
  92. styles.append(stmt.node)
  93. } else if (stmt.type === "nodes") {
  94. stmt.nodes.forEach(node => {
  95. node.parent = undefined
  96. styles.append(node)
  97. })
  98. }
  99. })
  100. }
  101. function parseStyles(result, styles, options, state, media) {
  102. const statements = parseStatements(result, styles)
  103. return Promise.resolve(statements)
  104. .then(stmts => {
  105. // process each statement in series
  106. return stmts.reduce((promise, stmt) => {
  107. return promise.then(() => {
  108. stmt.media = joinMedia(media, stmt.media || [])
  109. // skip protocol base uri (protocol://url) or protocol-relative
  110. if (
  111. stmt.type !== "import" ||
  112. /^(?:[a-z]+:)?\/\//i.test(stmt.uri)
  113. ) {
  114. return
  115. }
  116. if (options.filter && !options.filter(stmt.uri)) {
  117. // rejected by filter
  118. return
  119. }
  120. return resolveImportId(result, stmt, options, state)
  121. })
  122. }, Promise.resolve())
  123. })
  124. .then(() => {
  125. let charset
  126. const imports = []
  127. const bundle = []
  128. function handleCharset(stmt) {
  129. if (!charset) charset = stmt
  130. // charsets aren't case-sensitive, so convert to lower case to compare
  131. else if (
  132. stmt.node.params.toLowerCase() !==
  133. charset.node.params.toLowerCase()
  134. ) {
  135. throw new Error(
  136. `Incompatable @charset statements:
  137. ${stmt.node.params} specified in ${stmt.node.source.input.file}
  138. ${charset.node.params} specified in ${charset.node.source.input.file}`
  139. )
  140. }
  141. }
  142. // squash statements and their children
  143. statements.forEach(stmt => {
  144. if (stmt.type === "charset") handleCharset(stmt)
  145. else if (stmt.type === "import") {
  146. if (stmt.children) {
  147. stmt.children.forEach((child, index) => {
  148. if (child.type === "import") imports.push(child)
  149. else if (child.type === "charset") handleCharset(child)
  150. else bundle.push(child)
  151. // For better output
  152. if (index === 0) child.parent = stmt
  153. })
  154. } else imports.push(stmt)
  155. } else if (stmt.type === "media" || stmt.type === "nodes") {
  156. bundle.push(stmt)
  157. }
  158. })
  159. return charset
  160. ? [charset, ...imports.concat(bundle)]
  161. : imports.concat(bundle)
  162. })
  163. }
  164. function resolveImportId(result, stmt, options, state) {
  165. const atRule = stmt.node
  166. let sourceFile
  167. if (atRule.source && atRule.source.input && atRule.source.input.file) {
  168. sourceFile = atRule.source.input.file
  169. }
  170. const base = sourceFile
  171. ? path.dirname(atRule.source.input.file)
  172. : options.root
  173. return Promise.resolve(options.resolve(stmt.uri, base, options))
  174. .then(paths => {
  175. if (!Array.isArray(paths)) paths = [paths]
  176. // Ensure that each path is absolute:
  177. return Promise.all(
  178. paths.map(file => {
  179. return !path.isAbsolute(file)
  180. ? resolveId(file, base, options)
  181. : file
  182. })
  183. )
  184. })
  185. .then(resolved => {
  186. // Add dependency messages:
  187. resolved.forEach(file => {
  188. result.messages.push({
  189. type: "dependency",
  190. plugin: "postcss-import",
  191. file,
  192. parent: sourceFile,
  193. })
  194. })
  195. return Promise.all(
  196. resolved.map(file => {
  197. return loadImportContent(result, stmt, file, options, state)
  198. })
  199. )
  200. })
  201. .then(result => {
  202. // Merge loaded statements
  203. stmt.children = result.reduce((result, statements) => {
  204. return statements ? result.concat(statements) : result
  205. }, [])
  206. })
  207. }
  208. function loadImportContent(result, stmt, filename, options, state) {
  209. const atRule = stmt.node
  210. const { media } = stmt
  211. if (options.skipDuplicates) {
  212. // skip files already imported at the same scope
  213. if (
  214. state.importedFiles[filename] &&
  215. state.importedFiles[filename][media]
  216. ) {
  217. return
  218. }
  219. // save imported files to skip them next time
  220. if (!state.importedFiles[filename]) state.importedFiles[filename] = {}
  221. state.importedFiles[filename][media] = true
  222. }
  223. return Promise.resolve(options.load(filename, options)).then(
  224. content => {
  225. if (content.trim() === "") {
  226. result.warn(`${filename} is empty`, { node: atRule })
  227. return
  228. }
  229. // skip previous imported files not containing @import rules
  230. if (state.hashFiles[content] && state.hashFiles[content][media])
  231. return
  232. return processContent(result, content, filename, options).then(
  233. importedResult => {
  234. const styles = importedResult.root
  235. result.messages = result.messages.concat(
  236. importedResult.messages
  237. )
  238. if (options.skipDuplicates) {
  239. const hasImport = styles.some(child => {
  240. return child.type === "atrule" && child.name === "import"
  241. })
  242. if (!hasImport) {
  243. // save hash files to skip them next time
  244. if (!state.hashFiles[content]) state.hashFiles[content] = {}
  245. state.hashFiles[content][media] = true
  246. }
  247. }
  248. // recursion: import @import from imported file
  249. return parseStyles(result, styles, options, state, media)
  250. }
  251. )
  252. }
  253. )
  254. }
  255. },
  256. }
  257. }
  258. AtImport.postcss = true
  259. module.exports = AtImport