@use-voltra/expo-plugin 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.
@@ -0,0 +1,95 @@
1
+ import fs from 'fs'
2
+ import os from 'os'
3
+ import path from 'path'
4
+
5
+ import { validateInitialStatePath, validateWidgetEntry } from './validation'
6
+
7
+ function createProjectRoot(files: Record<string, string>): string {
8
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-widget-entry-'))
9
+
10
+ for (const [relativePath, contents] of Object.entries(files)) {
11
+ const filePath = path.join(root, relativePath)
12
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
13
+ fs.writeFileSync(filePath, contents)
14
+ }
15
+
16
+ return root
17
+ }
18
+
19
+ describe('validateWidgetEntry', () => {
20
+ it('accepts a relative entry and normalizes it to a POSIX project-relative path', () => {
21
+ const projectRoot = createProjectRoot({
22
+ 'widgets/home.tsx': 'export default function HomeWidget() {}',
23
+ })
24
+
25
+ try {
26
+ expect(validateWidgetEntry('./widgets/home.tsx', 'home', projectRoot)).toBe('widgets/home.tsx')
27
+ } finally {
28
+ fs.rmSync(projectRoot, { recursive: true, force: true })
29
+ }
30
+ })
31
+
32
+ it('accepts a valid relative entry without a project root', () => {
33
+ expect(validateWidgetEntry('./widgets/home.mjs', 'home')).toBe('widgets/home.mjs')
34
+ })
35
+
36
+ it('rejects missing entries', () => {
37
+ expect(() => validateWidgetEntry(undefined, 'home')).toThrow(/Widget 'home': entry is required/)
38
+ })
39
+
40
+ it('rejects absolute entry paths', () => {
41
+ const projectRoot = createProjectRoot({})
42
+
43
+ try {
44
+ expect(() => validateWidgetEntry(path.join(projectRoot, 'widgets/home.tsx'), 'home', projectRoot)).toThrow(
45
+ /Widget 'home': entry must be a relative path, not an absolute path/
46
+ )
47
+ } finally {
48
+ fs.rmSync(projectRoot, { recursive: true, force: true })
49
+ }
50
+ })
51
+
52
+ it('rejects entries that escape the project root', () => {
53
+ const projectRoot = createProjectRoot({})
54
+
55
+ try {
56
+ expect(() => validateWidgetEntry('../outside.tsx', 'home', projectRoot)).toThrow(
57
+ /Widget 'home': entry must stay within the project root after normalization/
58
+ )
59
+ } finally {
60
+ fs.rmSync(projectRoot, { recursive: true, force: true })
61
+ }
62
+ })
63
+
64
+ it('rejects unsupported entry extensions', () => {
65
+ expect(() => validateWidgetEntry('./widgets/home.txt', 'home')).toThrow(
66
+ /Widget 'home': entry must use a Metro-importable source extension/
67
+ )
68
+ })
69
+
70
+ it('rejects missing entry files when a project root is supplied', () => {
71
+ const projectRoot = createProjectRoot({})
72
+
73
+ try {
74
+ expect(() => validateWidgetEntry('./widgets/missing.tsx', 'home', projectRoot)).toThrow(
75
+ /Widget 'home': entry file not found at widgets\/missing\.tsx/
76
+ )
77
+ } finally {
78
+ fs.rmSync(projectRoot, { recursive: true, force: true })
79
+ }
80
+ })
81
+ })
82
+
83
+ describe('validateInitialStatePath', () => {
84
+ it('accepts a plain path without projectRoot', () => {
85
+ expect(() => validateInitialStatePath('./widgets/a.tsx', 'w')).not.toThrow()
86
+ })
87
+
88
+ it('accepts a locale map of paths', () => {
89
+ expect(() => validateInitialStatePath({ en: './widgets/en.tsx', pl: './widgets/pl.tsx' }, 'w')).not.toThrow()
90
+ })
91
+
92
+ it('rejects empty locale map', () => {
93
+ expect(() => validateInitialStatePath({} as any, 'w')).toThrow(/must not be empty/)
94
+ })
95
+ })
@@ -0,0 +1,225 @@
1
+ import * as fs from 'fs'
2
+ import * as path from 'path'
3
+
4
+ import type { WidgetInitialStatePath } from './types'
5
+
6
+ /** Widget id: Swift / Kotlin identifier fragment and Android XML token fragment */
7
+ const WIDGET_ID_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/
8
+
9
+ /**
10
+ * Locale keys in app.json become iOS `.lproj` folder names and Android `values-*` qualifiers (after mapping).
11
+ */
12
+ const LOCALE_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9]*([_-][a-zA-Z0-9]+)*$/
13
+
14
+ const MAX_LOCALE_KEY_LENGTH = 32
15
+ const WIDGET_ENTRY_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'])
16
+ const WIDGET_ENTRY_FIELD_NAME = 'entry'
17
+
18
+ function isAbsoluteWidgetPath(value: string): boolean {
19
+ return path.isAbsolute(value) || path.win32.isAbsolute(value) || /^[a-zA-Z]:[\\/]/.test(value)
20
+ }
21
+
22
+ function normalizeWidgetEntry(entry: string): string {
23
+ return path.posix.normalize(entry.replace(/\\/g, '/'))
24
+ }
25
+
26
+ function widgetEntryExtension(entry: string): string {
27
+ return path.posix.extname(entry)
28
+ }
29
+
30
+ export function validateHomeScreenWidgetId(widgetId: unknown): asserts widgetId is string {
31
+ if (!widgetId || typeof widgetId !== 'string') {
32
+ throw new Error('Widget ID is required and must be a string')
33
+ }
34
+
35
+ if (!WIDGET_ID_PATTERN.test(widgetId)) {
36
+ throw new Error(
37
+ `Widget ID '${widgetId}' is invalid. ` +
38
+ 'Must start with a letter or underscore and contain only alphanumeric characters and underscores.'
39
+ )
40
+ }
41
+ }
42
+
43
+ export function assertValidLocaleKey(localeKey: string, widgetId: string, fieldName: string): void {
44
+ if (localeKey.trim() !== localeKey) {
45
+ throw new Error(
46
+ `Widget '${widgetId}': ${fieldName} locale key '${localeKey}' must not have leading or trailing whitespace`
47
+ )
48
+ }
49
+
50
+ if (!localeKey) {
51
+ throw new Error(`Widget '${widgetId}': ${fieldName} locale map contains an empty locale key`)
52
+ }
53
+
54
+ if (localeKey.length > MAX_LOCALE_KEY_LENGTH) {
55
+ throw new Error(
56
+ `Widget '${widgetId}': ${fieldName} locale key '${localeKey}' exceeds ${MAX_LOCALE_KEY_LENGTH} characters`
57
+ )
58
+ }
59
+
60
+ if (!LOCALE_KEY_PATTERN.test(localeKey)) {
61
+ throw new Error(
62
+ `Widget '${widgetId}': ${fieldName} locale key '${localeKey}' is invalid. ` +
63
+ 'Use BCP-47-style tags (e.g. en, pl, pt-BR, zh-Hans). Letters, digits, single separators _ or - only.'
64
+ )
65
+ }
66
+ }
67
+
68
+ export function validateWidgetLabel(value: unknown, widgetId: string, fieldName: string): void {
69
+ if (typeof value === 'string') {
70
+ if (!value.trim()) {
71
+ throw new Error(`Widget '${widgetId}': ${fieldName} is required`)
72
+ }
73
+ return
74
+ }
75
+
76
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
77
+ throw new Error(
78
+ `Widget '${widgetId}': ${fieldName} must be a string or a locale map (e.g. { "en": "...", "pl": "..." })`
79
+ )
80
+ }
81
+
82
+ const entries = Object.entries(value as Record<string, unknown>)
83
+ if (entries.length === 0) {
84
+ throw new Error(`Widget '${widgetId}': ${fieldName} locale map must not be empty`)
85
+ }
86
+
87
+ const localeKeys = new Set<string>()
88
+ for (const [locale, v] of entries) {
89
+ assertValidLocaleKey(locale, widgetId, fieldName)
90
+
91
+ const normalized = locale.toLowerCase().replace(/_/g, '-')
92
+ if (localeKeys.has(normalized)) {
93
+ throw new Error(
94
+ `Widget '${widgetId}': ${fieldName} duplicates locale '${locale}' (underscore and hyphen forms count as the same, e.g. pt_BR vs pt-BR)`
95
+ )
96
+ }
97
+ localeKeys.add(normalized)
98
+
99
+ if (typeof v !== 'string' || !v.trim()) {
100
+ throw new Error(`Widget '${widgetId}': ${fieldName}.${locale} must be a non-empty string`)
101
+ }
102
+ }
103
+ }
104
+
105
+ export function normalizeWidgetEntryPath(entry: string, projectRoot?: string): string {
106
+ const normalizedInput = normalizeWidgetEntry(entry)
107
+
108
+ if (projectRoot) {
109
+ const absolutePath = path.resolve(projectRoot, normalizedInput)
110
+ const relativePath = path.relative(projectRoot, absolutePath)
111
+ return normalizeWidgetEntry(relativePath)
112
+ }
113
+
114
+ return normalizedInput
115
+ }
116
+
117
+ export function validateWidgetEntry(value: unknown, widgetId: string, projectRoot?: string): string {
118
+ if (typeof value !== 'string' || !value.trim()) {
119
+ throw new Error(`Widget '${widgetId}': ${WIDGET_ENTRY_FIELD_NAME} is required`)
120
+ }
121
+
122
+ if (isAbsoluteWidgetPath(value)) {
123
+ throw new Error(`Widget '${widgetId}': ${WIDGET_ENTRY_FIELD_NAME} must be a relative path, not an absolute path`)
124
+ }
125
+
126
+ const normalizedEntry = normalizeWidgetEntryPath(value)
127
+ if (!normalizedEntry || normalizedEntry === '.') {
128
+ throw new Error(`Widget '${widgetId}': ${WIDGET_ENTRY_FIELD_NAME} must point to a file inside the project root`)
129
+ }
130
+
131
+ if (normalizedEntry === '..' || normalizedEntry.startsWith('../')) {
132
+ throw new Error(
133
+ `Widget '${widgetId}': ${WIDGET_ENTRY_FIELD_NAME} must stay within the project root after normalization`
134
+ )
135
+ }
136
+
137
+ const extension = widgetEntryExtension(normalizedEntry)
138
+ if (!WIDGET_ENTRY_EXTENSIONS.has(extension)) {
139
+ throw new Error(
140
+ `Widget '${widgetId}': ${WIDGET_ENTRY_FIELD_NAME} must use a Metro-importable source extension ` +
141
+ `(${Array.from(WIDGET_ENTRY_EXTENSIONS).join(', ')}); received '${extension || '(none)'}'`
142
+ )
143
+ }
144
+
145
+ if (!projectRoot) {
146
+ return normalizedEntry
147
+ }
148
+
149
+ const normalizedInput = normalizeWidgetEntry(value)
150
+ const absolutePath = path.resolve(projectRoot, normalizedInput)
151
+ const normalizedProjectRelativePath = normalizeWidgetEntryPath(normalizedInput, projectRoot)
152
+
153
+ if (normalizedProjectRelativePath === '..' || normalizedProjectRelativePath.startsWith('../')) {
154
+ throw new Error(
155
+ `Widget '${widgetId}': ${WIDGET_ENTRY_FIELD_NAME} must stay within the project root after normalization`
156
+ )
157
+ }
158
+
159
+ if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile()) {
160
+ throw new Error(
161
+ `Widget '${widgetId}': ${WIDGET_ENTRY_FIELD_NAME} file not found at ${normalizedProjectRelativePath}`
162
+ )
163
+ }
164
+
165
+ return normalizedProjectRelativePath
166
+ }
167
+
168
+ /**
169
+ * Validates optional initialStatePath: plain string or locale map of project-relative file paths.
170
+ */
171
+ export function validateInitialStatePath(
172
+ value: WidgetInitialStatePath | undefined,
173
+ widgetId: string,
174
+ projectRoot?: string
175
+ ): void {
176
+ if (value === undefined) {
177
+ return
178
+ }
179
+
180
+ const assertPathExists = (relativePath: string, ctx: string) => {
181
+ if (!relativePath.trim()) {
182
+ throw new Error(`Widget '${widgetId}': initialStatePath ${ctx} must be a non-empty path`)
183
+ }
184
+ if (projectRoot) {
185
+ const fullPath = path.join(projectRoot, relativePath)
186
+ if (!fs.existsSync(fullPath)) {
187
+ throw new Error(`Widget '${widgetId}': initialStatePath file not found at ${relativePath}`)
188
+ }
189
+ }
190
+ }
191
+
192
+ if (typeof value === 'string') {
193
+ assertPathExists(value, '')
194
+ return
195
+ }
196
+
197
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
198
+ throw new Error(
199
+ `Widget '${widgetId}': initialStatePath must be a string or a locale map of paths (e.g. { "en": "./widgets/en.tsx", "pl": "./widgets/pl.tsx" })`
200
+ )
201
+ }
202
+
203
+ const entries = Object.entries(value as Record<string, unknown>)
204
+ if (entries.length === 0) {
205
+ throw new Error(`Widget '${widgetId}': initialStatePath locale map must not be empty`)
206
+ }
207
+
208
+ const localeKeys = new Set<string>()
209
+ for (const [locale, v] of entries) {
210
+ assertValidLocaleKey(locale, widgetId, 'initialStatePath')
211
+
212
+ const normalized = locale.toLowerCase().replace(/_/g, '-')
213
+ if (localeKeys.has(normalized)) {
214
+ throw new Error(
215
+ `Widget '${widgetId}': initialStatePath duplicates locale '${locale}' (underscore and hyphen forms count as the same)`
216
+ )
217
+ }
218
+ localeKeys.add(normalized)
219
+
220
+ if (typeof v !== 'string' || !v.trim()) {
221
+ throw new Error(`Widget '${widgetId}': initialStatePath.${locale} must be a non-empty path string`)
222
+ }
223
+ assertPathExists(v, `.${locale}`)
224
+ }
225
+ }