@nasriya/hypercloud
Version:
Nasriya HyperCloud is a lightweight Node.js HTTP2 framework.
368 lines (367 loc) • 19.6 kB
JavaScript
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const helpers_1 = __importDefault(require("../../utils/helpers"));
const ejs_1 = __importDefault(require("ejs"));
class Renderer {
#_request;
#_rendering;
#_page;
#_assetsBaseUrl;
#_rendered = '';
#_data = {
title: null,
description: undefined,
favicon: undefined,
thumbnail: undefined,
keywords: [],
stylesheets: [],
scripts: [],
lang: null,
dir: 'ltr',
locals: {}
};
constructor(req, name) {
this.#_request = req;
this.#_rendering = req.server.rendering;
this.#_assetsBaseUrl = this.#_rendering.assetsBaseUrl;
// Make sure the page name exist
this.#_page = this.#_rendering.pages.storage[name];
if (!this.#_page) {
throw `The page (${name}) template is not defined`;
}
}
#_helpers = {
getLocals: (locals = {}) => {
const mainLocals = { ...this.#_request.server.rendering.assets.locals.get(this.#_data.lang), ...this.#_page.locals.get(this.#_data.lang) };
return { ...mainLocals, ...(helpers_1.default.is.realObject(locals) ? locals : {}), lang: this.#_data.lang, dir: this.#_data.dir };
},
validateRenderingOptions: (options) => {
this.#_data.title = (() => {
if (helpers_1.default.is.validString(options?.title)) {
return options?.title;
}
else {
const pageTitle = this.#_page.title.get(this.#_data.lang);
const siteName = this.#_rendering.siteName.get(this.#_data.lang);
return siteName ? `${pageTitle} | ${siteName}` : pageTitle;
}
})();
this.#_data.description = (() => {
if (helpers_1.default.is.validString(options?.description)) {
return options?.description;
}
else {
const desc = this.#_page.description.get(this.#_data.lang);
if (helpers_1.default.is.validString(desc)) {
return desc;
}
}
})();
this.#_data.favicon = helpers_1.default.is.validString(options?.favicon) ? options?.favicon : undefined;
this.#_data.thumbnail = helpers_1.default.is.validString(options?.thumbnail) ? options?.thumbnail : undefined;
this.#_data.keywords = (() => {
if (typeof options?.keywords === 'string' && helpers_1.default.is.validString(options?.keywords)) {
return Array.from(new Set(options.keywords.split(',')));
}
else if (Array.isArray(options?.keywords)) {
return Array.from(new Set(options.keywords));
}
else {
return [];
}
})();
this.#_data.stylesheets = [
...this.#_rendering.assets.stylesheets.get().map(sheet => `<link rel="stylesheet" href="${sheet.scope === 'Internal' ? `${this.#_assetsBaseUrl}/global/css/${sheet.fileName}` : sheet.url}">`),
...this.#_page.stylesheets.get().map(sheet => `<link rel="stylesheet" href="${sheet.scope === 'Internal' ? `${this.#_assetsBaseUrl}/pages/${this.#_page._id}/${sheet.fileName}` : sheet.url}">`)
];
const stringifyScript = (script, resourceScope = 'Global') => {
switch (script.scope) {
case 'OnPage': {
return `<script${script.nomodule ? ' nomodule' : ''}>${script.content}</script>`;
}
case 'Internal': {
const attrs = [
script.async ? 'async' : '',
script.crossorigin ? `crossorigin=${script.crossorigin}` : '',
script.defer ? 'defer' : '',
script.nomodule ? 'nomodule' : '',
script.referrerpolicy ? `referrerpolicy=${script.referrerpolicy}` : '',
script.type ? `type="${script.type}"` : ''
].filter(i => i.length > 0);
const src = resourceScope === 'Page' ? `${this.#_assetsBaseUrl}/pages/${this.#_page._id}/${script.fileName}` : `${this.#_assetsBaseUrl}/global/js/${script.fileName}`;
return `<script src="${src}"${attrs.length > 0 ? ` ${attrs.join(' ')}` : ''}></script>`;
}
case 'External': {
const attrs = [
script.async ? 'async' : '',
script.crossorigin ? `crossorigin=${script.crossorigin}` : '',
script.defer ? 'defer' : '',
script.nomodule ? 'nomodule' : '',
script.referrerpolicy ? `referrerpolicy=${script.referrerpolicy}` : '',
script.type ? `type="${script.type}"` : ''
].filter(i => i.length > 0);
return `<script src="${script.src}${attrs.length > 0 ? ` ${attrs.join(' ')}` : ''}"></script>`;
}
}
};
this.#_data.scripts = [
...this.#_rendering.assets.scripts.get().map(script => stringifyScript(script)),
...this.#_page.scripts.get().map(script => stringifyScript(script, 'Page'))
];
this.#_data.locals = this.#_helpers.getLocals();
},
render: {
page: () => {
const template = this.#_page.template.content.get();
const locals = this.#_helpers.getLocals();
return this.#_helpers.render.withIncludes(template, locals);
},
component: async (name, locals) => {
const component = this.#_rendering.components.storage[name];
if (!component) {
throw new Error(`The page "${this.#_page.name}" has an undefined component "${name}" in its template`);
}
const stylesheet = component.stylesheet.get();
if (stylesheet) {
const href = `${this.#_assetsBaseUrl}/components/${component.name}/${stylesheet.fileName}`;
const existingStylesheet = this.#_data.stylesheets.find(i => i.includes(href));
if (!existingStylesheet) {
this.#_data.stylesheets.push(`<link rel="stylesheet" href="${href}">`);
}
}
const script = component.script.get();
if (script) {
const src = `${this.#_assetsBaseUrl}/components/${component.name}/${script.fileName}`;
const existingScript = this.#_data.scripts.find(i => i.includes(src));
if (!existingScript) {
const attrs = [
script.async ? 'async' : '',
script.crossorigin ? `crossorigin=${script.crossorigin}` : '',
script.defer ? 'defer' : '',
script.nomodule ? 'nomodule' : '',
script.referrerpolicy ? `referrerpolicy=${script.referrerpolicy}` : '',
script.type ? `type="${script.type}"` : ''
].map(i => i.length > 0);
const tag = `<script src="${src}"${attrs.length > 0 ? ` ${attrs.join(' ')}` : ''}></script>`;
this.#_data.scripts.push(tag);
}
}
const componentLocals = component.locals.get(this.#_data.lang);
const mainLocals = (() => {
if (Object.keys(componentLocals).length > 0) {
return componentLocals;
}
else if (!helpers_1.default.is.undefined(locals)) {
return locals;
}
else {
return {};
}
})();
// if (name === 'socialBar') {
// console.log('socialBar locals:', mainLocals);
// console.log({ passedLocals: locals, componentLocals, mainLocals })
// }
const handler = component.onRender.get();
if (typeof handler === 'function') {
const templateContent = handler(mainLocals, this.#_helpers.render.component, this.#_data.lang);
if (typeof templateContent === 'string') {
return this.#_helpers.render.withIncludes(templateContent, mainLocals);
}
else {
if (templateContent && typeof templateContent?.then === 'function') {
const content = await templateContent.then();
if (typeof content === 'string') {
return this.#_helpers.render.withIncludes(content, mainLocals);
}
}
throw new Error(`The onRender function of the component (${component.name}) does did not return a string value`);
}
}
else {
const templateContent = component.template.content.get();
return this.#_helpers.render.withIncludes(templateContent, mainLocals);
}
},
withIncludes: async (template, locals) => {
try {
const includeRegex = /<%-\s*include\(['"](.+?)['"](, ?(.+?))?\)\s*%>/g;
let match;
let result = '';
let lastIndex = 0;
const renderInclude = async (match, componentName, _, args) => {
let includedLocals;
if (args) {
try {
includedLocals = eval(`(${args})`);
}
catch (error) {
console.error('Failed to evaluate arguments for include:', args, error);
includedLocals = {};
}
}
else {
includedLocals = locals;
}
return this.#_helpers.render.component(componentName, includedLocals);
};
while ((match = includeRegex.exec(template)) !== null) {
const [fullMatch, componentName, , args] = match;
result += template.slice(lastIndex, match.index);
lastIndex = includeRegex.lastIndex;
try {
result += await renderInclude(fullMatch, componentName, null, args);
}
catch (error) {
console.error('Error in renderInclude:', error);
throw error;
}
}
result += template.slice(lastIndex);
return ejs_1.default.render(result, locals);
}
catch (error) {
throw error;
}
}
},
html: {
/**Check the document's `<html>` tag and add the `lang` and `dir` attributes */
check: () => {
const doctype = '<!DOCTYPE html>';
const hasDocType = this.#_rendered.substring(0, doctype.length).toLowerCase().startsWith(doctype.toLowerCase());
if (!hasDocType) {
this.#_rendered = `${doctype}\n${this.#_rendered}`;
}
const htmlTagStartIndex = this.#_rendered.indexOf('<html');
const hasHTML = htmlTagStartIndex > -1;
const newHTMLTag = `<html lang="${this.#_data.lang}" dir="${this.#_data.dir}" theme=${this.#_request.colorScheme}>`;
if (hasHTML) {
const fromHtml = this.#_rendered.substring(htmlTagStartIndex);
const htmlTag = fromHtml.substring(htmlTagStartIndex, fromHtml.indexOf('>') + 1);
this.#_rendered = this.#_rendered.replace(htmlTag, newHTMLTag);
}
else {
this.#_rendered = `${this.#_rendered.substring(0, `${doctype}\n`.length)}${newHTMLTag}\n${this.#_rendered.replace(`${doctype}\n`, '')}\n</html>`;
}
},
head: {
/**Check the document's `<head>` tag and add it if necessary */
check: () => {
const htmlTagStartIndex = this.#_rendered.indexOf('<html');
const fromHtml = this.#_rendered.substring(htmlTagStartIndex);
const htmlTag = fromHtml.substring(htmlTagStartIndex, fromHtml.indexOf('>') + 1);
let headStart = this.#_rendered.indexOf('<head>');
if (headStart === -1) {
this.#_rendered = this.#_rendered.replace(htmlTag, `${htmlTag}\n<head>`);
headStart = this.#_rendered.indexOf('<head>');
const bodyStart = this.#_rendered.indexOf('<body>');
if (bodyStart === -1) {
this.#_rendered = this.#_rendered.replace('<head>', '<head>\n</head>\n<body>\n</body>');
}
else {
this.#_rendered = this.#_rendered.replace('<body>', '</head>\n<body>');
}
}
},
content: () => {
const headStartIndex = this.#_rendered.indexOf('<head>');
const headEndIndex = this.#_rendered.indexOf('</head>');
// Extract existing head elements to avoid duplications
const headContent = this.#_rendered.substring(headStartIndex + '<head>'.length, headEndIndex);
const existingHeadElements = new Set();
headContent.replace(/<\/?(title|meta|link|script)[^>]*?>/gi, (match) => {
existingHeadElements.add(match);
return match;
});
// Return the existing content
return existingHeadElements;
},
update: () => {
const headStartIndex = this.#_rendered.indexOf('<head>') + '<head>'.length;
const headEndIndex = this.#_rendered.indexOf('</head>');
const existingHeadElements = this.#_helpers.html.head.content();
const metaTags = [...this.#_rendering.assets.metaTags.get(), ...this.#_page.metaTags.get()].map(meta => {
const attrs = [];
for (const prop in meta.attributes) {
const value = meta.attributes[prop];
attrs.push(`${prop}${helpers_1.default.is.validString(value) ? `="${value}"` : ''}`);
}
return attrs.length > 0 ? `<meta ${attrs.join(' ')}>` : undefined;
}).filter(i => i !== undefined);
// Generate new head elements
const newHeadElements = [
`<meta charset="utf-8">`,
`<meta name="viewport" content="width=device-width, initial-scale=1.0">`,
`<title>${this.#_data.title}</title>`,
this.#_data.description ? `<meta name="description" content="${this.#_data.description}">` : '',
this.#_data.favicon ? `<link rel="icon" href="${this.#_data.favicon}">` : '',
this.#_data.thumbnail ? `<meta property="og:image" content="${this.#_data.thumbnail}">` : '',
this.#_data.keywords.length ? `<meta name="keywords" content="${this.#_data.keywords.join(', ')}">` : '',
...metaTags,
...this.#_data.stylesheets,
...this.#_data.scripts
];
// Tags to ignore
const tagsToIgnore = [
'<meta charset="utf-8">',
'<meta name="viewport" content="width=device-width, initial-scale=1.0">',
'<title>'
];
// Remove existing duplicates and keep new values
const filteredNewHeadElements = newHeadElements.filter(element => {
const tagName = element.match(/<\/?(title|meta|link|script)[^>]*?>/i);
if (tagName && tagName[0]) {
const regex = new RegExp(`<${tagName[0]}[^>]*?>`, 'i');
existingHeadElements.forEach(existingElement => {
if (regex.test(existingElement) && !tagsToIgnore.some(tag => existingElement.startsWith(tag))) {
existingHeadElements.delete(existingElement);
}
});
}
return true;
});
// Combine existing and new head elements
const combinedHeadElements = [
...Array.from(existingHeadElements),
...filteredNewHeadElements
].filter(Boolean).join('\n');
// Update the head section
this.#_rendered = this.#_rendered.slice(0, headStartIndex) + combinedHeadElements + this.#_rendered.slice(headEndIndex);
}
}
}
};
/**
* Render the page
* @param {PageRenderingOptions} options A `key:value` pairs object for variables
* @returns {string} The rendered `HTML` page
*/
async render(options) {
try {
if (!this.#_rendered) {
this.#_data.lang = this.#_request.language;
this.#_data.dir = this.#_data.lang === 'ar' || this.#_data.lang === 'he' ? 'rtl' : 'ltr';
this.#_helpers.validateRenderingOptions(options);
this.#_rendered = await this.#_helpers.render.page();
this.#_helpers.html.check();
this.#_helpers.html.head.check();
this.#_helpers.html.head.update();
}
return this.#_rendered;
}
catch (error) {
if (typeof error === 'string') {
error = `Failed to render ${this.#_page.name}: ${error}`;
}
if (error instanceof Error) {
error.message = `Failed to render ${this.#_page.name}: ${error.message}`;
}
throw error;
}
}
}
exports.default = Renderer;
;