diff --git a/app/bundle/daton.ts b/app/bundle/daton.ts index 9b07d55..a7dbf5b 100644 --- a/app/bundle/daton.ts +++ b/app/bundle/daton.ts @@ -1,2 +1,5 @@ export * from '../../lib/src/module.ts' -export * from '../../di/module.ts' \ No newline at end of file +export * from '../../di/module.ts' + +import * as std from '../../lib/src/external/std.ts' +export { std } diff --git a/app/configs/app.config.ts b/app/configs/app.config.ts index a56cc2a..8a1b5f8 100644 --- a/app/configs/app.config.ts +++ b/app/configs/app.config.ts @@ -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', + }, } diff --git a/app/http/controllers/Home.controller.ts b/app/http/controllers/Home.controller.ts index 6ceeaa7..ae1ddf8 100644 --- a/app/http/controllers/Home.controller.ts +++ b/app/http/controllers/Home.controller.ts @@ -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' }) } } diff --git a/app/http/views/home.hbs b/app/http/views/home.hbs index 9eb7a75..ca7dadb 100644 --- a/app/http/views/home.hbs +++ b/app/http/views/home.hbs @@ -1 +1,6 @@ -

Welcome to Daton!

+ + {{> header }} + +

{{ greeting }} from Daton!

+ + \ No newline at end of file diff --git a/app/http/views/layouts/.gitkeep b/app/http/views/layouts/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/http/views/layouts/main.hbs b/app/http/views/layouts/main.hbs deleted file mode 100644 index 2129e45..0000000 --- a/app/http/views/layouts/main.hbs +++ /dev/null @@ -1,14 +0,0 @@ - - - - - {{#if title}} - {{ title }} | Daton - {{else}} - Daton - {{/if}} - - - {{{ body }}} - - \ No newline at end of file diff --git a/app/http/views/partials/.gitkeep b/app/http/views/partials/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/http/views/partials/header.hbs b/app/http/views/partials/header.hbs new file mode 100644 index 0000000..444e9b8 --- /dev/null +++ b/app/http/views/partials/header.hbs @@ -0,0 +1,3 @@ + + Daton + diff --git a/app/index.ts b/app/index.ts index 2ed0d40..adfc2ae 100755 --- a/app/index.ts +++ b/app/index.ts @@ -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() /* diff --git a/app/units.ts b/app/units.ts index acbcb16..d751bf2 100644 --- a/app/units.ts +++ b/app/units.ts @@ -17,11 +17,11 @@ export default [ ConfigUnit, DatabaseUnit, ModelsUnit, + ViewEngineUnit, HttpKernelUnit, MiddlewareUnit, ControllerUnit, - ViewEngineUnit, RoutesUnit, RoutingUnit, - // HttpServerUnit, + HttpServerUnit, ] diff --git a/lib/src/const/view_engines.ts b/lib/src/const/view_engines.ts new file mode 100644 index 0000000..383ece1 --- /dev/null +++ b/lib/src/const/view_engines.ts @@ -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)) +} diff --git a/lib/src/error/ConfigError.ts b/lib/src/error/ConfigError.ts new file mode 100644 index 0000000..a11b166 --- /dev/null +++ b/lib/src/error/ConfigError.ts @@ -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)`) + } +} diff --git a/lib/src/external/db.ts b/lib/src/external/db.ts index 14bb523..f3b7f82 100644 --- a/lib/src/external/db.ts +++ b/lib/src/external/db.ts @@ -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' diff --git a/lib/src/external/http.ts b/lib/src/external/http.ts index 6b6409b..71c371d 100644 --- a/lib/src/external/http.ts +++ b/lib/src/external/http.ts @@ -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' \ No newline at end of file +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 = hb +export { handlebars } diff --git a/lib/src/external/std.ts b/lib/src/external/std.ts index 3882bdb..51f2b72 100644 --- a/lib/src/external/std.ts +++ b/lib/src/external/std.ts @@ -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' diff --git a/lib/src/http/CookieJar.ts b/lib/src/http/CookieJar.ts index 3f93ada..b29129f 100644 --- a/lib/src/http/CookieJar.ts +++ b/lib/src/http/CookieJar.ts @@ -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 { await this._cache.drop(key) - delCookie(this.request.response, key) + deleteCookie(this.request.response, key) } } diff --git a/lib/src/http/response/HTMLResponseFactory.ts b/lib/src/http/response/HTMLResponseFactory.ts index 1b017c8..ca5efef 100644 --- a/lib/src/http/response/HTMLResponseFactory.ts +++ b/lib/src/http/response/HTMLResponseFactory.ts @@ -14,7 +14,7 @@ export default class HTMLResponseFactory extends ResponseFactory { public async write(request: Request): Promise { 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 } diff --git a/lib/src/http/response/PartialViewResponseFactory.ts b/lib/src/http/response/PartialViewResponseFactory.ts deleted file mode 100644 index 7b738a7..0000000 --- a/lib/src/http/response/PartialViewResponseFactory.ts +++ /dev/null @@ -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 - } -} diff --git a/lib/src/http/response/ViewResponseFactory.ts b/lib/src/http/response/ViewResponseFactory.ts index ca57df9..1cf4203 100644 --- a/lib/src/http/response/ViewResponseFactory.ts +++ b/lib/src/http/response/ViewResponseFactory.ts @@ -13,22 +13,19 @@ export default class ViewResponseFactory extends ResponseFactory { * @type string */ public readonly view: string, + /** - * Optionally, the view context. - */ - public readonly context?: any, - /** - * Optionally, the layout name. - * @type string + * Optionally, the view data. */ - 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 } } diff --git a/lib/src/http/response/helpers.ts b/lib/src/http/response/helpers.ts index e2dd320..19fab60 100644 --- a/lib/src/http/response/helpers.ts +++ b/lib/src/http/response/helpers.ts @@ -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) } diff --git a/lib/src/lifecycle/Application.ts b/lib/src/lifecycle/Application.ts index 6720959..b8fd493 100644 --- a/lib/src/lifecycle/Application.ts +++ b/lib/src/lifecycle/Application.ts @@ -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 } /** diff --git a/lib/src/unit/Scaffolding.ts b/lib/src/unit/Scaffolding.ts index f6b7603..4132e85 100644 --- a/lib/src/unit/Scaffolding.ts +++ b/lib/src/unit/Scaffolding.ts @@ -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}`) } /** diff --git a/lib/src/unit/ViewEngine.ts b/lib/src/unit/ViewEngine.ts index 63ae94f..d6dc6bd 100644 --- a/lib/src/unit/ViewEngine.ts +++ b/lib/src/unit/ViewEngine.ts @@ -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, - }) - - 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.`) + 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) } - 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 + * 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 */ - get handlebars(): Handlebars { - return this._handlebars + 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 view with the given name, using the specified arguments and layout. - * @param {string} view - * @param [args] - * @param {string} [layout] + * 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() + return path.resolve(this.template_dir, ...parts) + } + + /** + * Render a template string using the configured engine. + * @param {string} template + * @param {object} [data = {}] * @return Promise */ - async render(view: string, args?: any, layout?: string): Promise { - this.logger.debug(`Rendering view: ${view}`) - return this.handlebars.renderView(view, args, layout) + public async render(template: string, data: { [key: string]: any } = {}) { + const engine: views.Engine = this.get_engine() + return engine(template, data) } /** - * Render a partial view with the given name, using the specified arguments. - * @param {string} view - * @param [args] + * Get the rendering engine for the configured backend. */ - async partial(view: string, args?: any) { - const parts = `${view}.hbs`.split(':') - const resolved = this.app.app_path('http', 'views', ...parts) + 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() + } - this.logger.debug(`Rendering partial: ${view} from ${resolved}`) - return this.handlebars.render(resolved, args) + 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) + } + } } }