@seip/blue-bird 0.4.4 → 0.4.6

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.
Files changed (53) hide show
  1. package/.env_example +26 -25
  2. package/AGENTS.md +199 -199
  3. package/LICENSE +21 -0
  4. package/README.md +79 -79
  5. package/backend/index.js +13 -13
  6. package/backend/routes/api.js +31 -31
  7. package/backend/routes/frontend.js +41 -41
  8. package/backend/routes/seo.js +39 -39
  9. package/core/app.js +328 -325
  10. package/core/auth.js +114 -83
  11. package/core/cache.js +44 -44
  12. package/core/cli/component.js +42 -42
  13. package/core/cli/init.js +119 -118
  14. package/core/cli/react.js +435 -435
  15. package/core/cli/route.js +42 -42
  16. package/core/cli/scaffolding-auth.js +1037 -0
  17. package/core/config.js +48 -47
  18. package/core/debug.js +248 -248
  19. package/core/logger.js +100 -100
  20. package/core/middleware.js +27 -27
  21. package/core/router.js +333 -333
  22. package/core/seo.js +95 -100
  23. package/core/swagger.js +40 -25
  24. package/core/template.js +472 -462
  25. package/core/upload.js +76 -76
  26. package/core/validate.js +380 -380
  27. package/frontend/index.html +26 -26
  28. package/frontend/landing.html +69 -69
  29. package/frontend/resources/css/tailwind.css +17 -17
  30. package/frontend/resources/js/App.jsx +70 -70
  31. package/frontend/resources/js/Main.jsx +18 -18
  32. package/frontend/resources/js/blue-bird/components/Button.jsx +67 -67
  33. package/frontend/resources/js/blue-bird/components/Card.jsx +18 -18
  34. package/frontend/resources/js/blue-bird/components/DataTable.jsx +126 -126
  35. package/frontend/resources/js/blue-bird/components/Input.jsx +21 -21
  36. package/frontend/resources/js/blue-bird/components/Label.jsx +12 -12
  37. package/frontend/resources/js/blue-bird/components/LanguageButton.jsx +23 -23
  38. package/frontend/resources/js/blue-bird/components/Link.jsx +15 -15
  39. package/frontend/resources/js/blue-bird/components/Modal.jsx +27 -27
  40. package/frontend/resources/js/blue-bird/components/Skeleton.jsx +44 -44
  41. package/frontend/resources/js/blue-bird/components/Translate.jsx +12 -12
  42. package/frontend/resources/js/blue-bird/components/Typography.jsx +69 -69
  43. package/frontend/resources/js/blue-bird/contexts/LanguageContext.jsx +41 -41
  44. package/frontend/resources/js/blue-bird/contexts/SPAContext.jsx +239 -237
  45. package/frontend/resources/js/blue-bird/contexts/SnackbarContext.jsx +38 -38
  46. package/frontend/resources/js/blue-bird/contexts/ThemeContext.jsx +49 -49
  47. package/frontend/resources/js/blue-bird/locales/en.json +47 -47
  48. package/frontend/resources/js/blue-bird/locales/es.json +47 -47
  49. package/frontend/resources/js/components/Header.jsx +55 -55
  50. package/frontend/resources/js/pages/About.jsx +31 -31
  51. package/frontend/resources/js/pages/Home.jsx +82 -82
  52. package/package.json +57 -57
  53. package/vite.config.js +22 -22
