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    const jsModuleResults = await worker.transform(
89      config,
90      projectRoot,
91      filename,
92      Buffer.from(results.output),
93      options
94    );
95
96    const cssCode = results.css.toString();
97    const output: JsOutput[] = [
98      {
99        type: 'js/module',
100        data: {
101          // @ts-expect-error
102          ...jsModuleResults.output[0]?.data,
103
104          // Append additional css metadata for static extraction.
105          css: {
106            code: cssCode,
107            lineCount: countLines(cssCode),
108            map: [],
109            functionMap: null,
110          },
111        },
112      },
113    ];
114
115    return {
116      dependencies: jsModuleResults.dependencies,
117      output,
118    };
119  }
120
121  // Global CSS:
122
123  const { transform } = await import('lightningcss');
124
125  // TODO: Add bundling to resolve imports
126  // https://lightningcss.dev/bundling.html#bundling-order
127
128  const cssResults = transform({
129    filename,
130    code: Buffer.from(code),
131    sourceMap: false,
132    cssModules: false,
133    projectRoot,
134    minify: options.minify,
135  });
136
137  // TODO: Warnings:
138  // cssResults.warnings.forEach((warning) => {
139  // });
140
141  // Create a mock JS module that exports an empty object,
142  // this ensures Metro dependency graph is correct.
143  const jsModuleResults = await worker.transform(
144    config,
145    projectRoot,
146    filename,
147    options.dev ? Buffer.from(wrapDevelopmentCSS({ src: code, filename })) : Buffer.from(''),
148    options
149  );
150
151  const cssCode = cssResults.code.toString();
152
153  // In production, we export the CSS as a string and use a special type to prevent
154  // it from being included in the JS bundle. We'll extract the CSS like an asset later
155  // and append it to the HTML bundle.
156  const output: JsOutput[] = [
157    {
158      type: 'js/module',
159      data: {
160        // @ts-expect-error
161        ...jsModuleResults.output[0]?.data,
162
163        // Append additional css metadata for static extraction.
164        css: {
165          code: cssCode,
166          lineCount: countLines(cssCode),
167          map: [],
168          functionMap: null,
169        },
170      },
171    },
172  ];
173
174  return {
175    dependencies: jsModuleResults.dependencies,
176    output,
177  };
178}
179
180/**
181 * A custom Metro transformer that adds support for processing Expo-specific bundler features.
182 * - Global CSS files on web.
183 * - CSS Modules on web.
184 * - TODO: Tailwind CSS on web.
185 */
186module.exports = {
187  // Use defaults for everything that's not custom.
188  ...worker,
189  transform,
190};
191