Support deno 1.3.x/0.67.0; replace handlebars with view_engine library

This commit is contained in:
Garrett Mills 2020-09-04 09:40:16 -05:00
parent 0f182b592b
commit ff34578d07
Signed by: garrettmills
GPG Key ID: D2BF5FBA8298F246
23 changed files with 287 additions and 141 deletions

View File

@ -1,2 +1,5 @@
export * from '../../lib/src/module.ts' export * from '../../lib/src/module.ts'
export * from '../../di/module.ts' export * from '../../di/module.ts'
import * as std from '../../lib/src/external/std.ts'
export { std }

View File

@ -1,5 +1,27 @@
import { env } from '../../lib/src/unit/Scaffolding.ts'; import { env } from '../../lib/src/unit/Scaffolding.ts'
import {ViewEngine} from '../../lib/src/const/view_engines.ts'
export default { export default {
name: env('APP_NAME', 'Daton'), name: env('APP_NAME', 'Daton'),
views: {
/*
* View engine that should be used to render templates.
* Options are Handlebars, Ejs, or Denjuck.
*/
engine: ViewEngine.Handlebars,
/*
* Relative path from the app directory to the base directory where
* view files should be looked up.
*/
base_dir: 'http/views',
/*
* If using Handlebars, optionally, the path to the directory within the
* base_dir that contains the partials. They will be automatically registered
* with Handlebars.
*/
partials_dir: 'partials',
},
} }

View File

@ -1,11 +1,13 @@
import Controller from '../../../lib/src/http/Controller.ts' import Controller from '../../../lib/src/http/Controller.ts'
import {Request} from '../../../lib/src/http/Request.ts' import {Request} from '../../../lib/src/http/Request.ts'
import {view} from '../../../lib/src/http/response/helpers.ts' import {view} from '../../../lib/src/http/response/helpers.ts'
import {Injectable} from '../../../di/module.ts'
@Injectable()
export default class HomeController extends Controller { export default class HomeController extends Controller {
get_home(request: Request) { async get_home(request: Request) {
return view('home', { request }) return view('home', { greeting: 'Hello' })
} }
} }

View File

@ -1 +1,6 @@
<h1>Welcome to Daton!</h1> <html>
{{> header }}
<body>
<h1>{{ greeting }} from Daton!</h1>
</body>
</html>

View File

