@silexlabs/silex
Version:
Free and easy website builder for everyone.
292 lines (274 loc) • 11.1 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 { Component, CssRule, StyleProps, Editor } from 'grapesjs'
import { ClientSideFile, ClientSideFileType, Initiator, PublicationData } from '../types'
import { onAll } from './utils'
/**
* @fileoverview Silex publication transformers are used to control how the site is rendered and published
* Here we call path the path on the connector (either storage or hosting)
* We call permalink the path at which the resource is served
* This is where pages and assets paths and permalinks are set
* Here we also update background images urls, assets url and links to match the new permalinks
*/
// Properties names used to store the original methods on the components and styles
const ATTRIBUTE_METHOD_STORE_HTML = 'tmp-pre-publication-transformer-tohtml'
const ATTRIBUTE_METHOD_STORE_SRC = 'tmp-pre-publication-transformer-src'
const ATTRIBUTE_METHOD_STORE_ATTRIBUTES_SRC = 'tmp-pre-publication-transformer-attributes-src'
const ATTRIBUTE_METHOD_STORE_INLINE_CSS = 'tmp-pre-publication-transformer-inline-css'
const ATTRIBUTE_METHOD_STORE_HREF = 'tmp-pre-publication-transformer-href'
const ATTRIBUTE_METHOD_STORE_CSS = 'tmp-pre-publication-transformer-tocss'
/**
* Interface for publication transformers
* They are added to the config object with config.addPublicationTransformer()
*/
export interface PublicationTransformer {
// Temporarily override how components render at publication by grapesjs
renderComponent?(component: Component, toHtml: () => string): string | undefined
// Temporarily override how styles render at publication by grapesjs
renderCssRule?(rule: CssRule, initialRule: () => StyleProps): StyleProps | undefined
// Transform files after they are rendered and before they are published
transformFile?(file: ClientSideFile): ClientSideFile
// Define files URLs
transformPermalink?(link: string, type: ClientSideFileType, initiator: Initiator): string
// Define where files are published
transformPath?(path: string, type: ClientSideFileType): string
}
export const publicationTransformerDefault: PublicationTransformer = {
// Override how components render at publication by grapesjs
renderComponent(component: Component, toHtml: () => string): string | undefined {
return toHtml()
},
// Override how styles render at publication by grapesjs
renderCssRule(rule: CssRule, initialRule: () => StyleProps): StyleProps | undefined {
return initialRule()
},
// Define where files are published
transformPath(path: string, type: ClientSideFileType): string {
return path
},
// Define files URLs
transformPermalink(link: string, type: ClientSideFileType, initiator: Initiator): string {
switch(initiator) {
case Initiator.HTML:
return link
case Initiator.CSS:
// In case of a link from a CSS file, we need to go up one level
return `../${link.replace(/^\//, '')}`
default:
throw new Error(`Unknown initiator ${initiator}`)
}
},
// Define how files are named
transformFile(file: ClientSideFile): ClientSideFile {
return file
}
}
export function validatePublicationTransformer(transformer: PublicationTransformer): void {
// List all the properties
const allowedProperties = [
'renderComponent',
'renderCssRule',
'transformFile',
'transformPermalink',
'transformPath',
]
// Check that there are no unknown properties
Object.keys(transformer).forEach(key => {
if(!allowedProperties.includes(key)) {
throw new Error(`Publication transformer: unknown property ${key}`)
}
})
// Check that the methods are functions
allowedProperties.forEach(key => {
if(typeof transformer[key] !== 'function' && transformer[key] !== undefined) {
throw new Error(`Publication transformer: ${key} must be a function`)
}
})
}
/**
* Alter the components rendering
* Exported for unit tests
*/
export function renderComponents(editor: Editor) {
const config = editor.getModel().get('config')
onAll(editor, (c: Component) => {
if (c.get(ATTRIBUTE_METHOD_STORE_HTML)) {
console.warn('Publication transformer: HTML transform already altered', c)
} else {
const initialToHTML = c.toHTML.bind(c)
c[ATTRIBUTE_METHOD_STORE_HTML] = c.toHTML
const initialGetStyle = c.getStyle.bind(c)
c[ATTRIBUTE_METHOD_STORE_INLINE_CSS] = c.getStyle
const href = c.get('attributes').href as string | undefined
if(href?.startsWith('./')) {
c[ATTRIBUTE_METHOD_STORE_HREF] = href
c.set('attributes', {
...c.get('attributes'),
href: transformPermalink(editor, href, ClientSideFileType.HTML, Initiator.HTML),
})
}
// Handle both c.attributes.src and c.attributes.attributes.src
// For some reason we need both
// Especially when the component is not on the current page, we need c.attributes.attributes.src
if(c.get('attributes').src) {
c[ATTRIBUTE_METHOD_STORE_SRC] = c.get('attributes').src
const src = transformPermalink(editor, c.get('attributes').src, ClientSideFileType.ASSET, Initiator.HTML)
c.set('attributes', {
...c.get('attributes'),
src,
})
}
if(c.get('src')) {
c[ATTRIBUTE_METHOD_STORE_ATTRIBUTES_SRC] = c.get('src')
const src = transformPermalink(editor, c.get('src'), ClientSideFileType.ASSET, Initiator.HTML)
c.set('src', src)
}
c.toHTML = () => {
return config.publicationTransformers.reduce((html: string, transformer: PublicationTransformer) => {
return transformer.renderComponent ? transformer.renderComponent(c, () => html) ?? html : html
}, initialToHTML())
}
c.getStyle = () => transformBgImage(editor, initialGetStyle())
}
})
}
/**
* Alter the styles rendering
* Exported for unit tests
*/
export function renderCssRules(editor: Editor) {
const config = editor.getModel().get('config')
editor.Css.getAll().forEach((style: CssRule) => {
if (style[ATTRIBUTE_METHOD_STORE_CSS]) {
console.warn('Publication transformer: CSS transform already altered', style)
} else {
const initialGetStyle = style.getStyle.bind(style)
style[ATTRIBUTE_METHOD_STORE_CSS] = style.getStyle
style.getStyle = () => {
const initialStyle = transformBgImage(editor, initialGetStyle())
const result = config.publicationTransformers.reduce((s: CssRule, transformer: PublicationTransformer) => {
return {
...transformer.renderCssRule ? transformer.renderCssRule(s, () => initialStyle) ?? s : s,
}
}, initialStyle)
return result
}
}
})
}
function doTransformPermalink(editor: Editor, cssValue: string): string {
return cssValue.replace(/url\(([^)]+)\)/g, (match, url) => {
// Support URLs with or without quotes
const cleanUrl = url.replace(/['"]/g, '')
// Transform URLs
const newUrl = transformPermalink(editor, cleanUrl, ClientSideFileType.ASSET, Initiator.CSS)
// Return the new URL with url keyword
return `url("${newUrl}")`
})
}
/**
* Transform background image url according to the transformed path of assets
*/
export function transformBgImage(editor: Editor, style: StyleProps): StyleProps {
const cssValue = style['background-image']
if (cssValue) {
let newCssValue
if (Array.isArray(cssValue)) {
newCssValue = cssValue
.map(value => doTransformPermalink(editor, value))
.join(', ')
} else if (typeof cssValue === 'string') {
newCssValue = doTransformPermalink(editor, cssValue)
} else if (newCssValue === cssValue) {
// No change
return style
}
// Set the new value
return {
...style,
'background-image': newCssValue,
}
}
return style
}
/**
* Transform files
* Exported for unit tests
*/
export function transformFiles(editor: Editor, data: PublicationData) {
const config = editor.getModel().get('config')
data.files = config.publicationTransformers.reduce((files: ClientSideFile[], transformer: PublicationTransformer) => {
return files.map((file, idx) => {
const page = data.pages[idx] ?? null
return transformer.transformFile ? transformer.transformFile(file) as ClientSideFile ?? file : file
})
}, data.files)
}
/**
* Transform files paths
* Exported for unit tests
*/
export function transformPermalink(editor: Editor, path: string, type: ClientSideFileType, initiator: Initiator): string {
const config = editor.getModel().get('config')
return config.publicationTransformers.reduce((result: string, transformer: PublicationTransformer) => {
return transformer.transformPermalink ? transformer.transformPermalink(result, type, initiator) ?? result : result
}, path)
}
export function transformPath(editor: Editor, path: string, type: ClientSideFileType): string {
const config = editor.getModel().get('config')
return config.publicationTransformers.reduce((result: string, transformer: PublicationTransformer) => {
return transformer.transformPath ? transformer.transformPath(result, type) ?? result : result
}, path)
}
export function resetRenderComponents(editor: Editor) {
onAll(editor, (c: Component) => {
if (c[ATTRIBUTE_METHOD_STORE_HTML]) {
c.toHTML = c[ATTRIBUTE_METHOD_STORE_HTML]
delete c[ATTRIBUTE_METHOD_STORE_HTML]
}
if (c[ATTRIBUTE_METHOD_STORE_INLINE_CSS]) {
c.getStyle = c[ATTRIBUTE_METHOD_STORE_INLINE_CSS]
delete c[ATTRIBUTE_METHOD_STORE_INLINE_CSS]
}
if(c[ATTRIBUTE_METHOD_STORE_SRC]) {
c.set('attributes', {
...c.get('attributes'),
src: c[ATTRIBUTE_METHOD_STORE_SRC],
})
delete c[ATTRIBUTE_METHOD_STORE_SRC]
}
if(c[ATTRIBUTE_METHOD_STORE_ATTRIBUTES_SRC]) {
c.set('src', c[ATTRIBUTE_METHOD_STORE_ATTRIBUTES_SRC])
delete c[ATTRIBUTE_METHOD_STORE_ATTRIBUTES_SRC]
}
if(c[ATTRIBUTE_METHOD_STORE_HREF]) {
c.set('attributes', {
...c.get('attributes'),
href: c[ATTRIBUTE_METHOD_STORE_HREF],
})
delete c[ATTRIBUTE_METHOD_STORE_HREF]
}
})
}
export function resetRenderCssRules(editor: Editor) {
editor.Css.getAll().forEach(c => {
if (c[ATTRIBUTE_METHOD_STORE_CSS]) {
c.getStyle = c[ATTRIBUTE_METHOD_STORE_CSS]
delete c[ATTRIBUTE_METHOD_STORE_CSS]
}
})
}