@use-voltra/expo-plugin 2.0.0 → 2.1.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/build/cjs/index.js +5 -1
- package/build/cjs/index.js.map +1 -1
- package/build/cjs/utils/prerender.js +32 -5
- package/build/cjs/utils/prerender.js.map +1 -1
- package/build/cjs/validation.js +55 -0
- package/build/cjs/validation.js.map +1 -1
- package/build/esm/index.js +2 -2
- package/build/esm/index.js.map +1 -1
- package/build/esm/utils/prerender.js +30 -5
- package/build/esm/utils/prerender.js.map +1 -1
- package/build/esm/validation.js +53 -0
- package/build/esm/validation.js.map +1 -1
- package/build/types/index.d.ts +3 -3
- package/build/types/index.d.ts.map +1 -1
- package/build/types/types.d.ts +19 -0
- package/build/types/types.d.ts.map +1 -1
- package/build/types/utils/prerender.d.ts +14 -0
- package/build/types/utils/prerender.d.ts.map +1 -1
- package/build/types/validation.d.ts +2 -0
- package/build/types/validation.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/constants.ts +5 -0
- package/src/index.ts +25 -0
- package/src/types.ts +41 -0
- package/src/utils/entitlements.ts +26 -0
- package/src/utils/fonts.ts +44 -0
- package/src/utils/localePick.node.test.ts +24 -0
- package/src/utils/localePick.ts +53 -0
- package/src/utils/logger.ts +55 -0
- package/src/utils/prerender.node.test.ts +69 -0
- package/src/utils/prerender.ts +249 -0
- package/src/utils/widgetLabel.ts +19 -0
- package/src/validation.node.test.ts +95 -0
- package/src/validation.ts +225 -0
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
|
+
}
|