@felipe-software/react-native-gpui 0.0.0-stage → 1.0.1
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.
- package/.reanimated-prebuilt/react-native-reanimated.mjs +29373 -0
- package/LICENSE +21 -0
- package/README.md +657 -2
- package/cli/bin.ts +379 -0
- package/cli/bundle.ts +32 -0
- package/cli/commands/do.ts +213 -0
- package/cli/commands/flow.ts +283 -0
- package/cli/commands/get.ts +584 -0
- package/cli/commands/shot.ts +312 -0
- package/cli/commands/trace.ts +293 -0
- package/cli/host.ts +714 -0
- package/cli/hotReload.ts +210 -0
- package/cli/run.ts +66 -0
- package/cli/selectors.ts +205 -0
- package/dist/Animated.d.ts +120 -0
- package/dist/ChatList.d.ts +28 -0
- package/dist/Dimensions.d.ts +31 -0
- package/dist/StyleSheet.d.ts +18 -0
- package/dist/Window.d.ts +33 -0
- package/dist/apis.d.ts +165 -0
- package/dist/app-state.d.ts +27 -0
- package/dist/chat-list-types.d.ts +136 -0
- package/dist/colors.d.ts +32 -0
- package/dist/commands.d.ts +230 -0
- package/dist/components.d.ts +419 -0
- package/dist/hotModules.d.ts +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.development.js +14876 -0
- package/dist/index.js +12 -0
- package/dist/keyboard.d.ts +140 -0
- package/dist/native-menu.d.ts +7 -0
- package/dist/platform-driver.d.ts +32 -0
- package/dist/platform-services.d.ts +35 -0
- package/dist/portal.d.ts +43 -0
- package/dist/raf.d.ts +4 -0
- package/dist/reanimated/colors-processor-shim.d.ts +10 -0
- package/dist/reanimated/reanimated-host.d.ts +10 -0
- package/dist/reanimated/seam.d.ts +34 -0
- package/dist/reanimated/ui-entry.d.ts +2 -0
- package/dist/reanimated/worklet-channel.d.ts +10 -0
- package/dist/reanimated/worklet-runtime.d.ts +466 -0
- package/dist/reanimated/worklets.d.ts +199 -0
- package/dist/reconciler.d.ts +102 -0
- package/dist/refresh.d.ts +1 -0
- package/dist/render.d.ts +37 -0
- package/dist/runtime.d.ts +235 -0
- package/dist/surfaces.d.ts +51 -0
- package/dist/types.d.ts +276 -0
- package/dist/ui-runtime.js +42276 -0
- package/dist/window-context.d.ts +7 -0
- package/dist/window-host.d.ts +41 -0
- package/dist/wire-delta.d.ts +20 -0
- package/package.json +176 -5
- package/scripts/bundle-app.mjs +88 -0
- package/scripts/conformance-utils.mjs +293 -0
- package/scripts/hot-update-alias-plugin.mjs +88 -0
- package/scripts/native-package-exports-plugin.mjs +235 -0
- package/scripts/pixel.mjs +152 -0
- package/scripts/png.mjs +198 -0
- package/scripts/react-refresh-swc.mjs +35 -0
- package/scripts/reanimated-bun-plugin.mjs +188 -0
- package/scripts/service-binary.d.mts +27 -0
- package/scripts/service-binary.mjs +100 -0
- package/src/reanimated/colors-processor-shim.ts +119 -0
- package/src/reanimated/reanimated-host.ts +69 -0
- package/src/reanimated/rngpui-reanimated-prebuilt.d.ts +7 -0
- package/src/reanimated/seam.ts +566 -0
- package/src/reanimated/ui-entry.ts +80 -0
- package/src/reanimated/worklet-channel.ts +64 -0
- package/src/reanimated/worklet-runtime.ts +2569 -0
- package/src/reanimated/worklets.ts +1334 -0
- package/src/refresh.ts +33 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const REACT_EXPORTS = [
|
|
2
|
+
'Children', 'Component', 'Fragment', 'Profiler', 'PureComponent', 'StrictMode', 'Suspense',
|
|
3
|
+
'cloneElement', 'createContext', 'createElement', 'createRef', 'forwardRef', 'isValidElement',
|
|
4
|
+
'lazy', 'memo', 'startTransition', 'use', 'useActionState', 'useCallback', 'useContext',
|
|
5
|
+
'useDebugValue', 'useDeferredValue', 'useEffect', 'useId', 'useImperativeHandle',
|
|
6
|
+
'useInsertionEffect', 'useLayoutEffect', 'useMemo', 'useOptimistic', 'useReducer', 'useRef',
|
|
7
|
+
'useState', 'useSyncExternalStore', 'useTransition', 'version',
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
const RNGPUI_EXPORTS = [
|
|
11
|
+
'createRoot', 'render', 'AppRegistry', 'unstable_batchedUpdates',
|
|
12
|
+
'View', 'Text', 'TextInput', 'Image', 'Svg', 'WebView',
|
|
13
|
+
'SystemView', 'ScrollView', 'Pressable', 'TouchableOpacity',
|
|
14
|
+
'RefreshControl', 'Diff',
|
|
15
|
+
'TouchableHighlight', 'TouchableWithoutFeedback', 'ListGroup', 'Button', 'NativeButton',
|
|
16
|
+
'NativeTextInput', 'SafeAreaView',
|
|
17
|
+
'KeyboardAvoidingView', 'Switch', 'FlatList', 'SectionList', 'ActivityIndicator', 'StatusBar',
|
|
18
|
+
'Modal', 'requireNativeComponent', 'codegenNativeComponent', 'codegenNativeCommands',
|
|
19
|
+
'StyleSheet', 'normalizeStyle', 'LiquidGlassBackground', 'LiquidGlassView', 'EffectSurface',
|
|
20
|
+
'SmokeEffectSurface', 'effectBackgroundImage', 'smokeEffectBackgroundImage', 'Dimensions',
|
|
21
|
+
'useWindowDimensions', 'Platform', 'PixelRatio', 'useColorScheme', 'Appearance',
|
|
22
|
+
'DynamicColorIOS', 'PlatformColor', 'I18nManager', 'Alert', 'Keyboard', 'BackHandler',
|
|
23
|
+
'AppState', 'Linking', 'InteractionManager', 'LayoutAnimation', 'UIManager', 'NativeModules',
|
|
24
|
+
'findNodeHandle', 'processColor', 'resolveColorValue', 'PanResponder', 'Vibration',
|
|
25
|
+
'DeviceEventEmitter', 'NativeEventEmitter', 'AccessibilityInfo', 'FilePicker', 'VoiceRecorder',
|
|
26
|
+
'Animated', 'AnimatedValue', 'Easing', 'AppCommands', 'Dock', 'NativeClipboard', 'NativeLayout',
|
|
27
|
+
'NativeMenus', 'NativeWindow', 'PerformanceHUD', 'Renderer', 'setupTamaguiNativeMenus',
|
|
28
|
+
'KeyboardNavigationProvider', 'useKeyboardNavigation', 'useKeyboardNavigationController',
|
|
29
|
+
'useKeyboardNavigationState', 'useKeyboardNavigationTarget', 'useKeyboardNavigationKeyPress',
|
|
30
|
+
'useKeyboardNavigationWindowKeyboard', 'mergeRefs', 'enabledKeyboardNavigationTargets',
|
|
31
|
+
'firstKeyboardNavigationTarget', 'hasKeyboardNavigationModifier', 'nextKeyboardNavigationTarget',
|
|
32
|
+
'nextSequentialKeyboardNavigationTarget',
|
|
33
|
+
'Portal', 'PortalHost', 'PortalProvider', 'NativePortal', 'NativePortalHost', 'usePortal',
|
|
34
|
+
'setupTamaguiNativePortal', 'startBridge', 'platformDriver', 'registerPseudoListener',
|
|
35
|
+
'TurboModuleRegistry', 'Window', 'useAppState', 'useWindowFocused',
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
export function rngpuiHotUpdateAliasPlugin() {
|
|
39
|
+
return {
|
|
40
|
+
name: 'rngpui-hot-update-aliases',
|
|
41
|
+
setup(build) {
|
|
42
|
+
build.onResolve({ filter: /^react$/ }, () => ({ path: 'rngpui-hot:react', namespace: 'rngpui-hot' }))
|
|
43
|
+
build.onResolve({ filter: /^react\/jsx-runtime$/ }, () => ({ path: 'rngpui-hot:react/jsx-runtime', namespace: 'rngpui-hot' }))
|
|
44
|
+
build.onResolve({ filter: /^react\/jsx-dev-runtime$/ }, () => ({ path: 'rngpui-hot:react/jsx-dev-runtime', namespace: 'rngpui-hot' }))
|
|
45
|
+
build.onResolve({ filter: /^react\/compiler-runtime$/ }, () => ({ path: 'rngpui-hot:react/compiler-runtime', namespace: 'rngpui-hot' }))
|
|
46
|
+
build.onResolve({ filter: /^(?:@felipe-software\/)?react-native-gpui$/ }, () => ({ path: 'rngpui-hot:react-native-gpui', namespace: 'rngpui-hot' }))
|
|
47
|
+
build.onResolve({ filter: /^react-native$/ }, () => ({ path: 'rngpui-hot:react-native', namespace: 'rngpui-hot' }))
|
|
48
|
+
// Repository examples import the source entry directly. A hot bundle must
|
|
49
|
+
// still reuse the renderer already installed by the initial bundle.
|
|
50
|
+
build.onResolve({ filter: /^\.\.\/src\/index(?:\.ts)?$/ }, () => ({
|
|
51
|
+
path: 'rngpui-hot:react-native-gpui',
|
|
52
|
+
namespace: 'rngpui-hot',
|
|
53
|
+
}))
|
|
54
|
+
|
|
55
|
+
build.onLoad({ filter: /^rngpui-hot:react$/, namespace: 'rngpui-hot' }, () => ({
|
|
56
|
+
loader: 'js',
|
|
57
|
+
contents: moduleShim('react', REACT_EXPORTS, true),
|
|
58
|
+
}))
|
|
59
|
+
build.onLoad({ filter: /^rngpui-hot:react\/jsx-runtime$/, namespace: 'rngpui-hot' }, () => ({
|
|
60
|
+
loader: 'js',
|
|
61
|
+
contents: moduleShim('react/jsx-runtime', ['Fragment', 'jsx', 'jsxs'], true),
|
|
62
|
+
}))
|
|
63
|
+
build.onLoad({ filter: /^rngpui-hot:react\/jsx-dev-runtime$/, namespace: 'rngpui-hot' }, () => ({
|
|
64
|
+
loader: 'js',
|
|
65
|
+
contents: moduleShim('react/jsx-dev-runtime', ['Fragment', 'jsxDEV'], true),
|
|
66
|
+
}))
|
|
67
|
+
build.onLoad({ filter: /^rngpui-hot:react\/compiler-runtime$/, namespace: 'rngpui-hot' }, () => ({
|
|
68
|
+
loader: 'js',
|
|
69
|
+
contents: moduleShim('react/compiler-runtime', ['c'], false),
|
|
70
|
+
}))
|
|
71
|
+
build.onLoad({ filter: /^rngpui-hot:react-native-gpui$/, namespace: 'rngpui-hot' }, () => ({
|
|
72
|
+
loader: 'js',
|
|
73
|
+
contents: moduleShim('react-native-gpui', RNGPUI_EXPORTS, true),
|
|
74
|
+
}))
|
|
75
|
+
build.onLoad({ filter: /^rngpui-hot:react-native$/, namespace: 'rngpui-hot' }, () => ({
|
|
76
|
+
loader: 'js',
|
|
77
|
+
contents: moduleShim('react-native', RNGPUI_EXPORTS, true),
|
|
78
|
+
}))
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function moduleShim(name, exports, defaultExport) {
|
|
84
|
+
const lines = [`const mod = globalThis.__rngpuiHotModules[${JSON.stringify(name)}];`]
|
|
85
|
+
if (defaultExport) lines.push('export default (mod.default || mod);')
|
|
86
|
+
for (const key of exports) lines.push(`export const ${key} = mod[${JSON.stringify(key)}];`)
|
|
87
|
+
return lines.join('\n')
|
|
88
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const packageJsonCache = new Map()
|
|
5
|
+
|
|
6
|
+
// Force React Native package entry points to win. Node/Bun pick the FIRST
|
|
7
|
+
// matching key in an export map, so a package that lists `browser` before
|
|
8
|
+
// `react-native` (nanoid, tamagui, on-zero, …) resolves to its DOM build even
|
|
9
|
+
// with `conditions: ['react-native']` set on the build. Bun also ignores the
|
|
10
|
+
// legacy top-level `react-native` field used by packages such as
|
|
11
|
+
// react-native-screens. This plugin overrides those, and only those.
|
|
12
|
+
//
|
|
13
|
+
// It must claim ONLY the packages it actually rewrites. A Bun 1.3.14 bundler bug
|
|
14
|
+
// makes an onResolve callback that MATCHES a specifier and returns `undefined`
|
|
15
|
+
// drop the module across an `export *` re-export edge — silently, with no build
|
|
16
|
+
// error, leaving a dangling namespace reference that throws at runtime
|
|
17
|
+
// ("Property 'import_manifest' doesn't exist"). A broad `/^[^./].*/` filter put
|
|
18
|
+
// every bare import in range of that bug to serve the ~7% that need the
|
|
19
|
+
// override. So the filter is built from the packages that really declare a
|
|
20
|
+
// react-native condition or entry point; everything else never enters the
|
|
21
|
+
// plugin and Bun resolves it natively. Keep that invariant: do not widen this
|
|
22
|
+
// filter.
|
|
23
|
+
export function nativePackageExportsPlugin({ root, name = 'native package exports' } = {}) {
|
|
24
|
+
const fallbackRoot = root ? resolve(root) : process.cwd()
|
|
25
|
+
const filter = reactNativePackageFilter(fallbackRoot)
|
|
26
|
+
return {
|
|
27
|
+
name,
|
|
28
|
+
setup(build) {
|
|
29
|
+
build.onResolve({ filter }, (args) =>
|
|
30
|
+
resolveReactNativePackageExport(args.path, args.importer ? dirname(args.importer) : fallbackRoot)
|
|
31
|
+
)
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// every installed package whose export map declares a react-native condition
|
|
37
|
+
// or whose top-level react-native field names its native entry point:
|
|
38
|
+
// the root tree, the ancestor node_modules a workspace resolves through, and
|
|
39
|
+
// nested node_modules (a hoisted tree still nests duplicates). realpath dedup
|
|
40
|
+
// keeps a symlinked store from being walked twice or cycling. ~1800
|
|
41
|
+
// package.json reads, ~110ms, once per build.
|
|
42
|
+
function reactNativePackageFilter(root) {
|
|
43
|
+
const names = new Set()
|
|
44
|
+
const nodeModulesStack = []
|
|
45
|
+
for (let dir = root; ; dir = dirname(dir)) {
|
|
46
|
+
nodeModulesStack.push(join(dir, 'node_modules'))
|
|
47
|
+
if (dir === dirname(dir)) break
|
|
48
|
+
}
|
|
49
|
+
const seenNodeModules = new Set()
|
|
50
|
+
|
|
51
|
+
while (nodeModulesStack.length) {
|
|
52
|
+
const nodeModules = nodeModulesStack.pop()
|
|
53
|
+
let realNodeModules
|
|
54
|
+
let entries
|
|
55
|
+
try {
|
|
56
|
+
realNodeModules = realpathSync(nodeModules)
|
|
57
|
+
if (seenNodeModules.has(realNodeModules)) continue
|
|
58
|
+
seenNodeModules.add(realNodeModules)
|
|
59
|
+
entries = readdirSync(nodeModules, { withFileTypes: true })
|
|
60
|
+
} catch {
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const packageDirs = []
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
if (entry.name.startsWith('.')) continue
|
|
67
|
+
const entryPath = join(nodeModules, entry.name)
|
|
68
|
+
let directory
|
|
69
|
+
try {
|
|
70
|
+
directory = statSync(entryPath).isDirectory()
|
|
71
|
+
} catch {
|
|
72
|
+
directory = false
|
|
73
|
+
}
|
|
74
|
+
if (!directory) continue
|
|
75
|
+
if (!entry.name.startsWith('@')) {
|
|
76
|
+
packageDirs.push(entryPath)
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
let scopedEntries
|
|
80
|
+
try {
|
|
81
|
+
scopedEntries = readdirSync(entryPath, { withFileTypes: true })
|
|
82
|
+
} catch {
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
for (const scopedEntry of scopedEntries) {
|
|
86
|
+
const scopedPath = join(entryPath, scopedEntry.name)
|
|
87
|
+
try {
|
|
88
|
+
if (statSync(scopedPath).isDirectory()) packageDirs.push(scopedPath)
|
|
89
|
+
} catch {}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (const packageDir of packageDirs) {
|
|
94
|
+
const pkg = readPackageJson(join(packageDir, 'package.json'))
|
|
95
|
+
const values = [pkg?.exports]
|
|
96
|
+
let hasReactNativeTarget = false
|
|
97
|
+
while (values.length && !hasReactNativeTarget) {
|
|
98
|
+
const value = values.pop()
|
|
99
|
+
if (!value || typeof value !== 'object') continue
|
|
100
|
+
if (Object.prototype.hasOwnProperty.call(value, 'react-native')) {
|
|
101
|
+
hasReactNativeTarget = true
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
values.push(...Object.values(value))
|
|
105
|
+
}
|
|
106
|
+
if ((hasReactNativeTarget || typeof pkg?.['react-native'] === 'string') && pkg?.name) {
|
|
107
|
+
names.add(pkg.name)
|
|
108
|
+
}
|
|
109
|
+
const nestedNodeModules = join(packageDir, 'node_modules')
|
|
110
|
+
if (existsSync(nestedNodeModules)) nodeModulesStack.push(nestedNodeModules)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!names.size) return /\b\B/
|
|
115
|
+
const escaped = [...names]
|
|
116
|
+
.sort()
|
|
117
|
+
.map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
118
|
+
return new RegExp(`^(?:${escaped.join('|')})(?:/|$)`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function resolveReactNativePackageExport(specifier, importer) {
|
|
122
|
+
const parsed = parsePackageSpecifier(specifier)
|
|
123
|
+
if (!parsed) return undefined
|
|
124
|
+
let defaultPath
|
|
125
|
+
try {
|
|
126
|
+
defaultPath = Bun.resolveSync(specifier, importer)
|
|
127
|
+
} catch {
|
|
128
|
+
return undefined
|
|
129
|
+
}
|
|
130
|
+
const packageJsonPath = findPackageJson(parsed.name, defaultPath, importer)
|
|
131
|
+
if (!packageJsonPath) return { path: defaultPath }
|
|
132
|
+
const pkg = readPackageJson(packageJsonPath)
|
|
133
|
+
if (!parsed.subpath && typeof pkg?.['react-native'] === 'string') {
|
|
134
|
+
const nativeEntry = pkg['react-native'].startsWith('.')
|
|
135
|
+
? pkg['react-native']
|
|
136
|
+
: `./${pkg['react-native']}`
|
|
137
|
+
return { path: Bun.resolveSync(nativeEntry, dirname(packageJsonPath)) }
|
|
138
|
+
}
|
|
139
|
+
if (!pkg?.exports) return { path: defaultPath }
|
|
140
|
+
const exportKey = parsed.subpath ? `.${parsed.subpath}` : '.'
|
|
141
|
+
const match = exportValueForKey(pkg.exports, exportKey)
|
|
142
|
+
if (!match) return { path: defaultPath }
|
|
143
|
+
const target = preferredReactNativeTarget(match.value)
|
|
144
|
+
// bun 1.3.9 can drop a re-exported module when a matching onResolve hook
|
|
145
|
+
// returns undefined. once the narrowed filter matches a package name, every
|
|
146
|
+
// successfully resolved subpath must return the path bun selected.
|
|
147
|
+
if (!target) return { path: defaultPath }
|
|
148
|
+
const path = match.pattern ? target.replaceAll('*', match.pattern) : target
|
|
149
|
+
return { path: resolve(dirname(packageJsonPath), path) }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parsePackageSpecifier(specifier) {
|
|
153
|
+
if (
|
|
154
|
+
specifier.startsWith('.') ||
|
|
155
|
+
specifier.startsWith('/') ||
|
|
156
|
+
specifier.startsWith('node:') ||
|
|
157
|
+
/^[a-zA-Z]+:/.test(specifier)
|
|
158
|
+
) {
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
const parts = specifier.split('/')
|
|
162
|
+
const name = specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]
|
|
163
|
+
const subpath = specifier.slice(name.length) || ''
|
|
164
|
+
return { name, subpath }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function findPackageJson(packageName, resolvedPath, importer) {
|
|
168
|
+
try {
|
|
169
|
+
return Bun.resolveSync(`${packageName}/package.json`, importer)
|
|
170
|
+
} catch {}
|
|
171
|
+
return findPackageJsonAbove(resolvedPath, packageName)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function findPackageJsonAbove(resolvedPath, packageName) {
|
|
175
|
+
let dir = dirname(resolvedPath)
|
|
176
|
+
while (dir !== dirname(dir)) {
|
|
177
|
+
const candidate = join(dir, 'package.json')
|
|
178
|
+
if (existsSync(candidate) && readPackageJson(candidate)?.name === packageName) return candidate
|
|
179
|
+
dir = dirname(dir)
|
|
180
|
+
}
|
|
181
|
+
return null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function readPackageJson(path) {
|
|
185
|
+
if (packageJsonCache.has(path)) return packageJsonCache.get(path)
|
|
186
|
+
let value = null
|
|
187
|
+
try {
|
|
188
|
+
value = JSON.parse(readFileSync(path, 'utf8'))
|
|
189
|
+
} catch {}
|
|
190
|
+
packageJsonCache.set(path, value)
|
|
191
|
+
return value
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function exportValueForKey(exports, key) {
|
|
195
|
+
if (key === '.' && (typeof exports === 'string' || Array.isArray(exports))) {
|
|
196
|
+
return { value: exports }
|
|
197
|
+
}
|
|
198
|
+
if (!exports || typeof exports !== 'object' || Array.isArray(exports)) return null
|
|
199
|
+
if (Object.prototype.hasOwnProperty.call(exports, key)) return { value: exports[key] }
|
|
200
|
+
if (key === '.' && isConditionalExport(exports)) return { value: exports }
|
|
201
|
+
for (const [pattern, value] of Object.entries(exports)) {
|
|
202
|
+
if (!pattern.includes('*')) continue
|
|
203
|
+
const [prefix, suffix] = pattern.split('*')
|
|
204
|
+
if (!key.startsWith(prefix) || !key.endsWith(suffix)) continue
|
|
205
|
+
return { value, pattern: key.slice(prefix.length, key.length - suffix.length) }
|
|
206
|
+
}
|
|
207
|
+
return null
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isConditionalExport(value) {
|
|
211
|
+
return Object.keys(value).some((key) => !key.startsWith('.'))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function preferredReactNativeTarget(value) {
|
|
215
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
216
|
+
if (!Object.prototype.hasOwnProperty.call(value, 'react-native')) return null
|
|
217
|
+
return preferredExportTarget(value['react-native'])
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function preferredExportTarget(value) {
|
|
221
|
+
if (typeof value === 'string') return value
|
|
222
|
+
if (Array.isArray(value)) {
|
|
223
|
+
for (const item of value) {
|
|
224
|
+
const target = preferredExportTarget(item)
|
|
225
|
+
if (target) return target
|
|
226
|
+
}
|
|
227
|
+
return null
|
|
228
|
+
}
|
|
229
|
+
if (!value || typeof value !== 'object') return null
|
|
230
|
+
for (const key of ['import', 'default', 'require']) {
|
|
231
|
+
const target = preferredExportTarget(value[key])
|
|
232
|
+
if (target) return target
|
|
233
|
+
}
|
|
234
|
+
return null
|
|
235
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Pixel / color sampling on decoded PNGs — the "what color is it actually
|
|
2
|
+
// rendering" primitives the conformance + parity scripts kept re-deriving by
|
|
3
|
+
// hand. Operates on a decoded image ({ width, height, rgba }) from png.mjs, or
|
|
4
|
+
// on a path/Buffer (auto-decoded). All colors come back as { r, g, b, a, hex }.
|
|
5
|
+
import { decodePng, readPng } from './png.mjs'
|
|
6
|
+
|
|
7
|
+
// accept a decoded {width,height,rgba}, a Buffer, or a file path.
|
|
8
|
+
export function toImage(src) {
|
|
9
|
+
if (src && typeof src === 'object' && src.rgba && typeof src.width === 'number') return src
|
|
10
|
+
if (Buffer.isBuffer(src)) return decodePng(src)
|
|
11
|
+
if (typeof src === 'string') return readPng(src)
|
|
12
|
+
throw new Error('expected a decoded image, png Buffer, or file path')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function toHex({ r, g, b }) {
|
|
16
|
+
const h = (n) => Math.round(clamp(n, 0, 255)).toString(16).padStart(2, '0')
|
|
17
|
+
return `#${h(r)}${h(g)}${h(b)}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// "#rrggbb" / "#rgb" / "#rrggbbaa" / "r,g,b[,a]" → { r, g, b, a }
|
|
21
|
+
export function parseColor(value) {
|
|
22
|
+
if (value && typeof value === 'object') return { a: 255, ...value }
|
|
23
|
+
const s = String(value).trim()
|
|
24
|
+
if (s.startsWith('#')) {
|
|
25
|
+
let hex = s.slice(1)
|
|
26
|
+
if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('')
|
|
27
|
+
if (hex.length === 6) hex += 'ff'
|
|
28
|
+
if (hex.length !== 8) throw new Error(`invalid hex color "${value}"`)
|
|
29
|
+
return {
|
|
30
|
+
r: parseInt(hex.slice(0, 2), 16),
|
|
31
|
+
g: parseInt(hex.slice(2, 4), 16),
|
|
32
|
+
b: parseInt(hex.slice(4, 6), 16),
|
|
33
|
+
a: parseInt(hex.slice(6, 8), 16),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const parts = s.split(',').map((n) => Number(n.trim()))
|
|
37
|
+
if (parts.length < 3 || parts.some((n) => !Number.isFinite(n))) throw new Error(`invalid color "${value}"`)
|
|
38
|
+
const [r, g, b, a = 255] = parts
|
|
39
|
+
return { r, g, b, a }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function withHex(color) {
|
|
43
|
+
return { ...color, hex: toHex(color) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function pixelAt(src, x, y) {
|
|
47
|
+
const img = toImage(src)
|
|
48
|
+
const px = Math.round(x)
|
|
49
|
+
const py = Math.round(y)
|
|
50
|
+
if (px < 0 || py < 0 || px >= img.width || py >= img.height) {
|
|
51
|
+
throw new Error(`pixel ${px},${py} out of bounds (${img.width}x${img.height})`)
|
|
52
|
+
}
|
|
53
|
+
const i = (py * img.width + px) * 4
|
|
54
|
+
return withHex({ r: img.rgba[i], g: img.rgba[i + 1], b: img.rgba[i + 2], a: img.rgba[i + 3] })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// clamp a rect to image bounds; default rect = whole image.
|
|
58
|
+
function resolveRect(img, rect) {
|
|
59
|
+
if (!rect) return { x: 0, y: 0, width: img.width, height: img.height }
|
|
60
|
+
const x = Math.max(0, Math.round(rect.x ?? 0))
|
|
61
|
+
const y = Math.max(0, Math.round(rect.y ?? 0))
|
|
62
|
+
const width = Math.min(img.width - x, Math.round(rect.width ?? img.width))
|
|
63
|
+
const height = Math.min(img.height - y, Math.round(rect.height ?? img.height))
|
|
64
|
+
if (width <= 0 || height <= 0) throw new Error(`empty rect ${JSON.stringify(rect)} for ${img.width}x${img.height}`)
|
|
65
|
+
return { x, y, width, height }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// mean color over a rect. Premultiplies by alpha so transparent pixels don't
|
|
69
|
+
// drag the average toward their (often garbage) rgb — and reports mean alpha.
|
|
70
|
+
export function averageColor(src, rect) {
|
|
71
|
+
const img = toImage(src)
|
|
72
|
+
const r = resolveRect(img, rect)
|
|
73
|
+
let rs = 0
|
|
74
|
+
let gs = 0
|
|
75
|
+
let bs = 0
|
|
76
|
+
let as = 0
|
|
77
|
+
let weight = 0
|
|
78
|
+
for (let yy = 0; yy < r.height; yy += 1) {
|
|
79
|
+
for (let xx = 0; xx < r.width; xx += 1) {
|
|
80
|
+
const i = ((r.y + yy) * img.width + (r.x + xx)) * 4
|
|
81
|
+
const a = img.rgba[i + 3]
|
|
82
|
+
const w = a / 255
|
|
83
|
+
rs += img.rgba[i] * w
|
|
84
|
+
gs += img.rgba[i + 1] * w
|
|
85
|
+
bs += img.rgba[i + 2] * w
|
|
86
|
+
as += a
|
|
87
|
+
weight += w
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const count = r.width * r.height
|
|
91
|
+
const safe = weight || 1
|
|
92
|
+
return withHex({
|
|
93
|
+
r: rs / safe,
|
|
94
|
+
g: gs / safe,
|
|
95
|
+
b: bs / safe,
|
|
96
|
+
a: as / count,
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// quantize colors into buckets and return the most-covered bucket (+ coverage
|
|
101
|
+
// fraction). Good for "what's the dominant background color of this area".
|
|
102
|
+
export function dominantColor(src, rect, { bucket = 16 } = {}) {
|
|
103
|
+
const img = toImage(src)
|
|
104
|
+
const r = resolveRect(img, rect)
|
|
105
|
+
const counts = new Map()
|
|
106
|
+
const q = (n) => Math.min(255, Math.floor(n / bucket) * bucket + Math.floor(bucket / 2))
|
|
107
|
+
let total = 0
|
|
108
|
+
for (let yy = 0; yy < r.height; yy += 1) {
|
|
109
|
+
for (let xx = 0; xx < r.width; xx += 1) {
|
|
110
|
+
const i = ((r.y + yy) * img.width + (r.x + xx)) * 4
|
|
111
|
+
if (img.rgba[i + 3] < 8) continue // skip fully transparent
|
|
112
|
+
const key = (q(img.rgba[i]) << 16) | (q(img.rgba[i + 1]) << 8) | q(img.rgba[i + 2])
|
|
113
|
+
counts.set(key, (counts.get(key) || 0) + 1)
|
|
114
|
+
total += 1
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (!total) throw new Error('region is fully transparent')
|
|
118
|
+
let bestKey = 0
|
|
119
|
+
let bestN = 0
|
|
120
|
+
for (const [key, n] of counts) {
|
|
121
|
+
if (n > bestN) {
|
|
122
|
+
bestN = n
|
|
123
|
+
bestKey = key
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
...withHex({ r: (bestKey >> 16) & 0xff, g: (bestKey >> 8) & 0xff, b: bestKey & 0xff, a: 255 }),
|
|
128
|
+
coverage: bestN / total,
|
|
129
|
+
distinctColors: counts.size,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// euclidean rgb distance (0 = identical, ~441 = black↔white).
|
|
134
|
+
export function colorDistance(a, b) {
|
|
135
|
+
const dr = a.r - b.r
|
|
136
|
+
const dg = a.g - b.g
|
|
137
|
+
const db = a.b - b.b
|
|
138
|
+
return Math.sqrt(dr * dr + dg * dg + db * db)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// assertion helper: does the average color of a rect match `expected` within
|
|
142
|
+
// `tolerance` (rgb distance)? Returns { match, actual, expected, distance }.
|
|
143
|
+
export function regionMatches(src, rect, expected, tolerance = 12) {
|
|
144
|
+
const actual = averageColor(src, rect)
|
|
145
|
+
const want = withHex(parseColor(expected))
|
|
146
|
+
const distance = colorDistance(actual, want)
|
|
147
|
+
return { match: distance <= tolerance, actual, expected: want, distance, tolerance }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function clamp(n, lo, hi) {
|
|
151
|
+
return Math.max(lo, Math.min(hi, n))
|
|
152
|
+
}
|
package/scripts/png.mjs
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Minimal dependency-free PNG decode/encode, shared by pixel tooling.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from pixel-diff.mjs so the decoder isn't trapped inside one CLI.
|
|
4
|
+
// Supports the encodings our captures actually produce: 8/16-bit depth,
|
|
5
|
+
// non-interlaced, color types 0 (gray), 2 (rgb), 4 (gray+alpha), 6 (rgba).
|
|
6
|
+
// Decoded images are always normalized to 8-bit RGBA: { width, height, rgba }.
|
|
7
|
+
import { inflateSync, deflateSync } from 'node:zlib'
|
|
8
|
+
import { readFileSync } from 'node:fs'
|
|
9
|
+
|
|
10
|
+
export const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
|
11
|
+
let crcTable = null
|
|
12
|
+
|
|
13
|
+
// Read just the IHDR width/height without decoding pixels — cheap and used a lot.
|
|
14
|
+
export function pngSize(pathOrBuffer) {
|
|
15
|
+
const buf = Buffer.isBuffer(pathOrBuffer) ? pathOrBuffer : readFileSync(pathOrBuffer)
|
|
16
|
+
if (!buf.subarray(0, 8).equals(PNG_SIGNATURE)) throw new Error('not a png file')
|
|
17
|
+
return [buf.readUInt32BE(16), buf.readUInt32BE(20)]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function readPng(path) {
|
|
21
|
+
return decodePng(readFileSync(path))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function decodePng(buffer) {
|
|
25
|
+
if (!buffer.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
|
26
|
+
throw new Error('not a png file')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let width = 0
|
|
30
|
+
let height = 0
|
|
31
|
+
let bitDepth = 0
|
|
32
|
+
let colorType = 0
|
|
33
|
+
const idat = []
|
|
34
|
+
|
|
35
|
+
let offset = 8
|
|
36
|
+
while (offset < buffer.length) {
|
|
37
|
+
const length = buffer.readUInt32BE(offset)
|
|
38
|
+
const type = buffer.toString('ascii', offset + 4, offset + 8)
|
|
39
|
+
const dataStart = offset + 8
|
|
40
|
+
const dataEnd = dataStart + length
|
|
41
|
+
const data = buffer.subarray(dataStart, dataEnd)
|
|
42
|
+
|
|
43
|
+
if (type === 'IHDR') {
|
|
44
|
+
width = data.readUInt32BE(0)
|
|
45
|
+
height = data.readUInt32BE(4)
|
|
46
|
+
bitDepth = data[8]
|
|
47
|
+
colorType = data[9]
|
|
48
|
+
const compression = data[10]
|
|
49
|
+
const filter = data[11]
|
|
50
|
+
const interlace = data[12]
|
|
51
|
+
if (![8, 16].includes(bitDepth) || compression !== 0 || filter !== 0 || interlace !== 0) {
|
|
52
|
+
throw new Error('unsupported png encoding')
|
|
53
|
+
}
|
|
54
|
+
if (![0, 2, 4, 6].includes(colorType)) {
|
|
55
|
+
throw new Error(`unsupported png color type ${colorType}`)
|
|
56
|
+
}
|
|
57
|
+
} else if (type === 'IDAT') {
|
|
58
|
+
idat.push(data)
|
|
59
|
+
} else if (type === 'IEND') {
|
|
60
|
+
break
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
offset = dataEnd + 4
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const bytesPerSample = bitDepth / 8
|
|
67
|
+
const channels = colorType === 6 ? 4 : colorType === 4 ? 2 : colorType === 2 ? 3 : 1
|
|
68
|
+
const raw = inflateSync(Buffer.concat(idat))
|
|
69
|
+
const rowBytes = width * channels * bytesPerSample
|
|
70
|
+
const scanlineBytes = rowBytes + 1
|
|
71
|
+
const unfiltered = Buffer.alloc(rowBytes * height)
|
|
72
|
+
|
|
73
|
+
for (let y = 0; y < height; y += 1) {
|
|
74
|
+
const filter = raw[y * scanlineBytes]
|
|
75
|
+
const rowStart = y * rowBytes
|
|
76
|
+
const sourceStart = y * scanlineBytes + 1
|
|
77
|
+
for (let x = 0; x < rowBytes; x += 1) {
|
|
78
|
+
const value = raw[sourceStart + x]
|
|
79
|
+
const left = x >= channels * bytesPerSample ? unfiltered[rowStart + x - channels * bytesPerSample] : 0
|
|
80
|
+
const up = y > 0 ? unfiltered[rowStart + x - rowBytes] : 0
|
|
81
|
+
const upLeft =
|
|
82
|
+
y > 0 && x >= channels * bytesPerSample
|
|
83
|
+
? unfiltered[rowStart + x - rowBytes - channels * bytesPerSample]
|
|
84
|
+
: 0
|
|
85
|
+
switch (filter) {
|
|
86
|
+
case 0:
|
|
87
|
+
unfiltered[rowStart + x] = value
|
|
88
|
+
break
|
|
89
|
+
case 1:
|
|
90
|
+
unfiltered[rowStart + x] = (value + left) & 0xff
|
|
91
|
+
break
|
|
92
|
+
case 2:
|
|
93
|
+
unfiltered[rowStart + x] = (value + up) & 0xff
|
|
94
|
+
break
|
|
95
|
+
case 3:
|
|
96
|
+
unfiltered[rowStart + x] = (value + Math.floor((left + up) / 2)) & 0xff
|
|
97
|
+
break
|
|
98
|
+
case 4:
|
|
99
|
+
unfiltered[rowStart + x] = (value + paeth(left, up, upLeft)) & 0xff
|
|
100
|
+
break
|
|
101
|
+
default:
|
|
102
|
+
throw new Error(`unsupported png filter ${filter}`)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const rgba = Buffer.alloc(width * height * 4)
|
|
108
|
+
const sample = (index) => unfiltered[index]
|
|
109
|
+
for (let i = 0, j = 0; i < unfiltered.length; i += channels * bytesPerSample, j += 4) {
|
|
110
|
+
if (colorType === 6) {
|
|
111
|
+
rgba[j] = sample(i)
|
|
112
|
+
rgba[j + 1] = sample(i + bytesPerSample)
|
|
113
|
+
rgba[j + 2] = sample(i + bytesPerSample * 2)
|
|
114
|
+
rgba[j + 3] = sample(i + bytesPerSample * 3)
|
|
115
|
+
} else if (colorType === 2) {
|
|
116
|
+
rgba[j] = sample(i)
|
|
117
|
+
rgba[j + 1] = sample(i + bytesPerSample)
|
|
118
|
+
rgba[j + 2] = sample(i + bytesPerSample * 2)
|
|
119
|
+
rgba[j + 3] = 255
|
|
120
|
+
} else if (colorType === 4) {
|
|
121
|
+
rgba[j] = sample(i)
|
|
122
|
+
rgba[j + 1] = sample(i)
|
|
123
|
+
rgba[j + 2] = sample(i)
|
|
124
|
+
rgba[j + 3] = sample(i + bytesPerSample)
|
|
125
|
+
} else {
|
|
126
|
+
rgba[j] = sample(i)
|
|
127
|
+
rgba[j + 1] = sample(i)
|
|
128
|
+
rgba[j + 2] = sample(i)
|
|
129
|
+
rgba[j + 3] = 255
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { width, height, rgba }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function paeth(left, up, upLeft) {
|
|
137
|
+
const p = left + up - upLeft
|
|
138
|
+
const pa = Math.abs(p - left)
|
|
139
|
+
const pb = Math.abs(p - up)
|
|
140
|
+
const pc = Math.abs(p - upLeft)
|
|
141
|
+
if (pa <= pb && pa <= pc) return left
|
|
142
|
+
if (pb <= pc) return up
|
|
143
|
+
return upLeft
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function encodePng(width, height, rgba) {
|
|
147
|
+
const raw = Buffer.alloc((width * 4 + 1) * height)
|
|
148
|
+
for (let y = 0; y < height; y += 1) {
|
|
149
|
+
const rawStart = y * (width * 4 + 1)
|
|
150
|
+
raw[rawStart] = 0
|
|
151
|
+
rgba.copy(raw, rawStart + 1, y * width * 4, (y + 1) * width * 4)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const ihdr = Buffer.alloc(13)
|
|
155
|
+
ihdr.writeUInt32BE(width, 0)
|
|
156
|
+
ihdr.writeUInt32BE(height, 4)
|
|
157
|
+
ihdr[8] = 8
|
|
158
|
+
ihdr[9] = 6
|
|
159
|
+
ihdr[10] = 0
|
|
160
|
+
ihdr[11] = 0
|
|
161
|
+
ihdr[12] = 0
|
|
162
|
+
|
|
163
|
+
return Buffer.concat([
|
|
164
|
+
PNG_SIGNATURE,
|
|
165
|
+
chunk('IHDR', ihdr),
|
|
166
|
+
chunk('IDAT', deflateSync(raw)),
|
|
167
|
+
chunk('IEND', Buffer.alloc(0)),
|
|
168
|
+
])
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function chunk(type, data) {
|
|
172
|
+
const typeBuffer = Buffer.from(type, 'ascii')
|
|
173
|
+
const out = Buffer.alloc(12 + data.length)
|
|
174
|
+
out.writeUInt32BE(data.length, 0)
|
|
175
|
+
typeBuffer.copy(out, 4)
|
|
176
|
+
data.copy(out, 8)
|
|
177
|
+
out.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 8 + data.length)
|
|
178
|
+
return out
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function crc32(buffer) {
|
|
182
|
+
if (!crcTable) {
|
|
183
|
+
crcTable = new Uint32Array(256)
|
|
184
|
+
for (let n = 0; n < 256; n += 1) {
|
|
185
|
+
let c = n
|
|
186
|
+
for (let k = 0; k < 8; k += 1) {
|
|
187
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
|
188
|
+
}
|
|
189
|
+
crcTable[n] = c >>> 0
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let c = 0xffffffff
|
|
194
|
+
for (const byte of buffer) {
|
|
195
|
+
c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8)
|
|
196
|
+
}
|
|
197
|
+
return (c ^ 0xffffffff) >>> 0
|
|
198
|
+
}
|