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