voltra 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/build/cjs/config/normalize.js +93 -0
  2. package/build/cjs/config/normalize.js.map +1 -1
  3. package/build/cjs/index.js +5 -2
  4. package/build/cjs/index.js.map +1 -1
  5. package/build/cjs/platforms/android/apply.js +11 -2
  6. package/build/cjs/platforms/android/apply.js.map +1 -1
  7. package/build/cjs/platforms/android/generated.js +152 -130
  8. package/build/cjs/platforms/android/generated.js.map +1 -1
  9. package/build/cjs/platforms/android/gradle.js +120 -0
  10. package/build/cjs/platforms/android/gradle.js.map +1 -0
  11. package/build/cjs/platforms/ios/generated.js +246 -142
  12. package/build/cjs/platforms/ios/generated.js.map +1 -1
  13. package/build/cjs/platforms/ios/xcodeTarget.js +68 -0
  14. package/build/cjs/platforms/ios/xcodeTarget.js.map +1 -1
  15. package/build/cjs/platforms/shared/widgetModule.js +151 -0
  16. package/build/cjs/platforms/shared/widgetModule.js.map +1 -0
  17. package/build/esm/config/normalize.js +93 -0
  18. package/build/esm/config/normalize.js.map +1 -1
  19. package/build/esm/index.js +1 -0
  20. package/build/esm/index.js.map +1 -1
  21. package/build/esm/platforms/android/apply.js +11 -2
  22. package/build/esm/platforms/android/apply.js.map +1 -1
  23. package/build/esm/platforms/android/generated.js +152 -97
  24. package/build/esm/platforms/android/generated.js.map +1 -1
  25. package/build/esm/platforms/android/gradle.js +112 -0
  26. package/build/esm/platforms/android/gradle.js.map +1 -0
  27. package/build/esm/platforms/ios/generated.js +246 -109
  28. package/build/esm/platforms/ios/generated.js.map +1 -1
  29. package/build/esm/platforms/ios/xcodeTarget.js +69 -1
  30. package/build/esm/platforms/ios/xcodeTarget.js.map +1 -1
  31. package/build/esm/platforms/shared/widgetModule.js +111 -0
  32. package/build/esm/platforms/shared/widgetModule.js.map +1 -0
  33. package/build/types/config/normalize.d.ts.map +1 -1
  34. package/build/types/config/types.d.ts +32 -0
  35. package/build/types/config/types.d.ts.map +1 -1
  36. package/build/types/index.d.ts +3 -1
  37. package/build/types/index.d.ts.map +1 -1
  38. package/build/types/platforms/android/apply.d.ts.map +1 -1
  39. package/build/types/platforms/android/generated.d.ts.map +1 -1
  40. package/build/types/platforms/android/gradle.d.ts +18 -0
  41. package/build/types/platforms/android/gradle.d.ts.map +1 -0
  42. package/build/types/platforms/ios/generated.d.ts.map +1 -1
  43. package/build/types/platforms/ios/xcodeTarget.d.ts.map +1 -1
  44. package/build/types/platforms/shared/widgetModule.d.ts +9 -0
  45. package/build/types/platforms/shared/widgetModule.d.ts.map +1 -0
  46. package/package.json +2 -1
  47. package/src/apply/index.ts +309 -0
  48. package/src/apply/preflight.ts +172 -0
  49. package/src/bin.ts +14 -0
  50. package/src/commands/apply.ts +114 -0
  51. package/src/config/defaults.ts +34 -0
  52. package/src/config/load.ts +100 -0
  53. package/src/config/normalize.ts +562 -0
  54. package/src/config/types.ts +317 -0
  55. package/src/cosmiconfig.d.ts +21 -0
  56. package/src/dependencies/platformPackages.ts +75 -0
  57. package/src/discovery/android.ts +233 -0
  58. package/src/discovery/ios.ts +579 -0
  59. package/src/fs/path.ts +25 -0
  60. package/src/fs/readWrite.ts +102 -0
  61. package/src/git/status.ts +205 -0
  62. package/src/index.ts +204 -0
  63. package/src/platforms/android/apply.ts +106 -0
  64. package/src/platforms/android/generated.ts +1284 -0
  65. package/src/platforms/android/gradle.ts +155 -0
  66. package/src/platforms/android/manifest.ts +379 -0
  67. package/src/platforms/ios/apply.ts +139 -0
  68. package/src/platforms/ios/deploymentTarget.ts +47 -0
  69. package/src/platforms/ios/entitlements.ts +146 -0
  70. package/src/platforms/ios/generated.ts +1233 -0
  71. package/src/platforms/ios/mainAppEntitlements.ts +11 -0
  72. package/src/platforms/ios/plist.ts +321 -0
  73. package/src/platforms/ios/podfile.ts +255 -0
  74. package/src/platforms/ios/targetName.ts +11 -0
  75. package/src/platforms/ios/xcode.ts +181 -0
  76. package/src/platforms/ios/xcodeTarget.ts +899 -0
  77. package/src/platforms/shared/widgetModule.ts +153 -0
  78. package/src/reporting/clack.ts +67 -0
  79. package/src/reporting/summary.ts +116 -0
  80. package/src/state/diff.ts +21 -0
  81. package/src/state/files.ts +52 -0
  82. package/src/state/load.ts +66 -0
  83. package/src/state/save.ts +31 -0
  84. package/src/xml2js.d.ts +23 -0
