@silexlabs/silex 3.3.1-3 → 3.3.1-5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +27 -2
  2. package/dist/client/css/admin.css +26 -9
  3. package/dist/client/index.html +3 -3
  4. package/dist/client/js/main.js +285 -217
  5. package/dist/client/js/main.js.LICENSE.txt +2 -13
  6. package/dist/client/page.d.ts +1 -1
  7. package/dist/client/types.d.ts +1 -17
  8. package/dist/plugins/client/types.d.ts +1 -17
  9. package/dist/plugins/client/types.js.map +1 -1
  10. package/dist/plugins/server/page.d.ts +1 -1
  11. package/dist/plugins/server/page.js.map +1 -1
  12. package/dist/plugins/server/plugins/server/FtpConnector.js +53 -8
  13. package/dist/plugins/server/plugins/server/FtpConnector.js.map +1 -1
  14. package/dist/plugins/server/plugins/server/GitlabConnector.js +106 -85
  15. package/dist/plugins/server/plugins/server/GitlabConnector.js.map +1 -1
  16. package/dist/plugins/server/server/connectors/FsStorage.js +50 -4
  17. package/dist/plugins/server/server/connectors/FsStorage.js.map +1 -1
  18. package/dist/plugins/server/server/utils/websiteDataSerialization.d.ts +20 -0
  19. package/dist/plugins/server/server/utils/websiteDataSerialization.js +127 -0
  20. package/dist/plugins/server/server/utils/websiteDataSerialization.js.map +1 -0
  21. package/dist/plugins/server/types.d.ts +1 -17
  22. package/dist/plugins/server/types.js.map +1 -1
  23. package/dist/server/page.js +14 -0
  24. package/dist/server/server/cli.js +0 -0
  25. package/dist/server/server/connectors/FsStorage.js +40 -4
  26. package/dist/server/server/utils/websiteDataSerialization.js +85 -0
  27. package/package.json +13 -13
  28. package/src/css/_variables.scss +4 -0
  29. package/src/css/grapesjs-plugins.scss +5 -0
  30. package/src/css/grapesjs.scss +8 -2
  31. package/src/css/loader.scss +9 -6
  32. package/src/css/page-panel.scss +3 -2
  33. package/src/css/project-bar.scss +0 -1
  34. package/src/html/index.pug +1 -1
  35. package/src/ts/client/assetUrl.ts +1 -1
  36. package/src/ts/client/grapesjs/PublicationUi.ts +1 -1
  37. package/src/ts/client/grapesjs/css-props.ts +4 -3
  38. package/src/ts/client/grapesjs/flex.ts +8 -4
  39. package/src/ts/client/grapesjs/img.ts +9 -3
  40. package/src/ts/client/grapesjs/index.ts +9 -11
  41. package/src/ts/client/grapesjs/li.ts +3 -1
  42. package/src/ts/client/grapesjs/sectors.ts +3 -1
  43. package/src/ts/client/grapesjs/storage.ts +25 -30
  44. package/src/ts/client/index.ts +2 -3
  45. package/src/ts/page.ts +1 -1
  46. package/src/ts/plugins/server/FtpConnector.test.ts +2 -1
  47. package/src/ts/plugins/server/FtpConnector.ts +61 -9
  48. package/src/ts/plugins/server/GitlabConnector.ts +112 -102
  49. package/src/ts/server/connectors/FsStorage.test.ts +6 -2
  50. package/src/ts/server/connectors/FsStorage.ts +60 -5
  51. package/src/ts/server/utils/websiteDataSerialization.test.ts +59 -0
  52. package/src/ts/server/utils/websiteDataSerialization.ts +143 -0
  53. package/src/ts/types.ts +2 -20
  54. package/dist/client/css/admin.css.map +0 -1
  55. package/dist/client/grapesjs-filter-styles/dist/index.d.ts +0 -4
  56. package/dist/client/grapesjs-fonts/dist/index.d.ts +0 -4
  57. package/dist/client/grapesjs-fonts/src/commands.d.ts +0 -3
  58. package/dist/client/grapesjs-fonts/src/fonts.d.ts +0 -4
  59. package/dist/client/grapesjs-storage-rate-limit/dist/index.d.ts +0 -4
  60. package/dist/client/grapesjs-ui-suggest-classes/dist/index.d.ts +0 -4
  61. package/dist/client/silex-lib/src/ts/client/api.d.ts +0 -90
  62. package/dist/client/silex-lib/src/ts/client/assetUrl.d.ts +0 -46
  63. package/dist/client/silex-lib/src/ts/client/config.d.ts +0 -102
  64. package/dist/client/silex-lib/src/ts/client/events.d.ts +0 -23
  65. package/dist/client/silex-lib/src/ts/client/expose.d.ts +0 -8
  66. package/dist/client/silex-lib/src/ts/client/grapesjs/LoginDialog.d.ts +0 -15
  67. package/dist/client/silex-lib/src/ts/client/grapesjs/PublicationManager.d.ts +0 -74
  68. package/dist/client/silex-lib/src/ts/client/grapesjs/PublicationUi.d.ts +0 -59
  69. package/dist/client/silex-lib/src/ts/client/grapesjs/blocks.d.ts +0 -1
  70. package/dist/client/silex-lib/src/ts/client/grapesjs/breadcrumbs.d.ts +0 -1
  71. package/dist/client/silex-lib/src/ts/client/grapesjs/css-props.d.ts +0 -6
  72. package/dist/client/silex-lib/src/ts/client/grapesjs/flex.d.ts +0 -7
  73. package/dist/client/silex-lib/src/ts/client/grapesjs/footer.d.ts +0 -2
  74. package/dist/client/silex-lib/src/ts/client/grapesjs/img.d.ts +0 -6
  75. package/dist/client/silex-lib/src/ts/client/grapesjs/index.d.ts +0 -9
  76. package/dist/client/silex-lib/src/ts/client/grapesjs/internal-links.d.ts +0 -1
  77. package/dist/client/silex-lib/src/ts/client/grapesjs/keymaps.d.ts +0 -123
  78. package/dist/client/silex-lib/src/ts/client/grapesjs/li.d.ts +0 -7
  79. package/dist/client/silex-lib/src/ts/client/grapesjs/new-page-dialog.d.ts +0 -2
  80. package/dist/client/silex-lib/src/ts/client/grapesjs/openImport.d.ts +0 -9
  81. package/dist/client/silex-lib/src/ts/client/grapesjs/page-panel.d.ts +0 -9
  82. package/dist/client/silex-lib/src/ts/client/grapesjs/project-bar.d.ts +0 -21
  83. package/dist/client/silex-lib/src/ts/client/grapesjs/resize-panel.d.ts +0 -1
  84. package/dist/client/silex-lib/src/ts/client/grapesjs/rich-text.d.ts +0 -4
  85. package/dist/client/silex-lib/src/ts/client/grapesjs/scroll.d.ts +0 -7
  86. package/dist/client/silex-lib/src/ts/client/grapesjs/sectors.d.ts +0 -11
  87. package/dist/client/silex-lib/src/ts/client/grapesjs/semantic.d.ts +0 -1
  88. package/dist/client/silex-lib/src/ts/client/grapesjs/settings-sections.d.ts +0 -16
  89. package/dist/client/silex-lib/src/ts/client/grapesjs/settings.d.ts +0 -4
  90. package/dist/client/silex-lib/src/ts/client/grapesjs/storage.d.ts +0 -3
  91. package/dist/client/silex-lib/src/ts/client/grapesjs/symbolDialogs.d.ts +0 -3
  92. package/dist/client/silex-lib/src/ts/client/grapesjs/upload-progress.d.ts +0 -2
  93. package/dist/client/silex-lib/src/ts/client/grapesjs/view-buttons.d.ts +0 -1
  94. package/dist/client/silex-lib/src/ts/client/index.d.ts +0 -10
  95. package/dist/client/silex-lib/src/ts/client/publication-transformers.d.ts +0 -42
  96. package/dist/client/silex-lib/src/ts/client/publish-custom-code-block.d.ts +0 -4
  97. package/dist/client/silex-lib/src/ts/client/publish-fonts.d.ts +0 -4
  98. package/dist/client/silex-lib/src/ts/client/utils.d.ts +0 -22
  99. package/dist/client/silex-lib/src/ts/constants.d.ts +0 -33
  100. package/dist/client/silex-lib/src/ts/page.d.ts +0 -2
  101. package/dist/client/silex-lib/src/ts/types.d.ts +0 -341
  102. package/src/ts/client/grapesjs/dynamic-plugins.ts +0 -63
  103. package/src/ts/client/grapesjs/plugin-options.ts +0 -243
