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 {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}`);
});

View File

@@ -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;
}
}
}

View File

@@ -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<string|null>} - 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');
}
}

View File

@@ -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]);
}
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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;
}
}

View File

@@ -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),
};