UNPKG

one

Version:

One is a React framework for web, iOS, and Android apps.

303 lines (282 loc) • 11.2 kB
import path from 'node:path' import { createRequire } from 'node:module' import type { PluginItem, TransformOptions } from '@babel/core' import mm from 'micromatch' import tsconfigPaths from 'tsconfig-paths' import { pickOnePublicEnv } from '../native/env' import { API_ROUTE_GLOB_PATTERN, ROUTE_NATIVE_EXCLUSION_GLOB_PATTERNS, } from '../router/glob-patterns' // forward-slash a relative path produced by `path.relative` on every platform. // `babel-preset` deliberately avoids `vite.normalizePath` so the preset stays // usable in standalone Metro contexts where Vite isn't installed. function toPosixRelativePath(relativePath: string): string { return path.sep === '\\' ? relativePath.replace(/\\/g, '/') : relativePath } // Babel's `ConfigAPI` shape: cache mutator, env helper, and a cwd accessor. // The public @babel/core typing omits `cwd()` so we describe it locally. type BabelConfigAPI = { cache?: ((forever?: boolean) => void) & { forever?: () => void never?: () => void using?: (cb: () => unknown) => void invalidate?: (cb: () => unknown) => void } cwd?: () => string env?: (...args: unknown[]) => string | undefined caller?: <T>(cb: (caller: unknown) => T) => T } export type OneBabelPresetOptions = { /** Absolute path to the project root. Defaults to the babel `cwd`. */ projectRoot?: string /** Router root folder relative to the project root. Defaults to `'app'`. */ routerRoot?: string /** Route file patterns to exclude (same shape as `one({ router: { ignoredRouteFiles } })`). */ ignoredRouteFiles?: Array<`**/*${string}`> /** Routing linking config, mirrors `one({ router: { linking } })`. */ linking?: unknown /** Path to a native setup file, relative to the project root. */ setupFile?: string | { native?: string; ios?: string; android?: string } /** * Whether to include `@vxrn/vite-plugin-metro/babel-plugins/import-meta-env-plugin`. * Defaults to true. The Vite-driven Metro server injects this separately via * `patchMetroServerWithViteConfigAndMetroPluginOptions` using the user's Vite * `define` config, so the Vite path passes `false`. Re-applying is idempotent. */ includeImportMetaEnv?: boolean } /** * Standalone babel preset that drops the same plugin chain that the * Vite-driven Metro path applies into any `babel.config.{cjs,js,mjs}` file. * Builds on `@react-native/babel-preset` for syntax and platform transforms; * one's default Rolldown path does not use babel. * * @example * ```js * // babel.config.cjs * module.exports = require('one/babel-preset') * ``` */ export default function oneBabelPreset( api: BabelConfigAPI, options: OneBabelPresetOptions = {} ): TransformOptions { const hasViteInjectedOnePlugins = typeof api?.caller === 'function' ? api.caller((caller) => !!(caller as any)?.oneViteMetroBabelConfig) : false // @vxrn/compiler applies a user babel config inside the vite and rolldown // pipeline, which already runs the One chain and React Native transforms const isVxrnCompiler = typeof api?.caller === 'function' ? api.caller((caller) => (caller as { name?: string } | undefined)?.name === 'vxrn') : false if (isVxrnCompiler) { return { presets: [], plugins: [] } } if (!api?.caller && typeof api?.cache === 'function') { api.cache(true) } const projectRoot = path.resolve( options.projectRoot ?? (typeof api?.cwd === 'function' ? api.cwd() : process.cwd()) ) // Babel is the opt-in path, so the RN base resolves from the project, not // from one's own dependencies. only the resolve is guarded: a throw from // inside the preset itself must surface as-is, not as "not installed". const require = createRequire(projectRoot + '/') let reactNativePresetPath: string try { reactNativePresetPath = require.resolve('@react-native/babel-preset') } catch { throw new Error( `[one/babel-preset] Could not resolve '@react-native/babel-preset' from ${projectRoot}. ` + `Install it as a project dependency (it ships with every React Native app template).` ) } return { // presets run right to left: strip TypeScript exports before expanding // runtime namespace exports, then let the RN module transform consume them. presets: [ { plugins: [ createRequire(require.resolve('one/package.json')).resolve( '@babel/plugin-transform-export-namespace-from' ), ], }, require(reactNativePresetPath), ], plugins: hasViteInjectedOnePlugins ? [] : buildOneBabelPlugins({ projectRoot, relativeRouterRoot: options.routerRoot ?? 'app', ignoredRouteFiles: options.ignoredRouteFiles, linking: options.linking, setupFile: options.setupFile, includeImportMetaEnv: options.includeImportMetaEnv, }), } } export type BuildOneBabelPluginsOptions = { projectRoot: string relativeRouterRoot: string ignoredRouteFiles?: Array<`**/*${string}`> linking?: unknown setupFile?: string | { native?: string; ios?: string; android?: string } includeImportMetaEnv?: boolean } /** * The plugin chain shared between the Vite-driven Metro path * (`getViteMetroPluginOptions`) and the standalone preset above. */ export function buildOneBabelPlugins({ projectRoot, relativeRouterRoot, ignoredRouteFiles, linking, setupFile, includeImportMetaEnv = true, }: BuildOneBabelPluginsOptions): PluginItem[] { const tsconfig = tsconfigPaths.loadConfig(projectRoot) if (tsconfig.resultType === 'failed') { throw new Error('[one/babel-preset] tsconfig.json paths could not be loaded') } const require = createRequire(projectRoot + '/') const metroEntryPath = require.resolve('one/metro-entry', { paths: [projectRoot], }) const setupFileRelativeToMetroEntry = (() => { if (!setupFile) return undefined const file = typeof setupFile === 'string' ? setupFile : setupFile.native || setupFile.ios || setupFile.android if (!file) return undefined // posix-only, embedded as a JS import specifier in `one-router-metro` (becomes // literal `import "..."`); backslashes on Windows produce platform-conditional AST // and silently break rolldown/Vite POSIX module-graph keys for source-map / snapshot // consumers downstream. return toPosixRelativePath( path.relative(path.dirname(metroEntryPath), path.join(projectRoot, file)) ) })() return [ // standalone Metro CLI needs `import.meta.env.*` / // `process.env.*` baked in. The Vite path passes `false` here and injects // its own version with the user's `define` env via the server hook. ...(includeImportMetaEnv ? [ [ '@vxrn/vite-plugin-metro/babel-plugins/import-meta-env-plugin', { env: buildStandaloneImportMetaEnv() }, ] as PluginItem, ] : []), 'one/babel-plugin-environment-guard', ['one/babel-plugin-remove-server-code', { routerRoot: relativeRouterRoot }], [ 'babel-plugin-module-resolver', // "vite-tsconfig-paths" for Metro { alias: Object.fromEntries( Object.entries(tsconfig.paths).map(([k, v]) => { // exact-match aliases need a trailing `$`, prefix aliases drop `/*` const key = k.endsWith('/*') ? k.replace(/\/\*$/, '') : `${k}$` let value = v[0].replace(/\/\*$/, '') if (!value.startsWith('./')) value = `./${value}` return [key, value] }) ), }, ], [ 'one/babel-plugin-one-router-metro', { // posix-only, becomes the first arg of `require.context()` in `one-router-metro`; // backslashes on Windows drift the babel-emitted AST per platform. ONE_ROUTER_APP_ROOT_RELATIVE_TO_ENTRY: toPosixRelativePath( path.relative( path.dirname(metroEntryPath), path.join(projectRoot, relativeRouterRoot) ) ), ONE_ROUTER_ROOT_FOLDER_NAME: relativeRouterRoot, ONE_ROUTER_REQUIRE_CONTEXT_REGEX_STRING: buildRouterRequireContextRegexString(ignoredRouteFiles), ONE_ROUTER_LINKING_CONFIG: linking, ONE_SETUP_FILE_NATIVE: setupFileRelativeToMetroEntry, }, ], 'one/babel-plugin-inline-one-server-url', ] } /** * Build the `import.meta.env` substitution map for standalone Metro use. * Mirrors Vite's default `define`: MODE/BASE_URL/PROD/DEV/SSR plus any * `ONE_PUBLIC_*` / `EXPO_PUBLIC_*` / `ONE_*` / `VITE_*` env var from * `process.env`. */ function buildStandaloneImportMetaEnv(): Record<string, unknown> { const isProduction = process.env.NODE_ENV !== 'development' const expoPlatform = process.env.ONE_PLATFORM === 'ios' || process.env.ONE_PLATFORM === 'android' ? process.env.ONE_PLATFORM : process.env.EXPO_OS === 'ios' || process.env.EXPO_OS === 'android' ? process.env.EXPO_OS : undefined const env: Record<string, unknown> = { ...pickOnePublicEnv(process.env), MODE: isProduction ? 'production' : 'development', BASE_URL: '/', PROD: isProduction, DEV: !isProduction, SSR: false, ONE_PLATFORM: process.env.ONE_PLATFORM ?? 'web', ...(expoPlatform ? { EXPO_OS: expoPlatform } : {}), } for (const [key, value] of Object.entries(process.env)) { if (key.startsWith('ONE_') || key.startsWith('VITE_')) { env[key] = value } } return env } /** * On Windows, micromatch.makeRe() produces `[\\/]` / `[^\\/]` instead of `\/` / `[^/]`. * Normalize so the prefix/suffix check below works cross-platform. */ function normalizeReSource(source: string): string { return source.replace(/\[\\\\\/\]/g, '\\/').replace(/\[\^\\\\\/\]/g, '[^/]') } export function buildRouterRequireContextRegexString( ignoredRouteFiles?: Array<`**/*${string}`> ): string { const excludeRes = [ ...(ignoredRouteFiles || []).map((pattern) => mm.makeRe(pattern)), ...ROUTE_NATIVE_EXCLUSION_GLOB_PATTERNS.map((pattern) => mm.makeRe(pattern)), mm.makeRe(API_ROUTE_GLOB_PATTERN), ] const mustStartWith = String.raw`^(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?!\.)(?=.)[^/]*?` // biome-ignore lint/complexity/noUselessStringRaw: keep original code const mustEndWith = String.raw`)$` const negatives = excludeRes.map((re, i) => { const reSource = normalizeReSource(re.source) if (!(reSource.startsWith(mustStartWith) && reSource.endsWith(mustEndWith))) { const pattern = ignoredRouteFiles?.[i] throw new Error( pattern ? `[one/metro] ignoredRouteFile pattern "${pattern}" is not supported. We cannot process the corresponding regex "${reSource}" for now.` : `Unsupported regex "${reSource}" in "ignoredRouteFiles".` ) } const inner = reSource.slice( mustStartWith.length, reSource.length - mustEndWith.length ) // biome-ignore lint/complexity/noUselessStringRaw: keep original code return String.raw`(?:.*${inner})` }) return String.raw`^(?:\.\/)(?!${negatives.join('|')}$).*\.tsx?$` }