@ -1,14 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
{{#if title}}
<title>{{ title }} | Daton</title>
{{else}}
<title>Daton</title>
{{/if}}
</head>
<body>
{{{ body }}}
</body>
</html>

View File

@ -0,0 +1,3 @@
<head>
<title>Daton</title>
</head>

View File

@ -8,8 +8,11 @@ import units from './units.ts'
* Let's get up and running. The scaffolding provides the bare minimum * Let's get up and running. The scaffolding provides the bare minimum
* amount of support required to get Daton up and running. The app handles * amount of support required to get Daton up and running. The app handles
* the rest. * the rest.
*
* Daton will automatically load and process application resources, which is
* why we need to pass in the base path of this script.
*/ */
const scaffolding = make(Scaffolding) const scaffolding = make(Scaffolding, import.meta.url)
await scaffolding.up() await scaffolding.up()
/* /*

View File

@ -17,11 +17,11 @@ export default [
ConfigUnit, ConfigUnit,
DatabaseUnit, DatabaseUnit,
ModelsUnit, ModelsUnit,
ViewEngineUnit,
HttpKernelUnit, HttpKernelUnit,
MiddlewareUnit, MiddlewareUnit,
ControllerUnit, ControllerUnit,
ViewEngineUnit,
RoutesUnit, RoutesUnit,
RoutingUnit, RoutingUnit,
// HttpServerUnit, HttpServerUnit,
] ]

View File

@ -0,0 +1,20 @@
export enum ViewEngine {
Denjuck = 'Denjuck',
Ejs = 'Ejs',
Handlebars = 'Handlebars',
}
export enum ViewFileExtension {
Denjuck = '.html',
Ejs = '.ejs',
Handlebars = '.hbs',
}
export function isViewEngine(what: any): what is ViewEngine {
return (['Denjuck', 'Ejs', 'Handlebars'].includes(String(what)))
}
export function isViewFileExtension(what: any): what is ViewFileExtension {
return (['.html', '.ejs', '.hbs']).includes(String(what))
}

View File

@ -0,0 +1,12 @@
/**
* An error to be thrown when a configuration value is invalid.
*/
export class ConfigError extends Error {
constructor(
public readonly key: string,
public readonly supplied_value: any,
message?: string
) {
super(message || `The value of the config key "${key}" is invalid. (${supplied_value} provided)`)
}
}

View File

@ -1 +1,4 @@
export * from 'https://deno.land/x/postgres/mod.ts' // export * from 'https://deno.land/x/postgres@v0.4.3/mod.ts'
// FIXME: waiting on https://github.com/deno-postgres/deno-postgres/pull/166
export * from 'https://raw.githubusercontent.com/glmdev/deno-postgres/master/mod.ts'

View File

@ -1,3 +1,9 @@
export * from 'https://deno.land/std@0.53.0/http/server.ts' export * from 'https://deno.land/std@0.67.0/http/server.ts'
export * from 'https://deno.land/std@0.53.0/http/cookie.ts' export * from 'https://deno.land/std@0.67.0/http/cookie.ts'
export { Handlebars } from 'https://deno.land/x/handlebars/mod.ts'
import * as views from 'https://deno.land/x/view_engine@v1.3.0/mod.ts'
export { views }
import hb from 'https://dev.jspm.io/handlebars@4.7.6'
const handlebars = <any>hb
export { handlebars }

View File

@ -1,6 +1,6 @@
export * from 'https://deno.land/std@0.53.0/fmt/colors.ts' export * from 'https://deno.land/std@0.67.0/fmt/colors.ts'
export { config as dotenv } from 'https://deno.land/x/dotenv/mod.ts' export { config as dotenv } from 'https://deno.land/x/dotenv/mod.ts'
export * as path from 'https://deno.land/std@0.53.0/path/mod.ts' export * as path from 'https://deno.land/std@0.67.0/path/mod.ts'
export * as fs from 'https://deno.land/std@0.53.0/fs/mod.ts' export * as fs from 'https://deno.land/std@0.67.0/fs/mod.ts'
export { generate as uuid } from 'https://deno.land/std/uuid/v4.ts' export { generate as uuid } from 'https://deno.land/std@0.67.0/uuid/v4.ts'
// export { moment } from 'https://deno.land/x/moment/moment.ts' // export { moment } from 'https://deno.land/x/moment/moment.ts'

View File

@ -1,5 +1,5 @@
import { Injectable } from '../../../di/src/decorator/Injection.ts' import { Injectable } from '../../../di/src/decorator/Injection.ts'
import { getCookies, setCookie, delCookie, ServerRequest } from '../external/http.ts' import { getCookies, setCookie, deleteCookie, ServerRequest } from '../external/http.ts'
import { InMemCache } from '../support/InMemCache.ts' import { InMemCache } from '../support/InMemCache.ts'
import { HTTPRequest } from './type/HTTPRequest.ts' import { HTTPRequest } from './type/HTTPRequest.ts'
@ -117,6 +117,6 @@ export class CookieJar {
*/ */
public async delete(key: string): Promise<void> { public async delete(key: string): Promise<void> {
await this._cache.drop(key) await this._cache.drop(key)
delCookie(this.request.response, key) deleteCookie(this.request.response, key)
} }
} }

View File

@ -14,7 +14,7 @@ export default class HTMLResponseFactory extends ResponseFactory {
public async write(request: Request): Promise<Request> { public async write(request: Request): Promise<Request> {
request = await super.write(request) request = await super.write(request)
request.response.headers.set('Content-Type', 'text/html') request.response.headers.set('Content-Type', 'text/html; charset=utf-8')
request.response.body = this.value request.response.body = this.value
return request return request
} }

View File

@ -1,29 +0,0 @@
import ResponseFactory from './ResponseFactory.ts'
import ViewEngine from '../../unit/ViewEngine.ts'
import {Request} from '../Request.ts'
/**
* Response factory that renders a partial view as HTML.
* @return ResponseFactory
*/
export default class PartialViewResponseFactory extends ResponseFactory {
constructor(
/**
* The view name.
* @type string
*/
public readonly view: string,
/**
* Optionally, the response context.
*/
public readonly context?: any,
) {
super()
}
public async write(request: Request) {
const views: ViewEngine = this.make(ViewEngine)
request.response.body = await views.partial(this.view, this.context)
return request
}
}

View File

@ -13,22 +13,19 @@ export default class ViewResponseFactory extends ResponseFactory {
* @type string * @type string
*/ */
public readonly view: string, public readonly view: string,
/** /**
* Optionally, the view context. * Optionally, the view data.
*/ */
public readonly context?: any, public readonly data?: any,
/**
* Optionally, the layout name.
* @type string
*/
public readonly layout?: string,
) { ) {
super() super()
} }
public async write(request: Request) { public async write(request: Request) {
const views: ViewEngine = this.make(ViewEngine) const views: ViewEngine = this.make(ViewEngine)
request.response.body = await views.render(this.view, this.context, this.layout) request.response.body = await views.template(this.view, this.data)
request.response.headers.set('Content-Type', 'text/html; charset=utf-8')
return request return request
} }
} }

