@silexlabs/silex
Version:
Free and easy website builder for everyone.
453 lines (422 loc) • 18.5 kB
text/typescript
/*
* Silex website builder, free/libre no-code tool for makers.
* Copyright (c) 2023 lexoyo and Silex Labs foundation
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { getPageSlug } from '../../page'
import { ApiConnectorLoggedInPostMessage, ApiConnectorLoginQuery, ApiPublicationPublishBody, ClientSideFile, ClientSideFileType, ConnectorData, ConnectorType, ConnectorUser, JobStatus, Initiator, PublicationData, PublicationJobData, PublicationSettings, WebsiteData, WebsiteFile, WebsiteId, WebsiteSettings } from '../../types'
import { Editor } from 'grapesjs'
import { PublicationUi } from './PublicationUi'
import { getUser, logout, publicationStatus, publish } from '../api'
import { API_CONNECTOR_LOGIN, API_CONNECTOR_PATH, API_PATH } from '../../constants'
import { ClientEvent } from '../events'
import { resetRenderComponents, resetRenderCssRules, transformPermalink, transformFiles, transformPath, renderComponents, renderCssRules } from '../publication-transformers'
import { hashString } from '../utils'
import { displayedToStored } from '../assetUrl'
/**
* @fileoverview Publication manager for Silex
* This plugin adds a publication feature to Silex
* It lets the user publish the website to a hosting service
* It can optionally display a button and a dialog
* Useful commands:
* - publish: starts the publication process and optionally open the dialog
* - publish-login: open the login dialog
* - publish-logout: logout from the hosting service and let the user choose a hosting service again
*/
// Constants
export const cmdPublicationStart = 'publish'
export const cmdPublicationLogin = 'publish-login'
export const cmdPublicationLogout = 'publish-logout'
// Types
export type PublishableEditor = Editor & {
PublicationManager: PublicationManager
}
export enum PublicationStatus {
STATUS_NONE = 'STATUS_NONE',
STATUS_PENDING = 'STATUS_PENDING',
STATUS_ERROR = 'STATUS_ERROR',
STATUS_SUCCESS = 'STATUS_SUCCESS',
STATUS_LOGGED_OUT = 'STATUS_AUTH_ERROR',
}
export type PublicationManagerOptions = {
appendTo?: string // If not provided, no button nor dialog will be created
websiteId: WebsiteId
}
// plugin init cod
export default function publishPlugin(editor, opts) {
(editor as PublishableEditor).PublicationManager = new PublicationManager(editor, opts)
}
function jobStatusToPublicationStatus(status: JobStatus): PublicationStatus {
switch (status) {
case JobStatus.IN_PROGRESS:
return PublicationStatus.STATUS_PENDING
case JobStatus.ERROR:
return PublicationStatus.STATUS_ERROR
case JobStatus.SUCCESS:
return PublicationStatus.STATUS_SUCCESS
}
throw new Error(`Unknown job status ${status}`)
}
// Orging and path, should we use config.rootUrl?
const SERVER_URL = window.location.origin + window.location.pathname.replace(/\/$/, '')
// The publication manager class
// This class is responsible for the publication dialog and for the publication process
// It is added to the editor instance as editor.PublicationManager
export class PublicationManager {
/**
* Publication dialog
* This class is responsible for the UI
*/
dialog?: PublicationUi
/**
* Publication settings
* This is the data which is stored in the website settings
*/
_settings: PublicationSettings
get settings(): PublicationSettings {
return this._settings
}
set settings(newSettings: PublicationSettings) {
this._settings = newSettings
this.dialog && (this.dialog.settings = newSettings)
}
/**
* Plugin options
* This is the data which is passed to the plugin by grapesjs
*/
options: PublicationManagerOptions
/**
* Publication job during the publication process
*/
job: PublicationJobData | null = null
/**
* Publication state
* This is the state of the publication process
*/
status: PublicationStatus = PublicationStatus.STATUS_NONE
constructor(private editor: PublishableEditor, opts: PublicationManagerOptions) {
this.options = {
appendTo: 'options',
...opts,
} as PublicationManagerOptions
// Save the publication settings in the website settings
editor.on('storage:start:store', (data) => {
data.publication = this.settings
})
// load publication settings from the website
editor.on('storage:end:load', (data) => {
this.settings = data.publication ?? {}
// Check if the user is logged in
getUser({ type: ConnectorType.HOSTING, connectorId: this.settings.connector?.connectorId })
.then((user) => {})
.catch((err) => {
this.status = PublicationStatus.STATUS_LOGGED_OUT
this.settings = {}
this.dialog && this.dialog.displayError('Please login', this.job, this.status)
})
})
// Add the publish command to the editor
editor.Commands.add(cmdPublicationStart, () => this.startPublication())
editor.Commands.add(cmdPublicationLogin, (editor: PublishableEditor, sender: any, connector: ConnectorData) => this.goLogin(connector))
editor.Commands.add(cmdPublicationLogout, () => this.goLogout())
// Add the publication dialog to the editor
if (this.options.appendTo) {
this.dialog = new PublicationUi(editor, {
appendTo: this.options.appendTo,
})
} else {
console.info('PublicationUi is disabled because no appendTo option is set')
}
}
async goLogin(connector: ConnectorData) {
let preventDefault = false
this.editor.trigger(ClientEvent.PUBLISH_LOGIN_START, { connector, publicationManager: this, preventDefault: () => preventDefault = true })
if(preventDefault) {
this.status = PublicationStatus.STATUS_NONE
this.dialog && this.dialog.displayPending(this.job, this.status)
return
}
// Check if the user is already logged in
if(connector.isLoggedIn) {
this.settings = {
...this.settings, // In case there are options
connector,
}
this.status = PublicationStatus.STATUS_NONE
// Save the website with the new settings
// WIP: prevent saving during publication
// await this.editor.store(null)
// Display the dialog
this.dialog && this.dialog.displayPending(this.job, this.status)
return
}
this.settings = {}
this.status = PublicationStatus.STATUS_LOGGED_OUT
this.dialog && this.dialog.displayPending(this.job, this.status)
const params: ApiConnectorLoginQuery = {
connectorId: connector.connectorId,
type: connector.type,
}
window.open(connector.oauthUrl || `${SERVER_URL}${API_PATH}${API_CONNECTOR_PATH}${API_CONNECTOR_LOGIN}?connectorId=${params.connectorId}&type=${params.type}`, '_blank')
return new Promise<void>((resolve, reject) => {
const onMessage = async (event) => {
const data = event.data as ApiConnectorLoggedInPostMessage
if (data?.type === 'login') {
window.removeEventListener('message', onMessage)
if (data.error) {
this.status = PublicationStatus.STATUS_LOGGED_OUT
this.settings = {}
this.dialog && this.dialog.displayError(data.message, this.job, this.status)
reject(new Error(data.message))
} else {
this.editor.trigger(ClientEvent.PUBLISH_LOGIN_END)
//const uesr = await getUser({type: connector.type, connectorId: data.connectorId})
this.settings.connector = connector
this.settings.options = data.options
this.status = PublicationStatus.STATUS_NONE
// Save the website with the new settings
// WIP: prevent saving during publication
// await this.editor.store(null)
// Display the dialog
this.dialog && this.dialog.displayPending(this.job, this.status)
//await this.startPublication()
resolve()
}
}
}
window.addEventListener('message', onMessage, false)
})
}
async goLogout() {
try {
await logout({type: ConnectorType.HOSTING, connectorId: this.settings.connector.connectorId})
this.settings = {}
this.dialog && this.dialog.displayPending(this.job, this.status)
} catch (e) {
console.error('logout error', e)
this.status = PublicationStatus.STATUS_ERROR
this.dialog && this.dialog.displayError(e.message, this.job, this.status)
}
}
async getPublicationData(projectData, siteSettings, preventDefault: () => void): Promise<PublicationData> {
// Data to publish
// See assetUrl.ts which is a default transformer, always present
this.setPublicationTransformers()
// Build the files structure
const files: ClientSideFile[] = (await this.getHtmlFiles(siteSettings, preventDefault))
.flatMap(file => ([{
path: file.htmlPath, // Already "transformed" in getHtmlFiles
content: file.html,
type: ClientSideFileType.HTML,
} as ClientSideFile, {
path: file.cssPath, // Already "transformed" in getHtmlFiles
content: file.css,
type: ClientSideFileType.CSS,
} as ClientSideFile]))
.concat(projectData.assets
.map(asset => {
// TODO: is this needed?
// // Remove /assets that is added by grapesjs
// const initialPath = asset.src
// .replace(/^\/assets/, '')
// Transform the file paths with the transformers
const path = transformPath(this.editor, asset.src, ClientSideFileType.ASSET)
//const src = transformPermalink(this.editor, asset.src, ClientSideFileType.ASSET)
// This is done in transformPermalink and transformPath but other transformers may change it
// So we do this only using displayedToStored for the path
// As path is used to download the asset
const src = displayedToStored(asset.src)
// Remove the /asset prefix to keep only the file name
.replace(/^\/assets\//, '')
return {
...asset,
path,
src,
type: ClientSideFileType.ASSET, // Replaces grapesjs's 'image' type
} as ClientSideFile
}))
// Create the data to send to the server
const data: PublicationData = {
...projectData,
settings: siteSettings,
publication: this.settings,
files,
}
this.resetPublicationTransformers()
// Let plugins transform the data
transformFiles(this.editor, data)
this.editor.trigger(ClientEvent.PUBLISH_DATA, { data, preventDefault, publicationManager: this })
// Return the data
return data
}
/**
* Start the publication process
* This is the command "publish"
*/
async startPublication() {
try {
if (this.status === PublicationStatus.STATUS_PENDING) throw new Error('Publication is already in progress')
this.status = PublicationStatus.STATUS_PENDING
this.job = null
this.dialog && this.dialog.displayPending(this.job, this.status)
// Get the data to publish, clone the objects because plugins can change it
const projectData = { ...this.editor.getProjectData() as WebsiteData }
const siteSettings = { ...this.editor.getModel().get('settings') as WebsiteSettings }
let preventDefaultStart = false
this.editor.trigger(ClientEvent.PUBLISH_START, {projectData, siteSettings, preventDefault: () => preventDefaultStart = true, publicationManager: this })
if(preventDefaultStart) {
this.status = PublicationStatus.STATUS_NONE
this.dialog && this.dialog.displayPending(this.job, this.status)
return
}
// Get the data to publish
let preventDefaultData = false
const data = await this.getPublicationData(projectData, siteSettings, () => preventDefaultData = true)
if(preventDefaultData) {
this.status = PublicationStatus.STATUS_NONE
this.dialog && this.dialog.displayPending(this.job, this.status)
return
}
// User and where to publish
const storageUser = this.editor.getModel().get('user') as ConnectorUser
if(!storageUser) throw new Error('User not logged in to a storage connector')
if(!this.settings.connector?.connectorId) throw new Error('User not logged in to a hosting connector')
const websiteId = this.options.websiteId
const storageId = storageUser.storage.connectorId
// Use the publication API
const [url, job] = await publish({
websiteId,
hostingId: this.settings.connector.connectorId,
storageId,
data: data as ApiPublicationPublishBody,
options: this.settings.options,
})
// in gitlab pages situation, getUrl from gitlabHostingConnector gives publication url obtained with Gitlab API pages
console.info('Gitlab url: ', url)
// could be used in an future UI
this.job = job
this.status = jobStatusToPublicationStatus(this.job.status)
this.trackProgress()
} catch (e) {
console.error('publish error', e)
if(e.code === 401 || e.httpStatusCode === 401) {
this.status = PublicationStatus.STATUS_LOGGED_OUT
this.settings = {}
this.dialog && this.dialog.displayError('Please login.', this.job, this.status)
} else {
this.status = PublicationStatus.STATUS_ERROR
this.dialog && this.dialog.displayError(`An error occured, your site is not published. ${e.message}`, this.job, this.status)
}
this.editor.trigger(ClientEvent.PUBLISH_ERROR, { success: false, message: e.message })
this.editor.trigger(ClientEvent.PUBLISH_END, { success: false, message: e.message })
return
}
}
async getHtmlFiles(siteSettings: WebsiteSettings, preventDefault): Promise<WebsiteFile[]> {
const files: WebsiteFile[] = []
const generator = this.getHtmlFilesYield(siteSettings, preventDefault)
for await (const file of generator) {
if (file) {
files.push(file)
}
}
return files
}
async *getHtmlFilesYield(siteSettings: WebsiteSettings, preventDefault): AsyncGenerator<WebsiteFile | undefined> {
for (const page of this.editor.Pages.getAll()) {
// Clone the settings because plugins can change them
const clonedSiteSettings = { ...siteSettings }
const pageSettings = { ...page.get('settings') as WebsiteSettings }
// Utility function to get a setting from the page or the site settings
const getSetting = (name: string) =>
(pageSettings || {})[name] || (clonedSiteSettings || [])[name] || ''
// Get the content from GrapesJS
const body = page.getMainComponent()
const cssContent = this.editor.getCss({ component: body })
console.time(`getHtml ${page.getId()} ${page.get('name')}`)
const htmlContent = this.editor.getHtml({ component: body })
console.timeEnd(`getHtml ${page.getId()} ${page.get('name')}`)
yield undefined // Yield control to avoid blocking the main thread
// Transform the file paths
const slug = getPageSlug(page.get('name'))
const cssInitialPath = `/css/${slug}-${await hashString(cssContent)}.css`
const htmlInitialPath = `/${slug}.html`
const cssPermalink = transformPermalink(this.editor, cssInitialPath, ClientSideFileType.CSS, Initiator.HTML)
yield undefined // Yield control to avoid blocking the main thread
const cssPath = transformPath(this.editor, cssInitialPath, ClientSideFileType.CSS)
yield undefined // Yield control to avoid blocking the main thread
const htmlPath = transformPath(this.editor, htmlInitialPath, ClientSideFileType.HTML)
yield undefined // Yield control to avoid blocking the main thread
// Let plugins transform the data
this.editor.trigger(ClientEvent.PUBLISH_PAGE, {
page,
siteSettings: clonedSiteSettings,
pageSettings,
preventDefault,
publicationManager: this,
})
// Useful data for HTML result
const title = getSetting('title')
const favicon = getSetting('favicon')
// Return the HTML file
yield {
html: `<!DOCTYPE html>
<html lang="${getSetting('lang')}">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="${cssPermalink}" />
${clonedSiteSettings?.head || ''}
${pageSettings?.head || ''}
${title ? `<title>${title}</title>` : ''}
${favicon ? `<link rel="icon" href="${favicon}" />` : ''}
${['description', 'og:title', 'og:description', 'og:image']
.filter((prop) => !!getSetting(prop))
.map((prop) => `<meta name="${prop}" property="${prop}" content="${getSetting(prop)}"/>`)
.join('\n')}
</head>
${htmlContent}
</html>`,
css: cssContent,
cssPath,
htmlPath,
}
}
}
async trackProgress() {
try {
this.job = await publicationStatus({jobId: this.job.jobId})
this.status = jobStatusToPublicationStatus(this.job.status)
} catch (e) {
this.status = PublicationStatus.STATUS_ERROR
this.dialog && this.dialog.displayError(`An error occured, your site is not published. ${e.message}`, this.job, this.status)
this.editor.trigger(ClientEvent.PUBLISH_END, { success: false, message: e.message })
this.editor.trigger(ClientEvent.PUBLISH_ERROR, { success: false, message: e.message })
return
}
if (this.job.status === JobStatus.IN_PROGRESS) {
setTimeout(() => this.trackProgress(), 2000)
} else {
this.editor.trigger(ClientEvent.PUBLISH_END, { success: this.job.status === JobStatus.SUCCESS, message: this.job.message })
}
this.dialog && this.dialog.displayPending(this.job, this.status)
}
private setPublicationTransformers() {
renderComponents(this.editor)
renderCssRules(this.editor)
}
private resetPublicationTransformers() {
resetRenderComponents(this.editor)
resetRenderCssRules(this.editor)
}
}