@@ -18,7 +18,7 @@
18
18
  import { API_CONNECTOR_LOGIN_CALLBACK, API_CONNECTOR_PATH, API_PATH, WEBSITE_DATA_FILE, WEBSITE_PAGES_FOLDER } from '../../constants'
19
19
  import { ServerConfig } from '../../server/config'
20
20
  import { ConnectorFile, ConnectorFileContent, StatusCallback, StorageConnector, contentToBuffer, contentToString, toConnectorData } from '../../server/connectors/connectors'
21
- import { ApiError, ConnectorType, ConnectorUser, WebsiteData, WebsiteId, WebsiteMeta, WebsiteMetaFileContent, JobStatus, Page } from '../../types'
21
+ import { ApiError, ConnectorType, ConnectorUser, WebsiteData, WebsiteId, WebsiteMeta, WebsiteMetaFileContent, JobStatus } from '../../types'
22
22
  import fetch from 'node-fetch'
23
23
  import crypto, { createHash } from 'crypto'
24
24
  import { join } from 'path'
@@ -26,6 +26,8 @@ import { Agent } from 'https'
26
26
  import { getPageSlug } from '../../page'
27
27
  import e from 'express'
28
28
  import { fork } from 'child_process'
29
+ import { Page } from 'grapesjs'
30
+ import { stringify, split, merge } from '../../server/utils/websiteDataSerialization'
29
31
 
