UNPKG

@silexlabs/silex

Version:

Free and easy website builder for everyone.

628 lines (583 loc) 25.3 kB
/* * 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, SILEX_VERSION } from '../../constants' import { ClientEvent } from '../events' import { resetRenderComponents, resetRenderCssRules, transformPermalink, transformFiles, transformPath, renderComponents, renderCssRules } from '../publication-transformers' import { hashString } from '../utils' import { displayedToStored, isExternalUrl } 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()) // Register AI capabilities editor.on('ai-capabilities:ready', (addCapability) => { addCapability({ id: cmdPublicationStart, command: cmdPublicationStart, description: 'Publish the website', openWorld: true, tags: ['publication'], }) }) // 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 // Filter out external assets (http://, https://, //) - they don't need to be downloaded/uploaded .filter(asset => { if (isExternalUrl(asset.src)) { return false } return true }) .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 } // Check for missing SEO tags and warn user this.checkSeoTags(siteSettings) 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 } /** * Helper function to check if a viewport meta tag exists in the head content * @param headContent - HTML content from head settings * @returns true if viewport meta tag is found */ private hasViewportMeta(headContent: string): boolean { if (!headContent) return false // Check for viewport meta tag with various formats const viewportPattern = /<meta[^>]*name\s*=\s*["']viewport["'][^>]*>/i return viewportPattern.test(headContent) } /** * Get the default viewport meta tag if no viewport is present * @param siteHead - Head content from site settings * @param pageHead - Head content from page settings * @returns viewport meta tag or empty string if already present */ private ensureViewportMeta(siteHead: string, pageHead: string): string { if (this.hasViewportMeta(siteHead) || this.hasViewportMeta(pageHead)) { return '' } return '<meta name="viewport" content="width=device-width, initial-scale=1.0">' } /** * Helper function to check if a generator meta tag exists in the head content * @param headContent - HTML content from head settings * @returns true if generator meta tag is found */ private hasGeneratorMeta(headContent: string): boolean { if (!headContent) return false // Check for generator meta tag with various formats const generatorPattern = /<meta[^>]*name\s*=\s*["']generator["'][^>]*>/i return generatorPattern.test(headContent) } /** * Get the default generator meta tag if no generator is present * @param siteHead - Head content from site settings * @param pageHead - Head content from page settings * @returns generator meta tag or empty string if already present */ private ensureGeneratorMeta(siteHead: string, pageHead: string): string { if (this.hasGeneratorMeta(siteHead) || this.hasGeneratorMeta(pageHead)) { return '' } return `<meta name="generator" content="Silex v${ SILEX_VERSION }">` } /** * Helper function to check if a canonical link exists in the head content * @param headContent - HTML content from head settings * @returns true if canonical link is found */ private hasCanonicalLink(headContent: string): boolean { if (!headContent) return false const canonicalPattern = /<link[^>]*rel\s*=\s*["']canonical["'][^>]*>/i return canonicalPattern.test(headContent) } /** * Helper function to check if alternate links exist in the head content * @param headContent - HTML content from head settings * @returns true if alternate links are found */ private hasAlternateLinks(headContent: string): boolean { if (!headContent) return false const alternatePattern = /<link[^>]*rel\s*=\s*["']alternate["'][^>]*>/i return alternatePattern.test(headContent) } /** * Check for missing SEO tags and emit warnings * @param siteSettings - Site settings */ private checkSeoTags(siteSettings: WebsiteSettings): void { const pages = this.editor.Pages.getAll() // Utility function to get a setting from the page or the site settings const getSetting = (pageSettings: WebsiteSettings, name: string) => (pageSettings || {})[name] || (siteSettings || {})[name] || '' for (const page of pages) { const pageSettings = page.get('settings') as WebsiteSettings || {} const siteHead = siteSettings?.head || '' const pageHead = pageSettings?.head || '' const pageName = page.get('name') || 'Unnamed page' const bodyComponent = page.getMainComponent() const componentId = bodyComponent?.getId() const pageId = page.getId() // Check for lang attribute const lang = getSetting(pageSettings, 'lang') if (!lang || lang.trim() === '') { this.editor.runCommand('notifications:add', { id: `seo-validation-lang-${pageId}`, type: 'warning', message: `Page "${pageName}": Missing lang attribute on &lt;html&gt; tag. This is important for accessibility and SEO.`, componentId, group: 'seo-validation' }) } // Check for title const title = getSetting(pageSettings, 'title') if (!title || title.trim() === '') { this.editor.runCommand('notifications:add', { id: `seo-validation-title-${pageId}`, type: 'warning', message: `Page "${pageName}": Missing &lt;title&gt; tag. This is critical for SEO.`, componentId, group: 'seo-validation' }) } // Check for description const description = getSetting(pageSettings, 'description') if (!description || description.trim() === '') { this.editor.runCommand('notifications:add', { id: `seo-validation-description-${pageId}`, type: 'warning', message: `Page "${pageName}": Missing meta description. This is important for SEO and search results display.`, componentId, group: 'seo-validation' }) } // Check for canonical link if (!this.hasCanonicalLink(siteHead) && !this.hasCanonicalLink(pageHead)) { this.editor.runCommand('notifications:add', { id: `seo-validation-canonical-${pageId}`, type: 'warning', message: `Page "${pageName}": Missing canonical link. This helps prevent duplicate content issues.`, componentId, group: 'seo-validation' }) } // Check for alternate links (hreflang for multilingual sites) if (!this.hasAlternateLinks(siteHead) && !this.hasAlternateLinks(pageHead)) { this.editor.runCommand('notifications:add', { id: `seo-validation-alternate-${pageId}`, type: 'warning', message: `Page "${pageName}": Missing alternate links (hreflang). Consider adding these for multilingual sites.`, componentId, group: 'seo-validation' }) } } } 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') const viewportMeta = this.ensureViewportMeta(clonedSiteSettings?.head || '', pageSettings?.head || '') const generatorMeta = this.ensureGeneratorMeta(clonedSiteSettings?.head || '', pageSettings?.head || '') // Return the HTML file yield { html: `<!DOCTYPE html> <html lang="${getSetting('lang')}"> <head> <meta charset="UTF-8"> ${viewportMeta} ${generatorMeta} <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) } }