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    return worker.transform(config, projectRoot, filename, data, options);
45  }
46
47  // If the platform is not web, then return an empty module.
48  if (options.platform !== 'web') {
49    const code = matchCssModule(filename) ? 'module.exports={};' : '';
50    return worker.transform(
51      config,
52      projectRoot,
53      filename,
54      // TODO: Native CSS Modules
55      Buffer.from(code),
56      options
57    );
58  }
59
60  let code = data.toString('utf8');
61
62  // Apply postcss transforms
63  code = await transformPostCssModule(projectRoot, {
64    src: code,
65    filename,
66  });
67
68  // TODO: When native has CSS support, this will need to move higher up.
69  const syntax = matchSass(filename);
70  if (syntax) {
71    code = compileSass(projectRoot, { filename, src: code }, { syntax }).src;
72  }
73
74  // If the file is a CSS Module, then transform it to a JS module
75  // in development and a static CSS file in production.
76  if (matchCssModule(filename)) {
77    const results = await transformCssModuleWeb({
78      filename,
79      src: code,
80      options: {
81        projectRoot,
82        dev: options.dev,
83        minify: options.minify,
84        sourceMap: false,
85      },
86    });
87
88    if (options.dev) {
89      // Dev has the CSS appended to the JS file.
90      return worker.transform(config, projectRoot, filename, Buffer.from(results.output), options);
91    }
92
93    const jsModuleResults = await worker.transform(
94      config,
95      projectRoot,
96      filename,
97      Buffer.from(results.output),
98      options
99    );
100
101    const cssCode = results.css.toString();
102    const output: JsOutput[] = [
103      {
104        type: 'js/module',
105        data: {
106          // @ts-expect-error
107          ...jsModuleResults.output[0].data,
108
109          // Append additional css metadata for static extraction.
110          css: {
111            code: cssCode,
112            lineCount: countLines(cssCode),
113            map: [],
114            functionMap: null,
115          },
116        },
117      },
118    ];
119
120    return {
121      dependencies: jsModuleResults.dependencies,
122      output,
123    };
124  }
125
126  // Global CSS:
127
128  if (options.dev) {
129    return worker.transform(
130      config,
131      projectRoot,
132      filename,
133      // In development, we use a JS file that appends a style tag to the
134      // document. This is necessary because we need to replace the style tag
135      // when the CSS changes.
136      // NOTE: We may change this to better support static rendering in the future.
137      Buffer.from(wrapDevelopmentCSS({ src: code, filename })),
138      options
139    );
140  }
141
142  const { transform } = await import('lightningcss');
143
144  // TODO: Add bundling to resolve imports
145  // https://lightningcss.dev/bundling.html#bundling-order
146
147  const cssResults = transform({
148    filename,
149    code: Buffer.from(code),
150    sourceMap: false,
151    cssModules: false,
152    projectRoot,
153    minify: options.minify,
154  });
155
156  // TODO: Warnings:
157  // cssResults.warnings.forEach((warning) => {
158  // });
159
160  // Create a mock JS module that exports an empty object,
161  // this ensures Metro dependency graph is correct.
162  const jsModuleResults = await worker.transform(
163    config,
164    projectRoot,
165    filename,
166    Buffer.from(''),
167    options
168  );
169
170  const cssCode = cssResults.code.toString();
171
172  // In production, we export the CSS as a string and use a special type to prevent
173  // it from being included in the JS bundle. We'll extract the CSS like an asset later
174  // and append it to the HTML bundle.
175  const output: JsOutput[] = [
176    {
177      data: {
178        // @ts-expect-error
179        ...jsModuleResults.output[0].data,
180
181        // Append additional css metadata for static extraction.
182        css: {
183          code: cssCode,
184          lineCount: countLines(cssCode),
185          map: [],
186          functionMap: null,
187        },
188      },
189      type: 'js/module',
190    },
191  ];
192
193  return {
194    dependencies: jsModuleResults.dependencies,
195    output,
196  };
197}
198
199/**
200 * A custom Metro transformer that adds support for processing Expo-specific bundler features.
201 * - Global CSS files on web.
202 * - CSS Modules on web.
203 * - TODO: Tailwind CSS on web.
204 */
205module.exports = {
206  // Use defaults for everything that's not custom.
207  ...worker,
208  transform,
209};
210