30
32
  /**
31
33
  * Gitlab connector
@@ -120,16 +122,6 @@ interface GitlabFetchCommits {
120
122
  since: string
121
123
  }
122
124
 
123
- interface GitlabPage {
124
- id: string
125
- name: string
126
- isFile: true
127
- }
128
-
129
- interface GitlabWebsiteData {
130
- fileFormatVersion: string
131
- pages: GitlabPage[]
132
- }
133
125
 
134
126
  // interface MetaRepoFileContent {
135
127
  // websites: {
@@ -220,6 +212,7 @@ function sanitizeGitlabPath(name: string): string {
220
212
  .toLowerCase()
221
213
  }
222
214
 
215
+
223
216
  export default class GitlabConnector implements StorageConnector {
224
217
  connectorId = 'gitlab'
225
218
  connectorType = ConnectorType.STORAGE
@@ -327,15 +320,21 @@ export default class GitlabConnector implements StorageConnector {
327
320
  'Content-Type': 'application/json',
328
321
  }
329
322
  if (method === 'GET' && requestBody) {
330
- console.error('Gitlab API error (4) - GET request with body', { url, method, body: requestBody, params })
323
+ console.error('[GitlabConnector] Invalid GET request: GET requests should not have a body', { url, method, body: requestBody, params })
331
324
  }
332
325
  // With or without body
333
- let response
326
+ let response: Response
334
327
  const body = requestBody ? JSON.stringify(requestBody) : undefined
335
328
  try {
336
- if(body && Buffer.byteLength(body) > MAX_BODY_SIZE_KB * 1024) {
337
- // TODO: warn the end user
338
- console.warn('Gitlab API warning - body too big', Buffer.byteLength(body), 'bytes', { url, method, params })
329
+ if (body && Buffer.byteLength(body) > MAX_BODY_SIZE_KB * 1024) {
330
+ console.warn('[GitlabConnector] Request body size exceeds Gitlab API limit', {
331
+ size: Buffer.byteLength(body),
332
+ maxAllowed: MAX_BODY_SIZE_KB * 1024,
333
+ url,
334
+ method,
335
+ params,
336
+ session,
337
+ })
339
338
  }
340
339
  response = await fetch(url, requestBody && method !== 'GET' ? {
341
340
  agent: this.getAgent(),
@@ -348,8 +347,16 @@ export default class GitlabConnector implements StorageConnector {
348
347
  headers,
349
348
  })
350
349
  } catch (e) {
351
- console.error('Gitlab API error (0)', e)
352
- throw new ApiError(`Gitlab API error (0): ${e.message} ${e.text} ${e.code} ${e.name} ${e.type}`, 500)
350
+ console.error('[GitlabConnector] Failed to reach Gitlab API endpoint', {
351
+ error: e,
352
+ url,
353
+ method,
354
+ body: requestBody,
355
+ params,
356
+ session,
357
+ stack: e?.stack || new Error().stack,
358
+ })
359
+ throw new ApiError(`Could not reach Gitlab API: ${e.message || e}`, 500)
353
360
  }
354
361
  // Pass the response headers to the caller
355
362
  response.headers.forEach((value, name) => responseHeaders[name] = value)
@@ -358,14 +365,30 @@ export default class GitlabConnector implements StorageConnector {
358
365
  try {
359
366
  return await response.text()
360
367
  } catch (e) {
361
- console.error('Gitlab API error (6) - could not parse response', response.status, response.statusText, { url, method, body: requestBody, params }, e)
362
- throw new ApiError(`Gitlab API error (6): response body not available. ${e.message}`, 500)
368
+ console.error('[GitlabConnector] Failed to read response body from Gitlab API', {
369
+ status: response.status,
370
+ statusText: response.statusText,
371
+ url,
372
+ method,
373
+ body: requestBody,
374
+ params,
375
+ error: e
376
+ })
377
+ throw new ApiError(`Gitlab API: Could not read response body (${e.message})`, 500)
363
378
  }
364
379
  }()
365
380
  if (!response.ok) {
366
- console.error('Gitlab API error (7) - response not ok', response.status, response.statusText, { url, method, body: requestBody, params, text: text })
381
+ console.error('[GitlabConnector] Gitlab API responded with error status', {
382
+ status: response.status,
383
+ statusText: response.statusText,
384
+ url,
385
+ method,
386
+ body: requestBody,
387
+ params,
388
+ responseText: text
389
+ })
367
390
  if (text.includes('A file with this name doesn\'t exist')) {
368
- throw new ApiError('Gitlab API error (5): Not Found', 404)
391
+ throw new ApiError('Gitlab API: File not found', 404)
369
392
  } else if (response.status === 401 && this.getSessionToken(session).token?.refresh_token) {
370
393
  // Refresh the token
371
394
  const token = this.getSessionToken(session).token
@@ -401,17 +424,28 @@ export default class GitlabConnector implements StorageConnector {
401
424
  })
402
425
  } else {
403
426
  const message = typeof refreshJson?.message === 'object' ? Object.entries(refreshJson.message).map(entry => entry.join(' ')).join(' ') : refreshJson?.message ?? refreshJson?.error ?? response.statusText
404
- console.error('Gitlab API error (2) - could not refresh token', response.status, response.statusText, { message }, 'refresh_token:', token?.refresh_token)
405
- // Workaround for when the token is invalid
406
- // It happens often which is not normal (refresh token should last 6 months)
427
+ console.error('[GitlabConnector] Failed to refresh Gitlab OAuth token', {
428
+ status: response.status,
429
+ statusText: response.statusText,
430
+ message,
431
+ refresh_token: token?.refresh_token
432
+ })
407
433
  this.logout(session)
408
- // Notify the user
409
- throw new ApiError(`Gitlab API error (2): ${message}`, response.status)
434
+ throw new ApiError(`Gitlab API: Could not refresh token (${message})`, response.status)
410
435
  }
411
436
  } else {
412
437
  const message = response.statusText
413
- console.error('Gitlab API error (1)', response.status, response.statusText, { url, method, body: requestBody, params, text: text, message })
414
- throw new ApiError(`Gitlab API error (1): ${message} (${text})`, response.status)
438
+ console.error('[GitlabConnector] Unhandled Gitlab API error response', {
439
+ status: response.status,
440
+ statusText: response.statusText,
441
+ url,
442
+ method,
443
+ body: requestBody,
444
+ params,
445
+ responseText: text,
446
+ message
447
+ })
448
+ throw new ApiError(`Gitlab API error: ${message} (${text})`, response.status)
415
449
  }
416
450
  }
417
451
  let json: { message: string, error: string } | any
@@ -419,11 +453,19 @@ export default class GitlabConnector implements StorageConnector {
419
453
  json = JSON.parse(text)
420
454
  } catch (e) {
421
455
  if (!response.ok) {
422
- // A real error
423
456
  throw e
424
457
  } else {
425
- // Useless error linked to the fact that the response is not JSON
426
- console.error('Gitlab API error (3) - could not parse response', response.status, response.statusText, { url, method, body: requestBody, params, text: text })
458
+ console.error('[GitlabConnector] Response from Gitlab API is not valid JSON', {
459
+ statusText: response.statusText,
460
+ url,
461
+ method,
462
+ body: requestBody,
463
+ params,
464
+ responseText: text,
465
+ error: e,
466
+ session,
467
+ stack: e?.stack || new Error().stack,
468
+ })
427
469
  return text
428
470
  }
429
471
  }
@@ -451,18 +493,30 @@ export default class GitlabConnector implements StorageConnector {
451
493
  if (!fileRes.ok) {
452
494
  const errText = await fileRes.text()
453
495
  if (errText.includes('not found') || fileRes.status === 404) {
454
- throw new ApiError('GitLab raw error (5): Not Found', 404)
496
+ console.error('[GitlabConnector] GitLab raw file not found', {
497
+ status: fileRes.status,
498
+ statusText: fileRes.statusText,
499
+ url: rawUrl,
500
+ responseText: errText
501
+ })
502
+ throw new ApiError(`GitLab raw file not found: filePath="${filePath}" (status ${fileRes.status})`, 404)
455
503
  }
456
- console.error('GitLab raw error (1)', fileRes.status, fileRes.statusText, { rawUrl, errText })
457
- throw new ApiError(`GitLab raw error (1): ${fileRes.statusText} (${errText})`, fileRes.status)
504
+ console.error('[GitlabConnector] Failed to fetch raw file from GitLab', {
505
+ filePath,
506
+ url: rawUrl,
507
+ status: fileRes.status,
508
+ statusText: fileRes.statusText,
509
+ responseText: errText
510
+ })
511
+ throw new ApiError(`Failed to fetch raw file from GitLab: filePath="${filePath}" - ${fileRes.statusText} (${errText})`, fileRes.status)
458
512
  }
459
513
 
460
514
  try {
461
515
  const buffer = await fileRes.buffer()
462
516
  return buffer
463
517
  } catch (e) {
464
- console.error('GitLab raw error (3): could not read buffer', e)
465
- throw new ApiError('GitLab raw error (3): failed to read binary content', 500)
518
+ console.error('[GitlabConnector] Error reading binary content from GitLab raw file', e)
519
+ throw new ApiError('Failed to read binary content from GitLab raw file', 500)
466
520
  }
467
521
  }
468
522
 
@@ -673,39 +727,15 @@ export default class GitlabConnector implements StorageConnector {
673
727
  */
