UNPKG

@silexlabs/silex

Version:

Free and easy website builder for everyone.

377 lines (351 loc) 15.3 kB
import { Expression, Filter, Property, State, StateId, Token, getPersistantId, getStateVariableName, toExpression, FIXED_TOKEN_ID, UnariOperator, getExpressionResultType, BinaryOperator, } from '@silexlabs/grapesjs-data-source' import { Component } from 'grapesjs' import { EleventyDataSourceId } from './DataSource' export interface BinaryCondition { operator: BinaryOperator, expression: Expression, expression2: Expression, } export interface UnaryCondition { operator: UnariOperator, expression: Expression, } export type Condition = BinaryCondition | UnaryCondition /** * Generate liquid instructions which echo the value of an expression */ export function echoBlock(component: Component, expression: Expression): string { if (expression.length === 0) throw new Error('Expression is empty') if (expression.length === 1 && expression[0].type === 'property' && expression[0].fieldId === FIXED_TOKEN_ID) { return expression[0].options?.value as string ?? '' } const statements = getLiquidBlock(component, expression) return `{% liquid ${statements .map(({ liquid }) => liquid) .join('\n\t') } echo ${statements[statements.length - 1].variableName} %}` } /** * Generate liquid instructions which echo the value of an expression, on 1 line */ export function echoBlock1line(component: Component, expression: Expression): string { if (expression.length === 0) throw new Error('Expression is empty') if (expression.length === 1 && expression[0].type === 'property' && expression[0].fieldId === FIXED_TOKEN_ID) { return expression[0].options?.value as string ?? '' } const statements = getLiquidBlock(component, expression) return `{% ${statements .map(({ liquid }) => liquid) .join(' %}{% ') } %}{{ ${statements[statements.length - 1].variableName} }}` } /** * Generate liquid instructions which define a variable for later use * This is used for components states */ export function assignBlock(stateId: StateId, component: Component, expression: Expression): string { if (expression.length === 0) throw new Error('Expression is empty') const statements = getLiquidBlock(component, expression) const persistantId = getPersistantId(component) if (!persistantId) throw new Error('This component has no persistant ID') return `{% liquid ${statements .map(({ liquid }) => liquid) .join('\n\t') } assign ${getStateVariableName(persistantId, stateId)} = ${statements[statements.length - 1].variableName} %}` } /** * Generate liquid instructions which start and end a loop over the provided expression * This is used for components states */ export function loopBlock(component: Component, expression: Expression): [start: string, end: string] { if (expression.length === 0) throw new Error('Expression is empty') // Check data to loop over const field = getExpressionResultType(expression, component) if (!field) throw new Error(`Expression ${expression.map(token => token.label).join(' -> ')} is invalid`) if (field.kind !== 'list') throw new Error(`Provided property needs to be a list in order to loop, not a ${field.kind}`) const statements = getLiquidBlock(component, expression) const loopDataVariableName = statements[statements.length - 1].variableName const persistantId = getPersistantId(component) if (!persistantId) { console.error('Component', component, 'has no persistant ID. Persistant ID is required to get component states.') throw new Error('This component has no persistant ID') } return [`{% liquid ${statements .map(({ liquid }) => liquid) .join('\n\t') } %} {% for ${getStateVariableName(persistantId, '__data')} in ${loopDataVariableName} %} `, '{% endfor %}'] } /** * Generate liquid instructions which define a variable for later use * This is used for components states */ export function ifBlock(component: Component, condition: Condition): [start: string, end: string] { // Check the first expression if (condition.expression.length === 0) throw new Error('If block expression is empty') // Check the operator const unary = Object.values(UnariOperator).includes(condition.operator as UnariOperator) ? condition as UnaryCondition : null const binary = Object.values(BinaryOperator).includes(condition.operator as BinaryOperator) ? condition as BinaryCondition : null if (!unary && !binary) throw new Error(`If block operator is invalid: ${condition.operator}`) // Check the second expression if (binary && binary.expression2.length === 0) return ['', ''] // Get liquid for the first expression const statements = getLiquidBlock(component, condition.expression) const lastVariableName = statements[statements.length - 1].variableName // Get liquid for the second let lastVariableName2 = '' if (binary) { statements.push(...getLiquidBlock(component, binary.expression2)) lastVariableName2 = statements[statements.length - 1].variableName } // Get liquid for the whole if block return [`{% liquid ${statements .map(({ liquid }) => liquid) .join('\n\t') } %} {% if ${unary ? getUnaryOp(lastVariableName, unary.operator) : getBinaryOp(lastVariableName, lastVariableName2, binary!.operator)} %} `, '{% endif %}'] } function getUnaryOp(variableName: string, operator: UnariOperator): string { switch (operator) { case UnariOperator.TRUTHY: return `${variableName} and ${variableName} != blank and ${variableName} != empty` case UnariOperator.FALSY: return `not ${variableName}` case UnariOperator.EMPTY_ARR: return `${variableName}.size == 0` case UnariOperator.NOT_EMPTY_ARR: return `${variableName}.size > 0` } } function getBinaryOp(variableName: string, variableName2: string, operator: BinaryOperator): string { switch (operator) { case BinaryOperator.EQUAL: return `${variableName} == ${variableName2}` case BinaryOperator.NOT_EQUAL: return `${variableName} != ${variableName2}` case BinaryOperator.GREATER_THAN: return `${variableName} > ${variableName2}` case BinaryOperator.LESS_THAN: return `${variableName} < ${variableName2}` case BinaryOperator.GREATER_THAN_OR_EQUAL: return `${variableName} >= ${variableName2}` case BinaryOperator.LESS_THAN_OR_EQUAL: return `${variableName} <= ${variableName2}` } } let numNextVar = 0 /** * Pagination data has no filter and no states in it */ export function getPaginationData(expression: Property[]): string { const statement = getLiquidStatementProperties(expression) const firstToken = expression[0] if (firstToken) { if (!firstToken.dataSourceId || firstToken.dataSourceId === EleventyDataSourceId) { return statement } return `${firstToken.dataSourceId}.${statement}` } else { return '' } } /** * Convert an expression to liquid code */ export function getLiquidBlock(component: Component, expression: Expression): { variableName: string, liquid: string }[] { if (expression.length === 0) return [] const result = [] as { variableName: string, liquid: string }[] const firstToken = expression[0] let lastVariableName = '' if (firstToken.type === 'filter') throw new Error('Expression cannot start with a filter') if (firstToken.type === 'property' && firstToken.dataSourceId && firstToken.dataSourceId !== 'eleventy') { lastVariableName = firstToken.dataSourceId as string } const rest = [...expression] while (rest.length) { // Move all tokens until the first filter const firstFilterIndex = rest.findIndex(token => token.type === 'filter') const variableExpression = firstFilterIndex === -1 ? rest.splice(0) : rest.splice(0, firstFilterIndex) // Add all the filters until a property again const firstNonFilterIndex = rest.findIndex(token => token.type !== 'filter') const filterExpression = firstNonFilterIndex === -1 ? rest.splice(0) : rest.splice(0, firstNonFilterIndex) const variableName = getNextVariableName(component, numNextVar++) const { statement, prefixStatements } = getLiquidStatement(variableExpression.concat(filterExpression), variableName, lastVariableName, component) lastVariableName = variableName // Add any prefix statements first (for nested filter option expressions) result.push(...prefixStatements) // Then add the main statement result.push({ variableName, liquid: statement, }) } return result } export function getNextVariableName(component: Component, numNextVar: number): string { return `var_${component.ccid}_${numNextVar}` } /** * Get the liquid assign statement for the expression * The expression must * - start with a property or state * - once it has a filter it canot have a property again * - state can only be the first token * * Example of return value: `countries.continent.countries | first.name` * * Returns both the main statement and any prefix statements needed for nested filter options */ export function getLiquidStatement(expression: Expression, variableName: string, lastVariableName: string = '', component?: Component): { statement: string, prefixStatements: { variableName: string, liquid: string }[] } { if (expression.length === 0) throw new Error('Expression cannot be empty') // Split expression in 2: properties and filters const firstFilterIndex = expression.findIndex(token => token.type === 'filter') if (firstFilterIndex === 0) throw new Error('Expression cannot start with a filter') const properties = (firstFilterIndex < 0 ? expression : expression.slice(0, firstFilterIndex)) as (Property | State)[] const filters = firstFilterIndex > 0 ? expression.slice(firstFilterIndex) as Filter[] : [] // Check that no properties or state come after filter if (filters.find(token => token.type !== 'filter')) { throw new Error('A filter cannot be followed by a property or state') } // Get filters with any prefix statements for nested expressions const { filterStr, prefixStatements } = getLiquidStatementFilters(filters, component) // Start with the assign statement const statement = `assign ${variableName} = ${lastVariableName ? `${lastVariableName}.` : '' }${ // Add all the properties getLiquidStatementProperties(properties) }${ // Add all the filters filterStr }` return { statement, prefixStatements } } export function getLiquidStatementProperties(properties: (Property | State)[]): string { return properties.map((token, index) => { switch (token.type) { case 'state': { if (index !== 0) throw new Error('State can only be the first token in an expression') // 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) { return fieldId } return getStateVariableName(token.componentId, token.storedStateId) } case 'property': { if (token.fieldId === FIXED_TOKEN_ID) { return `"${token.options?.value ?? ''}"` } return token.fieldId } default: { throw new Error(`Only state or property can be used in an expression, got ${(token as Token).type}`) } } }) .join('.') } export function getLiquidStatementFilters(filters: Filter[], component?: Component): { filterStr: string, prefixStatements: { variableName: string, liquid: string }[] } { if (!filters.length) return { filterStr: '', prefixStatements: [] } const allPrefixStatements: { variableName: string, liquid: string }[] = [] const filterStr = ' | ' + filters.map(token => { const options = token.options ? Object.entries(token.options) // Order the filter's options by the order they appear in the filter's optionsKeys .map(([key, value]) => ({ key, value: value, order: token.optionsKeys?.indexOf(key), })) .sort((a, b) => { if (a.order === undefined && b.order === undefined) return 0 if (a.order === undefined) return 1 if (b.order === undefined) return -1 return a.order - b.order }) // Convert the options to liquid .map(({ key, value }) => { const result = handleFilterOption(token, key, value as string, component) allPrefixStatements.push(...result.prefixStatements) return result.optionStr }) : [] return `${token.filterName ?? token.id}${options.length ? `: ${options.join(', ')}` : ''}` }) .join(' | ') return { filterStr, prefixStatements: allPrefixStatements } } /** * Quote a string for liquid * Check that the string is not already quoted * Escape existing quotes */ function quote(value: string): string { if (value.startsWith('"') && value.endsWith('"')) return value return `"${value.replace(/"/g, '\\"')}"` } function handleFilterOption(filter: Filter, key: string, value: string, component?: Component): { optionStr: string, prefixStatements: { variableName: string, liquid: string }[] } { try { const expression = toExpression(value) if (expression) { // Check if expression contains filters - if so, we need to generate liquid statements for it const hasFilters = expression.some(token => token.type === 'filter') if (hasFilters && component) { // Expression contains filters - we need to use getLiquidBlock to process it // and use the resulting variable name as the option value const statements = getLiquidBlock(component, expression) const variableName = statements[statements.length - 1].variableName return { optionStr: filter.quotedOptions?.includes(key) ? quote(variableName) : variableName, prefixStatements: statements, } } // No filters - simple expression with just properties/states const result = expression.map(token => { switch (token.type) { case 'property': { if (token.fieldId === FIXED_TOKEN_ID) { return `"${token.options?.value ?? ''}"` } return token.fieldId } case '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) { return fieldId } // For other states, use the state variable name return getStateVariableName(token.componentId, token.storedStateId) } case 'filter': { // This shouldn't happen since we check hasFilters above, but handle it anyway throw new Error('Filter cannot be used in a filter option without component context') } } }) .join('.') return { optionStr: filter.quotedOptions?.includes(key) ? quote(result) : result, prefixStatements: [] } } } catch { // Ignore parsing errors and fall through to raw value } return { optionStr: filter.quotedOptions?.includes(key) ? quote(value) : value, prefixStatements: [] } }