diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 00000000..755e6f71 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,15 @@ +const path = require('node:path'); + +// babel-preset-expo ships inside `expo` rather than as a direct dependency, so +// resolve it from there; this is the preset Expo uses when no config exists. +const presetExpo = require.resolve('babel-preset-expo', { + paths: [path.dirname(require.resolve('expo/package.json'))], +}); + +module.exports = function babelConfig(api) { + api.cache(true); + return { + presets: [presetExpo], + plugins: [require.resolve('./scripts/babel-plugin-lucide-imports')], + }; +}; diff --git a/metro.config.js b/metro.config.js new file mode 100644 index 00000000..222aecac --- /dev/null +++ b/metro.config.js @@ -0,0 +1,20 @@ +const { getDefaultConfig } = require('expo/metro-config'); + +const config = getDefaultConfig(__dirname); + +// babel.config.js rewrites lucide-react-native imports to per-icon modules +// under dist/ (scripts/babel-plugin-lucide-imports.js). The package's +// `exports` map only lists its barrel, so resolve those paths as plain files +// instead of letting Metro warn about every one of them. +const LUCIDE_DEEP_IMPORT = 'lucide-react-native/dist/'; +const upstreamResolveRequest = config.resolver.resolveRequest; + +config.resolver.resolveRequest = (context, moduleName, platform) => { + const resolve = upstreamResolveRequest ?? context.resolveRequest; + if (moduleName.startsWith(LUCIDE_DEEP_IMPORT)) { + return resolve({ ...context, unstable_enablePackageExports: false }, moduleName, platform); + } + return resolve(context, moduleName, platform); +}; + +module.exports = config; diff --git a/scripts/babel-plugin-lucide-imports.js b/scripts/babel-plugin-lucide-imports.js new file mode 100644 index 00000000..44484f4e --- /dev/null +++ b/scripts/babel-plugin-lucide-imports.js @@ -0,0 +1,91 @@ +// Babel plugin: rewrite named imports from the `lucide-react-native` barrel to +// the per-icon modules they re-export, so the bundle only contains (and the +// app only evaluates) the icons it uses. The barrel re-exports all ~1,700 +// icons, and Metro has no tree shaking, so one `import { Mail }` used to pull +// in and run every icon at startup. +// +// import { Mail, Trash2 as Bin } from 'lucide-react-native'; +// becomes +// import Mail from 'lucide-react-native/dist/esm/icons/mail.js'; +// import Bin from 'lucide-react-native/dist/esm/icons/trash-2.js'; +// +// The name -> file table is read from the package's own ESM barrel, so icon +// aliases (`MailIcon`, `LucideMail`, renamed icons) resolve exactly as they do +// through the barrel. metro.config.js resolves these deep paths, which the +// package's `exports` map does not list. +const fs = require('node:fs'); +const path = require('node:path'); + +const PACKAGE = 'lucide-react-native'; +const ESM_DIR = 'dist/esm'; + +/** Map every name the barrel exports to `{ source, imported }`. */ +function loadExportMap() { + // `main` is dist/cjs/lucide-react-native.js; the ESM tree sits next to it. + const pkgDir = path.resolve(path.dirname(require.resolve(PACKAGE)), '..', '..'); + const barrel = fs.readFileSync(path.join(pkgDir, ESM_DIR, `${PACKAGE}.js`), 'utf8'); + const map = new Map(); + for (const m of barrel.matchAll(/export\s*\{([^}]*)\}\s*from\s*'\.\/([^']+)'/g)) { + const source = `${PACKAGE}/${ESM_DIR}/${m[2]}`; + for (const spec of m[1].split(',')) { + const [imported, exported = imported] = spec.trim().split(/\s+as\s+/); + if (imported) map.set(exported, { source, imported }); + } + } + if (map.size === 0) { + throw new Error(`${PACKAGE}: could not read the export list from its ESM barrel`); + } + return map; +} + +let exportMap = null; +function getExportMap() { + if (!exportMap) exportMap = loadExportMap(); + return exportMap; +} + +// A value-less binding (only used as a type) is dropped by the TypeScript +// transform anyway; treat it the same way instead of failing the build. +function isOnlyUsedAsType(importPath, localName) { + const binding = importPath.scope.getBinding(localName); + if (!binding) return true; + return binding.referencePaths.every((ref) => Boolean(ref.findParent((p) => p.isTSType()))); +} + +function lucideImportsPlugin({ types: t }) { + return { + name: 'lucide-direct-imports', + visitor: { + ImportDeclaration(importPath) { + const { node } = importPath; + if (node.source.value !== PACKAGE || node.importKind === 'type') return; + // Side-effect, default and namespace imports keep using the barrel. + if (node.specifiers.length === 0) return; + if (!node.specifiers.every((s) => t.isImportSpecifier(s))) return; + + const map = getExportMap(); + const replacements = []; + for (const spec of node.specifiers) { + if (spec.importKind === 'type') continue; + const name = t.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value; + const target = map.get(name); + if (!target) { + if (isOnlyUsedAsType(importPath, spec.local.name)) continue; + throw importPath.buildCodeFrameError(`${PACKAGE} has no export named "${name}"`); + } + const local = t.identifier(spec.local.name); + const specifier = target.imported === 'default' + ? t.importDefaultSpecifier(local) + : t.importSpecifier(local, t.identifier(target.imported)); + replacements.push(t.importDeclaration([specifier], t.stringLiteral(target.source))); + } + if (replacements.length === 0) importPath.remove(); + else importPath.replaceWithMultiple(replacements); + }, + }, + }; +} + +module.exports = lucideImportsPlugin; +module.exports.loadExportMap = loadExportMap; +module.exports.PACKAGE = PACKAGE; diff --git a/src/lib/__tests__/lucide-imports.test.ts b/src/lib/__tests__/lucide-imports.test.ts new file mode 100644 index 00000000..9a4c18a6 --- /dev/null +++ b/src/lib/__tests__/lucide-imports.test.ts @@ -0,0 +1,105 @@ +// The babel plugin in scripts/babel-plugin-lucide-imports.js rewrites +// `import { X } from 'lucide-react-native'` to per-icon modules. These tests +// make sure every icon the app imports maps to a file that exists, and that +// no import form slips back to the full barrel. +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'node:module'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +const require = createRequire(import.meta.url); +const plugin = require('../../../scripts/babel-plugin-lucide-imports.js'); +const babel = require('@babel/core'); + +const ROOT = join(__dirname, '..', '..', '..'); +const PACKAGE_DIR = join(require.resolve('lucide-react-native'), '..', '..', '..'); + +function sourceFiles(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const p = join(dir, entry); + if (statSync(p).isDirectory()) { + if (entry !== '__tests__' && entry !== 'node_modules') sourceFiles(p, out); + } else if (/\.tsx?$/.test(entry)) { + out.push(p); + } + } + return out; +} + +const FILES = [...sourceFiles(join(ROOT, 'src')), join(ROOT, 'App.tsx'), join(ROOT, 'index.ts')]; +const LUCIDE_IMPORT_RE = /import\s+(type\s+)?([^;]*?)\s+from\s+'lucide-react-native'/g; + +function transform(code: string): string { + return babel.transformSync(code, { + babelrc: false, + configFile: false, + filename: 'fixture.tsx', + parserOpts: { plugins: ['typescript', 'jsx'] }, + plugins: [plugin], + }).code as string; +} + +describe('lucide-react-native imports', () => { + const exportMap: Map = plugin.loadExportMap(); + + it('reads the icon table from the package barrel', () => { + expect(exportMap.size).toBeGreaterThan(1000); + expect(exportMap.get('Mail')?.source).toBe('lucide-react-native/dist/esm/icons/mail.js'); + // Aliases resolve to the icon they re-export. + expect(exportMap.get('MailIcon')?.source).toBe('lucide-react-native/dist/esm/icons/mail.js'); + expect(exportMap.get('AlarmCheck')?.source).toBe('lucide-react-native/dist/esm/icons/alarm-clock-check.js'); + }); + + it('maps every icon the app imports to an existing module', () => { + const problems: string[] = []; + let checked = 0; + for (const file of FILES) { + const text = readFileSync(file, 'utf8'); + for (const m of text.matchAll(LUCIDE_IMPORT_RE)) { + const where = relative(ROOT, file); + if (m[1]) continue; // `import type { … }` is erased + const clause = m[2].trim(); + if (!clause.startsWith('{') || !clause.endsWith('}')) { + problems.push(`${where}: only named imports avoid the barrel (${clause})`); + continue; + } + for (const raw of clause.slice(1, -1).split(',')) { + const spec = raw.trim(); + if (!spec || spec.startsWith('type ')) continue; + const name = spec.split(/\s+as\s+/)[0]; + const target = exportMap.get(name); + if (!target) { + problems.push(`${where}: ${name} is not a lucide export (import types with \`type\`)`); + continue; + } + const file = join(PACKAGE_DIR, target.source.slice('lucide-react-native/'.length)); + if (!existsSync(file)) problems.push(`${where}: ${name} -> missing ${target.source}`); + checked++; + } + } + } + expect(problems).toEqual([]); + expect(checked).toBeGreaterThan(100); + }); + + it('rewrites named imports to per-icon modules', () => { + const out = transform( + "import { Mail, Trash2 as Bin, type LucideIcon, LucideProvider } from 'lucide-react-native';\n" + + 'const icons: LucideIcon[] = [Mail, Bin];\n' + + 'export { icons, LucideProvider };\n', + ); + expect(out).toContain("import Mail from \"lucide-react-native/dist/esm/icons/mail.js\""); + expect(out).toContain("import Bin from \"lucide-react-native/dist/esm/icons/trash-2.js\""); + expect(out).toContain("import { LucideProvider } from \"lucide-react-native/dist/esm/context.js\""); + expect(out).not.toMatch(/from "lucide-react-native"/); + }); + + it('drops type-only names and fails on unknown values', () => { + expect(transform( + "import { LucideProps } from 'lucide-react-native';\nexport function f(p: LucideProps) { return p; }\n", + )).not.toContain('lucide-react-native'); + expect(() => transform( + "import { NotAnIcon } from 'lucide-react-native';\nexport const x = NotAnIcon;\n", + )).toThrow(/no export named "NotAnIcon"/); + }); +});