You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

213 lines
7.4 KiB

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<string>
*/
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<string>
*/
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)
}
}
}
}