You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

344 lines
11 KiB

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