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 '../../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 {
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 {Request} from '../../../lib/src/http/Request.ts'
import {view} from '../../../lib/src/http/response/helpers.ts'
import {Injectable} from '../../../di/module.ts'
@Injectable()
export default class HomeController extends Controller {
get_home(request: Request) {
return view('home', { request })
async get_home(request: 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
* amount of support required to get Daton up and running. The app handles
* 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()
/*

View File

@ -17,11 +17,11 @@ export default [
ConfigUnit,
DatabaseUnit,
ModelsUnit,
ViewEngineUnit,
HttpKernelUnit,
MiddlewareUnit,
ControllerUnit,
ViewEngineUnit,
RoutesUnit,
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.53.0/http/cookie.ts'
export { Handlebars } from 'https://deno.land/x/handlebars/mod.ts'
export * from 'https://deno.land/std@0.67.0/http/server.ts'
export * from 'https://deno.land/std@0.67.0/http/cookie.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 * as path from 'https://deno.land/std@0.53.0/path/mod.ts'
export * as fs from 'https://deno.land/std@0.53.0/fs/mod.ts'
export { generate as uuid } from 'https://deno.land/std/uuid/v4.ts'
export * as path from 'https://deno.land/std@0.67.0/path/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@0.67.0/uuid/v4.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 { getCookies, setCookie, delCookie, ServerRequest } from '../external/http.ts'
import { getCookies, setCookie, deleteCookie, ServerRequest } from '../external/http.ts'
import { InMemCache } from '../support/InMemCache.ts'
import { HTTPRequest } from './type/HTTPRequest.ts'
@ -117,6 +117,6 @@ export class CookieJar {
*/
public async delete(key: string): Promise<void> {
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> {
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
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
*/
public readonly view: string,
/**
* Optionally, the view context.
* Optionally, the view data.
*/
public readonly context?: any,
/**
* Optionally, the layout name.
* @type string
*/
public readonly layout?: string,
public readonly data?: any,
) {
super()
}
public async write(request: Request) {
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
}
}

View File

@ -9,7 +9,6 @@ import {HTTPStatus} from '../../const/http.ts'
import HTTPErrorResponseFactory from './HTTPErrorResponseFactory.ts'
import HTTPError from '../../error/HTTPError.ts'
import ViewResponseFactory from './ViewResponseFactory.ts'
import PartialViewResponseFactory from './PartialViewResponseFactory.ts'
/**
* 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 [context]
* @param {string} [layout]
* @param [data]
* @return ViewResponseFactory
*/
export function view(view: string, context?: any, layout?: string): ViewResponseFactory {
return make(ViewResponseFactory, view, context, layout)
}
/**
* 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)
export function view(view: string, data?: any): ViewResponseFactory {
return make(ViewResponseFactory, view, data)
}

View File

@ -8,6 +8,8 @@ import {Status} from '../const/status.ts'
import Instantiable from '../../../di/src/type/Instantiable.ts'
import {Collection} from '../collection/Collection.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.
@ -22,6 +24,7 @@ export default class Application {
constructor(
protected logger: Logging,
protected injector: Container,
protected rleh: RunLevelErrorHandler,
/**
* Array of unit classes to run for this application.
@ -112,7 +115,7 @@ export default class Application {
* @type string
*/
get root() {
return path.resolve('.')
return this.injector.make(Scaffolding).base_dir
}
/**
@ -120,7 +123,7 @@ export default class Application {
* @type string
*/
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 { Inject } from '../../../di/src/decorator/Injection.ts'
import CacheFactory from '../support/CacheFactory.ts'
import { path } from '../external/std.ts'
/**
* Simple helper for loading ENV values with fallback.
@ -29,11 +30,17 @@ export { env }
*/
@Unit()
export default class Scaffolding extends LifecycleUnit {
public base_dir!: string
constructor(
protected logger: Logging,
protected utility: Utility,
@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.
@ -47,6 +54,8 @@ export default class Scaffolding extends LifecycleUnit {
public async up() {
this.setup_logging()
this.register_factories()
this.logger.info(`Base directory: ${this.base_dir}`)
}
/**

View File

@ -1,84 +1,197 @@
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 {Handlebars} from '../external/http.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
*/
@Unit()
export default class ViewEngine extends LifecycleUnit {
/**
* The Handlebars instance.
* @type Handlebars
*/
protected _handlebars!: Handlebars
// TODO include basic app info in view data
protected engine?: Types
protected template_dir?: string
constructor(
protected readonly config: Config,
protected readonly logger: Logging,
) {
super()
}
async up() {
this.logger.info(`Setting views base dir: ${this.app.app_path('http', 'views')}`)
this._handlebars = new Handlebars({
baseDir: this.app.app_path('http', 'views'),
extname: '.hbs',
layoutsDir: 'layouts',
partialsDir: 'partials',
defaultLayout: 'main',
helpers: undefined,
compilerOptions: undefined,
})
public async up() {
const config: unknown = this.config.get('app.views.engine')
const base_dir: unknown = this.config.get('app.views.base_dir')
const main_layout_path = this.app.app_path('http', 'views', 'layouts', 'main.hbs')
if ( !(await fs.exists(main_layout_path)) ) {
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.`)
if ( !isViewEngine(config) ) {
throw new ConfigError('app.views.engine', config)
}
const partials_path = this.app.app_path('http', 'views', 'partials')
if ( !(await fs.exists(partials_path)) ) {
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.`)
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()
}
/**
* The handlebars instance.
* @type Handlebars
*/
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]
* 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>
*/
async render(view: string, args?: any, layout?: string): Promise<string> {
this.logger.debug(`Rendering view: ${view}`)
return this.handlebars.renderView(view, args, layout)
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()}`
)
}
/**
* Render a partial view with the given name, using the specified arguments.
* @param {string} view
* @param [args]
* Resolve a path within the template base directory.
* @param {...string} parts
* @return string
*/
async partial(view: string, args?: any) {
const parts = `${view}.hbs`.split(':')
const resolved = this.app.app_path('http', 'views', ...parts)
public template_path(...parts: string[]): string {
if ( !this.template_dir ) throw new MissingTemplateDirectoryError()
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)
}
}
}
}