1/** 2 * Copyright © 2023 650 Industries. 3 * Copyright JS Foundation and other contributors 4 * 5 * https://github.com/webpack-contrib/postcss-loader/ 6 */ 7import JsonFile from '@expo/json-file'; 8import fs from 'fs'; 9import path from 'path'; 10import type { AcceptedPlugin, ProcessOptions } from 'postcss'; 11import resolveFrom from 'resolve-from'; 12 13import { requireUncachedFile, tryRequireThenImport } from './utils/require'; 14 15type PostCSSInputConfig = { 16 plugins?: any[]; 17 from?: string; 18 to?: string; 19 syntax?: string; 20 map?: boolean; 21 parser?: string; 22 stringifier?: string; 23}; 24 25const CONFIG_FILE_NAME = 'postcss.config'; 26 27const debug = require('debug')('expo:metro:transformer:postcss'); 28 29export async function transformPostCssModule( 30 projectRoot: string, 31 { src, filename }: { src: string; filename: string } 32): Promise<string> { 33 const inputConfig = resolvePostcssConfig(projectRoot); 34 35 if (!inputConfig) { 36 return src; 37 } 38 39 return await processWithPostcssInputConfigAsync(projectRoot, { 40 inputConfig, 41 src, 42 filename, 43 }); 44} 45 46async function processWithPostcssInputConfigAsync( 47 projectRoot: string, 48 { src, filename, inputConfig }: { src: string; filename: string; inputConfig: PostCSSInputConfig } 49) { 50 const { plugins, processOptions } = await parsePostcssConfigAsync(projectRoot, { 51 config: inputConfig, 52 resourcePath: filename, 53 }); 54 55 debug('options:', processOptions); 56 debug('plugins:', plugins); 57 58 // TODO: Surely this can be cached... 59 const postcss = require('postcss') as typeof import('postcss'); 60 61 const processor = postcss.default(plugins); 62 const { content } = await processor.process(src, processOptions); 63 64 return content; 65} 66 67async function parsePostcssConfigAsync( 68 projectRoot: string, 69 { 70 resourcePath: file, 71 config: { plugins: inputPlugins, map, parser, stringifier, syntax, ...config } = {}, 72 }: { 73 resourcePath: string; 74 config: PostCSSInputConfig; 75 } 76): Promise<{ plugins: AcceptedPlugin[]; processOptions: ProcessOptions }> { 77 const factory = pluginFactory(); 78 79 factory(inputPlugins); 80 // delete config.plugins; 81 82 const plugins = [...factory()].map((item) => { 83 const [plugin, options] = item; 84 85 if (typeof plugin === 'string') { 86 return loadPlugin(projectRoot, plugin, options, file); 87 } 88 89 return plugin; 90 }); 91 92 if (config.from) { 93 config.from = path.resolve(projectRoot, config.from); 94 } 95 96 if (config.to) { 97 config.to = path.resolve(projectRoot, config.to); 98 } 99 100 const processOptions: Partial<ProcessOptions> = { 101 from: file, 102 to: file, 103 map: false, 104 }; 105 106 if (typeof parser === 'string') { 107 try { 108 processOptions.parser = await tryRequireThenImport( 109 resolveFrom.silent(projectRoot, parser) ?? parser 110 ); 111 } catch (error: unknown) { 112 if (error instanceof Error) { 113 throw new Error( 114 `Loading PostCSS "${parser}" parser failed: ${error.message}\n\n(@${file})` 115 ); 116 } 117 throw error; 118 } 119 } 120 121 if (typeof stringifier === 'string') { 122 try { 123 processOptions.stringifier = await tryRequireThenImport( 124 resolveFrom.silent(projectRoot, stringifier) ?? stringifier 125 ); 126 } catch (error: unknown) { 127 if (error instanceof Error) { 128 throw new Error( 129 `Loading PostCSS "${stringifier}" stringifier failed: ${error.message}\n\n(@${file})` 130 ); 131 } 132 throw error; 133 } 134 } 135 136 if (typeof syntax === 'string') { 137 try { 138 processOptions.syntax = await tryRequireThenImport( 139 resolveFrom.silent(projectRoot, syntax) ?? syntax 140 ); 141 } catch (error: any) { 142 throw new Error(`Loading PostCSS "${syntax}" syntax failed: ${error.message}\n\n(@${file})`); 143 } 144 } 145 146 if (map === true) { 147 // https://github.com/postcss/postcss/blob/master/docs/source-maps.md 148 processOptions.map = { inline: true }; 149 } 150 151 return { plugins, processOptions }; 152} 153 154function loadPlugin(projectRoot: string, plugin: string, options: unknown, file: string) { 155 try { 156 debug('load plugin:', plugin); 157 158 // e.g. `tailwindcss` 159 let loadedPlugin = require(resolveFrom(projectRoot, plugin)); 160 161 if (loadedPlugin.default) { 162 loadedPlugin = loadedPlugin.default; 163 } 164 165 if (!options || !Object.keys(options).length) { 166 return loadedPlugin; 167 } 168 169 return loadedPlugin(options); 170 } catch (error: unknown) { 171 if (error instanceof Error) { 172 throw new Error(`Loading PostCSS "${plugin}" plugin failed: ${error.message}\n\n(@${file})`); 173 } 174 throw error; 175 } 176} 177 178export function pluginFactory() { 179 const listOfPlugins = new Map<string, any>(); 180 181 return (plugins?: any) => { 182 if (typeof plugins === 'undefined') { 183 return listOfPlugins; 184 } 185 186 if (Array.isArray(plugins)) { 187 for (const plugin of plugins) { 188 if (Array.isArray(plugin)) { 189 const [name, options] = plugin; 190 191 if (typeof name !== 'string') { 192 throw new Error( 193 `PostCSS plugin must be a string, but "${name}" was found. Please check your configuration.` 194 ); 195 } 196 197 listOfPlugins.set(name, options); 198 } else if (plugin && typeof plugin === 'function') { 199 listOfPlugins.set(plugin, undefined); 200 } else if ( 201 plugin && 202 Object.keys(plugin).length === 1 && 203 (typeof plugin[Object.keys(plugin)[0]] === 'object' || 204 typeof plugin[Object.keys(plugin)[0]] === 'boolean') && 205 plugin[Object.keys(plugin)[0]] !== null 206 ) { 207 const [name] = Object.keys(plugin); 208 const options = plugin[name]; 209 210 if (options === false) { 211 listOfPlugins.delete(name); 212 } else { 213 listOfPlugins.set(name, options); 214 } 215 } else if (plugin) { 216 listOfPlugins.set(plugin, undefined); 217 } 218 } 219 } else { 220 const objectPlugins = Object.entries(plugins); 221 222 for (const [name, options] of objectPlugins) { 223 if (options === false) { 224 listOfPlugins.delete(name); 225 } else { 226 listOfPlugins.set(name, options); 227 } 228 } 229 } 230 231 return listOfPlugins; 232 }; 233} 234 235export function resolvePostcssConfig(projectRoot: string): PostCSSInputConfig | null { 236 // TODO: Maybe support platform-specific postcss config files in the future. 237 const jsConfigPath = path.join(projectRoot, CONFIG_FILE_NAME + '.js'); 238 239 if (fs.existsSync(jsConfigPath)) { 240 debug('load file:', jsConfigPath); 241 return requireUncachedFile(jsConfigPath); 242 } 243 244 const jsonConfigPath = path.join(projectRoot, CONFIG_FILE_NAME + '.json'); 245 246 if (fs.existsSync(jsonConfigPath)) { 247 debug('load file:', jsonConfigPath); 248 return JsonFile.read(jsonConfigPath, { json5: true }); 249 } 250 251 return null; 252} 253 254export function getPostcssConfigHash(projectRoot: string): string | null { 255 // TODO: Maybe recurse plugins and add versions to the hash in the future. 256 const { stableHash } = require('metro-cache'); 257 258 const jsConfigPath = path.join(projectRoot, CONFIG_FILE_NAME + '.js'); 259 if (fs.existsSync(jsConfigPath)) { 260 return stableHash(fs.readFileSync(jsConfigPath, 'utf8')).toString('hex'); 261 } 262 263 const jsonConfigPath = path.join(projectRoot, CONFIG_FILE_NAME + '.json'); 264 if (fs.existsSync(jsonConfigPath)) { 265 return stableHash(fs.readFileSync(jsonConfigPath, 'utf8')).toString('hex'); 266 } 267 return null; 268} 269