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.
215 lines
7.5 KiB
215 lines
7.5 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 } = {}) {
|
|
let file_path = `${this.template_path(template_path.replace(/:/g, '/'))}${this.get_file_extension()}`
|
|
if ( file_path.startsWith('file://') ) {
|
|
file_path = file_path.slice(7)
|
|
}
|
|
|
|
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 {
|
|
if ( !this.template_dir ) throw new MissingTemplateDirectoryError()
|
|
let template_dir = this.template_dir
|
|
if ( template_dir.startsWith('file://') ) template_dir = template_dir.slice(7)
|
|
|
|
const resolved_path = path.resolve(template_dir, ...parts)
|
|
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()
|
|
let template_dir = this.template_dir
|
|
if ( template_dir.startsWith('file://') ) template_dir = template_dir.slice(7)
|
|
|
|
return {
|
|
viewExt: this.get_file_extension(),
|
|
viewEngine: this.get_engine(),
|
|
viewRoot: 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) ) {
|
|
let partials_path = this.template_path(String(partials_dir))
|
|
this.logger.info(`Registering Handlebars partials from: ${partials_path}`)
|
|
|
|
if ( partials_path.startsWith('file://') ) {
|
|
partials_path = partials_path.slice(7)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|