From cf6d14abca5a67ce4758f5f660d676eaf34a7e91 Mon Sep 17 00:00:00 2001 From: garrettmills Date: Tue, 29 Jun 2021 01:44:07 -0500 Subject: [PATCH] - Start support for auto-generated routes using UniversalPath - Start support for custom view engine props & functions - Start login template and namespace --- .../module/MountActivatedRouteHTTPModule.ts | 2 +- src/http/routing/ActivatedRoute.ts | 2 + src/resources/views/auth/form.pug | 12 +++++ src/resources/views/auth/login.pug | 25 ++++++++-- src/resources/views/auth/theme.pug | 22 +++++++++ src/service/Routing.ts | 49 ++++++++++++++++++- src/util/support/Pipe.ts | 19 +++++-- src/views/PugViewEngine.ts | 14 +++++- src/views/ViewEngine.ts | 41 +++++++++++++++- 9 files changed, 172 insertions(+), 14 deletions(-) create mode 100644 src/resources/views/auth/form.pug create mode 100644 src/resources/views/auth/theme.pug diff --git a/src/http/kernel/module/MountActivatedRouteHTTPModule.ts b/src/http/kernel/module/MountActivatedRouteHTTPModule.ts index a950b45..ab833a6 100644 --- a/src/http/kernel/module/MountActivatedRouteHTTPModule.ts +++ b/src/http/kernel/module/MountActivatedRouteHTTPModule.ts @@ -28,7 +28,7 @@ export class MountActivatedRouteHTTPModule extends HTTPKernelModule { const route = this.routing.match(request.method, request.path) if ( route ) { this.logging.verbose(`Mounting activated route: ${request.path} -> ${route}`) - const activated = new ActivatedRoute(route, request.path) + const activated = request.make(ActivatedRoute, route, request.path) request.registerSingletonInstance(ActivatedRoute, activated) } else { this.logging.debug(`No matching route found for: ${request.method} -> ${request.path}`) diff --git a/src/http/routing/ActivatedRoute.ts b/src/http/routing/ActivatedRoute.ts index de3eaa7..d9e9ddf 100644 --- a/src/http/routing/ActivatedRoute.ts +++ b/src/http/routing/ActivatedRoute.ts @@ -1,9 +1,11 @@ import {ErrorWithContext} from '../../util' import {ResolvedRouteHandler, Route} from './Route' +import {Injectable} from '../../di' /** * Class representing a resolved route that a request is mounted to. */ +@Injectable() export class ActivatedRoute { /** * The parsed params from the route definition. diff --git a/src/resources/views/auth/form.pug b/src/resources/views/auth/form.pug new file mode 100644 index 0000000..242c0db --- /dev/null +++ b/src/resources/views/auth/form.pug @@ -0,0 +1,12 @@ +extends ./theme + +block content + h3.login-heading.mb-4 + block heading + + if errors + each error in errors + p.form-error-message #{error} + + form(method='post' enctype='multipart/form-data') + block form diff --git a/src/resources/views/auth/login.pug b/src/resources/views/auth/login.pug index b428040..fa97b66 100644 --- a/src/resources/views/auth/login.pug +++ b/src/resources/views/auth/login.pug @@ -1,3 +1,22 @@ -html - body - h1 Extollo Login Page +extends ./form + +block head + title Login | #{config('app.name', 'Extollo')} + +block heading + | Login to Continue + +block form + .form-label-group + input#inputUsername.form-control(type='text' name='username' value=(form_data ? form_data.username : '') required placeholder='Username' autofocus) + label(for='inputUsername') Username + .form-label-group + input#inputPassword.form-control(type='password' name='password' required placeholder='Password') + label(for='inputPassword') Password + button.btn.btn-lg.btn-primary.btn-block.btn-login.text-uppercase.font-weight-bold.mb-2.form-submit-button(type='submit') Login + + .text-center + span.small Need an account?  + a(href='./register') Register here. + // .text-center + span.small(style="color: #999999;") Provider: #{provider_name} diff --git a/src/resources/views/auth/theme.pug b/src/resources/views/auth/theme.pug new file mode 100644 index 0000000..0e03974 --- /dev/null +++ b/src/resources/views/auth/theme.pug @@ -0,0 +1,22 @@ +html + head + meta(name='viewport' content='width=device-width initial-scale=1') + + block head + + block styles + link(rel='stylesheet' href='https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css') + link(rel='stylesheet' href=vendor('@extollo', 'auth/theme.css')) + body + .container-fluid + .row.no-gutter + .d-none.d-md-flex.col-md-6.col-lg-8.bg-image + .col-md-6.col-lg-4 + .login.d-flex.align-items-center.py-5 + .container + .row + .col-md-9.col-lg-8.mx-auto + block content + + block scripts + script(src='https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css') diff --git a/src/service/Routing.ts b/src/service/Routing.ts index b79a724..ad0eaff 100644 --- a/src/service/Routing.ts +++ b/src/service/Routing.ts @@ -1,5 +1,5 @@ import {Singleton, Inject} from '../di' -import {UniversalPath, Collection} from '../util' +import {UniversalPath, Collection, Pipe, universalPath} from '../util' import {Unit} from '../lifecycle/Unit' import {Logging} from './Logging' import {Route} from '../http/routing/Route' @@ -7,6 +7,7 @@ import {HTTPMethod} from '../http/lifecycle/Request' import {ViewEngineFactory} from '../views/ViewEngineFactory' import {ViewEngine} from '../views/ViewEngine' import {lib} from '../lib' +import {Config} from './Config' /** * Application unit that loads the various route files from `app/http/routes` and pre-compiles the route handlers. @@ -16,6 +17,9 @@ export class Routing extends Unit { @Inject() protected readonly logging!: Logging + @Inject() + protected readonly config!: Config + protected compiledRoutes: Collection = new Collection() public async up(): Promise { @@ -68,4 +72,47 @@ export class Routing extends Unit { public getCompiled(): Collection { return this.compiledRoutes } + + public getAssetPath(...parts: string[]): UniversalPath { + return this.getAssetBase().concat(...parts) + } + + public getAssetBase(): UniversalPath { + return this.getAppUrl().concat(this.config.get('server.builtIns.assets.prefix', '/assets')) + } + + public getVendorPath(namespace: string, ...parts: string[]): UniversalPath { + return this.getVendorBase().concat(encodeURIComponent(namespace), ...parts) + } + + public getVendorBase(): UniversalPath { + return this.getAppUrl().concat(this.config.get('server.builtIns.vendor.prefix', '/vendor')) + } + + public getAppUrl(): UniversalPath { + const rawHost = String(this.config.get('server.url', 'http://localhost')).toLowerCase() + const isSSL = rawHost.startsWith('https://') + const port = this.config.get('server.port', 8000) + + return Pipe.wrap(rawHost) + .unless( + host => host.startsWith('http://') || host.startsWith('https'), + host => `http://${host}`, + ) + .when( + host => { + const hasPort = host.split(':').length > 2 + const defaultRaw = !isSSL && port === 80 + const defaultSSL = isSSL && port === 443 + return !hasPort && !defaultRaw && !defaultSSL + }, + host => { + const parts = host.split('/') + parts[2] += `:${port}` + return parts.join('/') + }, + ) + .tap(host => universalPath(host)) + .get() + } } diff --git a/src/util/support/Pipe.ts b/src/util/support/Pipe.ts index 0382a55..e6caa40 100644 --- a/src/util/support/Pipe.ts +++ b/src/util/support/Pipe.ts @@ -8,6 +8,11 @@ export type PipeOperator = (subject: T) => T2 */ export type ReflexivePipeOperator = (subject: T) => T +/** + * A condition or condition-resolving function for pipe methods. + */ +export type PipeCondition = boolean | ((subject: T) => boolean) + /** * A class for writing chained/conditional operations in a data-flow manner. * @@ -79,8 +84,8 @@ export class Pipe { * @param check * @param op */ - when(check: boolean, op: ReflexivePipeOperator): Pipe { - if ( check ) { + when(check: PipeCondition, op: ReflexivePipeOperator): Pipe { + if ( (typeof check === 'function' && check(this.subject)) || check ) { return Pipe.wrap(op(this.subject)) } @@ -94,8 +99,12 @@ export class Pipe { * @param check * @param op */ - unless(check: boolean, op: ReflexivePipeOperator): Pipe { - return this.when(!check, op) + unless(check: PipeCondition, op: ReflexivePipeOperator): Pipe { + if ( (typeof check === 'function' && check(this.subject)) || check ) { + return this + } + + return Pipe.wrap(op(this.subject)) } /** @@ -103,7 +112,7 @@ export class Pipe { * @param check * @param op */ - whenNot(check: boolean, op: ReflexivePipeOperator): Pipe { + whenNot(check: PipeCondition, op: ReflexivePipeOperator): Pipe { return this.unless(check, op) } diff --git a/src/views/PugViewEngine.ts b/src/views/PugViewEngine.ts index 37b87c5..69a7d11 100644 --- a/src/views/PugViewEngine.ts +++ b/src/views/PugViewEngine.ts @@ -17,14 +17,20 @@ export class PugViewEngine extends ViewEngine { public renderByName(templateName: string, locals: { [p: string]: any }): string | Promise { let compiled = this.compileCache[templateName] if ( compiled ) { - return compiled(locals) + return compiled({ + ...this.getGlobals(), + ...locals, + }) } const filePath = this.resolveName(templateName) compiled = pug.compileFile(filePath.toLocal, this.getOptions(templateName)) this.compileCache[templateName] = compiled - return compiled(locals) + return compiled({ + ...this.getGlobals(), + ...locals, + }) } /** @@ -39,4 +45,8 @@ export class PugViewEngine extends ViewEngine { globals: [], } } + + getFileExtension(): string { + return '.pug' + } } diff --git a/src/views/ViewEngine.ts b/src/views/ViewEngine.ts index f8adef5..2445ee6 100644 --- a/src/views/ViewEngine.ts +++ b/src/views/ViewEngine.ts @@ -2,6 +2,7 @@ import {AppClass} from '../lifecycle/AppClass' import {Config} from '../service/Config' import {Container} from '../di' import {ErrorWithContext, UniversalPath} from '../util' +import {Routing} from '../service/Routing' /** * Abstract base class for rendering views via different view engines. @@ -9,6 +10,8 @@ import {ErrorWithContext, UniversalPath} from '../util' export abstract class ViewEngine extends AppClass { protected readonly config: Config + protected readonly routing: Routing + protected readonly debug: boolean protected readonly namespaces: {[key: string]: UniversalPath} = {} @@ -16,6 +19,7 @@ export abstract class ViewEngine extends AppClass { constructor() { super() this.config = Container.getContainer().make(Config) + this.routing = Container.getContainer().make(Routing) this.debug = (this.config.get('server.mode', 'production') === 'development' || this.config.get('server.debug', false)) } @@ -41,6 +45,30 @@ export abstract class ViewEngine extends AppClass { */ public abstract renderByName(templateName: string, locals: {[key: string]: any}): string | Promise + /** + * Get the file extension of template files of this engine. + * @example `.pug` + */ + public abstract getFileExtension(): string + + /** + * Get the global variables that should be passed to every view rendered. + * @protected + */ + protected getGlobals(): {[key: string]: any} { + return { + app: this.app(), + config: (key: string, fallback?: any) => this.config.get(key, fallback), + asset: (...parts: string[]) => this.routing.getAssetPath(...parts).toRemote, + vendor: (namespace: string, ...parts: string[]) => this.routing.getVendorPath(namespace, ...parts).toRemote, + } + } + + /** + * Register a path as a root for rendering views prefixed with the given namespace. + * @param namespace + * @param basePath + */ public registerNamespace(namespace: string, basePath: UniversalPath): this { if ( namespace.startsWith('@') ) { namespace = namespace.substr(1) @@ -50,6 +78,10 @@ export abstract class ViewEngine extends AppClass { return this } + /** + * Given the name of a template, get a UniversalPath pointing to its file. + * @param templateName + */ public resolveName(templateName: string): UniversalPath { let path = this.path if ( templateName.startsWith('@') ) { @@ -66,13 +98,18 @@ export abstract class ViewEngine extends AppClass { templateName = parts.join(':') } - if ( !templateName.endsWith('.pug') ) { - templateName += '.pug' + if ( !templateName.endsWith(this.getFileExtension()) ) { + templateName += this.getFileExtension() } return path.concat(...templateName.split(':')) } + /** + * Given the name of a template, get a UniversalPath to the root of the tree where + * that template resides. + * @param templateName + */ public resolveBasePath(templateName: string): UniversalPath { let path = this.path if ( templateName.startsWith('@') ) {