@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,43 @@
1
+ const { html } = window.BFS.MARKUP
2
+
3
+ export const renderQuillEditor = (input, path, disabled, label, onChange) => {
4
+ return html`
5
+ <div class="control-field-block quill-editor-control">
6
+ <span class="label">${label}</span>
7
+ <div class="quill-editor-wrapper">
8
+ <div ref="quill-editor" class="quill-editor">
9
+ ${html([input.value])}
10
+ </div>
11
+ </div>
12
+ </div>
13
+ `.onMount((temp) => {
14
+ const editorElement = temp.refs['quill-editor'][0]
15
+
16
+ if (!window.Quill) {
17
+ console.error('Quill library not loaded')
18
+ return
19
+ }
20
+
21
+ const quillInstance = new Quill(editorElement, {
22
+ theme: 'snow',
23
+ readOnly: disabled(),
24
+ modules: {
25
+ toolbar: disabled()
26
+ ? false
27
+ : [
28
+ [{ header: [1, 2, 3, 4, 5, 6, false] }],
29
+ ['bold', 'italic', 'underline'],
30
+ ['link', 'blockquote'],
31
+ [{ list: 'ordered' }, { list: 'bullet' }],
32
+ ],
33
+ },
34
+ })
35
+
36
+ // Handle content changes (Requirements 5.2, 5.3)
37
+ if (!disabled()) {
38
+ quillInstance.on('text-change', (delta, oldDelta, source) => {
39
+ onChange(path, quillInstance.root.innerHTML, 'html')
40
+ })
41
+ }
42
+ })
43
+ }
@@ -0,0 +1,31 @@
1
+ const { html } = window.BFS.MARKUP
2
+
3
+ export const renderMarkdownEditor = (
4
+ input,
5
+ path,
6
+ disabled,
7
+ label,
8
+ onChange
9
+ ) => {
10
+ return html`
11
+ <div class="control-field-block markdown-editor-control">
12
+ <span class="label">${label}</span>
13
+ <div class="markdown-editor-wrapper">
14
+ <div class="markdown-editor" ref="markdown-editor"></div>
15
+ </div>
16
+ </div>
17
+ `.onMount((temp) => {
18
+ const editorElement = temp.refs['markdown-editor'][0]
19
+
20
+ const editor = new TinyMDE.Editor({
21
+ element: editorElement,
22
+ content: input.value,
23
+ })
24
+
25
+ if (!disabled()) {
26
+ editor.addEventListener('change', ({ content }) => {
27
+ onChange(path, content, 'markdown')
28
+ })
29
+ }
30
+ })
31
+ }
@@ -0,0 +1,543 @@
1
+ import { hideFlashbar, showFlashbar } from './flashbar.js'
2
+ import { showModal } from './modal.js'
3
+ import * as authManager from './auth-manager.js'
4
+ import * as repositoryManager from './repository-manager.js'
5
+
6
+ const { MARKUP, SITE_BUILDER } = window.BFS
7
+ const { state, html, effect } = MARKUP
8
+ const { inputDefinitionsToObject, mergeDataIntoInputDefinition } = SITE_BUILDER
9
+
10
+ // Store original widget values when first loaded
11
+ export const originalWidgetValues = new Map()
12
+ export const widgets = new Map()
13
+ export const widgetElements = new Map()
14
+ export const widgetLoadingStates = new Set()
15
+ export const isLocalDevelopment =
16
+ location.hostname === 'localhost' ||
17
+ location.hostname === '127.0.0.1' ||
18
+ location.port === '8888'
19
+
20
+ export const [loading, setLoading] = state(true)
21
+ export const [publishing, setPublishing] = state(false)
22
+ export const [panel, setPanel] = state('page')
23
+ export const [config, setConfig] = state(null)
24
+ export const [currentPage, setCurrentPage] = state(null)
25
+ export const [currentWidget, setCurrentWidget] = state(null)
26
+ export const [currentWidgetId, setCurrentWidgetId] = state('')
27
+ const [pendingChanges, setPendingChanges] = state([])
28
+
29
+ effect(() => {
30
+ const widgetId = currentWidgetId()
31
+ if (!widgetId) {
32
+ return
33
+ }
34
+ // Re-load widget when panel changes so page overrides don't leak into widget panel.
35
+ loadWidget(widgetId)
36
+ })
37
+
38
+ export const hasPendingChanges = () => pendingChanges().length > 0
39
+
40
+ export const removePendingChange = (id) => {
41
+ setPendingChanges(pendingChanges().filter((change) => change.id !== id))
42
+ }
43
+ export const getPendingChange = (id) => {
44
+ return pendingChanges().find((change) => change.id === id) ?? null
45
+ }
46
+ export const allPendingChanges = pendingChanges
47
+
48
+ export const addPendingChange = (id, change) => {
49
+ removePendingChange(id)
50
+ setPendingChanges((prev) => [
51
+ ...prev,
52
+ {
53
+ ...change,
54
+ id,
55
+ },
56
+ ])
57
+ }
58
+
59
+ export const selectWidget = (widgetId, newWidget) => {
60
+ setCurrentWidget(newWidget)
61
+ setCurrentWidgetId(widgetId)
62
+ }
63
+
64
+ export const selectPage = (newPage) => {
65
+ setCurrentPage(newPage)
66
+ setCurrentWidget(null)
67
+ setCurrentWidgetId('')
68
+ }
69
+
70
+ const getWidgetById = (id) => widgets.get(id)
71
+
72
+ const fetchLocalWidget = async (widgetId) => {
73
+ return (await import(`/admin/widgets/${widgetId}.js`)).default
74
+ }
75
+
76
+ const fetchRemoteWidget = async (widgetId) => {
77
+ const response = await fetch(`/api/widgets?action=get&widget=${widgetId}`)
78
+
79
+ if (!response.ok) {
80
+ const errorData = await response.json().catch(() => ({}))
81
+
82
+ throw new Error(
83
+ errorData.error ||
84
+ `Remote fetch failed: ${response.status} ${response.statusText}`
85
+ )
86
+ }
87
+
88
+ const data = await response.json()
89
+
90
+ if (!data.success) {
91
+ throw new Error(
92
+ data.error || 'Remote widget fetch failed without specific error'
93
+ )
94
+ }
95
+
96
+ if (!data.content || typeof data.content !== 'string') {
97
+ throw new Error('Remote widget content is missing or invalid')
98
+ }
99
+
100
+ const blob = new Blob([data.content], { type: 'text/javascript' })
101
+ const url = URL.createObjectURL(blob)
102
+ try {
103
+ const mod = await import(url)
104
+ return mod?.default ?? mod
105
+ } finally {
106
+ URL.revokeObjectURL(url)
107
+ }
108
+ }
109
+
110
+ const storeOriginalValues = (key, widget) => {
111
+ if (!originalWidgetValues.has(key)) {
112
+ const originalValues = {}
113
+
114
+ const extractOriginalValues = (inputs, pathPrefix = '') => {
115
+ if (!Array.isArray(inputs)) {
116
+ return
117
+ }
118
+ inputs.forEach((input, index) => {
119
+ const currentPath = pathPrefix
120
+ ? `${pathPrefix}.${index}`
121
+ : String(index)
122
+
123
+ if (input.type === 'list' || input.type === 'group') {
124
+ extractOriginalValues(input.definitions, currentPath)
125
+ } else {
126
+ originalValues[currentPath] = input.value
127
+ }
128
+ })
129
+ }
130
+
131
+ extractOriginalValues(widget?.inputs)
132
+ originalWidgetValues.set(key, originalValues)
133
+ }
134
+ }
135
+
136
+ const cloneInputs = (inputs) => {
137
+ if (!Array.isArray(inputs)) {
138
+ return []
139
+ }
140
+ return inputs.map((input) => ({
141
+ ...input,
142
+ definitions: input.definitions
143
+ ? cloneInputs(input.definitions)
144
+ : undefined,
145
+ }))
146
+ }
147
+
148
+ const cloneWidgetWithInputs = (widget) => ({
149
+ ...widget,
150
+ inputs: cloneInputs(widget?.inputs),
151
+ })
152
+
153
+ const applyWidgetOverrides = (inputs, overrides) => {
154
+ if (!Array.isArray(inputs)) {
155
+ return []
156
+ }
157
+ return inputs.map((input) => {
158
+ if (!input?.name) {
159
+ return input
160
+ }
161
+ const overrideValue = overrides?.[input.name]
162
+ if (overrideValue === undefined) {
163
+ return input
164
+ }
165
+ return mergeDataIntoInputDefinition(overrideValue, input)
166
+ })
167
+ }
168
+
169
+ export const loadWidget = async (widgetId) => {
170
+ let loadingId
171
+
172
+ try {
173
+ if (widgetLoadingStates.has(widgetId)) {
174
+ return
175
+ }
176
+
177
+ // Check if widget is already cached
178
+ let w = widgets.get(widgetId)
179
+
180
+ if (w) {
181
+ selectWidget(widgetId, w)
182
+ return w
183
+ }
184
+
185
+ widgetLoadingStates.add(widgetId)
186
+
187
+ loadingId = showFlashbar({
188
+ type: 'info',
189
+ title: 'Loading Widget',
190
+ message: `Fetching ${widgetId} widget...`,
191
+ loading: true,
192
+ })
193
+
194
+ let data
195
+
196
+ if (isLocalDevelopment) {
197
+ data = await fetchLocalWidget(widgetId)
198
+ } else {
199
+ data = await fetchRemoteWidget(widgetId)
200
+ }
201
+
202
+ widgets.set(widgetId, data)
203
+
204
+ const pageId = currentPage()?.id ?? ''
205
+ const isPagePanel = panel() === 'page'
206
+ const pageOverrides = isPagePanel
207
+ ? currentPage()?.widgetsData?.[widgetId] || null
208
+ : null
209
+
210
+ if (pageOverrides) {
211
+ const widgetClone = cloneWidgetWithInputs(data)
212
+ widgetClone.inputs = applyWidgetOverrides(
213
+ widgetClone.inputs,
214
+ pageOverrides
215
+ )
216
+ selectWidget(widgetId, widgetClone)
217
+ storeOriginalValues(`${pageId}:${widgetId}`, widgetClone)
218
+ } else {
219
+ selectWidget(widgetId, data)
220
+ storeOriginalValues(widgetId, data)
221
+ }
222
+ } catch (error) {
223
+ console.error(error)
224
+ showFlashbar({
225
+ type: 'error',
226
+ title: 'Widget Load Error',
227
+ message: `Failed to load widget "${widgetId}"`,
228
+ })
229
+ } finally {
230
+ hideFlashbar(loadingId)
231
+ widgetLoadingStates.delete(widgetId)
232
+ }
233
+ }
234
+
235
+ export const loadConfig = async () => {
236
+ const response = await fetch('/admin/config.json')
237
+
238
+ if (!response.ok) {
239
+ return showFlashbar({
240
+ type: 'error',
241
+ title: `Failed to load your website`,
242
+ message: 'Please refresh to try again',
243
+ })
244
+ }
245
+
246
+ const cf = await response.json()
247
+ setConfig(cf)
248
+ selectPage(cf.pages.find((pg) => pg.id === 'home') ?? null)
249
+ }
250
+
251
+ const resolveNamePath = (inputs, path) => {
252
+ const segments = String(path).split('.')
253
+ const topIndex = Number.parseInt(segments.shift(), 10)
254
+ if (!Number.isFinite(topIndex)) {
255
+ return null
256
+ }
257
+ let currentInput = inputs?.[topIndex]
258
+ if (!currentInput?.name) {
259
+ return null
260
+ }
261
+ const namePath = [currentInput.name]
262
+ while (segments.length) {
263
+ if (!currentInput?.definitions) {
264
+ return null
265
+ }
266
+ const index = Number.parseInt(segments.shift(), 10)
267
+ if (!Number.isFinite(index)) {
268
+ return null
269
+ }
270
+ const nextInput = currentInput.definitions?.[index]
271
+ if (!nextInput) {
272
+ return null
273
+ }
274
+ if (currentInput.type === 'list') {
275
+ namePath.push(index)
276
+ } else {
277
+ namePath.push(nextInput.name)
278
+ }
279
+ currentInput = nextInput
280
+ }
281
+ return namePath
282
+ }
283
+
284
+ const setDeepValue = (target, path, value) => {
285
+ if (!path.length) {
286
+ return
287
+ }
288
+ let cursor = target
289
+ for (let i = 0; i < path.length - 1; i += 1) {
290
+ const key = path[i]
291
+ const nextKey = path[i + 1]
292
+ const shouldBeArray = typeof nextKey === 'number'
293
+ if (cursor[key] == null) {
294
+ cursor[key] = shouldBeArray ? [] : {}
295
+ }
296
+ cursor = cursor[key]
297
+ }
298
+ cursor[path[path.length - 1]] = value
299
+ }
300
+
301
+ const buildWidgetInputPatch = (widget, change) => {
302
+ const inputValues = inputDefinitionsToObject(widget.inputs || [])
303
+ const patch = {}
304
+ for (const [path, changeEntry] of Object.entries(change.changes || {})) {
305
+ const namePath = resolveNamePath(widget.inputs || [], path)
306
+ if (!namePath?.length) {
307
+ continue
308
+ }
309
+ setDeepValue(inputValues, namePath, changeEntry.newValue)
310
+ const topKey = namePath[0]
311
+ if (Object.prototype.hasOwnProperty.call(inputValues, topKey)) {
312
+ patch[topKey] = inputValues[topKey]
313
+ }
314
+ }
315
+ return patch
316
+ }
317
+
318
+ export const publishChanges = async () => {
319
+ if (publishing()) {
320
+ return
321
+ }
322
+
323
+ setPublishing(true)
324
+
325
+ let progressId
326
+ let publishProgressId
327
+ let buildProgressId
328
+
329
+ try {
330
+ // Step 1: Collect unique widgets, page-widget changes, and separate file uploads
331
+ const changedWidgetIds = new Set()
332
+ const fileUploads = []
333
+ const pageWidgetUpdates = new Map()
334
+
335
+ for (const change of allPendingChanges()) {
336
+ if (change.type === 'widget') {
337
+ changedWidgetIds.add(change.id)
338
+
339
+ // Collect file uploads from this widget's changes
340
+ for (const [path, changeData] of Object.entries(
341
+ change.changes
342
+ )) {
343
+ if (changeData.fileData) {
344
+ fileUploads.push({
345
+ widgetId: change.id,
346
+ path,
347
+ filePath: changeData.fileData.targetPath,
348
+ fileData: changeData.fileData,
349
+ })
350
+ }
351
+ }
352
+ } else if (change.type === 'page-widget') {
353
+ const pageId = currentPage()?.id
354
+ const widgetId = change.id
355
+ if (!pageId || !widgetId) {
356
+ console.warn(
357
+ '[cms:publish] Skipping page-widget change without page/widget id',
358
+ change
359
+ )
360
+ continue
361
+ }
362
+ const widget = getWidgetById(widgetId)
363
+ if (!widget) {
364
+ console.warn(
365
+ '[cms:publish] Widget not found for page-widget update',
366
+ { widgetId, pageId }
367
+ )
368
+ continue
369
+ }
370
+ const patch = buildWidgetInputPatch(widget, change)
371
+ if (Object.keys(patch).length === 0) {
372
+ continue
373
+ }
374
+ for (const [path, changeData] of Object.entries(
375
+ change.changes
376
+ )) {
377
+ if (changeData.fileData) {
378
+ fileUploads.push({
379
+ widgetId,
380
+ path,
381
+ filePath: changeData.fileData.targetPath,
382
+ fileData: changeData.fileData,
383
+ })
384
+ }
385
+ }
386
+ if (!pageWidgetUpdates.has(pageId)) {
387
+ pageWidgetUpdates.set(pageId, new Map())
388
+ }
389
+ const pageUpdates = pageWidgetUpdates.get(pageId)
390
+ pageUpdates.set(widgetId, {
391
+ ...(pageUpdates.get(widgetId) || {}),
392
+ ...patch,
393
+ })
394
+ }
395
+ }
396
+
397
+ if (
398
+ changedWidgetIds.size === 0 &&
399
+ fileUploads.length === 0 &&
400
+ pageWidgetUpdates.size === 0
401
+ ) {
402
+ return showFlashbar({
403
+ type: 'info',
404
+ title: 'No Changes',
405
+ message: 'There are no changes to publish.',
406
+ })
407
+ }
408
+
409
+ // Show progress message
410
+ progressId = showFlashbar({
411
+ type: 'info',
412
+ title: 'Saving changes...',
413
+ message: 'Making sure your changes are valid and ready to publish.',
414
+ loading: true,
415
+ dismissable: false,
416
+ })
417
+
418
+ const widgetsToCommit = []
419
+ for (const widgetId of changedWidgetIds) {
420
+ const widget = getWidgetById(widgetId)
421
+ if (!widget) {
422
+ continue
423
+ }
424
+
425
+ widgetsToCommit.push({
426
+ widgetId,
427
+ inputs: widget.inputs,
428
+ })
429
+ }
430
+
431
+ const changedFiles = []
432
+ for (const [pageId, widgetsMap] of pageWidgetUpdates.entries()) {
433
+ const filePath = `templates/${pageId}.json`
434
+ const { content, sha } =
435
+ await repositoryManager.getFileContent(filePath)
436
+ let templateData
437
+ try {
438
+ templateData = JSON.parse(content)
439
+ } catch (error) {
440
+ throw new Error(
441
+ `Failed to parse ${filePath} JSON: ${error.message}`
442
+ )
443
+ }
444
+ const widgetsData = {
445
+ ...(templateData.widgetsData || {}),
446
+ }
447
+ for (const [widgetId, patch] of widgetsMap.entries()) {
448
+ widgetsData[widgetId] = {
449
+ ...(widgetsData[widgetId] || {}),
450
+ ...patch,
451
+ }
452
+ }
453
+ const updatedContent = `${JSON.stringify(
454
+ {
455
+ ...templateData,
456
+ widgetsData,
457
+ },
458
+ null,
459
+ 2
460
+ )}\n`
461
+ changedFiles.push({
462
+ filePath,
463
+ content: updatedContent,
464
+ sha,
465
+ })
466
+ }
467
+
468
+ if (
469
+ widgetsToCommit.length === 0 &&
470
+ fileUploads.length === 0 &&
471
+ changedFiles.length === 0
472
+ ) {
473
+ hideFlashbar(progressId)
474
+ return showFlashbar({
475
+ type: 'error',
476
+ title: 'No Valid Changes',
477
+ message: 'No valid changes found to publish.',
478
+ })
479
+ }
480
+
481
+ // Prepare file uploads for commit (Requirements 3.1, 3.2)
482
+ const uploadedFiles = fileUploads.map((upload) => ({
483
+ filePath: upload.filePath,
484
+ fileData: upload.fileData,
485
+ }))
486
+
487
+ const widgetLabels = widgetsToCommit.map(
488
+ (w) => `widgets/${w.widgetId}.js`
489
+ )
490
+ const templateLabels = changedFiles.map((file) => file.filePath)
491
+ const commitTargets = [...widgetLabels, ...templateLabels]
492
+ const commitMsg = `[${authManager.user().name}] CMS Update ${commitTargets.join(', ')}${fileUploads.length > 0 ? ` + ${fileUploads.length} files` : ''}: ${new Date().toISOString()}`
493
+ // Step 3: Commit all changes and file uploads at once
494
+ const commitTime = new Date().toISOString()
495
+ await repositoryManager.commitChanges(
496
+ commitMsg,
497
+ changedFiles,
498
+ uploadedFiles,
499
+ widgetsToCommit
500
+ )
501
+
502
+ hideFlashbar(progressId)
503
+ publishProgressId = showFlashbar({
504
+ type: 'info',
505
+ title: 'Publishing...',
506
+ message:
507
+ 'We are updating the live website. This may take a minute.',
508
+ loading: true,
509
+ })
510
+
511
+ const { buildId } = await repositoryManager.waitForBuild(commitTime)
512
+
513
+ await repositoryManager.pollBuildStatus(buildId)
514
+
515
+ hideFlashbar(publishProgressId)
516
+
517
+ showModal({
518
+ dismissable: false,
519
+ title: 'Changes Published!',
520
+ content: html`<p>
521
+ Your changes are now live.
522
+ <a href="${location.origin}">View website</a>.
523
+ </p>`,
524
+ action: () => {
525
+ // Page reload will automatically clear widget cache and fetch fresh widgets
526
+ window.location.reload(true) // Hard refresh
527
+ },
528
+ })
529
+ } catch (error) {
530
+ console.error('Error publishing changes:', error)
531
+ hideFlashbar(progressId)
532
+ hideFlashbar(publishProgressId)
533
+ hideFlashbar(buildProgressId)
534
+ showFlashbar({
535
+ type: 'error',
536
+ title: 'Publish Failed',
537
+ message:
538
+ 'We could not publish or update the website with your changes. Please try again.',
539
+ })
540
+ } finally {
541
+ setPublishing(false)
542
+ }
543
+ }