diff --git a/CONTEXT.md b/CONTEXT.md index f3ca4ab4..54d3988a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -98,15 +98,32 @@ Shared runtime: Configuration shape: -- `cssEntryFile`: required CSS entry path, resolved from `process.cwd()`. +- `cssEntryFile`: required CSS entry path, resolved from `process.cwd()`. A sibling platform file overrules it for that platform. - `extraThemes`: optional named themes added to default `light` and `dark`. - `dtsFile`: optional generated declaration file path, default `uniwind-types.d.ts`. - Metro-only `polyfills.rem`: custom rem base, default `16`. - Metro-only `debug` and `isTV` flags exist in types. +Platform entry files: + +- `UniwindBundlerConfig.cssPath` resolves a sibling `..css` before falling back to the configured `cssEntryFile`, mirroring how Metro resolves `.ios` / `.native` modules. +- Suffixes are the platform variants in `artifacts/css/variants.ts`: `ios`, `android`, `web`, `native`, `tv`, `android-tv`, `apple-tv`. +- Each platform tries its suffixes in order, most specific first, then falls back to the configured entry: + - `web`: `web` + - `ios`: `ios`, `native` + - `android`: `android`, `native` + - `native`: `native` + - `tv`: `tv`, `native` + - `android-tv`: `android-tv`, `tv`, `android`, `native` + - `apple-tv`: `apple-tv`, `tv`, `ios`, `native` +- Web is the only platform with no `native` fallback, matching how Metro resolves modules. +- Only `cssPath` is affected. `cssEntryFile` remains the configured path, the identity the Metro transformer matches, and the module Metro transforms. +- `generateArtifacts` deliberately still reads `cssEntryFile`: the theme artifact is one file per install, so a per-platform value there would let web and native transforms overwrite each other. +- Each entry compiles independently, so every entry must carry the full set of bare imports (`tailwindcss`, `uniwind`, ...). Shared content belongs in a file the entries `@import`. + Compilation flow: -- `compileTailwind` reads `cssEntryFile`, runs Tailwind v4 compile, scans files under the CSS entry directory, and builds final CSS. +- `compileTailwind` reads `cssPath`, runs Tailwind v4 compile, scans files under the CSS entry directory, and builds final CSS. - `compileCSS` routes to web or native by platform. - `compileWebCSS` runs Lightning CSS with `UniwindCSSVisitor` and returns CSS. - `compileNativeCSS` runs `ProcessorBuilder`, serializes variables, scoped variables, and native stylesheet metadata into JS source. diff --git a/packages/uniwind/src/bundler/config.ts b/packages/uniwind/src/bundler/config.ts index f448b405..a57bde7b 100644 --- a/packages/uniwind/src/bundler/config.ts +++ b/packages/uniwind/src/bundler/config.ts @@ -4,8 +4,26 @@ import { UniwindCSSVisitor } from '@/bundler/css-visitor' import type { UniwindConfig, UniwindMetroConfig } from '@/bundler/types' import { Platform } from '@/common/consts' import { isDefined } from '@/common/utils' +import fs from 'fs' import path from 'path' +/** + * Which suffixed entries a platform accepts, most specific first. + * + * Mirrors how Metro resolves `.ios` / `.native` modules, including that web never falls back + * to `.native`. The suffixes are the platform variants Uniwind already generates, so an entry + * is named after the prefix you would otherwise write inside it. + */ +const CSS_ENTRY_PLATFORM_FALLBACKS: Record> = { + [Platform.Web]: [Platform.Web], + [Platform.iOS]: [Platform.iOS, Platform.Native], + [Platform.Android]: [Platform.Android, Platform.Native], + [Platform.Native]: [Platform.Native], + [Platform.TV]: [Platform.TV, Platform.Native], + [Platform.AndroidTV]: [Platform.AndroidTV, Platform.TV, Platform.Android, Platform.Native], + [Platform.AppleTV]: [Platform.AppleTV, Platform.TV, Platform.iOS, Platform.Native], +} + export class UniwindBundlerConfig { static fromMetroConfig(config: UniwindMetroConfig, platform?: string | null) { const getPlatform = () => { @@ -57,8 +75,27 @@ export class UniwindBundlerConfig { constructor(private readonly config: UniwindMetroConfig, readonly platform: Platform) {} + /** + * The stylesheet to compile, honouring a platform file beside the configured entry. + * + * `global.web.css` overrules `global.css` on web, `global.native.css` does on both native + * platforms, and so on. The configured entry stays the fallback and the identity of the + * module Metro transforms, so nothing else in the pipeline needs to know. + */ get cssPath() { - return path.join(process.cwd(), this.config.cssEntryFile) + const entryPath = path.join(process.cwd(), this.config.cssEntryFile) + const extension = path.extname(entryPath) + const stem = entryPath.slice(0, entryPath.length - extension.length) + + for (const suffix of CSS_ENTRY_PLATFORM_FALLBACKS[this.platform] ?? []) { + const platformEntryPath = `${stem}.${suffix}${extension}` + + if (fs.existsSync(platformEntryPath)) { + return platformEntryPath + } + } + + return entryPath } get themes() { diff --git a/packages/uniwind/tests/native/bundler/css-entry-platform-files.test.ts b/packages/uniwind/tests/native/bundler/css-entry-platform-files.test.ts new file mode 100644 index 00000000..701dcd95 --- /dev/null +++ b/packages/uniwind/tests/native/bundler/css-entry-platform-files.test.ts @@ -0,0 +1,85 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { UniwindBundlerConfig } from '../../../src/bundler/config' + +/** + * `cssPath` resolves against `process.cwd()`, so the entry is handed over as a path relative to + * it rather than chdir-ing the worker. + */ +const withEntries = (entries: Array, assert: (cssEntryFile: string) => void) => { + const root = mkdtempSync(join(tmpdir(), 'uniwind-css-entry-')) + + try { + entries.forEach(entry => writeFileSync(join(root, entry), '')) + + assert(relative(process.cwd(), join(root, 'global.css'))) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +test('uses the configured entry when no platform file sits beside it', () => { + withEntries(['global.css'], cssEntryFile => { + const forWeb = UniwindBundlerConfig.fromMetroConfig({ cssEntryFile }, 'web') + const forIOS = UniwindBundlerConfig.fromMetroConfig({ cssEntryFile }, 'ios') + + expect(forWeb.cssPath.endsWith('global.css')).toBe(true) + expect(forIOS.cssPath.endsWith('global.css')).toBe(true) + }) +}) + +test('prefers a platform file over the configured entry', () => { + withEntries(['global.css', 'global.web.css'], cssEntryFile => { + const forWeb = UniwindBundlerConfig.fromMetroConfig({ cssEntryFile }, 'web') + const forIOS = UniwindBundlerConfig.fromMetroConfig({ cssEntryFile }, 'ios') + + expect(forWeb.cssPath.endsWith('global.web.css')).toBe(true) + expect(forIOS.cssPath.endsWith('global.css')).toBe(true) + }) +}) + +test('falls back from a platform file to the native one', () => { + withEntries(['global.css', 'global.native.css'], cssEntryFile => { + const forIOS = UniwindBundlerConfig.fromMetroConfig({ cssEntryFile }, 'ios') + const forAndroid = UniwindBundlerConfig.fromMetroConfig({ cssEntryFile }, 'android') + + expect(forIOS.cssPath.endsWith('global.native.css')).toBe(true) + expect(forAndroid.cssPath.endsWith('global.native.css')).toBe(true) + }) +}) + +test('takes the more specific platform file when both exist', () => { + withEntries(['global.css', 'global.native.css', 'global.ios.css'], cssEntryFile => { + const forIOS = UniwindBundlerConfig.fromMetroConfig({ cssEntryFile }, 'ios') + const forAndroid = UniwindBundlerConfig.fromMetroConfig({ cssEntryFile }, 'android') + + expect(forIOS.cssPath.endsWith('global.ios.css')).toBe(true) + expect(forAndroid.cssPath.endsWith('global.native.css')).toBe(true) + }) +}) + +// Metro resolves `.native` for native platforms only, and web is not one of them. +test('never falls back to the native file on web', () => { + withEntries(['global.css', 'global.native.css'], cssEntryFile => { + const forWeb = UniwindBundlerConfig.fromMetroConfig({ cssEntryFile }, 'web') + + expect(forWeb.cssPath.endsWith('global.css')).toBe(true) + }) +}) + +test('resolves the TV entries when isTV maps the platform', () => { + withEntries(['global.css', 'global.native.css', 'global.ios.css', 'global.apple-tv.css'], cssEntryFile => { + const forAppleTV = UniwindBundlerConfig.fromMetroConfig({ cssEntryFile, isTV: true }, 'ios') + + expect(forAppleTV.cssPath.endsWith('global.apple-tv.css')).toBe(true) + }) +}) + +test('an absent platform argument resolves the native entry', () => { + withEntries(['global.css', 'global.native.css'], cssEntryFile => { + const forNative = UniwindBundlerConfig.fromMetroConfig({ cssEntryFile }) + + expect(forNative.cssPath.endsWith('global.native.css')).toBe(true) + }) +}) diff --git a/skills/uniwind/references/setup.md b/skills/uniwind/references/setup.md index 17fd149a..230914e8 100644 --- a/skills/uniwind/references/setup.md +++ b/skills/uniwind/references/setup.md @@ -59,6 +59,30 @@ module.exports = withUniwindConfig(withOtherConfig(config, opts), { cssEntryFile module.exports = withOtherConfig(withUniwindConfig(config, { cssEntryFile: './global.css' }), opts); ``` +### Platform entry files + +A file named after a platform, sitting beside `cssEntryFile`, overrules it when Uniwind compiles for that platform — the same way Metro resolves `.ios` / `.native` modules. Keep pointing `cssEntryFile` at the base entry; the suffixed file is picked up behind it. + +``` +global.css # the configured entry, and the fallback for every platform +global.web.css # used on web instead +global.native.css # used on iOS and Android instead +global.ios.css # used on iOS, in preference to global.native.css +``` + +Suffixes are the platform variants Uniwind already generates — `ios`, `android`, `web`, `native`, `tv`, `android-tv`, `apple-tv` — so an entry is named after the prefix you would otherwise write inside it. Resolution is most-specific-first, and web never falls back to `native`. + +Use it when a platform needs stylesheets the others must not get — web-only vendor CSS you override, for instance, which would otherwise be compiled into the native bundle as dead weight: + +```css +/* global.web.css */ +@import 'tailwindcss'; +@import 'uniwind'; +@import './vendor-overrides.css'; +``` + +Each entry is compiled on its own, so every one of them must carry the full set of bare imports (`tailwindcss`, `uniwind`, …). Put the shared remainder in a file they both `@import`. + ### Vite Configuration (v1.2.0+) If user has storybook setup, add extra vite config: