voltra 2.3.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -59,3 +59,93 @@ describe('generateWidgetBundleSwift', () => {
59
59
  assert.ok(!swift.includes('Voltra_Widget_streak'))
60
60
  })
61
61
  })
62
+
63
+ describe('localisation (ADR 0009)', () => {
64
+ const configurable: DetectedIOSWidget = {
65
+ id: 'weather',
66
+ entry: './widgets/weather.tsx',
67
+ displayName: { en: 'Weather', pl: 'Pogoda' },
68
+ description: 'Forecast',
69
+ supportedFamilies: ['systemSmall'],
70
+ clientRendered: true,
71
+ clientSourcePath: '/tmp/weather.tsx',
72
+ appIntent: {
73
+ parameters: [
74
+ {
75
+ name: 'units',
76
+ title: { en: 'Units', pl: 'Jednostki' },
77
+ default: 'imperial',
78
+ options: [
79
+ { value: 'metric', title: { en: 'Metric', pl: 'Metryczne' } },
80
+ { value: 'imperial', title: 'Imperial' },
81
+ ],
82
+ },
83
+ { name: 'city', title: 'City', default: 'Warsaw' },
84
+ ],
85
+ },
86
+ }
87
+
88
+ test('declares the app and widget languages, plus en', () => {
89
+ assert.deepEqual(__test__.resolveExtensionLocalizations(['de'], [configurable]), ['de', 'en', 'pl'])
90
+ assert.deepEqual(__test__.resolveExtensionLocalizations([], []), [])
91
+ })
92
+
93
+ test('uses a non-English development language for the declared set and the string fallback', async () => {
94
+ assert.deepEqual(__test__.resolveExtensionLocalizations(['fr'], [], 'de'), ['de', 'fr'])
95
+
96
+ const fs = await import('node:fs')
97
+ const os = await import('node:os')
98
+ const path = await import('node:path')
99
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-devregion-'))
100
+ try {
101
+ await __test__.generateLocalizableStrings(root, path.join(root, 'Ext'), ['fr'], [configurable], 'pl')
102
+ const french = fs.readFileSync(path.join(root, 'Ext', 'fr.lproj', 'Localizable.strings'), 'utf8')
103
+ assert.ok(french.includes('"voltra_widget_weather_param_units_title" = "Jednostki";'))
104
+ } finally {
105
+ fs.rmSync(root, { recursive: true, force: true })
106
+ }
107
+ })
108
+
109
+ test('keys locale-mapped sheet titles and emits an AppEnum for static options', () => {
110
+ const swift = __test__.generateWidgetBundleSwift([configurable])
111
+
112
+ assert.ok(swift.includes('static var title: LocalizedStringResource = "Configure Weather"'))
113
+ assert.ok(swift.includes('enum VoltraWidget_weather_units_Option: String, AppEnum {'))
114
+ assert.ok(swift.includes('.option0: "voltra_widget_weather_param_units_option_metric",'))
115
+ assert.ok(swift.includes('.option1: "Imperial",'))
116
+ assert.ok(
117
+ swift.includes(
118
+ '@Parameter(title: "voltra_widget_weather_param_units_title", default: .option1)\n var units: VoltraWidget_weather_units_Option'
119
+ )
120
+ )
121
+ assert.ok(swift.includes('@Parameter(title: "City", default: "Warsaw")\n var city: String'))
122
+ assert.ok(swift.includes('"units": configuration.units.rawValue'))
123
+ assert.ok(swift.includes('configuration: ["units": "imperial", "city": "Warsaw"]'))
124
+ })
125
+
126
+ test('writes Localizable.strings with fallback for every declared language', async () => {
127
+ const fs = await import('node:fs')
128
+ const os = await import('node:os')
129
+ const path = await import('node:path')
130
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-l10n-'))
131
+ try {
132
+ const result = await __test__.generateLocalizableStrings(
133
+ root,
134
+ path.join(root, 'Ext'),
135
+ ['en', 'fr', 'pl'],
136
+ [configurable]
137
+ )
138
+ assert.deepEqual(result.files.sort(), [
139
+ 'Ext/en.lproj/Localizable.strings',
140
+ 'Ext/fr.lproj/Localizable.strings',
141
+ 'Ext/pl.lproj/Localizable.strings',
142
+ ])
143
+ const polish = fs.readFileSync(path.join(root, 'Ext', 'pl.lproj', 'Localizable.strings'), 'utf8')
144
+ assert.ok(polish.includes('"voltra_widget_weather_param_units_title" = "Jednostki";'))
145
+ const french = fs.readFileSync(path.join(root, 'Ext', 'fr.lproj', 'Localizable.strings'), 'utf8')
146
+ assert.ok(french.includes('"voltra_widget_weather_param_units_title" = "Units";'))
147
+ } finally {
148
+ fs.rmSync(root, { recursive: true, force: true })
149
+ }
150
+ })
151
+ })
@@ -29,6 +29,7 @@ const FONT_EXTENSIONS = new Set(['.ttf', '.otf', '.woff', '.woff2'])
29
29
  const MAX_IMAGE_SIZE_BYTES = 4096
30
30
  const DEFAULT_INITIAL_STATE_LOCALE = '__default'
31
31
  const VOLTRA_WIDGET_STRINGS_BASENAME = 'VoltraWidgets.strings'
32
+ const VOLTRA_LOCALIZABLE_STRINGS_BASENAME = 'Localizable.strings'
32
33
  const IOS_DYNAMIC_WIDGET_MANIFEST_PATH = path.join('.voltra', 'manifest.ios.json')
33
34
 
34
35
  const IOS_WIDGET_FAMILY_MAP: Record<IOSWidgetFamily, string> = {
@@ -123,6 +124,10 @@ interface MainAppMetadata {
123
124
  shortVersionString: string
124
125
  buildNumber: string
125
126
  urlTypes?: Array<{ CFBundleURLSchemes: string[] }>
127
+ /** The app's own `CFBundleLocalizations`, mirrored into the extension (ADR 0009 §4). */
128
+ localizations: string[]
129
+ /** The app's `CFBundleDevelopmentRegion` when it is a literal tag, otherwise `en`. */
130
+ developmentRegion: string
126
131
  }
127
132
 
128
133
  type WidgetVariants = Record<string, unknown>
@@ -226,6 +231,15 @@ export async function generateIOSFiles(options: GenerateIOSFilesOptions): Promis
226
231
  const localizedStringResults = await generateLocalizedWidgetStrings(projectRoot, targetPath, detectedWidgets)
227
232
  mergeResult(localizedStringResults, changes, warnings, generatedFiles)
228
233
 
234
+ const localizableStringResults = await generateLocalizableStrings(
235
+ projectRoot,
236
+ targetPath,
237
+ resolveExtensionLocalizations(mainAppMetadata.localizations, detectedWidgets, mainAppMetadata.developmentRegion),
238
+ detectedWidgets,
239
+ mainAppMetadata.developmentRegion
240
+ )
241
+ mergeResult(localizableStringResults, changes, warnings, generatedFiles)
242
+
229
243
  return {
230
244
  changes,
231
245
  files: [...generatedFiles].sort(),
@@ -245,6 +259,11 @@ async function generateInfoPlistFile(
245
259
  voltraVersion: string
246
260
  ): Promise<GeneratedFileResult> {
247
261
  const plistPath = path.join(targetPath, 'Info.plist')
262
+ const extensionLocalizations = resolveExtensionLocalizations(
263
+ mainAppMetadata.localizations,
264
+ widgets,
265
+ mainAppMetadata.developmentRegion
266
+ )
248
267
  const fontNames = ios.fonts.map((fontPath) => path.basename(fontPath)).sort()
249
268
  const serverWidgets = widgets.filter((widget) => widget.serverUpdate)
250
269
  const hasClientRenderedWidget = widgets.some((widget) => widget.clientRendered)
@@ -267,6 +286,9 @@ async function generateInfoPlistFile(
267
286
  CFBundleDisplayName: targetName,
268
287
  CFBundleExecutable: '$(EXECUTABLE_NAME)',
269
288
  CFBundleIdentifier: '$(PRODUCT_BUNDLE_IDENTIFIER)',
289
+ // Mirrors the app's languages so Locale.current and \.locale in the extension resolve like
290
+ // they do in the app (ADR 0009 §4).
291
+ CFBundleLocalizations: extensionLocalizations.length > 0 ? extensionLocalizations : undefined,
270
292
  CFBundleInfoDictionaryVersion: '6.0',
271
293
  CFBundleName: '$(PRODUCT_NAME)',
272
294
  CFBundlePackageType: '$(PRODUCT_BUNDLE_PACKAGE_TYPE)',
@@ -445,6 +467,149 @@ async function generateLocalizedWidgetStrings(
445
467
  }
446
468
  }
447
469
 
470
+ const LOCALE_TAG_PATTERN = /^[a-zA-Z][a-zA-Z0-9]*([_-][a-zA-Z0-9]+)*$/
471
+
472
+ /**
473
+ * The languages the widget extension declares (ADR 0009 §4): the app's `CFBundleLocalizations` and
474
+ * every locale a widget's locale maps use, plus the app's development language, which iOS falls back
475
+ * to when none of the user's languages match. Mirrors `resolveExtensionLocalizations` in the Expo
476
+ * plugin.
477
+ */
478
+ function resolveExtensionLocalizations(
479
+ appLocalizations: string[],
480
+ widgets: NormalizedIOSWidgetConfig[],
481
+ developmentRegion = 'en'
482
+ ): string[] {
483
+ const candidates = [...appLocalizations]
484
+ for (const widget of widgets) {
485
+ for (const label of widgetLabels(widget)) {
486
+ if (label !== undefined && isWidgetLocalizedMap(label)) {
487
+ candidates.push(...Object.keys(label).filter((locale) => label[locale]?.trim()))
488
+ }
489
+ }
490
+ }
491
+
492
+ const byNormalized = new Map<string, string>()
493
+ for (const candidate of candidates) {
494
+ const trimmed = candidate.trim()
495
+ if (!trimmed || trimmed === DEFAULT_INITIAL_STATE_LOCALE || !LOCALE_TAG_PATTERN.test(trimmed)) {
496
+ continue
497
+ }
498
+ const normalized = normalizeLocaleTag(trimmed)
499
+ if (!byNormalized.has(normalized)) {
500
+ byNormalized.set(normalized, trimmed)
501
+ }
502
+ }
503
+
504
+ const developmentLanguage = normalizeLocaleTag(developmentRegion).split('-')[0]
505
+ if (byNormalized.size > 0 && ![...byNormalized.keys()].some((key) => key.split('-')[0] === developmentLanguage)) {
506
+ byNormalized.set(normalizeLocaleTag(developmentRegion), developmentRegion)
507
+ }
508
+
509
+ return [...byNormalized.values()].sort((a, b) => a.localeCompare(b))
510
+ }
511
+
512
+ function widgetLabels(widget: NormalizedIOSWidgetConfig): Array<WidgetLabel | undefined> {
513
+ return [
514
+ widget.displayName,
515
+ widget.description,
516
+ ...collectWidgetConfigurationStrings(widget).map(({ label }) => label),
517
+ ]
518
+ }
519
+
520
+ /**
521
+ * The Edit Widget sheet strings (ADR 0009 §6), keyed the same way on both platforms. Mirrors
522
+ * `collectWidgetConfigurationStrings` in `@use-voltra/expo-plugin`.
523
+ */
524
+ function collectWidgetConfigurationStrings(
525
+ widget: NormalizedIOSWidgetConfig
526
+ ): Array<{ key: string; label: WidgetLabel }> {
527
+ const strings: Array<{ key: string; label: WidgetLabel }> = []
528
+ for (const parameter of widget.appIntent?.parameters ?? []) {
529
+ strings.push({ key: widgetParameterTitleKey(widget.id, parameter.name), label: parameter.title })
530
+ for (const option of parameter.options ?? []) {
531
+ strings.push({ key: widgetParameterOptionKey(widget.id, parameter.name, option.value), label: option.title })
532
+ }
533
+ }
534
+ return strings
535
+ }
536
+
537
+ function widgetParameterTitleKey(widgetId: string, parameterName: string): string {
538
+ return `voltra_widget_${widgetId}_param_${parameterName}_title`
539
+ }
540
+
541
+ function widgetParameterOptionKey(widgetId: string, parameterName: string, optionValue: string): string {
542
+ return `voltra_widget_${widgetId}_param_${parameterName}_option_${optionValue}`
543
+ }
544
+
545
+ /**
546
+ * `<locale>.lproj/Localizable.strings` for every declared language: the locale-mapped sheet strings,
547
+ * resolved with development-language and English fallback so a key never shows verbatim, and — even
548
+ * when empty —
549
+ * the resource that makes the `.lproj` folder part of the extension.
550
+ */
551
+ async function generateLocalizableStrings(
552
+ projectRoot: string,
553
+ targetPath: string,
554
+ locales: string[],
555
+ widgets: NormalizedIOSWidgetConfig[],
556
+ developmentRegion = 'en'
557
+ ): Promise<GenerateIOSFilesResult> {
558
+ const changes: ReportedChange[] = []
559
+ const generatedFiles = new Set<string>()
560
+
561
+ for (const locale of locales) {
562
+ const entries: Record<string, string> = {}
563
+ for (const widget of widgets) {
564
+ for (const { key, label } of collectWidgetConfigurationStrings(widget)) {
565
+ if (isWidgetLocalizedMap(label)) {
566
+ entries[key] = pickLocalizedLabel(label, [locale, developmentRegion])
567
+ }
568
+ }
569
+ }
570
+ const keys = Object.keys(entries).sort()
571
+ const content = `/* Voltra widget extension strings (auto-generated) */\n${keys
572
+ .map((key) => `${JSON.stringify(key)} = ${JSON.stringify(entries[key])};\n`)
573
+ .join('')}`
574
+ const result = await writeGeneratedTextFile(
575
+ projectRoot,
576
+ path.join(targetPath, `${locale}.lproj`, VOLTRA_LOCALIZABLE_STRINGS_BASENAME),
577
+ content
578
+ )
579
+ mergeSingleResult(result, changes, generatedFiles)
580
+ }
581
+
582
+ return {
583
+ changes,
584
+ files: [...generatedFiles],
585
+ warnings: [],
586
+ targetName: '',
587
+ targetPath,
588
+ }
589
+ }
590
+
591
+ function normalizeLocaleTag(tag: string): string {
592
+ return tag.trim().toLowerCase().replace(/_/g, '-')
593
+ }
594
+
595
+ /** For each wanted locale an exact tag, then its language; then English: the initial-state order. */
596
+ function pickLocalizedLabel(label: Record<string, string>, locales: string[]): string {
597
+ const entries = Object.entries(label).filter(([, value]) => value.trim())
598
+ for (const locale of locales) {
599
+ const wanted = normalizeLocaleTag(locale)
600
+ const exact = entries.find(([key]) => normalizeLocaleTag(key) === wanted)
601
+ if (exact) {
602
+ return exact[1]
603
+ }
604
+ const language = wanted.split('-')[0]
605
+ const sameLanguage = entries.find(([key]) => normalizeLocaleTag(key).split('-')[0] === language)
606
+ if (sameLanguage) {
607
+ return sameLanguage[1]
608
+ }
609
+ }
610
+ return widgetLabelEnglish(label)
611
+ }
612
+
448
613
  /**
449
614
  * The widget extension has a single Info.plist, so its version has to come from one of the app's.
450
615
  * An app whose build configurations disagree on the version would ship an extension that matches
@@ -489,11 +654,23 @@ async function readMainAppMetadata(infoPlistPath: string): Promise<MainAppMetada
489
654
  const shortVersionString = readPlistString(dict, 'CFBundleShortVersionString') ?? '1.0.0'
490
655
  const buildNumber = readPlistString(dict, 'CFBundleVersion') ?? '1'
491
656
  const urlTypes = readUrlTypes(dict)
657
+ const localizationsValue = dict.CFBundleLocalizations
658
+ const localizations = Array.isArray(localizationsValue)
659
+ ? localizationsValue.filter((value): value is string => typeof value === 'string')
660
+ : []
661
+
662
+ // RN CLI templates set `$(DEVELOPMENT_LANGUAGE)`, which only the build resolves; `en` is what that
663
+ // is in practice.
664
+ const developmentRegionValue = readPlistString(dict, 'CFBundleDevelopmentRegion')?.trim()
665
+ const developmentRegion =
666
+ developmentRegionValue && LOCALE_TAG_PATTERN.test(developmentRegionValue) ? developmentRegionValue : 'en'
492
667
 
493
668
  return {
494
669
  shortVersionString,
495
670
  buildNumber,
496
671
  urlTypes: urlTypes.length > 0 ? urlTypes : undefined,
672
+ localizations,
673
+ developmentRegion,
497
674
  }
498
675
  }
499
676
 
@@ -718,20 +895,34 @@ function generateClientAppIntentWidgetCode(
718
895
  const intentTitle = `Configure ${widgetLabelEnglish(widget.displayName)}`
719
896
  const displayNameExpr = createSwiftLabelExpression(widget.id, 'displayName', widget.displayName)
720
897
  const descriptionExpr = createSwiftLabelExpression(widget.id, 'description', widget.description)
898
+ const optionEnums = widget.appIntent.parameters
899
+ .filter(hasParameterOptions)
900
+ .map((parameter) => generateParameterOptionEnum(widget.id, parameter))
721
901
  const parameterDeclarations = widget.appIntent.parameters
722
902
  .map(
723
903
  (parameter) =>
724
- ` @Parameter(title: ${JSON.stringify(parameter.title)}, default: ${swiftDefaultValue(parameter)})\n var ${
725
- parameter.name
726
- }: String`
904
+ ` @Parameter(title: ${sheetStringLiteral(
905
+ parameter.title,
906
+ widgetParameterTitleKey(widget.id, parameter.name)
907
+ )}, default: ${parameterDefaultExpression(parameter)})\n var ${parameter.name}: ${parameterSwiftType(
908
+ widget.id,
909
+ parameter
910
+ )}`
727
911
  )
728
912
  .join('\n\n')
729
- const initializerParameters = widget.appIntent.parameters.map((parameter) => `${parameter.name}: String`).join(', ')
913
+ const initializerParameters = widget.appIntent.parameters
914
+ .map((parameter) => `${parameter.name}: ${parameterSwiftType(widget.id, parameter)}`)
915
+ .join(', ')
730
916
  const initializerAssignments = widget.appIntent.parameters
731
917
  .map((parameter) => ` self.${parameter.name} = ${parameter.name}`)
732
918
  .join('\n')
733
919
  const configuredDictionary = createSwiftDictionaryLiteral(
734
- widget.appIntent.parameters.map((parameter) => `${JSON.stringify(parameter.name)}: configuration.${parameter.name}`)
920
+ widget.appIntent.parameters.map(
921
+ (parameter) =>
922
+ `${JSON.stringify(parameter.name)}: configuration.${parameter.name}${
923
+ hasParameterOptions(parameter) ? '.rawValue' : ''
924
+ }`
925
+ )
735
926
  )
736
927
  const defaultDictionary = createSwiftDictionaryLiteral(
737
928
  widget.appIntent.parameters.map((parameter) => `${JSON.stringify(parameter.name)}: ${swiftDefaultValue(parameter)}`)
@@ -754,6 +945,7 @@ function generateClientAppIntentWidgetCode(
754
945
  ]
755
946
 
756
947
  return [
948
+ ...optionEnums.flatMap((optionEnum) => [optionEnum, '']),
757
949
  `@available(iOS 17.0, *)`,
758
950
  `struct ${intentName}: WidgetConfigurationIntent {`,
759
951
  ` static var title: LocalizedStringResource = ${JSON.stringify(intentTitle)}`,
@@ -828,10 +1020,69 @@ function createSwiftLabelExpression(
828
1020
  )}), table: ${JSON.stringify('VoltraWidgets')}))`
829
1021
  }
830
1022
 
1023
+ /** The raw default, as env.configuration sees it before the user configures anything. */
831
1024
  function swiftDefaultValue(parameter: IOSWidgetAppIntentParameter): string {
1025
+ if (hasParameterOptions(parameter)) {
1026
+ return JSON.stringify(parameter.options![defaultOptionIndex(parameter)]!.value)
1027
+ }
832
1028
  return JSON.stringify(parameter.default ?? '')
833
1029
  }
834
1030
 
1031
+ function hasParameterOptions(parameter: IOSWidgetAppIntentParameter): boolean {
1032
+ return (parameter.options?.length ?? 0) > 0
1033
+ }
1034
+
1035
+ function defaultOptionIndex(parameter: IOSWidgetAppIntentParameter): number {
1036
+ const index = (parameter.options ?? []).findIndex((option) => option.value === parameter.default)
1037
+ return index >= 0 ? index : 0
1038
+ }
1039
+
1040
+ function parameterOptionEnumName(widgetId: string, parameterName: string): string {
1041
+ return `VoltraWidget_${widgetId}_${parameterName}_Option`
1042
+ }
1043
+
1044
+ function parameterSwiftType(widgetId: string, parameter: IOSWidgetAppIntentParameter): string {
1045
+ return hasParameterOptions(parameter) ? parameterOptionEnumName(widgetId, parameter.name) : 'String'
1046
+ }
1047
+
1048
+ function parameterDefaultExpression(parameter: IOSWidgetAppIntentParameter): string {
1049
+ return hasParameterOptions(parameter) ? `.option${defaultOptionIndex(parameter)}` : swiftDefaultValue(parameter)
1050
+ }
1051
+
1052
+ /**
1053
+ * A title on the Edit Widget sheet (ADR 0009 §6): a plain string is compiled in as-is; a locale map
1054
+ * becomes a bare string-literal key into the extension's `Localizable` table.
1055
+ */
1056
+ function sheetStringLiteral(label: WidgetLabel, key: string): string {
1057
+ return JSON.stringify(isWidgetLocalizedMap(label) ? key : label)
1058
+ }
1059
+
1060
+ /** Static options become an AppEnum so the sheet shows a picker of (localised) labels. */
1061
+ function generateParameterOptionEnum(widgetId: string, parameter: IOSWidgetAppIntentParameter): string {
1062
+ const enumName = parameterOptionEnumName(widgetId, parameter.name)
1063
+ const options = parameter.options ?? []
1064
+ return [
1065
+ '@available(iOS 17.0, *)',
1066
+ `enum ${enumName}: String, AppEnum {`,
1067
+ ...options.map((option, index) => ` case option${index} = ${JSON.stringify(option.value)}`),
1068
+ '',
1069
+ ` static var typeDisplayRepresentation: TypeDisplayRepresentation = ${sheetStringLiteral(
1070
+ parameter.title,
1071
+ widgetParameterTitleKey(widgetId, parameter.name)
1072
+ )}`,
1073
+ ` static var caseDisplayRepresentations: [${enumName}: DisplayRepresentation] = [`,
1074
+ ...options.map(
1075
+ (option, index) =>
1076
+ ` .option${index}: ${sheetStringLiteral(
1077
+ option.title,
1078
+ widgetParameterOptionKey(widgetId, parameter.name, option.value)
1079
+ )},`
1080
+ ),
1081
+ ' ]',
1082
+ '}',
1083
+ ].join('\n')
1084
+ }
1085
+
835
1086
  function createSwiftDictionaryLiteral(entries: string[]): string {
836
1087
  return entries.length > 0 ? `[${entries.join(', ')}]` : '[:]'
837
1088
  }
@@ -975,6 +1226,10 @@ async function prerenderClientRenderedWidgets(
975
1226
  widgetFamily: 'systemMedium',
976
1227
  colorScheme: 'light',
977
1228
  locale: 'en-US',
1229
+ preferredLanguages: ['en-US'],
1230
+ layoutDirection: 'ltr',
1231
+ hourCycle: 'h12',
1232
+ timeZone: 'UTC',
978
1233
  widgetRenderingMode: 'fullColor',
979
1234
  showsWidgetContainerBackground: true,
980
1235
  configuration: undefined,
@@ -1328,5 +1583,7 @@ function mergeSingleResult(result: GeneratedFileResult, changes: ReportedChange[
1328
1583
  }
1329
1584
 
1330
1585
  export const __test__ = {
1586
+ resolveExtensionLocalizations,
1587
+ generateLocalizableStrings,
1331
1588
  generateWidgetBundleSwift,
1332
1589
  }