1/**
2 * Copyright 2023-present 650 Industries (Expo). All rights reserved.
3 * Copyright (c) Meta Platforms, Inc. and affiliates.
4 *
5 * This source code is licensed under the MIT license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8import { FBSourceFunctionMap, MetroSourceMapSegmentTuple } from 'metro-source-map';
9import worker, {
10  JsTransformerConfig,
11  JsTransformOptions,
12  TransformResponse,
13} from 'metro-transform-worker';
14
15import { wrapDevelopmentCSS } from './css';
16import { matchCssModule, transformCssModuleWeb } from './css-modules';
17import { transformPostCssModule } from './postcss';
18import { compileSass, matchSass } from './sass';
19
20const countLines = require('metro/src/lib/countLines') as (string: string) => number;
21
22type JSFileType = 'js/script' | 'js/module' | 'js/module/asset';
23
24type JsOutput = {
25  data: {
26    code: string;
27    lineCount: number;
28    map: MetroSourceMapSegmentTuple[];
29    functionMap: FBSourceFunctionMap | null;
30  };
31  type: JSFileType;
32};
33
34export async function transform(
35  config: JsTransformerConfig,
36  projectRoot: string,
37  filename: string,
38  data: Buffer,
39  options: JsTransformOptions
40): Promise<TransformResponse> {
41  const isCss = options.type !== 'asset' && /\.(s?css|sass)$/.test(filename);
42  // If the file is not CSS, then use the default behavior.
43  if (!isCss) {
44    const environment = options.customTransformOptions?.environment;
45
46    if (
47      environment !== 'node' &&
48      // TODO: Ensure this works with windows.
49      (filename.match(new RegExp(`^app/\\+html(\\.${options.platform})?\\.([tj]sx?|[cm]js)?$`)) ||
50        // Strip +api files.
51        filename.match(/\+api(\.(native|ios|android|web))?\.[tj]sx?$/))
52    ) {
53      // Remove the server-only +html file and API Routes from the bundle when bundling for a client environment.
54      return worker.transform(
55        config,
56        projectRoot,
57        filename,
58        !options.minify
59          ? Buffer.from(
60              // Use a string so this notice is visible in the bundle if the user is
61              // looking for it.
62              '"> The server-only file was removed from the client JS bundle by Expo CLI."'
63            )
64          : Buffer.from(''),
65        options
66      );
67    }
68
69    if (
70      environment !== 'node' &&
71      !filename.match(/\/node_modules\//) &&
72      filename.match(/\+api(\.(native|ios|android|web))?\.[tj]sx?$/)
73    ) {
74      // Clear the contents of +api files when bundling for the client.
75      // This ensures that the client doesn't accidentally use the server-only +api files.
76      return worker.transform(config, projectRoot, filename, Buffer.from(''), options);
77    }
78
79    return worker.transform(config, projectRoot, filename, data, options);
80  }
81
82  // If the platform is not web, then return an empty module.
83  if (options.platform !== 'web') {
84    const code = matchCssModule(filename) ? 'module.exports={ unstable_styles: {} };' : '';
85    return worker.transform(
86      config,
87      projectRoot,
88      filename,
89      // TODO: Native CSS Modules
90      Buffer.from(code),
91      options
92    );
93  }
94
95  let code = data.toString('utf8');
96
97  // Apply postcss transforms
98  code = await transformPostCssModule(projectRoot, {
99    src: code,
100    filename,
101  });
102
103  // TODO: When native has CSS support, this will need to move higher up.
104  const syntax = matchSass(filename);
105  if (syntax) {
106    code = compileSass(projectRoot, { filename, src: code }, { syntax }).src;
107  }
108
109  // If the file is a CSS Module, then transform it to a JS module
110  // in development and a static CSS file in production.
111  if (matchCssModule(filename)) {
112    const results = await transformCssModuleWeb({
113      filename,
114      src: code,
115      options: {
116        projectRoot,
117        dev: options.dev,
118        minify: options.minify,
119        sourceMap: false,
120      },
121    });
122
123    const jsModuleResults = await worker.transform(
124      config,
125      projectRoot,
126      filename,
127      Buffer.from(results.output),
128      options
129    );
130
131    const cssCode = results.css.toString();
132    const output: JsOutput[] = [
133      {
134        type: 'js/module',
135        data: {
136          // @ts-expect-error
137          ...jsModuleResults.output[0]?.data,
138
139          // Append additional css metadata for static extraction.
140          css: {
141            code: cssCode,
142            lineCount: countLines(cssCode),
143            map: [],
144            functionMap: null,
145          },
146        },
147      },
148    ];
149
150    return {
151      dependencies: jsModuleResults.dependencies,
152      output,
153    };
154  }
155
156  // Global CSS:
157
158  const { transform } = require('lightningcss') as typeof import('lightningcss');
159
160  // TODO: Add bundling to resolve imports
161  // https://lightningcss.dev/bundling.html#bundling-order
162
163  const cssResults = transform({
164    filename,
165    code: Buffer.from(code),
166    sourceMap: false,
167    cssModules: false,
168    projectRoot,
169    minify: options.minify,
170  });
171
172  // TODO: Warnings:
173  // cssResults.warnings.forEach((warning) => {
174  // });
175
176  // Create a mock JS module that exports an empty object,
177  // this ensures Metro dependency graph is correct.
178  const jsModuleResults = await worker.transform(
179    config,
180    projectRoot,
181    filename,
182    options.dev ? Buffer.from(wrapDevelopmentCSS({ src: code, filename })) : Buffer.from(''),
183    options
184  );
185
186  const cssCode = cssResults.code.toString();
187
188  // In production, we export the CSS as a string and use a special type to prevent
189  // it from being included in the JS bundle. We'll extract the CSS like an asset later
190  // and append it to the HTML bundle.
191  const output: JsOutput[] = [
192    {
193      type: 'js/module',
194      data: {
195        // @ts-expect-error
196        ...jsModuleResults.output[0]?.data,
197
198        // Append additional css metadata for static extraction.
199        css: {
200          code: cssCode,
201          lineCount: countLines(cssCode),
202          map: [],
203          functionMap: null,
204        },
205      },
206    },
207  ];
208
209  return {
210    dependencies: jsModuleResults.dependencies,
211    output,
212  };
213}
214
215/**
216 * A custom Metro transformer that adds support for processing Expo-specific bundler features.
217 * - Global CSS files on web.
218 * - CSS Modules on web.
219 * - TODO: Tailwind CSS on web.
220 */
221module.exports = {
222  // Use defaults for everything that's not custom.
223  ...worker,
224  transform,
225};
226