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,1284 @@
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
+ import { vdConvert } from 'vd-tool'
6
+
7
+ import { requirePlatformPackage } from '../../dependencies/platformPackages'
8
+ import { ensureDirectory, pathExists, readTextFile, writeTextFile } from '../../fs/readWrite'
9
+ import { normalizeRelativePath, toRelativePath } from '../../fs/path'
10
+ import { VoltraCliError } from '../../reporting/summary'
11
+ import { DYNAMIC_WIDGET_BUILD_INFO, evaluateWidgetModuleExports } from '../shared/widgetModule'
12
+
13
+ import type { AndroidProjectDiscovery } from '../../discovery/android'
14
+ import type {
15
+ AndroidWidgetAppIntentParameter,
16
+ NormalizedAndroidWidgetConfig,
17
+ NormalizedVoltraAndroidConfig,
18
+ WidgetLabel,
19
+ } from '../../config/types'
20
+ import type { ReportedChange } from '../../reporting/summary'
21
+
22
+ const VALID_DRAWABLE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.xml', '.svg'])
23
+ const FONT_EXTENSIONS = new Set(['.ttf', '.otf', '.woff', '.woff2'])
24
+ const MAX_IMAGE_SIZE_BYTES = 4096
25
+ const DEFAULT_INITIAL_STATE_LOCALE = '__default'
26
+ const LOCALIZED_INITIAL_STATE_KEY = '__voltraLocales'
27
+ const DEFAULT_WIDGET_LOCALE_QUALIFIER = 'en'
28
+ const ANDROID_DYNAMIC_WIDGET_MANIFEST_PATH = path.join('.voltra', 'manifest.android.json')
29
+ const ANDROID_WIDGET_CONFIG_DEFAULTS_PATH = path.join('assets', 'voltra', 'widget_config_defaults.json')
30
+
31
+ export interface GenerateAndroidFilesOptions {
32
+ projectRoot: string
33
+ android: NormalizedVoltraAndroidConfig
34
+ discovery: AndroidProjectDiscovery
35
+ }
36
+
37
+ export interface GenerateAndroidFilesResult {
38
+ changes: ReportedChange[]
39
+ files: string[]
40
+ warnings: string[]
41
+ }
42
+
43
+ interface GeneratedFileResult {
44
+ change?: ReportedChange
45
+ relativePath: string
46
+ }
47
+
48
+ export class AndroidGeneratedFilesError extends VoltraCliError {
49
+ constructor(message: string) {
50
+ super(message, 'VOLTRA_ANDROID_GENERATED_FILES_FAILED')
51
+ this.name = 'AndroidGeneratedFilesError'
52
+ }
53
+ }
54
+
55
+ function createAndroidGeneratedFilesError(message: string): AndroidGeneratedFilesError {
56
+ return new AndroidGeneratedFilesError(message)
57
+ }
58
+
59
+ type AndroidWidgetVariants = Record<string, unknown>
60
+ type AndroidWidgetRenderer = (variants: AndroidWidgetVariants) => string
61
+ type AndroidDynamicWidgetRenderer = (element: unknown) => unknown
62
+ interface AndroidPlatformPackage {
63
+ renderAndroidWidgetToString?: unknown
64
+ }
65
+
66
+ type PrerenderedWidgetStates = Map<string, Map<string, string>>
67
+ type DynamicWidgetManifest = {
68
+ version: 1
69
+ platform: 'android'
70
+ widgets: Array<{ id: string; entry: string }>
71
+ }
72
+ type WidgetConfigDefaults = Record<string, Record<string, string>>
73
+ type DetectedAndroidWidget =
74
+ | (NormalizedAndroidWidgetConfig & { clientRendered: false })
75
+ | (NormalizedAndroidWidgetConfig & { entry: string; clientRendered: true; clientSourcePath: string })
76
+
77
+ export async function generateAndroidFiles(options: GenerateAndroidFilesOptions): Promise<GenerateAndroidFilesResult> {
78
+ const { projectRoot, android, discovery } = options
79
+ const resourceRoot = path.join(discovery.appModuleRoot, 'src', 'main')
80
+ const detectedWidgets = detectClientRenderedWidgets(projectRoot, android.widgets)
81
+ const changes: ReportedChange[] = []
82
+ const warnings: string[] = []
83
+ const generatedFiles = new Set<string>()
84
+
85
+ mergeSingleResult(
86
+ await writeGeneratedTextFile(
87
+ projectRoot,
88
+ path.join(projectRoot, ANDROID_DYNAMIC_WIDGET_MANIFEST_PATH),
89
+ `${JSON.stringify(createDynamicWidgetsManifest(detectedWidgets), null, 2)}\n`
90
+ ),
91
+ changes,
92
+ generatedFiles
93
+ )
94
+
95
+ const receiverFiles = await generateWidgetReceivers(projectRoot, discovery, detectedWidgets)
96
+ mergeResult(receiverFiles, changes, warnings, generatedFiles)
97
+
98
+ const assetFiles = await generateAndroidAssets(projectRoot, resourceRoot, android)
99
+ mergeResult(assetFiles, changes, warnings, generatedFiles)
100
+
101
+ const xmlFiles = await generateAndroidXmlFiles(projectRoot, resourceRoot, detectedWidgets)
102
+ mergeResult(xmlFiles, changes, warnings, generatedFiles)
103
+
104
+ const fontFiles = await copyAndroidFonts(projectRoot, resourceRoot, android.fonts)
105
+ mergeResult(fontFiles, changes, warnings, generatedFiles)
106
+
107
+ const initialStateFiles = await generateAndroidInitialStates(projectRoot, resourceRoot, detectedWidgets)
108
+ mergeResult(initialStateFiles, changes, warnings, generatedFiles)
109
+
110
+ const configDefaultsResult = await generateAndroidConfigDefaults(projectRoot, resourceRoot, detectedWidgets)
111
+ if (configDefaultsResult) {
112
+ mergeSingleResult(configDefaultsResult, changes, generatedFiles)
113
+ }
114
+
115
+ return {
116
+ changes,
117
+ files: [...generatedFiles].sort(),
118
+ warnings,
119
+ }
120
+ }
121
+
122
+ async function generateWidgetReceivers(
123
+ projectRoot: string,
124
+ discovery: AndroidProjectDiscovery,
125
+ widgets: DetectedAndroidWidget[]
126
+ ): Promise<GenerateAndroidFilesResult> {
127
+ const javaRoot = path.join(discovery.appModuleRoot, 'src', 'main', 'java')
128
+ const widgetDir = path.join(javaRoot, discovery.packageName.replace(/\./g, '/'), 'widget')
129
+ const changes: ReportedChange[] = []
130
+ const generatedFiles = new Set<string>()
131
+
132
+ for (const widget of widgets) {
133
+ assertValidWidgetId(widget.id)
134
+ const receiverPath = path.join(widgetDir, `VoltraWidget_${widget.id}Receiver.kt`)
135
+ const receiverContent = generateWidgetReceiverContent(widget, discovery.packageName)
136
+ const result = await writeGeneratedTextFile(projectRoot, receiverPath, receiverContent)
137
+
138
+ pushChange(changes, result.change)
139
+ generatedFiles.add(result.relativePath)
140
+ }
141
+
142
+ return {
143
+ changes,
144
+ files: [...generatedFiles],
145
+ warnings: [],
146
+ }
147
+ }
148
+
149
+ async function generateAndroidXmlFiles(
150
+ projectRoot: string,
151
+ resourceRoot: string,
152
+ widgets: DetectedAndroidWidget[]
153
+ ): Promise<GenerateAndroidFilesResult> {
154
+ const changes: ReportedChange[] = []
155
+ const generatedFiles = new Set<string>()
156
+ const warnings: string[] = []
157
+ const xmlDir = path.join(resourceRoot, 'res', 'xml')
158
+ const layoutDir = path.join(resourceRoot, 'res', 'layout')
159
+ const valuesDir = path.join(resourceRoot, 'res', 'values')
160
+
161
+ if (widgets.length === 0) {
162
+ return { changes, files: [], warnings }
163
+ }
164
+
165
+ const defaultStringsPath = path.join(valuesDir, 'voltra_widgets.xml')
166
+ const defaultStringsResult = await writeGeneratedTextFile(
167
+ projectRoot,
168
+ defaultStringsPath,
169
+ generateWidgetStringsXml(widgets, null)
170
+ )
171
+ pushChange(changes, defaultStringsResult.change)
172
+ generatedFiles.add(defaultStringsResult.relativePath)
173
+
174
+ for (const localeKey of collectWidgetLocaleKeys(widgets)) {
175
+ const qualifier = localeKeyToAndroidValuesQualifier(localeKey)
176
+
177
+ if (qualifier === DEFAULT_WIDGET_LOCALE_QUALIFIER) {
178
+ continue
179
+ }
180
+
181
+ const localizedValuesPath = path.join(resourceRoot, 'res', `values-${qualifier}`, 'voltra_widgets.xml')
182
+ const localizedValuesResult = await writeGeneratedTextFile(
183
+ projectRoot,
184
+ localizedValuesPath,
185
+ generateWidgetStringsXml(widgets, localeKey)
186
+ )
187
+ pushChange(changes, localizedValuesResult.change)
188
+ generatedFiles.add(localizedValuesResult.relativePath)
189
+ }
190
+
191
+ const placeholderLayoutPath = path.join(layoutDir, 'voltra_widget_placeholder.xml')
192
+ const placeholderLayoutResult = await writeGeneratedTextFile(
193
+ projectRoot,
194
+ placeholderLayoutPath,
195
+ generatePlaceholderLayoutXml()
196
+ )
197
+ pushChange(changes, placeholderLayoutResult.change)
198
+ generatedFiles.add(placeholderLayoutResult.relativePath)
199
+
200
+ const previewLayoutMap = await generatePreviewLayouts(
201
+ projectRoot,
202
+ layoutDir,
203
+ widgets,
204
+ warnings,
205
+ changes,
206
+ generatedFiles
207
+ )
208
+
209
+ for (const widget of widgets) {
210
+ const widgetInfoPath = path.join(xmlDir, `voltra_widget_${widget.id}_info.xml`)
211
+ const widgetInfoContent = generateWidgetInfoXml(
212
+ widget,
213
+ widget.previewImage ? getPreviewImageResourceName(widget) : undefined,
214
+ previewLayoutMap.get(widget.id)
215
+ )
216
+ const widgetInfoResult = await writeGeneratedTextFile(projectRoot, widgetInfoPath, widgetInfoContent)
217
+ pushChange(changes, widgetInfoResult.change)
218
+ generatedFiles.add(widgetInfoResult.relativePath)
219
+ }
220
+
221
+ return {
222
+ changes,
223
+ files: [...generatedFiles],
224
+ warnings,
225
+ }
226
+ }
227
+
228
+ async function generatePreviewLayouts(
229
+ projectRoot: string,
230
+ layoutDir: string,
231
+ widgets: DetectedAndroidWidget[],
232
+ warnings: string[],
233
+ changes: ReportedChange[],
234
+ generatedFiles: Set<string>
235
+ ): Promise<Map<string, string>> {
236
+ const previewLayoutMap = new Map<string, string>()
237
+
238
+ for (const widget of widgets) {
239
+ const layoutResourceName = `voltra_widget_${widget.id}_preview`
240
+ const layoutFilePath = path.join(layoutDir, `${layoutResourceName}.xml`)
241
+
242
+ if (widget.previewLayout) {
243
+ const sourceExists = await pathExists(widget.previewLayout)
244
+
245
+ if (!sourceExists) {
246
+ throw new AndroidGeneratedFilesError(
247
+ `Preview layout not found for widget '${widget.id}' at ${widget.previewLayout}`
248
+ )
249
+ }
250
+
251
+ const content = await readTextFile(widget.previewLayout)
252
+ const layoutResult = await writeGeneratedTextFile(projectRoot, layoutFilePath, content)
253
+ pushChange(changes, layoutResult.change)
254
+ generatedFiles.add(layoutResult.relativePath)
255
+ previewLayoutMap.set(widget.id, layoutResourceName)
256
+ continue
257
+ }
258
+
259
+ if (!widget.previewImage) {
260
+ continue
261
+ }
262
+
263
+ const content = generateAutoImagePreviewLayout(widget.id, getPreviewImageResourceName(widget))
264
+ const layoutResult = await writeGeneratedTextFile(projectRoot, layoutFilePath, content)
265
+ pushChange(changes, layoutResult.change)
266
+ generatedFiles.add(layoutResult.relativePath)
267
+ previewLayoutMap.set(widget.id, layoutResourceName)
268
+
269
+ const previewImageSizeWarning = await getLargeImageWarning(widget.previewImage, path.basename(widget.previewImage))
270
+ if (previewImageSizeWarning) {
271
+ warnings.push(previewImageSizeWarning)
272
+ }
273
+ }
274
+
275
+ return previewLayoutMap
276
+ }
277
+
278
+ async function generateAndroidAssets(
279
+ projectRoot: string,
280
+ resourceRoot: string,
281
+ android: NormalizedVoltraAndroidConfig
282
+ ): Promise<GenerateAndroidFilesResult> {
283
+ const drawableDir = path.join(resourceRoot, 'res', 'drawable')
284
+ const changes: ReportedChange[] = []
285
+ const warnings: string[] = []
286
+ const generatedFiles = new Set<string>()
287
+
288
+ for (const assetPath of await collectUserAssetPaths(projectRoot, android.userImagesPath)) {
289
+ const extension = path.extname(assetPath).toLowerCase()
290
+
291
+ if (!VALID_DRAWABLE_EXTENSIONS.has(extension)) {
292
+ throw new AndroidGeneratedFilesError(
293
+ `Unsupported Android drawable asset '${assetPath}'. Supported extensions: ${[...VALID_DRAWABLE_EXTENSIONS]
294
+ .sort()
295
+ .join(', ')}`
296
+ )
297
+ }
298
+
299
+ const resourceName = sanitizeDrawableName(path.relative(android.userImagesPath, assetPath))
300
+
301
+ if (extension === '.svg') {
302
+ const destinationPath = path.join(drawableDir, `${resourceName}.svg`)
303
+ const svgResult = await convertSvgToVectorDrawable(projectRoot, assetPath, destinationPath)
304
+
305
+ pushChange(changes, svgResult.change)
306
+ generatedFiles.add(svgResult.relativePath)
307
+ continue
308
+ }
309
+
310
+ const destinationPath = path.join(drawableDir, `${resourceName}${extension}`)
311
+ const imageWarning =
312
+ extension === '.xml' ? undefined : await getLargeImageWarning(assetPath, path.basename(assetPath))
313
+
314
+ if (imageWarning) {
315
+ warnings.push(imageWarning)
316
+ }
317
+
318
+ const assetResult = await copyGeneratedFile(projectRoot, assetPath, destinationPath)
319
+ pushChange(changes, assetResult.change)
320
+ generatedFiles.add(assetResult.relativePath)
321
+ }
322
+
323
+ for (const widget of android.widgets) {
324
+ if (!widget.previewImage) {
325
+ continue
326
+ }
327
+
328
+ if (!(await pathExists(widget.previewImage))) {
329
+ throw new AndroidGeneratedFilesError(
330
+ `Preview image not found for widget '${widget.id}' at ${widget.previewImage}`
331
+ )
332
+ }
333
+
334
+ const extension = path.extname(widget.previewImage).toLowerCase()
335
+
336
+ if (!VALID_DRAWABLE_EXTENSIONS.has(extension)) {
337
+ throw new AndroidGeneratedFilesError(
338
+ `Unsupported Android preview image '${widget.previewImage}' for widget '${widget.id}'. Supported extensions: ${[
339
+ ...VALID_DRAWABLE_EXTENSIONS,
340
+ ]
341
+ .sort()
342
+ .join(', ')}`
343
+ )
344
+ }
345
+
346
+ const resourceName = getPreviewImageResourceName(widget)
347
+ if (extension === '.svg') {
348
+ const destinationPath = path.join(drawableDir, `${resourceName}.svg`)
349
+ const previewResult = await convertSvgToVectorDrawable(projectRoot, widget.previewImage, destinationPath)
350
+
351
+ pushChange(changes, previewResult.change)
352
+ generatedFiles.add(previewResult.relativePath)
353
+ continue
354
+ }
355
+
356
+ const destinationPath = path.join(drawableDir, `${resourceName}${extension}`)
357
+ const imageWarning =
358
+ extension === '.xml'
359
+ ? undefined
360
+ : await getLargeImageWarning(widget.previewImage, path.basename(widget.previewImage))
361
+
362
+ if (imageWarning) {
363
+ warnings.push(imageWarning)
364
+ }
365
+
366
+ const previewResult = await copyGeneratedFile(projectRoot, widget.previewImage, destinationPath)
367
+ pushChange(changes, previewResult.change)
368
+ generatedFiles.add(previewResult.relativePath)
369
+ }
370
+
371
+ return {
372
+ changes,
373
+ files: [...generatedFiles],
374
+ warnings,
375
+ }
376
+ }
377
+
378
+ async function copyAndroidFonts(
379
+ projectRoot: string,
380
+ resourceRoot: string,
381
+ fonts: string[]
382
+ ): Promise<GenerateAndroidFilesResult> {
383
+ const changes: ReportedChange[] = []
384
+ const generatedFiles = new Set<string>()
385
+ const warnings: string[] = []
386
+
387
+ if (fonts.length === 0) {
388
+ return { changes, files: [], warnings }
389
+ }
390
+
391
+ const fontsDir = path.join(resourceRoot, 'assets', 'fonts')
392
+ const fontPaths = await resolveFontPaths(projectRoot, fonts)
393
+
394
+ for (const fontPath of fontPaths) {
395
+ const destinationPath = path.join(fontsDir, path.basename(fontPath))
396
+ const fontResult = await copyGeneratedFile(projectRoot, fontPath, destinationPath)
397
+
398
+ pushChange(changes, fontResult.change)
399
+ generatedFiles.add(fontResult.relativePath)
400
+ }
401
+
402
+ return {
403
+ changes,
404
+ files: [...generatedFiles],
405
+ warnings,
406
+ }
407
+ }
408
+
409
+ async function generateAndroidInitialStates(
410
+ projectRoot: string,
411
+ resourceRoot: string,
412
+ widgets: DetectedAndroidWidget[]
413
+ ): Promise<GenerateAndroidFilesResult> {
414
+ const serverWidgets = widgets.filter(
415
+ (widget): widget is Extract<DetectedAndroidWidget, { clientRendered: false }> => !widget.clientRendered
416
+ )
417
+ const prerenderableServerWidgets = serverWidgets.filter((widget) => widget.initialStatePath)
418
+ const serverStates = await prerenderWidgetStates(
419
+ projectRoot,
420
+ prerenderableServerWidgets,
421
+ loadAndroidWidgetRenderer(projectRoot)
422
+ )
423
+ const clientStates = await prerenderClientRenderedAndroidWidgets(projectRoot, widgets)
424
+ const prerenderedStates = new Map([...serverStates, ...clientStates])
425
+
426
+ if (prerenderedStates.size === 0) {
427
+ return {
428
+ changes: [],
429
+ files: [],
430
+ warnings: [],
431
+ }
432
+ }
433
+
434
+ const initialStatesPath = path.join(resourceRoot, 'assets', 'voltra_initial_states.json')
435
+ const initialStatesContent = JSON.stringify(convertPrerenderedStatesToObject(prerenderedStates), null, 2)
436
+ const result = await writeGeneratedTextFile(projectRoot, initialStatesPath, `${initialStatesContent}\n`)
437
+
438
+ return {
439
+ changes: result.change ? [result.change] : [],
440
+ files: [result.relativePath],
441
+ warnings: [],
442
+ }
443
+ }
444
+
445
+ async function prerenderWidgetStates(
446
+ projectRoot: string,
447
+ widgets: NormalizedAndroidWidgetConfig[],
448
+ renderer: AndroidWidgetRenderer
449
+ ): Promise<PrerenderedWidgetStates> {
450
+ const prerenderedStates: PrerenderedWidgetStates = new Map()
451
+
452
+ for (const widget of widgets) {
453
+ const initialStatePath = widget.initialStatePath
454
+
455
+ if (!initialStatePath) {
456
+ continue
457
+ }
458
+
459
+ const perLocalePaths =
460
+ typeof initialStatePath === 'string'
461
+ ? { [DEFAULT_INITIAL_STATE_LOCALE]: initialStatePath }
462
+ : Object.fromEntries(Object.entries(initialStatePath))
463
+
464
+ const localeStates = new Map<string, string>()
465
+
466
+ for (const [localeKey, modulePath] of Object.entries(perLocalePaths)) {
467
+ if (!(await pathExists(modulePath))) {
468
+ throw new AndroidGeneratedFilesError(`Initial state file not found for widget '${widget.id}' at ${modulePath}`)
469
+ }
470
+
471
+ const widgetVariants = evaluateLegacyWidgetModule(projectRoot, modulePath)
472
+ localeStates.set(localeKey, renderer(widgetVariants))
473
+ }
474
+
475
+ prerenderedStates.set(widget.id, localeStates)
476
+ }
477
+
478
+ return prerenderedStates
479
+ }
480
+
481
+ function evaluateLegacyWidgetModule(projectRoot: string, filePath: string): AndroidWidgetVariants {
482
+ const exports = evaluateWidgetModuleExports(projectRoot, filePath, createAndroidGeneratedFilesError) as {
483
+ default?: unknown
484
+ }
485
+ const widgetVariants = exports.default ?? exports
486
+
487
+ if (!widgetVariants || typeof widgetVariants !== 'object') {
488
+ throw new AndroidGeneratedFilesError(`Widget file must export widget variants: ${filePath}`)
489
+ }
490
+
491
+ return widgetVariants as AndroidWidgetVariants
492
+ }
493
+
494
+ function loadAndroidWidgetRenderer(projectRoot: string): AndroidWidgetRenderer {
495
+ const androidPackage = requirePlatformPackage<AndroidPlatformPackage>(projectRoot, 'android')
496
+
497
+ if (typeof androidPackage.renderAndroidWidgetToString !== 'function') {
498
+ throw new AndroidGeneratedFilesError(
499
+ 'Installed @use-voltra/android package does not export renderAndroidWidgetToString.'
500
+ )
501
+ }
502
+
503
+ return androidPackage.renderAndroidWidgetToString as AndroidWidgetRenderer
504
+ }
505
+
506
+ function loadAndroidDynamicWidgetRenderer(projectRoot: string): AndroidDynamicWidgetRenderer {
507
+ const androidPackage = requirePlatformPackage<{ renderAndroidVariantToJson?: unknown }>(projectRoot, 'android')
508
+
509
+ if (typeof androidPackage.renderAndroidVariantToJson !== 'function') {
510
+ throw new AndroidGeneratedFilesError(
511
+ 'Installed @use-voltra/android package does not export renderAndroidVariantToJson.'
512
+ )
513
+ }
514
+
515
+ return androidPackage.renderAndroidVariantToJson as AndroidDynamicWidgetRenderer
516
+ }
517
+
518
+ function createDynamicWidgetsManifest(widgets: DetectedAndroidWidget[]): DynamicWidgetManifest {
519
+ return {
520
+ version: 1,
521
+ platform: 'android',
522
+ widgets: widgets
523
+ .filter((widget): widget is Extract<DetectedAndroidWidget, { clientRendered: true }> => widget.clientRendered)
524
+ .map((widget) => ({
525
+ id: widget.id,
526
+ entry: widget.entry,
527
+ })),
528
+ }
529
+ }
530
+
531
+ function detectClientRenderedWidgets(
532
+ projectRoot: string,
533
+ widgets: NormalizedAndroidWidgetConfig[]
534
+ ): DetectedAndroidWidget[] {
535
+ return widgets.map((widget) => detectSingleWidget(projectRoot, widget))
536
+ }
537
+
538
+ function detectSingleWidget(projectRoot: string, widget: NormalizedAndroidWidgetConfig): DetectedAndroidWidget {
539
+ if (!widget.entry) {
540
+ return {
541
+ ...widget,
542
+ clientRendered: false,
543
+ }
544
+ }
545
+
546
+ const clientSourcePath = path.join(projectRoot, widget.entry)
547
+
548
+ if (!fs.existsSync(clientSourcePath) || !fs.statSync(clientSourcePath).isFile()) {
549
+ throw createAndroidGeneratedFilesError(`[voltra] Dynamic Widget "${widget.id}" entry not found at ${widget.entry}`)
550
+ }
551
+
552
+ const widgetModule = safelyEvaluateDynamicWidget(projectRoot, widget.id, widget.entry, clientSourcePath)
553
+ const widgetFn = readDynamicWidgetExport(widget.id, widget.entry, widgetModule)
554
+
555
+ if (typeof widgetFn !== 'function') {
556
+ throw createAndroidGeneratedFilesError(
557
+ `[voltra] Dynamic Widget "${widget.id}" at ${widget.entry} must default-export a function or component.`
558
+ )
559
+ }
560
+
561
+ return {
562
+ ...widget,
563
+ entry: widget.entry,
564
+ clientRendered: true,
565
+ clientSourcePath,
566
+ }
567
+ }
568
+
569
+ async function prerenderClientRenderedAndroidWidgets(
570
+ projectRoot: string,
571
+ widgets: DetectedAndroidWidget[]
572
+ ): Promise<PrerenderedWidgetStates> {
573
+ const clientWidgets = widgets.filter(
574
+ (widget): widget is Extract<DetectedAndroidWidget, { clientRendered: true }> => widget.clientRendered
575
+ )
576
+
577
+ if (clientWidgets.length === 0) {
578
+ return new Map()
579
+ }
580
+
581
+ const renderer = loadAndroidDynamicWidgetRenderer(projectRoot)
582
+ const placeholderEnv = {
583
+ date: Date.now(),
584
+ widgetFamily: '200x200',
585
+ colorScheme: 'light',
586
+ locale: 'en-US',
587
+ configuration: undefined,
588
+ build: DYNAMIC_WIDGET_BUILD_INFO,
589
+ }
590
+ const prerenderedStates: PrerenderedWidgetStates = new Map()
591
+
592
+ for (const widget of clientWidgets) {
593
+ try {
594
+ const widgetModule = safelyEvaluateDynamicWidget(projectRoot, widget.id, widget.entry, widget.clientSourcePath)
595
+ const widgetFn = readDynamicWidgetExport(widget.id, widget.entry, widgetModule)
596
+ const element = widgetFn({}, placeholderEnv)
597
+ prerenderedStates.set(widget.id, new Map([[DEFAULT_INITIAL_STATE_LOCALE, JSON.stringify(renderer(element))]]))
598
+ } catch (error) {
599
+ if (error instanceof AndroidGeneratedFilesError) {
600
+ throw error
601
+ }
602
+
603
+ throw createAndroidGeneratedFilesError(
604
+ `[voltra] Dynamic Widget "${widget.id}" failed placeholder prerender at ${widget.entry}: ${
605
+ error instanceof Error ? error.message : String(error)
606
+ }`
607
+ )
608
+ }
609
+ }
610
+
611
+ return prerenderedStates
612
+ }
613
+
614
+ function safelyEvaluateDynamicWidget(
615
+ projectRoot: string,
616
+ widgetId: string,
617
+ widgetEntry: string,
618
+ clientSourcePath: string
619
+ ): unknown {
620
+ try {
621
+ return evaluateWidgetModuleExports(projectRoot, clientSourcePath, createAndroidGeneratedFilesError)
622
+ } catch (error) {
623
+ if (error instanceof AndroidGeneratedFilesError) {
624
+ throw error
625
+ }
626
+
627
+ throw createAndroidGeneratedFilesError(
628
+ `[voltra] Dynamic Widget "${widgetId}" failed to evaluate entry at ${widgetEntry}: ${
629
+ error instanceof Error ? error.message : String(error)
630
+ }`
631
+ )
632
+ }
633
+ }
634
+
635
+ function readDynamicWidgetExport(
636
+ widgetId: string,
637
+ widgetEntry: string,
638
+ widgetModule: unknown
639
+ ): (props: unknown, env: unknown) => unknown {
640
+ const exports =
641
+ widgetModule && typeof widgetModule === 'object'
642
+ ? (widgetModule as { default?: unknown })
643
+ : { default: widgetModule }
644
+ const widgetFn = exports.default ?? widgetModule
645
+
646
+ if (typeof widgetFn !== 'function') {
647
+ throw createAndroidGeneratedFilesError(
648
+ `[voltra] Dynamic Widget "${widgetId}" at ${widgetEntry} must default-export a function or component.`
649
+ )
650
+ }
651
+
652
+ return widgetFn as (props: unknown, env: unknown) => unknown
653
+ }
654
+
655
+ async function generateAndroidConfigDefaults(
656
+ projectRoot: string,
657
+ resourceRoot: string,
658
+ widgets: DetectedAndroidWidget[]
659
+ ): Promise<GeneratedFileResult | undefined> {
660
+ const defaults = createWidgetConfigDefaults(widgets)
661
+
662
+ if (Object.keys(defaults).length === 0) {
663
+ return undefined
664
+ }
665
+
666
+ return writeGeneratedTextFile(
667
+ projectRoot,
668
+ path.join(resourceRoot, ANDROID_WIDGET_CONFIG_DEFAULTS_PATH),
669
+ `${JSON.stringify(defaults, null, 2)}\n`
670
+ )
671
+ }
672
+
673
+ function createWidgetConfigDefaults(widgets: DetectedAndroidWidget[]): WidgetConfigDefaults {
674
+ const defaults: WidgetConfigDefaults = {}
675
+
676
+ for (const widget of widgets) {
677
+ if (!widget.clientRendered) {
678
+ continue
679
+ }
680
+
681
+ const parameters = widget.appIntent?.parameters ?? []
682
+ const widgetDefaults = Object.fromEntries(
683
+ parameters
684
+ .filter((parameter): parameter is AndroidWidgetAppIntentParameter & { default: string } => {
685
+ return typeof parameter.default === 'string'
686
+ })
687
+ .map((parameter) => [parameter.name, parameter.default])
688
+ )
689
+
690
+ if (Object.keys(widgetDefaults).length > 0) {
691
+ defaults[widget.id] = widgetDefaults
692
+ }
693
+ }
694
+
695
+ return defaults
696
+ }
697
+
698
+ function createProjectRequire(projectRoot: string): NodeRequire {
699
+ return createRequire(path.join(projectRoot, 'package.json'))
700
+ }
701
+
702
+ function convertPrerenderedStatesToObject(prerenderedStates: PrerenderedWidgetStates): Record<string, unknown> {
703
+ const result: Record<string, unknown> = {}
704
+
705
+ for (const [widgetId, localeStates] of prerenderedStates.entries()) {
706
+ if (localeStates.size === 1 && localeStates.has(DEFAULT_INITIAL_STATE_LOCALE)) {
707
+ result[widgetId] = JSON.parse(localeStates.get(DEFAULT_INITIAL_STATE_LOCALE) ?? 'null')
708
+ continue
709
+ }
710
+
711
+ const localizedStates: Record<string, unknown> = {}
712
+
713
+ for (const [localeKey, state] of localeStates.entries()) {
714
+ localizedStates[localeKey] = JSON.parse(state)
715
+ }
716
+
717
+ result[widgetId] = {
718
+ [LOCALIZED_INITIAL_STATE_KEY]: localizedStates,
719
+ }
720
+ }
721
+
722
+ return result
723
+ }
724
+
725
+ async function resolveFontPaths(projectRoot: string, fonts: string[]): Promise<string[]> {
726
+ const projectRequire = createProjectRequire(projectRoot)
727
+ const resolvedFontPaths = new Set<string>()
728
+
729
+ for (const font of fonts) {
730
+ const resolvedFontInput = await resolveFontInput(projectRoot, font, projectRequire)
731
+
732
+ if (!resolvedFontInput) {
733
+ throw new AndroidGeneratedFilesError(`Could not resolve Android font path: ${font}`)
734
+ }
735
+
736
+ const stat = await fsPromises.stat(resolvedFontInput)
737
+
738
+ if (!stat.isDirectory()) {
739
+ if (FONT_EXTENSIONS.has(path.extname(resolvedFontInput).toLowerCase())) {
740
+ resolvedFontPaths.add(resolvedFontInput)
741
+ }
742
+
743
+ continue
744
+ }
745
+
746
+ for (const entry of await fsPromises.readdir(resolvedFontInput)) {
747
+ const candidatePath = path.join(resolvedFontInput, entry)
748
+
749
+ if (FONT_EXTENSIONS.has(path.extname(candidatePath).toLowerCase())) {
750
+ resolvedFontPaths.add(candidatePath)
751
+ }
752
+ }
753
+ }
754
+
755
+ return [...resolvedFontPaths].sort()
756
+ }
757
+
758
+ async function resolveFontInput(
759
+ projectRoot: string,
760
+ input: string,
761
+ projectRequire: NodeRequire
762
+ ): Promise<string | null> {
763
+ const resolvedPath = path.isAbsolute(input) ? input : path.resolve(projectRoot, input)
764
+
765
+ if (await pathExists(resolvedPath)) {
766
+ return resolvedPath
767
+ }
768
+
769
+ try {
770
+ return projectRequire.resolve(input)
771
+ } catch {
772
+ return null
773
+ }
774
+ }
775
+
776
+ async function collectUserAssetPaths(projectRoot: string, userImagesPath: string): Promise<string[]> {
777
+ const resolvedUserImagesPath = path.isAbsolute(userImagesPath)
778
+ ? userImagesPath
779
+ : path.resolve(projectRoot, userImagesPath)
780
+
781
+ if (!(await pathExists(resolvedUserImagesPath))) {
782
+ return []
783
+ }
784
+
785
+ const collectedPaths: string[] = []
786
+
787
+ await collectPathsRecursively(resolvedUserImagesPath, collectedPaths)
788
+ return collectedPaths.sort()
789
+ }
790
+
791
+ async function collectPathsRecursively(currentPath: string, collectedPaths: string[]): Promise<void> {
792
+ const stat = await fsPromises.lstat(currentPath)
793
+
794
+ if (!stat.isDirectory()) {
795
+ if (!path.basename(currentPath).startsWith('.')) {
796
+ collectedPaths.push(currentPath)
797
+ }
798
+ return
799
+ }
800
+
801
+ for (const entry of await fsPromises.readdir(currentPath)) {
802
+ await collectPathsRecursively(path.join(currentPath, entry), collectedPaths)
803
+ }
804
+ }
805
+
806
+ async function convertSvgToVectorDrawable(
807
+ projectRoot: string,
808
+ sourcePath: string,
809
+ destinationSvgPath: string
810
+ ): Promise<GeneratedFileResult> {
811
+ await ensureDirectory(path.dirname(destinationSvgPath))
812
+ const sourceContent = await fsPromises.readFile(sourcePath)
813
+ const existingSvgContent = (await pathExists(destinationSvgPath))
814
+ ? await fsPromises.readFile(destinationSvgPath)
815
+ : undefined
816
+ const vectorDrawablePath = destinationSvgPath.replace(/\.svg$/i, '.xml')
817
+ const existingVectorDrawableContent = (await pathExists(vectorDrawablePath))
818
+ ? await fsPromises.readFile(vectorDrawablePath)
819
+ : undefined
820
+
821
+ try {
822
+ await fsPromises.writeFile(destinationSvgPath, sourceContent)
823
+ await vdConvert(destinationSvgPath)
824
+ } catch (error: unknown) {
825
+ if (existingSvgContent) {
826
+ await fsPromises.writeFile(destinationSvgPath, existingSvgContent)
827
+ } else {
828
+ await fsPromises.rm(destinationSvgPath, { force: true })
829
+ }
830
+
831
+ if (existingVectorDrawableContent) {
832
+ await fsPromises.writeFile(vectorDrawablePath, existingVectorDrawableContent)
833
+ } else {
834
+ await fsPromises.rm(vectorDrawablePath, { force: true })
835
+ }
836
+
837
+ throw new AndroidGeneratedFilesError(
838
+ `Failed to convert SVG asset ${sourcePath}: ${error instanceof Error ? error.message : String(error)}`
839
+ )
840
+ }
841
+
842
+ await fsPromises.rm(destinationSvgPath, { force: true })
843
+ const nextVectorDrawableContent = await fsPromises.readFile(vectorDrawablePath)
844
+
845
+ return {
846
+ change:
847
+ existingVectorDrawableContent && Buffer.compare(existingVectorDrawableContent, nextVectorDrawableContent) === 0
848
+ ? undefined
849
+ : {
850
+ kind: existingVectorDrawableContent ? 'updated' : 'created',
851
+ path: toRelativePath(projectRoot, vectorDrawablePath),
852
+ },
853
+ relativePath: toRelativePath(projectRoot, vectorDrawablePath),
854
+ }
855
+ }
856
+
857
+ async function copyGeneratedFile(
858
+ projectRoot: string,
859
+ sourcePath: string,
860
+ destinationPath: string
861
+ ): Promise<GeneratedFileResult> {
862
+ await ensureDirectory(path.dirname(destinationPath))
863
+ const sourceContent = await fsPromises.readFile(sourcePath)
864
+ const existingContent = (await pathExists(destinationPath)) ? await fsPromises.readFile(destinationPath) : undefined
865
+ const relativePath = toRelativePath(projectRoot, destinationPath)
866
+
867
+ if (existingContent && Buffer.compare(existingContent, sourceContent) === 0) {
868
+ return {
869
+ relativePath,
870
+ }
871
+ }
872
+
873
+ await fsPromises.writeFile(destinationPath, sourceContent)
874
+
875
+ return {
876
+ change: {
877
+ kind: existingContent ? 'updated' : 'created',
878
+ path: relativePath,
879
+ },
880
+ relativePath,
881
+ }
882
+ }
883
+
884
+ async function writeGeneratedTextFile(
885
+ projectRoot: string,
886
+ destinationPath: string,
887
+ content: string
888
+ ): Promise<GeneratedFileResult> {
889
+ const existingContent = (await pathExists(destinationPath)) ? await readTextFile(destinationPath) : undefined
890
+ const relativePath = toRelativePath(projectRoot, destinationPath)
891
+
892
+ if (existingContent === content) {
893
+ return {
894
+ relativePath,
895
+ }
896
+ }
897
+
898
+ await writeTextFile(destinationPath, content)
899
+
900
+ return {
901
+ change: {
902
+ kind: existingContent === undefined ? 'created' : 'updated',
903
+ path: relativePath,
904
+ },
905
+ relativePath,
906
+ }
907
+ }
908
+
909
+ async function getLargeImageWarning(imagePath: string, fileName: string): Promise<string | undefined> {
910
+ const stat = await fsPromises.stat(imagePath)
911
+
912
+ if (stat.size < MAX_IMAGE_SIZE_BYTES) {
913
+ return undefined
914
+ }
915
+
916
+ return `Image '${fileName}' is ${stat.size} bytes. Large Android widget images may not display correctly.`
917
+ }
918
+
919
+ function generateWidgetReceiverContent(widget: DetectedAndroidWidget, packageName: string): string {
920
+ const className = `VoltraWidget_${widget.id}Receiver`
921
+ const labelForComment = widgetLabelEnglish(widget.displayName)
922
+
923
+ if (widget.clientRendered) {
924
+ return [
925
+ `package ${packageName}.widget`,
926
+ '',
927
+ 'import voltra.widget.VoltraClientWidgetReceiver',
928
+ '',
929
+ '/**',
930
+ ` * Auto-generated Dynamic Widget receiver for ${labelForComment}`,
931
+ ` * Widget ID: ${widget.id}`,
932
+ ' */',
933
+ `class ${className} : VoltraClientWidgetReceiver() {`,
934
+ ` override val widgetId: String = "${widget.id}"`,
935
+ '}',
936
+ '',
937
+ ].join('\n')
938
+ }
939
+
940
+ if (widget.serverUpdate) {
941
+ const refreshEnabled = widget.serverUpdate.refresh === true
942
+
943
+ return [
944
+ `package ${packageName}.widget`,
945
+ '',
946
+ 'import android.appwidget.AppWidgetManager',
947
+ 'import android.content.Context',
948
+ 'import voltra.widget.VoltraWidgetReceiver',
949
+ 'import voltra.widget.VoltraWidgetUpdateScheduler',
950
+ '',
951
+ '/**',
952
+ ` * Auto-generated widget receiver for ${labelForComment}`,
953
+ ` * Widget ID: ${widget.id}`,
954
+ ` * Server Update: ${widget.serverUpdate.url} (every ${widget.serverUpdate.intervalMinutes} minutes)`,
955
+ ` * Refresh Button: ${String(refreshEnabled)}`,
956
+ ' */',
957
+ `class ${className} : VoltraWidgetReceiver() {`,
958
+ ` override val widgetId: String = "${widget.id}"`,
959
+ '',
960
+ ' override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {',
961
+ ' super.onUpdate(context, appWidgetManager, appWidgetIds)',
962
+ '',
963
+ ' VoltraWidgetUpdateScheduler.schedulePeriodicUpdate(',
964
+ ' context = context,',
965
+ ` widgetId = "${widget.id}",`,
966
+ ` serverUrl = "${widget.serverUpdate.url}",`,
967
+ ` intervalMinutes = ${widget.serverUpdate.intervalMinutes}L,`,
968
+ ` refreshEnabled = ${String(refreshEnabled)}`,
969
+ ' )',
970
+ ' }',
971
+ '',
972
+ ' override fun onDeleted(context: Context, appWidgetIds: IntArray) {',
973
+ ' super.onDeleted(context, appWidgetIds)',
974
+ '',
975
+ ' val remaining = appWidgetManager(context, appWidgetIds)',
976
+ ' if (remaining == 0) {',
977
+ ` VoltraWidgetUpdateScheduler.cancelPeriodicUpdate(context, "${widget.id}")`,
978
+ ' }',
979
+ ' }',
980
+ '',
981
+ ' private fun appWidgetManager(context: Context, deletedIds: IntArray): Int {',
982
+ ' val manager = AppWidgetManager.getInstance(context)',
983
+ ' val componentName = android.content.ComponentName(context, this::class.java)',
984
+ ' val allIds = manager.getAppWidgetIds(componentName)',
985
+ ' return allIds.count { it !in deletedIds }',
986
+ ' }',
987
+ '}',
988
+ '',
989
+ ].join('\n')
990
+ }
991
+
992
+ return [
993
+ `package ${packageName}.widget`,
994
+ '',
995
+ 'import voltra.widget.VoltraWidgetReceiver',
996
+ '',
997
+ '/**',
998
+ ` * Auto-generated widget receiver for ${labelForComment}`,
999
+ ` * Widget ID: ${widget.id}`,
1000
+ ' */',
1001
+ `class ${className} : VoltraWidgetReceiver() {`,
1002
+ ` override val widgetId: String = "${widget.id}"`,
1003
+ '}',
1004
+ '',
1005
+ ].join('\n')
1006
+ }
1007
+
1008
+ function generateWidgetInfoXml(
1009
+ widget: NormalizedAndroidWidgetConfig,
1010
+ previewImageResourceName?: string,
1011
+ previewLayoutResourceName?: string
1012
+ ): string {
1013
+ const minWidth = widget.minWidth ?? (widget.minCellWidth !== undefined ? widget.minCellWidth * 70 - 30 : undefined)
1014
+ const minHeight =
1015
+ widget.minHeight ?? (widget.minCellHeight !== undefined ? widget.minCellHeight * 70 - 30 : undefined)
1016
+ const resizeMode = widget.resizeMode ?? 'horizontal|vertical'
1017
+ const widgetCategory = widget.widgetCategory ?? 'home_screen'
1018
+ const lines = [
1019
+ '<?xml version="1.0" encoding="utf-8"?>',
1020
+ `<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"${
1021
+ minWidth !== undefined ? ` android:minWidth="${minWidth}dp"` : ''
1022
+ }${minHeight !== undefined ? ` android:minHeight="${minHeight}dp"` : ''}`,
1023
+ ` android:targetCellWidth="${widget.targetCellWidth}"`,
1024
+ ` android:targetCellHeight="${widget.targetCellHeight}"`,
1025
+ ' android:updatePeriodMillis="0"',
1026
+ ' android:initialLayout="@layout/voltra_widget_placeholder"',
1027
+ ` android:resizeMode="${resizeMode}"`,
1028
+ ` android:widgetCategory="${widgetCategory}"`,
1029
+ ` android:description="@string/voltra_widget_${widget.id}_description"${
1030
+ previewImageResourceName ? ` android:previewImage="@drawable/${previewImageResourceName}"` : ''
1031
+ }${previewLayoutResourceName ? ` android:previewLayout="@layout/${previewLayoutResourceName}"` : ''}>`,
1032
+ '</appwidget-provider>',
1033
+ '',
1034
+ ]
1035
+
1036
+ return lines.join('\n')
1037
+ }
1038
+
1039
+ function generatePlaceholderLayoutXml(): string {
1040
+ return [
1041
+ '<?xml version="1.0" encoding="utf-8"?>',
1042
+ '<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"',
1043
+ ' android:layout_width="match_parent"',
1044
+ ' android:layout_height="match_parent"',
1045
+ ' android:background="?android:attr/colorBackground">',
1046
+ ' <ProgressBar',
1047
+ ' style="?android:attr/progressBarStyle"',
1048
+ ' android:layout_width="wrap_content"',
1049
+ ' android:layout_height="wrap_content"',
1050
+ ' android:layout_gravity="center"',
1051
+ ' android:indeterminate="true" />',
1052
+ '</FrameLayout>',
1053
+ '',
1054
+ ].join('\n')
1055
+ }
1056
+
1057
+ function generateAutoImagePreviewLayout(widgetId: string, drawableResourceName: string): string {
1058
+ return [
1059
+ '<?xml version="1.0" encoding="utf-8"?>',
1060
+ '<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"',
1061
+ ' android:layout_width="match_parent"',
1062
+ ' android:layout_height="match_parent">',
1063
+ ' <ImageView',
1064
+ ' android:layout_width="match_parent"',
1065
+ ' android:layout_height="match_parent"',
1066
+ ` android:src="@drawable/${drawableResourceName}"`,
1067
+ ' android:scaleType="centerCrop"',
1068
+ ` android:contentDescription="@string/voltra_widget_${widgetId}_description" />`,
1069
+ '</FrameLayout>',
1070
+ '',
1071
+ ].join('\n')
1072
+ }
1073
+
1074
+ function generateWidgetStringsXml(widgets: NormalizedAndroidWidgetConfig[], localeKey: string | null): string {
1075
+ const localeComment =
1076
+ localeKey === null
1077
+ ? 'default (values/)'
1078
+ : `locale ${localeKey} → values-${localeKeyToAndroidValuesQualifier(localeKey)}`
1079
+ const entries = widgets
1080
+ .map((widget) => {
1081
+ const label = escapeAndroidString(resolveWidgetLabel(widget.displayName, localeKey))
1082
+ const description = escapeAndroidString(resolveWidgetLabel(widget.description, localeKey))
1083
+ return ` <string name="voltra_widget_${widget.id}_label">${label}</string>\n <string name="voltra_widget_${widget.id}_description">${description}</string>`
1084
+ })
1085
+ .join('\n')
1086
+
1087
+ return [
1088
+ '<?xml version="1.0" encoding="utf-8"?>',
1089
+ '<resources>',
1090
+ ` <!-- Voltra widget picker strings (auto-generated). ${localeComment} -->`,
1091
+ entries,
1092
+ '</resources>',
1093
+ '',
1094
+ ].join('\n')
1095
+ }
1096
+
1097
+ function collectWidgetLocaleKeys(widgets: NormalizedAndroidWidgetConfig[]): Set<string> {
1098
+ const localeKeys = new Set<string>()
1099
+
1100
+ for (const widget of widgets) {
1101
+ for (const value of [widget.displayName, widget.description]) {
1102
+ if (isWidgetLocalizedMap(value)) {
1103
+ for (const [localeKey, text] of Object.entries(value)) {
1104
+ if (text.trim()) {
1105
+ localeKeys.add(localeKey)
1106
+ }
1107
+ }
1108
+ }
1109
+ }
1110
+ }
1111
+
1112
+ return localeKeys
1113
+ }
1114
+
1115
+ function resolveWidgetLabel(label: WidgetLabel, localeKey: string | null): string {
1116
+ if (!isWidgetLocalizedMap(label)) {
1117
+ return label
1118
+ }
1119
+
1120
+ if (localeKey !== null) {
1121
+ const localizedValue = label[localeKey]
1122
+
1123
+ if (typeof localizedValue === 'string' && localizedValue.trim()) {
1124
+ return localizedValue
1125
+ }
1126
+ }
1127
+
1128
+ return widgetLabelEnglish(label)
1129
+ }
1130
+
1131
+ function widgetLabelEnglish(label: WidgetLabel): string {
1132
+ if (!isWidgetLocalizedMap(label)) {
1133
+ return label
1134
+ }
1135
+
1136
+ const englishLabel = label.en
1137
+
1138
+ if (typeof englishLabel === 'string' && englishLabel.trim()) {
1139
+ return englishLabel
1140
+ }
1141
+
1142
+ for (const [localeKey, value] of Object.entries(label)) {
1143
+ if ((localeKey.startsWith('en-') || localeKey.startsWith('en_')) && value.trim()) {
1144
+ return value
1145
+ }
1146
+ }
1147
+
1148
+ return Object.values(label).find((value) => value.trim()) ?? ''
1149
+ }
1150
+
1151
+ function isWidgetLocalizedMap(label: WidgetLabel): label is Record<string, string> {
1152
+ return typeof label === 'object' && label !== null && !Array.isArray(label)
1153
+ }
1154
+
1155
+ function localeKeyToAndroidValuesQualifier(localeKey: string): string {
1156
+ const normalized = localeKey.trim().replace(/_/g, '-')
1157
+ const segments = normalized.split('-').filter(Boolean)
1158
+ const language = segments[0]?.toLowerCase()
1159
+
1160
+ if (!language) {
1161
+ return normalized.toLowerCase()
1162
+ }
1163
+
1164
+ const rest = segments.slice(1)
1165
+
1166
+ if (rest.length === 0) {
1167
+ return language
1168
+ }
1169
+
1170
+ const [first, ...tail] = rest
1171
+
1172
+ if (first && isRegionSubtag(first) && tail.length === 0) {
1173
+ return `${language}-r${first.toUpperCase()}`
1174
+ }
1175
+
1176
+ const bcp47Segments = [language]
1177
+
1178
+ for (const segment of rest) {
1179
+ if (isScriptSubtag(segment)) {
1180
+ bcp47Segments.push(formatScriptSubtag(segment))
1181
+ continue
1182
+ }
1183
+
1184
+ if (isRegionSubtag(segment)) {
1185
+ bcp47Segments.push(segment.toUpperCase())
1186
+ continue
1187
+ }
1188
+
1189
+ bcp47Segments.push(segment.toLowerCase())
1190
+ }
1191
+
1192
+ return `b+${bcp47Segments.join('+')}`
1193
+ }
1194
+
1195
+ function escapeAndroidString(value: string): string {
1196
+ return value
1197
+ .replace(/\\/g, '\\\\')
1198
+ .replace(/"/g, '\\"')
1199
+ .replace(/'/g, "\\'")
1200
+ .replace(/\n/g, '\\n')
1201
+ .replace(/\r/g, '\\r')
1202
+ .replace(/&/g, '&amp;')
1203
+ .replace(/</g, '&lt;')
1204
+ .replace(/>/g, '&gt;')
1205
+ }
1206
+
1207
+ function sanitizeDrawableName(filePath: string): string {
1208
+ const directoryName = path.dirname(filePath)
1209
+ const fileName = path.parse(filePath).name
1210
+ const nameParts: string[] = []
1211
+
1212
+ if (directoryName !== '.') {
1213
+ nameParts.push(
1214
+ ...directoryName
1215
+ .split(path.sep)
1216
+ .filter(
1217
+ (segment) => segment !== '.' && segment !== 'assets' && segment !== 'voltra' && segment !== 'voltra-android'
1218
+ )
1219
+ )
1220
+ }
1221
+
1222
+ nameParts.push(fileName)
1223
+
1224
+ let sanitizedName = nameParts
1225
+ .join('_')
1226
+ .toLowerCase()
1227
+ .replace(/[^a-z0-9_]/g, '_')
1228
+
1229
+ if (!/^[a-z]/.test(sanitizedName)) {
1230
+ sanitizedName = `img_${sanitizedName}`
1231
+ }
1232
+
1233
+ return sanitizedName
1234
+ }
1235
+
1236
+ function getPreviewImageResourceName(widget: Pick<NormalizedAndroidWidgetConfig, 'id'>): string {
1237
+ return `voltra_widget_${widget.id}_preview`
1238
+ }
1239
+
1240
+ function assertValidWidgetId(widgetId: string): void {
1241
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(widgetId)) {
1242
+ throw new AndroidGeneratedFilesError(
1243
+ `Widget ID '${widgetId}' is invalid. Must start with a letter or underscore and contain only alphanumeric characters and underscores.`
1244
+ )
1245
+ }
1246
+ }
1247
+
1248
+ function isScriptSubtag(value: string): boolean {
1249
+ return value.length === 4 && /^[a-z]+$/i.test(value)
1250
+ }
1251
+
1252
+ function isRegionSubtag(value: string): boolean {
1253
+ return (value.length === 2 && /^[a-z]+$/i.test(value)) || (value.length === 3 && /^\d+$/.test(value))
1254
+ }
1255
+
1256
+ function formatScriptSubtag(value: string): string {
1257
+ const lower = value.toLowerCase()
1258
+ return `${lower[0]?.toUpperCase() ?? ''}${lower.slice(1)}`
1259
+ }
1260
+
1261
+ function mergeResult(
1262
+ result: GenerateAndroidFilesResult,
1263
+ changes: ReportedChange[],
1264
+ warnings: string[],
1265
+ generatedFiles: Set<string>
1266
+ ): void {
1267
+ changes.push(...result.changes)
1268
+ warnings.push(...result.warnings)
1269
+
1270
+ for (const filePath of result.files) {
1271
+ generatedFiles.add(normalizeRelativePath(filePath))
1272
+ }
1273
+ }
1274
+
1275
+ function mergeSingleResult(result: GeneratedFileResult, changes: ReportedChange[], generatedFiles: Set<string>): void {
1276
+ pushChange(changes, result.change)
1277
+ generatedFiles.add(normalizeRelativePath(result.relativePath))
1278
+ }
1279
+
1280
+ function pushChange(changes: ReportedChange[], change: ReportedChange | undefined): void {
1281
+ if (change) {
1282
+ changes.push(change)
1283
+ }
1284
+ }