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