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