From c2a1ced2c72517e202acf3f5cfc6896d8059d1ac Mon Sep 17 00:00:00 2001 From: Yauheni Pasiukevich Date: Thu, 30 Apr 2026 19:35:41 +0200 Subject: [PATCH 1/6] feat: add localization support for widget display names and descriptions --- example/app.json | 90 ++++++++--- .../expo-plugin/src/android/files/kotlin.ts | 6 +- packages/expo-plugin/src/android/files/xml.ts | 153 ++++++++++++++++-- packages/expo-plugin/src/index.ts | 2 + .../expo-plugin/src/ios-widget/files/swift.ts | 147 ++++++++++++++++- .../src/ios-widget/xcode/buildPhases.ts | 12 +- .../src/ios-widget/xcode/groups.ts | 17 +- packages/expo-plugin/src/types.ts | 27 +++- .../expo-plugin/src/utils/fileDiscovery.ts | 14 ++ packages/expo-plugin/src/utils/widgetLabel.ts | 20 +++ packages/expo-plugin/src/validation.ts | 132 ++++++++++----- 11 files changed, 525 insertions(+), 95 deletions(-) create mode 100644 packages/expo-plugin/src/utils/widgetLabel.ts diff --git a/example/app.json b/example/app.json index a082b0ae..bdd8052a 100644 --- a/example/app.json +++ b/example/app.json @@ -37,15 +37,27 @@ "widgets": [ { "id": "weather", - "displayName": "Weather Widget", - "description": "Shows current weather conditions", + "displayName": { + "pl": "Widget pogody", + "en": "Weather Widget" + }, + "description": { + "pl": "Pokazuje aktualne warunki pogodowe", + "en": "Shows current weather conditions" + }, "supportedFamilies": ["systemSmall", "systemMedium", "systemLarge"], "initialStatePath": "./widgets/ios/ios-weather-initial.tsx" }, { "id": "portfolio", - "displayName": "Portfolio Widget", - "description": "Shows portfolio performance with a chart and live updates", + "displayName": { + "pl": "Widget portfela", + "en": "Portfolio Widget" + }, + "description": { + "pl": "Pokazuje wyniki portfela z wykresem i aktualizacjami na żywo", + "en": "Shows portfolio performance with a chart and live updates" + }, "supportedFamilies": ["systemSmall", "systemMedium", "systemLarge"], "initialStatePath": "./widgets/ios/ios-portfolio-initial.tsx", "serverUpdate": { @@ -60,8 +72,14 @@ "widgets": [ { "id": "voltra", - "displayName": "Voltra Widget", - "description": "Voltra logo widget", + "displayName": { + "pl": "Widget Voltra", + "en": "Voltra Widget" + }, + "description": { + "pl": "Widget z logo Voltra", + "en": "Voltra logo widget" + }, "minCellWidth": 2, "minCellHeight": 2, "targetCellWidth": 2, @@ -73,8 +91,14 @@ }, { "id": "interactive_todos", - "displayName": "Interactive Todos Widget", - "description": "Testing interactive widgets with checkboxes, switches, and buttons", + "displayName": { + "pl": "Interaktywna lista zadań", + "en": "Interactive Todos Widget" + }, + "description": { + "pl": "Test interaktywnych widgetów z polami wyboru, przełącznikami i przyciskami", + "en": "Testing interactive widgets with checkboxes, switches, and buttons" + }, "targetCellWidth": 2, "targetCellHeight": 2, "resizeMode": "horizontal|vertical", @@ -83,8 +107,14 @@ }, { "id": "image_preloading", - "displayName": "Image Preloading Widget", - "description": "Test image preloading on Android", + "displayName": { + "pl": "Widget wstępnego ładowania obrazów", + "en": "Image Preloading Widget" + }, + "description": { + "pl": "Test wstępnego ładowania obrazów na Androidzie", + "en": "Test image preloading on Android" + }, "targetCellWidth": 2, "targetCellHeight": 2, "resizeMode": "horizontal|vertical", @@ -92,8 +122,14 @@ }, { "id": "image_fallback", - "displayName": "Image Fallback Widget", - "description": "Test image fallback with backgroundColor from styles", + "displayName": { + "pl": "Widget zapasowego obrazu", + "en": "Image Fallback Widget" + }, + "description": { + "pl": "Test zapasowego obrazu z kolorem tła ze stylów", + "en": "Test image fallback with backgroundColor from styles" + }, "targetCellWidth": 2, "targetCellHeight": 2, "resizeMode": "horizontal|vertical", @@ -102,8 +138,14 @@ }, { "id": "chart_widget", - "displayName": "Chart Widget", - "description": "Test Chart component", + "displayName": { + "pl": "Widget wykresu", + "en": "Chart Widget" + }, + "description": { + "pl": "Test komponentu Chart", + "en": "Test Chart component" + }, "targetCellWidth": 3, "targetCellHeight": 3, "resizeMode": "horizontal|vertical", @@ -112,8 +154,14 @@ }, { "id": "portfolio", - "displayName": "Portfolio Widget", - "description": "Shows portfolio performance with a chart and live updates", + "displayName": { + "pl": "Widget portfela", + "en": "Portfolio Widget" + }, + "description": { + "pl": "Pokazuje wyniki portfela z wykresem i aktualizacjami na żywo", + "en": "Shows portfolio performance with a chart and live updates" + }, "targetCellWidth": 2, "targetCellHeight": 2, "resizeMode": "horizontal|vertical", @@ -127,8 +175,14 @@ }, { "id": "material_colors", - "displayName": "Material Colors Widget", - "description": "Compare client-side and server-side rendering with Android dynamic colors", + "displayName": { + "pl": "Widget kolorów Material", + "en": "Material Colors Widget" + }, + "description": { + "pl": "Porównanie renderowania po stronie klienta i serwera z dynamicznymi kolorami Androida", + "en": "Compare client-side and server-side rendering with Android dynamic colors" + }, "targetCellWidth": 2, "targetCellHeight": 2, "resizeMode": "horizontal|vertical", diff --git a/packages/expo-plugin/src/android/files/kotlin.ts b/packages/expo-plugin/src/android/files/kotlin.ts index f858d6f0..10f1cfc4 100644 --- a/packages/expo-plugin/src/android/files/kotlin.ts +++ b/packages/expo-plugin/src/android/files/kotlin.ts @@ -3,6 +3,7 @@ import * as fs from 'fs' import * as path from 'path' import type { AndroidWidgetConfig } from '../../types' +import { widgetLabelEnglish } from '../../utils/widgetLabel' export interface GenerateKotlinFilesProps { platformProjectRoot: string @@ -49,6 +50,7 @@ export async function generateWidgetReceivers(props: GenerateKotlinFilesProps): */ function generateWidgetReceiverClass(widget: AndroidWidgetConfig, packageName: string): string { const className = `VoltraWidget_${widget.id}Receiver` + const labelForComment = widgetLabelEnglish(widget.displayName) if (widget.serverUpdate) { const refreshEnabled = widget.serverUpdate.refresh === true @@ -62,7 +64,7 @@ function generateWidgetReceiverClass(widget: AndroidWidgetConfig, packageName: s import voltra.widget.VoltraWidgetUpdateScheduler /** - * Auto-generated widget receiver for ${widget.displayName} + * Auto-generated widget receiver for ${labelForComment} * Widget ID: ${widget.id} * Server Update: ${widget.serverUpdate.url} (every ${widget.serverUpdate.intervalMinutes ?? 15} minutes) * Refresh Button: ${refreshEnabled} @@ -110,7 +112,7 @@ function generateWidgetReceiverClass(widget: AndroidWidgetConfig, packageName: s import voltra.widget.VoltraWidgetReceiver /** - * Auto-generated widget receiver for ${widget.displayName} + * Auto-generated widget receiver for ${labelForComment} * Widget ID: ${widget.id} */ class ${className} : VoltraWidgetReceiver() { diff --git a/packages/expo-plugin/src/android/files/xml.ts b/packages/expo-plugin/src/android/files/xml.ts index 602be7f4..92af4a99 100644 --- a/packages/expo-plugin/src/android/files/xml.ts +++ b/packages/expo-plugin/src/android/files/xml.ts @@ -4,6 +4,7 @@ import * as path from 'path' import type { AndroidWidgetConfig } from '../../types' import { logger } from '../../utils/logger' +import { isWidgetLocalizedMap, widgetLabelEnglish } from '../../utils/widgetLabel' export interface GenerateXmlFilesProps { platformProjectRoot: string @@ -25,7 +26,8 @@ export async function generateWidgetInfoFiles(props: { }): Promise { const { platformProjectRoot, widgets } = props const xmlPath = path.join(platformProjectRoot, 'app', 'src', 'main', 'res', 'xml') - const valuesPath = path.join(platformProjectRoot, 'app', 'src', 'main', 'res', 'values') + const mainRes = path.join(platformProjectRoot, 'app', 'src', 'main', 'res') + const valuesPath = path.join(mainRes, 'values') // Ensure directories exist if (!fs.existsSync(xmlPath)) { @@ -35,10 +37,30 @@ export async function generateWidgetInfoFiles(props: { fs.mkdirSync(valuesPath, { recursive: true }) } - // Generate string resources for all widgets - const stringsPath = path.join(valuesPath, 'voltra_widgets.xml') - const stringsContent = generateStringResourcesXml(widgets) - fs.writeFileSync(stringsPath, stringsContent, 'utf8') + // Default strings (development / fallback language — prefers `en` in locale maps): res/values/voltra_widgets.xml + const stringsPath = path.join(valuesPath, VOLTRA_WIDGET_STRINGS_FILE) + fs.writeFileSync(stringsPath, generateVoltraWidgetsStringResourcesXml(widgets, null), 'utf8') + + const localeKeys = collectAndroidLocaleKeysFromWidgets(widgets) + /** Qualifiers we generated under res/values-/ (Android resource folder suffixes) */ + const generatedQualifiers = new Set() + + for (const localeKey of localeKeys) { + const qualifier = localeKeyToAndroidValuesQualifier(localeKey) + if (qualifier === DEFAULT_WIDGET_LOCALE_QUALIFIER) { + continue + } + generatedQualifiers.add(qualifier) + const localizedValuesDir = path.join(mainRes, `values-${qualifier}`) + fs.mkdirSync(localizedValuesDir, { recursive: true }) + fs.writeFileSync( + path.join(localizedValuesDir, VOLTRA_WIDGET_STRINGS_FILE), + generateVoltraWidgetsStringResourcesXml(widgets, localeKey), + 'utf8' + ) + } + + cleanupStaleVoltraWidgetLocaleFiles(mainRes, generatedQualifiers) } /** @@ -131,29 +153,134 @@ function generateWidgetInfoXml( } // ============================================================================ -// String Resources XML +// String Resources XML (multilingual: res/values/ + res/values-/) +// https://stackoverflow.com/questions/47976576/working-with-strings-xml-and-translations +// https://developer.android.com/guide/topics/resources/localization // ============================================================================ +const VOLTRA_WIDGET_STRINGS_FILE = 'voltra_widgets.xml' + +/** Locale maps use `en` for default English; unqualified `values/` covers that so skip `values-en/`. */ +const DEFAULT_WIDGET_LOCALE_QUALIFIER = 'en' + /** - * Generates string resources for widget display names and descriptions + * Maps BCP-style locale keys from app.json to Android resource folder qualifiers. + * Examples: `pl` → `pl`, `pt-BR` / `pt_BR` → `pt-rBR` */ -function generateStringResourcesXml(widgets: AndroidWidgetConfig[]): string { +function localeKeyToAndroidValuesQualifier(localeKey: string): string { + const normalized = localeKey.trim().replace(/_/g, '-') + const segments = normalized.split('-').filter(Boolean) + if (segments.length >= 2 && segments[1].length >= 2) { + const lang = segments[0].toLowerCase() + const region = segments[1].toUpperCase() + return `${lang}-r${region}` + } + return segments[0]?.toLowerCase() ?? normalized.toLowerCase() +} + +function collectAndroidLocaleKeysFromWidgets(widgets: AndroidWidgetConfig[]): Set { + const locales = new Set() + for (const w of widgets) { + for (const field of ['displayName', 'description'] as const) { + const v = w[field] + if (isWidgetLocalizedMap(v)) { + for (const [localeKey, text] of Object.entries(v)) { + if (typeof text === 'string' && text.trim()) { + locales.add(localeKey) + } + } + } + } + } + return locales +} + +function resolveAndroidWidgetLabel( + widget: AndroidWidgetConfig, + field: 'displayName' | 'description', + localeKey: string | null +): string { + const v = widget[field] + if (!isWidgetLocalizedMap(v)) { + return v + } + if (localeKey !== null) { + const localized = v[localeKey] + if (typeof localized === 'string' && localized.trim()) { + return localized + } + } + return widgetLabelEnglish(v) +} + +function escapeAndroidStringRes(text: string): string { + return text + .replace(/\\/g, '\\\\') + .replace(/\"/g, '\\"') + .replace(/'/g, "\\'") + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/&/g, '&') + .replace(//g, '>') +} + +function generateVoltraWidgetsStringResourcesXml(widgets: AndroidWidgetConfig[], localeKey: string | null): string { + const localeComment = + localeKey === null ? 'default (values/)' : `locale ${localeKey} → values-${localeKeyToAndroidValuesQualifier(localeKey)}` + const stringEntries = widgets - .map( - (widget) => - `${widget.displayName}\n ${widget.description}` - ) + .map((widget) => { + const label = escapeAndroidStringRes(resolveAndroidWidgetLabel(widget, 'displayName', localeKey)) + const desc = escapeAndroidStringRes(resolveAndroidWidgetLabel(widget, 'description', localeKey)) + return `${label}\n ${desc}` + }) .join('\n ') return dedent` - + ${stringEntries} ` } +/** + * Removes generated voltra_widgets.xml from values-/ folders we no longer use. + */ +function cleanupStaleVoltraWidgetLocaleFiles(mainRes: string, activeQualifiers: Set): void { + if (!fs.existsSync(mainRes)) { + return + } + + for (const entry of fs.readdirSync(mainRes)) { + if (!entry.startsWith('values-')) { + continue + } + + const qualifier = entry.slice('values-'.length) + const stringsFile = path.join(mainRes, entry, VOLTRA_WIDGET_STRINGS_FILE) + if (!fs.existsSync(stringsFile)) { + continue + } + + if (activeQualifiers.has(qualifier)) { + continue + } + + fs.unlinkSync(stringsFile) + try { + const dir = path.join(mainRes, entry) + if (fs.readdirSync(dir).length === 0) { + fs.rmdirSync(dir) + } + } catch { + /* ignore */ + } + } +} + // ============================================================================ // Placeholder Layout XML // ============================================================================ diff --git a/packages/expo-plugin/src/index.ts b/packages/expo-plugin/src/index.ts index 50b7f218..db8f5f7f 100644 --- a/packages/expo-plugin/src/index.ts +++ b/packages/expo-plugin/src/index.ts @@ -91,5 +91,7 @@ export type { VoltraConfigPlugin, WidgetConfig, WidgetFamily, + WidgetLabel, + WidgetLocalizedCopy, WidgetServerUpdateConfig, } from './types' diff --git a/packages/expo-plugin/src/ios-widget/files/swift.ts b/packages/expo-plugin/src/ios-widget/files/swift.ts index 88dcec97..716eea64 100644 --- a/packages/expo-plugin/src/ios-widget/files/swift.ts +++ b/packages/expo-plugin/src/ios-widget/files/swift.ts @@ -3,9 +3,11 @@ import * as fs from 'fs' import * as path from 'path' import { DEFAULT_WIDGET_FAMILIES, WIDGET_FAMILY_MAP } from '../../constants' -import type { WidgetConfig } from '../../types' +import type { WidgetConfig, WidgetLabel } from '../../types' +import { VOLTRA_WIDGET_STRINGS_BASENAME } from '../../utils/fileDiscovery' import { logger } from '../../utils/logger' import { prerenderWidgetState } from '../../utils/prerender' +import { isWidgetLocalizedMap, widgetLabelEnglish } from '../../utils/widgetLabel' export interface GenerateSwiftFilesOptions { targetPath: string @@ -39,6 +41,8 @@ export async function generateSwiftFiles(options: GenerateSwiftFilesOptions): Pr // Prerender widget initial states if any widgets have initialStatePath configured const prerenderedStates = await prerenderWidgetState(widgets || [], projectRoot, renderWidgetToString) + syncVoltraWidgetGalleryStrings(targetPath, widgets) + // Generate the initial states Swift file const initialStatesContent = generateInitialStatesSwift(prerenderedStates) const initialStatesPath = path.join(targetPath, 'VoltraWidgetInitialStates.swift') @@ -57,9 +61,136 @@ export async function generateSwiftFiles(options: GenerateSwiftFilesOptions): Pr } // ============================================================================ -// Widget Bundle +// Widget gallery localization: *.lproj/VoltraWidgets.strings + LocalizedStringResource +// +// We use classic .strings tables (not .xcstrings): the `xcode` npm library assigns +// unknown lastKnownFileType to .xcstrings, so Xcode may not treat them as string tables +// and lookups fall back to English defaultValue. +// +// LocalizedStringResource is still appropriate for extensions (deferred resolution). +// https://developer.apple.com/documentation/foundation/localizedstringresource // ============================================================================ +function escapeForSwiftStringLiteral(s: string): string { + return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r') +} + +function escapeDotStringsValue(s: string): string { + return escapeForSwiftStringLiteral(s) +} + +function collectGalleryStringsByLocale(widgets: WidgetConfig[]): Map> { + const byLocale = new Map>() + + const add = (locale: string, key: string, value: string) => { + let bucket = byLocale.get(locale) + if (!bucket) { + bucket = {} + byLocale.set(locale, bucket) + } + bucket[key] = value + } + + for (const w of widgets) { + if (isWidgetLocalizedMap(w.displayName)) { + const key = `voltra_widget_${w.id}_displayName` + for (const [locale, val] of Object.entries(w.displayName)) { + if (typeof val === 'string' && val.trim()) { + add(locale, key, val) + } + } + } + if (isWidgetLocalizedMap(w.description)) { + const key = `voltra_widget_${w.id}_description` + for (const [locale, val] of Object.entries(w.description)) { + if (typeof val === 'string' && val.trim()) { + add(locale, key, val) + } + } + } + } + + return byLocale +} + +function formatVoltraWidgetsStringsFile(entries: Record): string { + const sortedKeys = Object.keys(entries).sort() + const lines = sortedKeys.map((k) => `"${k}" = "${escapeDotStringsValue(entries[k]!)}";`) + return `/* Voltra widget gallery strings (auto-generated) */\n${lines.join('\n')}\n` +} + +/** Remove generated gallery strings and obsolete string catalog before rewriting. */ +function clearVoltraWidgetGalleryStringArtifacts(targetPath: string): void { + const catalogPath = path.join(targetPath, 'VoltraWidgets.xcstrings') + if (fs.existsSync(catalogPath)) { + fs.unlinkSync(catalogPath) + } + + if (!fs.existsSync(targetPath)) { + return + } + + for (const entry of fs.readdirSync(targetPath)) { + if (!entry.endsWith('.lproj')) { + continue + } + const stringsPath = path.join(targetPath, entry, VOLTRA_WIDGET_STRINGS_BASENAME) + if (fs.existsSync(stringsPath)) { + fs.unlinkSync(stringsPath) + } + const dirPath = path.join(targetPath, entry) + try { + if (fs.existsSync(dirPath) && fs.readdirSync(dirPath).length === 0) { + fs.rmdirSync(dirPath) + } + } catch { + /* ignore */ + } + } +} + +/** + * Writes `.lproj/VoltraWidgets.strings` for locale-map gallery labels. + */ +function syncVoltraWidgetGalleryStrings(targetPath: string, widgets: WidgetConfig[] | undefined): void { + const list = widgets ?? [] + clearVoltraWidgetGalleryStringArtifacts(targetPath) + + if (list.length === 0) { + return + } + + const byLocale = collectGalleryStringsByLocale(list) + if (byLocale.size === 0) { + return + } + + for (const [locale, kv] of byLocale) { + const lproj = path.join(targetPath, `${locale}.lproj`) + fs.mkdirSync(lproj, { recursive: true }) + fs.writeFileSync(path.join(lproj, VOLTRA_WIDGET_STRINGS_BASENAME), formatVoltraWidgetsStringsFile(kv), 'utf8') + } +} + +function widgetUsesGalleryLocalization(widget: WidgetConfig): boolean { + return isWidgetLocalizedMap(widget.displayName) || isWidgetLocalizedMap(widget.description) +} + +/** + * Widget gallery title / description: deferred lookup via LocalizedStringResource when using a locale map + * (recommended for extensions); plain Text for single-string config. + */ +function iosWidgetGalleryLabelSwiftExpr(widgetId: string, field: 'displayName' | 'description', label: WidgetLabel): string { + if (!isWidgetLocalizedMap(label)) { + return `Text("${escapeForSwiftStringLiteral(label)}")` + } + + const key = `voltra_widget_${widgetId}_${field}` + const defaultEnglish = escapeForSwiftStringLiteral(widgetLabelEnglish(label)) + + return `Text(LocalizedStringResource("${key}", defaultValue: String.LocalizationValue("${defaultEnglish}"), table: "VoltraWidgets"))` +} + /** * Generates Swift code for a single widget struct */ @@ -70,6 +201,9 @@ function generateWidgetStruct(widget: WidgetConfig): string { // Sanitize the widget id for use as a Swift identifier const structName = `VoltraWidget_${widget.id}` + const displayNameExpr = iosWidgetGalleryLabelSwiftExpr(widget.id, 'displayName', widget.displayName) + const descriptionExpr = iosWidgetGalleryLabelSwiftExpr(widget.id, 'description', widget.description) + return dedent` public struct ${structName}: Widget { private let widgetId = "${widget.id}" @@ -86,8 +220,8 @@ function generateWidgetStruct(widget: WidgetConfig): string { ) { entry in VoltraHomeWidgetView(entry: entry) } - .configurationDisplayName("${widget.displayName}") - .description("${widget.description}") + .configurationDisplayName(${displayNameExpr}) + .description(${descriptionExpr}) .supportedFamilies([${familiesSwift}]) .contentMarginsDisabled() } @@ -105,6 +239,9 @@ function generateWidgetBundleSwift(widgets: WidgetConfig[]): string { // Generate widget bundle body entries const widgetInstances = widgets.map((w) => `VoltraWidget_${w.id}()`).join('\n ') + const needsFoundation = widgets.some(widgetUsesGalleryLocalization) + const foundationImport = needsFoundation ? 'import Foundation\n' : '' + return dedent` // // VoltraWidgetBundle.swift @@ -113,7 +250,7 @@ function generateWidgetBundleSwift(widgets: WidgetConfig[]): string { // This file defines which Voltra widgets are available in your app. // - import SwiftUI + ${foundationImport}import SwiftUI import WidgetKit import VoltraWidget diff --git a/packages/expo-plugin/src/ios-widget/xcode/buildPhases.ts b/packages/expo-plugin/src/ios-widget/xcode/buildPhases.ts index 565a937f..fc3ab7e1 100644 --- a/packages/expo-plugin/src/ios-widget/xcode/buildPhases.ts +++ b/packages/expo-plugin/src/ios-widget/xcode/buildPhases.ts @@ -29,7 +29,8 @@ export function addBuildPhases(xcodeProject: XcodeProject, options: AddBuildPhas const buildPath = `""` const folderType = 'app_extension' - const { swiftFiles, intentFiles, assetDirectories } = widgetFiles + const { swiftFiles, intentFiles, assetDirectories, localizedStringResources } = widgetFiles + const resourcePaths = [...assetDirectories, ...localizedStringResources] // Sources build phase xcodeProject.addBuildPhase( @@ -61,7 +62,7 @@ export function addBuildPhases(xcodeProject: XcodeProject, options: AddBuildPhas xcodeProject.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', targetUuid, folderType, buildPath) // Resources build phase - xcodeProject.addBuildPhase([...assetDirectories], 'PBXResourcesBuildPhase', 'Resources', targetUuid) + xcodeProject.addBuildPhase([...resourcePaths], 'PBXResourcesBuildPhase', 'Resources', targetUuid) } /** @@ -73,7 +74,8 @@ export function ensureBuildPhases(xcodeProject: XcodeProject, options: EnsureBui const folderType = 'app_extension' const mainTargetUuid = options.mainTargetUuid ?? xcodeProject.getFirstTarget().uuid - const { swiftFiles, intentFiles, assetDirectories } = widgetFiles + const { swiftFiles, intentFiles, assetDirectories, localizedStringResources } = widgetFiles + const resourcePaths = [...assetDirectories, ...localizedStringResources] dedupeBuildPhasesForTarget(xcodeProject, targetUuid, 'PBXSourcesBuildPhase', 'Sources') dedupeBuildPhasesForTarget(xcodeProject, targetUuid, 'PBXFrameworksBuildPhase', 'Frameworks') @@ -115,11 +117,11 @@ export function ensureBuildPhases(xcodeProject: XcodeProject, options: EnsureBui // Resources build phase let resourcesPhase = xcodeProject.buildPhaseObject('PBXResourcesBuildPhase', 'Resources', targetUuid) if (!resourcesPhase) { - xcodeProject.addBuildPhase([...assetDirectories], 'PBXResourcesBuildPhase', 'Resources', targetUuid) + xcodeProject.addBuildPhase([...resourcePaths], 'PBXResourcesBuildPhase', 'Resources', targetUuid) resourcesPhase = xcodeProject.buildPhaseObject('PBXResourcesBuildPhase', 'Resources', targetUuid) } if (resourcesPhase) { - ensureBuildPhaseFiles(xcodeProject, resourcesPhase, [...assetDirectories]) + ensureBuildPhaseFiles(xcodeProject, resourcesPhase, [...resourcePaths]) } } diff --git a/packages/expo-plugin/src/ios-widget/xcode/groups.ts b/packages/expo-plugin/src/ios-widget/xcode/groups.ts index ebc6c7f3..c412ae96 100644 --- a/packages/expo-plugin/src/ios-widget/xcode/groups.ts +++ b/packages/expo-plugin/src/ios-widget/xcode/groups.ts @@ -14,11 +14,12 @@ export interface AddPbxGroupOptions { */ export function addPbxGroup(xcodeProject: XcodeProject, options: AddPbxGroupOptions): void { const { targetName, widgetFiles } = options - const { swiftFiles, intentFiles, assetDirectories, entitlementFiles, plistFiles } = widgetFiles + const { swiftFiles, intentFiles, assetDirectories, entitlementFiles, plistFiles, localizedStringResources } = + widgetFiles // Add PBX group with all widget files const { uuid: pbxGroupUuid } = xcodeProject.addPbxGroup( - [...swiftFiles, ...intentFiles, ...entitlementFiles, ...plistFiles, ...assetDirectories], + [...swiftFiles, ...intentFiles, ...entitlementFiles, ...plistFiles, ...assetDirectories, ...localizedStringResources], targetName, targetName ) @@ -39,8 +40,16 @@ export function addPbxGroup(xcodeProject: XcodeProject, options: AddPbxGroupOpti */ export function ensurePbxGroup(xcodeProject: XcodeProject, options: AddPbxGroupOptions): void { const { targetName, widgetFiles } = options - const { swiftFiles, intentFiles, assetDirectories, entitlementFiles, plistFiles } = widgetFiles - const allFiles = [...swiftFiles, ...intentFiles, ...entitlementFiles, ...plistFiles, ...assetDirectories] + const { swiftFiles, intentFiles, assetDirectories, entitlementFiles, plistFiles, localizedStringResources } = + widgetFiles + const allFiles = [ + ...swiftFiles, + ...intentFiles, + ...entitlementFiles, + ...plistFiles, + ...assetDirectories, + ...localizedStringResources, + ] const existingGroup = xcodeProject.pbxGroupByName(targetName) if (!existingGroup) { diff --git a/packages/expo-plugin/src/types.ts b/packages/expo-plugin/src/types.ts index b9883a76..8c973973 100644 --- a/packages/expo-plugin/src/types.ts +++ b/packages/expo-plugin/src/types.ts @@ -8,6 +8,14 @@ import { ConfigPlugin } from '@expo/config-plugins' // Widget Types // ============================================================================ +/** + * Per-locale strings for widget picker/gallery labels (`displayName`, `description`). + * Keys should be BCP-47-style locale tags (e.g. `en`, `pl`, `pt-BR`). Plain `string` is still allowed for a single-language setup. + */ +export type WidgetLocalizedCopy = Record; + +export type WidgetLabel = string | WidgetLocalizedCopy + /** * Supported widget size families */ @@ -30,13 +38,14 @@ export interface WidgetConfig { */ id: string /** - * Display name shown in the widget gallery + * Display name shown in the widget gallery. + * For locale maps, keys must be BCP-47-like (`en`, `pl`, `pt-BR`); include `en` when possible so defaults align with Android `values/` and iOS fallbacks. */ - displayName: string + displayName: WidgetLabel /** - * Description shown in the widget gallery + * Description shown in the widget gallery (same rules as `displayName`). */ - description: string + description: WidgetLabel /** * Supported widget sizes * @default ['systemSmall', 'systemMedium', 'systemLarge'] @@ -89,6 +98,8 @@ export interface WidgetFiles { plistFiles: string[] assetDirectories: string[] intentFiles: string[] + /** Paths relative to the widget extension root (e.g. en.lproj/VoltraWidgets.strings) */ + localizedStringResources: string[] } // ============================================================================ @@ -104,13 +115,13 @@ export interface AndroidWidgetConfig { */ id: string /** - * Display name shown in the widget picker + * Display name shown in the widget picker (same localization rules as iOS `widgets[].displayName`). */ - displayName: string + displayName: WidgetLabel /** - * Description shown in the widget picker + * Description shown in the widget picker (same localization rules as iOS `widgets[].description`). */ - description: string + description: WidgetLabel /** * Minimum width in dp. If provided, takes precedence over minCellWidth. */ diff --git a/packages/expo-plugin/src/utils/fileDiscovery.ts b/packages/expo-plugin/src/utils/fileDiscovery.ts index d10baedb..4da07365 100644 --- a/packages/expo-plugin/src/utils/fileDiscovery.ts +++ b/packages/expo-plugin/src/utils/fileDiscovery.ts @@ -4,6 +4,9 @@ import * as path from 'path' import type { WidgetFiles } from '../types' import { logger } from './logger' +/** Table name matches basename without extension; co-located per locale in *.lproj */ +export const VOLTRA_WIDGET_STRINGS_BASENAME = 'VoltraWidgets.strings' + /** * Scans the widget extension target directory and returns categorized file lists. * @@ -21,6 +24,7 @@ export function getWidgetFiles(targetPath: string, targetName: string): WidgetFi plistFiles: [], assetDirectories: [], intentFiles: [], + localizedStringResources: [], } if (!fs.existsSync(targetPath)) { @@ -35,6 +39,16 @@ export function getWidgetFiles(targetPath: string, targetName: string): WidgetFi const files = fs.readdirSync(targetPath) + for (const entry of files) { + if (!entry.endsWith('.lproj')) { + continue + } + const stringsPath = path.join(targetPath, entry, VOLTRA_WIDGET_STRINGS_BASENAME) + if (fs.existsSync(stringsPath)) { + widgetFiles.localizedStringResources.push(`${entry}/${VOLTRA_WIDGET_STRINGS_BASENAME}`) + } + } + for (const file of files) { const itemPath = path.join(targetPath, file) const isDirectory = fs.lstatSync(itemPath).isDirectory() diff --git a/packages/expo-plugin/src/utils/widgetLabel.ts b/packages/expo-plugin/src/utils/widgetLabel.ts new file mode 100644 index 00000000..67e66150 --- /dev/null +++ b/packages/expo-plugin/src/utils/widgetLabel.ts @@ -0,0 +1,20 @@ +import type { WidgetLabel, WidgetLocalizedCopy } from '../types' + +export function isWidgetLocalizedMap(label: WidgetLabel): label is WidgetLocalizedCopy { + return typeof label === 'object' && label !== null && !Array.isArray(label) +} + +/** + * Development / fallback English copy for LocalizedStringResource.defaultValue. + */ +export function widgetLabelEnglish(label: WidgetLabel): string { + if (!isWidgetLocalizedMap(label)) { + return label + } + const en = label.en + if (typeof en === 'string' && en.trim()) { + return en + } + const first = Object.values(label).find((v) => typeof v === 'string' && v.trim()) + return first ?? '' +} diff --git a/packages/expo-plugin/src/validation.ts b/packages/expo-plugin/src/validation.ts index 30f4884f..85201740 100644 --- a/packages/expo-plugin/src/validation.ts +++ b/packages/expo-plugin/src/validation.ts @@ -7,6 +7,92 @@ import type { AndroidWidgetConfig, ConfigPluginProps, WidgetConfig, WidgetFamily * Validation functions for the Voltra plugin */ +/** Widget id: Swift / Kotlin identifier fragment and Android XML token fragment */ +const WIDGET_ID_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/ + +/** + * Locale keys in app.json become iOS `.lproj` folder names and Android `values-*` qualifiers (after mapping). + * Restrict to safe, BCP-47-like tags: no slashes, spaces, or exotic punctuation. + */ +const LOCALE_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9]*([_-][a-zA-Z0-9]+)*$/ + +const MAX_LOCALE_KEY_LENGTH = 32 + +function validateHomeScreenWidgetId(widgetId: unknown): asserts widgetId is string { + if (!widgetId || typeof widgetId !== 'string') { + throw new Error('Widget ID is required and must be a string') + } + + if (!WIDGET_ID_PATTERN.test(widgetId)) { + throw new Error( + `Widget ID '${widgetId}' is invalid. ` + + 'Must start with a letter or underscore and contain only alphanumeric characters and underscores.' + ) + } +} + +function assertValidLocaleKey(localeKey: string, widgetId: string, fieldName: string): void { + if (localeKey.trim() !== localeKey) { + throw new Error( + `Widget '${widgetId}': ${fieldName} locale key '${localeKey}' must not have leading or trailing whitespace` + ) + } + + if (!localeKey) { + throw new Error(`Widget '${widgetId}': ${fieldName} locale map contains an empty locale key`) + } + + if (localeKey.length > MAX_LOCALE_KEY_LENGTH) { + throw new Error( + `Widget '${widgetId}': ${fieldName} locale key '${localeKey}' exceeds ${MAX_LOCALE_KEY_LENGTH} characters` + ) + } + + if (!LOCALE_KEY_PATTERN.test(localeKey)) { + throw new Error( + `Widget '${widgetId}': ${fieldName} locale key '${localeKey}' is invalid. ` + + 'Use BCP-47-style tags (e.g. en, pl, pt-BR, zh-Hans). Letters, digits, single separators _ or - only.' + ) + } +} + +function validateWidgetLabel(value: unknown, widgetId: string, fieldName: string): void { + if (typeof value === 'string') { + if (!value.trim()) { + throw new Error(`Widget '${widgetId}': ${fieldName} is required`) + } + return + } + + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error( + `Widget '${widgetId}': ${fieldName} must be a string or a locale map (e.g. { "en": "...", "pl": "..." })` + ) + } + + const entries = Object.entries(value as Record) + if (entries.length === 0) { + throw new Error(`Widget '${widgetId}': ${fieldName} locale map must not be empty`) + } + + const localeKeys = new Set() + for (const [locale, v] of entries) { + assertValidLocaleKey(locale, widgetId, fieldName) + + const normalized = locale.toLowerCase().replace(/_/g, '-') + if (localeKeys.has(normalized)) { + throw new Error( + `Widget '${widgetId}': ${fieldName} duplicates locale '${locale}' (underscore and hyphen forms count as the same, e.g. pt_BR vs pt-BR)` + ) + } + localeKeys.add(normalized) + + if (typeof v !== 'string' || !v.trim()) { + throw new Error(`Widget '${widgetId}': ${fieldName}.${locale} must be a non-empty string`) + } + } +} + // ============================================================================ // iOS Widget Validation // ============================================================================ @@ -26,27 +112,10 @@ const VALID_FAMILIES: Set = new Set([ * Throws an error if validation fails. */ export function validateWidgetConfig(widget: WidgetConfig): void { - // Validate widget ID - if (!widget.id || typeof widget.id !== 'string') { - throw new Error('Widget ID is required and must be a string') - } - - if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(widget.id)) { - throw new Error( - `Widget ID '${widget.id}' is invalid. ` + - 'Must start with a letter or underscore and contain only alphanumeric characters and underscores.' - ) - } - - // Validate display name - if (!widget.displayName?.trim()) { - throw new Error(`Widget '${widget.id}': displayName is required`) - } + validateHomeScreenWidgetId(widget.id) - // Validate description - if (!widget.description?.trim()) { - throw new Error(`Widget '${widget.id}': description is required`) - } + validateWidgetLabel(widget.displayName, widget.id, 'displayName') + validateWidgetLabel(widget.description, widget.id, 'description') // Validate supported families if provided if (widget.supportedFamilies) { @@ -74,27 +143,10 @@ export function validateWidgetConfig(widget: WidgetConfig): void { * Throws an error if validation fails. */ export function validateAndroidWidgetConfig(widget: AndroidWidgetConfig, projectRoot?: string): void { - // Validate widget ID - if (!widget.id || typeof widget.id !== 'string') { - throw new Error('Widget ID is required and must be a string') - } + validateHomeScreenWidgetId(widget.id) - if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(widget.id)) { - throw new Error( - `Widget ID '${widget.id}' is invalid. ` + - 'Must start with a letter or underscore and contain only alphanumeric characters and underscores.' - ) - } - - // Validate display name - if (!widget.displayName?.trim()) { - throw new Error(`Widget '${widget.id}': displayName is required`) - } - - // Validate description - if (!widget.description?.trim()) { - throw new Error(`Widget '${widget.id}': description is required`) - } + validateWidgetLabel(widget.displayName, widget.id, 'displayName') + validateWidgetLabel(widget.description, widget.id, 'description') // Validate targetCellWidth if (typeof widget.targetCellWidth !== 'number') { From 4416d9feb5fc6f646aa15148569533c4c5a8779b Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 4 May 2026 13:42:50 +0200 Subject: [PATCH 2/6] feat(expo-plugin): localized widget initial states and native i18n - Allow initialStatePath as a string or per-locale path map; validate with existing locale key rules - Prerender one JSON payload per locale; iOS bundles locale map + VoltraInitialStateLocale picker; Android uses __voltraLocales in voltra_initial_states.json with VoltraWidgetManager resolution - Add localePick.ts for shared fallback order (matches Swift/Kotlin) - Replace hardcoded English placeholders with ProgressView / ProgressBar - Document locale maps in website docs, plugin schema, and example app.json - Add Jest in expo-plugin (ts-jest) and unit tests; exclude *.node.test.ts from tsc build Co-authored-by: Cursor --- example/app.json | 5 +- package-lock.json | 466 +++++++++--------- packages/expo-plugin/jest.config.js | 14 + packages/expo-plugin/package.json | 7 + .../src/android/files/initialStates.ts | 32 +- packages/expo-plugin/src/android/files/xml.ts | 6 +- packages/expo-plugin/src/index.ts | 1 + .../expo-plugin/src/ios-widget/files/swift.ts | 35 +- packages/expo-plugin/src/types.ts | 14 +- .../src/utils/localePick.node.test.ts | 22 + packages/expo-plugin/src/utils/localePick.ts | 53 ++ packages/expo-plugin/src/utils/prerender.ts | 39 +- .../expo-plugin/src/validation.node.test.ts | 17 + packages/expo-plugin/src/validation.ts | 70 ++- packages/expo-plugin/tsconfig.base.json | 2 +- packages/expo-plugin/tsconfig.jest.json | 8 + .../java/voltra/widget/VoltraWidgetManager.kt | 77 ++- .../ios/shared/VoltraInitialStateLocale.swift | 54 ++ .../voltra/ios/target/VoltraHomeWidget.swift | 17 +- skills/voltra/references/plugin-schema.md | 12 +- .../docs/android/api/plugin-configuration.md | 6 +- .../development/widget-pre-rendering.md | 7 +- website/docs/ios/api/plugin-configuration.md | 11 +- .../ios/development/widget-pre-rendering.md | 13 +- 24 files changed, 671 insertions(+), 317 deletions(-) create mode 100644 packages/expo-plugin/jest.config.js create mode 100644 packages/expo-plugin/src/utils/localePick.node.test.ts create mode 100644 packages/expo-plugin/src/utils/localePick.ts create mode 100644 packages/expo-plugin/src/validation.node.test.ts create mode 100644 packages/expo-plugin/tsconfig.jest.json create mode 100644 packages/voltra/ios/shared/VoltraInitialStateLocale.swift diff --git a/example/app.json b/example/app.json index bdd8052a..67063cc1 100644 --- a/example/app.json +++ b/example/app.json @@ -46,7 +46,10 @@ "en": "Shows current weather conditions" }, "supportedFamilies": ["systemSmall", "systemMedium", "systemLarge"], - "initialStatePath": "./widgets/ios/ios-weather-initial.tsx" + "initialStatePath": { + "en": "./widgets/ios/ios-weather-initial.tsx", + "pl": "./widgets/ios/ios-weather-initial.tsx" + } }, { "id": "portfolio", diff --git a/package-lock.json b/package-lock.json index 807eac3a..60aeeae9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1013,32 +1013,6 @@ } } }, - "example/node_modules/expo/node_modules/@expo/cli/node_modules/@expo/metro-runtime": { - "version": "55.0.7", - "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-55.0.7.tgz", - "integrity": "sha512-fV+DYvJ+A3fKEwkpJiXUhrpsWy4HjjbdapwJi/QmnGLFKYrzGvGqsWG+xf3mmUDwP413t6GL9162bnyMReYOaA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@expo/log-box": "55.0.8", - "anser": "^1.4.9", - "pretty-format": "^29.7.0", - "stacktrace-parser": "^0.1.10", - "whatwg-fetch": "^3.0.0" - }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-dom": "*", - "react-native": "*" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, "example/node_modules/expo/node_modules/@expo/cli/node_modules/@expo/router-server": { "version": "55.0.11", "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-55.0.11.tgz", @@ -1669,27 +1643,6 @@ "react-native": "*" } }, - "example/node_modules/react-server-dom-webpack": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-server-dom-webpack/-/react-server-dom-webpack-19.2.4.tgz", - "integrity": "sha512-zEhkWv6RhXDctC2N7yEUHg3751nvFg81ydHj8LTTZuukF/IF1gcOKqqAL6Ds+kS5HtDVACYPik0IvzkgYXPhlQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "acorn-loose": "^8.3.0", - "neo-async": "^2.6.1", - "webpack-sources": "^3.2.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "peerDependencies": { - "react": "^19.2.4", - "react-dom": "^19.2.4", - "webpack": "^5.59.0" - } - }, "example/node_modules/semver": { "version": "7.6.3", "license": "ISC", @@ -3700,9 +3653,8 @@ }, "node_modules/@bcoe/v8-coverage": { "version": "0.2.3", - "dev": true, - "license": "MIT", - "peer": true + "devOptional": true, + "license": "MIT" }, "node_modules/@changesets/apply-release-plan": { "version": "7.0.14", @@ -3946,7 +3898,7 @@ }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.9" @@ -3957,7 +3909,7 @@ }, "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { "version": "0.3.9", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", @@ -4656,15 +4608,6 @@ "resolve-from": "^5.0.0" } }, - "node_modules/@expo/metro-runtime": { - "version": "5.0.5", - "license": "MIT", - "optional": true, - "peer": true, - "peerDependencies": { - "react-native": "*" - } - }, "node_modules/@expo/metro/node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -5440,7 +5383,7 @@ }, "node_modules/@jest/console": { "version": "29.7.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -5456,9 +5399,8 @@ }, "node_modules/@jest/core": { "version": "29.7.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/console": "^29.7.0", "@jest/reporters": "^29.7.0", @@ -5503,9 +5445,8 @@ }, "node_modules/@jest/core/node_modules/strip-ansi": { "version": "6.0.1", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -5538,7 +5479,7 @@ }, "node_modules/@jest/expect": { "version": "29.7.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "expect": "^29.7.0", @@ -5550,7 +5491,7 @@ }, "node_modules/@jest/expect-utils": { "version": "29.7.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "jest-get-type": "^29.6.3" @@ -5576,7 +5517,7 @@ }, "node_modules/@jest/globals": { "version": "29.7.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/environment": "^29.7.0", @@ -5610,9 +5551,8 @@ }, "node_modules/@jest/reporters": { "version": "29.7.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@bcoe/v8-coverage": "^0.2.3", "@jest/console": "^29.7.0", @@ -5653,9 +5593,8 @@ }, "node_modules/@jest/reporters/node_modules/brace-expansion": { "version": "1.1.12", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5663,9 +5602,8 @@ }, "node_modules/@jest/reporters/node_modules/glob": { "version": "7.2.3", - "dev": true, + "devOptional": true, "license": "ISC", - "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -5683,9 +5621,8 @@ }, "node_modules/@jest/reporters/node_modules/minimatch": { "version": "3.1.5", - "dev": true, + "devOptional": true, "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -5695,9 +5632,8 @@ }, "node_modules/@jest/reporters/node_modules/strip-ansi": { "version": "6.0.1", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -5717,9 +5653,8 @@ }, "node_modules/@jest/source-map": { "version": "29.6.3", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.18", "callsites": "^3.0.0", @@ -5731,7 +5666,7 @@ }, "node_modules/@jest/test-result": { "version": "29.7.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/console": "^29.7.0", @@ -5745,9 +5680,8 @@ }, "node_modules/@jest/test-sequencer": { "version": "29.7.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/test-result": "^29.7.0", "graceful-fs": "^4.2.9", @@ -8021,22 +7955,22 @@ }, "node_modules/@tsconfig/node10": { "version": "1.0.11", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node12": { "version": "1.0.11", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node14": { "version": "1.0.3", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node16": { "version": "1.0.4", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node18": { @@ -8780,7 +8714,7 @@ }, "node_modules/acorn-walk": { "version": "8.3.4", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -9244,17 +9178,6 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, - "node_modules/babel-plugin-react-compiler": { - "version": "19.0.0-beta-ebf51a3-20250411", - "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-19.0.0-beta-ebf51a3-20250411.tgz", - "integrity": "sha512-q84bNR9JG1crykAlJUt5Ud0/5BUyMFuQww/mrwIQDFBaxsikqBDj3f/FNDsVd2iR26A1HvXKWPEIfgJDv8/V2g==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@babel/types": "^7.26.0" - } - }, "node_modules/babel-plugin-react-native-web": { "version": "0.19.13", "license": "MIT" @@ -9637,9 +9560,8 @@ }, "node_modules/callsites": { "version": "3.1.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -9692,7 +9614,7 @@ }, "node_modules/char-regex": { "version": "1.0.2", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10" @@ -9787,9 +9709,8 @@ }, "node_modules/cjs-module-lexer": { "version": "1.4.3", - "dev": true, - "license": "MIT", - "peer": true + "devOptional": true, + "license": "MIT" }, "node_modules/cli-cursor": { "version": "2.1.0", @@ -9862,9 +9783,8 @@ }, "node_modules/co": { "version": "4.6.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "engines": { "iojs": ">= 1.0.0", "node": ">= 0.12.0" @@ -9872,7 +9792,7 @@ }, "node_modules/collect-v8-coverage": { "version": "1.0.2", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/color": { @@ -10087,9 +10007,8 @@ }, "node_modules/create-jest": { "version": "29.7.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/types": "^29.6.3", "chalk": "^4.0.0", @@ -10108,7 +10027,7 @@ }, "node_modules/create-require": { "version": "1.1.1", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/cross-fetch": { @@ -10387,9 +10306,8 @@ }, "node_modules/detect-newline": { "version": "3.1.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -10402,7 +10320,7 @@ }, "node_modules/diff": { "version": "4.0.2", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -10507,7 +10425,7 @@ }, "node_modules/emittery": { "version": "0.13.1", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -10590,9 +10508,8 @@ }, "node_modules/error-ex": { "version": "1.3.2", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "is-arrayish": "^0.2.1" } @@ -11451,9 +11368,8 @@ }, "node_modules/execa": { "version": "5.1.1", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", @@ -11474,21 +11390,19 @@ }, "node_modules/execa/node_modules/signal-exit": { "version": "3.0.7", - "dev": true, - "license": "ISC", - "peer": true + "devOptional": true, + "license": "ISC" }, "node_modules/exit": { "version": "0.1.2", - "dev": true, - "peer": true, + "devOptional": true, "engines": { "node": ">= 0.8.0" } }, "node_modules/expect": { "version": "29.7.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/expect-utils": "^29.7.0", @@ -12206,9 +12120,8 @@ }, "node_modules/get-stream": { "version": "6.0.1", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -12351,6 +12264,28 @@ "dev": true, "license": "MIT" }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "dev": true, @@ -12484,9 +12419,8 @@ }, "node_modules/html-escaper": { "version": "2.0.2", - "dev": true, - "license": "MIT", - "peer": true + "devOptional": true, + "license": "MIT" }, "node_modules/http-errors": { "version": "2.0.0", @@ -12544,9 +12478,8 @@ }, "node_modules/human-signals": { "version": "2.1.0", - "dev": true, + "devOptional": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=10.17.0" } @@ -12632,9 +12565,8 @@ }, "node_modules/import-local": { "version": "3.2.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" @@ -12726,9 +12658,8 @@ }, "node_modules/is-arrayish": { "version": "0.2.1", - "dev": true, - "license": "MIT", - "peer": true + "devOptional": true, + "license": "MIT" }, "node_modules/is-async-function": { "version": "2.1.1", @@ -12897,9 +12828,8 @@ }, "node_modules/is-generator-fn": { "version": "2.1.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -13175,7 +13105,7 @@ }, "node_modules/istanbul-lib-instrument": { "version": "6.0.3", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "@babel/core": "^7.23.9", @@ -13190,9 +13120,8 @@ }, "node_modules/istanbul-lib-report": { "version": "3.0.1", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", @@ -13204,9 +13133,8 @@ }, "node_modules/istanbul-lib-report/node_modules/make-dir": { "version": "4.0.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "semver": "^7.5.3" }, @@ -13219,9 +13147,8 @@ }, "node_modules/istanbul-lib-source-maps": { "version": "4.0.1", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0", @@ -13233,9 +13160,8 @@ }, "node_modules/istanbul-reports": { "version": "3.2.0", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" @@ -13275,9 +13201,8 @@ }, "node_modules/jest": { "version": "29.7.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -13301,9 +13226,8 @@ }, "node_modules/jest-changed-files": { "version": "29.7.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "execa": "^5.0.0", "jest-util": "^29.7.0", @@ -13315,9 +13239,8 @@ }, "node_modules/jest-circus": { "version": "29.7.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/environment": "^29.7.0", "@jest/expect": "^29.7.0", @@ -13346,9 +13269,8 @@ }, "node_modules/jest-cli": { "version": "29.7.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/test-result": "^29.7.0", @@ -13379,9 +13301,8 @@ }, "node_modules/jest-config": { "version": "29.7.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.11.6", "@jest/test-sequencer": "^29.7.0", @@ -13424,9 +13345,8 @@ }, "node_modules/jest-config/node_modules/brace-expansion": { "version": "1.1.12", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -13434,9 +13354,8 @@ }, "node_modules/jest-config/node_modules/glob": { "version": "7.2.3", - "dev": true, + "devOptional": true, "license": "ISC", - "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -13454,9 +13373,8 @@ }, "node_modules/jest-config/node_modules/minimatch": { "version": "3.1.5", - "dev": true, + "devOptional": true, "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -13480,9 +13398,8 @@ }, "node_modules/jest-docblock": { "version": "29.7.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "detect-newline": "^3.0.0" }, @@ -13492,9 +13409,8 @@ }, "node_modules/jest-each": { "version": "29.7.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/types": "^29.6.3", "chalk": "^4.0.0", @@ -13609,9 +13525,8 @@ }, "node_modules/jest-leak-detector": { "version": "29.7.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" @@ -13678,9 +13593,8 @@ }, "node_modules/jest-pnp-resolver": { "version": "1.2.3", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" }, @@ -13702,9 +13616,8 @@ }, "node_modules/jest-resolve": { "version": "29.7.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "chalk": "^4.0.0", "graceful-fs": "^4.2.9", @@ -13722,9 +13635,8 @@ }, "node_modules/jest-resolve-dependencies": { "version": "29.7.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "jest-regex-util": "^29.6.3", "jest-snapshot": "^29.7.0" @@ -13735,9 +13647,8 @@ }, "node_modules/jest-runner": { "version": "29.7.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/console": "^29.7.0", "@jest/environment": "^29.7.0", @@ -13767,9 +13678,8 @@ }, "node_modules/jest-runner/node_modules/source-map-support": { "version": "0.5.13", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -13777,9 +13687,8 @@ }, "node_modules/jest-runtime": { "version": "29.7.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/environment": "^29.7.0", "@jest/fake-timers": "^29.7.0", @@ -13810,9 +13719,8 @@ }, "node_modules/jest-runtime/node_modules/brace-expansion": { "version": "1.1.12", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -13820,9 +13728,8 @@ }, "node_modules/jest-runtime/node_modules/glob": { "version": "7.2.3", - "dev": true, + "devOptional": true, "license": "ISC", - "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -13840,9 +13747,8 @@ }, "node_modules/jest-runtime/node_modules/minimatch": { "version": "3.1.5", - "dev": true, + "devOptional": true, "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -13852,7 +13758,7 @@ }, "node_modules/jest-snapshot": { "version": "29.7.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/core": "^7.11.6", @@ -14034,7 +13940,7 @@ }, "node_modules/jest-watcher": { "version": "29.7.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/test-result": "^29.7.0", @@ -14167,8 +14073,7 @@ "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "devOptional": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "1.0.0", @@ -14730,7 +14635,7 @@ }, "node_modules/make-error": { "version": "1.3.6", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/makeerror": { @@ -15332,7 +15237,7 @@ }, "node_modules/natural-compare": { "version": "1.4.0", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/negotiator": { @@ -15431,9 +15336,8 @@ }, "node_modules/npm-run-path": { "version": "4.0.1", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "path-key": "^3.0.0" }, @@ -15874,9 +15778,8 @@ }, "node_modules/parse-json": { "version": "5.2.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -16086,9 +15989,8 @@ }, "node_modules/pkg-dir": { "version": "4.2.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "find-up": "^4.0.0" }, @@ -16098,9 +16000,8 @@ }, "node_modules/pkg-dir/node_modules/find-up": { "version": "4.1.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" @@ -16111,9 +16012,8 @@ }, "node_modules/pkg-dir/node_modules/locate-path": { "version": "5.0.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "p-locate": "^4.1.0" }, @@ -16123,9 +16023,8 @@ }, "node_modules/pkg-dir/node_modules/p-limit": { "version": "2.3.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "p-try": "^2.0.0" }, @@ -16138,9 +16037,8 @@ }, "node_modules/pkg-dir/node_modules/p-locate": { "version": "4.1.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "p-limit": "^2.2.0" }, @@ -16351,7 +16249,7 @@ }, "node_modules/pure-rand": { "version": "6.1.0", - "dev": true, + "devOptional": true, "funding": [ { "type": "individual", @@ -16362,8 +16260,7 @@ "url": "https://opencollective.com/fast-check" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/qrcode-terminal": { "version": "0.11.0", @@ -16647,20 +16544,6 @@ "react-native": "*" } }, - "node_modules/react-native-webview": { - "version": "13.13.5", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "escape-string-regexp": "^4.0.0", - "invariant": "2.2.4" - }, - "peerDependencies": { - "react": "*", - "react-native": "*" - } - }, "node_modules/react-native-worklets": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.7.4.tgz", @@ -17220,9 +17103,8 @@ }, "node_modules/resolve-cwd": { "version": "3.0.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "resolve-from": "^5.0.0" }, @@ -17252,7 +17134,6 @@ "node_modules/resolve.exports": { "version": "2.0.3", "license": "MIT", - "peer": true, "engines": { "node": ">=10" } @@ -18027,7 +17908,7 @@ }, "node_modules/string-length": { "version": "4.0.2", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "char-regex": "^1.0.2", @@ -18039,7 +17920,7 @@ }, "node_modules/string-length/node_modules/strip-ansi": { "version": "6.0.1", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -18214,9 +18095,8 @@ }, "node_modules/strip-bom": { "version": "4.0.0", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -18241,9 +18121,8 @@ }, "node_modules/strip-json-comments": { "version": "3.1.1", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" }, @@ -18713,7 +18592,7 @@ }, "node_modules/ts-node": { "version": "10.9.2", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", @@ -18755,7 +18634,7 @@ }, "node_modules/ts-node/node_modules/arg": { "version": "4.1.3", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/tsconfig-paths": { @@ -19043,6 +18922,20 @@ "node": "*" } }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "dev": true, @@ -19263,14 +19156,13 @@ }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/v8-to-istanbul": { "version": "9.3.0", - "dev": true, + "devOptional": true, "license": "ISC", - "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", @@ -19674,6 +19566,13 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/wrap-ansi": { "version": "7.0.0", "license": "MIT", @@ -19919,7 +19818,7 @@ }, "node_modules/yn": { "version": "3.1.1", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -20029,12 +19928,97 @@ "vd-tool": "^4.0.2", "xcode": "^3.0.1" }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/node": "^20.19.25", + "jest": "^29.7.0", + "ts-jest": "^29.3.4" + }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, + "packages/expo-plugin/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "packages/expo-plugin/node_modules/ts-jest": { + "version": "29.4.9", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz", + "integrity": "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.9", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.4", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "packages/expo-plugin/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/ios": { "name": "@use-voltra/ios", "version": "1.4.0", diff --git a/packages/expo-plugin/jest.config.js b/packages/expo-plugin/jest.config.js new file mode 100644 index 00000000..dcdfc6cb --- /dev/null +++ b/packages/expo-plugin/jest.config.js @@ -0,0 +1,14 @@ +/** @type {import('jest').Config} */ +module.exports = { + testEnvironment: 'node', + testMatch: ['/src/**/*.node.test.ts'], + modulePathIgnorePatterns: ['/build'], + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.jest.json', + }, + ], + }, +} diff --git a/packages/expo-plugin/package.json b/packages/expo-plugin/package.json index 8112a27d..31a7b7e7 100644 --- a/packages/expo-plugin/package.json +++ b/packages/expo-plugin/package.json @@ -22,6 +22,7 @@ "build": "node ../../scripts/build-package.mjs packages/expo-plugin", "clean": "rm -rf build", "lint": "oxlint src", + "test": "jest --config jest.config.js", "typecheck": "tsc -p tsconfig.typecheck.json --noEmit" }, "dependencies": { @@ -54,5 +55,11 @@ "expo": "*", "react": "*", "react-native": "*" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/node": "^20.19.25", + "jest": "^29.7.0", + "ts-jest": "^29.3.4" } } diff --git a/packages/expo-plugin/src/android/files/initialStates.ts b/packages/expo-plugin/src/android/files/initialStates.ts index f6f77acf..3f4512ac 100644 --- a/packages/expo-plugin/src/android/files/initialStates.ts +++ b/packages/expo-plugin/src/android/files/initialStates.ts @@ -3,8 +3,12 @@ import path from 'path' import type { AndroidWidgetConfig } from '../../types' import { logger } from '../../utils/logger' +import type { PrerenderedWidgetStates } from '../../utils/prerender' import { prerenderWidgetState } from '../../utils/prerender' +/** Wrapped asset shape when multiple locales are built; matches Android reader in VoltraWidgetManager */ +export const VOLTRA_LOCALIZED_INITIAL_STATE_KEY = '__voltraLocales' + export interface GenerateInitialStatesOptions { widgets: AndroidWidgetConfig[] projectRoot: string @@ -29,8 +33,12 @@ export async function generateAndroidInitialStates(options: GenerateInitialState renderAndroidWidgetToString: RenderAndroidWidgetToString } - // Prerender widget states - const prerenderedStates = await prerenderWidgetState(widgets, projectRoot, renderAndroidWidgetToString) + // Prerender widget states (per locale when `initialStatePath` is a locale map) + const prerenderedStates: PrerenderedWidgetStates = await prerenderWidgetState( + widgets, + projectRoot, + renderAndroidWidgetToString + ) if (prerenderedStates.size === 0) { return @@ -42,16 +50,22 @@ export async function generateAndroidInitialStates(options: GenerateInitialState fs.mkdirSync(assetsDir, { recursive: true }) } - // Convert Map to Object for JSON serialization - const statesObj: Record = {} - for (const [id, stateJson] of prerenderedStates.entries()) { + // Convert Map to Object for JSON serialization (legacy flat shape vs localized wrapper) + const statesObj: Record = {} + for (const [id, perLocale] of prerenderedStates.entries()) { try { - // Parse the JSON string so it's embedded as an object in the final JSON - statesObj[id] = JSON.parse(stateJson) + if (perLocale.size === 1 && perLocale.has('__default')) { + const raw = perLocale.get('__default')! + statesObj[id] = JSON.parse(raw) + } else { + const locales: Record = {} + for (const [localeKey, jsonStr] of perLocale.entries()) { + locales[localeKey] = JSON.parse(jsonStr) + } + statesObj[id] = { [VOLTRA_LOCALIZED_INITIAL_STATE_KEY]: locales } + } } catch (e) { logger.warn(`Failed to parse prerendered state for widget ${id}: ${e}`) - // If it's not valid JSON, we might skip it or include it as string? - // renderWidgetToString should return valid JSON. } } diff --git a/packages/expo-plugin/src/android/files/xml.ts b/packages/expo-plugin/src/android/files/xml.ts index 92af4a99..206ccea6 100644 --- a/packages/expo-plugin/src/android/files/xml.ts +++ b/packages/expo-plugin/src/android/files/xml.ts @@ -296,12 +296,12 @@ function generatePlaceholderLayoutXml(): string { android:layout_width="match_parent" android:layout_height="match_parent" android:background="?android:attr/colorBackground"> - + android:indeterminate="true" /> ` } diff --git a/packages/expo-plugin/src/index.ts b/packages/expo-plugin/src/index.ts index db8f5f7f..5ff78226 100644 --- a/packages/expo-plugin/src/index.ts +++ b/packages/expo-plugin/src/index.ts @@ -91,6 +91,7 @@ export type { VoltraConfigPlugin, WidgetConfig, WidgetFamily, + WidgetInitialStatePath, WidgetLabel, WidgetLocalizedCopy, WidgetServerUpdateConfig, diff --git a/packages/expo-plugin/src/ios-widget/files/swift.ts b/packages/expo-plugin/src/ios-widget/files/swift.ts index 716eea64..387ff02b 100644 --- a/packages/expo-plugin/src/ios-widget/files/swift.ts +++ b/packages/expo-plugin/src/ios-widget/files/swift.ts @@ -6,6 +6,7 @@ import { DEFAULT_WIDGET_FAMILIES, WIDGET_FAMILY_MAP } from '../../constants' import type { WidgetConfig, WidgetLabel } from '../../types' import { VOLTRA_WIDGET_STRINGS_BASENAME } from '../../utils/fileDiscovery' import { logger } from '../../utils/logger' +import type { PrerenderedWidgetStates } from '../../utils/prerender' import { prerenderWidgetState } from '../../utils/prerender' import { isWidgetLocalizedMap, widgetLabelEnglish } from '../../utils/widgetLabel' @@ -48,7 +49,9 @@ export async function generateSwiftFiles(options: GenerateSwiftFilesOptions): Pr const initialStatesPath = path.join(targetPath, 'VoltraWidgetInitialStates.swift') fs.writeFileSync(initialStatesPath, initialStatesContent) - logger.info(`Generated VoltraWidgetInitialStates.swift with ${prerenderedStates.size} pre-rendered widget states`) + logger.info( + `Generated VoltraWidgetInitialStates.swift with ${prerenderedStates.size} widget(s) (localized initial states where configured)` + ) // Generate the widget bundle Swift file const widgetBundleContent = @@ -303,18 +306,22 @@ function generateDefaultWidgetBundleSwift(): string { // ============================================================================ /** - * Generates Swift code that bundles pre-rendered widget initial states. + * Generates Swift code that bundles pre-rendered widget initial states (per locale when configured). */ -function generateInitialStatesSwift(prerenderedStates: Map): string { +function generateInitialStatesSwift(prerenderedStates: PrerenderedWidgetStates): string { if (prerenderedStates.size === 0) { return generateEmptyInitialStatesSwift() } - // Generate the bundled states dictionary - const stateEntries = Array.from(prerenderedStates.entries()) - .map(([widgetId, json]) => { - const delimiter = getSwiftRawStringDelimiter(json) - return `"${widgetId}": ${delimiter}"${json}"${delimiter}` + const widgetEntries = Array.from(prerenderedStates.entries()) + .map(([widgetId, perLocale]) => { + const localeEntries = Array.from(perLocale.entries()) + .map(([localeKey, json]) => { + const delimiter = getSwiftRawStringDelimiter(json) + return `"${escapeForSwiftStringLiteral(localeKey)}": ${delimiter}"${json}"${delimiter}` + }) + .join(',\n ') + return `"${widgetId}": [\n ${localeEntries}\n ]` }) .join(',\n ') @@ -329,14 +336,18 @@ function generateInitialStatesSwift(prerenderedStates: Map): str import Foundation public enum VoltraWidgetInitialStates { - private static let bundledStates: [String: String] = [ - ${stateEntries} + private static let bundledLocalizedStates: [String: [String: String]] = [ + ${widgetEntries} ] - /// Get the bundled initial state JSON for a widget. + /// Get the bundled initial state JSON for a widget, matching the device locale when multiple locales were built. /// Returns nil if no initial state was configured for the widget. public static func getInitialState(for widgetId: String) -> Data? { - guard let jsonString = bundledStates[widgetId] else { return nil } + guard let perLocale = bundledLocalizedStates[widgetId] else { return nil } + let tags = VoltraInitialStateLocale.preferredLanguageTags() + guard let jsonString = VoltraInitialStateLocale.pickJson(from: perLocale, preferredLanguages: tags) else { + return nil + } return jsonString.data(using: .utf8) } } diff --git a/packages/expo-plugin/src/types.ts b/packages/expo-plugin/src/types.ts index 8c973973..ac2f47a9 100644 --- a/packages/expo-plugin/src/types.ts +++ b/packages/expo-plugin/src/types.ts @@ -16,6 +16,12 @@ export type WidgetLocalizedCopy = Record; export type WidgetLabel = string | WidgetLocalizedCopy +/** + * Build-time widget initial state source: a single file path, or per-locale paths (same key rules as `WidgetLocalizedCopy`). + * Each path must point to a module that exports the widget variants / default export for prerendering. + */ +export type WidgetInitialStatePath = string | WidgetLocalizedCopy + /** * Supported widget size families */ @@ -52,10 +58,10 @@ export interface WidgetConfig { */ supportedFamilies?: WidgetFamily[] /** - * Path to a file that default exports a WidgetVariants object for initial widget state. + * Path to a file that default exports a WidgetVariants object for initial widget state (or a locale map of paths). * This will be pre-rendered at build time and bundled into the iOS app. */ - initialStatePath?: string + initialStatePath?: WidgetInitialStatePath /** * Configuration for server-driven widget updates. * When configured, the widget will periodically fetch new content from a remote server @@ -157,10 +163,10 @@ export interface AndroidWidgetConfig { */ widgetCategory?: 'home_screen' | 'keyguard' | 'home_screen|keyguard' /** - * Path to a file that default exports a WidgetVariants object for initial widget state. + * Path to a file that default exports a WidgetVariants object for initial widget state (or a locale map of paths). * This will be pre-rendered at build time and bundled into the app. */ - initialStatePath?: string + initialStatePath?: WidgetInitialStatePath /** * Configuration for server-driven widget updates. * When configured, the widget will periodically fetch new content from a remote server diff --git a/packages/expo-plugin/src/utils/localePick.node.test.ts b/packages/expo-plugin/src/utils/localePick.node.test.ts new file mode 100644 index 00000000..9775daef --- /dev/null +++ b/packages/expo-plugin/src/utils/localePick.node.test.ts @@ -0,0 +1,22 @@ +import { pickLocalizedValue } from './localePick' + +describe('pickLocalizedValue', () => { + it('prefers exact locale tag match', () => { + const out = pickLocalizedValue( + { en: '{"a":1}', pl: '{"a":2}', 'pt-BR': '{"a":3}' }, + ['pt-BR', 'en'] + ) + expect(out).toBe('{"a":3}') + }) + + it('falls back to language-only match', () => { + const out = pickLocalizedValue({ en: 'en', pl: 'pl' }, ['pl-PL']) + expect(out).toBe('pl') + }) + + it('uses en then __default then first sorted key', () => { + expect(pickLocalizedValue({ de: 'de', en: 'en' }, ['fr'])).toBe('en') + expect(pickLocalizedValue({ __default: 'd', pl: 'pl' }, ['fr'])).toBe('d') + expect(pickLocalizedValue({ zz: 'z', aa: 'a' }, ['fr'])).toBe('a') + }) +}) diff --git a/packages/expo-plugin/src/utils/localePick.ts b/packages/expo-plugin/src/utils/localePick.ts new file mode 100644 index 00000000..798fc1a5 --- /dev/null +++ b/packages/expo-plugin/src/utils/localePick.ts @@ -0,0 +1,53 @@ +/** + * Picks a localized string value from a locale-keyed map using the same fallback rules as iOS/Android runtimes: + * preferred language tags (full match, then language-only), then `en`, then `__default`, then first value. + */ +export function normalizeLocaleTag(tag: string): string { + return tag.trim().toLowerCase().replace(/_/g, '-') +} + +export function pickLocalizedValue( + perLocale: Record, + preferredLanguages: string[] +): string | undefined { + const entries = Object.entries(perLocale).filter(([, v]) => typeof v === 'string' && v.length > 0) + if (entries.length === 0) { + return undefined + } + + const byNorm = new Map() + for (const [k, v] of entries) { + byNorm.set(normalizeLocaleTag(k), v) + } + + const DEFAULT_KEY = '__default' + + for (const pref of preferredLanguages) { + const n = normalizeLocaleTag(pref) + const direct = byNorm.get(n) + if (direct !== undefined) { + return direct + } + const lang = n.split('-')[0] ?? n + for (const [key, val] of entries) { + const kn = normalizeLocaleTag(key) + const keyLang = kn.split('-')[0] ?? kn + if (keyLang === lang) { + return val + } + } + } + + const en = byNorm.get('en') ?? entries.find(([k]) => normalizeLocaleTag(k) === 'en')?.[1] + if (en !== undefined) { + return en + } + + const defaultVal = byNorm.get(DEFAULT_KEY) ?? entries.find(([k]) => k === DEFAULT_KEY)?.[1] + if (defaultVal !== undefined) { + return defaultVal + } + + const sorted = [...entries].sort(([a], [b]) => a.localeCompare(b)) + return sorted[0]?.[1] +} diff --git a/packages/expo-plugin/src/utils/prerender.ts b/packages/expo-plugin/src/utils/prerender.ts index f8c68113..828e0c01 100644 --- a/packages/expo-plugin/src/utils/prerender.ts +++ b/packages/expo-plugin/src/utils/prerender.ts @@ -5,6 +5,8 @@ import vm from 'node:vm' import * as babel from '@babel/core' import { MODULE_EXTENSIONS } from '../constants' +import type { WidgetInitialStatePath, WidgetLabel } from '../types' +import { isWidgetLocalizedMap } from './widgetLabel' /** * Type for the widget renderer function @@ -16,9 +18,12 @@ export type WidgetRenderer = (variants: any) => string */ export interface PrerenderableWidget { id: string - initialStatePath?: string + initialStatePath?: WidgetInitialStatePath } +/** widgetId -> locale key -> prerendered JSON string (single-file widgets use `__default`) */ +export type PrerenderedWidgetStates = Map> + /** * Check if a module specifier is a relative or absolute path (local file) */ @@ -168,36 +173,38 @@ function evaluateWidgetModule(projectRoot: string, filePath: string): any { * @param widgets - Array of widget configurations * @param projectRoot - Root directory of the Expo project * @param renderer - The renderer function to use (voltra/server or voltra/android/server) - * @returns Map of widgetId -> prerendered widget state as JSON string + * @returns Map of widgetId -> (locale key -> prerendered JSON string) */ export async function prerenderWidgetState( widgets: PrerenderableWidget[], projectRoot: string, renderer: WidgetRenderer -): Promise> { - const prerenderedStates = new Map() +): Promise { + const prerenderedStates: PrerenderedWidgetStates = new Map() for (const widget of widgets) { if (!widget.initialStatePath) { continue } - try { - // Resolve the absolute path to the widget file - const absoluteWidgetPath = path.resolve(projectRoot, widget.initialStatePath) - - // Evaluate the module (transpiles with Babel and executes in VM) - const widgetVariants = evaluateWidgetModule(projectRoot, absoluteWidgetPath) + const pathSpec = widget.initialStatePath + const perLocalePaths: Record = isWidgetLocalizedMap(pathSpec as WidgetLabel) + ? (pathSpec as Record) + : { __default: pathSpec as string } - // Render the widget variants to a JSON string - const prerenderedState = renderer(widgetVariants) + const inner = new Map() - prerenderedStates.set(widget.id, prerenderedState) + try { + for (const [localeKey, relativePath] of Object.entries(perLocalePaths)) { + const absoluteWidgetPath = path.resolve(projectRoot, relativePath) + const widgetVariants = evaluateWidgetModule(projectRoot, absoluteWidgetPath) + const prerenderedState = renderer(widgetVariants) + inner.set(localeKey, prerenderedState) + } + prerenderedStates.set(widget.id, inner) } catch (error) { throw new Error( - `Failed to prerender widget state for ${widget.id} (${widget.initialStatePath}): ${ - error instanceof Error ? error.message : String(error) - }` + `Failed to prerender widget state for ${widget.id}: ${error instanceof Error ? error.message : String(error)}` ) } } diff --git a/packages/expo-plugin/src/validation.node.test.ts b/packages/expo-plugin/src/validation.node.test.ts new file mode 100644 index 00000000..24fd6d77 --- /dev/null +++ b/packages/expo-plugin/src/validation.node.test.ts @@ -0,0 +1,17 @@ +import { validateInitialStatePath } from './validation' + +describe('validateInitialStatePath', () => { + it('accepts a plain path without projectRoot', () => { + expect(() => validateInitialStatePath('./widgets/a.tsx', 'w')).not.toThrow() + }) + + it('accepts a locale map of paths', () => { + expect(() => + validateInitialStatePath({ en: './widgets/en.tsx', pl: './widgets/pl.tsx' }, 'w') + ).not.toThrow() + }) + + it('rejects empty locale map', () => { + expect(() => validateInitialStatePath({} as any, 'w')).toThrow(/must not be empty/) + }) +}) diff --git a/packages/expo-plugin/src/validation.ts b/packages/expo-plugin/src/validation.ts index 85201740..76195afd 100644 --- a/packages/expo-plugin/src/validation.ts +++ b/packages/expo-plugin/src/validation.ts @@ -1,7 +1,13 @@ import * as fs from 'fs' import * as path from 'path' -import type { AndroidWidgetConfig, ConfigPluginProps, WidgetConfig, WidgetFamily } from './types' +import type { + AndroidWidgetConfig, + ConfigPluginProps, + WidgetConfig, + WidgetFamily, + WidgetInitialStatePath, +} from './types' /** * Validation functions for the Voltra plugin @@ -93,6 +99,65 @@ function validateWidgetLabel(value: unknown, widgetId: string, fieldName: string } } +/** + * Validates optional initialStatePath: plain string or locale map of project-relative file paths. + */ +export function validateInitialStatePath( + value: WidgetInitialStatePath | undefined, + widgetId: string, + projectRoot?: string +): void { + if (value === undefined) { + return + } + + const assertPathExists = (relativePath: string, ctx: string) => { + if (!relativePath.trim()) { + throw new Error(`Widget '${widgetId}': initialStatePath ${ctx} must be a non-empty path`) + } + if (projectRoot) { + const fullPath = path.join(projectRoot, relativePath) + if (!fs.existsSync(fullPath)) { + throw new Error(`Widget '${widgetId}': initialStatePath file not found at ${relativePath}`) + } + } + } + + if (typeof value === 'string') { + assertPathExists(value, '') + return + } + + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error( + `Widget '${widgetId}': initialStatePath must be a string or a locale map of paths (e.g. { "en": "./widgets/en.tsx", "pl": "./widgets/pl.tsx" })` + ) + } + + const entries = Object.entries(value as Record) + if (entries.length === 0) { + throw new Error(`Widget '${widgetId}': initialStatePath locale map must not be empty`) + } + + const localeKeys = new Set() + for (const [locale, v] of entries) { + assertValidLocaleKey(locale, widgetId, 'initialStatePath') + + const normalized = locale.toLowerCase().replace(/_/g, '-') + if (localeKeys.has(normalized)) { + throw new Error( + `Widget '${widgetId}': initialStatePath duplicates locale '${locale}' (underscore and hyphen forms count as the same)` + ) + } + localeKeys.add(normalized) + + if (typeof v !== 'string' || !v.trim()) { + throw new Error(`Widget '${widgetId}': initialStatePath.${locale} must be a non-empty path string`) + } + assertPathExists(v, `.${locale}`) + } +} + // ============================================================================ // iOS Widget Validation // ============================================================================ @@ -116,6 +181,8 @@ export function validateWidgetConfig(widget: WidgetConfig): void { validateWidgetLabel(widget.displayName, widget.id, 'displayName') validateWidgetLabel(widget.description, widget.id, 'description') + /** File existence is checked when `projectRoot` is available (e.g. Android prebuild). */ + validateInitialStatePath(widget.initialStatePath, widget.id) // Validate supported families if provided if (widget.supportedFamilies) { @@ -147,6 +214,7 @@ export function validateAndroidWidgetConfig(widget: AndroidWidgetConfig, project validateWidgetLabel(widget.displayName, widget.id, 'displayName') validateWidgetLabel(widget.description, widget.id, 'description') + validateInitialStatePath(widget.initialStatePath, widget.id, projectRoot) // Validate targetCellWidth if (typeof widget.targetCellWidth !== 'number') { diff --git a/packages/expo-plugin/tsconfig.base.json b/packages/expo-plugin/tsconfig.base.json index 49f9c30e..6bb57ecd 100644 --- a/packages/expo-plugin/tsconfig.base.json +++ b/packages/expo-plugin/tsconfig.base.json @@ -13,5 +13,5 @@ "isolatedModules": true }, "include": ["./src"], - "exclude": ["**/__mocks__/*", "**/__tests__/*", "**/__rsc_tests__/*"] + "exclude": ["**/__mocks__/*", "**/__tests__/*", "**/__rsc_tests__/*", "**/*.node.test.ts"] } diff --git a/packages/expo-plugin/tsconfig.jest.json b/packages/expo-plugin/tsconfig.jest.json new file mode 100644 index 00000000..c0c766d0 --- /dev/null +++ b/packages/expo-plugin/tsconfig.jest.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["jest", "node"] + }, + "include": ["./src/**/*.ts"] +} diff --git a/packages/voltra/android/src/main/java/voltra/widget/VoltraWidgetManager.kt b/packages/voltra/android/src/main/java/voltra/widget/VoltraWidgetManager.kt index 72ad25e8..02b23740 100644 --- a/packages/voltra/android/src/main/java/voltra/widget/VoltraWidgetManager.kt +++ b/packages/voltra/android/src/main/java/voltra/widget/VoltraWidgetManager.kt @@ -4,6 +4,8 @@ import android.appwidget.AppWidgetManager import android.content.ComponentName import android.content.Context import android.content.SharedPreferences +import android.content.res.Resources +import android.os.Build import android.util.Log import android.widget.RemoteViews import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi @@ -25,6 +27,9 @@ class VoltraWidgetManager( private const val KEY_JSON_PREFIX = "Voltra_Widget_JSON_" private const val KEY_DEEP_LINK_PREFIX = "Voltra_Widget_DeepLinkURL_" private const val ASSET_INITIAL_STATES = "voltra_initial_states.json" + + /** Keys must match `packages/expo-plugin/src/android/files/initialStates.ts` */ + private const val LOCALIZED_INITIAL_STATE_KEY = "__voltraLocales" } private val prefs: SharedPreferences = @@ -92,16 +97,82 @@ class VoltraWidgetManager( val jsonString = String(buffer, Charset.forName("UTF-8")) val jsonObject = JSONObject(jsonString) - if (jsonObject.has(widgetId)) { - jsonObject.get(widgetId).toString() - } else { + if (!jsonObject.has(widgetId)) { null + } else { + val raw = jsonObject.get(widgetId) + when (raw) { + is JSONObject -> resolveInitialStatePayload(raw, context.resources) + else -> raw.toString() + } } } catch (e: Exception) { // Asset might not exist or be invalid, which is fine if no pre-rendering was configured null } + /** + * Legacy flat payload vs localized `{ "__voltraLocales": { "en": {...}, "pl": {...} } }`. + */ + private fun resolveInitialStatePayload( + obj: JSONObject, + res: Resources, + ): String { + if (!obj.has(LOCALIZED_INITIAL_STATE_KEY)) { + return obj.toString() + } + val perLocale = obj.optJSONObject(LOCALIZED_INITIAL_STATE_KEY) ?: return obj.toString() + val picked = pickLocalizedPayload(perLocale, res) ?: return obj.toString() + return picked.toString() + } + + private fun preferredLanguageTags(res: Resources): List { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val locales = res.configuration.locales + (0 until locales.size()).map { locales[it].toLanguageTag() } + } else { + @Suppress("DEPRECATION") + listOf(res.configuration.locale.toLanguageTag()) + } + } + + private fun normalizeLocaleTag(tag: String): String = + tag.trim().lowercase().replace('_', '-') + + /** Mirrors `packages/expo-plugin/src/utils/localePick.ts` */ + private fun pickLocalizedPayload( + perLocale: JSONObject, + res: Resources, + ): JSONObject? { + val keys = perLocale.keys().asSequence().toList() + if (keys.isEmpty()) { + return null + } + val preferred = preferredLanguageTags(res) + fun keyNorm(k: String) = normalizeLocaleTag(k) + val byNorm = keys.associateBy { keyNorm(it) } + + for (pref in preferred) { + val n = keyNorm(pref) + byNorm[n]?.let { k -> return perLocale.optJSONObject(k) } + val lang = n.substringBefore('-') + for (k in keys) { + val kn = keyNorm(k) + val keyLang = kn.substringBefore('-') + if (keyLang == lang) { + return perLocale.optJSONObject(k) + } + } + } + if (perLocale.has("en")) { + return perLocale.optJSONObject("en") + } + if (perLocale.has("__default")) { + return perLocale.optJSONObject("__default") + } + return keys.sorted().firstOrNull()?.let { perLocale.optJSONObject(it) } + } + /** * Read widget deep link URL from SharedPreferences */ diff --git a/packages/voltra/ios/shared/VoltraInitialStateLocale.swift b/packages/voltra/ios/shared/VoltraInitialStateLocale.swift new file mode 100644 index 00000000..cdc17572 --- /dev/null +++ b/packages/voltra/ios/shared/VoltraInitialStateLocale.swift @@ -0,0 +1,54 @@ +import Foundation + +/// Matches `packages/expo-plugin/src/utils/localePick.ts` fallback order for bundled widget initial JSON. +public enum VoltraInitialStateLocale { + public static func pickJson(from perLocale: [String: String], preferredLanguages: [String]) -> String? { + let entries = perLocale.filter { !$0.value.isEmpty } + if entries.isEmpty { + return nil + } + + func normalize(_ tag: String) -> String { + tag.trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "_", with: "-") + } + + var byNorm: [String: String] = [:] + for (k, v) in entries { + byNorm[normalize(k)] = v + } + + for pref in preferredLanguages { + let n = normalize(pref) + if let direct = byNorm[n] { + return direct + } + let lang = n.split(separator: "-").first.map(String.init) ?? n + for (key, val) in entries { + let kn = normalize(key) + let keyLang = kn.split(separator: "-").first.map(String.init) ?? kn + if keyLang == lang { + return val + } + } + } + + if let en = byNorm["en"] { + return en + } + if let def = byNorm["__default"] { + return def + } + + let sorted = entries.keys.sorted() + guard let firstKey = sorted.first else { + return nil + } + return entries[firstKey] + } + + public static func preferredLanguageTags() -> [String] { + Locale.preferredLanguages + } +} diff --git a/packages/voltra/ios/target/VoltraHomeWidget.swift b/packages/voltra/ios/target/VoltraHomeWidget.swift index 91acd1f5..b34f2049 100644 --- a/packages/voltra/ios/target/VoltraHomeWidget.swift +++ b/packages/voltra/ios/target/VoltraHomeWidget.swift @@ -348,19 +348,14 @@ public struct VoltraHomeWidgetView: View { } private func placeholderView(widgetId _: String) -> some View { - VStack(alignment: .leading, spacing: 8) { - Text("Almost ready") - .font(.headline) - Text("Open the app once to sync data for this widget.") - .font(.caption) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .padding(16) - .background( + ZStack { RoundedRectangle(cornerRadius: 18, style: .continuous) .fill(Color(UIColor.secondarySystemBackground)) - ) + ProgressView() + .progressViewStyle(.circular) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(16) } } diff --git a/skills/voltra/references/plugin-schema.md b/skills/voltra/references/plugin-schema.md index 69bb6dbe..7b183759 100644 --- a/skills/voltra/references/plugin-schema.md +++ b/skills/voltra/references/plugin-schema.md @@ -18,10 +18,10 @@ Voltra plugin config lives under `expo.plugins`. Use top-level `widgets` for iOS widget gallery registration. - `id`: unique identifier, use alphanumeric and underscores only -- `displayName` -- `description` +- `displayName` (string or per-locale map) +- `description` (string or per-locale map) - `supportedFamilies`: array of iOS families such as `systemSmall`, `systemMedium`, `systemLarge` -- `initialStatePath` +- `initialStatePath` (string or per-locale map of paths for localized pre-render) - `serverUpdate.url`: widget endpoint, Voltra appends `widgetId`, `platform=ios`, and `family` - `serverUpdate.intervalMinutes`: polling interval, default `15`, subject to WidgetKit throttling @@ -38,8 +38,8 @@ Other important Apple-side keys: Use `android.widgets` for Android widget registration. - `id`: unique identifier, use alphanumeric and underscores only -- `displayName` -- `description` +- `displayName` (string or per-locale map) +- `description` (string or per-locale map) - `targetCellWidth` - `targetCellHeight` - `minCellWidth` @@ -48,7 +48,7 @@ Use `android.widgets` for Android widget registration. - `minHeight` - `resizeMode` - `widgetCategory` -- `initialStatePath` +- `initialStatePath` (string or per-locale map of paths) - `serverUpdate.url`: widget endpoint, Voltra appends `widgetId` and `platform=android` - `serverUpdate.intervalMinutes`: polling interval; use at least 15 minutes - `previewImage` diff --git a/website/docs/android/api/plugin-configuration.md b/website/docs/android/api/plugin-configuration.md index 3defdb82..10fe0bd1 100644 --- a/website/docs/android/api/plugin-configuration.md +++ b/website/docs/android/api/plugin-configuration.md @@ -54,8 +54,8 @@ Array of widget configurations for Home Screen widgets. Each widget will be avai **Widget Configuration Properties:** - `id`: Unique identifier for the widget (alphanumeric with underscores only) -- `displayName`: Name shown in the widget picker -- `description`: Description shown in the widget picker +- `displayName`: Name shown in the widget picker (plain string, or per-locale map; same rules as iOS `widgets[].displayName`) +- `description`: Description shown in the widget picker (same rules as `displayName`) - `targetCellWidth`: Target widget width in grid cells (1-5, required) - `targetCellHeight`: Target widget height in grid cells (1-5, required) - `minCellWidth`: (optional) Minimum width in grid cells (defaults to targetCellWidth) @@ -64,7 +64,7 @@ Array of widget configurations for Home Screen widgets. Each widget will be avai - `minHeight`: (optional) Minimum height in dp (overrides minCellHeight calculation) - `resizeMode`: (optional) Widget resize behavior (`"none"` | `"horizontal"` | `"vertical"` | `"horizontal|vertical"`, default: `"horizontal|vertical"`) - `widgetCategory`: (optional) Widget category (`"home_screen"` | `"keyguard"` | `"home_screen|keyguard"`, default: `"home_screen"`) -- `initialStatePath`: (optional) Path to a file that exports initial widget state (see [Widget Pre-rendering](../development/widget-pre-rendering)) +- `initialStatePath`: (optional) Path to a file that exports initial widget state, or a locale map of paths for localized build-time pre-rendering (see [Widget Pre-rendering](../development/widget-pre-rendering)) - `previewImage`: (optional) Path to preview image for widget picker (PNG/JPG/WebP) - `previewLayout`: (optional) Path to custom XML layout for widget picker preview (Android 12+) - `serverUpdate`: (optional) Enable server-driven updates. See [Server-driven widgets](../development/server-driven-widgets) for full details. diff --git a/website/docs/android/development/widget-pre-rendering.md b/website/docs/android/development/widget-pre-rendering.md index f9b72617..82288465 100644 --- a/website/docs/android/development/widget-pre-rendering.md +++ b/website/docs/android/development/widget-pre-rendering.md @@ -17,7 +17,10 @@ Add `initialStatePath` to your widget configuration in the `android` section of "widgets": [ { "id": "weather", - "name": "Weather Widget", + "displayName": "Weather Widget", + "description": "Shows current weather", + "targetCellWidth": 2, + "targetCellHeight": 2, "initialStatePath": "./widgets/weather-android-initial.tsx" } ] @@ -29,6 +32,8 @@ Add `initialStatePath` to your widget configuration in the `android` section of } ``` +For multiple locales, set `initialStatePath` to a map of locale tag → file path (same pattern as iOS); the first-run payload matches the device locale when possible. + ## Implementation Create a file at the specified `initialStatePath` that exports a default Voltra component (or a React element). For Android, this should use `VoltraAndroid` primitives. diff --git a/website/docs/ios/api/plugin-configuration.md b/website/docs/ios/api/plugin-configuration.md index b7558041..42431ee4 100644 --- a/website/docs/ios/api/plugin-configuration.md +++ b/website/docs/ios/api/plugin-configuration.md @@ -94,10 +94,10 @@ Array of widget configurations for Home Screen widgets. Each widget will be avai **Widget Configuration Properties:** - `id`: Unique identifier for the widget (alphanumeric with underscores only) -- `displayName`: Name shown in the widget gallery -- `description`: Description shown in the widget gallery +- `displayName`: Name shown in the widget gallery (plain string, or per-locale map like `{ "en": "Weather", "pl": "Pogoda" }`; locale keys are BCP‑47-style tags) +- `description`: Description shown in the widget gallery (same localization rules as `displayName`) - `supportedFamilies`: Array of supported widget sizes (`systemSmall`, `systemMedium`, `systemLarge`) -- `initialStatePath`: (optional) Path to a file that exports initial widget state (see [Widget Pre-rendering](../development/widget-pre-rendering)) +- `initialStatePath`: (optional) Project-relative path to a file that exports initial widget state, **or** a locale map of paths for localized build-time pre-rendering (see [Widget Pre-rendering](../development/widget-pre-rendering)) - `serverUpdate`: (optional) Enable server-driven updates. See [Server-driven widgets](../development/server-driven-widgets) for full details. - `url`: The Voltra SSR endpoint URL - `intervalMinutes`: Update interval in minutes (default: `15`) @@ -113,7 +113,10 @@ Array of widget configurations for Home Screen widgets. Each widget will be avai "displayName": "Weather Widget", "description": "Current weather conditions", "supportedFamilies": ["systemSmall", "systemMedium", "systemLarge"], - "initialStatePath": "./widgets/weather-initial.tsx", + "initialStatePath": { + "en": "./widgets/weather-initial.tsx", + "pl": "./widgets/weather-initial-pl.tsx" + }, "serverUpdate": { "url": "https://api.example.com/widgets/render", "intervalMinutes": 30, diff --git a/website/docs/ios/development/widget-pre-rendering.md b/website/docs/ios/development/widget-pre-rendering.md index 8eea604a..c9002c9d 100644 --- a/website/docs/ios/development/widget-pre-rendering.md +++ b/website/docs/ios/development/widget-pre-rendering.md @@ -29,9 +29,20 @@ Add `initialStatePath` to your widget configuration in `app.json`: } ``` +### Localized initial states + +Use a locale map so the config plugin pre-renders one bundle per language; iOS and Android pick the best match at runtime from the device locale (fallback order: preferred languages → language-only → `en` → first locale): + +```json +"initialStatePath": { + "en": "./widgets/weather-initial.tsx", + "pl": "./widgets/weather-initial-pl.tsx" +} +``` + ## Implementation -Create a file at the specified `initialStatePath` that exports a `WidgetVariants` object: +Create a file at each configured path that exports a `WidgetVariants` object (or use the same file path for multiple locales if copy is identical): ```tsx import { Voltra, type WidgetVariants } from 'voltra' From 66fc62ecc7f62f05d79401de4abbfc9d618124b1 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 4 May 2026 13:44:09 +0200 Subject: [PATCH 3/6] chore: reformat --- packages/expo-plugin/src/android/files/xml.ts | 4 +++- packages/expo-plugin/src/ios-widget/files/swift.ts | 6 +++++- packages/expo-plugin/src/ios-widget/xcode/groups.ts | 9 ++++++++- packages/expo-plugin/src/types.ts | 2 +- packages/expo-plugin/src/utils/localePick.node.test.ts | 5 +---- packages/expo-plugin/src/validation.node.test.ts | 4 +--- 6 files changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/expo-plugin/src/android/files/xml.ts b/packages/expo-plugin/src/android/files/xml.ts index 206ccea6..18e40fa2 100644 --- a/packages/expo-plugin/src/android/files/xml.ts +++ b/packages/expo-plugin/src/android/files/xml.ts @@ -227,7 +227,9 @@ function escapeAndroidStringRes(text: string): string { function generateVoltraWidgetsStringResourcesXml(widgets: AndroidWidgetConfig[], localeKey: string | null): string { const localeComment = - localeKey === null ? 'default (values/)' : `locale ${localeKey} → values-${localeKeyToAndroidValuesQualifier(localeKey)}` + localeKey === null + ? 'default (values/)' + : `locale ${localeKey} → values-${localeKeyToAndroidValuesQualifier(localeKey)}` const stringEntries = widgets .map((widget) => { diff --git a/packages/expo-plugin/src/ios-widget/files/swift.ts b/packages/expo-plugin/src/ios-widget/files/swift.ts index 387ff02b..c3b29ce5 100644 --- a/packages/expo-plugin/src/ios-widget/files/swift.ts +++ b/packages/expo-plugin/src/ios-widget/files/swift.ts @@ -183,7 +183,11 @@ function widgetUsesGalleryLocalization(widget: WidgetConfig): boolean { * Widget gallery title / description: deferred lookup via LocalizedStringResource when using a locale map * (recommended for extensions); plain Text for single-string config. */ -function iosWidgetGalleryLabelSwiftExpr(widgetId: string, field: 'displayName' | 'description', label: WidgetLabel): string { +function iosWidgetGalleryLabelSwiftExpr( + widgetId: string, + field: 'displayName' | 'description', + label: WidgetLabel +): string { if (!isWidgetLocalizedMap(label)) { return `Text("${escapeForSwiftStringLiteral(label)}")` } diff --git a/packages/expo-plugin/src/ios-widget/xcode/groups.ts b/packages/expo-plugin/src/ios-widget/xcode/groups.ts index c412ae96..8fae66d4 100644 --- a/packages/expo-plugin/src/ios-widget/xcode/groups.ts +++ b/packages/expo-plugin/src/ios-widget/xcode/groups.ts @@ -19,7 +19,14 @@ export function addPbxGroup(xcodeProject: XcodeProject, options: AddPbxGroupOpti // Add PBX group with all widget files const { uuid: pbxGroupUuid } = xcodeProject.addPbxGroup( - [...swiftFiles, ...intentFiles, ...entitlementFiles, ...plistFiles, ...assetDirectories, ...localizedStringResources], + [ + ...swiftFiles, + ...intentFiles, + ...entitlementFiles, + ...plistFiles, + ...assetDirectories, + ...localizedStringResources, + ], targetName, targetName ) diff --git a/packages/expo-plugin/src/types.ts b/packages/expo-plugin/src/types.ts index ac2f47a9..4de4e8d0 100644 --- a/packages/expo-plugin/src/types.ts +++ b/packages/expo-plugin/src/types.ts @@ -12,7 +12,7 @@ import { ConfigPlugin } from '@expo/config-plugins' * Per-locale strings for widget picker/gallery labels (`displayName`, `description`). * Keys should be BCP-47-style locale tags (e.g. `en`, `pl`, `pt-BR`). Plain `string` is still allowed for a single-language setup. */ -export type WidgetLocalizedCopy = Record; +export type WidgetLocalizedCopy = Record export type WidgetLabel = string | WidgetLocalizedCopy diff --git a/packages/expo-plugin/src/utils/localePick.node.test.ts b/packages/expo-plugin/src/utils/localePick.node.test.ts index 9775daef..29dee9b4 100644 --- a/packages/expo-plugin/src/utils/localePick.node.test.ts +++ b/packages/expo-plugin/src/utils/localePick.node.test.ts @@ -2,10 +2,7 @@ import { pickLocalizedValue } from './localePick' describe('pickLocalizedValue', () => { it('prefers exact locale tag match', () => { - const out = pickLocalizedValue( - { en: '{"a":1}', pl: '{"a":2}', 'pt-BR': '{"a":3}' }, - ['pt-BR', 'en'] - ) + const out = pickLocalizedValue({ en: '{"a":1}', pl: '{"a":2}', 'pt-BR': '{"a":3}' }, ['pt-BR', 'en']) expect(out).toBe('{"a":3}') }) diff --git a/packages/expo-plugin/src/validation.node.test.ts b/packages/expo-plugin/src/validation.node.test.ts index 24fd6d77..9ac4394b 100644 --- a/packages/expo-plugin/src/validation.node.test.ts +++ b/packages/expo-plugin/src/validation.node.test.ts @@ -6,9 +6,7 @@ describe('validateInitialStatePath', () => { }) it('accepts a locale map of paths', () => { - expect(() => - validateInitialStatePath({ en: './widgets/en.tsx', pl: './widgets/pl.tsx' }, 'w') - ).not.toThrow() + expect(() => validateInitialStatePath({ en: './widgets/en.tsx', pl: './widgets/pl.tsx' }, 'w')).not.toThrow() }) it('rejects empty locale map', () => { From e26e3518b2a743b69ff92e240ba98aff12cdf403 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 4 May 2026 14:01:27 +0200 Subject: [PATCH 4/6] fix(expo-plugin): support script locale qualifiers --- .../src/android/files/xml.node.test.ts | 18 +++++ packages/expo-plugin/src/android/files/xml.ts | 67 ++++++++++++++++--- packages/expo-plugin/src/types.ts | 2 +- .../src/utils/localePick.node.test.ts | 5 ++ packages/expo-plugin/src/utils/widgetLabel.ts | 11 ++- 5 files changed, 87 insertions(+), 16 deletions(-) create mode 100644 packages/expo-plugin/src/android/files/xml.node.test.ts diff --git a/packages/expo-plugin/src/android/files/xml.node.test.ts b/packages/expo-plugin/src/android/files/xml.node.test.ts new file mode 100644 index 00000000..78ef6765 --- /dev/null +++ b/packages/expo-plugin/src/android/files/xml.node.test.ts @@ -0,0 +1,18 @@ +import { __test__ } from './xml' + +describe('localeKeyToAndroidValuesQualifier', () => { + it('maps plain language tags to classic Android qualifiers', () => { + expect(__test__.localeKeyToAndroidValuesQualifier('pl')).toBe('pl') + }) + + it('maps language-region tags to classic Android qualifiers', () => { + expect(__test__.localeKeyToAndroidValuesQualifier('pt-BR')).toBe('pt-rBR') + expect(__test__.localeKeyToAndroidValuesQualifier('pt_BR')).toBe('pt-rBR') + }) + + it('maps script tags to Android BCP-47 qualifiers', () => { + expect(__test__.localeKeyToAndroidValuesQualifier('zh-Hans')).toBe('b+zh+Hans') + expect(__test__.localeKeyToAndroidValuesQualifier('zh-Hans-CN')).toBe('b+zh+Hans+CN') + expect(__test__.localeKeyToAndroidValuesQualifier('sr-Latn-RS')).toBe('b+sr+Latn+RS') + }) +}) diff --git a/packages/expo-plugin/src/android/files/xml.ts b/packages/expo-plugin/src/android/files/xml.ts index 18e40fa2..dc9a9514 100644 --- a/packages/expo-plugin/src/android/files/xml.ts +++ b/packages/expo-plugin/src/android/files/xml.ts @@ -37,7 +37,7 @@ export async function generateWidgetInfoFiles(props: { fs.mkdirSync(valuesPath, { recursive: true }) } - // Default strings (development / fallback language — prefers `en` in locale maps): res/values/voltra_widgets.xml + // Default strings (development / fallback language — prefers English locale entries in locale maps): res/values/voltra_widgets.xml const stringsPath = path.join(valuesPath, VOLTRA_WIDGET_STRINGS_FILE) fs.writeFileSync(stringsPath, generateVoltraWidgetsStringResourcesXml(widgets, null), 'utf8') @@ -160,22 +160,71 @@ function generateWidgetInfoXml( const VOLTRA_WIDGET_STRINGS_FILE = 'voltra_widgets.xml' -/** Locale maps use `en` for default English; unqualified `values/` covers that so skip `values-en/`. */ +/** Locale maps use `en` for the canonical default English locale; unqualified `values/` covers that so skip `values-en/`. */ const DEFAULT_WIDGET_LOCALE_QUALIFIER = 'en' +function isAlpha(value: string): boolean { + return /^[a-z]+$/i.test(value) +} + +function isNumeric(value: string): boolean { + return /^\d+$/.test(value) +} + +function isScriptSubtag(value: string): boolean { + return value.length === 4 && isAlpha(value) +} + +function isRegionSubtag(value: string): boolean { + return (value.length === 2 && isAlpha(value)) || (value.length === 3 && isNumeric(value)) +} + +function formatScriptSubtag(value: string): string { + const lower = value.toLowerCase() + return `${lower[0]?.toUpperCase() ?? ''}${lower.slice(1)}` +} + /** * Maps BCP-style locale keys from app.json to Android resource folder qualifiers. - * Examples: `pl` → `pl`, `pt-BR` / `pt_BR` → `pt-rBR` + * Examples: `pl` → `pl`, `pt-BR` / `pt_BR` → `pt-rBR`, `zh-Hans` → `b+zh+Hans` */ function localeKeyToAndroidValuesQualifier(localeKey: string): string { const normalized = localeKey.trim().replace(/_/g, '-') const segments = normalized.split('-').filter(Boolean) - if (segments.length >= 2 && segments[1].length >= 2) { - const lang = segments[0].toLowerCase() - const region = segments[1].toUpperCase() - return `${lang}-r${region}` + + const language = segments[0]?.toLowerCase() + if (!language) { + return normalized.toLowerCase() + } + + const rest = segments.slice(1) + if (rest.length === 0) { + return language + } + + const [first, ...tail] = rest + if (first && isRegionSubtag(first) && tail.length === 0) { + return `${language}-r${first.toUpperCase()}` + } + + const bcp47Segments = [language] + for (const segment of rest) { + if (isScriptSubtag(segment)) { + bcp47Segments.push(formatScriptSubtag(segment)) + continue + } + if (isRegionSubtag(segment)) { + bcp47Segments.push(segment.toUpperCase()) + continue + } + bcp47Segments.push(segment.toLowerCase()) } - return segments[0]?.toLowerCase() ?? normalized.toLowerCase() + + return `b+${bcp47Segments.join('+')}` +} + +export const __test__ = { + localeKeyToAndroidValuesQualifier, } function collectAndroidLocaleKeysFromWidgets(widgets: AndroidWidgetConfig[]): Set { @@ -216,7 +265,7 @@ function resolveAndroidWidgetLabel( function escapeAndroidStringRes(text: string): string { return text .replace(/\\/g, '\\\\') - .replace(/\"/g, '\\"') + .replace(/"/g, '\\"') .replace(/'/g, "\\'") .replace(/\n/g, '\\n') .replace(/\r/g, '\\r') diff --git a/packages/expo-plugin/src/types.ts b/packages/expo-plugin/src/types.ts index 4de4e8d0..dacfe5da 100644 --- a/packages/expo-plugin/src/types.ts +++ b/packages/expo-plugin/src/types.ts @@ -45,7 +45,7 @@ export interface WidgetConfig { id: string /** * Display name shown in the widget gallery. - * For locale maps, keys must be BCP-47-like (`en`, `pl`, `pt-BR`); include `en` when possible so defaults align with Android `values/` and iOS fallbacks. + * For locale maps, keys must be BCP-47-like (`en`, `pl`, `pt-BR`, `zh-Hans`); include an English locale when possible so defaults align with Android `values/` and iOS fallbacks. */ displayName: WidgetLabel /** diff --git a/packages/expo-plugin/src/utils/localePick.node.test.ts b/packages/expo-plugin/src/utils/localePick.node.test.ts index 29dee9b4..78493031 100644 --- a/packages/expo-plugin/src/utils/localePick.node.test.ts +++ b/packages/expo-plugin/src/utils/localePick.node.test.ts @@ -16,4 +16,9 @@ describe('pickLocalizedValue', () => { expect(pickLocalizedValue({ __default: 'd', pl: 'pl' }, ['fr'])).toBe('d') expect(pickLocalizedValue({ zz: 'z', aa: 'a' }, ['fr'])).toBe('a') }) + + it('prefers english-family locales when plain en is absent', () => { + expect(pickLocalizedValue({ 'en-US': 'us', pl: 'pl' }, ['en'])).toBe('us') + expect(pickLocalizedValue({ de: 'de', en_GB: 'gb' }, ['en'])).toBe('gb') + }) }) diff --git a/packages/expo-plugin/src/utils/widgetLabel.ts b/packages/expo-plugin/src/utils/widgetLabel.ts index 67e66150..6437622d 100644 --- a/packages/expo-plugin/src/utils/widgetLabel.ts +++ b/packages/expo-plugin/src/utils/widgetLabel.ts @@ -1,20 +1,19 @@ import type { WidgetLabel, WidgetLocalizedCopy } from '../types' +import { pickLocalizedValue } from './localePick' + export function isWidgetLocalizedMap(label: WidgetLabel): label is WidgetLocalizedCopy { return typeof label === 'object' && label !== null && !Array.isArray(label) } /** * Development / fallback English copy for LocalizedStringResource.defaultValue. + * Prefers `en`, then `en-*`, then default fallback order from locale picking. */ export function widgetLabelEnglish(label: WidgetLabel): string { if (!isWidgetLocalizedMap(label)) { return label } - const en = label.en - if (typeof en === 'string' && en.trim()) { - return en - } - const first = Object.values(label).find((v) => typeof v === 'string' && v.trim()) - return first ?? '' + + return pickLocalizedValue(label, ['en']) ?? '' } From 379ab1fd18904d3b9c34fcdb3c42dec167a9ad07 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 5 May 2026 07:57:14 +0200 Subject: [PATCH 5/6] docs: add translation --- .../docs/android/api/plugin-configuration.md | 37 +++++++++++++++++++ website/docs/ios/api/plugin-configuration.md | 33 +++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/website/docs/android/api/plugin-configuration.md b/website/docs/android/api/plugin-configuration.md index 10fe0bd1..79582758 100644 --- a/website/docs/android/api/plugin-configuration.md +++ b/website/docs/android/api/plugin-configuration.md @@ -72,6 +72,43 @@ Array of widget configurations for Home Screen widgets. Each widget will be avai - `intervalMinutes`: Update interval in minutes (default: `15`, minimum 15 per WorkManager) - `refresh`: Show a native refresh button (default: `false`) +### Localizing `displayName` and `description` + +Use a locale map when the widget picker label should be translated: + +```json +{ + "android": { + "widgets": [ + { + "id": "weather", + "displayName": { + "en": "Weather", + "pl": "Pogoda", + "zh-Hans": "天气" + }, + "description": { + "en": "Current weather conditions", + "pl": "Aktualne warunki pogodowe", + "zh-Hans": "当前天气状况" + }, + "targetCellWidth": 2, + "targetCellHeight": 2 + } + ] + } +} +``` + +Use BCP-47-style locale tags such as `en`, `en-US`, `pt-BR`, or `zh-Hans`. + +Fallback behavior: + +- Voltra first tries the device locale. +- If there is no exact match, it falls back to the language-only match. +- If there is still no match, it prefers an English locale such as `en` or `en-US`. +- If no English entry exists, it uses the first configured locale. + ## Widget Sizing ### Grid Cells vs Density-Independent Pixels (dp) diff --git a/website/docs/ios/api/plugin-configuration.md b/website/docs/ios/api/plugin-configuration.md index 42431ee4..0aa22973 100644 --- a/website/docs/ios/api/plugin-configuration.md +++ b/website/docs/ios/api/plugin-configuration.md @@ -126,3 +126,36 @@ Array of widget configurations for Home Screen widgets. Each widget will be avai ] } ``` + +### Localizing `displayName` and `description` + +Use a locale map when the widget gallery label should be translated: + +```json +{ + "widgets": [ + { + "id": "weather", + "displayName": { + "en": "Weather", + "pl": "Pogoda", + "zh-Hans": "天气" + }, + "description": { + "en": "Current weather conditions", + "pl": "Aktualne warunki pogodowe", + "zh-Hans": "当前天气状况" + } + } + ] +} +``` + +Use BCP-47-style locale tags such as `en`, `en-US`, `pt-BR`, or `zh-Hans`. + +Fallback behavior: + +- Voltra first tries the device locale. +- If there is no exact match, it falls back to the language-only match. +- If there is still no match, it prefers an English locale such as `en` or `en-US`. +- If no English entry exists, it uses the first configured locale. From 9cf4ab77f594b29e22652fa78755c776fa1bff5b Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 5 May 2026 08:32:21 +0200 Subject: [PATCH 6/6] fix: missing helpers --- .../src/ios-widget/files/swift.node.test.ts | 22 ++++++ .../expo-plugin/src/ios-widget/files/swift.ts | 70 ++++++++++++++++++- .../java/voltra/widget/VoltraWidgetManager.kt | 9 ++- 3 files changed, 94 insertions(+), 7 deletions(-) create mode 100644 packages/expo-plugin/src/ios-widget/files/swift.node.test.ts diff --git a/packages/expo-plugin/src/ios-widget/files/swift.node.test.ts b/packages/expo-plugin/src/ios-widget/files/swift.node.test.ts new file mode 100644 index 00000000..8fa35bd7 --- /dev/null +++ b/packages/expo-plugin/src/ios-widget/files/swift.node.test.ts @@ -0,0 +1,22 @@ +import { __test__ } from './swift' + +describe('generateInitialStatesSwift', () => { + it('embeds the locale helper into generated initial states Swift', () => { + const swift = __test__.generateInitialStatesSwift( + new Map([ + [ + 'weather', + new Map([ + ['en-US', '{"ok":true}'], + ['pl', '{"ok":false}'], + ]), + ], + ]) + ) + + expect(swift).toContain('private enum VoltraGeneratedInitialStateLocale') + expect(swift).toContain('VoltraGeneratedInitialStateLocale.preferredLanguageTags()') + expect(swift).toContain('VoltraGeneratedInitialStateLocale.pickJson(from: perLocale, preferredLanguages: tags)') + expect(swift).not.toContain('VoltraInitialStateLocale.preferredLanguageTags()') + }) +}) diff --git a/packages/expo-plugin/src/ios-widget/files/swift.ts b/packages/expo-plugin/src/ios-widget/files/swift.ts index c3b29ce5..cccfad54 100644 --- a/packages/expo-plugin/src/ios-widget/files/swift.ts +++ b/packages/expo-plugin/src/ios-widget/files/swift.ts @@ -63,6 +63,66 @@ export async function generateSwiftFiles(options: GenerateSwiftFilesOptions): Pr logger.info(`Generated VoltraWidgetBundle.swift with ${widgets?.length ?? 0} home screen widgets`) } +const GENERATED_INITIAL_STATE_LOCALE_HELPER = dedent` + private enum VoltraGeneratedInitialStateLocale { + static func pickJson(from perLocale: [String: String], preferredLanguages: [String]) -> String? { + let entries = perLocale.filter { !$0.value.isEmpty } + if entries.isEmpty { + return nil + } + + func normalize(_ tag: String) -> String { + tag.trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "_", with: "-") + } + + var byNorm: [String: String] = [:] + for (k, v) in entries { + byNorm[normalize(k)] = v + } + + for pref in preferredLanguages { + let n = normalize(pref) + if let direct = byNorm[n] { + return direct + } + let lang = n.split(separator: "-").first.map(String.init) ?? n + for (key, val) in entries { + let kn = normalize(key) + let keyLang = kn.split(separator: "-").first.map(String.init) ?? kn + if keyLang == lang { + return val + } + } + } + + if let en = byNorm["en"] { + return en + } + if let englishFamily = entries.keys.sorted().first(where: { + let normalized = normalize($0) + return normalized == "en" || normalized.hasPrefix("en-") + }) { + return entries[englishFamily] + } + if let def = byNorm["__default"] { + return def + } + + let sorted = entries.keys.sorted() + guard let firstKey = sorted.first else { + return nil + } + return entries[firstKey] + } + + static func preferredLanguageTags() -> [String] { + Locale.preferredLanguages + } + } +` + // ============================================================================ // Widget gallery localization: *.lproj/VoltraWidgets.strings + LocalizedStringResource // @@ -339,6 +399,8 @@ function generateInitialStatesSwift(prerenderedStates: PrerenderedWidgetStates): import Foundation + ${GENERATED_INITIAL_STATE_LOCALE_HELPER} + public enum VoltraWidgetInitialStates { private static let bundledLocalizedStates: [String: [String: String]] = [ ${widgetEntries} @@ -348,8 +410,8 @@ function generateInitialStatesSwift(prerenderedStates: PrerenderedWidgetStates): /// Returns nil if no initial state was configured for the widget. public static func getInitialState(for widgetId: String) -> Data? { guard let perLocale = bundledLocalizedStates[widgetId] else { return nil } - let tags = VoltraInitialStateLocale.preferredLanguageTags() - guard let jsonString = VoltraInitialStateLocale.pickJson(from: perLocale, preferredLanguages: tags) else { + let tags = VoltraGeneratedInitialStateLocale.preferredLanguageTags() + guard let jsonString = VoltraGeneratedInitialStateLocale.pickJson(from: perLocale, preferredLanguages: tags) else { return nil } return jsonString.data(using: .utf8) @@ -404,3 +466,7 @@ function getSwiftRawStringDelimiter(str: string): string { const maxHashes = Math.max(...matches.map((m) => m.length - 1)) // -1 to exclude the '"' return '#'.repeat(maxHashes + 1) } + +export const __test__ = { + generateInitialStatesSwift, +} diff --git a/packages/voltra/android/src/main/java/voltra/widget/VoltraWidgetManager.kt b/packages/voltra/android/src/main/java/voltra/widget/VoltraWidgetManager.kt index 02b23740..0626c748 100644 --- a/packages/voltra/android/src/main/java/voltra/widget/VoltraWidgetManager.kt +++ b/packages/voltra/android/src/main/java/voltra/widget/VoltraWidgetManager.kt @@ -126,18 +126,16 @@ class VoltraWidgetManager( return picked.toString() } - private fun preferredLanguageTags(res: Resources): List { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + private fun preferredLanguageTags(res: Resources): List = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { val locales = res.configuration.locales (0 until locales.size()).map { locales[it].toLanguageTag() } } else { @Suppress("DEPRECATION") listOf(res.configuration.locale.toLanguageTag()) } - } - private fun normalizeLocaleTag(tag: String): String = - tag.trim().lowercase().replace('_', '-') + private fun normalizeLocaleTag(tag: String): String = tag.trim().lowercase().replace('_', '-') /** Mirrors `packages/expo-plugin/src/utils/localePick.ts` */ private fun pickLocalizedPayload( @@ -149,6 +147,7 @@ class VoltraWidgetManager( return null } val preferred = preferredLanguageTags(res) + fun keyNorm(k: String) = normalizeLocaleTag(k) val byNorm = keys.associateBy { keyNorm(it) }