package/core/template.js CHANGED
@@ -1,462 +1,472 @@
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
- let BASE_TEMPLATE = null;
11
- let CACHE_TEMPLATE = {};
12
-
13
- /**
14
- * Loads the base HTML template lazily on first use.
15
- * Prevents crash at boot if frontend/index.html doesn't exist yet.
16
- * @returns {string} The base HTML template contents.
17
- */
18
- function getBaseTemplate() {
19
- if (BASE_TEMPLATE === null) {
20
- if (!fs.existsSync(TEMPLATE_PATH)) {
21
- Logger.error(
22
- `Template file not found: ${TEMPLATE_PATH}. Run 'npm run create-react-app' to create it.`,
23
- );
24
- return "";
25
- }
26
- BASE_TEMPLATE = fs.readFileSync(TEMPLATE_PATH, "utf-8");
27
- }
28
- return BASE_TEMPLATE;
29
- }
30
-
31
- /**
32
- * Checks if the current request is a frontend SPA navigation request.
33
- * @param {import('express').Response} res - Express response object.
34
- * @returns {boolean}
35
- */
36
- function isSPARequest(res) {
37
- const req = res.req;
38
- if (!req) return false;
39
- return (
40
- req.query?.source === "frontend" ||
41
- req.headers?.["x-blue-bird-spa"] === "true"
42
- );
43
- }
44
-
45
- /**
46
- * Generates a stable cache key from parts, filtering out empty values.
47
- * @param {string} prefix - Cache key prefix.
48
- * @param {Object} metaTags - The meta tags object.
49
- * @returns {string}
50
- */
51
- function buildCacheKey(prefix, metaTags) {
52
- const parts = [
53
- prefix,
54
- metaTags.titleMeta || "_",
55
- metaTags.descriptionMeta || "_",
56
- metaTags.langMeta || "_",
57
- metaTags.ogImage || "_",
58
- ];
59
- return parts.join("|");
60
- }
61
-
62
- /**
63
- * Lightweight HTML template renderer optimized for SPA environments.
64
- */
65
- class Template {
66
- /**
67
- * Renders the base HTML template for a React application.
68
- * Supports SPA mode: when `?source=frontend` is detected, returns JSON with meta/props.
69
- *
70
- * @static
71
- * @method renderReact
72
- * @param {import('express').Response} res - Express response object.
73
- * @param {string} [component="App"] - Root React component name.
74
- * @param {Object} [componentProps={}] - Props for the React component.
75
- * @param {Object} [options={}] - Rendering configuration.
76
- */
77
- static renderReact(
78
- res,
79
- component = "App",
80
- componentProps = {},
81
- options = {},
82
- ) {
83
- try {
84
- let {
85
- langHtml = options.langHtml || props.langMeta || "en",
86
- classBody = "body",
87
- head = [],
88
- linkStyles = [],
89
- scriptsInHead = [],
90
- scriptsInBody = [],
91
- cache = true,
92
- metaTags = {},
93
- skeleton = true,
94
- } = options;
95
-
96
- const metaTagsDefault = {
97
- titleMeta: props.titleMeta,
98
- descriptionMeta: props.descriptionMeta,
99
- keywordsMeta: props.keywordsMeta,
100
- authorMeta: props.authorMeta,
101
- langMeta: props.langMeta,
102
- ogImage: "",
103
- ogType: "website",
104
- twitterCard: "summary_large_image",
105
- };
106
-
107
- metaTags = { ...metaTagsDefault, ...metaTags };
108
-
109
- if (metaTags.langMeta && !options.langHtml) {
110
- langHtml = metaTags.langMeta;
111
- }
112
-
113
- // SPA mode: return JSON instead of HTML
114
- if (isSPARequest(res)) {
115
- return res.json({
116
- meta: {
117
- titleMeta: metaTags.titleMeta || "",
118
- descriptionMeta: metaTags.descriptionMeta || "",
119
- keywordsMeta: metaTags.keywordsMeta || "",
120
- authorMeta: metaTags.authorMeta || "",
121
- ogImage: metaTags.ogImage || "",
122
- ogType: metaTags.ogType || "website",
123
- twitterCard: metaTags.twitterCard || "summary_large_image",
124
- },
125
- props: componentProps.props || componentProps,
126
- component: component,
127
- lang: langHtml,
128
- });
129
- }
130
-
131
- res.type("text/html");
132
- res.status(200);
133
-
134
- const cacheKey = buildCacheKey(`react:${component}`, metaTags);
135
- if (!props.debug && cache && CACHE_TEMPLATE[cacheKey]) {
136
- return res.send(CACHE_TEMPLATE[cacheKey]);
137
- }
138
-
139
- const baseTemplate = getBaseTemplate();
140
- if (!baseTemplate) {
141
- return res.status(500).send("Template not found");
142
- }
143
-
144
- const title = this.escapeHtml(metaTags.titleMeta || "");
145
- const description = this.escapeHtml(metaTags.descriptionMeta || "");
146
- const keywords = this.escapeHtml(metaTags.keywordsMeta || "");
147
- const author = this.escapeHtml(metaTags.authorMeta || "");
148
- const ogImage = this.escapeHtml(metaTags.ogImage || "");
149
- const ogType = this.escapeHtml(metaTags.ogType || "website");
150
- const twitterCard = this.escapeHtml(
151
- metaTags.twitterCard || "summary_large_image",
152
- );
153
-
154
- const headOptions = head
155
- .map(
156
- (item) =>
157
- `<${item.tag} ${Object.entries(item.attrs)
158
- .map(([k, v]) => `${k}="${v}"`)
159
- .join(" ")} />`,
160
- )
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
- const stylesSkeleton = skeleton
177
- ? `<style>${this.skeletonStyles()}</style>`
178
- : "";
179
- const skeletonHtml = skeleton ? this.skeletonHtml() : "";
180
-
181
- const ogTags = `
182
- <meta property="og:title" content="${title}" />
183
- <meta property="og:description" content="${description}" />
184
- <meta property="og:type" content="${ogType}" />
185
- ${ogImage ? `<meta property="og:image" content="${ogImage}" />` : ""}
186
- <meta name="twitter:card" content="${twitterCard}" />
187
- <meta name="twitter:title" content="${title}" />
188
- <meta name="twitter:description" content="${description}" />
189
- ${ogImage ? `<meta name="twitter:image" content="${ogImage}" />` : ""}
190
- `;
191
-
192
- let html = baseTemplate
193
- .replace(/__LANG__/g, this.escapeHtml(langHtml))
194
- .replace(/__TITLE__/g, title)
195
- .replace(/__DESCRIPTION__/g, description)
196
- .replace(/__KEYWORDS__/g, keywords)
197
- .replace(/__AUTHOR__/g, author)
198
- .replace(/__HEAD_OPTIONS__/g, headOptions + ogTags)
199
- .replace(/__LINK_STYLES__/g, linkTags)
200
- .replace(/__SCRIPTS_HEAD__/g, scriptsHeadTags)
201
- .replace(/__CLASS_BODY__/g, classBody)
202
- .replace(/__COMPONENT__/g, component)
203
- .replace(/__PROPS__/g, propsJson)
204
- .replace(/__VITE_ASSETS__/g, this.vite_assets())
205
- .replace(/__SCRIPTS_BODY__/g, scriptsBodyTags)
206
- .replace(/__STYLES_SKELETON__/g, stylesSkeleton)
207
- .replace(/__SKELETON__/g, skeletonHtml);
208
-
209
- html = this.minifyHtml(html);
210
- if (cache && !props.debug) CACHE_TEMPLATE[cacheKey] = html;
211
- return res.send(html);
212
- } catch (error) {
213
- Logger.error(`Template render error: ${error.message}`);
214
- return res.status(500).send("Internal Server Error");
215
- }
216
- }
217
-
218
- /**
219
- * Renders an HTML file or raw content with SEO support and caching.
220
- * Supports SPA mode: when `?source=frontend` is detected, returns JSON.
221
- *
222
- * @static
223
- * @method renderHtml
224
- * @param {import('express').Response} res - Express response object.
225
- * @param {string} templateOrContent - File name (in frontend/) or HTML string.
226
- * @param {Object} [options={}] - Configuration options.
227
- */
228
- static renderHtml(res, templateOrContent = "", options = {}) {
229
- try {
230
- const {
231
- langHtml = "en",
232
- classBody = "body",
233
- head = [],
234
- linkStyles = [],
235
- scriptsInHead = [],
236
- scriptsInBody = [],
237
- cache = true,
238
- metaTags = {},
239
- withAssets = false,
240
- replace = true,
241
- } = options;
242
-
243
- // SPA mode for renderHtml
244
- if (isSPARequest(res)) {
245
- return res.json({
246
- meta: {
247
- titleMeta: metaTags.titleMeta || props.titleMeta || "",
248
- descriptionMeta:
249
- metaTags.descriptionMeta || props.descriptionMeta || "",
250
- keywordsMeta: metaTags.keywordsMeta || props.keywordsMeta || "",
251
- authorMeta: metaTags.authorMeta || props.authorMeta || "",
252
- },
253
- props: {},
254
- component: null,
255
- lang: langHtml,
256
- });
257
- }
258
-
259
- let html = "";
260
- const isFile =
261
- !templateOrContent.includes("<") && templateOrContent.length < 100;
262
-
263
- if (isFile) {
264
- const filePath = path.join(
265
- __dirname,
266
- "frontend",
267
- `${templateOrContent}.html`,
268
- );
269
- const fileCacheKey = `file:${templateOrContent}`;
270
- if (cache && CACHE_TEMPLATE[fileCacheKey]) {
271
- html = CACHE_TEMPLATE[fileCacheKey];
272
- } else if (fs.existsSync(filePath)) {
273
- html = fs.readFileSync(filePath, "utf-8");
274
- if (cache) CACHE_TEMPLATE[fileCacheKey] = html;
275
- } else {
276
- html = templateOrContent;
277
- }
278
- } else {
279
- html = templateOrContent;
280
- }
281
-
282
- res.type("text/html");
283
-
284
- const cacheKey = buildCacheKey(
285
- `html:${templateOrContent}`,
286
- metaTags,
287
- );
288
- if (!props.debug && cache && CACHE_TEMPLATE[cacheKey]) {
289
- return res.send(CACHE_TEMPLATE[cacheKey]);
290
- }
291
-
292
- if (replace) {
293
- const title = this.escapeHtml(
294
- metaTags.titleMeta || props.titleMeta || "",
295
- );
296
- const description = this.escapeHtml(
297
- metaTags.descriptionMeta || props.descriptionMeta || "",
298
- );
299
- const keywords = this.escapeHtml(
300
- metaTags.keywordsMeta || props.keywordsMeta || "",
301
- );
302
- const author = this.escapeHtml(
303
- metaTags.authorMeta || props.authorMeta || "",
304
- );
305
- const ogImage = this.escapeHtml(metaTags.ogImage || "");
306
- const ogType = this.escapeHtml(metaTags.ogType || "website");
307
- const twitterCard = this.escapeHtml(
308
- metaTags.twitterCard || "summary_large_image",
309
- );
310
-
311
- const headOptions = head
312
- .map(
313
- (item) =>
314
- `<${item.tag} ${Object.entries(item.attrs)
315
- .map(([k, v]) => `${k}="${v}"`)
316
- .join(" ")} />`,
317
- )
318
- .join("");
319
-
320
- const ogTags = `
321
- <meta property="og:title" content="${title}" />
322
- <meta property="og:description" content="${description}" />
323
- <meta property="og:type" content="${ogType}" />
324
- ${ogImage ? `<meta property="og:image" content="${ogImage}" />` : ""}
325
- <meta name="twitter:card" content="${twitterCard}" />
326
- <meta name="twitter:title" content="${title}" />
327
- <meta name="twitter:description" content="${description}" />
328
- ${ogImage ? `<meta name="twitter:image" content="${ogImage}" />` : ""}
329
- `;
330
-
331
- html = html
332
- .replace(/__LANG__/g, this.escapeHtml(langHtml))
333
- .replace(/__TITLE__/g, title)
334
- .replace(/__DESCRIPTION__/g, description)
335
- .replace(/__KEYWORDS__/g, keywords)
336
- .replace(/__AUTHOR__/g, author)
337
- .replace(/__HEAD_OPTIONS__/g, headOptions + ogTags)
338
- .replace(/__CLASS_BODY__/g, classBody)
339
- .replace(/__VITE_ASSETS__/g, withAssets ? this.vite_assets() : "")
340
- .replace(/__STYLES_SKELETON__/g, "");
341
-
342
- if (html.includes("__LINK_STYLES__")) {
343
- const linkTags = linkStyles
344
- .map((item) => `<link rel="stylesheet" href="${item.href}" />`)
345
- .join("");
346
- html = html.replace(/__LINK_STYLES__/g, linkTags);
347
- }
348
- if (html.includes("__SCRIPTS_HEAD__")) {
349
- const scriptsHeadTags = scriptsInHead
350
- .map((item) => `<script src="${item.src}"></script>`)
351
- .join("");
352
- html = html.replace(/__SCRIPTS_HEAD__/g, scriptsHeadTags);
353
- }
354
- if (html.includes("__SCRIPTS_BODY__")) {
355
- const scriptsBodyTags = scriptsInBody
356
- .map((item) => `<script src="${item.src}"></script>`)
357
- .join("");
358
- html = html.replace(/__SCRIPTS_BODY__/g, scriptsBodyTags);
359
- }
360
- }
361
-
362
- html = this.minifyHtml(html);
363
- if (cache && !props.debug) CACHE_TEMPLATE[cacheKey] = html;
364
- res.send(html);
365
- } catch (error) {
366
- Logger.error(`Error rendering HTML template: ${error.message}`);
367
- res.status(500).send("Internal Server Error");
368
- }
369
- }
370
-
371
- static vite_assets() {
372
- if (props.debug) {
373
- return `
374
- <script type="module">
375
- import RefreshRuntime from "http://localhost:5173/build/@react-refresh";
376
- RefreshRuntime.injectIntoGlobalHook(window);
377
- window.$RefreshReg$ = () => {};
378
- window.$RefreshSig$ = () => (type) => type;
379
- window.__vite_plugin_react_preamble_installed__ = true;
380
- </script>
381
- <script type="module" src="http://localhost:5173/build/@vite/client"></script>
382
- <script type="module" src="http://localhost:5173/build/Main.jsx"></script>`;
383
- }
384
-
385
- const buildPath = path.join(__dirname, props.static.path, "build");
386
- let manifestPath = path.join(buildPath, "manifest.json");
387
- if (!fs.existsSync(manifestPath))
388
- manifestPath = path.join(buildPath, ".vite", "manifest.json");
389
-
390
- if (fs.existsSync(manifestPath)) {
391
- const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
392
- const entry = manifest["Main.jsx"];
393
- if (entry) {
394
- let html = "";
395
- (entry.css || []).forEach((cssFile) => {
396
- html += `<link rel="stylesheet" href="/build/${cssFile}">`;
397
- });
398
- html += `<script type="module" src="/build/${entry.file}"></script>`;
399
- return html;
400
- }
401
- }
402
- return "";
403
- }
404
-
405
- static minifyHtml(html) {
406
- return html
407
- .replace(/<!--(?!\[if).*?-->/gs, "")
408
- .replace(/>\s+</g, "><")
409
- .replace(/\s{2,}/g, " ")
410
- .trim();
411
- }
412
-
413
- static escapeHtml(str = "") {
414
- return String(str)
415
- .replace(/&/g, "&amp;")
416
- .replace(/</g, "&lt;")
417
- .replace(/>/g, "&gt;")
418
- .replace(/"/g, "&quot;")
419
- .replace(/'/g, "&#39;");
420
- }
421
-
422
- static skeletonStyles() {
423
- return `
424
- @keyframes sk-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
425
- .sk-animate-pulse { animation: sk-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
426
- .sk-container { min-height: 100vh; width: 100%; background-color: #f9fafb; padding: 1rem; box-sizing: border-box; }
427
- .sk-inner { display: flex; flex-direction: column; gap: 1.5rem; }
428
- .sk-header { display: flex; align-items: center; justify-content: space-between; width: 100%; margin-bottom: 1rem; }
429
- .sk-btn-text { height: 2.5rem; width: 8rem; background-color: #d1d5db; border-radius: 0.5rem; }
430
- .sk-avatar { height: 2.5rem; width: 2.5rem; background-color: #d1d5db; border-radius: 9999px; }
431
- .sk-btn { height: 2.5rem; width: 6rem; background-color: #d1d5db; border-radius: 0.5rem; }
432
- .sk-hero { height: 12rem; width: 100%; background-color: #d1d5db; border-radius: 1rem; }
433
- .sk-grid { display: grid; grid-template-columns: 1fr; gap: 1.5rem; }
434
- .sk-card { display: flex; flex-direction: column; gap: 0.75rem; }
435
- .sk-card-img { height: 10rem; width: 100%; background-color: #d1d5db; border-radius: 0.75rem; }
436
- .sk-footer { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 1rem; }
437
- .sk-text-full { height: 1rem; width: 100%; background-color: #e5e7eb; border-radius: 0.25rem; }
438
- @media (min-width: 768px) { .sk-grid { grid-template-columns: repeat(3, 1fr); } .sk-hero { height: 16rem; } }
439
- html.dark .sk-container { background-color: #0b0f19; }
440
- html.dark .sk-btn-text, html.dark .sk-avatar, html.dark .sk-btn, html.dark .sk-hero, html.dark .sk-card-img { background-color: #374151; }
441
- html.dark .sk-text-full { background-color: #1f2937; }
442
- `;
443
- }
444
-
445
- static skeletonHtml() {
446
- return `
447
- <div class="sk-container">
448
- <div class="sk-inner sk-animate-pulse">
449
- <div class="sk-header"><div class="sk-btn-text"></div><div class="flex gap-4"><div class="sk-avatar"></div><div class="sk-btn"></div></div></div>
450
- <div class="sk-hero"></div>
451
- <div class="sk-grid">
452
- <div class="sk-card"><div class="sk-card-img"></div><div class="sk-text-full"></div></div>
453
- <div class="sk-card"><div class="sk-card-img"></div><div class="sk-text-full"></div></div>
454
- <div class="sk-card"><div class="sk-card-img"></div><div class="sk-text-full"></div></div>
455
- </div>
456
- </div>
457
- </div>
458
- `;
459
- }
460
- }
461
-
462
- 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
+ const logger = new Logger();
9
+
10
+ const TEMPLATE_PATH = path.join(__dirname, "frontend", "index.html");
11
+ let BASE_TEMPLATE = null;
12
+ let CACHE_TEMPLATE = {};
13
+
14
+ /**
15
+ * Loads the base HTML template lazily on first use.
16
+ * Prevents crash at boot if frontend/index.html doesn't exist yet.
17
+ * @returns {string} The base HTML template contents.
18
+ */
19
+ function getBaseTemplate() {
20
+ if (BASE_TEMPLATE === null) {
21
+ if (!fs.existsSync(TEMPLATE_PATH)) {
22
+ logger.error(
23
+ `Template file not found: ${TEMPLATE_PATH}. Run 'npm run create-react-app' to create it.`,
24
+ );
25
+ return "";
26
+ }
27
+ BASE_TEMPLATE = fs.readFileSync(TEMPLATE_PATH, "utf-8");
28
+ }
29
+ return BASE_TEMPLATE;
30
+ }
31
+
32
+ /**
33
+ * Checks if the current request is a frontend SPA navigation request.
34
+ * @param {import('express').Response} res - Express response object.
35
+ * @returns {boolean}
36
+ */
37
+ function isSPARequest(res) {
38
+ const req = res.req;
39
+ if (!req) return false;
40
+ return (
41
+ req.query?.source === "frontend" ||
42
+ req.headers?.["x-blue-bird-spa"] === "true"
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Generates a stable cache key from parts, filtering out empty values.
48
+ * @param {string} prefix - Cache key prefix.
49
+ * @param {Object} metaTags - The meta tags object.
50
+ * @param {string} [extra=""] - Extra data to differentiate the cache (e.g., URL).
51
+ * @returns {string}
52
+ */
53
+ function buildCacheKey(prefix, metaTags, extra = "") {
54
+ const parts = [
55
+ prefix,
56
+ extra,
57
+ metaTags.titleMeta || "_",
58
+ metaTags.descriptionMeta || "_",
59
+ metaTags.langMeta || "_",
60
+ metaTags.ogImage || "_",
61
+ ];
62
+ return parts.join("|");
63
+ }
64
+
65
+ /**
66
+ * Lightweight HTML template renderer optimized for SPA environments.
67
+ */
68
+ class Template {
69
+ /**
70
+ * Renders the base HTML template for a React application.
71
+ * Supports SPA mode: when `?source=frontend` is detected, returns JSON with meta/props.
72
+ *
73
+ * @static
74
+ * @method renderReact
75
+ * @param {import('express').Response} res - Express response object.
76
+ * @param {string} [component="App"] - Root React component name.
77
+ * @param {Object} [componentProps={}] - Props for the React component.
78
+ * @param {Object} [options={}] - Rendering configuration.
79
+ */
80
+ static renderReact(
81
+ res,
82
+ component = "App",
83
+ componentProps = {},
84
+ options = {},
85
+ ) {
86
+ try {
87
+ let {
88
+ langHtml = options.langHtml || props.langMeta || "en",
89
+ classBody = "body",
90
+ head = [],
91
+ linkStyles = [],
92
+ scriptsInHead = [],
93
+ scriptsInBody = [],
94
+ cache = true,
95
+ revalidate = false,
96
+ cacheKey = null,
97
+ metaTags = {},
98
+ skeleton = true,
99
+ } = options;
100
+
101
+ const metaTagsDefault = {
102
+ titleMeta: props.titleMeta,
103
+ descriptionMeta: props.descriptionMeta,
104
+ keywordsMeta: props.keywordsMeta,
105
+ authorMeta: props.authorMeta,
106
+ langMeta: props.langMeta,
107
+ ogImage: "",
108
+ ogType: "website",
109
+ twitterCard: "summary_large_image",
110
+ };
111
+
112
+ metaTags = { ...metaTagsDefault, ...metaTags };
113
+
114
+ if (metaTags.langMeta && !options.langHtml) {
115
+ langHtml = metaTags.langMeta;
116
+ }
117
+
118
+ if (isSPARequest(res)) {
119
+ return res.json({
120
+ meta: {
121
+ titleMeta: metaTags.titleMeta || "",
122
+ descriptionMeta: metaTags.descriptionMeta || "",
123
+ keywordsMeta: metaTags.keywordsMeta || "",
124
+ authorMeta: metaTags.authorMeta || "",
125
+ ogImage: metaTags.ogImage || "",
126
+ ogType: metaTags.ogType || "website",
127
+ twitterCard: metaTags.twitterCard || "summary_large_image",
128
+ },
129
+ props: componentProps.props || componentProps,
130
+ component: component,
131
+ lang: langHtml,
132
+ });
133
+ }
134
+
135
+ res.type("text/html");
136
+ res.status(200);
137
+
138
+ const extraKey = res.req ? res.req.originalUrl : "";
139
+ const finalCacheKey = cacheKey || buildCacheKey(`react:${component}`, metaTags, extraKey);
140
+
141
+ if (!props.debug && cache && !revalidate && CACHE_TEMPLATE[finalCacheKey]) {
142
+ return res.send(CACHE_TEMPLATE[finalCacheKey]);
143
+ }
144
+
145
+ const baseTemplate = getBaseTemplate();
146
+ if (!baseTemplate) {
147
+ return res.status(500).send("Template not found");
148
+ }
149
+
150
+ const title = this.escapeHtml(metaTags.titleMeta || "");
151
+ const description = this.escapeHtml(metaTags.descriptionMeta || "");
152
+ const keywords = this.escapeHtml(metaTags.keywordsMeta || "");
153
+ const author = this.escapeHtml(metaTags.authorMeta || "");
154
+ const ogImage = this.escapeHtml(metaTags.ogImage || "");
155
+ const ogType = this.escapeHtml(metaTags.ogType || "website");
156
+ const twitterCard = this.escapeHtml(
157
+ metaTags.twitterCard || "summary_large_image",
158
+ );
159
+
160
+ const headOptions = head
161
+ .map(
162
+ (item) =>
163
+ `<${item.tag} ${Object.entries(item.attrs)
164
+ .map(([k, v]) => `${k}="${v}"`)
165
+ .join(" ")} />`,
166
+ )
167
+ .join("");
168
+
169
+ const linkTags = linkStyles
170
+ .map((item) => `<link rel="stylesheet" href="${item.href}" />`)
171
+ .join("");
172
+
173
+ const scriptsHeadTags = scriptsInHead
174
+ .map((item) => `<script src="${item.src}"></script>`)
175
+ .join("");
176
+
177
+ const scriptsBodyTags = scriptsInBody
178
+ .map((item) => `<script src="${item.src}"></script>`)
179
+ .join("");
180
+
181
+ const propsJson = JSON.stringify(componentProps).replace(/'/g, "&#39;");
182
+ const stylesSkeleton = skeleton
183
+ ? `<style>${this.skeletonStyles()}</style>`
184
+ : "";
185
+ const skeletonHtml = skeleton ? this.skeletonHtml() : "";
186
+
187
+ const ogTags = `
188
+ <meta property="og:title" content="${title}" />
189
+ <meta property="og:description" content="${description}" />
190
+ <meta property="og:type" content="${ogType}" />
191
+ ${ogImage ? `<meta property="og:image" content="${ogImage}" />` : ""}
192
+ <meta name="twitter:card" content="${twitterCard}" />
193
+ <meta name="twitter:title" content="${title}" />
194
+ <meta name="twitter:description" content="${description}" />
195
+ ${ogImage ? `<meta name="twitter:image" content="${ogImage}" />` : ""}
196
+ `;
197
+
198
+ let html = baseTemplate
199
+ .replace(/__LANG__/g, this.escapeHtml(langHtml))
200
+ .replace(/__TITLE__/g, title)
201
+ .replace(/__DESCRIPTION__/g, description)
202
+ .replace(/__KEYWORDS__/g, keywords)
203
+ .replace(/__AUTHOR__/g, author)
204
+ .replace(/__HEAD_OPTIONS__/g, headOptions + ogTags)
205
+ .replace(/__LINK_STYLES__/g, linkTags)
206
+ .replace(/__SCRIPTS_HEAD__/g, scriptsHeadTags)
207
+ .replace(/__CLASS_BODY__/g, classBody)
208
+ .replace(/__COMPONENT__/g, component)
209
+ .replace(/__PROPS__/g, propsJson)
210
+ .replace(/__VITE_ASSETS__/g, this.vite_assets())
211
+ .replace(/__SCRIPTS_BODY__/g, scriptsBodyTags)
212
+ .replace(/__STYLES_SKELETON__/g, stylesSkeleton)
213
+ .replace(/__SKELETON__/g, skeletonHtml);
214
+
215
+ html = this.minifyHtml(html);
216
+ if (cache && !props.debug) CACHE_TEMPLATE[finalCacheKey] = html;
217
+ return res.send(html);
218
+ } catch (error) {
219
+ logger.error(`Template render error: ${error.message}`);
220
+ return res.status(500).send("Internal Server Error");
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Renders an HTML file or raw content with SEO support and caching.
226
+ * Supports SPA mode: when `?source=frontend` is detected, returns JSON.
227
+ *
228
+ * @static
229
+ * @method renderHtml
230
+ * @param {import('express').Response} res - Express response object.
231
+ * @param {string} templateOrContent - File name (in frontend/) or HTML string.
232
+ * @param {Object} [options={}] - Configuration options.
233
+ */
234
+ static renderHtml(res, templateOrContent = "", options = {}) {
235
+ try {
236
+ const {
237
+ langHtml = "en",
238
+ classBody = "body",
239
+ head = [],
240
+ linkStyles = [],
241
+ scriptsInHead = [],
242
+ scriptsInBody = [],
243
+ cache = true,
244
+ revalidate = false,
245
+ cacheKey = null,
246
+ metaTags = {},
247
+ withAssets = false,
248
+ replace = true,
249
+ } = options;
250
+
251
+ if (isSPARequest(res)) {
252
+ return res.json({
253
+ meta: {
254
+ titleMeta: metaTags.titleMeta || props.titleMeta || "",
255
+ descriptionMeta:
256
+ metaTags.descriptionMeta || props.descriptionMeta || "",
257
+ keywordsMeta: metaTags.keywordsMeta || props.keywordsMeta || "",
258
+ authorMeta: metaTags.authorMeta || props.authorMeta || "",
259
+ },
260
+ props: {},
261
+ component: null,
262
+ lang: langHtml,
263
+ });
264
+ }
265
+
266
+ let html = "";
267
+ const isFile =
268
+ !templateOrContent.includes("<") && templateOrContent.length < 100;
269
+
270
+ if (isFile) {
271
+ const filePath = path.join(
272
+ __dirname,
273
+ "frontend",
274
+ `${templateOrContent}.html`,
275
+ );
276
+ const fileCacheKey = `file:${templateOrContent}`;
277
+ if (cache && CACHE_TEMPLATE[fileCacheKey]) {
278
+ html = CACHE_TEMPLATE[fileCacheKey];
279
+ } else if (fs.existsSync(filePath)) {
280
+ html = fs.readFileSync(filePath, "utf-8");
281
+ if (cache) CACHE_TEMPLATE[fileCacheKey] = html;
282
+ } else {
283
+ html = templateOrContent;
284
+ }
285
+ } else {
286
+ html = templateOrContent;
287
+ }
288
+
289
+ res.type("text/html");
290
+
291
+ const extraKey = res.req ? res.req.originalUrl : "";
292
+ const finalCacheKey = cacheKey || buildCacheKey(
293
+ `html:${templateOrContent}`,
294
+ metaTags,
295
+ extraKey
296
+ );
297
+
298
+ if (!props.debug && cache && !revalidate && CACHE_TEMPLATE[finalCacheKey]) {
299
+ return res.send(CACHE_TEMPLATE[finalCacheKey]);
300
+ }
301
+
302
+ if (replace) {
303
+ const title = this.escapeHtml(
304
+ metaTags.titleMeta || props.titleMeta || "",
305
+ );
306
+ const description = this.escapeHtml(
307
+ metaTags.descriptionMeta || props.descriptionMeta || "",
308
+ );
309
+ const keywords = this.escapeHtml(
310
+ metaTags.keywordsMeta || props.keywordsMeta || "",
311
+ );
312
+ const author = this.escapeHtml(
313
+ metaTags.authorMeta || props.authorMeta || "",
314
+ );
315
+ const ogImage = this.escapeHtml(metaTags.ogImage || "");
316
+ const ogType = this.escapeHtml(metaTags.ogType || "website");
317
+ const twitterCard = this.escapeHtml(
318
+ metaTags.twitterCard || "summary_large_image",
319
+ );
320
+
321
+ const headOptions = head
322
+ .map(
323
+ (item) =>
324
+ `<${item.tag} ${Object.entries(item.attrs)
325
+ .map(([k, v]) => `${k}="${v}"`)
326
+ .join(" ")} />`,
327
+ )
328
+ .join("");
329
+
330
+ const ogTags = `
331
+ <meta property="og:title" content="${title}" />
332
+ <meta property="og:description" content="${description}" />
333
+ <meta property="og:type" content="${ogType}" />
334
+ ${ogImage ? `<meta property="og:image" content="${ogImage}" />` : ""}
335
+ <meta name="twitter:card" content="${twitterCard}" />
336
+ <meta name="twitter:title" content="${title}" />
337
+ <meta name="twitter:description" content="${description}" />
338
+ ${ogImage ? `<meta name="twitter:image" content="${ogImage}" />` : ""}
339
+ `;
340
+
341
+ html = html
342
+ .replace(/__LANG__/g, this.escapeHtml(langHtml))
343
+ .replace(/__TITLE__/g, title)
344
+ .replace(/__DESCRIPTION__/g, description)
345
+ .replace(/__KEYWORDS__/g, keywords)
346
+ .replace(/__AUTHOR__/g, author)
347
+ .replace(/__HEAD_OPTIONS__/g, headOptions + ogTags)
348
+ .replace(/__CLASS_BODY__/g, classBody)
349
+ .replace(/__VITE_ASSETS__/g, withAssets ? this.vite_assets() : "")
350
+ .replace(/__STYLES_SKELETON__/g, "");
351
+
352
+ if (html.includes("__LINK_STYLES__")) {
353
+ const linkTags = linkStyles
354
+ .map((item) => `<link rel="stylesheet" href="${item.href}" />`)
355
+ .join("");
356
+ html = html.replace(/__LINK_STYLES__/g, linkTags);
357
+ }
358
+ if (html.includes("__SCRIPTS_HEAD__")) {
359
+ const scriptsHeadTags = scriptsInHead
360
+ .map((item) => `<script src="${item.src}"></script>`)
361
+ .join("");
362
+ html = html.replace(/__SCRIPTS_HEAD__/g, scriptsHeadTags);
363
+ }
364
+ if (html.includes("__SCRIPTS_BODY__")) {
365
+ const scriptsBodyTags = scriptsInBody
366
+ .map((item) => `<script src="${item.src}"></script>`)
367
+ .join("");
368
+ html = html.replace(/__SCRIPTS_BODY__/g, scriptsBodyTags);
369
+ }
370
+ }
371
+
372
+ html = this.minifyHtml(html);
373
+ if (cache && !props.debug) CACHE_TEMPLATE[finalCacheKey] = html;
374
+ res.send(html);
375
+ } catch (error) {
376
+ logger.error(`Error rendering HTML template: ${error.message}`);
377
+ res.status(500).send("Internal Server Error");
378
+ }
379
+ }
380
+
381
+ static vite_assets() {
382
+ if (props.debug) {
383
+ return `
384
+ <script type="module">
385
+ import RefreshRuntime from "http://localhost:5173/build/@react-refresh";
386
+ RefreshRuntime.injectIntoGlobalHook(window);
387
+ window.$RefreshReg$ = () => {};
388
+ window.$RefreshSig$ = () => (type) => type;
389
+ window.__vite_plugin_react_preamble_installed__ = true;
390
+ </script>
391
+ <script type="module" src="http://localhost:5173/build/@vite/client"></script>
392
+ <script type="module" src="http://localhost:5173/build/Main.jsx"></script>`;
393
+ }
394
+
395
+ const buildPath = path.join(__dirname, props.static.path, "build");
396
+ let manifestPath = path.join(buildPath, "manifest.json");
397
+ if (!fs.existsSync(manifestPath))
398
+ manifestPath = path.join(buildPath, ".vite", "manifest.json");
399
+
400
+ if (fs.existsSync(manifestPath)) {
401
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
402
+ const entry = manifest["Main.jsx"];
403
+ if (entry) {
404
+ let html = "";
405
+ (entry.css || []).forEach((cssFile) => {
406
+ html += `<link rel="stylesheet" href="/build/${cssFile}">`;
407
+ });
408
+ html += `<script type="module" src="/build/${entry.file}"></script>`;
409
+ return html;
410
+ }
411
+ }
412
+ return "";
413
+ }
414
+
415
+ static minifyHtml(html) {
416
+ return html
417
+ .replace(/<!--(?!\[if).*?-->/gs, "")
418
+ .replace(/>\s+</g, "><")
419
+ .replace(/\s{2,}/g, " ")
420
+ .trim();
421
+ }
422
+
423
+ static escapeHtml(str = "") {
424
+ return String(str)
425
+ .replace(/&/g, "&amp;")
426
+ .replace(/</g, "&lt;")
427
+ .replace(/>/g, "&gt;")
428
+ .replace(/"/g, "&quot;")
429
+ .replace(/'/g, "&#39;");
430
+ }
431
+
432
+ static skeletonStyles() {
433
+ return `
434
+ @keyframes sk-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
435
+ .sk-animate-pulse { animation: sk-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
436
+ .sk-container { min-height: 100vh; width: 100%; background-color: #f9fafb; padding: 1rem; box-sizing: border-box; }
437
+ .sk-inner { display: flex; flex-direction: column; gap: 1.5rem; }
438
+ .sk-header { display: flex; align-items: center; justify-content: space-between; width: 100%; margin-bottom: 1rem; }
439
+ .sk-btn-text { height: 2.5rem; width: 8rem; background-color: #d1d5db; border-radius: 0.5rem; }
440
+ .sk-avatar { height: 2.5rem; width: 2.5rem; background-color: #d1d5db; border-radius: 9999px; }
441
+ .sk-btn { height: 2.5rem; width: 6rem; background-color: #d1d5db; border-radius: 0.5rem; }
442
+ .sk-hero { height: 12rem; width: 100%; background-color: #d1d5db; border-radius: 1rem; }
443
+ .sk-grid { display: grid; grid-template-columns: 1fr; gap: 1.5rem; }
444
+ .sk-card { display: flex; flex-direction: column; gap: 0.75rem; }
445
+ .sk-card-img { height: 10rem; width: 100%; background-color: #d1d5db; border-radius: 0.75rem; }
446
+ .sk-footer { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 1rem; }
447
+ .sk-text-full { height: 1rem; width: 100%; background-color: #e5e7eb; border-radius: 0.25rem; }
448
+ @media (min-width: 768px) { .sk-grid { grid-template-columns: repeat(3, 1fr); } .sk-hero { height: 16rem; } }
449
+ html.dark .sk-container { background-color: #0b0f19; }
450
+ html.dark .sk-btn-text, html.dark .sk-avatar, html.dark .sk-btn, html.dark .sk-hero, html.dark .sk-card-img { background-color: #374151; }
451
+ html.dark .sk-text-full { background-color: #1f2937; }
452
+ `;
453
+ }
454
+
455
+ static skeletonHtml() {
456
+ return `
457
+ <div class="sk-container">
458
+ <div class="sk-inner sk-animate-pulse">
459
+ <div class="sk-header"><div class="sk-btn-text"></div><div class="flex gap-4"><div class="sk-avatar"></div><div class="sk-btn"></div></div></div>
460
+ <div class="sk-hero"></div>
461
+ <div class="sk-grid">
462
+ <div class="sk-card"><div class="sk-card-img"></div><div class="sk-text-full"></div></div>
463
+ <div class="sk-card"><div class="sk-card-img"></div><div class="sk-text-full"></div></div>
464
+ <div class="sk-card"><div class="sk-card-img"></div><div class="sk-text-full"></div></div>
465
+ </div>
466
+ </div>
467
+ </div>
468
+ `;
469
+ }
470
+ }
471
+
472
+ export default Template;