674
728
  async readWebsite(session: GitlabSession, websiteId: string): Promise<WebsiteData> {
675
729
  const websiteDataBuf = await this.downloadRawFile(session, websiteId, WEBSITE_DATA_FILE)
676
- const websiteDataOb = JSON.parse(websiteDataBuf.toString('utf8')) as GitlabWebsiteData & WebsiteData
677
- const { fileFormatVersion, ...websiteData } = websiteDataOb
678
-
679
- // Check the file format version
680
- if (fileFormatVersion !== WEBSITE_DATA_FILE_FORMAT_VERSION) {
681
- // This should be handled by a migration mechanism
682
- console.warn('Gitlab connector: website data file format version mismatch', fileFormatVersion, '!=', WEBSITE_DATA_FILE_FORMAT_VERSION)
683
- }
730
+ const websiteDataContent = websiteDataBuf.toString('utf8')
684
731
 
685
- // This happens when the website was just created
686
- // Let grapesjs create the pages in the frontend
687
- if (!websiteData.pages) {
688
- return websiteData as WebsiteData
732
+ // Use the common merge function to reconstruct website data
733
+ const pageLoader = async (pagePath: string): Promise<string> => {
734
+ const pageBuffer = await this.downloadRawFile(session, websiteId, pagePath)
735
+ return pageBuffer.toString('utf8')
689
736
  }
690
737
 
691
- // Load each page in parallel
692
- const pages = await Promise.all(websiteData.pages.map(async (page: GitlabPage | Page) => {
693
- if ((page as GitlabPage).isFile) {
694
- const name = getPageSlug(page.name)
695
- const fileName = (`${name}-${page.id}`)
696
- const filePath = `${WEBSITE_PAGES_FOLDER}/${fileName}.json`
697
- const pageContent = await this.downloadRawFile(session, websiteId, filePath)
698
- const res = JSON.parse(pageContent.toString('utf8')) as Page
699
- return res
700
- }
701
- return page as Page
702
- }))
703
-
704
- // Read each page file if needed
705
- return {
706
- ...websiteData,
707
- pages,
708
- } as WebsiteData
738
+ return await merge(websiteDataContent, pageLoader)
709
739
  }
710
740
 
711
741
  /**
@@ -720,7 +750,7 @@ export default class GitlabConnector implements StorageConnector {
720
750
  name: this.options.repoPrefix + websiteMeta.name,
721
751
  }
722
752
  }) as any
723
- await this.createFile(session, project.id, WEBSITE_DATA_FILE, JSON.stringify({} as WebsiteData))
753
+ await this.createFile(session, project.id, WEBSITE_DATA_FILE, stringify({} as WebsiteData))
724
754
  //await this.createFile(session, project.id, WEBSITE_META_DATA_FILE, JSON.stringify(websiteMeta))
725
755
  //await this.updateWebsite(session, project.id, {} as WebsiteData)
726
756
  //await this.setWebsiteMeta(session, project.id, websiteMeta)
@@ -745,16 +775,19 @@ export default class GitlabConnector implements StorageConnector {
745
775
  })
746
776
 
747
777
  // **
748
- // Create the pages for batch upload
749
- const pages = websiteData.pages.map((page: Page) => {
750
- const fileName = encodeURIComponent(`${getPageSlug(page.name)}-${page.id}`)
751
- const filePath = `${WEBSITE_PAGES_FOLDER}/${fileName}.json`
752
- const content = JSON.stringify(page)
778
+ // Use the common split function to create files
779
+ const filesToWrite = split(websiteData)
780
+
781
+ // **
782
+ // Process each file to create/update
783
+ for (const file of filesToWrite) {
784
+ const filePath = file.path
785
+ const content = file.content
753
786
  const newSha = computeGitBlobSha(content, false)
754
787
 
755
- const existingSha = existingFiles.get(filePath)
788
+ const existingSha = existingFiles.get(filePath) || (filePath === WEBSITE_DATA_FILE ? 'always-update' : undefined)
756
789
 
757
- if (existingSha) {
790
+ if (existingSha && existingSha !== 'always-update') {
758
791
  if (existingSha !== newSha) {
759
792
  batchActions.push({
760
793
  action: 'update',
@@ -764,26 +797,18 @@ export default class GitlabConnector implements StorageConnector {
764
797
  } // else: skip unchanged file
765
798
  } else {
766
799
  batchActions.push({
767
- action: 'create',
800
+ action: file.path === WEBSITE_DATA_FILE ? 'update' : 'create',
768
801
  file_path: filePath,
769
802
  content,
770
803
  })
771
804
  }
772
-
773
- return {
774
- name: page.name,
775
- id: page.id,
776
- isFile: true,
777
- }
778
- })
805
+ }
779
806
 
780
807
  // **
781
808
  // Delete pages that are not in the new website data
809
+ const newPageFiles = new Set(filesToWrite.filter(f => f.path.startsWith(WEBSITE_PAGES_FOLDER)).map(f => f.path))
782
810
  for (const filePath of existingFiles.keys()) {
783
- const pageName = filePath.replace(/.*\//, '').replace(/\.json$/, '')
784
- const pageId = pageName.split('-').pop()
785
- const page = websiteData.pages.find((p: Page) => p.id === pageId)
786
- if (!page) {
811
+ if (!newPageFiles.has(filePath)) {
787
812
  batchActions.push({
788
813
  action: 'delete',
789
814
  file_path: filePath,
@@ -791,21 +816,6 @@ export default class GitlabConnector implements StorageConnector {
791
816
  }
792
817
  }
793
818
 
794
- // **
795
- // Add the main website data file
796
- const websiteDataWithGitlabPages = {
797
- ...websiteData,
798
- fileFormatVersion: WEBSITE_DATA_FILE_FORMAT_VERSION,
799
- pages,
800
- } as GitlabWebsiteData
801
-
802
- const websiteJsonContent = JSON.stringify(websiteDataWithGitlabPages)
803
- batchActions.push({
804
- action: 'update',
805
- file_path: WEBSITE_DATA_FILE,
806
- content: websiteJsonContent,
807
- })
808
-
809
819
  // **
810
820
  // Perform a batch commit
811
821
  return this.callApi({
@@ -3,6 +3,7 @@ import { ServerConfig } from '../config'
3
3
  import { FsStorage } from './FsStorage'
4
4
  import { readFileSync, rmdirSync, statSync } from 'fs'
5
5
  import { join } from 'path'
6
+ import { WEBSITE_DATA_FILE } from '../../constants'
6
7
 
7
8
  const storageRootPath = '/tmp/silex-tests'
8
9
  const assetsFolder = 'assets'
@@ -45,7 +46,10 @@ describe('FsStorage website', () => {
45
46
  })
46
47
  const id = await connector.createWebsite(dummySession, { name: 'dummy name', connectorUserSettings: {} })
47
48
  expect(id).toBeDefined()
48
- expect(() => statSync(join(storageRootPath, id, 'website.json'))).not.toThrow()
49
- expect(readFileSync(join(storageRootPath, id, 'website.json'), 'utf8')).toBe(JSON.stringify({}))
49
+ expect(() => statSync(join(storageRootPath, id, WEBSITE_DATA_FILE))).not.toThrow()
50
+ const content = readFileSync(join(storageRootPath, id, WEBSITE_DATA_FILE), 'utf8')
51
+ const parsed = JSON.parse(content)
52
+ expect(parsed).toHaveProperty('fileFormatVersion', '1.0.0')
53
+ expect(parsed).toHaveProperty('pages', [])
50
54
  })
51
55
  })
@@ -23,10 +23,11 @@ import { ConnectorUser, WebsiteMeta, JobStatus, WebsiteId, ConnectorType, Websit
23
23
  import { userInfo } from 'os'
24
24
  import { requiredParam } from '../utils/validation'
25
25
  import { ServerConfig } from '../config'
26
- import { DEFAULT_WEBSITE_ID, WEBSITE_DATA_FILE, WEBSITE_META_DATA_FILE } from '../../constants'
26
+ import { DEFAULT_WEBSITE_ID, WEBSITE_DATA_FILE, WEBSITE_META_DATA_FILE, WEBSITE_PAGES_FOLDER } from '../../constants'
27
27
  import { Readable } from 'stream'
28
28
  import { v4 as uuid } from 'uuid'
29
29
  import { fileURLToPath } from 'url'
30
+ import { stringify, split, merge } from '../utils/websiteDataSerialization'
30
31
 
31
32
  // Variables needed for jest tests
32
33
  if(!globalThis.__dirname) {
@@ -145,7 +146,7 @@ export class FsStorage implements StorageConnector<FsSession> {
145
146
 
146
147
  async setWebsiteMeta(session: any, id: string, data: WebsiteMetaFileContent): Promise<void> {
147
148
  const websiteId = requiredParam<WebsiteId>(id, 'website id')
148
- const content = JSON.stringify(data)
149
+ const content = stringify(data)
149
150
  const path = join(this.options.path, id, WEBSITE_META_DATA_FILE)
150
151
  await fs.writeFile(path, content)
151
152
  }
@@ -183,14 +184,68 @@ export class FsStorage implements StorageConnector<FsSession> {
183
184
  async readWebsite(session: FsSession, websiteId: WebsiteId): Promise<WebsiteData> {
184
185
  const id = requiredParam<WebsiteId>(websiteId, 'website id')
185
186
  const path = join(this.options.path, id, WEBSITE_DATA_FILE)
187
+
186
188
  const content = await fs.readFile(path)
187
- return JSON.parse(content.toString())
189
+ const websiteDataContent = content.toString()
190
+
191
+ // Check if this is the new split format
192
+ const parsedData = JSON.parse(websiteDataContent)
193
+ if (parsedData.fileFormatVersion === '1.0.0') {
194
+ // Use the merge function to reconstruct website data
195
+ const pageLoader = async (pagePath: string): Promise<string> => {
196
+ const fullPath = join(this.options.path, id, pagePath)
197
+ const pageContent = await fs.readFile(fullPath)
198
+ return pageContent.toString()
199
+ }
200
+
201
+ return await merge(websiteDataContent, pageLoader)
202
+ } else {
203
+ // Legacy format, return as is
204
+ return parsedData
205
+ }
188
206
  }
189
207
 
190
208
  async updateWebsite(session: FsSession, websiteId: WebsiteId, data: WebsiteData): Promise<void> {
191
209
  const id = requiredParam<WebsiteId>(websiteId, 'website id')
192
- const path = join(this.options.path, id, WEBSITE_DATA_FILE)
193
- await fs.writeFile(path, JSON.stringify(data))
210
+ const websitePath = join(this.options.path, id)
211
+
212
+ // Use the split function to create separate files for pages
213
+ const filesToWrite = split(data)
214
+
215
+ // Ensure the website directory exists
216
+ await fs.mkdir(websitePath, { recursive: true })
217
+
218
+ // Ensure the pages directory exists if we have page files
219
+ const hasPageFiles = filesToWrite.some(f => f.path.startsWith(WEBSITE_PAGES_FOLDER))
220
+ if (hasPageFiles) {
221
+ await fs.mkdir(join(websitePath, WEBSITE_PAGES_FOLDER), { recursive: true })
222
+ }
223
+
224
+ // **
225
+ // Delete pages that are not in the new website data
226
+ const pagesPath = join(websitePath, WEBSITE_PAGES_FOLDER)
227
+ try {
228
+ const existingPageFiles = await fs.readdir(pagesPath)
229
+ const newPageFiles = new Set(
230
+ filesToWrite
231
+ .filter(f => f.path.startsWith(WEBSITE_PAGES_FOLDER))
232
+ .map(f => f.path.replace(`${WEBSITE_PAGES_FOLDER}/`, ''))
233
+ )
234
+
235
+ for (const existingFile of existingPageFiles) {
236
+ if (existingFile.endsWith('.json') && !newPageFiles.has(existingFile)) {
237
+ await fs.unlink(join(pagesPath, existingFile))
238
+ }
239
+ }
240
+ } catch (error) {
241
+ // Ignore error if pages directory doesn't exist yet
242
+ }
243
+
244
+ // Write all files
245
+ for (const file of filesToWrite) {
246
+ const filePath = join(websitePath, file.path)
247
+ await fs.writeFile(filePath, file.content)
248
+ }
194
249
  }
195
250
 
196
251
  async deleteWebsite(session: FsSession, websiteId: WebsiteId): Promise<void> {
@@ -0,0 +1,59 @@
1
+ import { stringify, split, merge } from './websiteDataSerialization'
2
+ import { expect, it, describe } from '@jest/globals'
3
+ import { WebsiteData, defaultWebsiteData } from '../../types'
4
+ import { WEBSITE_DATA_FILE } from '../../constants'
5
+
6
+ describe('websiteDataSerialization', () => {
7
+ describe('stringify', () => {
8
+ it('should format JSON with sorted keys', () => {
9
+ const data = { z: 1, a: 2, m: { z: 3, a: 4 } }
10
+ const result = stringify(data)
11
+ expect(result).toBe(`{
12
+ "a": 2,
13
+ "m": {
14
+ "a": 4,
15
+ "z": 3
16
+ },
17
+ "z": 1
18
+ }`)
19
+ })
20
+ })
21
+
22
+ describe('split', () => {
23
+ it('should split website data into separate files', () => {
24
+ const websiteData: WebsiteData = {
25
+ ...defaultWebsiteData,
26
+ pages: [
27
+ { id: 'page1', getName: () => 'Home' } as any,
28
+ { id: 'page2', getName: () => 'About' } as any
29
+ ]
30
+ }
31
+
32
+ const files = split(websiteData)
33
+
34
+ expect(files).toHaveLength(3)
35
+ expect(files[0].path).toBe('src/home-page1.json')
36
+ expect(files[1].path).toBe('src/about-page2.json')
37
+ expect(files[2].path).toBe(WEBSITE_DATA_FILE)
38
+
39
+ const mainFile = JSON.parse(files[2].content)
40
+ expect(mainFile.fileFormatVersion).toBe('1.0.0')
41
+ expect(mainFile.pages[0].isFile).toBe(true)
42
+ })
43
+ })
44
+
45
+ describe('merge', () => {
46
+ it('should handle legacy format', async () => {
47
+ const legacyContent = `{
48
+ "settings": {},
49
+ "pages": [{ "id": "page1" }]
50
+ }`
51
+
52
+ const pageLoader = async () => { throw new Error('Should not be called') }
53
+ const result = await merge(legacyContent, pageLoader)
54
+
55
+ expect(result.pages).toHaveLength(1)
56
+ expect(result.pages[0].id).toBe('page1')
57
+ })
58
+ })
59
+ })
@@ -0,0 +1,143 @@
1
+ /*
2
+ * Silex website builder, free/libre no-code tool for makers.
3
+ * Copyright (c) 2023 lexoyo and Silex Labs foundation
4
+ *
5
+ * This program is free software: you can redistribute it and/or modify
6
+ * it under the terms of the GNU Affero General Public License as published by
7
+ * the Free Software Foundation, either version 3 of the License, or any later version.
8
+ *
9
+ * This program is distributed in the hope that it will be useful,
10
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ * GNU Affero General Public License for more details.
13
+ *
14
+ * You should have received a copy of the GNU Affero General Public License
15
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+ */
17
+
18
+ import { WebsiteData } from '../../types'
19
+ import { Page } from 'grapesjs'
20
+ import { getPageSlug } from '../../page'
21
+ import { WEBSITE_PAGES_FOLDER, WEBSITE_DATA_FILE } from '../../constants'
22
+
23
+ /**
24
+ * Serialize JSON with stable formatting for git
25
+ * - Uses 2-space indentation for readability
26
+ * - Sorts object keys deterministically to avoid random diffs
27
+ */
28
+ export function stringify(data: any): string {
29
+ return JSON.stringify(data, sortKeys, 2)
30
+ }
31
+
32
+ /**
33
+ * Sort object keys for consistent output across serializations
34
+ */
35
+ function sortKeys(key: string, value: any): any {
36
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
37
+ const sorted: any = {}
38
+ Object.keys(value).sort().forEach(k => {
39
+ sorted[k] = value[k]
40
+ })
41
+ return sorted
42
+ }
43
+ return value
44
+ }
45
+
46
+ /**
47
+ * Split website data into multiple files
48
+ * Returns array of files to create/update
49
+ */
50
+ export function split(websiteData: WebsiteData): Array<{path: string, content: string}> {
51
+ const files: Array<{path: string, content: string}> = []
52
+
53
+ // Split pages into separate files
54
+ const pages = websiteData.pages?.map((page: Page) => {
55
+ const pageName = getPageName(page)
56
+ const slug = getPageSlug(pageName)
57
+ const fileName = `${slug}-${page.id}.json`
58
+ const filePath = `${WEBSITE_PAGES_FOLDER}/${fileName}`
59
+
60
+ files.push({
61
+ path: filePath,
62
+ content: stringify(page)
63
+ })
64
+
65
+ return {
66
+ name: pageName,
67
+ id: page.id,
68
+ isFile: true,
69
+ }
70
+ }) || []
71
+
72
+ // Create main website data file without pages content
73
+ const websiteDataWithoutPages = {
74
+ ...websiteData,
75
+ fileFormatVersion: '1.0.0',
76
+ pages,
77
+ }
78
+
79
+ files.push({
80
+ path: WEBSITE_DATA_FILE,
81
+ content: stringify(websiteDataWithoutPages)
82
+ })
83
+
84
+ return files
85
+ }
86
+
87
+ /**
88
+ * Reconstruct website data from main file and page loader function
89
+ * The pageLoader function will be called for each page that needs to be loaded
90
+ */
91
+ export async function merge(
92
+ websiteDataContent: string,
93
+ pageLoader: (pagePath: string) => Promise<string>
94
+ ): Promise<WebsiteData> {
95
+ const websiteDataOb = JSON.parse(websiteDataContent) as any
96
+ const { fileFormatVersion, ...websiteData } = websiteDataOb
97
+
98
+ // Check file format version
99
+ if (fileFormatVersion && fileFormatVersion !== '1.0.0') {
100
+ console.warn('Website data file format version mismatch', fileFormatVersion, '!=', '1.0.0')
101
+ }
102
+
103
+ // Handle legacy format (no split pages) or empty pages
104
+ if (!websiteData.pages || websiteData.pages.length === 0) {
105
+ return websiteData as WebsiteData
106
+ }
107
+
108
+ // Check if pages are already embedded (legacy format)
109
+ if (websiteData.pages[0] && !websiteData.pages[0].isFile) {
110
+ return websiteData as WebsiteData
111
+ }
112
+
113
+ // Load pages from separate files
114
+ const pages = await Promise.all(websiteData.pages.map(async (pageRef: any) => {
115
+ if (pageRef.isFile) {
116
+ const pageName = pageRef.name
117
+ const slug = getPageSlug(pageName)
118
+ const fileName = `${slug}-${pageRef.id}.json`
119
+ const filePath = `${WEBSITE_PAGES_FOLDER}/${fileName}`
120
+
121
+ try {
122
+ const pageContent = await pageLoader(filePath)
123
+ return JSON.parse(pageContent) as Page
124
+ } catch (error) {
125
+ console.warn(`Could not load page file: ${filePath}`, error)
126
+ return pageRef
127
+ }
128
+ }
129
+ return pageRef as Page
130
+ }))
131
+
132
+ return {
133
+ ...websiteData,
134
+ pages,
135
+ } as WebsiteData
136
+ }
137
+
138
+ /**
139
+ * Get page name from page object
140
+ */
141
+ function getPageName(page: Page | any): string {
142
+ return (page as any).getName ? (page as Page).getName() : (page as any).name
143
+ }