From 0e0b51fa24eb69add8fe030979b81adbbad69ba4 Mon Sep 17 00:00:00 2001 From: gh0sTedBuddy Date: Thu, 28 Mar 2024 07:07:35 +0000 Subject: [PATCH] feat(server) layout handling; placekeeper wip --- examples/index.js | 12 ++- src/IO/LayResponse.js | 177 +++++++++++++++++++++----------------- src/LayServer.js | 87 +++++++++++-------- src/Routing/LayRouting.js | 4 + src/Routing/Router.js | 20 +++-- src/Utils/Placekeeper.js | 107 +++++++++-------------- src/Views/Html.js | 13 +-- src/Views/Markdown.js | 3 +- 8 files changed, 223 insertions(+), 200 deletions(-) diff --git a/examples/index.js b/examples/index.js index ade6c75..b5ba71f 100644 --- a/examples/index.js +++ b/examples/index.js @@ -1,6 +1,7 @@ import { LayServer } from '../src'; import {resolve} from 'path'; import Html from '../src/Views/Html'; +import Markdown from '../src/Views/Markdown'; const APP_PORT = Bun.env.APP_PORT ?? Bun.env.PORT ?? 3000; @@ -36,6 +37,7 @@ class InfoController { const server = new LayServer({ viewsPath: resolve(__dirname, 'views'), + layout: 'blank.html', loaders: new Map([[ 'html', new Html() ]]), @@ -43,7 +45,7 @@ const server = new LayServer({ function Main(server) { server.get('/', async (req, res) => { return res.json({message: 'hello world'}) - }, { domain: 'api.tipedi.local' }); + }, { domain: 'example.layc.dev' }); server.all('/data', (req, res) => { let respBody = `your ${req.method} request was well recieved`; switch (req.method) { @@ -86,6 +88,14 @@ server.get('/', async (req, res) => { return res.render('index'); }); +server.get('/blog/:slug', async (req, res) => { + return res.send('index.html', { layout: 'blank.html' }); +}); + +server.use(async (req, res) => { + return res.send('batman'); +}) + server.listen(APP_PORT, () => { console.log(`server listen on ${APP_PORT}`); }); diff --git a/src/IO/LayResponse.js b/src/IO/LayResponse.js index f08a4d1..c687167 100644 --- a/src/IO/LayResponse.js +++ b/src/IO/LayResponse.js @@ -1,37 +1,96 @@ import MimeTypeMap from './MimeTypeMap'; export default class LayResponse { - routing = undefined; - body; - statusCode; - headerList = new Map(); - mimeType; - options = { + #routing = undefined; + #body = 'Not Implemented'; + #file = undefined; + #redirectUrl = undefined; + #statusCode = 502; + #headerList = new Map(); + #mimeType = 'text/plain'; + #options = { disableFileCheck: false, }; constructor(routing) { - this.routing = routing; - this.body = 'Not Implemented'; - this.statusCode = 502; - this.headerList = new Map(); - this.mimeType = 'text/plain'; + this.#routing = routing; } - async send(str) { - this.type('text'); - this.body = str; + type(key) { + if (!MimeTypeMap.has(key)) { + if(this.#mimeType != 'text/plain') this.#mimeType = MimeTypeMap.get('text'); + return; + } + this.mimeType = MimeTypeMap.get(key); + } + + status(value = null) { + if (isNaN(value)) throw new TypeError('status has to be a number'); + this.#statusCode = value || 502; + } + + redirect(value = null, status = 302) { + if (value === null) { + return this.#redirectUrl; + } + + this.#redirectUrl = value; + this.status(status); + } + + headers() { + if (!this.#headerList.get('Content-Type')) { + this.#headerList.set('Content-Type', `${this.#mimeType}`); + } + return this.#headerList; + } + + header(key, value) { + this.#headerList.set(key, value); + } + + async build(options = {}) { + if (this.#redirectUrl) { + return Response.redirect(this.#redirectUrl, this.status()); + } + + if(this.#file) return new Response(this.#file); + + if(['text/plain'].includes(this.#mimeType)) { + await this.handleLayout(this.#body, options); + } + + return new Response(this.#body, { + status: this.status(), + headers: this.headers(), + }); + } + + async send() { + await this.text(...arguments); + + if (typeof(arguments[0]) != 'string' || !arguments[0]) return; + if (this.#options.disableFileCheck) return; - if (this.options.disableFileCheck) return; try { - const temp = Bun.file(str); + const temp = Bun.file(arguments[0]); if (await temp.exists()) { - this.file = str; + this.#file = temp; } } catch (err) {} } + async text() { + let [value, ...rest] = arguments; + if (typeof value != 'string') { + value = JSON.stringify(value); + } + + this.type('text'); + await this.handleLayout(value, ...rest); + } + json(data, options = {}) { let { replacer, space } = options; if (!Array.isArray(replacer) && typeof replacer != 'function') { @@ -41,72 +100,30 @@ export default class LayResponse { space = 0; } this.type('json'); - this.body = JSON.stringify(data, replacer, space); - } - - type(key) { - if (!MimeTypeMap.has(key)) { - this.mimeType = MimeTypeMap.get('text'); - return; - } - this.mimeType = MimeTypeMap.get(key); - } - - status(value = null) { - if (isNaN(value)) throw new Error('status has to be a number'); - this.statusCode = value || 502; - } - - text(value = null) { - if (typeof value != 'string') { - value = JSON.stringify(value); - } - - this.type('text'); - this.body = value; - } - - json(value = null) { - this.text(value); - this.type('json'); - } - - redirect(value = null, status = 302) { - if (value === null) { - return this.redirectUrl; - } - - this.redirectUrl = value; - this.status(status); - } - - headers() { - if (!this.headerList.get('Content-Type')) { - this.headerList.set('Content-Type', `${this.mimeType}`); - } - return this.headerList; - } - - header(key, value) { - this.headerList.set(key, value); - } - - build(options = {}) { - if (this.redirectUrl) { - return Response.redirect(this.redirectUrl, this.status()); - } - return new Response(this.body, { - status: this.status(), - headers: this.headers(), - }); + this.#body = JSON.stringify(data, replacer, space); } async render(path, data = {}) { - const loader = await this.routing.getServer().load(path); - const content = await loader.parse(data, { - status: this.status(), - headers: this.headers(), - }); + const loader = await this.#routing.getServer().load(path); + const content = await loader.parse(data); + return content; } + + async handleLayout(content, options = {}) { + this.#body = String(content); + if(!options.hasOwnProperty('layout') || typeof options.layout !== 'string') { + if(this.#routing.getServer().hasLayout()) { + options.layout = this.#routing.getServer().getLayout(); + } + } + + if(!options.layout) return + + const layout = await this.#routing.getServer().load(options.layout); + if(layout) { + this.#body = await layout.parse({ ...(options?.data || {}), content: String(content) }, options); + this.#mimeType = layout.mimeType; + } + } } diff --git a/src/LayServer.js b/src/LayServer.js index 7739922..f494505 100644 --- a/src/LayServer.js +++ b/src/LayServer.js @@ -14,12 +14,13 @@ import Logger from './Utils/Logger'; * @class */ export default class LayServer extends BaseRouter { - controllers = new Map(); - loaders = new Map(); - proc = null; - publicFiles; - routing = null; - options = new Map([ + #controllers = new Map(); + #loaders = new Map(); + #proc = null; + #publicFiles; + #routing = null; + #options = new Map([ + ['layout', undefined], ['loaders', undefined], ['publicDir', undefined], ['viewsPath', undefined], @@ -40,15 +41,15 @@ export default class LayServer extends BaseRouter { const optionKeys = Object.getOwnPropertyNames(options); for (const key of optionKeys) { - if (!this.options.has(key)) continue; + if (!this.#options.has(key)) continue; if (key == 'loaders') continue; - const valueType = typeof this.options.get(key); + const valueType = typeof this.#options.get(key); if ( typeof options[key] !== 'undefined' && (typeof options[key] == valueType || valueType == 'undefined') ) - this.options.set(key, options[key]); + this.#options.set(key, options[key]); } if (options.hasOwnProperty('loaders')) { @@ -58,10 +59,10 @@ export default class LayServer extends BaseRouter { } } - this.routing = new LayRouting(this); + this.#routing = new LayRouting(this); if(options.router) { - this.routing.addRouter(options.router); + this.#routing.addRouter(options.router); } if (options.hasOwnProperty('controllers')) { @@ -72,13 +73,13 @@ export default class LayServer extends BaseRouter { if ( !options.publicFiles && - this.options.discoverPublicFiles && - this.options.publicDir + this.#options.discoverPublicFiles && + this.#options.publicDir ) - this.indexPublicFiles(this.options.publicDir); + this.indexPublicFiles(this.#options.publicDir); - if (!this.options.viewsEngine && this.options.viewsPath) { - this.options.viewsEngine = new HtmlEngine(); + if (!this.#options.viewsEngine && this.#options.viewsPath) { + this.#options.viewsEngine = new HtmlEngine(); } } @@ -89,9 +90,9 @@ export default class LayServer extends BaseRouter { * @returns {*} - The current value of the option, or undefined if the option does not exist. */ option(key, value = undefined) { - if (!this.options.has(key)) return undefined; + if (!this.#options.has(key)) return undefined; if (value !== undefined) this.option.set(key, value); - return this.options.get(key); + return this.#options.get(key); } /** @@ -105,11 +106,11 @@ export default class LayServer extends BaseRouter { throw new Error( 'publicFiles option has to be an array or undefined' ); - if (!this.publicFiles || !Array.isArray(this.publicFiles)) - this.publicFiles = []; + if (!this.#publicFiles || !Array.isArray(this.#publicFiles)) + this.#publicFiles = []; for (let path of filePaths) { if (typeof path != 'string') continue; - this.publicFiles.push(path); + this.#publicFiles.push(path); } } @@ -139,11 +140,11 @@ export default class LayServer extends BaseRouter { // if the given controller is just an anonymous function defining the routes itself if (!controller?.prototype?.constructor?.name) { - controller(this.routing); + controller(this.#routing); continue; } - this.controllers.set( + this.#controllers.set( controller?.name, this.instantiateController(controller) ); @@ -156,9 +157,9 @@ export default class LayServer extends BaseRouter { * @returns {Object} - The instantiated controller. */ instantiateController(controller) { - this.routing.setNamespace(controller.namespace); - const _temp = new controller(this.routing); - if (controller.namespace) this.routing.setNamespace(null); + this.#routing.setNamespace(controller.namespace); + const _temp = new controller(this.#routing); + if (controller.namespace) this.#routing.setNamespace(null); return _temp; } @@ -181,10 +182,10 @@ export default class LayServer extends BaseRouter { if ( !url.pathname.endsWith('/') && - Array.isArray(this.publicFiles) && - this.publicFiles.length > 0 + Array.isArray(this.#publicFiles) && + this.#publicFiles.length > 0 ) { - let path = this.publicFiles.find( + let path = this.#publicFiles.find( (v) => url.pathname.substring(1) === v ); if (path) { @@ -192,7 +193,7 @@ export default class LayServer extends BaseRouter { return new Response( Bun.file( resolvePath( - this.options.get('publicDir') ?? + this.#options.get('publicDir') ?? `${import.meta.dir}/public`, path ) @@ -207,11 +208,11 @@ export default class LayServer extends BaseRouter { } } - if (this.options.enforceTrailingSlash && !url.pathname.endsWith('/')) { + if (this.#options.enforceTrailingSlash && !url.pathname.endsWith('/')) { return Response.redirect(`${url.toString()}/`, 302); } - let resp = await this.routing.handle(req); + let resp = await this.#routing.handle(req); if (resp instanceof Response) return resp; return new Response('Not Found', { status: 404 }); } @@ -254,7 +255,7 @@ export default class LayServer extends BaseRouter { * @returns {Promise} - The path of the view file, or null if not found. */ async getViewPath(filepath) { - let path = this.options.get('viewsPath'); + let path = this.#options.get('viewsPath'); if (!path.endsWith('/')) path += '/'; const filename = filepath.split('/').pop().toString(); @@ -290,7 +291,7 @@ export default class LayServer extends BaseRouter { const fullFilePath = await this.getViewPath(filepath); if (!fullFilePath) return new DefaultLoader(filepath); const filename = fullFilePath.split('/').pop().toString(); - const ext = filename.substring(filename.indexOf('.') + 1); + const ext = filename.substring(filename.lastIndexOf('.') + 1); if (this.loaders.size > 0) { for (const keys of this.loaders.keys()) { @@ -302,7 +303,21 @@ export default class LayServer extends BaseRouter { return new DefaultLoader(filepath); } - async add(path, cb) { - this.routing.add(...arguments) + add(path, cb) { + this.#routing.add(...arguments) + } + + use (cb) { + this.add('*', cb, arguments[1]); + } + + hasLayout() { + return typeof(this.#options.get('layout')) == 'string' && this.#options.get('layout').length > 0; + } + + getLayout() { + if(!this.#options.has('layout')) return undefined; + + return this.#options.get('layout'); } } diff --git a/src/Routing/LayRouting.js b/src/Routing/LayRouting.js index 84fcf1d..bb32d91 100644 --- a/src/Routing/LayRouting.js +++ b/src/Routing/LayRouting.js @@ -97,4 +97,8 @@ export default class LayRouting extends BaseRouter { this.routers.get(options.domain).add (path, cb, options); } } + + use (cb) { + this.add('*', cb, arguments[1]); + } } diff --git a/src/Routing/Router.js b/src/Routing/Router.js index 6289219..c2828c4 100644 --- a/src/Routing/Router.js +++ b/src/Routing/Router.js @@ -33,12 +33,16 @@ export default class Router extends BaseRouter { if (path.endsWith('/')) path = path.substring(0, path.length - 1); let currentNode = this.routes; + let fallbackNode = currentNode.has('*') ? currentNode.get('*') : undefined; let paramsList = []; if (path != '') { let segments = path.split('/'); for (let index = 0; index < segments.length; index++) { const segment = segments[index]; + if (currentNode.has('*')) { + fallbackNode = currentNode.get('*'); + } if (currentNode.has(segment)) { currentNode = currentNode.get(segment); @@ -51,7 +55,7 @@ export default class Router extends BaseRouter { continue; } - currentNode = null; + currentNode = fallbackNode ?? null; break; } } @@ -98,21 +102,24 @@ export default class Router extends BaseRouter { try { await reqContext.request.parseBody(); - const retVal = await handle.callback( + let retVal = await handle.callback( reqContext.request, reqContext.response ); if (typeof retVal != 'undefined') { - if (retVal instanceof Response) { + if (retVal instanceof Promise) { + retVal = await retVal; + } else if (retVal instanceof Response) { return retVal; } else if (typeof retVal == 'string') { - reqContext.response.send(retVal); + await reqContext.response.text(retVal); } else { reqContext.response.json(retVal); } } } catch (err) { Logger(err); + console.log(err); return new Response('Error', { status: 500 }); } @@ -123,10 +130,7 @@ export default class Router extends BaseRouter { ); } - return new Response(reqContext.response.body, { - status: reqContext.response.status() || 200, - headers: reqContext.response.headers() || {}, - }); + return await reqContext.response.build(); } add(route, callback) { diff --git a/src/Utils/Placekeeper.js b/src/Utils/Placekeeper.js index a6da1c4..ce085ba 100644 --- a/src/Utils/Placekeeper.js +++ b/src/Utils/Placekeeper.js @@ -1,51 +1,16 @@ import Logger from './Logger'; +const TOKEN_PAIRS = [ + ['{{','}}'] +] + export default class Placekeeper { static init(content) { const sections = Array(); const variables = new Map(); const currentSection = new Map(); - for (let idx = 0; idx < content.length; idx++) { - const char = content[idx]; - if (char == '{') { - if (content[idx + 1] == '{') { - if (currentSection.has('start')) currentSection.clear(); // everything before this was not a section then - // begins a new section meant to be for a variable but still can be an operational section - // @example: {{#if variableName}} {{#for variableName}} {{#while variableName}} - currentSection.set('start', idx); - } - } else if (currentSection.has('start')) { - if (char == '}') { - if (content[idx + 1] == '}') { - // ends a section - currentSection.set('end', idx + 2); - currentSection.set( - 'content', - content - .substring( - currentSection.get('start'), - currentSection.get('end') - ) - .toString() - .trim() - ); - - currentSection.set( - 'variables', - Placekeeper.parseVariables( - currentSection.get('content') - ) - ); - sections.push(new Map(currentSection)); - currentSection.clear(); - idx++; - } - } - } - } - - Logger('content to array', Placekeeper.contentToArray(content)); + console.log('content to array', Placekeeper.contentToArray(content)); return { variables, @@ -53,37 +18,47 @@ export default class Placekeeper { }; } - static contentToArray(content) { - const parts = content.split(/\{\{([^}]+)\}\}|\{(#[a-zA-Z]+)\s*([^}]+)\}/); - const result = []; - let stack = []; - function processStack() { - const block = stack.pop(); - result.push(new Function('data', 'with(data){return `'+ block.content.join('') + '`;}')); - } + static walkToEnd(content, endToken = undefined) { + let result = { + stack: [], + length: 0 + }; + for(let idx = 0; idx < content.length; idx++) { + const char = content[idx]; + const token = TOKEN_PAIRS.find((t) => { + if(!t[0].startsWith(char)) return false; + return content.substring(idx, idx + t[0].length) == t[0]; + }); - // Process each part of the split content - for (let i = 0; i < parts.length; i++) { - if (parts[i] === '#' && ['for', 'if', 'else', 'elseif', 'while'].includes(parts[i + 1])) { - // Start of control structure - stack.push({ type: parts[i + 1], content: [] }); - i += 2; // Skip the control structure identifier - } else if (parts[i] === '/#' && ['for', 'if', 'while'].includes(stack[stack.length - 1].type)) { - // End of control structure - i++; - processStack(); - } else if (stack.length > 0) { - // Inside a control structure - stack[stack.length - 1].content.push(parts[i]); - } else if (parts[i] !== undefined && parts[i] !== '#end') { - // Directly executable expression or static string - result.push(parts[i].startsWith('\n') ? '\n' + parts[i].trimStart() : parts[i].trimEnd()); + if(token) { + // found new start token walk to end: + idx += token[0].length + const subset = Placekeeper.walkToEnd(content.substring(idx), token[1]); + + // is a subset convertable to a function? + // can't we convert the whole tree to one function so its only one call instead of iteration on runtime? + + result.stack.push(subset.stack); + result.length += subset.length; + idx += subset.length + token[1].length - 1; + } else { + if(endToken && endToken.startsWith(char) && content.substring(idx, idx + endToken.length)) { + // found end token + return result; + } + + if(result.stack.length == 0 || Array.isArray(result.stack[result.stack.length - 1])) result.stack.push(''); + result.stack[result.stack.length - 1] += char; + result.length += char.length; } } - return result; } + static contentToArray(content) { + return Placekeeper.walkToEnd(content); + } + static variableRegex = new RegExp(/[\w\d]/, 'i'); static parseVariables(content) { @@ -109,6 +84,8 @@ export default class Placekeeper { currentVar = null; } } + + return variables; } static parse(content, sections) { diff --git a/src/Views/Html.js b/src/Views/Html.js index ad20d5f..3314518 100644 --- a/src/Views/Html.js +++ b/src/Views/Html.js @@ -29,6 +29,7 @@ export default class HtmlEngine { }); return { + mimeType: file.type || 'text/html', parse: (data = null, options = {}) => this.parse(filepath, data, options), }; @@ -50,17 +51,11 @@ export default class HtmlEngine { let parsed = Placekeeper.parse(view.content, view.sections); for (const [key, value] of Object.entries(data)) { parsed = parsed.replace( - new RegExp(`{{${key}}}`, 'g'), + new RegExp(`{{\s?${key}\s?}}`, 'g'), value.toString() ); } - return new Response(parsed, { - ...(options || {}), - headers: { - ...(options.headers || {}), - 'Content-Type': view.mimeType, - 'Content-Length': view.size, - }, - }); + + return parsed; } } diff --git a/src/Views/Markdown.js b/src/Views/Markdown.js index a532098..cd6b9fe 100644 --- a/src/Views/Markdown.js +++ b/src/Views/Markdown.js @@ -34,7 +34,7 @@ export default class MarkdownEngine { ['sections', sections], ['variables', variables], ['metadata', metadata], - ['mimeType', file.type || 'text/html'], + ['mimeType', file.type || 'text/markdown'], ['size', file.size], ]) ); @@ -42,6 +42,7 @@ export default class MarkdownEngine { console.log(filepath, this.#internalCache.get(filepath)); return { + mimeType: file.type || 'text/markdown', parse: (data = null, options = {}) => this.parse(filepath, data, options), };