@beforesemicolon/site-builder 0.35.0 → 0.36.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.
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Get file content from repository via Netlify Function
3
+ * @param {string} filePath - Path to file in repository
4
+ * @returns {Promise<{content: string, sha: string}>} File content and SHA
5
+ */
6
+ export async function getFileContent(filePath) {
7
+ try {
8
+ const response = await fetch('/api/github', {
9
+ method: 'POST',
10
+ headers: {
11
+ 'Content-Type': 'application/json',
12
+ },
13
+ body: JSON.stringify({
14
+ action: 'getFile',
15
+ data: { filePath },
16
+ }),
17
+ })
18
+
19
+ if (!response.ok) {
20
+ const error = await response.json()
21
+ throw new Error(
22
+ error.error || `Failed to fetch file: ${response.status}`
23
+ )
24
+ }
25
+
26
+ const result = await response.json()
27
+ return result
28
+ } catch (error) {
29
+ console.error('Error fetching file content:', error)
30
+ throw error
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Parse widget file and extract inputs section
36
+ * @param {string} fileContent - Widget file content
37
+ * @returns {{inputs: string, beforeInputs: string, afterInputs: string}}
38
+ */
39
+ export function parseWidgetFile(fileContent) {
40
+ // Find the inputs array in the widget file
41
+ const inputsMatch = fileContent.match(/inputs:\s*\[([\s\S]*?)\],\s*render/)
42
+
43
+ if (!inputsMatch) {
44
+ throw new Error('Could not find inputs section in widget file')
45
+ }
46
+
47
+ const inputsContent = inputsMatch[1]
48
+ const inputsStartIndex = inputsMatch.index
49
+ const inputsEndIndex =
50
+ inputsStartIndex + inputsMatch[0].length - ', render'.length
51
+
52
+ return {
53
+ inputs: inputsContent,
54
+ beforeInputs: fileContent.substring(
55
+ 0,
56
+ inputsStartIndex + 'inputs: ['.length
57
+ ),
58
+ afterInputs: fileContent.substring(inputsEndIndex),
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Convert widget inputs object to JavaScript code string
64
+ * @param {Array} inputs - Widget inputs array
65
+ * @returns {string} JavaScript code representation
66
+ */
67
+ export function serializeInputs(inputs) {
68
+ const serializeValue = (value) => {
69
+ if (typeof value === 'string') {
70
+ // Escape special characters and preserve newlines
71
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`
72
+ }
73
+ if (typeof value === 'number' || typeof value === 'boolean') {
74
+ return String(value)
75
+ }
76
+ if (Array.isArray(value)) {
77
+ return `[${value.map(serializeValue).join(', ')}]`
78
+ }
79
+ if (typeof value === 'object' && value !== null) {
80
+ const entries = Object.entries(value).map(
81
+ ([k, v]) => `${k}: ${serializeValue(v)}`
82
+ )
83
+ return `{${entries.join(', ')}}`
84
+ }
85
+ return 'null'
86
+ }
87
+
88
+ const serializeInput = (input, indent = ' ') => {
89
+ const parts = []
90
+
91
+ // Add type
92
+ parts.push(`type: "${input.type}"`)
93
+
94
+ // Add name
95
+ parts.push(`name: "${input.name}"`)
96
+
97
+ // Add value
98
+ parts.push(`value: ${serializeValue(input.value)}`)
99
+
100
+ // Add definitions if present (for list/group types)
101
+ if (input.definitions) {
102
+ const defIndent = indent + ' '
103
+ const defsStr = input.definitions
104
+ .map((def) => `\n${defIndent}${serializeInput(def, defIndent)}`)
105
+ .join(',')
106
+ parts.push(`definitions: [${defsStr}\n${indent}]`)
107
+ }
108
+
109
+ return `{ ${parts.join(', ')} }`
110
+ }
111
+
112
+ return inputs.map((input) => `\n ${serializeInput(input)}`).join(',')
113
+ }
114
+
115
+ /**
116
+ * Update widget file with new inputs
117
+ * @param {string} widgetId - Widget identifier
118
+ * @param {Array} newInputs - New inputs array
119
+ * @returns {Promise<{content: string, sha: string, filePath: string}>} Updated file content
120
+ */
121
+ export async function updateWidgetFile(widgetId, newInputs) {
122
+ const filePath = `widgets/${widgetId}.js`
123
+
124
+ // Fetch current file content
125
+ const { content, sha } = await getFileContent(filePath)
126
+
127
+ // Parse the file to extract inputs section
128
+ const { beforeInputs, afterInputs } = parseWidgetFile(content)
129
+
130
+ // Serialize new inputs
131
+ const serializedInputs = serializeInputs(newInputs)
132
+
133
+ // Reconstruct the file (add comma before render function)
134
+ const updatedContent = `${beforeInputs}${serializedInputs}\n ],${afterInputs}`
135
+
136
+ return {
137
+ content: updatedContent,
138
+ sha,
139
+ filePath,
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Convert ArrayBuffer to base64 string for GitHub API
145
+ * @param {ArrayBuffer} buffer - File data as ArrayBuffer
146
+ * @returns {string} Base64 encoded string
147
+ */
148
+ function arrayBufferToBase64(buffer) {
149
+ const bytes = new Uint8Array(buffer)
150
+ let binary = ''
151
+ for (let i = 0; i < bytes.byteLength; i++) {
152
+ binary += String.fromCharCode(bytes[i])
153
+ }
154
+ return btoa(binary)
155
+ }
156
+
157
+ /**
158
+ * Commit changes to repository via Netlify Function
159
+ * @param {string} message - Commit message
160
+ * @param {Array<{filePath: string, content: string, sha: string}>} changedFiles - Files to commit
161
+ * @param {Array<{filePath: string, fileData: Object}>} uploadedFiles - Files to upload (optional)
162
+ * @returns {Promise<Object>} Commit response
163
+ */
164
+ export async function commitChanges(
165
+ message,
166
+ changedFiles,
167
+ uploadedFiles = [],
168
+ widgets = []
169
+ ) {
170
+ const commitData = { message }
171
+ const hasWidgets = Array.isArray(widgets) && widgets.length > 0
172
+ const hasFiles = Array.isArray(changedFiles) && changedFiles.length > 0
173
+
174
+ if (hasWidgets) {
175
+ commitData.widgets = widgets
176
+ }
177
+
178
+ if (hasFiles) {
179
+ commitData.files = changedFiles
180
+ }
181
+
182
+ if (uploadedFiles && uploadedFiles.length > 0) {
183
+ commitData.uploadedFiles = uploadedFiles.map((file) => ({
184
+ filePath: file.filePath,
185
+ content: arrayBufferToBase64(file.fileData.data), // Convert ArrayBuffer to base64
186
+ encoding: 'base64', // GitHub API expects base64 for binary files
187
+ }))
188
+ }
189
+
190
+ const hasUploadedFiles =
191
+ Array.isArray(commitData.uploadedFiles) &&
192
+ commitData.uploadedFiles.length > 0
193
+
194
+ if (!hasWidgets && !hasFiles && !hasUploadedFiles) {
195
+ return
196
+ }
197
+
198
+ const response = await fetch('/api/github', {
199
+ method: 'POST',
200
+ headers: {
201
+ 'Content-Type': 'application/json',
202
+ },
203
+ body: JSON.stringify({
204
+ action: 'commitChanges',
205
+ data: commitData,
206
+ }),
207
+ })
208
+
209
+ if (!response.ok) {
210
+ const error = await response.json()
211
+ console.error('[cms:commitChanges] Commit failed', {
212
+ status: response.status,
213
+ error,
214
+ })
215
+ throw new Error(
216
+ error.error || `Failed to commit changes: ${response.status}`
217
+ )
218
+ }
219
+
220
+ return await response.json()
221
+ }
222
+
223
+ /**
224
+ * Trigger a Netlify build/deploy
225
+ * @returns {Promise<{buildId: string}>}
226
+ */
227
+ /**
228
+ * Wait for auto-build to start after commit
229
+ * Polls for a new build that was created after the commit time
230
+ * @param {string} commitTime - ISO timestamp of when commit was made
231
+ * @returns {Promise<{buildId: string}>} Build ID
232
+ */
233
+ export async function waitForBuild(commitTime) {
234
+ const maxAttempts = 12 // 1 minute max (5 second intervals)
235
+ let attempts = 0
236
+
237
+ while (attempts < maxAttempts) {
238
+ const response = await fetch('/api/build', {
239
+ method: 'POST',
240
+ headers: {
241
+ 'Content-Type': 'application/json',
242
+ },
243
+ body: JSON.stringify({
244
+ action: 'getLatestBuild',
245
+ }),
246
+ })
247
+
248
+ if (!response.ok) {
249
+ const error = await response.json()
250
+ throw new Error(
251
+ error.error || `Failed to get latest build: ${response.status}`
252
+ )
253
+ }
254
+
255
+ const { buildId, createdAt } = await response.json()
256
+
257
+ // Check if this build was created after our commit
258
+ if (new Date(createdAt) >= new Date(commitTime)) {
259
+ return { buildId }
260
+ }
261
+
262
+ // Wait 5 seconds before next check
263
+ await new Promise((resolve) => setTimeout(resolve, 5000))
264
+ attempts++
265
+ }
266
+
267
+ throw new Error(
268
+ 'Timeout waiting for build to start - check Netlify dashboard'
269
+ )
270
+ }
271
+
272
+ /**
273
+ * Check build status via Netlify Function
274
+ * @param {string} buildId - Build ID to check
275
+ * @returns {Promise<{status: string, done: boolean}>} Build status
276
+ */
277
+ export async function checkBuildStatus(buildId) {
278
+ const response = await fetch('/api/build', {
279
+ method: 'POST',
280
+ headers: {
281
+ 'Content-Type': 'application/json',
282
+ },
283
+ body: JSON.stringify({
284
+ action: 'checkBuildStatus',
285
+ data: { buildId },
286
+ }),
287
+ })
288
+
289
+ if (!response.ok) {
290
+ const error = await response.json()
291
+ throw new Error(
292
+ error.error || `Failed to check build status: ${response.status}`
293
+ )
294
+ }
295
+
296
+ return await response.json()
297
+ }
298
+
299
+ /**
300
+ * Poll build status until complete
301
+ * @param {string} buildId - Build ID to poll
302
+ * @param {Function} onProgress - Progress callback
303
+ * @returns {Promise<void>}
304
+ */
305
+ export async function pollBuildStatus(buildId) {
306
+ const maxAttempts = 60 // 5 minutes max (5 second intervals)
307
+ let attempts = 0
308
+
309
+ while (attempts < maxAttempts) {
310
+ const { done, status, error } = await checkBuildStatus(buildId)
311
+
312
+ if (done) {
313
+ // Check if build failed
314
+ if (status === 'error') {
315
+ throw new Error(
316
+ error ||
317
+ 'Build failed - check Netlify dashboard for details'
318
+ )
319
+ }
320
+ return
321
+ }
322
+
323
+ // Wait 5 seconds before next check
324
+ await new Promise((resolve) => setTimeout(resolve, 5000))
325
+ attempts++
326
+ }
327
+
328
+ throw new Error('Build timeout - please check Netlify dashboard')
329
+ }