@use-voltra/expo-plugin 2.0.0-rc.4 → 2.1.0

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/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ export { MAX_IMAGE_SIZE_BYTES, MODULE_EXTENSIONS } from './constants'
2
+ export type {
3
+ DynamicWidgetEntryConfig,
4
+ DynamicWidgetManifest,
5
+ DynamicWidgetManifestWidget,
6
+ DynamicWidgetPlatform,
7
+ WidgetInitialStatePath,
8
+ WidgetLabel,
9
+ WidgetLocalizedCopy,
10
+ } from './types'
11
+ export {
12
+ assertValidLocaleKey,
13
+ normalizeWidgetEntryPath,
14
+ validateHomeScreenWidgetId,
15
+ validateInitialStatePath,
16
+ validateWidgetEntry,
17
+ validateWidgetLabel,
18
+ } from './validation'
19
+ export { addApplicationGroupsEntitlement } from './utils/entitlements'
20
+ export { resolveFontPaths } from './utils/fonts'
21
+ export { normalizeLocaleTag, pickLocalizedValue } from './utils/localePick'
22
+ export { logger } from './utils/logger'
23
+ export type { PrerenderableWidget, PrerenderedWidgetStates, WidgetRenderer } from './utils/prerender'
24
+ export { evaluateWidgetModule, evaluateWidgetModuleExports, prerenderWidgetState } from './utils/prerender'
25
+ export { isWidgetLocalizedMap, widgetLabelEnglish } from './utils/widgetLabel'
package/src/types.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Types shared by iOS and Android Voltra config plugins.
3
+ * Platform widget config types live in each client's expo-plugin package.
4
+ */
5
+
6
+ export type DynamicWidgetPlatform = 'ios' | 'android'
7
+
8
+ /**
9
+ * Shared app.json entry contract for Dynamic Widgets.
10
+ *
11
+ * `entry` stays optional during rollout so platform packages can adopt the shared contract
12
+ * incrementally, but validation should require it for real widget declarations.
13
+ */
14
+ export interface DynamicWidgetEntryConfig {
15
+ entry?: string
16
+ }
17
+
18
+ export interface DynamicWidgetManifestWidget {
19
+ id: string
20
+ entry: string
21
+ }
22
+
23
+ export interface DynamicWidgetManifest {
24
+ version: 1
25
+ platform: DynamicWidgetPlatform
26
+ widgets: DynamicWidgetManifestWidget[]
27
+ }
28
+
29
+ /**
30
+ * Per-locale strings for widget picker/gallery labels (`displayName`, `description`).
31
+ * Keys should be BCP-47-style locale tags (e.g. `en`, `pl`, `pt-BR`). Plain `string` is still allowed for a single-language setup.
32
+ */
33
+ export type WidgetLocalizedCopy = Record<string, string>
34
+
35
+ export type WidgetLabel = string | WidgetLocalizedCopy
36
+
37
+ /**
38
+ * Build-time widget initial state source: a single file path, or per-locale paths (same key rules as `WidgetLocalizedCopy`).
39
+ * Each path must point to a module that exports the widget variants / default export for prerendering.
40
+ */
41
+ export type WidgetInitialStatePath = string | WidgetLocalizedCopy
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Shared entitlements utilities.
3
+ */
4
+
5
+ /**
6
+ * Adds application groups entitlement to an entitlements object.
7
+ * Ensures the group list is deduped and preserves existing order.
8
+ */
9
+ export function addApplicationGroupsEntitlement(
10
+ entitlements: Record<string, any>,
11
+ groupIdentifier: string
12
+ ): Record<string, any> {
13
+ const existingApplicationGroups = ((entitlements['com.apple.security.application-groups'] as string[]) ?? []).filter(
14
+ Boolean
15
+ )
16
+
17
+ const deduped = Array.from(new Set(existingApplicationGroups))
18
+ if (deduped.includes(groupIdentifier)) {
19
+ entitlements['com.apple.security.application-groups'] = deduped
20
+ return entitlements
21
+ }
22
+
23
+ entitlements['com.apple.security.application-groups'] = [...deduped, groupIdentifier]
24
+
25
+ return entitlements
26
+ }
@@ -0,0 +1,44 @@
1
+ import * as fs from 'fs/promises'
2
+ import * as path from 'path'
3
+ import { createRequire } from 'module'
4
+
5
+ import { logger } from './logger'
6
+
7
+ const FONT_EXTENSIONS = ['.ttf', '.otf', '.woff', '.woff2']
8
+
9
+ async function resolveFontInput(input: string, projectRoot: string): Promise<string | null> {
10
+ const resolvedPath = path.resolve(projectRoot, input)
11
+
12
+ try {
13
+ await fs.stat(resolvedPath)
14
+ return resolvedPath
15
+ } catch {
16
+ try {
17
+ const projectRequire = createRequire(path.join(projectRoot, 'package.json'))
18
+ return projectRequire.resolve(input)
19
+ } catch {
20
+ logger.warn(`Could not resolve font path: ${input}`)
21
+ return null
22
+ }
23
+ }
24
+ }
25
+
26
+ export async function resolveFontPaths(fonts: string[], projectRoot: string): Promise<string[]> {
27
+ const promises = fonts.map(async (input) => {
28
+ const resolvedInput = await resolveFontInput(input, projectRoot)
29
+ if (!resolvedInput) {
30
+ return []
31
+ }
32
+
33
+ const stat = await fs.stat(resolvedInput)
34
+ if (stat.isDirectory()) {
35
+ const dir = await fs.readdir(resolvedInput)
36
+ return dir.map((file) => path.join(resolvedInput, file))
37
+ }
38
+
39
+ return [resolvedInput]
40
+ })
41
+
42
+ const results = await Promise.all(promises)
43
+ return results.flat().filter((filePath) => FONT_EXTENSIONS.some((ext) => filePath.endsWith(ext)))
44
+ }
@@ -0,0 +1,24 @@
1
+ import { pickLocalizedValue } from './localePick'
2
+
3
+ describe('pickLocalizedValue', () => {
4
+ it('prefers exact locale tag match', () => {
5
+ const out = pickLocalizedValue({ en: '{"a":1}', pl: '{"a":2}', 'pt-BR': '{"a":3}' }, ['pt-BR', 'en'])
6
+ expect(out).toBe('{"a":3}')
7
+ })
8
+
9
+ it('falls back to language-only match', () => {
10
+ const out = pickLocalizedValue({ en: 'en', pl: 'pl' }, ['pl-PL'])
11
+ expect(out).toBe('pl')
12
+ })
13
+
14
+ it('uses en then __default then first sorted key', () => {
15
+ expect(pickLocalizedValue({ de: 'de', en: 'en' }, ['fr'])).toBe('en')
16
+ expect(pickLocalizedValue({ __default: 'd', pl: 'pl' }, ['fr'])).toBe('d')
17
+ expect(pickLocalizedValue({ zz: 'z', aa: 'a' }, ['fr'])).toBe('a')
18
+ })
19
+
20
+ it('prefers english-family locales when plain en is absent', () => {
21
+ expect(pickLocalizedValue({ 'en-US': 'us', pl: 'pl' }, ['en'])).toBe('us')
22
+ expect(pickLocalizedValue({ de: 'de', en_GB: 'gb' }, ['en'])).toBe('gb')
23
+ })
24
+ })
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Picks a localized string value from a locale-keyed map using the same fallback rules as iOS/Android runtimes:
3
+ * preferred language tags (full match, then language-only), then `en`, then `__default`, then first value.
4
+ */
5
+ export function normalizeLocaleTag(tag: string): string {
6
+ return tag.trim().toLowerCase().replace(/_/g, '-')
7
+ }
8
+
9
+ export function pickLocalizedValue(
10
+ perLocale: Record<string, string>,
11
+ preferredLanguages: string[]
12
+ ): string | undefined {
13
+ const entries = Object.entries(perLocale).filter(([, v]) => typeof v === 'string' && v.length > 0)
14
+ if (entries.length === 0) {
15
+ return undefined
16
+ }
17
+
18
+ const byNorm = new Map<string, string>()
19
+ for (const [k, v] of entries) {
20
+ byNorm.set(normalizeLocaleTag(k), v)
21
+ }
22
+
23
+ const DEFAULT_KEY = '__default'
24
+
25
+ for (const pref of preferredLanguages) {
26
+ const n = normalizeLocaleTag(pref)
27
+ const direct = byNorm.get(n)
28
+ if (direct !== undefined) {
29
+ return direct
30
+ }
31
+ const lang = n.split('-')[0] ?? n
32
+ for (const [key, val] of entries) {
33
+ const kn = normalizeLocaleTag(key)
34
+ const keyLang = kn.split('-')[0] ?? kn
35
+ if (keyLang === lang) {
36
+ return val
37
+ }
38
+ }
39
+ }
40
+
41
+ const en = byNorm.get('en') ?? entries.find(([k]) => normalizeLocaleTag(k) === 'en')?.[1]
42
+ if (en !== undefined) {
43
+ return en
44
+ }
45
+
46
+ const defaultVal = byNorm.get(DEFAULT_KEY) ?? entries.find(([k]) => k === DEFAULT_KEY)?.[1]
47
+ if (defaultVal !== undefined) {
48
+ return defaultVal
49
+ }
50
+
51
+ const sorted = [...entries].sort(([a], [b]) => a.localeCompare(b))
52
+ return sorted[0]?.[1]
53
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Structured logging utility for the Voltra plugin
3
+ */
4
+
5
+ const PREFIX = '[Voltra]'
6
+
7
+ // ANSI color codes
8
+ const RED = '\x1b[31m'
9
+ const RESET = '\x1b[0m'
10
+
11
+ export const logger = {
12
+ /**
13
+ * Log an informational message
14
+ */
15
+ info: (message: string): void => {
16
+ console.log(`${PREFIX} ${message}`)
17
+ },
18
+
19
+ /**
20
+ * Log a warning message
21
+ */
22
+ warn: (message: string): void => {
23
+ console.warn(`${PREFIX} ⚠️ ${message}`)
24
+ },
25
+
26
+ /**
27
+ * Log a warning message in red (for critical warnings)
28
+ */
29
+ warnRed: (message: string): void => {
30
+ console.warn(`${RED}${PREFIX} ⚠️ ${message}${RESET}`)
31
+ },
32
+
33
+ /**
34
+ * Log an error message
35
+ */
36
+ error: (message: string): void => {
37
+ console.error(`${PREFIX} ❌ ${message}`)
38
+ },
39
+
40
+ /**
41
+ * Log a success message
42
+ */
43
+ success: (message: string): void => {
44
+ console.log(`${PREFIX} ✅ ${message}`)
45
+ },
46
+
47
+ /**
48
+ * Log a debug message (only when VOLTRA_DEBUG env var is set)
49
+ */
50
+ debug: (message: string): void => {
51
+ if (process.env.VOLTRA_DEBUG) {
52
+ console.log(`${PREFIX} 🔍 ${message}`)
53
+ }
54
+ },
55
+ }
@@ -0,0 +1,69 @@
1
+ import fs from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+
5
+ import { logger } from './logger'
6
+ import { prerenderWidgetState } from './prerender'
7
+
8
+ async function writePackage(root: string, packageName: string, source: string): Promise<void> {
9
+ const packageDir = path.join(root, 'node_modules', ...packageName.split('/'))
10
+ await fs.mkdir(packageDir, { recursive: true })
11
+ await fs.writeFile(
12
+ path.join(packageDir, 'package.json'),
13
+ JSON.stringify(
14
+ {
15
+ name: packageName,
16
+ main: 'index.js',
17
+ },
18
+ null,
19
+ 2
20
+ )
21
+ )
22
+ await fs.writeFile(path.join(packageDir, 'index.js'), source)
23
+ }
24
+
25
+ describe('prerenderWidgetState', () => {
26
+ it.each([
27
+ ['@use-voltra/ios-client', '@use-voltra/ios', 'ios'],
28
+ ['@use-voltra/android-client', '@use-voltra/android', 'android'],
29
+ ] as const)('redirects %s to %s and warns once', async (clientPackage, targetPackage, targetLabel) => {
30
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'voltra-prerender-'))
31
+ const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined)
32
+
33
+ try {
34
+ await fs.writeFile(
35
+ path.join(tempRoot, 'package.json'),
36
+ JSON.stringify(
37
+ {
38
+ name: 'fixture-project',
39
+ },
40
+ null,
41
+ 2
42
+ )
43
+ )
44
+
45
+ await fs.mkdir(path.join(tempRoot, 'widgets'), { recursive: true })
46
+ await writePackage(tempRoot, targetPackage, `exports.label = ${JSON.stringify(targetLabel)}\n`)
47
+ await writePackage(tempRoot, clientPackage, `throw new Error('client package should not load')\n`)
48
+
49
+ await fs.writeFile(
50
+ path.join(tempRoot, 'widgets', 'state.ts'),
51
+ [`import { label } from '${clientPackage}'`, 'export default { label }', ''].join('\n')
52
+ )
53
+
54
+ const states = await prerenderWidgetState(
55
+ [{ id: 'demo', initialStatePath: './widgets/state.ts' }],
56
+ tempRoot,
57
+ (variants) => JSON.stringify(variants)
58
+ )
59
+
60
+ expect(states.get('demo')?.get('__default')).toBe(JSON.stringify({ label: targetLabel }))
61
+ expect(warnSpy).toHaveBeenCalledTimes(1)
62
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(clientPackage))
63
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(targetPackage))
64
+ } finally {
65
+ warnSpy.mockRestore()
66
+ await fs.rm(tempRoot, { recursive: true, force: true })
67
+ }
68
+ })
69
+ })
@@ -0,0 +1,249 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { createRequire } from 'node:module'
4
+ import vm from 'node:vm'
5
+
6
+ import * as babel from '@babel/core'
7
+
8
+ import { MODULE_EXTENSIONS } from '../constants'
9
+ import type { WidgetInitialStatePath, WidgetLabel } from '../types'
10
+ import { logger } from './logger'
11
+ import { isWidgetLocalizedMap } from './widgetLabel'
12
+
13
+ /**
14
+ * Type for the widget renderer function
15
+ */
16
+ export type WidgetRenderer = (variants: any) => string
17
+
18
+ /**
19
+ * Minimal interface for widget configuration needed for prerendering
20
+ */
21
+ export interface PrerenderableWidget {
22
+ id: string
23
+ initialStatePath?: WidgetInitialStatePath
24
+ }
25
+
26
+ /** widgetId -> locale key -> prerendered JSON string (single-file widgets use `__default`) */
27
+ export type PrerenderedWidgetStates = Map<string, Map<string, string>>
28
+
29
+ const PRERENDER_PACKAGE_REDIRECTS: Record<string, string> = {
30
+ '@use-voltra/ios-client': '@use-voltra/ios',
31
+ '@use-voltra/android-client': '@use-voltra/android',
32
+ }
33
+
34
+ /**
35
+ * Check if a module specifier is a relative or absolute path (local file)
36
+ */
37
+ function isLocalModule(moduleSpecifier: string): boolean {
38
+ return moduleSpecifier.startsWith('.') || moduleSpecifier.startsWith('/')
39
+ }
40
+
41
+ /**
42
+ * Resolve a module path, trying different extensions
43
+ */
44
+ function resolveModulePath(moduleSpecifier: string, fromDir: string): string | null {
45
+ const basePath = path.resolve(fromDir, moduleSpecifier)
46
+
47
+ for (const ext of MODULE_EXTENSIONS) {
48
+ const fullPath = basePath + ext
49
+ if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
50
+ return fullPath
51
+ }
52
+ }
53
+
54
+ // Try index files if it's a directory
55
+ if (fs.existsSync(basePath) && fs.statSync(basePath).isDirectory()) {
56
+ for (const ext of MODULE_EXTENSIONS) {
57
+ const indexPath = path.join(basePath, 'index' + ext)
58
+ if (fs.existsSync(indexPath)) {
59
+ return indexPath
60
+ }
61
+ }
62
+ }
63
+
64
+ return null
65
+ }
66
+
67
+ function getProjectBabelConfigPath(projectRoot: string): string | null {
68
+ const configPath = path.join(projectRoot, 'babel.config.js')
69
+ return fs.existsSync(configPath) ? configPath : null
70
+ }
71
+
72
+ function getFallbackExpoPreset(projectRoot: string): string {
73
+ return require.resolve('babel-preset-expo', { paths: [projectRoot] })
74
+ }
75
+
76
+ /**
77
+ * Transpile a file with Babel
78
+ */
79
+ function transpileFile(filePath: string, projectRoot: string): string {
80
+ const code = fs.readFileSync(filePath, 'utf8')
81
+ const projectBabelConfigPath = getProjectBabelConfigPath(projectRoot)
82
+
83
+ const result = babel.transformSync(code, {
84
+ cwd: projectRoot,
85
+ filename: filePath,
86
+ ...(projectBabelConfigPath
87
+ ? { configFile: projectBabelConfigPath }
88
+ : {
89
+ babelrc: false,
90
+ configFile: false,
91
+ presets: [getFallbackExpoPreset(projectRoot)],
92
+ }),
93
+ })
94
+
95
+ if (!result || !result.code) {
96
+ throw new Error(`Babel transpilation failed for ${filePath}`)
97
+ }
98
+
99
+ return result.code
100
+ }
101
+
102
+ /**
103
+ * Evaluate a widget module using Babel transpilation and Node.js VM.
104
+ * This allows executing widget code that uses JSX and React components.
105
+ * Local module dependencies are also transpiled with the same Babel settings.
106
+ *
107
+ * Exported so platform-specific prerender flows can reuse the same module loader rather
108
+ * than duplicating the Babel + VM scaffolding. The returned value is the module's exports
109
+ * object — callers decide whether to read `.default`, a named export, etc.
110
+ */
111
+ export function evaluateWidgetModuleExports(
112
+ projectRoot: string,
113
+ filePath: string,
114
+ warnedRedirects = new Set<string>()
115
+ ): any {
116
+ // Cache for already-evaluated modules to handle circular dependencies
117
+ const moduleCache = new Map<string, any>()
118
+ const projectRequire = createRequire(path.join(projectRoot, 'package.json'))
119
+
120
+ /**
121
+ * Custom require that transpiles local modules with Babel
122
+ */
123
+ function customRequire(moduleSpecifier: string, currentDir: string): any {
124
+ // For non-local modules (npm packages), use native require
125
+ if (!isLocalModule(moduleSpecifier)) {
126
+ const redirectedSpecifier = PRERENDER_PACKAGE_REDIRECTS[moduleSpecifier]
127
+
128
+ if (redirectedSpecifier) {
129
+ if (!warnedRedirects.has(moduleSpecifier)) {
130
+ warnedRedirects.add(moduleSpecifier)
131
+ logger.warn(
132
+ `Prerendering initial state imported '${moduleSpecifier}'. Using '${redirectedSpecifier}' instead.`
133
+ )
134
+ }
135
+
136
+ return projectRequire(redirectedSpecifier)
137
+ }
138
+
139
+ return projectRequire(moduleSpecifier)
140
+ }
141
+
142
+ // Resolve the local module path
143
+ const resolvedPath = resolveModulePath(moduleSpecifier, currentDir)
144
+ if (!resolvedPath) {
145
+ throw new Error(`Cannot resolve module '${moduleSpecifier}' from '${currentDir}'`)
146
+ }
147
+
148
+ // Return cached module if already evaluated
149
+ if (moduleCache.has(resolvedPath)) {
150
+ return moduleCache.get(resolvedPath)
151
+ }
152
+
153
+ // Transpile and evaluate the module
154
+ const transpiledCode = transpileFile(resolvedPath, projectRoot)
155
+ const moduleDir = path.dirname(resolvedPath)
156
+
157
+ const mockModule = { exports: {} as any }
158
+
159
+ // Create require function bound to the module's directory
160
+ const boundRequire = (spec: string) => customRequire(spec, moduleDir)
161
+
162
+ const context = vm.createContext({
163
+ exports: mockModule.exports,
164
+ module: mockModule,
165
+ require: boundRequire,
166
+ __filename: resolvedPath,
167
+ __dirname: moduleDir,
168
+ console: console,
169
+ process: process,
170
+ })
171
+
172
+ // Cache before evaluation to handle circular dependencies
173
+ moduleCache.set(resolvedPath, mockModule.exports)
174
+
175
+ const script = new vm.Script(transpiledCode, { filename: resolvedPath })
176
+ script.runInContext(context)
177
+
178
+ // Update cache with final exports (in case module.exports was reassigned)
179
+ moduleCache.set(resolvedPath, mockModule.exports)
180
+
181
+ return mockModule.exports
182
+ }
183
+
184
+ return customRequire(filePath, path.dirname(filePath))
185
+ }
186
+
187
+ /**
188
+ * Evaluate a widget file as a server-style WidgetVariants module and return its object export.
189
+ */
190
+ export function evaluateWidgetModule(projectRoot: string, filePath: string, warnedRedirects = new Set<string>()): any {
191
+ const exports = evaluateWidgetModuleExports(projectRoot, filePath, warnedRedirects)
192
+ const widgetVariants: any = exports.default || exports
193
+
194
+ if (!widgetVariants || typeof widgetVariants !== 'object') {
195
+ throw new Error('Widget file must export a WidgetVariants object or have a default export of WidgetVariants')
196
+ }
197
+
198
+ return widgetVariants
199
+ }
200
+
201
+ /**
202
+ * Prerender widget initial states for build-time inclusion.
203
+ *
204
+ * Widget code is transpiled with Babel and executed in a Node.js VM sandbox.
205
+ * This function loads widget files that export WidgetVariants for widgets with initialStatePath configured,
206
+ * renders them to JSON, and returns a map of prerendered states that can be bundled into the app.
207
+ *
208
+ * @param widgets - Array of widget configurations
209
+ * @param projectRoot - Root directory of the Expo project
210
+ * @param renderer - The renderer function to use (voltra/server or voltra/android/server)
211
+ * @returns Map of widgetId -> (locale key -> prerendered JSON string)
212
+ */
213
+ export async function prerenderWidgetState(
214
+ widgets: PrerenderableWidget[],
215
+ projectRoot: string,
216
+ renderer: WidgetRenderer
217
+ ): Promise<PrerenderedWidgetStates> {
218
+ const prerenderedStates: PrerenderedWidgetStates = new Map()
219
+ const warnedRedirects = new Set<string>()
220
+
221
+ for (const widget of widgets) {
222
+ if (!widget.initialStatePath) {
223
+ continue
224
+ }
225
+
226
+ const pathSpec = widget.initialStatePath
227
+ const perLocalePaths: Record<string, string> = isWidgetLocalizedMap(pathSpec as WidgetLabel)
228
+ ? (pathSpec as Record<string, string>)
229
+ : { __default: pathSpec as string }
230
+
231
+ const inner = new Map<string, string>()
232
+
233
+ try {
234
+ for (const [localeKey, relativePath] of Object.entries(perLocalePaths)) {
235
+ const absoluteWidgetPath = path.resolve(projectRoot, relativePath)
236
+ const widgetVariants = evaluateWidgetModule(projectRoot, absoluteWidgetPath, warnedRedirects)
237
+ const prerenderedState = renderer(widgetVariants)
238
+ inner.set(localeKey, prerenderedState)
239
+ }
240
+ prerenderedStates.set(widget.id, inner)
241
+ } catch (error) {
242
+ throw new Error(
243
+ `Failed to prerender widget state for ${widget.id}: ${error instanceof Error ? error.message : String(error)}`
244
+ )
245
+ }
246
+ }
247
+
248
+ return prerenderedStates
249
+ }
@@ -0,0 +1,19 @@
1
+ import type { WidgetLabel, WidgetLocalizedCopy } from '../types'
2
+
3
+ import { pickLocalizedValue } from './localePick'
4
+
5
+ export function isWidgetLocalizedMap(label: WidgetLabel): label is WidgetLocalizedCopy {
6
+ return typeof label === 'object' && label !== null && !Array.isArray(label)
7
+ }
8
+
9
+ /**
10
+ * Development / fallback English copy for LocalizedStringResource.defaultValue.
11
+ * Prefers `en`, then `en-*`, then default fallback order from locale picking.
12
+ */
13
+ export function widgetLabelEnglish(label: WidgetLabel): string {
14
+ if (!isWidgetLocalizedMap(label)) {
15
+ return label
16
+ }
17
+
18
+ return pickLocalizedValue(label, ['en']) ?? ''
19
+ }