UNPKG

vite-plugin-twig-drupal

Version:

Provides a ⚡️ Vite plugin to transform 🌱 Twig into HTML with a 💧 Drupal flavour

291 lines (258 loc) 8.18 kB
import Twig from "twig" import { join, resolve, dirname } from "node:path" import { existsSync, readdirSync } from "node:fs" import { normalizePath } from "vite" const { twig } = Twig const FRAMEWORK_REACT = "react" const FRAMEWORK_HTML = "html" const defaultOptions = { namespaces: {}, filters: {}, functions: {}, globalContext: {}, framework: FRAMEWORK_HTML, pattern: /\.(twig)(\?.*)?$/, } Twig.cache(false) const includeTokenTypes = [ "Twig.logic.type.embed", "Twig.logic.type.include", "Twig.logic.type.extends", "Twig.logic.type.import", ] const findInChildDirectories = (directory, component) => { const files = readdirSync(directory, { recursive: true }) for (const file of files) { const filePath = join(directory, file) if (file.endsWith(`/${component}.twig`)) { return filePath } } return null } const resolveFile = (directory, file) => { const filesToTry = [file, `${file}.twig`, `${file}.html.twig`] for (const ix in filesToTry) { const path = resolve(filesToTry[ix]) if (existsSync(path)) { return normalizePath(path) } const withDir = resolve(directory, filesToTry[ix]) if (existsSync(withDir)) { return normalizePath(withDir) } } return normalizePath(resolve(directory, file)) } const pluckIncludes = (tokens) => { return [ ...tokens .filter((token) => includeTokenTypes.includes(token.token?.type)) .reduce( (carry, token) => [ ...carry, ...token.token.stack.map((stack) => stack.value), ], [] ), ...tokens.reduce( (carry, token) => [...carry, ...pluckIncludes(token.token?.output || [])], [] ), ].filter((value, index, array) => { return array.indexOf(value) === index }) } const resolveNamespaceOrComponent = (namespaces, template) => { let resolveTemplate = template const isNamespace = template.includes(":") // Support for SDC. if (isNamespace) { const [namespace, component] = template.split(":") resolveTemplate = `@${namespace}/${component}/${component}` } let expandedPath = Twig.path.expandNamespace(namespaces, resolveTemplate) // If file not found and we are in namespace -> search deeper. if (!existsSync(expandedPath) && isNamespace) { const [namespace, component] = template.split(":") let foundFile = findInChildDirectories(namespaces[namespace], component) if (existsSync(foundFile)) { expandedPath = foundFile } } return expandedPath } const compileTemplate = (id, file, { namespaces }) => { return new Promise((resolve, reject) => { const options = { namespaces, rethrow: true, allowInlineIncludes: true } twig({ id, path: file, error: reject, allowInlineIncludes: true, load(template) { if (typeof template.tokens === "undefined") { reject("Error compiling twig file") return } resolve({ includes: pluckIncludes(template.tokens), code: template.compile(options), }) }, }) }) } Twig.cache(false) const errorHandler = (id, isDefault = true) => (e) => { if (isDefault) { return { code: `export default () => 'An error occurred whilst rendering ${id}: ${e.toString()} ${ e.stack }';`, map: null, } } return { code: null, map: null, } } const plugin = (options = {}) => { options = { ...defaultOptions, ...options } return { name: "vite-plugin-twig-drupal", config: ({ root }) => { if (!options.root) { options.root = root } }, async shouldTransformCachedModule(src, id) { return options.pattern.test(id) }, async transform(src, id) { if (options.pattern.test(id)) { let frameworkInclude = "" let frameworkTransform = "const frameworkTransform = (html) => html;" let asTwigJs = id.match(/\?twig$/) if (options.framework === FRAMEWORK_REACT && !asTwigJs) { frameworkInclude = `import React from 'react'` frameworkTransform = `const frameworkTransform = (html) => React.createElement('div', {dangerouslySetInnerHTML: {'__html': html}});;` } if (asTwigJs) { // Tidy up file path by remove ?twig id = id.slice(0, -5) } let embed, embeddedIncludes, functions, code, includes, seen = {} try { const result = await compileTemplate(id, id, options).catch( errorHandler(id) ) if ("map" in result) { // An error occurred. return result } code = result.code includes = result.includes // Process includes in a queue. const promisifyIncludes = (includes) => { return includes.reduce( (queue, template) => queue.then(() => { const file = resolveFile( dirname(id), resolveNamespaceOrComponent(options.namespaces, template) ) if (!(template in seen)) { return compileTemplate(template, file, options) .catch(errorHandler(template, false)) .then(({ code, includes }) => { seen[template] = code if (!includes) { return Promise.resolve() } return promisifyIncludes(includes) }) } return Promise.resolve() }), Promise.resolve() ) } const includeResult = await promisifyIncludes(includes).catch( errorHandler(id) ) embed = Object.keys(seen) .filter((template) => template !== "_self") .map( (template) => `import '${resolveFile( dirname(id), resolveNamespaceOrComponent(options.namespaces, template) )}';` ) .join("\n") functions = Object.entries(options.functions) .map(([name, value]) => { return ` const ${name} = ${value}; ${name}(Twig); ` }) .join("\n") if (includeResult !== undefined && "map" in includeResult) { // An error occurred. return includeResult } embeddedIncludes = Object.values(seen).reverse().join("\n") } catch (e) { return errorHandler(id)(e) } const output = ` import Twig, { twig } from 'twig'; import DrupalAttribute from 'drupal-attribute'; import { addDrupalExtensions } from 'drupal-twig-extensions/twig'; ${frameworkInclude} ${embed} ${functions} addDrupalExtensions(Twig); // Disable caching. Twig.cache(false); ${embeddedIncludes}; ${frameworkTransform}; export default (context = {}) => { const component = ${code} ${includes ? `component.options.allowInlineIncludes = true;` : ""} try { let defaultAttributes = context.defaultAttributes ? context.defaultAttributes : []; if (!Array.isArray(defaultAttributes)) { // We were passed a map, turn it into an array. defaultAttributes = Object.entries(defaultAttributes); } return frameworkTransform(component.render({ attributes: new DrupalAttribute(defaultAttributes), ...${JSON.stringify(options.globalContext)}, ...context })); } catch (e) { return frameworkTransform('An error occurred whilst rendering ${id}: ' + e.toString()); } }` return { code: output, map: null, dependencies: seen, } } }, } } export default plugin