one
Version:
One is a React framework for web, iOS, and Android apps.
303 lines (282 loc) • 11.2 kB
text/typescript
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?$`
}