@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/.env_example +11 -11
- package/LICENSE +21 -21
- package/README.md +80 -80
- package/backend/index.js +12 -12
- package/backend/routes/app.js +52 -40
- package/core/app.js +182 -182
- package/core/auth.js +69 -69
- package/core/cli/component.js +42 -42
- package/core/cli/init.js +117 -116
- package/core/cli/react.js +408 -393
- package/core/cli/route.js +42 -42
- package/core/config.js +41 -41
- package/core/logger.js +80 -80
- package/core/middleware.js +27 -27
- package/core/router.js +134 -134
- package/core/template.js +283 -220
- package/core/upload.js +76 -76
- package/core/validate.js +291 -291
- package/frontend/index.html +20 -0
- package/package.json +40 -43
package/core/template.js
CHANGED
|
@@ -1,220 +1,283 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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, "'");
|
|
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, "&")
|
|
276
|
+
.replace(/</g, "<")
|
|
277
|
+
.replace(/>/g, ">")
|
|
278
|
+
.replace(/"/g, """)
|
|
279
|
+
.replace(/'/g, "'");
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export default Template;
|