View File

@ -9,7 +9,6 @@ import {HTTPStatus} from '../../const/http.ts'
import HTTPErrorResponseFactory from './HTTPErrorResponseFactory.ts' import HTTPErrorResponseFactory from './HTTPErrorResponseFactory.ts'
import HTTPError from '../../error/HTTPError.ts' import HTTPError from '../../error/HTTPError.ts'
import ViewResponseFactory from './ViewResponseFactory.ts' import ViewResponseFactory from './ViewResponseFactory.ts'
import PartialViewResponseFactory from './PartialViewResponseFactory.ts'
/** /**
* Get a new JSON response factory that writes the given object as JSON. * Get a new JSON response factory that writes the given object as JSON.
@ -69,22 +68,11 @@ export function http(status: HTTPStatus, message?: string): HTTPErrorResponseFac
} }
/** /**
* Get a new view response factory for the given view name, passing along context and layout. * Get a new view response factory for the given view name, passing along any view data.
* @param {string} view * @param {string} view
* @param [context] * @param [data]
* @param {string} [layout]
* @return ViewResponseFactory * @return ViewResponseFactory
*/ */
export function view(view: string, context?: any, layout?: string): ViewResponseFactory { export function view(view: string, data?: any): ViewResponseFactory {
return make(ViewResponseFactory, view, context, layout) return make(ViewResponseFactory, view, data)
}
/**
* Get a new partial view response factory for the given view name, passing along context.
* @param {string} view
* @param [context]
* @return PartialViewResponseFactory
*/
export function partial(view: string, context?: any): PartialViewResponseFactory {
return make(PartialViewResponseFactory, view, context)
} }

View File

