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