@seip/blue-bird 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/template.js CHANGED
@@ -1,220 +1,283 @@
1
- import ejs from "ejs";
2
- import path from "node:path";
3
- import fs from "node:fs";
4
- import Config from "./config.js";
5
- import Logger from "./logger.js";
6
-
7
- const __dirname = Config.dirname();
8
- const props = Config.props();
9
-
10
- /**
11
- * Template engine wrapper using EJS, providing helper functions and React island support.
12
- */
13
- class Template {
14
- /**
15
- * Renders an EJS template with the provided context and helper functions.
16
- * @param {string} template - The template name (without .ejs extension).
17
- * @param {Object} [context={}] - Data to pass to the template.
18
- * @param {import('express').Response} res - The Express response object.
19
- * @returns {Promise<void>}
20
- */
21
- static async render(template, context = {}, res) {
22
- try {
23
- const templatePath = path.join(__dirname, "templates", `${template}.ejs`);
24
-
25
- const helpers = {
26
- asset: (file) => this.asset(file),
27
- url: (p = "") => this.url(p),
28
- react: (component, props = {}) => this.react(component, props),
29
- vite_assets: () => this.vite_assets()
30
- };
31
-
32
- const fullContext = {
33
- ...props,
34
- ...helpers,
35
- ...context
36
- };
37
-
38
- const html = await ejs.renderFile(templatePath, fullContext);
39
- const minifiedHtml = this.minifyHtml(html);
40
-
41
- res.send(minifiedHtml);
42
- } catch (error) {
43
- const logger = new Logger();
44
- logger.error(`Template render error: ${error.message}`);
45
-
46
- if (props.debug) {
47
- res.status(500).send(`<pre>${error.stack}</pre>`);
48
- } else {
49
- res.status(500).send("Internal Server Error");
50
- }
51
- }
52
- }
53
-
54
- /**
55
- * Generates a URL for a static asset.
56
- * @param {string} file - The asset path.
57
- * @returns {string} The full asset URL.
58
- */
59
- static asset(file) {
60
- return `${props.host}:${props.port}/public/${file.replace(/^\//, "")}`;
61
- }
62
-
63
- /**
64
- * Generates a full URL for the given path.
65
- * @param {string} [p=""] - The relative path.
66
- * @returns {string} The full URL.
67
- */
68
- static url(p = "") {
69
- const cleanPath = p.replace(/^\//, "");
70
- return `${props.host}:${props.port}/${cleanPath}`;
71
- }
72
-
73
- /**
74
- * Renders a React component as an HTML string.
75
- * @param {string} component - The React component name.
76
- * @param {Object} [componentProps={}] - Props to pass to the component.
77
- * @options {Object} options - Options for the template.
78
- * @returns {string} The HTML string of the React component.
79
- * @example
80
- * const options = {
81
- * head: [
82
- * { tag: "meta", attrs: { name: "description", content: "Description" } },
83
- * { tag: "link", attrs: { rel: "stylesheet", href: "style.css" } }
84
- * ],
85
- * classBody: "bg-gray-100",
86
- * linkStyles: [
87
- * { href: "style.css" }
88
- * ],
89
- * scriptScripts: [
90
- * { src: "script.js" }
91
- * ]
92
- * };
93
- *
94
- * Template.renderReact(res, "App", { title: "Example title" }, options);
95
- */
96
- static renderReact(res, component = "App", propsReact = {}, options = {}) {
97
- const optionsHead = options.head || [];
98
- const classBody = options.classBody || "";
99
- const linkStyles = options.linkStyles || [];
100
- const scriptScripts = options.scriptScripts || [];
101
- const html = `
102
- <!DOCTYPE html>
103
- <html lang="${this.escapeHtml(props.langMeta)}">
104
- <head>
105
- <meta charset="UTF-8">
106
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
107
- <title>${this.escapeHtml(props.titleMeta)}</title>
108
- <link rel="icon" href="favicon.ico" />
109
- <meta name="description" content="${this.escapeHtml(props.descriptionMeta)}"/>
110
- <meta name="keywords" content="${this.escapeHtml(props.keywordsMeta)}"/>
111
- <meta name="author" content="${this.escapeHtml(props.authorMeta)}"/>
112
- ${linkStyles.map(item => `<link rel="stylesheet" href="${item.href}" />`).join("")}
113
- ${scriptScripts.map(item => `<script src="${item.src}"></script>`).join("")}
114
- ${optionsHead.map(item => `<${item.tag} ${Object.entries(item.attrs).map(([key, value]) => `${key}="${value}"`).join(" ")} />`).join("")}
115
- </head>
116
- <body class="${classBody}">
117
- ${this.react(component, propsReact)}
118
- ${this.vite_assets()}
119
- </body>
120
- </html>
121
- `
122
- res.type("text/html");
123
- res.status(200);
124
- return res.send(this.minifyHtml(html));
125
- }
126
-
127
- /**
128
- * Generates a container for a React island component.
129
- * @param {string} component - The React component name.
130
- * @param {Object} [componentProps={}] - Props to pass to the component.
131
- * @returns {string} The HTML container with data attributes for hydration.
132
- */
133
- static react(component, componentProps = {}, divId = "root") {
134
- const id = divId || `react-${Math.random().toString(36).substr(2, 9)}`;
135
- const propsJson = JSON.stringify(componentProps).replace(/'/g, "&apos;");
136
- return `<div id="${id}" data-react-component="${component}" data-props='${propsJson}'></div>`;
137
- }
138
-
139
- /**
140
- * Generates script and link tags for Vite-managed assets.
141
- * Handles both development (Vite dev server) and production (manifest.json) modes.
142
- * @returns {string} The HTML tags for scripts and styles.
143
- */
144
- static vite_assets() {
145
-
146
- if (props.debug) {
147
- return `
148
- <script type="module">
149
- import RefreshRuntime from "http://localhost:5173/build/@react-refresh";
150
- RefreshRuntime.injectIntoGlobalHook(window);
151
- window.$RefreshReg$ = () => {};
152
- window.$RefreshSig$ = () => (type) => type;
153
- window.__vite_plugin_react_preamble_installed__ = true;
154
- </script>
155
- <script type="module" src="http://localhost:5173/build/@vite/client"></script>
156
- <script type="module" src="http://localhost:5173/build/Main.jsx"></script>`;
157
- }
158
-
159
- const buildPath = path.join(__dirname, props.static.path, "build");
160
- let manifestPath = path.join(buildPath, "manifest.json");
161
-
162
-
163
- if (!fs.existsSync(manifestPath)) {
164
- manifestPath = path.join(buildPath, ".vite", "manifest.json");
165
- }
166
-
167
- if (fs.existsSync(manifestPath)) {
168
- try {
169
- const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
170
- const entry = manifest["Main.jsx"];
171
- if (entry) {
172
- const file = entry.file;
173
- const css = entry.css || [];
174
-
175
- let html = "";
176
- css.forEach(cssFile => {
177
- html += `<link rel="stylesheet" href="/build/${cssFile}">`;
178
- });
179
- html += `<script type="module" src="/build/${file}"></script>`;
180
-
181
- return html;
182
- }
183
- } catch (e) {
184
- return `<!-- Error parsing Vite manifest: ${e.message} -->`;
185
- }
186
- }
187
-
188
- return `<!-- Vite Manifest not found at ${manifestPath} -->`;
189
- }
190
-
191
-
192
-
193
- /**
194
- * Minifies the HTML output by removing comments and excessive whitespace.
195
- * @param {string} html - The raw HTML string.
196
- * @returns {string} The minified HTML string.
197
- */
198
- static minifyHtml(html) {
199
- return html
200
- .replace(/<!--(?!\[if).*?-->/gs, "")
201
- .replace(/>\s+</g, "><")
202
- .replace(/\s{2,}/g, " ")
203
- .trim();
204
- }
205
- /**
206
- * Escapes HTML special characters in a string to prevent XSS attacks.
207
- * @param {string} str - The input string.
208
- * @returns {string} The escaped string.
209
- */
210
- static escapeHtml(str = "") {
211
- return String(str)
212
- .replace(/&/g, "&amp;")
213
- .replace(/</g, "&lt;")
214
- .replace(/>/g, "&gt;")
215
- .replace(/"/g, "&quot;")
216
- .replace(/'/g, "&#39;");
217
- }
218
- }
219
-
220
- export default Template;
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ import Config from "./config.js";
4
+ import Logger from "./logger.js";
5
+
6
+ const __dirname = Config.dirname();
7
+ const props = Config.props();
8
+
9
+ const TEMPLATE_PATH = path.join(__dirname, "frontend", "index.html");
10
+ const BASE_TEMPLATE = fs.readFileSync(TEMPLATE_PATH, "utf-8");
11
+ let CACHE_TEMPLATE = {};
12
+
13
+ /**
14
+ * Lightweight HTML template renderer optimized for SPA environments.
15
+ */
16
+ class Template {
17
+
18
+ /**
19
+ * Renders the base HTML template for a React application using
20
+ * string placeholder replacement and optional in-memory caching.
21
+ *
22
+ * This method injects:
23
+ * - The root React component name
24
+ * - Serialized component props
25
+ * - SEO meta tags
26
+ * - Custom <head> tags
27
+ * - Stylesheets
28
+ * - Scripts (head and body)
29
+ * - Vite assets
30
+ *
31
+ * It supports basic HTML escaping, optional minification,
32
+ * and template caching per component.
33
+ *
34
+ * @static
35
+ * @method renderReact
36
+ *
37
+ * @param {import('express').Response} res
38
+ * Express response object used to send the generated HTML.
39
+ *
40
+ * @param {string} [component="App"]
41
+ * The root React component name to bootstrap on the client.
42
+ * This value replaces the `__COMPONENT__` placeholder in the template.
43
+ *
44
+ * @param {Object<string, any>} [componentProps={}]
45
+ * Props passed to the root React component.
46
+ * These are serialized and injected into the template
47
+ * via the `__PROPS__` placeholder.
48
+ *
49
+ * @param {Object} [options={}]
50
+ * Rendering configuration options.
51
+ *
52
+ * @param {string} [options.langHtml="en"]
53
+ * Value for the `<html lang="">` attribute.
54
+ * Falls back to metaTags.langMeta if available.
55
+ *
56
+ * @param {string} [options.classBody="body"]
57
+ * CSS class applied to the `<body>` tag.
58
+ *
59
+ * @param {Array<{tag:string, attrs:Object<string,string>}>} [options.head=[]]
60
+ * Additional custom tags injected into `<head>`.
61
+ * Example:
62
+ * `{ tag: "meta", attrs: { name: "description", content: "Example" } }`
63
+ *
64
+ * @param {Array<{href:string}>} [options.linkStyles=[]]
65
+ * Stylesheets injected as `<link rel="stylesheet" />` tags.
66
+ *
67
+ * @param {Array<{src:string}>} [options.scriptsInHead=[]]
68
+ * Script files injected inside `<head>`.
69
+ *
70
+ * @param {Array<{src:string}>} [options.scriptsInBody=[]]
71
+ * Script files injected before `</body>`.
72
+ *
73
+ * @param {boolean} [options.cache=true]
74
+ * Enables in-memory caching of the generated HTML
75
+ * per component name to improve performance.
76
+ *
77
+ * @param {Object} [options.metaTags]
78
+ * SEO metadata configuration.
79
+ *
80
+ * @param {string} [options.metaTags.titleMeta]
81
+ * Content for the `<title>` tag.
82
+ *
83
+ * @param {string} [options.metaTags.descriptionMeta]
84
+ * Content for `<meta name="description">`.
85
+ *
86
+ * @param {string} [options.metaTags.keywordsMeta]
87
+ * Content for `<meta name="keywords">`.
88
+ *
89
+ * @param {string} [options.metaTags.authorMeta]
90
+ * Content for `<meta name="author">`.
91
+ *
92
+ * @param {string} [options.metaTags.langMeta]
93
+ * Alternative language metadata value.
94
+ *
95
+ * @returns {void}
96
+ * Sends a complete HTML response to the client.
97
+ *
98
+ * @throws {Error}
99
+ * If template rendering fails, a 500 response is returned.
100
+ *
101
+ * @example
102
+ * const options = {
103
+ * cache: true,
104
+ * classBody: "bg-gray-100",
105
+ * head: [
106
+ * { tag: "meta", attrs: { name: "robots", content: "index, follow" } }
107
+ * ],
108
+ * linkStyles: [
109
+ * { href: "/css/style.css" }
110
+ * ],
111
+ * scriptsInHead: [
112
+ * { src: "/js/head.js" }
113
+ * ],
114
+ * scriptsInBody: [
115
+ * { src: "/js/body.js" }
116
+ * ],
117
+ * metaTags: {
118
+ * titleMeta: "Example Title",
119
+ * descriptionMeta: "Example description",
120
+ * keywordsMeta: "express, react, framework",
121
+ * authorMeta: "Blue Bird",
122
+ * langMeta: "en"
123
+ * }
124
+ * };
125
+ *
126
+ * Template.renderReact(res, "App", { title: "Hello World" }, options);
127
+ */
128
+ static renderReact(res, component = "App", componentProps = {}, options = {}) {
129
+ try {
130
+ const {
131
+ langHtml = options.langHtml || props.langMeta || "en",
132
+ classBody = "body",
133
+ head = [],
134
+ linkStyles = [],
135
+ scriptsInHead = [],
136
+ scriptsInBody = [],
137
+ cache = true,
138
+ metaTags = {
139
+ titleMeta: options.metaTags?.titleMeta || props.titleMeta,
140
+ descriptionMeta: options.metaTags?.descriptionMeta || props.descriptionMeta,
141
+ keywordsMeta: options.metaTags?.keywordsMeta || props.keywordsMeta,
142
+ authorMeta: options.metaTags?.authorMeta || props.authorMeta,
143
+ langMeta: options.metaTags?.langMeta || props.langMeta,
144
+ },
145
+ } = options;
146
+
147
+ res.type("text/html");
148
+ res.status(200);
149
+
150
+ if (cache && CACHE_TEMPLATE[component]) {
151
+ return res.send(CACHE_TEMPLATE[component]);
152
+ }
153
+
154
+ const title = this.escapeHtml(metaTags.titleMeta || "");
155
+ const description = this.escapeHtml(metaTags.descriptionMeta || "");
156
+ const keywords = this.escapeHtml(metaTags.keywordsMeta || "");
157
+ const author = this.escapeHtml(metaTags.authorMeta || "");
158
+
159
+ const headOptions = head
160
+ .map(item => `<${item.tag} ${Object.entries(item.attrs).map(([k, v]) => `${k}="${v}"`).join(" ")} />`)
161
+ .join("");
162
+
163
+ const linkTags = linkStyles
164
+ .map(item => `<link rel="stylesheet" href="${item.href}" />`)
165
+ .join("");
166
+
167
+ const scriptsHeadTags = scriptsInHead
168
+ .map(item => `<script src="${item.src}"></script>`)
169
+ .join("");
170
+
171
+ const scriptsBodyTags = scriptsInBody
172
+ .map(item => `<script src="${item.src}"></script>`)
173
+ .join("");
174
+
175
+ const propsJson = JSON.stringify(componentProps).replace(/'/g, "&#39;");
176
+
177
+ let html = BASE_TEMPLATE
178
+ .replace(/__LANG__/g, this.escapeHtml(langHtml))
179
+ .replace(/__TITLE__/g, title)
180
+ .replace(/__DESCRIPTION__/g, description)
181
+ .replace(/__KEYWORDS__/g, keywords)
182
+ .replace(/__AUTHOR__/g, author)
183
+ .replace(/__HEAD_OPTIONS__/g, headOptions)
184
+ .replace(/__LINK_STYLES__/g, linkTags)
185
+ .replace(/__SCRIPTS_HEAD__/g, scriptsHeadTags)
186
+ .replace(/__CLASS_BODY__/g, classBody)
187
+ .replace(/__COMPONENT__/g, component)
188
+ .replace(/__PROPS__/g, propsJson)
189
+ .replace(/__VITE_ASSETS__/g, this.vite_assets())
190
+ .replace(/__SCRIPTS_BODY__/g, scriptsBodyTags);
191
+
192
+ html = this.minifyHtml(html);
193
+ CACHE_TEMPLATE[component] = html;
194
+ return res.send(html);
195
+
196
+ } catch (error) {
197
+ const logger = new Logger();
198
+ logger.error(`Template render error: ${error.message}`);
199
+
200
+ if (props.debug) {
201
+ console.log(error)
202
+ return res.status(500).send(`<pre>${error.stack}</pre>`);
203
+ }
204
+
205
+ return res.status(500).send("Internal Server Error");
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Generates Vite asset tags depending on environment.
211
+ * @returns {string}
212
+ */
213
+ static vite_assets() {
214
+
215
+ if (props.debug) {
216
+ return `
217
+ <script type="module">
218
+ import RefreshRuntime from "http://localhost:5173/build/@react-refresh";
219
+ RefreshRuntime.injectIntoGlobalHook(window);
220
+ window.$RefreshReg$ = () => {};
221
+ window.$RefreshSig$ = () => (type) => type;
222
+ window.__vite_plugin_react_preamble_installed__ = true;
223
+ </script>
224
+ <script type="module" src="http://localhost:5173/build/@vite/client"></script>
225
+ <script type="module" src="http://localhost:5173/build/Main.jsx"></script>`;
226
+ }
227
+
228
+ const buildPath = path.join(__dirname, props.static.path, "build");
229
+ let manifestPath = path.join(buildPath, "manifest.json");
230
+
231
+ if (!fs.existsSync(manifestPath)) {
232
+ manifestPath = path.join(buildPath, ".vite", "manifest.json");
233
+ }
234
+
235
+ if (fs.existsSync(manifestPath)) {
236
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
237
+ const entry = manifest["Main.jsx"];
238
+
239
+ if (entry) {
240
+ const file = entry.file;
241
+ const css = entry.css || [];
242
+
243
+ let html = "";
244
+ css.forEach(cssFile => {
245
+ html += `<link rel="stylesheet" href="/build/${cssFile}">`;
246
+ });
247
+ html += `<script type="module" src="/build/${file}"></script>`;
248
+ return html;
249
+ }
250
+ }
251
+
252
+ return "";
253
+ }
254
+
255
+ /**
256
+ * Minifies HTML output.
257
+ * @param {string} html
258
+ * @returns {string}
259
+ */
260
+ static minifyHtml(html) {
261
+ return html
262
+ .replace(/<!--(?!\[if).*?-->/gs, "")
263
+ .replace(/>\s+</g, "><")
264
+ .replace(/\s{2,}/g, " ")
265
+ .trim();
266
+ }
267
+
268
+ /**
269
+ * Escapes HTML special characters.
270
+ * @param {string} str
271
+ * @returns {string}
272
+ */
273
+ static escapeHtml(str = "") {
274
+ return String(str)
275
+ .replace(/&/g, "&amp;")
276
+ .replace(/</g, "&lt;")
277
+ .replace(/>/g, "&gt;")
278
+ .replace(/"/g, "&quot;")
279
+ .replace(/'/g, "&#39;");
280
+ }
281
+ }
282
+
283
+ export default Template;