expo-modules-autolinking 0.0.3 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,102 @@
1
+
2
+ module Expo
3
+ module ProjectIntegrator
4
+ include Pod
5
+
6
+ # Integrates targets in the project and generates modules providers.
7
+ def self.integrate_targets_in_project(targets, project)
8
+ # Find the targets that use expo modules and need the modules provider
9
+ targets_with_modules_provider = targets.select do |target|
10
+ autolinking_manager = target.target_definition.autolinking_manager
11
+ autolinking_manager.present?
12
+ end
13
+
14
+ # Find existing PBXGroup for modules providers.
15
+ generated_group = modules_providers_group(project, targets_with_modules_provider.any?)
16
+
17
+ # Return early when the modules providers group has not been auto-created in the line above.
18
+ return if generated_group.nil?
19
+
20
+ # Remove existing groups for targets without modules provider.
21
+ generated_group.groups.each do |group|
22
+ # Remove the group if there is no target for this group.
23
+ if targets.none? { |target| target.target_definition.name == group.name && targets_with_modules_provider.include?(target) }
24
+ recursively_remove_group(group)
25
+ end
26
+ end
27
+
28
+ targets_with_modules_provider.sort_by(&:name).each do |target|
29
+ # The user target name (without `Pods-` prefix which is a part of `target.name`)
30
+ target_name = target.target_definition.name
31
+
32
+ UI.message '- Generating the provider for ' << target_name.green << ' target' do
33
+ # PBXNativeTarget of the user target
34
+ native_target = project.native_targets.find { |native_target| native_target.name == target_name }
35
+
36
+ # Shorthand ref for the autolinking manager.
37
+ autolinking_manager = target.target_definition.autolinking_manager
38
+
39
+ # Absolute path to `Pods/Target Support Files/<pods target name>/<modules provider file>` within the project path
40
+ modules_provider_path = File.join(target.support_files_dir, autolinking_manager.modules_provider_name)
41
+
42
+ # Run `expo-modules-autolinking` command to generate the file
43
+ autolinking_manager.generate_package_list(target_name, modules_provider_path)
44
+
45
+ # PBXGroup for generated files per target
46
+ generated_target_group = generated_group.find_subpath(target_name, true)
47
+
48
+ # PBXGroup uses relative paths, so we need to strip the absolute path
49
+ modules_provider_relative_path = Pathname.new(modules_provider_path).relative_path_from(generated_target_group.real_path).to_s
50
+
51
+ if generated_target_group.find_file_by_path(modules_provider_relative_path).nil?
52
+ # Create new PBXFileReference if the modules provider is not in the group yet
53
+ modules_provider_file_reference = generated_target_group.new_file(modules_provider_path)
54
+
55
+ if native_target.source_build_phase.files_references.find { |ref| ref.present? && ref.path == modules_provider_relative_path }.nil?
56
+ # Put newly created PBXFileReference to the source files of the native target
57
+ native_target.add_file_references([modules_provider_file_reference])
58
+ project.mark_dirty!
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ # Remove the generated group if it has nothing left inside
65
+ if targets_with_modules_provider.empty?
66
+ recursively_remove_group(generated_group)
67
+ end
68
+ end
69
+
70
+ def self.recursively_remove_group(group)
71
+ return if group.nil?
72
+
73
+ UI.message '- Removing ' << group.name.green << ' group' do
74
+ group.recursive_children.each do |child|
75
+ UI.message ' - Removing a reference to ' << child.name.green
76
+ child.remove_from_project
77
+ end
78
+
79
+ group.remove_from_project
80
+ group.project.mark_dirty!
81
+ end
82
+ end
83
+
84
+ # CocoaPods doesn't properly remove file references from the build phase
85
+ # They appear as nils and it's safe to just delete them from native targets
86
+ def self.remove_nils_from_source_files(project)
87
+ project.native_targets.each do |native_target|
88
+ native_target.source_build_phase.files.each do |build_file|
89
+ next unless build_file.file_ref.nil?
90
+
91
+ build_file.remove_from_project
92
+ project.mark_dirty!
93
+ end
94
+ end
95
+ end
96
+
97
+ def self.modules_providers_group(project, autocreate = false)
98
+ project.main_group.find_subpath(Constants::GENERATED_GROUP_NAME, autocreate)
99
+ end
100
+
101
+ end # module ExpoAutolinkingExtension
102
+ end # module Expo
@@ -0,0 +1,38 @@
1
+ import { RawExpoModuleConfig, SupportedPlatform } from './types';
2
+
3
+ /**
4
+ * A class that wraps the raw config (`expo-module.json` or `unimodule.json`).
5
+ */
6
+ export class ExpoModuleConfig {
7
+ constructor(readonly rawConfig: RawExpoModuleConfig) {}
8
+
9
+ /**
10
+ * Whether the module supports given platform.
11
+ */
12
+ supportsPlatform(platform: SupportedPlatform): boolean {
13
+ return this.rawConfig.platforms?.includes(platform) ?? false;
14
+ }
15
+
16
+ /**
17
+ * Returns a list of names of Swift native modules classes to put to the generated modules provider file.
18
+ */
19
+ iosModulesClassNames() {
20
+ return this.rawConfig.ios?.modulesClassNames ?? [];
21
+ }
22
+
23
+ /**
24
+ * Returns serializable raw config.
25
+ */
26
+ toJSON(): RawExpoModuleConfig {
27
+ return this.rawConfig;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Reads the config at given path and returns the config wrapped by `ExpoModuleConfig` class.
33
+ */
34
+ export function requireAndResolveExpoModuleConfig(path: string): ExpoModuleConfig {
35
+ // TODO: Validate the raw config against a schema.
36
+ // TODO: Support for `*.js` files, not only static `*.json`.
37
+ return new ExpoModuleConfig(require(path) as RawExpoModuleConfig);
38
+ }
@@ -2,8 +2,10 @@ import chalk from 'chalk';
2
2
  import glob from 'fast-glob';
3
3
  import findUp from 'find-up';
4
4
  import fs from 'fs-extra';
5
+ import { createRequire } from 'module';
5
6
  import path from 'path';
6
7
 
8
+ import { requireAndResolveExpoModuleConfig } from './ExpoModuleConfig';
7
9
  import {
8
10
  GenerateOptions,
9
11
  ModuleDescriptor,
@@ -13,8 +15,24 @@ import {
13
15
  SearchResults,
14
16
  } from './types';
15
17
 
16
- // TODO: Rename to `expo-module.json`
17
- const EXPO_MODULE_CONFIG_FILENAME = 'unimodule.json';
18
+ // Names of the config files. From lowest to highest priority.
19
+ const EXPO_MODULE_CONFIG_FILENAMES = ['unimodule.json', 'expo-module.config.json'];
20
+
21
+ /**
22
+ * Path to the `package.json` of the closest project in the current working dir.
23
+ */
24
+ const projectPackageJsonPath = findUp.sync('package.json', { cwd: process.cwd() }) as string;
25
+
26
+ // This won't happen in usual scenarios, but we need to unwrap the optional path :)
27
+ if (!projectPackageJsonPath) {
28
+ throw new Error(`Couldn't find "package.json" up from path "${process.cwd()}"`);
29
+ }
30
+
31
+ /**
32
+ * Custom `require` that resolves from the current working dir instead of this script path.
33
+ * **Requires Node v12.2.0**
34
+ */
35
+ const projectRequire = createRequire(projectPackageJsonPath);
18
36
 
19
37
  /**
20
38
  * Resolves autolinking search paths. If none is provided, it accumulates all node_modules when
@@ -25,17 +43,10 @@ export async function resolveSearchPathsAsync(
25
43
  cwd: string
26
44
  ): Promise<string[]> {
27
45
  return searchPaths && searchPaths.length > 0
28
- ? searchPaths.map(searchPath => path.resolve(cwd, searchPath))
46
+ ? searchPaths.map((searchPath) => path.resolve(cwd, searchPath))
29
47
  : await findDefaultPathsAsync(cwd);
30
48
  }
31
49
 
32
- /**
33
- * Finds project's package.json and returns its path.
34
- */
35
- export async function findPackageJsonPathAsync(): Promise<string | null> {
36
- return (await findUp('package.json', { cwd: process.cwd() })) ?? null;
37
- }
38
-
39
50
  /**
40
51
  * Looks up for workspace's `node_modules` paths.
41
52
  */
@@ -59,19 +70,32 @@ export async function findModulesAsync(providedOptions: SearchOptions): Promise<
59
70
  const results: SearchResults = {};
60
71
 
61
72
  for (const searchPath of options.searchPaths) {
62
- const paths = await glob(
63
- [`*/${EXPO_MODULE_CONFIG_FILENAME}`, `@*/*/${EXPO_MODULE_CONFIG_FILENAME}`],
64
- {
65
- cwd: searchPath,
66
- }
73
+ const bracedFilenames = '{' + EXPO_MODULE_CONFIG_FILENAMES.join(',') + '}';
74
+ const paths = await glob([`*/${bracedFilenames}`, `@*/*/${bracedFilenames}`], {
75
+ cwd: searchPath,
76
+ });
77
+
78
+ // If the package has multiple configs (e.g. `unimodule.json` and `expo-module.config.json` during the transition time)
79
+ // then we want to give `expo-module.config.json` the priority.
80
+ const uniqueConfigPaths: string[] = Object.values(
81
+ paths.reduce<Record<string, string>>((acc, configPath) => {
82
+ const dirname = path.dirname(configPath);
83
+
84
+ if (!acc[dirname] || configPriority(configPath) > configPriority(acc[dirname])) {
85
+ acc[dirname] = configPath;
86
+ }
87
+ return acc;
88
+ }, {})
67
89
  );
68
90
 
69
- for (const packageConfigPath of paths) {
91
+ for (const packageConfigPath of uniqueConfigPaths) {
70
92
  const packagePath = await fs.realpath(path.join(searchPath, path.dirname(packageConfigPath)));
71
- const packageConfig = require(path.join(packagePath, EXPO_MODULE_CONFIG_FILENAME));
93
+ const expoModuleConfig = requireAndResolveExpoModuleConfig(
94
+ path.join(packagePath, path.basename(packageConfigPath))
95
+ );
72
96
  const { name, version } = require(path.join(packagePath, 'package.json'));
73
97
 
74
- if (options.exclude?.includes(name) || !packageConfig.platforms?.includes(options.platform)) {
98
+ if (options.exclude?.includes(name) || !expoModuleConfig.supportsPlatform(options.platform)) {
75
99
  continue;
76
100
  }
77
101
 
@@ -82,8 +106,12 @@ export async function findModulesAsync(providedOptions: SearchOptions): Promise<
82
106
 
83
107
  if (!results[name]) {
84
108
  // The revision that was found first will be the main one.
85
- // An array of duplicates is needed only here.
86
- results[name] = { ...currentRevision, duplicates: [] };
109
+ // An array of duplicates and the config are needed only here.
110
+ results[name] = {
111
+ ...currentRevision,
112
+ config: expoModuleConfig,
113
+ duplicates: [],
114
+ };
87
115
  } else if (
88
116
  results[name].path !== packagePath &&
89
117
  results[name].duplicates?.every(({ path }) => path !== packagePath)
@@ -92,7 +120,69 @@ export async function findModulesAsync(providedOptions: SearchOptions): Promise<
92
120
  }
93
121
  }
94
122
  }
95
- return results;
123
+
124
+ // It doesn't make much sense to strip modules if there is only one search path.
125
+ // Workspace root usually doesn't specify all its dependencies (see Expo Go),
126
+ // so in this case we should link everything.
127
+ if (options.searchPaths.length <= 1) {
128
+ return results;
129
+ }
130
+ return filterToProjectDependencies(results);
131
+ }
132
+
133
+ /**
134
+ * Filters out packages that are not the dependencies of the project.
135
+ */
136
+ function filterToProjectDependencies(results: SearchResults) {
137
+ const filteredResults: SearchResults = {};
138
+ const visitedPackages = new Set<string>();
139
+
140
+ // Helper for traversing the dependency hierarchy.
141
+ function visitPackage(packageJsonPath: string) {
142
+ const packageJson = require(packageJsonPath);
143
+
144
+ // Prevent getting into the recursive loop.
145
+ if (visitedPackages.has(packageJson.name)) {
146
+ return;
147
+ }
148
+ visitedPackages.add(packageJson.name);
149
+
150
+ // Iterate over the dependencies to find transitive modules.
151
+ for (const dependencyName in packageJson.dependencies) {
152
+ const dependencyResult = results[dependencyName];
153
+
154
+ if (!filteredResults[dependencyName]) {
155
+ let dependencyPackageJsonPath: string;
156
+
157
+ if (dependencyResult) {
158
+ filteredResults[dependencyName] = dependencyResult;
159
+ dependencyPackageJsonPath = path.join(dependencyResult.path, 'package.json');
160
+ } else {
161
+ try {
162
+ dependencyPackageJsonPath = projectRequire.resolve(`${dependencyName}/package.json`);
163
+ } catch (error) {
164
+ // Some packages don't include package.json in its `exports` field,
165
+ // but none of our packages do that, so it seems fine to just ignore that type of error.
166
+ // Related issue: https://github.com/react-native-community/cli/issues/1168
167
+ if (error.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') {
168
+ console.warn(
169
+ chalk.yellow(`⚠️ Cannot resolve the path to "${dependencyName}" package.`)
170
+ );
171
+ }
172
+ continue;
173
+ }
174
+ }
175
+
176
+ // Visit the dependency package.
177
+ visitPackage(dependencyPackageJsonPath);
178
+ }
179
+ }
180
+ }
181
+
182
+ // Visit project's package.
183
+ visitPackage(projectPackageJsonPath);
184
+
185
+ return filteredResults;
96
186
  }
97
187
 
98
188
  /**
@@ -104,8 +194,7 @@ export async function findModulesAsync(providedOptions: SearchOptions): Promise<
104
194
  export async function mergeLinkingOptionsAsync<OptionsType extends SearchOptions>(
105
195
  providedOptions: OptionsType
106
196
  ): Promise<OptionsType> {
107
- const packageJsonPath = await findPackageJsonPathAsync();
108
- const packageJson = packageJsonPath ? require(packageJsonPath) : {};
197
+ const packageJson = require(projectPackageJsonPath);
109
198
  const baseOptions = packageJson.expo?.autolinking;
110
199
  const platformOptions = providedOptions.platform && baseOptions?.[providedOptions.platform];
111
200
  const finalOptions = Object.assign(
@@ -126,7 +215,7 @@ export async function mergeLinkingOptionsAsync<OptionsType extends SearchOptions
126
215
  */
127
216
  export function verifySearchResults(searchResults: SearchResults): number {
128
217
  const cwd = process.cwd();
129
- const relativePath: (pkg: PackageRevision) => string = pkg => path.relative(cwd, pkg.path);
218
+ const relativePath: (pkg: PackageRevision) => string = (pkg) => path.relative(cwd, pkg.path);
130
219
  let counter = 0;
131
220
 
132
221
  for (const moduleName in searchResults) {
@@ -198,3 +287,10 @@ export async function generatePackageListAsync(
198
287
  );
199
288
  }
200
289
  }
290
+
291
+ /**
292
+ * Returns the priority of the config at given path. Higher number means higher priority.
293
+ */
294
+ function configPriority(fullpath: string): number {
295
+ return EXPO_MODULE_CONFIG_FILENAMES.indexOf(path.basename(fullpath));
296
+ }
package/src/index.ts CHANGED
@@ -28,6 +28,11 @@ function registerSearchCommand<OptionsType extends SearchOptions>(
28
28
  'Package names to exclude when looking up for modules.',
29
29
  (value, previous) => (previous ?? []).concat(value)
30
30
  )
31
+ .option(
32
+ '-p, --platform [platform]',
33
+ 'The platform that the resulting modules must support. Available options: "ios", "android"',
34
+ 'ios'
35
+ )
31
36
  .action(async (searchPaths, providedOptions) => {
32
37
  const options = await mergeLinkingOptionsAsync<OptionsType>({
33
38
  ...providedOptions,
@@ -45,21 +50,21 @@ function registerResolveCommand<OptionsType extends ResolveOptions>(
45
50
  commandName: string,
46
51
  fn: (search: SearchResults, options: OptionsType) => any
47
52
  ) {
48
- return registerSearchCommand<OptionsType>(commandName, fn).option(
49
- '-p, --platform [platform]',
50
- 'The platform that the resulted modules must support. Available options: "ios", "android"',
51
- 'ios'
52
- );
53
+ return registerSearchCommand<OptionsType>(commandName, fn);
53
54
  }
54
55
 
55
- module.exports = async function(args: string[]) {
56
+ module.exports = async function (args: string[]) {
56
57
  // Searches for available expo modules.
57
- registerSearchCommand('search', async results => {
58
- console.log(require('util').inspect(results, false, null, true));
59
- });
58
+ registerSearchCommand<SearchOptions & { json?: boolean }>('search', async (results, options) => {
59
+ if (options.json) {
60
+ console.log(JSON.stringify(results));
61
+ } else {
62
+ console.log(require('util').inspect(results, false, null, true));
63
+ }
64
+ }).option<boolean>('-j, --json', 'Output results in the plain JSON format.', () => true, false);
60
65
 
61
66
  // Checks whether there are no resolving issues in the current setup.
62
- registerSearchCommand('verify', results => {
67
+ registerSearchCommand('verify', (results) => {
63
68
  const numberOfDuplicates = verifySearchResults(results);
64
69
  if (!numberOfDuplicates) {
65
70
  console.log('✅ Everything is fine!');
@@ -31,6 +31,12 @@ export async function resolveModuleAsync(
31
31
  cwd: revision.path,
32
32
  ignore: ['**/node_modules/**'],
33
33
  });
34
+
35
+ // Just in case where the module doesn't have its own `build.gradle`.
36
+ if (!buildGradleFile) {
37
+ return null;
38
+ }
39
+
34
40
  const sourceDir = path.dirname(path.join(revision.path, buildGradleFile));
35
41
 
36
42
  return {
@@ -46,20 +52,27 @@ async function generatePackageListFileContentAsync(
46
52
  modules: ModuleDescriptor[],
47
53
  namespace: string
48
54
  ): Promise<string> {
49
- const packagesClasses = await findAndroidPackagesAsync(modules);
55
+ // TODO: Instead of ignoring `expo` here, make the package class paths configurable from `expo-module.config.json`.
56
+ const packagesClasses = await findAndroidPackagesAsync(
57
+ modules.filter((module) => module.packageName !== 'expo')
58
+ );
50
59
 
51
60
  return `package ${namespace};
52
61
 
53
62
  import java.util.Arrays;
54
63
  import java.util.List;
55
- import org.unimodules.core.interfaces.Package;
64
+ import expo.modules.core.interfaces.Package;
56
65
 
57
66
  public class ExpoModulesPackageList {
58
- public List<Package> getPackageList() {
59
- return Arrays.<Package>asList(
60
- ${packagesClasses.map(packageClass => ` new ${packageClass}()`).join(',\n')}
67
+ private static class LazyHolder {
68
+ static final List<Package> packagesList = Arrays.<Package>asList(
69
+ ${packagesClasses.map((packageClass) => ` new ${packageClass}()`).join(',\n')}
61
70
  );
62
71
  }
72
+
73
+ public static List<Package> getPackageList() {
74
+ return LazyHolder.packagesList;
75
+ }
63
76
  }
64
77
  `;
65
78
  }
@@ -68,7 +81,7 @@ async function findAndroidPackagesAsync(modules: ModuleDescriptor[]): Promise<st
68
81
  const classes: string[] = [];
69
82
 
70
83
  await Promise.all(
71
- modules.map(async module => {
84
+ modules.map(async (module) => {
72
85
  const files = await glob('src/**/*Package.{java,kt}', {
73
86
  cwd: module.sourceDir,
74
87
  });
@@ -76,10 +89,16 @@ async function findAndroidPackagesAsync(modules: ModuleDescriptor[]): Promise<st
76
89
  for (const file of files) {
77
90
  const fileContent = await fs.readFile(path.join(module.sourceDir, file), 'utf8');
78
91
 
92
+ const packageRegex = (() => {
93
+ if (process.env.EXPO_SHOULD_USE_LEGACY_PACKAGE_INTERFACE) {
94
+ return /\bimport\s+org\.unimodules\.core\.(interfaces\.Package|BasePackage)\b/;
95
+ } else {
96
+ return /\bimport\s+expo\.modules\.core\.(interfaces\.Package|BasePackage)\b/;
97
+ }
98
+ })();
99
+
79
100
  // Very naive check to skip non-expo packages
80
- if (
81
- !/\bimport\s+org\.unimodules\.core\.(interfaces\.Package|BasePackage)\b/.test(fileContent)
82
- ) {
101
+ if (!packageRegex.test(fileContent)) {
83
102
  continue;
84
103
  }
85
104
 
@@ -1,4 +1,5 @@
1
1
  import glob from 'fast-glob';
2
+ import fs from 'fs-extra';
2
3
  import path from 'path';
3
4
 
4
5
  import { ModuleDescriptor, PackageRevision, SearchOptions } from '../types';
@@ -27,5 +28,49 @@ export async function resolveModuleAsync(
27
28
  podName,
28
29
  podspecDir,
29
30
  flags: options.flags,
31
+ modulesClassNames: revision.config?.iosModulesClassNames(),
30
32
  };
31
33
  }
34
+
35
+ /**
36
+ * Generates Swift file that contains all autolinked Swift packages.
37
+ */
38
+ export async function generatePackageListAsync(
39
+ modules: ModuleDescriptor[],
40
+ targetPath: string
41
+ ): Promise<void> {
42
+ const className = path.basename(targetPath, path.extname(targetPath));
43
+ const generatedFileContent = await generatePackageListFileContentAsync(modules, className);
44
+
45
+ await fs.outputFile(targetPath, generatedFileContent);
46
+ }
47
+
48
+ /**
49
+ * Generates the string to put into the generated package list.
50
+ */
51
+ async function generatePackageListFileContentAsync(
52
+ modules: ModuleDescriptor[],
53
+ className: string
54
+ ): Promise<string> {
55
+ const modulesToProvide = modules.filter((module) => module.modulesClassNames.length > 0);
56
+ const pods = modulesToProvide.map((module) => module.podName);
57
+ const classNames = [].concat(...modulesToProvide.map((module) => module.modulesClassNames));
58
+
59
+ return `/**
60
+ * Automatically generated by expo-modules-autolinking.
61
+ *
62
+ * This autogenerated class provides a list of classes of native Expo modules,
63
+ * but only these that are written in Swift and use the new API for creating Expo modules.
64
+ */
65
+
66
+ import ExpoModulesCore
67
+ ${pods.map((podName) => `import ${podName}\n`).join('')}
68
+ @objc(${className})
69
+ public class ${className}: ModulesProvider {
70
+ public override func getModuleClasses() -> [AnyModule.Type] {
71
+ return [${classNames.map((className) => `\n ${className}.self`).join(',')}
72
+ ]
73
+ }
74
+ }
75
+ `;
76
+ }
package/src/types.ts CHANGED
@@ -1,4 +1,6 @@
1
- export type SupportedPlatform = 'ios' | 'android';
1
+ import { ExpoModuleConfig } from './ExpoModuleConfig';
2
+
3
+ export type SupportedPlatform = 'ios' | 'android' | 'web';
2
4
 
3
5
  export interface SearchOptions {
4
6
  // Available in the CLI
@@ -17,13 +19,14 @@ export interface ResolveOptions extends SearchOptions {
17
19
 
18
20
  export interface GenerateOptions extends ResolveOptions {
19
21
  target: string;
20
- namespace: string;
22
+ namespace?: string;
21
23
  empty?: boolean;
22
24
  }
23
25
 
24
26
  export type PackageRevision = {
25
27
  path: string;
26
28
  version: string;
29
+ config?: ExpoModuleConfig;
27
30
  duplicates?: PackageRevision[];
28
31
  };
29
32
 
@@ -32,3 +35,23 @@ export type SearchResults = {
32
35
  };
33
36
 
34
37
  export type ModuleDescriptor = Record<string, any>;
38
+
39
+ /**
40
+ * Represents a raw config from `expo-module.json`.
41
+ */
42
+ export interface RawExpoModuleConfig {
43
+ /**
44
+ * An array of supported platforms.
45
+ */
46
+ platforms?: SupportedPlatform[];
47
+
48
+ /**
49
+ * iOS-specific config.
50
+ */
51
+ ios?: {
52
+ /**
53
+ * Names of Swift native modules classes to put to the generated modules provider file.
54
+ */
55
+ modulesClassNames?: string[];
56
+ };
57
+ }