import LifecycleUnit from '../lifecycle/Unit.ts' import Config from './Config.ts' import {isViewEngine, ViewEngine as Types, ViewFileExtension} from '../const/view_engines.ts' import {views, handlebars} from '../external/http.ts' import {Unit} from '../lifecycle/decorators.ts' import {Logging} from '../service/logging/Logging.ts' import {ConfigError} from '../error/ConfigError.ts' import {path, fs} from '../external/std.ts' /** * Error thrown when an action requiring a view engine is attempted, but no view * engine has been configured properly. */ export class MissingViewEngineError extends Error { constructor(message = 'Missing or invalid view engine type config.') { super(message) } } /** * Error thrown when an action requiring a template base directory is attempted, * but no such directory has been configured. */ export class MissingTemplateDirectoryError extends Error { constructor(message = 'Missing or invalid path to template base directory.') { super(message) } } /** * Lifecycle unit which manages the initialization of the configured rendering * engine, as well as utilities for rendering template files and strings. * @extends LifecycleUnit */ @Unit() export default class ViewEngine extends LifecycleUnit { protected engine?: Types protected template_dir?: string constructor( protected readonly config: Config, protected readonly logger: Logging, ) { super() } public async up() { const config: unknown = this.config.get('app.views.engine') const base_dir: unknown = this.config.get('app.views.base_dir') if ( !isViewEngine(config) ) { throw new ConfigError('app.views.engine', config) } if ( !String(base_dir) ) { throw new ConfigError('app.views.base_dir', base_dir) } this.template_dir = this.app.app_path(base_dir) this.engine = config this.logger.info(`Determined view engine from config: ${config}`) this.logger.info(`Determined base directory for templates: ${this.template_dir}`) await this.init_engine() } /** * Render a template file using the configured engine. * @param {string} template_path - the relative path of the template w/in the template dir. * @param {object} [data = {}] * @return Promise */ public async template(template_path: string, data: { [key: string]: any } = {}) { const file_path = `${this.template_path(template_path.replace(/:/g, '/'))}${this.get_file_extension()}` this.logger.debug(`Rendering template "${template_path}" from file: ${file_path}`) // TODO cache this // TODO replace with fs.readFileStr const content = await Deno.readTextFile(file_path) const engine: views.Engine = this.get_engine() return engine( content, data, this.get_render_context(), `${template_path}${this.get_file_extension()}` ) } /** * Resolve a path within the template base directory. * @param {...string} parts * @return string */ public template_path(...parts: string[]): string { console.log('template path parts', parts) if ( !this.template_dir ) throw new MissingTemplateDirectoryError() let template_dir = this.template_dir if ( template_dir.startsWith('file://') ) template_dir = template_dir.slice(7) console.log('template path dir', template_dir) const resolved_path = path.resolve(template_dir, ...parts) console.log('template path resolved 1', resolved_path) if ( resolved_path.startsWith('/') ) { return `file://${resolved_path}` } else { return resolved_path } } /** * Render a template string using the configured engine. * @param {string} template * @param {object} [data = {}] * @return Promise */ public async render(template: string, data: { [key: string]: any } = {}) { const engine: views.Engine = this.get_engine() return engine(template, data) } /** * Get the rendering engine for the configured backend. */ public get_engine(): views.Engine { if ( this.engine === Types.Denjuck ) { return views.engineFactory.getDenjuckEngine() } else if ( this.engine === Types.Ejs ) { return views.engineFactory.getEjsEngine() } else if ( this.engine === Types.Handlebars ) { return views.engineFactory.getHandlebarsEngine() } throw new MissingViewEngineError() } /** * Get the file extension expected by the currently configured render engine. */ public get_file_extension(): ViewFileExtension { if ( this.engine === Types.Denjuck ) { return ViewFileExtension.Denjuck } else if ( this.engine === Types.Ejs ) { return ViewFileExtension.Ejs } else if ( this.engine === Types.Handlebars ) { return ViewFileExtension.Handlebars } throw new MissingViewEngineError() } /** * Get the rendering context to pass to the render engine. This is here * for compatibility with the view_engine library. */ public get_render_context() { if ( !this.template_dir ) throw new MissingTemplateDirectoryError() return { viewExt: this.get_file_extension(), viewEngine: this.get_engine(), viewRoot: this.template_dir, useCache: false, // TODO better value here cache: undefined, } } /** * Initialize the appropriate rendering engine. */ protected async init_engine() { if ( this.engine === Types.Handlebars ) { await this.init_engine_handlebars() } } /** * Initialize the Handlebars rendering engine by loading and registering partial views * from the configured partials directory, if one exists. */ protected async init_engine_handlebars() { const partials_dir: unknown = this.config.get('app.views.partials_dir') if ( String(partials_dir) ) { const partials_path = this.template_path(String(partials_dir)) console.log({ partials_path, partials_dir }) this.logger.info(`Registering Handlebars partials from: ${partials_path}`) for await ( const entry of fs.walk(partials_path) ) { if ( !entry.isFile || !entry.path.endsWith(this.get_file_extension()) ) { if ( entry.isFile ) this.logger.debug(`Skipping file in Handlebars partials path with invalid suffix: ${entry.path}`) continue } const content = await Deno.readTextFile(entry.path) // Generate the partial name. // This is done by taking the relative path from the partials dir, removing the suffix, // and replacing directory separators with the standard : identifier. let partial_name = entry.path.replace(partials_path, '').slice(0, -1 * (this.get_file_extension()).length) if ( partial_name.startsWith('/') ) partial_name = partial_name.slice(1) partial_name = partial_name.replace(/\//g, ':') this.logger.debug(`Registering handlebars partial: ${partial_name}`) handlebars.registerPartial(partial_name, content) } } } }