@@ -0,0 +1,1233 @@
1
+ import fs from 'node:fs'
2
+ import fsPromises from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import { createRequire } from 'node:module'
5
+
6
+ import { requirePlatformPackage } from '../../dependencies/platformPackages'
7
+ import { ensureDirectory, pathExists, readTextFile, writeTextFile } from '../../fs/readWrite'
8
+ import { normalizeRelativePath, toRelativePath } from '../../fs/path'
9
+ import { VoltraCliError } from '../../reporting/summary'
10
+ import { DYNAMIC_WIDGET_BUILD_INFO, evaluateWidgetModuleExports } from '../shared/widgetModule'
11
+ import { buildPlistXml, parsePlistFile } from './plist'
12
+ import { resolveIOSWidgetTargetName } from './targetName'
13
+
14
+ import type { IOSProjectDiscovery } from '../../discovery/ios'
15
+ import type {
16
+ IOSWidgetAppIntentParameter,
17
+ IOSWidgetFamily,
18
+ NormalizedIOSWidgetConfig,
19
+ NormalizedVoltraIOSConfig,
20
+ WidgetLabel,
21
+ } from '../../config/types'
22
+ import type { ReportedChange } from '../../reporting/summary'
23
+
24
+ const VALID_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg'])
25
+ const FONT_EXTENSIONS = new Set(['.ttf', '.otf', '.woff', '.woff2'])
26
+ const MAX_IMAGE_SIZE_BYTES = 4096
27
+ const DEFAULT_INITIAL_STATE_LOCALE = '__default'
28
+ const VOLTRA_WIDGET_STRINGS_BASENAME = 'VoltraWidgets.strings'
29
+ const IOS_DYNAMIC_WIDGET_MANIFEST_PATH = path.join('.voltra', 'manifest.ios.json')
30
+
31
+ const IOS_WIDGET_FAMILY_MAP: Record<IOSWidgetFamily, string> = {
32
+ systemSmall: '.systemSmall',
33
+ systemMedium: '.systemMedium',
34
+ systemLarge: '.systemLarge',
35
+ systemExtraLarge: '.systemExtraLarge',
36
+ accessoryCircular: '.accessoryCircular',
37
+ accessoryRectangular: '.accessoryRectangular',
38
+ accessoryInline: '.accessoryInline',
39
+ }
40
+
41
+ const GENERATED_INITIAL_STATE_LOCALE_HELPER = `private enum VoltraGeneratedInitialStateLocale {
42
+ static func pickJson(from perLocale: [String: String], preferredLanguages: [String]) -> String? {
43
+ let entries = perLocale.filter { !$0.value.isEmpty }
44
+ if entries.isEmpty {
45
+ return nil
46
+ }
47
+
48
+ func normalize(_ tag: String) -> String {
49
+ tag.trimmingCharacters(in: .whitespacesAndNewlines)
50
+ .lowercased()
51
+ .replacingOccurrences(of: "_", with: "-")
52
+ }
53
+
54
+ var byNorm: [String: String] = [:]
55
+ for (k, v) in entries {
56
+ byNorm[normalize(k)] = v
57
+ }
58
+
59
+ for pref in preferredLanguages {
60
+ let n = normalize(pref)
61
+ if let direct = byNorm[n] {
62
+ return direct
63
+ }
64
+ let lang = n.split(separator: "-").first.map(String.init) ?? n
65
+ for (key, val) in entries {
66
+ let kn = normalize(key)
67
+ let keyLang = kn.split(separator: "-").first.map(String.init) ?? kn
68
+ if keyLang == lang {
69
+ return val
70
+ }
71
+ }
72
+ }
73
+
74
+ if let en = byNorm["en"] {
75
+ return en
76
+ }
77
+ if let englishFamily = entries.keys.sorted().first(where: {
78
+ let normalized = normalize($0)
79
+ return normalized == "en" || normalized.hasPrefix("en-")
80
+ }) {
81
+ return entries[englishFamily]
82
+ }
83
+ if let def = byNorm["__default"] {
84
+ return def
85
+ }
86
+
87
+ let sorted = entries.keys.sorted()
88
+ guard let firstKey = sorted.first else {
89
+ return nil
90
+ }
91
+ return entries[firstKey]
92
+ }
93
+
94
+ static func preferredLanguageTags() -> [String] {
95
+ Locale.preferredLanguages
96
+ }
97
+ }
98
+ `
99
+
100
+ export interface GenerateIOSFilesOptions {
101
+ projectRoot: string
102
+ ios: NormalizedVoltraIOSConfig
103
+ discovery: IOSProjectDiscovery
104
+ }
105
+
106
+ export interface GenerateIOSFilesResult {
107
+ changes: ReportedChange[]
108
+ files: string[]
109
+ warnings: string[]
110
+ targetName: string
111
+ targetPath: string
112
+ }
113
+
114
+ interface GeneratedFileResult {
115
+ change?: ReportedChange
116
+ relativePath: string
117
+ }
118
+
119
+ interface MainAppMetadata {
120
+ shortVersionString: string
121
+ buildNumber: string
122
+ urlTypes?: Array<{ CFBundleURLSchemes: string[] }>
123
+ }
124
+
125
+ type WidgetVariants = Record<string, unknown>
126
+ type IOSWidgetRenderer = (variants: WidgetVariants) => string
127
+ type IOSDynamicWidgetRenderer = (element: unknown) => unknown
128
+ interface IOSPlatformPackage {
129
+ renderWidgetToString?: unknown
130
+ }
131
+ type PrerenderedWidgetStates = Map<string, Map<string, string>>
132
+ type DynamicWidgetManifest = {
133
+ version: 1
134
+ platform: 'ios'
135
+ widgets: Array<{ id: string; entry: string }>
136
+ }
137
+
138
+ type DetectedIOSWidget =
139
+ | (NormalizedIOSWidgetConfig & { clientRendered: false })
140
+ | (NormalizedIOSWidgetConfig & { entry: string; clientRendered: true; clientSourcePath: string })
141
+
142
+ export class IOSGeneratedFilesError extends VoltraCliError {
143
+ constructor(message: string) {
144
+ super(message, 'VOLTRA_IOS_GENERATED_FILES_FAILED')
145
+ this.name = 'IOSGeneratedFilesError'
146
+ }
147
+ }
148
+
149
+ function createGeneratedFilesError(message: string): IOSGeneratedFilesError {
150
+ return new IOSGeneratedFilesError(message)
151
+ }
152
+
153
+ export async function generateIOSFiles(options: GenerateIOSFilesOptions): Promise<GenerateIOSFilesResult> {
154
+ const { projectRoot, ios, discovery } = options
155
+ const targetName = resolveIOSWidgetTargetName(ios, discovery)
156
+ const targetPath = path.join(discovery.iosRoot, targetName)
157
+ const detectedWidgets = detectClientRenderedWidgets(projectRoot, ios.widgets)
158
+ const mainAppMetadata = await readMainAppMetadata(discovery.infoPlistPath)
159
+ const changes: ReportedChange[] = []
160
+ const warnings: string[] = []
161
+ const generatedFiles = new Set<string>()
162
+
163
+ mergeSingleResult(
164
+ await writeGeneratedTextFile(
165
+ projectRoot,
166
+ path.join(projectRoot, IOS_DYNAMIC_WIDGET_MANIFEST_PATH),
167
+ `${JSON.stringify(createDynamicWidgetsManifest(detectedWidgets), null, 2)}\n`
168
+ ),
169
+ changes,
170
+ generatedFiles
171
+ )
172
+
173
+ const infoPlistResult = await generateInfoPlistFile(
174
+ projectRoot,
175
+ targetPath,
176
+ targetName,
177
+ ios,
178
+ mainAppMetadata,
179
+ detectedWidgets
180
+ )
181
+ mergeSingleResult(infoPlistResult, changes, generatedFiles)
182
+
183
+ const entitlementsResult = await generateEntitlementsFile(projectRoot, targetPath, targetName, ios)
184
+ mergeSingleResult(entitlementsResult, changes, generatedFiles)
185
+
186
+ const assetResult = await generateAssetsCatalog(projectRoot, targetPath, ios.userImagesPath)
187
+ mergeResult(assetResult, changes, warnings, generatedFiles)
188
+
189
+ const fontsResult = await copyIOSFonts(projectRoot, targetPath, ios.fonts)
190
+ mergeResult(fontsResult, changes, warnings, generatedFiles)
191
+
192
+ const initialStatesResult = await generateInitialStatesSwift(projectRoot, detectedWidgets)
193
+ mergeSingleResult(
194
+ await writeGeneratedTextFile(
195
+ projectRoot,
196
+ path.join(targetPath, 'VoltraWidgetInitialStates.swift'),
197
+ initialStatesResult
198
+ ),
199
+ changes,
200
+ generatedFiles
201
+ )
202
+
203
+ const widgetBundleResult = await writeGeneratedTextFile(
204
+ projectRoot,
205
+ path.join(targetPath, 'VoltraWidgetBundle.swift'),
206
+ generateWidgetBundleSwift(detectedWidgets)
207
+ )
208
+ mergeSingleResult(widgetBundleResult, changes, generatedFiles)
209
+
210
+ const localizedStringResults = await generateLocalizedWidgetStrings(projectRoot, targetPath, detectedWidgets)
211
+ mergeResult(localizedStringResults, changes, warnings, generatedFiles)
212
+
213
+ return {
214
+ changes,
215
+ files: [...generatedFiles].sort(),
216
+ warnings,
217
+ targetName,
218
+ targetPath,
219
+ }
220
+ }
221
+
222
+ async function generateInfoPlistFile(
223
+ projectRoot: string,
224
+ targetPath: string,
225
+ targetName: string,
226
+ ios: NormalizedVoltraIOSConfig,
227
+ mainAppMetadata: MainAppMetadata,
228
+ widgets: DetectedIOSWidget[]
229
+ ): Promise<GeneratedFileResult> {
230
+ const plistPath = path.join(targetPath, 'Info.plist')
231
+ const fontNames = ios.fonts.map((fontPath) => path.basename(fontPath)).sort()
232
+ const serverWidgets = widgets.filter((widget) => widget.serverUpdate)
233
+ const hasClientRenderedWidget = widgets.some((widget) => widget.clientRendered)
234
+ const serverUrls = Object.fromEntries(serverWidgets.map((widget) => [widget.id, widget.serverUpdate?.url]))
235
+ const serverIntervals = Object.fromEntries(
236
+ serverWidgets.map((widget) => [widget.id, widget.serverUpdate?.intervalMinutes])
237
+ )
238
+ const serverRefresh = Object.fromEntries(
239
+ serverWidgets.map((widget) => [widget.id, widget.serverUpdate?.refresh ?? false])
240
+ )
241
+ const infoPlist = buildPlistXml(
242
+ {
243
+ CFBundleDevelopmentRegion: '$(DEVELOPMENT_LANGUAGE)',
244
+ CFBundleDisplayName: targetName,
245
+ CFBundleExecutable: '$(EXECUTABLE_NAME)',
246
+ CFBundleIdentifier: '$(PRODUCT_BUNDLE_IDENTIFIER)',
247
+ CFBundleInfoDictionaryVersion: '6.0',
248
+ CFBundleName: '$(PRODUCT_NAME)',
249
+ CFBundlePackageType: '$(PRODUCT_BUNDLE_PACKAGE_TYPE)',
250
+ CFBundleShortVersionString: mainAppMetadata.shortVersionString,
251
+ CFBundleVersion: mainAppMetadata.buildNumber,
252
+ NSExtension: {
253
+ NSExtensionPointIdentifier: 'com.apple.widgetkit-extension',
254
+ },
255
+ RCTNewArchEnabled: true,
256
+ CFBundleURLTypes: mainAppMetadata.urlTypes,
257
+ UIAppFonts: fontNames.length > 0 ? fontNames : undefined,
258
+ Voltra_AppGroupIdentifier: ios.groupIdentifier,
259
+ Voltra_KeychainGroup: ios.keychainGroup,
260
+ Voltra_WidgetServerIntervals: Object.keys(serverIntervals).length > 0 ? serverIntervals : undefined,
261
+ Voltra_WidgetServerRefresh: Object.keys(serverRefresh).length > 0 ? serverRefresh : undefined,
262
+ NSAppTransportSecurity: createWidgetAppTransportSecurity(hasClientRenderedWidget, serverWidgets.length > 0),
263
+ Voltra_WidgetServerUrls: Object.keys(serverUrls).length > 0 ? serverUrls : undefined,
264
+ },
265
+ createGeneratedFilesError
266
+ )
267
+
268
+ return writeGeneratedTextFile(projectRoot, plistPath, infoPlist)
269
+ }
270
+
271
+ function createWidgetAppTransportSecurity(
272
+ hasClientRenderedWidget: boolean,
273
+ hasServerWidgets: boolean
274
+ ): Record<string, unknown> | undefined {
275
+ if (!hasClientRenderedWidget && !hasServerWidgets) {
276
+ return undefined
277
+ }
278
+
279
+ return {
280
+ NSAllowsLocalNetworking: true,
281
+ ...(hasClientRenderedWidget
282
+ ? {
283
+ NSExceptionDomains: {
284
+ localhost: {
285
+ NSExceptionAllowsInsecureHTTPLoads: true,
286
+ NSIncludesSubdomains: true,
287
+ },
288
+ },
289
+ }
290
+ : {}),
291
+ ...(hasServerWidgets ? { NSAllowsArbitraryLoads: false } : {}),
292
+ }
293
+ }
294
+
295
+ async function generateEntitlementsFile(
296
+ projectRoot: string,
297
+ targetPath: string,
298
+ targetName: string,
299
+ ios: NormalizedVoltraIOSConfig
300
+ ): Promise<GeneratedFileResult> {
301
+ const entitlementsPath = path.join(targetPath, `${targetName}.entitlements`)
302
+ const entitlements = buildPlistXml(
303
+ {
304
+ 'com.apple.security.application-groups': ios.groupIdentifier ? [ios.groupIdentifier] : undefined,
305
+ 'keychain-access-groups': ios.keychainGroup ? [ios.keychainGroup] : undefined,
306
+ },
307
+ createGeneratedFilesError
308
+ )
309
+
310
+ return writeGeneratedTextFile(projectRoot, entitlementsPath, entitlements)
311
+ }
312
+
313
+ async function generateAssetsCatalog(
314
+ projectRoot: string,
315
+ targetPath: string,
316
+ userImagesPath: string
317
+ ): Promise<GenerateIOSFilesResult> {
318
+ const changes: ReportedChange[] = []
319
+ const warnings: string[] = []
320
+ const generatedFiles = new Set<string>()
321
+ const assetsCatalogPath = path.join(targetPath, 'Assets.xcassets')
322
+ const rootContentsResult = await writeGeneratedTextFile(
323
+ projectRoot,
324
+ path.join(assetsCatalogPath, 'Contents.json'),
325
+ `${JSON.stringify({ info: { author: 'xcode', version: 1 } }, null, 2)}\n`
326
+ )
327
+ mergeSingleResult(rootContentsResult, changes, generatedFiles)
328
+
329
+ const userImages = await collectUserImages(userImagesPath)
330
+ const seenAssetNames = new Map<string, string>()
331
+
332
+ for (const imagePath of userImages) {
333
+ const extension = path.extname(imagePath).toLowerCase()
334
+
335
+ if (!VALID_IMAGE_EXTENSIONS.has(extension)) {
336
+ continue
337
+ }
338
+
339
+ const relativeAssetPath = path.relative(userImagesPath, imagePath).slice(0, -extension.length)
340
+ const imageName = getAssetName(relativeAssetPath, seenAssetNames)
341
+ const imagesetPath = path.join(assetsCatalogPath, `${imageName}.imageset`)
342
+ const imageFileName = `${imageName}${extension}`
343
+ const imageResult = await copyGeneratedFile(projectRoot, imagePath, path.join(imagesetPath, imageFileName))
344
+ mergeSingleResult(imageResult, changes, generatedFiles)
345
+
346
+ const contentsResult = await writeGeneratedTextFile(
347
+ projectRoot,
348
+ path.join(imagesetPath, 'Contents.json'),
349
+ `${JSON.stringify(
350
+ {
351
+ images: [{ filename: imageFileName, idiom: 'universal' }],
352
+ info: { author: 'xcode', version: 1 },
353
+ },
354
+ null,
355
+ 2
356
+ )}\n`
357
+ )
358
+ mergeSingleResult(contentsResult, changes, generatedFiles)
359
+
360
+ const imageWarning = await getLargeImageWarning(imagePath, imageFileName)
361
+ if (imageWarning) {
362
+ warnings.push(imageWarning)
363
+ }
364
+ }
365
+
366
+ return {
367
+ changes,
368
+ files: [...generatedFiles],
369
+ warnings,
370
+ targetName: '',
371
+ targetPath,
372
+ }
373
+ }
374
+
375
+ async function copyIOSFonts(projectRoot: string, targetPath: string, fonts: string[]): Promise<GenerateIOSFilesResult> {
376
+ const changes: ReportedChange[] = []
377
+ const generatedFiles = new Set<string>()
378
+ const warnings: string[] = []
379
+ const fontPaths = await resolveFontPaths(projectRoot, fonts)
380
+
381
+ for (const fontPath of fontPaths) {
382
+ const result = await copyGeneratedFile(projectRoot, fontPath, path.join(targetPath, path.basename(fontPath)))
383
+ mergeSingleResult(result, changes, generatedFiles)
384
+ }
385
+
386
+ return {
387
+ changes,
388
+ files: [...generatedFiles],
389
+ warnings,
390
+ targetName: '',
391
+ targetPath,
392
+ }
393
+ }
394
+
395
+ async function generateLocalizedWidgetStrings(
396
+ projectRoot: string,
397
+ targetPath: string,
398
+ widgets: NormalizedIOSWidgetConfig[]
399
+ ): Promise<GenerateIOSFilesResult> {
400
+ const changes: ReportedChange[] = []
401
+ const generatedFiles = new Set<string>()
402
+ const byLocale = collectGalleryStringsByLocale(widgets)
403
+
404
+ for (const [locale, entries] of byLocale.entries()) {
405
+ const lprojPath = path.join(targetPath, `${locale}.lproj`)
406
+ const stringsPath = path.join(lprojPath, VOLTRA_WIDGET_STRINGS_BASENAME)
407
+ const stringsContent = formatStringsFile(entries)
408
+ const result = await writeGeneratedTextFile(projectRoot, stringsPath, stringsContent)
409
+ mergeSingleResult(result, changes, generatedFiles)
410
+ }
411
+
412
+ return {
413
+ changes,
414
+ files: [...generatedFiles],
415
+ warnings: [],
416
+ targetName: '',
417
+ targetPath,
418
+ }
419
+ }
420
+
421
+ async function readMainAppMetadata(infoPlistPath: string): Promise<MainAppMetadata> {
422
+ const dict = await parsePlistFile(
423
+ infoPlistPath,
424
+ 'main app Info.plist',
425
+ (message: string) => new IOSGeneratedFilesError(message)
426
+ )
427
+ const shortVersionString = readPlistString(dict, 'CFBundleShortVersionString') ?? '1.0.0'
428
+ const buildNumber = readPlistString(dict, 'CFBundleVersion') ?? '1'
429
+ const urlTypes = readUrlTypes(dict)
430
+
431
+ return {
432
+ shortVersionString,
433
+ buildNumber,
434
+ urlTypes: urlTypes.length > 0 ? urlTypes : undefined,
435
+ }
436
+ }
437
+
438
+ async function generateInitialStatesSwift(projectRoot: string, widgets: DetectedIOSWidget[]): Promise<string> {
439
+ const serverWidgets = widgets.filter(
440
+ (widget): widget is Extract<DetectedIOSWidget, { clientRendered: false }> => !widget.clientRendered
441
+ )
442
+ const prerenderableServerWidgets = serverWidgets.filter((widget) => widget.initialStatePath)
443
+ const serverStates = await prerenderWidgetStates(
444
+ projectRoot,
445
+ prerenderableServerWidgets,
446
+ loadIOSWidgetRenderer(projectRoot)
447
+ )
448
+ const clientStates = await prerenderClientRenderedWidgets(projectRoot, widgets)
449
+ const prerenderedStates = new Map([...serverStates, ...clientStates])
450
+
451
+ if (prerenderedStates.size === 0) {
452
+ return [
453
+ '//',
454
+ '// VoltraWidgetInitialStates.swift',
455
+ '//',
456
+ '// Auto-generated by Voltra.',
457
+ '// No widget initial states configured.',
458
+ '//',
459
+ '',
460
+ 'import Foundation',
461
+ '',
462
+ 'public enum VoltraWidgetInitialStates {',
463
+ ' public static func getInitialState(for widgetId: String) -> Data? {',
464
+ ' nil',
465
+ ' }',
466
+ '}',
467
+ '',
468
+ ].join('\n')
469
+ }
470
+ const widgetEntries = [...prerenderedStates.entries()]
471
+ .map(([widgetId, localeMap]) => {
472
+ const localeEntries = [...localeMap.entries()]
473
+ .map(([localeKey, json]) => {
474
+ const delimiter = getSwiftRawStringDelimiter(json)
475
+ return ` ${JSON.stringify(localeKey)}: ${delimiter}"${json}"${delimiter}`
476
+ })
477
+ .join(',\n')
478
+ return ` ${JSON.stringify(widgetId)}: [\n${localeEntries}\n ]`
479
+ })
480
+ .join(',\n')
481
+
482
+ return [
483
+ '//',
484
+ '// VoltraWidgetInitialStates.swift',
485
+ '//',
486
+ '// Auto-generated by Voltra.',
487
+ '// Contains pre-rendered initial states for home screen widgets.',
488
+ '//',
489
+ '',
490
+ 'import Foundation',
491
+ '',
492
+ GENERATED_INITIAL_STATE_LOCALE_HELPER,
493
+ '',
494
+ 'public enum VoltraWidgetInitialStates {',
495
+ ' private static let bundledLocalizedStates: [String: [String: String]] = [',
496
+ widgetEntries,
497
+ ' ]',
498
+ '',
499
+ ' public static func getInitialState(for widgetId: String) -> Data? {',
500
+ ' guard let perLocale = bundledLocalizedStates[widgetId] else { return nil }',
501
+ ' let tags = VoltraGeneratedInitialStateLocale.preferredLanguageTags()',
502
+ ' guard let jsonString = VoltraGeneratedInitialStateLocale.pickJson(from: perLocale, preferredLanguages: tags) else {',
503
+ ' return nil',
504
+ ' }',
505
+ ' return jsonString.data(using: .utf8)',
506
+ ' }',
507
+ '}',
508
+ '',
509
+ ].join('\n')
510
+ }
511
+
512
+ function generateWidgetBundleSwift(widgets: DetectedIOSWidget[]): string {
513
+ const needsFoundation = widgets.some(
514
+ (widget) => isWidgetLocalizedMap(widget.displayName) || isWidgetLocalizedMap(widget.description)
515
+ )
516
+ const appIntentWidgets = widgets.filter(widgetUsesAppIntent)
517
+ const imports = [
518
+ needsFoundation ? 'import Foundation' : undefined,
519
+ appIntentWidgets.length > 0 ? 'import AppIntents' : undefined,
520
+ 'import SwiftUI',
521
+ 'import WidgetKit',
522
+ 'import VoltraWidget',
523
+ ]
524
+ .filter((value): value is string => value !== undefined)
525
+ .join('\n')
526
+
527
+ if (widgets.length === 0) {
528
+ return [
529
+ '//',
530
+ '// VoltraWidgetBundle.swift',
531
+ '//',
532
+ '// Auto-generated by Voltra.',
533
+ '//',
534
+ '',
535
+ imports,
536
+ '',
537
+ '@main',
538
+ 'struct VoltraWidgetBundle: WidgetBundle {',
539
+ ' var body: some Widget {',
540
+ ' VoltraWidget()',
541
+ ' }',
542
+ '}',
543
+ '',
544
+ ].join('\n')
545
+ }
546
+
547
+ const widgetStructs = widgets.map(generateWidgetStruct).join('\n\n')
548
+ const plainWidgets = widgets.filter((widget) => !widgetUsesAppIntent(widget))
549
+ const plainInstances = plainWidgets.map((widget) => ` VoltraWidget_${widget.id}()`).join('\n')
550
+ const appIntentInstances =
551
+ appIntentWidgets.length > 0
552
+ ? ` if #available(iOS 17.0, *) {\n${appIntentWidgets
553
+ .map((widget) => ` VoltraWidget_${widget.id}()`)
554
+ .join('\n')}\n }`
555
+ : ''
556
+ const widgetInstances = [plainInstances, appIntentInstances].filter(Boolean).join('\n')
557
+
558
+ return [
559
+ '//',
560
+ '// VoltraWidgetBundle.swift',
561
+ '//',
562
+ '// Auto-generated by Voltra.',
563
+ '//',
564
+ '',
565
+ imports,
566
+ '',
567
+ '@main',
568
+ 'struct VoltraWidgetBundle: WidgetBundle {',
569
+ ' var body: some Widget {',
570
+ ' VoltraWidget()',
571
+ widgetInstances,
572
+ ' }',
573
+ '}',
574
+ '',
575
+ widgetStructs,
576
+ '',
577
+ ].join('\n')
578
+ }
579
+
580
+ function generateWidgetStruct(widget: DetectedIOSWidget): string {
581
+ if (widgetUsesAppIntent(widget)) {
582
+ return generateClientAppIntentWidgetCode(widget)
583
+ }
584
+
585
+ const familiesSwift = widget.supportedFamilies.map((family) => IOS_WIDGET_FAMILY_MAP[family]).join(', ')
586
+ const displayNameExpr = createSwiftLabelExpression(widget.id, 'displayName', widget.displayName)
587
+ const descriptionExpr = createSwiftLabelExpression(widget.id, 'description', widget.description)
588
+ const providerAndContent = widget.clientRendered
589
+ ? [
590
+ ' provider: VoltraClientWidgetProvider(',
591
+ ' widgetId: widgetId,',
592
+ ' initialState: VoltraWidgetInitialStates.getInitialState(for: widgetId)',
593
+ ' )',
594
+ ' ) { entry in',
595
+ ' VoltraClientWidgetContentView(',
596
+ ' entry: entry,',
597
+ ' initialState: VoltraWidgetInitialStates.getInitialState(for: widgetId)',
598
+ ' )',
599
+ ].join('\n')
600
+ : [
601
+ ' provider: VoltraHomeWidgetProvider(',
602
+ ' widgetId: widgetId,',
603
+ ' initialState: VoltraWidgetInitialStates.getInitialState(for: widgetId)',
604
+ ' )',
605
+ ' ) { entry in',
606
+ ' VoltraHomeWidgetView(entry: entry)',
607
+ ].join('\n')
608
+
609
+ return [
610
+ `public struct VoltraWidget_${widget.id}: Widget {`,
611
+ ` private let widgetId = ${JSON.stringify(widget.id)}`,
612
+ '',
613
+ ' public init() {}',
614
+ '',
615
+ ' public var body: some WidgetConfiguration {',
616
+ ' StaticConfiguration(',
617
+ ` kind: ${JSON.stringify(`Voltra_Widget_${widget.id}`)},`,
618
+ providerAndContent,
619
+ ' }',
620
+ ` .configurationDisplayName(${displayNameExpr})`,
621
+ ` .description(${descriptionExpr})`,
622
+ ` .supportedFamilies([${familiesSwift}])`,
623
+ ' .contentMarginsDisabled()',
624
+ ' }',
625
+ '}',
626
+ ].join('\n')
627
+ }
628
+
629
+ function widgetUsesAppIntent(widget: DetectedIOSWidget): widget is Extract<
630
+ DetectedIOSWidget,
631
+ { clientRendered: true }
632
+ > & {
633
+ appIntent: { parameters: IOSWidgetAppIntentParameter[] }
634
+ } {
635
+ return widget.clientRendered && !!widget.appIntent && widget.appIntent.parameters.length > 0
636
+ }
637
+
638
+ function generateClientAppIntentWidgetCode(
639
+ widget: Extract<DetectedIOSWidget, { clientRendered: true }> & {
640
+ appIntent: { parameters: IOSWidgetAppIntentParameter[] }
641
+ }
642
+ ): string {
643
+ const familiesSwift = widget.supportedFamilies.map((family) => IOS_WIDGET_FAMILY_MAP[family]).join(', ')
644
+ const intentName = `VoltraWidget_${widget.id}_Intent`
645
+ const providerName = `VoltraWidget_${widget.id}_ClientProvider`
646
+ const intentTitle = `Configure ${widgetLabelEnglish(widget.displayName)}`
647
+ const displayNameExpr = createSwiftLabelExpression(widget.id, 'displayName', widget.displayName)
648
+ const descriptionExpr = createSwiftLabelExpression(widget.id, 'description', widget.description)
649
+ const parameterDeclarations = widget.appIntent.parameters
650
+ .map(
651
+ (parameter) =>
652
+ ` @Parameter(title: ${JSON.stringify(parameter.title)}, default: ${swiftDefaultValue(parameter)})\n var ${
653
+ parameter.name
654
+ }: String`
655
+ )
656
+ .join('\n\n')
657
+ const initializerParameters = widget.appIntent.parameters.map((parameter) => `${parameter.name}: String`).join(', ')
658
+ const initializerAssignments = widget.appIntent.parameters
659
+ .map((parameter) => ` self.${parameter.name} = ${parameter.name}`)
660
+ .join('\n')
661
+ const configuredDictionary = createSwiftDictionaryLiteral(
662
+ widget.appIntent.parameters.map((parameter) => `${JSON.stringify(parameter.name)}: configuration.${parameter.name}`)
663
+ )
664
+ const defaultDictionary = createSwiftDictionaryLiteral(
665
+ widget.appIntent.parameters.map((parameter) => `${JSON.stringify(parameter.name)}: ${swiftDefaultValue(parameter)}`)
666
+ )
667
+
668
+ return [
669
+ `@available(iOS 17.0, *)`,
670
+ `struct ${intentName}: WidgetConfigurationIntent {`,
671
+ ` static var title: LocalizedStringResource = ${JSON.stringify(intentTitle)}`,
672
+ '',
673
+ parameterDeclarations,
674
+ '',
675
+ ' init() {}',
676
+ ` init(${initializerParameters}) {`,
677
+ initializerAssignments,
678
+ ' }',
679
+ '}',
680
+ '',
681
+ `@available(iOS 17.0, *)`,
682
+ `private struct ${providerName}: AppIntentTimelineProvider {`,
683
+ ` typealias Intent = ${intentName}`,
684
+ ' typealias Entry = VoltraClientWidgetEntry',
685
+ '',
686
+ ` private let widgetId = ${JSON.stringify(widget.id)}`,
687
+ '',
688
+ ' func placeholder(in _: Context) -> VoltraClientWidgetEntry {',
689
+ ` VoltraClientWidgetEntry(date: Date(), widgetId: widgetId, bundleReady: false, configuration: ${defaultDictionary})`,
690
+ ' }',
691
+ '',
692
+ ` func snapshot(for configuration: ${intentName}, in _: Context) async -> VoltraClientWidgetEntry {`,
693
+ ` await VoltraClientWidgetProvider.loadEntry(widgetId: widgetId, configuration: ${configuredDictionary})`,
694
+ ' }',
695
+ '',
696
+ ` func timeline(for configuration: ${intentName}, in _: Context) async -> Timeline<VoltraClientWidgetEntry> {`,
697
+ ` let entry = await VoltraClientWidgetProvider.loadEntry(widgetId: widgetId, configuration: ${configuredDictionary})`,
698
+ ' return Timeline(entries: [entry], policy: .never)',
699
+ ' }',
700
+ '}',
701
+ '',
702
+ `@available(iOS 17.0, *)`,
703
+ `public struct VoltraWidget_${widget.id}: Widget {`,
704
+ ` private let widgetId = ${JSON.stringify(widget.id)}`,
705
+ '',
706
+ ' public init() {}',
707
+ '',
708
+ ' public var body: some WidgetConfiguration {',
709
+ ' AppIntentConfiguration(',
710
+ ` kind: ${JSON.stringify(`Voltra_Widget_${widget.id}`)},`,
711
+ ` intent: ${intentName}.self,`,
712
+ ` provider: ${providerName}()`,
713
+ ' ) { entry in',
714
+ ' VoltraClientWidgetContentView(',
715
+ ' entry: entry,',
716
+ ' initialState: VoltraWidgetInitialStates.getInitialState(for: widgetId)',
717
+ ' )',
718
+ ' }',
719
+ ` .configurationDisplayName(${displayNameExpr})`,
720
+ ` .description(${descriptionExpr})`,
721
+ ` .supportedFamilies([${familiesSwift}])`,
722
+ ' .contentMarginsDisabled()',
723
+ ' }',
724
+ '}',
725
+ ].join('\n')
726
+ }
727
+
728
+ function createSwiftLabelExpression(
729
+ widgetId: string,
730
+ field: 'displayName' | 'description',
731
+ label: WidgetLabel
732
+ ): string {
733
+ if (!isWidgetLocalizedMap(label)) {
734
+ return `Text(${JSON.stringify(label)})`
735
+ }
736
+
737
+ const key = `voltra_widget_${widgetId}_${field}`
738
+ const defaultEnglish = widgetLabelEnglish(label)
739
+
740
+ return `Text(LocalizedStringResource(${JSON.stringify(key)}, defaultValue: String.LocalizationValue(${JSON.stringify(
741
+ defaultEnglish
742
+ )}), table: ${JSON.stringify('VoltraWidgets')}))`
743
+ }
744
+
745
+ function swiftDefaultValue(parameter: IOSWidgetAppIntentParameter): string {
746
+ return JSON.stringify(parameter.default ?? '')
747
+ }
748
+
749
+ function createSwiftDictionaryLiteral(entries: string[]): string {
750
+ return entries.length > 0 ? `[${entries.join(', ')}]` : '[:]'
751
+ }
752
+
753
+ async function prerenderWidgetStates(
754
+ projectRoot: string,
755
+ widgets: NormalizedIOSWidgetConfig[],
756
+ renderer: IOSWidgetRenderer
757
+ ): Promise<PrerenderedWidgetStates> {
758
+ const prerenderedStates: PrerenderedWidgetStates = new Map()
759
+
760
+ for (const widget of widgets) {
761
+ const initialStatePath = widget.initialStatePath
762
+
763
+ if (!initialStatePath) {
764
+ continue
765
+ }
766
+
767
+ const perLocalePaths =
768
+ typeof initialStatePath === 'string' ? { [DEFAULT_INITIAL_STATE_LOCALE]: initialStatePath } : initialStatePath
769
+ const localeStates = new Map<string, string>()
770
+
771
+ for (const [localeKey, modulePath] of Object.entries(perLocalePaths)) {
772
+ if (!(await pathExists(modulePath))) {
773
+ throw new IOSGeneratedFilesError(`Initial state file not found for widget '${widget.id}' at ${modulePath}`)
774
+ }
775
+
776
+ const widgetVariants = evaluateLegacyWidgetModule(projectRoot, modulePath)
777
+ localeStates.set(localeKey, renderer(widgetVariants))
778
+ }
779
+
780
+ prerenderedStates.set(widget.id, localeStates)
781
+ }
782
+
783
+ return prerenderedStates
784
+ }
785
+
786
+ function evaluateLegacyWidgetModule(projectRoot: string, filePath: string): WidgetVariants {
787
+ const exports = evaluateWidgetModuleExports(projectRoot, filePath, createGeneratedFilesError) as { default?: unknown }
788
+ const widgetVariants = exports.default ?? exports
789
+
790
+ if (!widgetVariants || typeof widgetVariants !== 'object') {
791
+ throw new IOSGeneratedFilesError(`Widget file must export widget variants: ${filePath}`)
792
+ }
793
+
794
+ return widgetVariants as WidgetVariants
795
+ }
796
+
797
+ function loadIOSWidgetRenderer(projectRoot: string): IOSWidgetRenderer {
798
+ const iosPackage = requirePlatformPackage<IOSPlatformPackage>(projectRoot, 'ios')
799
+
800
+ if (typeof iosPackage.renderWidgetToString !== 'function') {
801
+ throw new IOSGeneratedFilesError('Installed @use-voltra/ios package does not export renderWidgetToString.')
802
+ }
803
+
804
+ return iosPackage.renderWidgetToString as IOSWidgetRenderer
805
+ }
806
+
807
+ function loadIOSDynamicWidgetRenderer(projectRoot: string): IOSDynamicWidgetRenderer {
808
+ const iosPackage = requirePlatformPackage<{ renderVoltraVariantToJson?: unknown }>(projectRoot, 'ios')
809
+
810
+ if (typeof iosPackage.renderVoltraVariantToJson !== 'function') {
811
+ throw new IOSGeneratedFilesError('Installed @use-voltra/ios package does not export renderVoltraVariantToJson.')
812
+ }
813
+
814
+ return iosPackage.renderVoltraVariantToJson as IOSDynamicWidgetRenderer
815
+ }
816
+
817
+ function createDynamicWidgetsManifest(widgets: DetectedIOSWidget[]): DynamicWidgetManifest {
818
+ return {
819
+ version: 1,
820
+ platform: 'ios',
821
+ widgets: widgets
822
+ .filter((widget): widget is Extract<DetectedIOSWidget, { clientRendered: true }> => widget.clientRendered)
823
+ .map((widget) => ({
824
+ id: widget.id,
825
+ entry: widget.entry,
826
+ })),
827
+ }
828
+ }
829
+
830
+ function detectClientRenderedWidgets(projectRoot: string, widgets: NormalizedIOSWidgetConfig[]): DetectedIOSWidget[] {
831
+ return widgets.map((widget) => detectSingleWidget(projectRoot, widget))
832
+ }
833
+
834
+ function detectSingleWidget(projectRoot: string, widget: NormalizedIOSWidgetConfig): DetectedIOSWidget {
835
+ if (!widget.entry) {
836
+ return {
837
+ ...widget,
838
+ clientRendered: false,
839
+ }
840
+ }
841
+
842
+ const clientSourcePath = path.join(projectRoot, widget.entry)
843
+
844
+ if (!fs.existsSync(clientSourcePath) || !fs.statSync(clientSourcePath).isFile()) {
845
+ throw createGeneratedFilesError(`[voltra] Dynamic Widget "${widget.id}" entry not found at ${widget.entry}`)
846
+ }
847
+
848
+ const widgetModule = safelyEvaluateDynamicWidget(projectRoot, widget.id, widget.entry, clientSourcePath)
849
+ const widgetFn = readDynamicWidgetExport(widget.id, widget.entry, widgetModule)
850
+
851
+ if (typeof widgetFn !== 'function') {
852
+ throw createGeneratedFilesError(
853
+ `[voltra] Dynamic Widget "${widget.id}" at ${widget.entry} must default-export a function or component.`
854
+ )
855
+ }
856
+
857
+ return {
858
+ ...widget,
859
+ entry: widget.entry,
860
+ clientRendered: true,
861
+ clientSourcePath,
862
+ }
863
+ }
864
+
865
+ async function prerenderClientRenderedWidgets(
866
+ projectRoot: string,
867
+ widgets: DetectedIOSWidget[]
868
+ ): Promise<PrerenderedWidgetStates> {
869
+ const clientWidgets = widgets.filter(
870
+ (widget): widget is Extract<DetectedIOSWidget, { clientRendered: true }> => widget.clientRendered
871
+ )
872
+
873
+ if (clientWidgets.length === 0) {
874
+ return new Map()
875
+ }
876
+
877
+ const renderer = loadIOSDynamicWidgetRenderer(projectRoot)
878
+ const placeholderEnv = {
879
+ date: Date.now(),
880
+ widgetFamily: 'systemMedium',
881
+ colorScheme: 'light',
882
+ locale: 'en-US',
883
+ widgetRenderingMode: 'fullColor',
884
+ showsWidgetContainerBackground: true,
885
+ configuration: undefined,
886
+ build: DYNAMIC_WIDGET_BUILD_INFO,
887
+ }
888
+ const prerenderedStates: PrerenderedWidgetStates = new Map()
889
+
890
+ for (const widget of clientWidgets) {
891
+ try {
892
+ const widgetModule = safelyEvaluateDynamicWidget(projectRoot, widget.id, widget.entry, widget.clientSourcePath)
893
+ const widgetFn = readDynamicWidgetExport(widget.id, widget.entry, widgetModule)
894
+ const element = widgetFn({}, placeholderEnv)
895
+ prerenderedStates.set(widget.id, new Map([[DEFAULT_INITIAL_STATE_LOCALE, JSON.stringify(renderer(element))]]))
896
+ } catch (error) {
897
+ if (error instanceof IOSGeneratedFilesError) {
898
+ throw error
899
+ }
900
+
901
+ throw createGeneratedFilesError(
902
+ `[voltra] Dynamic Widget "${widget.id}" failed placeholder prerender at ${widget.entry}: ${
903
+ error instanceof Error ? error.message : String(error)
904
+ }`
905
+ )
906
+ }
907
+ }
908
+
909
+ return prerenderedStates
910
+ }
911
+
912
+ function safelyEvaluateDynamicWidget(
913
+ projectRoot: string,
914
+ widgetId: string,
915
+ widgetEntry: string,
916
+ clientSourcePath: string
917
+ ): unknown {
918
+ try {
919
+ return evaluateWidgetModuleExports(projectRoot, clientSourcePath, createGeneratedFilesError)
920
+ } catch (error) {
921
+ if (error instanceof IOSGeneratedFilesError) {
922
+ throw error
923
+ }
924
+
925
+ throw createGeneratedFilesError(
926
+ `[voltra] Dynamic Widget "${widgetId}" failed to evaluate entry at ${widgetEntry}: ${
927
+ error instanceof Error ? error.message : String(error)
928
+ }`
929
+ )
930
+ }
931
+ }
932
+
933
+ function readDynamicWidgetExport(
934
+ widgetId: string,
935
+ widgetEntry: string,
936
+ widgetModule: unknown
937
+ ): (props: unknown, env: unknown) => unknown {
938
+ const exports =
939
+ widgetModule && typeof widgetModule === 'object'
940
+ ? (widgetModule as { default?: unknown })
941
+ : { default: widgetModule }
942
+ const widgetFn = exports.default ?? widgetModule
943
+
944
+ if (typeof widgetFn !== 'function') {
945
+ throw createGeneratedFilesError(
946
+ `[voltra] Dynamic Widget "${widgetId}" at ${widgetEntry} must default-export a function or component.`
947
+ )
948
+ }
949
+
950
+ return widgetFn as (props: unknown, env: unknown) => unknown
951
+ }
952
+
953
+ function createProjectRequire(projectRoot: string): NodeRequire {
954
+ return createRequire(path.join(projectRoot, 'package.json'))
955
+ }
956
+
957
+ async function resolveFontPaths(projectRoot: string, fonts: string[]): Promise<string[]> {
958
+ const projectRequire = createProjectRequire(projectRoot)
959
+ const resolvedFontPaths = new Set<string>()
960
+
961
+ for (const font of fonts) {
962
+ const resolvedFontInput = await resolveFontInput(projectRoot, font, projectRequire)
963
+
964
+ if (!resolvedFontInput) {
965
+ throw new IOSGeneratedFilesError(`Could not resolve iOS font path: ${font}`)
966
+ }
967
+
968
+ const stat = await fsPromises.stat(resolvedFontInput)
969
+ if (!stat.isDirectory()) {
970
+ if (FONT_EXTENSIONS.has(path.extname(resolvedFontInput).toLowerCase())) {
971
+ resolvedFontPaths.add(resolvedFontInput)
972
+ }
973
+ continue
974
+ }
975
+
976
+ for (const entry of await fsPromises.readdir(resolvedFontInput)) {
977
+ const candidatePath = path.join(resolvedFontInput, entry)
978
+ if (FONT_EXTENSIONS.has(path.extname(candidatePath).toLowerCase())) {
979
+ resolvedFontPaths.add(candidatePath)
980
+ }
981
+ }
982
+ }
983
+
984
+ return [...resolvedFontPaths].sort()
985
+ }
986
+
987
+ async function resolveFontInput(
988
+ projectRoot: string,
989
+ input: string,
990
+ projectRequire: NodeRequire
991
+ ): Promise<string | null> {
992
+ const resolvedPath = path.isAbsolute(input) ? input : path.resolve(projectRoot, input)
993
+ if (await pathExists(resolvedPath)) {
994
+ return resolvedPath
995
+ }
996
+
997
+ try {
998
+ return projectRequire.resolve(input)
999
+ } catch {
1000
+ return null
1001
+ }
1002
+ }
1003
+
1004
+ async function collectUserImages(userImagesPath: string): Promise<string[]> {
1005
+ if (!(await pathExists(userImagesPath))) {
1006
+ return []
1007
+ }
1008
+
1009
+ const collectedPaths: string[] = []
1010
+ await collectPathsRecursively(userImagesPath, collectedPaths)
1011
+ return collectedPaths.sort()
1012
+ }
1013
+
1014
+ async function collectPathsRecursively(currentPath: string, collectedPaths: string[]): Promise<void> {
1015
+ const stat = await fsPromises.lstat(currentPath)
1016
+
1017
+ if (!stat.isDirectory()) {
1018
+ if (!path.basename(currentPath).startsWith('.')) {
1019
+ collectedPaths.push(currentPath)
1020
+ }
1021
+ return
1022
+ }
1023
+
1024
+ for (const entry of await fsPromises.readdir(currentPath)) {
1025
+ await collectPathsRecursively(path.join(currentPath, entry), collectedPaths)
1026
+ }
1027
+ }
1028
+
1029
+ async function copyGeneratedFile(
1030
+ projectRoot: string,
1031
+ sourcePath: string,
1032
+ destinationPath: string
1033
+ ): Promise<GeneratedFileResult> {
1034
+ await ensureDirectory(path.dirname(destinationPath))
1035
+ const sourceContent = await fsPromises.readFile(sourcePath)
1036
+ const existingContent = (await pathExists(destinationPath)) ? await fsPromises.readFile(destinationPath) : undefined
1037
+ const relativePath = toRelativePath(projectRoot, destinationPath)
1038
+
1039
+ if (existingContent && Buffer.compare(existingContent, sourceContent) === 0) {
1040
+ return { relativePath }
1041
+ }
1042
+
1043
+ await fsPromises.writeFile(destinationPath, sourceContent)
1044
+
1045
+ return {
1046
+ change: {
1047
+ kind: existingContent ? 'updated' : 'created',
1048
+ path: relativePath,
1049
+ },
1050
+ relativePath,
1051
+ }
1052
+ }
1053
+
1054
+ async function writeGeneratedTextFile(
1055
+ projectRoot: string,
1056
+ destinationPath: string,
1057
+ content: string
1058
+ ): Promise<GeneratedFileResult> {
1059
+ const existingContent = (await pathExists(destinationPath)) ? await readTextFile(destinationPath) : undefined
1060
+ const relativePath = toRelativePath(projectRoot, destinationPath)
1061
+
1062
+ if (existingContent === content) {
1063
+ return { relativePath }
1064
+ }
1065
+
1066
+ await writeTextFile(destinationPath, content)
1067
+
1068
+ return {
1069
+ change: {
1070
+ kind: existingContent === undefined ? 'created' : 'updated',
1071
+ path: relativePath,
1072
+ },
1073
+ relativePath,
1074
+ }
1075
+ }
1076
+
1077
+ async function getLargeImageWarning(imagePath: string, fileName: string): Promise<string | undefined> {
1078
+ const stat = await fsPromises.stat(imagePath)
1079
+
1080
+ if (stat.size < MAX_IMAGE_SIZE_BYTES) {
1081
+ return undefined
1082
+ }
1083
+
1084
+ return `Image '${fileName}' is ${stat.size} bytes. Large iOS widget images may not display correctly.`
1085
+ }
1086
+
1087
+ function collectGalleryStringsByLocale(widgets: NormalizedIOSWidgetConfig[]): Map<string, Record<string, string>> {
1088
+ const byLocale = new Map<string, Record<string, string>>()
1089
+
1090
+ const add = (locale: string, key: string, value: string): void => {
1091
+ const bucket = byLocale.get(locale) ?? {}
1092
+ bucket[key] = value
1093
+ byLocale.set(locale, bucket)
1094
+ }
1095
+
1096
+ for (const widget of widgets) {
1097
+ if (isWidgetLocalizedMap(widget.displayName)) {
1098
+ for (const [locale, value] of Object.entries(widget.displayName)) {
1099
+ if (value.trim()) {
1100
+ add(locale, `voltra_widget_${widget.id}_displayName`, value)
1101
+ }
1102
+ }
1103
+ }
1104
+
1105
+ if (isWidgetLocalizedMap(widget.description)) {
1106
+ for (const [locale, value] of Object.entries(widget.description)) {
1107
+ if (value.trim()) {
1108
+ add(locale, `voltra_widget_${widget.id}_description`, value)
1109
+ }
1110
+ }
1111
+ }
1112
+ }
1113
+
1114
+ return byLocale
1115
+ }
1116
+
1117
+ function formatStringsFile(entries: Record<string, string>): string {
1118
+ const sortedKeys = Object.keys(entries).sort()
1119
+ const lines = sortedKeys.map((key) => `${JSON.stringify(key)} = ${JSON.stringify(entries[key])};`)
1120
+ return `/* Voltra widget gallery strings (auto-generated) */\n${lines.join('\n')}\n`
1121
+ }
1122
+
1123
+ function widgetLabelEnglish(label: WidgetLabel): string {
1124
+ if (!isWidgetLocalizedMap(label)) {
1125
+ return label
1126
+ }
1127
+
1128
+ const englishLabel = label.en
1129
+ if (typeof englishLabel === 'string' && englishLabel.trim()) {
1130
+ return englishLabel
1131
+ }
1132
+
1133
+ for (const [localeKey, value] of Object.entries(label)) {
1134
+ if ((localeKey.startsWith('en-') || localeKey.startsWith('en_')) && value.trim()) {
1135
+ return value
1136
+ }
1137
+ }
1138
+
1139
+ return Object.values(label).find((value) => value.trim()) ?? ''
1140
+ }
1141
+
1142
+ function isWidgetLocalizedMap(label: WidgetLabel): label is Record<string, string> {
1143
+ return typeof label === 'object' && label !== null && !Array.isArray(label)
1144
+ }
1145
+
1146
+ function sanitizeAssetName(value: string): string {
1147
+ let sanitized = value.replace(/[^A-Za-z0-9_-]/g, '-')
1148
+ sanitized = sanitized.replace(/-+/g, '-')
1149
+ sanitized = sanitized.replace(/^[-_]+|[-_]+$/g, '')
1150
+ if (!/^[A-Za-z]/.test(sanitized)) {
1151
+ sanitized = `asset-${sanitized}`
1152
+ }
1153
+ return sanitized
1154
+ }
1155
+
1156
+ function getAssetName(relativeAssetPath: string, seenAssetNames: Map<string, string>): string {
1157
+ const assetName = sanitizeAssetName(path.basename(relativeAssetPath))
1158
+ const existingPath = seenAssetNames.get(assetName)
1159
+
1160
+ if (existingPath && existingPath !== relativeAssetPath) {
1161
+ throw new IOSGeneratedFilesError(
1162
+ `iOS widget assets must have unique basenames. Found both '${existingPath}' and '${relativeAssetPath}' resolving to asset '${assetName}'.`
1163
+ )
1164
+ }
1165
+
1166
+ seenAssetNames.set(assetName, relativeAssetPath)
1167
+ return assetName
1168
+ }
1169
+
1170
+ function readPlistString(dict: Record<string, unknown>, key: string): string | undefined {
1171
+ const value = dict[key]
1172
+ return typeof value === 'string' && value.trim() ? value : undefined
1173
+ }
1174
+
1175
+ function readUrlTypes(dict: Record<string, unknown>): Array<{ CFBundleURLSchemes: string[] }> {
1176
+ const urlTypesValue = dict.CFBundleURLTypes
1177
+
1178
+ if (!Array.isArray(urlTypesValue)) {
1179
+ return []
1180
+ }
1181
+
1182
+ return urlTypesValue
1183
+ .map((entry): { CFBundleURLSchemes: string[] } | undefined => {
1184
+ if (!entry || typeof entry !== 'object') {
1185
+ return undefined
1186
+ }
1187
+
1188
+ const schemesValue = (entry as { CFBundleURLSchemes?: unknown }).CFBundleURLSchemes
1189
+
1190
+ if (!Array.isArray(schemesValue)) {
1191
+ return undefined
1192
+ }
1193
+
1194
+ const schemes = schemesValue.filter(
1195
+ (scheme): scheme is string => typeof scheme === 'string' && scheme.trim().length > 0
1196
+ )
1197
+
1198
+ return schemes.length > 0 ? { CFBundleURLSchemes: schemes } : undefined
1199
+ })
1200
+ .filter((entry): entry is { CFBundleURLSchemes: string[] } => entry !== undefined)
1201
+ }
1202
+
1203
+ function getSwiftRawStringDelimiter(value: string): string {
1204
+ const matches = value.match(/"#+/g)
1205
+ if (!matches) {
1206
+ return '#'
1207
+ }
1208
+
1209
+ const maxHashes = Math.max(...matches.map((match) => match.length - 1))
1210
+ return '#'.repeat(maxHashes + 1)
1211
+ }
1212
+
1213
+ function mergeResult(
1214
+ result: Pick<GenerateIOSFilesResult, 'changes' | 'files' | 'warnings'>,
1215
+ changes: ReportedChange[],
1216
+ warnings: string[],
1217
+ generatedFiles: Set<string>
1218
+ ): void {
1219
+ changes.push(...result.changes)
1220
+ warnings.push(...result.warnings)
1221
+
1222
+ for (const filePath of result.files) {
1223
+ generatedFiles.add(normalizeRelativePath(filePath))
1224
+ }
1225
+ }
1226
+
1227
+ function mergeSingleResult(result: GeneratedFileResult, changes: ReportedChange[], generatedFiles: Set<string>): void {
1228
+ if (result.change) {
1229
+ changes.push(result.change)
1230
+ }
1231
+
1232
+ generatedFiles.add(normalizeRelativePath(result.relativePath))
1233
+ }