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