@ -8,6 +8,8 @@ import {Status} from '../const/status.ts'
import Instantiable from '../../../di/src/type/Instantiable.ts' import Instantiable from '../../../di/src/type/Instantiable.ts'
import {Collection} from '../collection/Collection.ts' import {Collection} from '../collection/Collection.ts'
import {path} from '../external/std.ts' import {path} from '../external/std.ts'
import Scaffolding from '../unit/Scaffolding.ts'
import {Container} from '../../../di/src/Container.ts'
/** /**
* Central class for Daton applications. * Central class for Daton applications.
@ -22,6 +24,7 @@ export default class Application {
constructor( constructor(
protected logger: Logging, protected logger: Logging,
protected injector: Container,
protected rleh: RunLevelErrorHandler, protected rleh: RunLevelErrorHandler,
/** /**
* Array of unit classes to run for this application. * Array of unit classes to run for this application.
@ -112,7 +115,7 @@ export default class Application {
* @type string * @type string
*/ */
get root() { get root() {
return path.resolve('.') return this.injector.make(Scaffolding).base_dir
} }
/** /**
@ -120,7 +123,7 @@ export default class Application {
* @type string * @type string
*/ */
get app_root() { get app_root() {
return path.resolve('./app') return this.injector.make(Scaffolding).base_dir
} }
/** /**

View File

@ -9,6 +9,7 @@ import 'https://deno.land/x/dotenv/load.ts'
import { Container } from '../../../di/src/Container.ts' import { Container } from '../../../di/src/Container.ts'
import { Inject } from '../../../di/src/decorator/Injection.ts' import { Inject } from '../../../di/src/decorator/Injection.ts'
import CacheFactory from '../support/CacheFactory.ts' import CacheFactory from '../support/CacheFactory.ts'
import { path } from '../external/std.ts'
/** /**
* Simple helper for loading ENV values with fallback. * Simple helper for loading ENV values with fallback.
@ -29,11 +30,17 @@ export { env }
*/ */
@Unit() @Unit()
export default class Scaffolding extends LifecycleUnit { export default class Scaffolding extends LifecycleUnit {
public base_dir!: string
constructor( constructor(
protected logger: Logging, protected logger: Logging,
protected utility: Utility, protected utility: Utility,
@Inject('injector') protected injector: Container, @Inject('injector') protected injector: Container,
) { super() } base_script: string
) {
super()
this.base_dir = path.dirname(base_script).replace('file:///', '/')
}
/** /**
* Helper method for fetching environment variables. * Helper method for fetching environment variables.
@ -47,6 +54,8 @@ export default class Scaffolding extends LifecycleUnit {
public async up() { public async up() {
this.setup_logging() this.setup_logging()
this.register_factories() this.register_factories()
this.logger.info(`Base directory: ${this.base_dir}`)
} }
/** /**

View File

@ -1,84 +1,197 @@
import LifecycleUnit from '../lifecycle/Unit.ts' 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 {Unit} from '../lifecycle/decorators.ts'
import {Handlebars} from '../external/http.ts'
import {Logging} from '../service/logging/Logging.ts' import {Logging} from '../service/logging/Logging.ts'
import {fs} from '../external/std.ts' import {ConfigError} from '../error/ConfigError.ts'
import {path, fs} from '../external/std.ts'
/** /**
* Lifecycle unit which sets up and provides basic view engine services. * 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 * @extends LifecycleUnit
*/ */
@Unit() @Unit()
export default class ViewEngine extends LifecycleUnit { export default class ViewEngine extends LifecycleUnit {
/** protected engine?: Types
* The Handlebars instance. protected template_dir?: string
* @type Handlebars
*/
protected _handlebars!: Handlebars
// TODO include basic app info in view data
constructor( constructor(
protected readonly config: Config,
protected readonly logger: Logging, protected readonly logger: Logging,
) { ) {
super() super()
} }
async up() { public async up() {
this.logger.info(`Setting views base dir: ${this.app.app_path('http', 'views')}`) const config: unknown = this.config.get('app.views.engine')
this._handlebars = new Handlebars({ const base_dir: unknown = this.config.get('app.views.base_dir')
baseDir: this.app.app_path('http', 'views'),
extname: '.hbs',
layoutsDir: 'layouts',
partialsDir: 'partials',
defaultLayout: 'main',
helpers: undefined,
compilerOptions: undefined,
})
const main_layout_path = this.app.app_path('http', 'views', 'layouts', 'main.hbs') if ( !isViewEngine(config) ) {
if ( !(await fs.exists(main_layout_path)) ) { throw new ConfigError('app.views.engine', config)
this.logger.warn(`Unable to open main view layout file: ${main_layout_path}`)
this.logger.warn(`Unless you are using a custom layout, this could cause errors.`)
} }
const partials_path = this.app.app_path('http', 'views', 'partials') if ( !String(base_dir) ) {
if ( !(await fs.exists(partials_path)) ) { throw new ConfigError('app.views.base_dir', base_dir)
this.logger.warn(`Unable to open view partials directory: ${partials_path}`)
this.logger.warn(`This directory must exist for the view engine to function, even if it is empty.`)
} }
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()
} }
/** /**
* The handlebars instance. * Render a template file using the configured engine.
* @type Handlebars * @param {string} template_path - the relative path of the template w/in the template dir.
*/ * @param {object} [data = {}]
get handlebars(): Handlebars {
return this._handlebars
}
/**
* Render a view with the given name, using the specified arguments and layout.
* @param {string} view
* @param [args]
* @param {string} [layout]
* @return Promise<string> * @return Promise<string>
*/ */
async render(view: string, args?: any, layout?: string): Promise<string> { public async template(template_path: string, data: { [key: string]: any } = {}) {
this.logger.debug(`Rendering view: ${view}`) const file_path = `${this.template_path(template_path.replace(/:/g, '/'))}${this.get_file_extension()}`
return this.handlebars.renderView(view, args, layout)
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()}`
)
} }
/** /**
* Render a partial view with the given name, using the specified arguments. * Resolve a path within the template base directory.
* @param {string} view * @param {...string} parts
* @param [args] * @return string
*/ */
async partial(view: string, args?: any) { public template_path(...parts: string[]): string {
const parts = `${view}.hbs`.split(':') if ( !this.template_dir ) throw new MissingTemplateDirectoryError()
const resolved = this.app.app_path('http', 'views', ...parts) return path.resolve(this.template_dir, ...parts)
}
this.logger.debug(`Rendering partial: ${view} from ${resolved}`) /**
return this.handlebars.render(resolved, args) * 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))
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)
}
}
} }
} }