UNPKG

@silexlabs/silex

Version:

Free and easy website builder for everyone.

742 lines (683 loc) 27.6 kB
import dedent from 'dedent' import { Component, Page, Editor } from 'grapesjs' import { BinaryOperator, Filter, GraphQLOptions, IDataSource, NOTIFICATION_GROUP, Properties, Property, State, StateId, StoredState, Token, UnariOperator, fromStored, getAllDataSources, getDataSource, getPageQuery, getPersistantId, getState, getStateIds, getStateVariableName, toExpression } from '@silexlabs/grapesjs-data-source' import { assignBlock, echoBlock, echoBlock1line, getPaginationData, ifBlock, loopBlock } from './liquid' import { EleventyPluginOptions, Silex11tyPluginWebsiteSettings } from './index' import { PublicationTransformer } from '../../publication-transformers' import { ClientConfig } from '../../config' import { UNWRAP_ID } from './traits' import { EleventyDataSourceId } from './DataSource' import { ClientEvent } from '../../events' import { WebsiteSettings, ClientSideFile, ClientSideFileType, ClientSideFileWithContent, PublicationData } from '../../../types' const ATTRIBUTE_MULTIPLE_VALUES = ['class', 'style'] /** * A memoization mechanism to avoid rendering the same component multiple times * The cache is cleared every time the publication is done * This is a workaround because grapesjs editor.getHtml will call each component's toHtml method multiple times */ const cache = new Map<string, string>() /** * A state with the real tokens instead of the stored tokens */ interface RealState { stateId: StateId, label?: string, tokens: Token[] } function getFetchPluginOptions(options: EleventyPluginOptions, settings: Silex11tyPluginWebsiteSettings): object | false { if(typeof options.fetchPluginSettings !== 'undefined') { return options.fetchPluginSettings } return settings.eleventyFetch ? { duration: '1s' } : false } export default function (editor: Editor, options: EleventyPluginOptions) { /* @ts-ignore There should be a better way to get Silex config */ const config = window.silex.config as ClientConfig // Generate the liquid when the site is published config.addPublicationTransformers({ // Render the components when they are published // Will run even with enable11ty = false in order to enable HTML attributes renderComponent: (component: Component, toHtml: () => string) => withNotification(() => renderComponent(editor, component, toHtml), editor, component.getId()), // Transform the paths to be published according to options.urls transformPermalink: options.enable11ty ? (path: string, type: string) => withNotification(() => transformPermalink(editor, path, type, options), editor, null) : undefined, // Transform the paths to be published according to options.dir transformPath: options.enable11ty ? (path: string, type: string) => withNotification(() => transformPath(editor, path, type, options), editor, null) : undefined, // Transform the files content //transformFile: (file) => transformFile(file), }) if (options.enable11ty) { // Generate 11ty data files // FIXME: should this be in the publication transformers editor.on('silex:publish:page', (data, ...args) => { withNotification(() => transformPage(editor, data), editor, null) }) editor.on('silex:publish:data', ({ data/*, preventDefault, publicationManager */ }, ...args) => { withNotification(() => transformFiles(editor, options, data, config), editor, null) }) editor.on('silex:publish:end', (...args) => { cache.clear() }) } } /** * Check if the 11ty publication is enabled */ function enable11ty(): boolean { return getAllDataSources() .filter(ds => ds.id !== EleventyDataSourceId) .length > 0 } /** * Make html attribute * Quote strings, no values for boolean */ function makeAttribute(key: string, value: string | boolean): string { switch (typeof value) { case 'boolean': return value ? key : '' default: return `${key}="${value}"` } } /** * Transform the file name to be published */ function slugify(text: string | number) { return text.toString().toLowerCase() .replace(/\s+/g, '-') // Replace spaces with - .replace(/[^a-z0-9-]/g, '') // Remove all non-word chars .replace(/--+/g, '-') // Replace multiple - with single - .replace(/^-+/, '') // Trim - from start of text .replace(/-+$/, '') // Trim - from end of text } function ensureLeadingAndTrailingSlash(str: string): string { if (str.length === 0) return '/' let s = str if (!s.startsWith('/')) s = '/' + s // No empty file name if (s.endsWith('/.html')) throw new Error('Permalink may not end with "/.html". Your missing a file name!') // If it ends with .html, keep as-is; otherwise ensure trailing slash if (!s.endsWith('/') && !s.endsWith('.html')) s = s + '/' return s } export function getPermalink(page: Page, permalink: Token[], isCollectionPage: boolean, slug: string): string | null { const isHome = slug === 'index' // User provided a permalink explicitely if (permalink && permalink.length > 0) { const body = page.getMainComponent() as Component const transformedTokens = permalink.map(token => { // Replace states which will be one from ./states.ts // For permalink, we need to use the actual 11ty variable names, not state references if(token.type === 'state') { // Map known 11ty pagination states to their variable names const stateToFieldId: Record<string, string> = { 'pagination': 'pagination', 'items': 'pagination.items', 'pages': 'pagination.pages', } const fieldId = stateToFieldId[token.storedStateId] if (fieldId) { // Create a clean Property token for known 11ty pagination states return { type: 'property', propType: 'field', fieldId, label: token.storedStateId, dataSourceId: undefined, // No data source, this is an 11ty variable typeIds: [], kind: 'object', options: {}, } as Property } // For other states, use the existing logic const state = getState(body, token.storedStateId, true) if(!state) throw new Error('State not found on body') return { ...state.expression[0], dataSourceId: undefined, fieldId: token.label, } as Property } return token }) return ensureLeadingAndTrailingSlash(echoBlock1line(body, transformedTokens)) } else if (isCollectionPage) { // Let 11ty handle the permalink return null } else if (isHome) { // Normal home page return '/index.html' } else { // Use the page name return `/${slug}/index.html` } } /** * Get the front matter for a given page */ export function getFrontMatter(page: Page, settings: Silex11tyPluginWebsiteSettings, slug: string, collection: string, lang = ''): string { const data = (function() { if(!settings.eleventyPageData) return undefined const expression = toExpression(settings.eleventyPageData) if(expression) { if(expression.filter(token => token.type !== 'property').length > 0) { console.warn('Expression for pagination data has to contain only properties', expression.map(token => token.type)) } return getPaginationData(expression as Property[]) } else { // Probably not JSON (backward compat) return settings.eleventyPageData } })() const isCollectionPage = !!data && data.length > 0 const permalinkExpression = toExpression(settings.eleventyPermalink) // Here permalinkExpression contains filters and properties. It contains 11ty data source states too const permalink = getPermalink(page, permalinkExpression as (Property | Filter)[], isCollectionPage, slug) // Escape quotes in permalink // because it is in double quotes in the front matter ?.replace(/"/g, '\\"') return dedent`--- ${data && data.length > 0 ? `pagination: addAllPagesToCollections: true data: ${data} size: ${settings.eleventyPageSize ? settings.eleventyPageSize : '1'} ` : ''} ${permalink ? `permalink: "${permalink}"` : ''} ${lang ? `lang: "${lang}"` : ''} ${collection ? `collection: "${collection}"` : ''} ${settings?.eleventyNavigationKey ? `eleventyNavigation: key: ${settings.eleventyNavigationKey} ${settings.eleventyNavigationTitle ? `title: ${settings.eleventyNavigationTitle}` : ''} ${settings.eleventyNavigationOrder ? `order: ${settings.eleventyNavigationOrder}` : ''} ${settings.eleventyNavigationParent ? `parent: ${settings.eleventyNavigationParent}` : ''} ${settings.eleventyNavigationUrl ? `url: ${settings.eleventyNavigationUrl}` : ''} ` : ''} ` // Prettify .split('\n') .filter(line => line.trim().length > 0) .concat(['', '---', '']) .join('\n') } /** * Get the body states for a given page */ export function getBodyStates(page: Page): string { // Render the body states const body = page.getMainComponent() as Component const pagination = getState(body, 'pagination', true) if (pagination && pagination.expression.length > 0) { //const block = getLiquidBlock(body, pagination.expression) const bodyId = getPersistantId(body) if (bodyId) { return dedent` {% assign ${getStateVariableName(bodyId, 'pagination')} = pagination %} {% assign ${getStateVariableName(bodyId, 'items')} = pagination.items %} {% assign ${getStateVariableName(bodyId, 'pages')} = pagination.pages %} ` } else { console.error('body has no persistant ID => do not add liquid for 11ty data') } } return '' } export function transformPage(editor: Editor, data: { page: Page, siteSettings: WebsiteSettings, pageSettings: Silex11tyPluginWebsiteSettings }): void { // Do nothing if there is no data source, just a static site if(!enable11ty()) return const { pageSettings, page } = data const body = page.getMainComponent() if (pageSettings.eleventySeoTitle) { const expression = toExpression(pageSettings.eleventySeoTitle) if (expression && expression.length) pageSettings.title = echoBlock(body, expression) } if (pageSettings.eleventySeoDescription) { const expression = toExpression(pageSettings.eleventySeoDescription) if (expression && expression.length) pageSettings.description = echoBlock(body, expression) } if (pageSettings.eleventyFavicon) { const expression = toExpression(pageSettings.eleventyFavicon) if (expression && expression.length) pageSettings.favicon = echoBlock(body, expression) } if (pageSettings.eleventyOGImage) { const expression = toExpression(pageSettings.eleventyOGImage) if (expression && expression.length) pageSettings['og:image'] = echoBlock(body, expression) } if (pageSettings.eleventyOGTitle) { const expression = toExpression(pageSettings.eleventyOGTitle) if (expression && expression.length) pageSettings['og:title'] = echoBlock(body, expression) } if (pageSettings.eleventyOGDescription) { const expression = toExpression(pageSettings.eleventyOGDescription) if (expression && expression.length) pageSettings['og:description'] = echoBlock(body, expression) } } /** * Transform the files to be published * This hook is called just before the files are written to the file system * Exported for unit tests */ export function transformFiles(editor: Editor, options: EleventyPluginOptions, data: PublicationData, config: ClientConfig): void { // Do nothing if there is no data source, just a static site if(!enable11ty()) return editor.Pages.getAll().forEach(page => { // Get the page properties const slug = slugify(page.getName() || 'index') const settings = (page.get('settings') ?? {}) as Silex11tyPluginWebsiteSettings const languages = settings.silexLanguagesList?.split(',').map(lang => lang.trim()).filter(lang => !!lang) // Create the data file for this page const query = getPageQuery(page, editor) // Remove empty data source queries Object.entries(query).forEach(([key, value]) => { if (value.length === 0) { delete query[key] } }) // Find the page in the published data if (!data.files) throw new Error('No files in publication data') const path = transformPath(editor, `/${slug}.html`, ClientSideFileType.HTML, config.cmsConfig as EleventyPluginOptions) const pageData = data.files.find(file => file.path === path) as ClientSideFileWithContent | undefined if (!pageData) throw new Error(`No file for path ${path}`) if (pageData.type !== ClientSideFileType.HTML) throw new Error(`File for path ${path} is not HTML`) const dataFile = Object.keys(query).length > 0 ? { type: ClientSideFileType.OTHER, path: transformPath(editor, `/${slugify(page.getName() || 'index')}.11tydata.mjs`, ClientSideFileType.HTML, config.cmsConfig as EleventyPluginOptions), //path: `/${page.getName() || 'index'}.11tydata.mjs`, content: getDataFile(editor, page, null, query, options), } : null if (languages && languages.length > 0) { const pages: ClientSideFileWithContent[] = languages.flatMap(lang => { // Change the HTML const frontMatter = getFrontMatter(page, settings, slug, page.getName(), lang) const bodyStates = getBodyStates(page) const pageFile = { type: ClientSideFileType.HTML, path: path.replace(/\.html$/, `-${lang}.html`), content: frontMatter + bodyStates + pageData.content, } // Create the data file for this page if (dataFile) { return [pageFile, { ...dataFile, path: dataFile.path.replace(/\.11tydata\.mjs$/, `-${lang}.11tydata.mjs`), content: getDataFile(editor, page, lang, query, options), }] // It is important to keep pageFile first, see bellow } return pageFile }) // Update the existing page const [existingPage, ...newPages] = pages pageData.content = existingPage.content pageData.path = existingPage.path // Add the other pages data.files.push(...newPages) } else { // Change the HTML const frontMatter = getFrontMatter(page, settings, slug, page.getName()) const bodyStates = getBodyStates(page) // Update the page before it is published const content = frontMatter + bodyStates + pageData.content pageData.content = content // Add the data file if (dataFile) { // There is at least 1 query in this page data.files.push(dataFile) } } }) } /** * Generate the data file for a given silex page * This file will be used by 11ty to generate the final website's page * 11ty will use this file to get the data from the data sources * - Language * - Native fetch or 11ty-fetch plugin * - esModule or commonjs * - Cache buster * */ function getDataFile(editor: Editor, page: Page, lang: string | null, query: Record<string, string>, options: EleventyPluginOptions): string { const esModule = options.esModule === true || typeof options.esModule === 'undefined' const fetchPlugin = getFetchPluginOptions(options, editor.getModel().get('settings') || {}) const fetchImportStatement = fetchPlugin ? (esModule ? 'import EleventyFetch from \'@11ty/eleventy-fetch\'' : 'const EleventyFetch = require(\'@11ty/eleventy-fetch\')') : '' const exportStatement = esModule ? 'export default' : 'module.exports =' const content = Object.entries(query).map(([dataSourceId, queryStr]) => { const dataSource = getDataSource(dataSourceId) if (dataSource) { return queryToDataFile(dataSource, queryStr, options, page, lang, fetchPlugin) } else { console.error('No data source for id', dataSourceId) throw new Error(`No data source for id ${dataSourceId}`) } }).join('\n') return ` ${fetchImportStatement} ${exportStatement} async function (configData) { const data = { ...configData, lang: '${lang || ''}', } const result = {} ${content} return result } ` } /** * Exported for unit tests */ export function queryToDataFile(dataSource: IDataSource, queryStr: string, options: EleventyPluginOptions, page: Page, lang: string | null, fetchPlugin: object | false): string { if (dataSource.type !== 'graphql') { console.info('not graphql', dataSource) return '' } const s2s = (dataSource as GraphQLOptions).serverToServer const url = s2s ? s2s.url : dataSource.url const urlWithCacheBuster = options.cacheBuster ? `${url}${url.includes('?') ? '&' : '?'}page_id_for_cache=${page.getId()}${lang ? `-${lang}` : ''}` : url const method = s2s ? s2s.method : dataSource.method const headers = s2s ? s2s.headers : dataSource.headers if (headers && !Object.keys(headers).find(key => key.toLowerCase() === 'content-type')) { console.warn('11ty plugin for Silex: no content-type in headers of the graphql query. I will set it to application/json for you. To avoid this warning, add a header with key "content-type" and value "application/json" in silex config.') headers['content-type'] = 'application/json' } const headersStr = headers ? Object.entries(headers).map(([key, value]) => `'${key}': \`${value}\`,`).join('\n') : '' const fetchOptions = { key: dataSource.id as string, method: method || 'POST', url: urlWithCacheBuster, headers: headersStr, query: `JSON.stringify({ query: \`${queryStr}\`, })`, // Let 11ty interpolate the query wich let us add variables in the plugin config } return fetchPlugin ? makeFetchCallEleventy(fetchOptions, fetchPlugin) : makeFetchCall(fetchOptions) } export function makeFetchCall(options: {key: string, url: string, method: string, headers: string, query: string}): string { return dedent` try { const response = await fetch(\`${options.url}\`, { headers: { ${options.headers} }, method: '${options.method}', body: ${options.query} }) if (!response.ok) { throw new Error(\`Error fetching graphql data: HTTP status code \${response.status}, HTTP status text: \${response.statusText}\`) } const json = await response.json() if (json.errors) { throw new Error(\`GraphQL error: \\n> \${json.errors.map(e => e.message).join('\\n> ')}\`) } result['${options.key}'] = json.data } catch (e) { console.error('11ty plugin for Silex: error fetching graphql data', e, '${options.key}', '${options.url}') throw e } ` } export function makeFetchCallEleventy(options: {key: string, url: string, method: string, headers: string, query: string}, fetchPlugin: object): string { return dedent` try { const json = await EleventyFetch(\`${options.url}\`, { ...${JSON.stringify(fetchPlugin)}, type: 'json', fetchOptions: { headers: { ${options.headers} }, method: '${options.method}', body: ${options.query}, } }) if (json.errors) { throw new Error(\`GraphQL error: \\n> \${json.errors.map(e => e.message).join('\\n> ')}\`) } result['${options.key}'] = json.data } catch (e) { console.error('11ty plugin for Silex: error fetching graphql data', e, '${options.key}', '${options.url}') throw e } ` } /** * Make stored states into real states * Filter out hidden states and empty expressions */ function getRealStates(states: { stateId: StateId, state: StoredState }[]): { stateId: StateId, label: string, tokens: State[] }[] { return states .filter(({ state }) => !state.hidden) .filter(({ state }) => state.expression.length > 0) // From expression of stored tokens to tokens (with methods not only data) .map(({ stateId, state }) => ({ stateId, label: state.label || stateId, tokens: state.expression.map(token => { const componentId = state.expression[0].type === 'state' ? state.expression[0].componentId : null return fromStored(token, componentId) }), })) } /** * Check if a state is an attribute * Exported for unit tests */ export function isAttribute(label: string): boolean { if (!label) return false return !Object.values(Properties).includes(label as Properties) } /** * Build the attributes string for a given component * Handle attributes which appear multiple times (class, style) * Append to the original attributes * Exported for unit tests */ export function buildAttributes(originalAttributes: Record<string, string>, attributeStates: { stateId: StateId, label: string, value: string }[]): string { const attributesArr = Object.entries(originalAttributes) // Start with the original attributes .map(([label, value]) => ({ stateId: label, label, value, })) // Override or add state attributes .concat(attributeStates) // Handle attributes which appear multiple times .reduce((final, { stateId, label, value }) => { const existing = final.find(({ label: existingLabel }) => existingLabel === label) if (existing) { if (ATTRIBUTE_MULTIPLE_VALUES.includes(label)) { // Add to the original value existing.value += ' ' + value } else { // Override the original value existing.value = value } } else { // First time we see this attribute final.push({ stateId, label, value, }) } // Return the original array return final }, [] as ({ stateId: StateId, value: string | boolean, label: string })[]) // Build final result return attributesArr // Convert to key="value" string .map(({ label, value }) => makeAttribute(label, value)) // Back to string .join(' ') } function withNotification<T>(cbk: () => T, editor: Editor, componentId: string | null): T { try { return cbk() } catch (e) { editor.runCommand('notifications:add', { type: 'error', message: `Error rendering component: ${e.message}`, group: NOTIFICATION_GROUP, componentId, }) throw e } } /** * Render the components when they are published */ function renderComponent(editor: Editor, component: Component, toHtml: () => string): string | undefined { if(cache.has(component.getId())) { return cache.get(component.getId()) } const statesPrivate = withNotification(() => getRealStates(getStateIds(component, false) .map(stateId => ({ stateId, state: getState(component, stateId, false)!, }))), editor, component.getId()) const statesPublic = withNotification(() => getRealStates(getStateIds(component, true) .map(stateId => ({ stateId, state: getState(component, stateId, true)!, }))), editor, component.getId()) const unwrap = component.get(UNWRAP_ID) if (statesPrivate.length > 0 || statesPublic.length > 0 || unwrap) { const tagName = component.get('tagName')?.toLowerCase() if (tagName) { // Convenience key value object const statesObj = statesPrivate // Filter out attributes, keep only properties .filter(({ label }) => !isAttribute(label)) // Add states .concat(statesPublic) .reduce((final, { stateId, label, tokens }) => ({ ...final, [stateId]: { stateId, label, tokens, }, }), {} as Record<Properties, RealState>) const hasInnerHtml = !!statesObj.innerHTML?.tokens.length const hasCondition = !!statesObj.condition?.tokens.length const hasData = !!statesObj.__data?.tokens.length // Style attribute const innerHtml = hasInnerHtml ? echoBlock(component, statesObj.innerHTML.tokens) : component.getInnerHTML() const operator = component.get('conditionOperator') ?? UnariOperator.TRUTHY const binary = operator && Object.values(BinaryOperator).includes(operator) const [ifStart, ifEnd] = hasCondition ? ifBlock(component, binary ? { expression: statesObj.condition.tokens, expression2: statesObj.condition2?.tokens ?? [], operator, } : { expression: statesObj.condition.tokens, operator, }) : [] const [forStart, forEnd] = hasData ? loopBlock(component, statesObj.__data.tokens) : [] const states = statesPublic .map(({ stateId, tokens }) => assignBlock(stateId, component, tokens)) .join('\n') const before = (states ?? '') + (forStart ?? '') + (ifStart ?? '') const after = (ifEnd ?? '') + (forEnd ?? '') // Attributes const originalAttributes = component.get('attributes') as Record<string, string> // Add css classes originalAttributes.class = component.getClasses().join(' ') // Make the list of attributes const attributes = buildAttributes(originalAttributes, statesPrivate // Filter out properties, keep only attributes .filter(({ label }) => isAttribute(label)) // Make tokens a string .map(({ stateId, tokens, label }) => ({ stateId, label, value: echoBlock(component, tokens), })) ) if (unwrap) { const html = `${before}${innerHtml}${after}` cache.set(component.getId(), html) return html } else { const html = `${before}<${tagName}${attributes ? ` ${attributes}` : ''}>${innerHtml}</${tagName}>${after}` cache.set(component.getId(), html) return html } } else { // Not a real component // FIXME: understand why throw new Error('Why no tagName?') } } else { const html = toHtml() cache.set(component.getId(), html) return html } } function toPath(path: (string | undefined)[]) { return '/' + path .filter(p => !!p) .map(p => p?.replace(/(^\/|\/$)/g, '')) .join('/') } function transformPermalink(editor: Editor, path: string, type: string, options: EleventyPluginOptions): string { // Do nothing if there is no data source, just a static site if(!enable11ty()) return path switch (type) { case 'html': return toPath([ path ]) case 'asset': return toPath([ options.urls?.assets, path.replace(/^\/?assets\//, ''), ]) case 'css': { return toPath([ options.urls?.css, path.replace(/^\.?\/?css\//, ''), ]) } default: console.warn('Unknown file type in transform permalink:', type) return path } } function transformPath(editor: Editor, path: string, type: string, options: EleventyPluginOptions): string { // Do nothing if there is no data source, just a static site if(!enable11ty()) return path switch (type) { case 'html': return toPath([ options.dir?.input, options.dir?.html, path, ]) case 'css': return toPath([ options.dir?.input, options.dir?.css, path.replace(/^\/?css\//, ''), ]) case 'asset': return toPath([ options.dir?.input, options.dir?.assets, path.replace(/^\/?assets\//, ''), ]) default: console.warn('Unknown file type in transform path:', type) return path } } //function transformFile(file: ClientSideFile/*, options: EleventyPluginOptions*/): ClientSideFile { // //const fileWithContent = file as ClientSideFileWithContent // switch (file.type) { // case 'html': // case 'css': // case 'asset': // return file // default: // console.warn('Unknown file type in transform file:', file.type) // return file // } //}