From c2a1ced2c72517e202acf3f5cfc6896d8059d1ac Mon Sep 17 00:00:00 2001 From: Yauheni Pasiukevich Date: Thu, 30 Apr 2026 19:35:41 +0200 Subject: [PATCH] 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') {