1import { MetroConfig } from '@expo/metro-config';
2import crypto from 'crypto';
3import type { Module } from 'metro';
4import { getJsOutput, isJsModule } from 'metro/src/DeltaBundler/Serializers/helpers/js';
5import type { ReadOnlyDependencies } from 'metro/src/DeltaBundler/types';
6import type IncrementalBundler from 'metro/src/IncrementalBundler';
7import splitBundleOptions from 'metro/src/lib/splitBundleOptions';
8import path from 'path';
9
10// import { getAssetData } from 'metro/src/Assets';
11
12type Options = {
13  processModuleFilter: (modules: Module) => boolean;
14  assetPlugins: readonly string[];
15  platform?: string | null;
16  projectRoot: string;
17  publicPath: string;
18};
19
20type MetroModuleCSSMetadata = {
21  code: string;
22  lineCount: number;
23  map: any[];
24};
25
26export type CSSAsset = {
27  // 'styles.css'
28  originFilename: string;
29  // '_expo/static/css/bc6aa0a69dcebf8e8cac1faa76705756.css'
30  filename: string;
31  // '\ndiv {\n    background: cyan;\n}\n\n'
32  source: string;
33};
34
35// s = static
36const STATIC_EXPORT_DIRECTORY = '_expo/static/css';
37
38/** @returns the static CSS assets used in a given bundle. CSS assets are only enabled if the `@expo/metro-config` `transformerPath` is used. */
39export async function getCssModulesFromBundler(
40  config: MetroConfig,
41  incrementalBundler: IncrementalBundler,
42  options: any
43): Promise<CSSAsset[]> {
44  // Static CSS is a web-only feature.
45  if (options.platform !== 'web') {
46    return [];
47  }
48
49  const { entryFile, onProgress, resolverOptions, transformOptions } = splitBundleOptions(options);
50
51  const dependencies = await incrementalBundler.getDependencies(
52    [entryFile],
53    transformOptions,
54    resolverOptions,
55    { onProgress, shallow: false }
56  );
57
58  return getCssModules(dependencies, {
59    processModuleFilter: config.serializer.processModuleFilter,
60    assetPlugins: config.transformer.assetPlugins,
61    platform: transformOptions.platform,
62    projectRoot: config.server.unstable_serverRoot ?? config.projectRoot,
63    publicPath: config.transformer.publicPath,
64  });
65}
66
67function hashString(str: string) {
68  return crypto.createHash('md5').update(str).digest('hex');
69}
70
71function getCssModules(
72  dependencies: ReadOnlyDependencies,
73  { processModuleFilter, projectRoot }: Options
74) {
75  const promises = [];
76
77  for (const module of dependencies.values()) {
78    if (
79      isJsModule(module) &&
80      processModuleFilter(module) &&
81      getJsOutput(module).type === 'js/module' &&
82      path.relative(projectRoot, module.path) !== 'package.json'
83    ) {
84      const cssMetadata = getCssMetadata(module);
85      if (cssMetadata) {
86        const contents = cssMetadata.code;
87        const filename = path.join(
88          // Consistent location
89          STATIC_EXPORT_DIRECTORY,
90          // Hashed file contents + name for caching
91          getFileName(module.path) + '-' + hashString(module.path + contents) + '.css'
92        );
93        promises.push({
94          originFilename: path.relative(projectRoot, module.path),
95          filename,
96          source: contents,
97        });
98      }
99    }
100  }
101
102  return promises;
103}
104
105function getCssMetadata(module: Module): MetroModuleCSSMetadata | null {
106  const data = module.output[0]?.data;
107  if (data && typeof data === 'object' && 'css' in data) {
108    if (typeof data.css !== 'object' || !('code' in (data as any).css)) {
109      throw new Error(
110        `Unexpected CSS metadata in Metro module (${module.path}): ${JSON.stringify(data.css)}`
111      );
112    }
113    return data.css as MetroModuleCSSMetadata;
114  }
115  return null;
116}
117
118export function getFileName(module: string) {
119  return path.basename(module).replace(/\.[^.]+$/, '');
120}
121