voltra 2.0.0-rc.4 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/cjs/config/normalize.js +93 -0
- package/build/cjs/config/normalize.js.map +1 -1
- package/build/cjs/index.js +5 -2
- package/build/cjs/index.js.map +1 -1
- package/build/cjs/platforms/android/apply.js +11 -2
- package/build/cjs/platforms/android/apply.js.map +1 -1
- package/build/cjs/platforms/android/generated.js +152 -130
- package/build/cjs/platforms/android/generated.js.map +1 -1
- package/build/cjs/platforms/android/gradle.js +120 -0
- package/build/cjs/platforms/android/gradle.js.map +1 -0
- package/build/cjs/platforms/ios/generated.js +246 -142
- package/build/cjs/platforms/ios/generated.js.map +1 -1
- package/build/cjs/platforms/ios/xcodeTarget.js +68 -0
- package/build/cjs/platforms/ios/xcodeTarget.js.map +1 -1
- package/build/cjs/platforms/shared/widgetModule.js +151 -0
- package/build/cjs/platforms/shared/widgetModule.js.map +1 -0
- package/build/esm/config/normalize.js +93 -0
- package/build/esm/config/normalize.js.map +1 -1
- package/build/esm/index.js +1 -0
- package/build/esm/index.js.map +1 -1
- package/build/esm/platforms/android/apply.js +11 -2
- package/build/esm/platforms/android/apply.js.map +1 -1
- package/build/esm/platforms/android/generated.js +152 -97
- package/build/esm/platforms/android/generated.js.map +1 -1
- package/build/esm/platforms/android/gradle.js +112 -0
- package/build/esm/platforms/android/gradle.js.map +1 -0
- package/build/esm/platforms/ios/generated.js +246 -109
- package/build/esm/platforms/ios/generated.js.map +1 -1
- package/build/esm/platforms/ios/xcodeTarget.js +69 -1
- package/build/esm/platforms/ios/xcodeTarget.js.map +1 -1
- package/build/esm/platforms/shared/widgetModule.js +111 -0
- package/build/esm/platforms/shared/widgetModule.js.map +1 -0
- package/build/types/config/normalize.d.ts.map +1 -1
- package/build/types/config/types.d.ts +32 -0
- package/build/types/config/types.d.ts.map +1 -1
- package/build/types/index.d.ts +3 -1
- package/build/types/index.d.ts.map +1 -1
- package/build/types/platforms/android/apply.d.ts.map +1 -1
- package/build/types/platforms/android/generated.d.ts.map +1 -1
- package/build/types/platforms/android/gradle.d.ts +18 -0
- package/build/types/platforms/android/gradle.d.ts.map +1 -0
- package/build/types/platforms/ios/generated.d.ts.map +1 -1
- package/build/types/platforms/ios/xcodeTarget.d.ts.map +1 -1
- package/build/types/platforms/shared/widgetModule.d.ts +9 -0
- package/build/types/platforms/shared/widgetModule.d.ts.map +1 -0
- package/package.json +2 -1
- package/src/apply/index.ts +309 -0
- package/src/apply/preflight.ts +172 -0
- package/src/bin.ts +14 -0
- package/src/commands/apply.ts +114 -0
- package/src/config/defaults.ts +34 -0
- package/src/config/load.ts +100 -0
- package/src/config/normalize.ts +562 -0
- package/src/config/types.ts +317 -0
- package/src/cosmiconfig.d.ts +21 -0
- package/src/dependencies/platformPackages.ts +75 -0
- package/src/discovery/android.ts +233 -0
- package/src/discovery/ios.ts +579 -0
- package/src/fs/path.ts +25 -0
- package/src/fs/readWrite.ts +102 -0
- package/src/git/status.ts +205 -0
- package/src/index.ts +204 -0
- package/src/platforms/android/apply.ts +106 -0
- package/src/platforms/android/generated.ts +1284 -0
- package/src/platforms/android/gradle.ts +155 -0
- package/src/platforms/android/manifest.ts +379 -0
- package/src/platforms/ios/apply.ts +139 -0
- package/src/platforms/ios/deploymentTarget.ts +47 -0
- package/src/platforms/ios/entitlements.ts +146 -0
- package/src/platforms/ios/generated.ts +1233 -0
- package/src/platforms/ios/mainAppEntitlements.ts +11 -0
- package/src/platforms/ios/plist.ts +321 -0
- package/src/platforms/ios/podfile.ts +255 -0
- package/src/platforms/ios/targetName.ts +11 -0
- package/src/platforms/ios/xcode.ts +181 -0
- package/src/platforms/ios/xcodeTarget.ts +899 -0
- package/src/platforms/shared/widgetModule.ts +153 -0
- package/src/reporting/clack.ts +67 -0
- package/src/reporting/summary.ts +116 -0
- package/src/state/diff.ts +21 -0
- package/src/state/files.ts +52 -0
- package/src/state/load.ts +66 -0
- package/src/state/save.ts +31 -0
- 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, '&')
|
|
1203
|
+
.replace(/</g, '<')
|
|
1204
|
+
.replace(/>/g, '>')
|
|
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
|
+
}
|