feat(server) layout handling; placekeeper wip

This commit is contained in:
gh0sTedBuddy
2024-03-28 07:07:35 +00:00
parent b6503f1831
commit 0e0b51fa24
8 changed files with 223 additions and 200 deletions

View File

@@ -1,6 +1,7 @@
import { LayServer } from '../src'; import { LayServer } from '../src';
import {resolve} from 'path'; import {resolve} from 'path';
import Html from '../src/Views/Html'; import Html from '../src/Views/Html';
import Markdown from '../src/Views/Markdown';
const APP_PORT = Bun.env.APP_PORT ?? Bun.env.PORT ?? 3000; const APP_PORT = Bun.env.APP_PORT ?? Bun.env.PORT ?? 3000;
@@ -36,6 +37,7 @@ class InfoController {
const server = new LayServer({ const server = new LayServer({
viewsPath: resolve(__dirname, 'views'), viewsPath: resolve(__dirname, 'views'),
layout: 'blank.html',
loaders: new Map([[ loaders: new Map([[
'html', new Html() 'html', new Html()
]]), ]]),
@@ -43,7 +45,7 @@ const server = new LayServer({
function Main(server) { function Main(server) {
server.get('/', async (req, res) => { server.get('/', async (req, res) => {
return res.json({message: 'hello world'}) return res.json({message: 'hello world'})
}, { domain: 'api.tipedi.local' }); }, { domain: 'example.layc.dev' });
server.all('/data', (req, res) => { server.all('/data', (req, res) => {
let respBody = `your ${req.method} request was well recieved`; let respBody = `your ${req.method} request was well recieved`;
switch (req.method) { switch (req.method) {
@@ -86,6 +88,14 @@ server.get('/', async (req, res) => {
return res.render('index'); 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, () => { server.listen(APP_PORT, () => {
console.log(`server listen on ${APP_PORT}`); console.log(`server listen on ${APP_PORT}`);
}); });

View File

@@ -1,37 +1,96 @@
import MimeTypeMap from './MimeTypeMap'; import MimeTypeMap from './MimeTypeMap';
export default class LayResponse { export default class LayResponse {
routing = undefined; #routing = undefined;
body; #body = 'Not Implemented';
statusCode; #file = undefined;
headerList = new Map(); #redirectUrl = undefined;
mimeType; #statusCode = 502;
options = { #headerList = new Map();
#mimeType = 'text/plain';
#options = {
disableFileCheck: false, disableFileCheck: false,
}; };
constructor(routing) { constructor(routing) {
this.routing = routing; this.#routing = routing;
this.body = 'Not Implemented';
this.statusCode = 502;
this.headerList = new Map();
this.mimeType = 'text/plain';
} }
async send(str) { type(key) {
this.type('text'); if (!MimeTypeMap.has(key)) {
this.body = str; 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 { try {
const temp = Bun.file(str); const temp = Bun.file(arguments[0]);
if (await temp.exists()) { if (await temp.exists()) {
this.file = str; this.#file = temp;
} }
} catch (err) {} } 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 = {}) { json(data, options = {}) {
let { replacer, space } = options; let { replacer, space } = options;
if (!Array.isArray(replacer) && typeof replacer != 'function') { if (!Array.isArray(replacer) && typeof replacer != 'function') {
@@ -41,72 +100,30 @@ export default class LayResponse {
space = 0; space = 0;
} }
this.type('json'); this.type('json');
this.body = JSON.stringify(data, replacer, space); 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(),
});
} }
async render(path, data = {}) { async render(path, data = {}) {
const loader = await this.routing.getServer().load(path); const loader = await this.#routing.getServer().load(path);
const content = await loader.parse(data, { const content = await loader.parse(data);
status: this.status(),
headers: this.headers(),
});
return content; 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;
}
}
} }

View File

@@ -14,12 +14,13 @@ import Logger from './Utils/Logger';
* @class * @class
*/ */
export default class LayServer extends BaseRouter { export default class LayServer extends BaseRouter {
controllers = new Map(); #controllers = new Map();
loaders = new Map(); #loaders = new Map();
proc = null; #proc = null;
publicFiles; #publicFiles;
routing = null; #routing = null;
options = new Map([ #options = new Map([
['layout', undefined],
['loaders', undefined], ['loaders', undefined],
['publicDir', undefined], ['publicDir', undefined],
['viewsPath', undefined], ['viewsPath', undefined],
@@ -40,15 +41,15 @@ export default class LayServer extends BaseRouter {
const optionKeys = Object.getOwnPropertyNames(options); const optionKeys = Object.getOwnPropertyNames(options);
for (const key of optionKeys) { for (const key of optionKeys) {
if (!this.options.has(key)) continue; if (!this.#options.has(key)) continue;
if (key == 'loaders') continue; if (key == 'loaders') continue;
const valueType = typeof this.options.get(key); const valueType = typeof this.#options.get(key);
if ( if (
typeof options[key] !== 'undefined' && typeof options[key] !== 'undefined' &&
(typeof options[key] == valueType || (typeof options[key] == valueType ||
valueType == 'undefined') valueType == 'undefined')
) )
this.options.set(key, options[key]); this.#options.set(key, options[key]);
} }
if (options.hasOwnProperty('loaders')) { 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) { if(options.router) {
this.routing.addRouter(options.router); this.#routing.addRouter(options.router);
} }
if (options.hasOwnProperty('controllers')) { if (options.hasOwnProperty('controllers')) {
@@ -72,13 +73,13 @@ export default class LayServer extends BaseRouter {
if ( if (
!options.publicFiles && !options.publicFiles &&
this.options.discoverPublicFiles && this.#options.discoverPublicFiles &&
this.options.publicDir this.#options.publicDir
) )
this.indexPublicFiles(this.options.publicDir); this.indexPublicFiles(this.#options.publicDir);
if (!this.options.viewsEngine && this.options.viewsPath) { if (!this.#options.viewsEngine && this.#options.viewsPath) {
this.options.viewsEngine = new HtmlEngine(); 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. * @returns {*} - The current value of the option, or undefined if the option does not exist.
*/ */
option(key, value = undefined) { 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); 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( throw new Error(
'publicFiles option has to be an array or undefined' 'publicFiles option has to be an array or undefined'
); );
if (!this.publicFiles || !Array.isArray(this.publicFiles)) if (!this.#publicFiles || !Array.isArray(this.#publicFiles))
this.publicFiles = []; this.#publicFiles = [];
for (let path of filePaths) { for (let path of filePaths) {
if (typeof path != 'string') continue; 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 the given controller is just an anonymous function defining the routes itself
if (!controller?.prototype?.constructor?.name) { if (!controller?.prototype?.constructor?.name) {
controller(this.routing); controller(this.#routing);
continue; continue;
} }
this.controllers.set( this.#controllers.set(
controller?.name, controller?.name,
this.instantiateController(controller) this.instantiateController(controller)
); );
@@ -156,9 +157,9 @@ export default class LayServer extends BaseRouter {
* @returns {Object} - The instantiated controller. * @returns {Object} - The instantiated controller.
*/ */
instantiateController(controller) { instantiateController(controller) {
this.routing.setNamespace(controller.namespace); this.#routing.setNamespace(controller.namespace);
const _temp = new controller(this.routing); const _temp = new controller(this.#routing);
if (controller.namespace) this.routing.setNamespace(null); if (controller.namespace) this.#routing.setNamespace(null);
return _temp; return _temp;
} }
@@ -181,10 +182,10 @@ export default class LayServer extends BaseRouter {
if ( if (
!url.pathname.endsWith('/') && !url.pathname.endsWith('/') &&
Array.isArray(this.publicFiles) && Array.isArray(this.#publicFiles) &&
this.publicFiles.length > 0 this.#publicFiles.length > 0
) { ) {
let path = this.publicFiles.find( let path = this.#publicFiles.find(
(v) => url.pathname.substring(1) === v (v) => url.pathname.substring(1) === v
); );
if (path) { if (path) {
@@ -192,7 +193,7 @@ export default class LayServer extends BaseRouter {
return new Response( return new Response(
Bun.file( Bun.file(
resolvePath( resolvePath(
this.options.get('publicDir') ?? this.#options.get('publicDir') ??
`${import.meta.dir}/public`, `${import.meta.dir}/public`,
path 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); 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; if (resp instanceof Response) return resp;
return new Response('Not Found', { status: 404 }); return new Response('Not Found', { status: 404 });
} }
@@ -254,7 +255,7 @@ export default class LayServer extends BaseRouter {
* @returns {Promise<string|null>} - The path of the view file, or null if not found. * @returns {Promise<string|null>} - The path of the view file, or null if not found.
*/ */
async getViewPath(filepath) { async getViewPath(filepath) {
let path = this.options.get('viewsPath'); let path = this.#options.get('viewsPath');
if (!path.endsWith('/')) path += '/'; if (!path.endsWith('/')) path += '/';
const filename = filepath.split('/').pop().toString(); const filename = filepath.split('/').pop().toString();
@@ -290,7 +291,7 @@ export default class LayServer extends BaseRouter {
const fullFilePath = await this.getViewPath(filepath); const fullFilePath = await this.getViewPath(filepath);
if (!fullFilePath) return new DefaultLoader(filepath); if (!fullFilePath) return new DefaultLoader(filepath);
const filename = fullFilePath.split('/').pop().toString(); 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) { if (this.loaders.size > 0) {
for (const keys of this.loaders.keys()) { for (const keys of this.loaders.keys()) {
@@ -302,7 +303,21 @@ export default class LayServer extends BaseRouter {
return new DefaultLoader(filepath); return new DefaultLoader(filepath);
} }
async add(path, cb) { add(path, cb) {
this.routing.add(...arguments) 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');
} }
} }

View File

@@ -97,4 +97,8 @@ export default class LayRouting extends BaseRouter {
this.routers.get(options.domain).add (path, cb, options); this.routers.get(options.domain).add (path, cb, options);
} }
} }
use (cb) {
this.add('*', cb, arguments[1]);
}
} }

View File

@@ -33,12 +33,16 @@ export default class Router extends BaseRouter {
if (path.endsWith('/')) path = path.substring(0, path.length - 1); if (path.endsWith('/')) path = path.substring(0, path.length - 1);
let currentNode = this.routes; let currentNode = this.routes;
let fallbackNode = currentNode.has('*') ? currentNode.get('*') : undefined;
let paramsList = []; let paramsList = [];
if (path != '') { if (path != '') {
let segments = path.split('/'); let segments = path.split('/');
for (let index = 0; index < segments.length; index++) { for (let index = 0; index < segments.length; index++) {
const segment = segments[index]; const segment = segments[index];
if (currentNode.has('*')) {
fallbackNode = currentNode.get('*');
}
if (currentNode.has(segment)) { if (currentNode.has(segment)) {
currentNode = currentNode.get(segment); currentNode = currentNode.get(segment);
@@ -51,7 +55,7 @@ export default class Router extends BaseRouter {
continue; continue;
} }
currentNode = null; currentNode = fallbackNode ?? null;
break; break;
} }
} }
@@ -98,21 +102,24 @@ export default class Router extends BaseRouter {
try { try {
await reqContext.request.parseBody(); await reqContext.request.parseBody();
const retVal = await handle.callback( let retVal = await handle.callback(
reqContext.request, reqContext.request,
reqContext.response reqContext.response
); );
if (typeof retVal != 'undefined') { if (typeof retVal != 'undefined') {
if (retVal instanceof Response) { if (retVal instanceof Promise) {
retVal = await retVal;
} else if (retVal instanceof Response) {
return retVal; return retVal;
} else if (typeof retVal == 'string') { } else if (typeof retVal == 'string') {
reqContext.response.send(retVal); await reqContext.response.text(retVal);
} else { } else {
reqContext.response.json(retVal); reqContext.response.json(retVal);
} }
} }
} catch (err) { } catch (err) {
Logger(err); Logger(err);
console.log(err);
return new Response('Error', { status: 500 }); return new Response('Error', { status: 500 });
} }
@@ -123,10 +130,7 @@ export default class Router extends BaseRouter {
); );
} }
return new Response(reqContext.response.body, { return await reqContext.response.build();
status: reqContext.response.status() || 200,
headers: reqContext.response.headers() || {},
});
} }
add(route, callback) { add(route, callback) {

View File

@@ -1,51 +1,16 @@
import Logger from './Logger'; import Logger from './Logger';
const TOKEN_PAIRS = [
['{{','}}']
]
export default class Placekeeper { export default class Placekeeper {
static init(content) { static init(content) {
const sections = Array(); const sections = Array();
const variables = new Map(); const variables = new Map();
const currentSection = new Map(); const currentSection = new Map();
for (let idx = 0; idx < content.length; idx++) { console.log('content to array', Placekeeper.contentToArray(content));
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));
return { return {
variables, variables,
@@ -53,37 +18,47 @@ export default class Placekeeper {
}; };
} }
static contentToArray(content) { static walkToEnd(content, endToken = undefined) {
const parts = content.split(/\{\{([^}]+)\}\}|\{(#[a-zA-Z]+)\s*([^}]+)\}/); let result = {
const result = []; stack: [],
let stack = []; length: 0
function processStack() { };
const block = stack.pop(); for(let idx = 0; idx < content.length; idx++) {
result.push(new Function('data', 'with(data){return `'+ block.content.join('') + '`;}')); 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 if(token) {
for (let i = 0; i < parts.length; i++) { // found new start token walk to end:
if (parts[i] === '#' && ['for', 'if', 'else', 'elseif', 'while'].includes(parts[i + 1])) { idx += token[0].length
// Start of control structure const subset = Placekeeper.walkToEnd(content.substring(idx), token[1]);
stack.push({ type: parts[i + 1], content: [] });
i += 2; // Skip the control structure identifier // is a subset convertable to a function?
} else if (parts[i] === '/#' && ['for', 'if', 'while'].includes(stack[stack.length - 1].type)) { // can't we convert the whole tree to one function so its only one call instead of iteration on runtime?
// End of control structure
i++; result.stack.push(subset.stack);
processStack(); result.length += subset.length;
} else if (stack.length > 0) { idx += subset.length + token[1].length - 1;
// Inside a control structure } else {
stack[stack.length - 1].content.push(parts[i]); if(endToken && endToken.startsWith(char) && content.substring(idx, idx + endToken.length)) {
} else if (parts[i] !== undefined && parts[i] !== '#end') { // found end token
// Directly executable expression or static string return result;
result.push(parts[i].startsWith('\n') ? '\n' + parts[i].trimStart() : parts[i].trimEnd()); }
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; return result;
} }
static contentToArray(content) {
return Placekeeper.walkToEnd(content);
}
static variableRegex = new RegExp(/[\w\d]/, 'i'); static variableRegex = new RegExp(/[\w\d]/, 'i');
static parseVariables(content) { static parseVariables(content) {
@@ -109,6 +84,8 @@ export default class Placekeeper {
currentVar = null; currentVar = null;
} }
} }
return variables;
} }
static parse(content, sections) { static parse(content, sections) {

View File

@@ -29,6 +29,7 @@ export default class HtmlEngine {
}); });
return { return {
mimeType: file.type || 'text/html',
parse: (data = null, options = {}) => parse: (data = null, options = {}) =>
this.parse(filepath, data, options), this.parse(filepath, data, options),
}; };
@@ -50,17 +51,11 @@ export default class HtmlEngine {
let parsed = Placekeeper.parse(view.content, view.sections); let parsed = Placekeeper.parse(view.content, view.sections);
for (const [key, value] of Object.entries(data)) { for (const [key, value] of Object.entries(data)) {
parsed = parsed.replace( parsed = parsed.replace(
new RegExp(`{{${key}}}`, 'g'), new RegExp(`{{\s?${key}\s?}}`, 'g'),
value.toString() value.toString()
); );
} }
return new Response(parsed, {
...(options || {}), return parsed;
headers: {
...(options.headers || {}),
'Content-Type': view.mimeType,
'Content-Length': view.size,
},
});
} }
} }

View File

@@ -34,7 +34,7 @@ export default class MarkdownEngine {
['sections', sections], ['sections', sections],
['variables', variables], ['variables', variables],
['metadata', metadata], ['metadata', metadata],
['mimeType', file.type || 'text/html'], ['mimeType', file.type || 'text/markdown'],
['size', file.size], ['size', file.size],
]) ])
); );
@@ -42,6 +42,7 @@ export default class MarkdownEngine {
console.log(filepath, this.#internalCache.get(filepath)); console.log(filepath, this.#internalCache.get(filepath));
return { return {
mimeType: file.type || 'text/markdown',
parse: (data = null, options = {}) => parse: (data = null, options = {}) =>
this.parse(filepath, data, options), this.parse(filepath, data, options),
}; };