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