@seip/blue-bird 0.6.0

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.
@@ -0,0 +1,254 @@
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
+ /** @type {Object<string, {html: string, expiry: number}>} */
11
+ const CACHE_TEMPLATE = {};
12
+
13
+ /** @type {Object<string, string>} */
14
+ const FILE_CACHE = {};
15
+
16
+ setInterval(() => {
17
+ const now = Date.now();
18
+ for (const key in CACHE_TEMPLATE) {
19
+ if (CACHE_TEMPLATE[key].expiry > 0 && CACHE_TEMPLATE[key].expiry <= now) {
20
+ delete CACHE_TEMPLATE[key];
21
+ }
22
+ }
23
+ }, 30000).unref();
24
+
25
+ /**
26
+ * Generates a stable cache key from parts, filtering out empty values.
27
+ * @param {string} prefix - The cache key prefix.
28
+ * @param {Object} metaTags - SEO metadata tags.
29
+ * @param {string} [extra=""] - Additional context for the cache key.
30
+ * @returns {string} The constructed cache key.
31
+ */
32
+ function buildCacheKey(prefix, metaTags, extra = "") {
33
+ const parts = [
34
+ prefix,
35
+ extra,
36
+ metaTags.titleMeta || "_",
37
+ metaTags.descriptionMeta || "_",
38
+ metaTags.langMeta || "_",
39
+ metaTags.ogImage || "_",
40
+ ];
41
+ return parts.join("|");
42
+ }
43
+
44
+ /**
45
+ * HTML template renderer for Express applications.
46
+ * Renders static HTML files with SEO placeholder injection and high-speed in-memory caching.
47
+ */
48
+ class Template {
49
+ /**
50
+ * Renders an HTML template file or raw HTML string with placeholder replacement and caching.
51
+ *
52
+ * @static
53
+ * @param {import('express').Response} res - Express response object.
54
+ * @param {string} templateOrContent - File name (without .html extension) or raw HTML string.
55
+ * @param {Object} [options={}] - Rendering configuration.
56
+ * @param {string} [options.langHtml="en"] - Lang attribute value for html.
57
+ * @param {string} [options.classBody="body"] - CSS class for the body element.
58
+ * @param {Array<{tag: string, attrs: Object}>} [options.head=[]] - Extra head elements to inject.
59
+ * @param {Array<{href: string}>} [options.linkStyles=[]] - Stylesheet links to inject.
60
+ * @param {Array<{src: string}>} [options.scriptsInHead=[]] - Script tags for the head.
61
+ * @param {Array<{src: string}>} [options.scriptsInBody=[]] - Script tags for the body.
62
+ * @param {boolean|number} [options.cache=60] - Cache TTL in seconds (0 or false to disable).
63
+ * @param {boolean} [options.minify=true] - Enable HTML minification.
64
+ * @param {string|null} [options.cacheKey=null] - Custom cache key.
65
+ * @param {Object} [options.metaTags={}] - SEO metadata tags.
66
+ * @returns {void}
67
+ */
68
+ static render(res, templateOrContent = "", options = {}) {
69
+ try {
70
+ const {
71
+ langHtml = "en",
72
+ cache = 60,
73
+ minify = true,
74
+ cacheKey = null,
75
+ metaTags = {},
76
+ } = options;
77
+
78
+ res.type("text/html");
79
+
80
+ const extraKey = res.req ? res.req.originalUrl : "";
81
+ const finalCacheKey =
82
+ cacheKey || buildCacheKey(`html:${templateOrContent}`, metaTags, extraKey);
83
+
84
+ const cacheDuration = typeof cache === "number" ? cache : (cache ? 60 : 0);
85
+ const isCacheEnabled = !props.debug && cacheDuration > 0;
86
+
87
+ if (isCacheEnabled && CACHE_TEMPLATE[finalCacheKey]) {
88
+ const cached = CACHE_TEMPLATE[finalCacheKey];
89
+ if (cached.expiry === 0 || cached.expiry > Date.now()) {
90
+ return res.send(cached.html);
91
+ }
92
+ delete CACHE_TEMPLATE[finalCacheKey];
93
+ }
94
+
95
+ const isFile =
96
+ !templateOrContent.includes("<") && templateOrContent.length < 100;
97
+
98
+ let templateStr = "";
99
+ let filePath = "";
100
+
101
+ if (isFile) {
102
+ filePath = path.join(
103
+ __dirname,
104
+ "frontend",
105
+ "templates",
106
+ `${templateOrContent}.html`,
107
+ );
108
+ const fileCacheKey = `file:${templateOrContent}`;
109
+ if (!props.debug && FILE_CACHE[fileCacheKey]) {
110
+ templateStr = FILE_CACHE[fileCacheKey];
111
+ } else if (fs.existsSync(filePath)) {
112
+ templateStr = fs.readFileSync(filePath, "utf-8");
113
+ if (!props.debug) FILE_CACHE[fileCacheKey] = templateStr;
114
+ } else {
115
+ templateStr = templateOrContent;
116
+ }
117
+ } else {
118
+ templateStr = templateOrContent;
119
+ }
120
+
121
+ const title = metaTags.titleMeta || props.titleMeta || "";
122
+ const description = metaTags.descriptionMeta || props.descriptionMeta || "";
123
+ const keywords = metaTags.keywordsMeta || props.keywordsMeta || "";
124
+ const author = metaTags.authorMeta || props.authorMeta || "";
125
+ const canonicalUrl = metaTags.canonicalUrl || props.appUrl || "";
126
+ const lang = langHtml || res.locals.lang || "en";
127
+
128
+ let finalHtml = templateStr;
129
+
130
+ const escapes = {
131
+ title,
132
+ description,
133
+ keywords,
134
+ author,
135
+ canonicalUrl,
136
+ titleMeta: title,
137
+ descriptionMeta: description,
138
+ keywordsMeta: keywords,
139
+ authorMeta: author,
140
+ };
141
+
142
+ const raws = {
143
+ lang,
144
+ langHtml: lang,
145
+ };
146
+
147
+ for (const [k, v] of Object.entries(escapes)) {
148
+ finalHtml = finalHtml.replaceAll(`{{${k}}}`, this.escapeHtml(v));
149
+ }
150
+
151
+ for (const [k, v] of Object.entries(raws)) {
152
+ finalHtml = finalHtml.replaceAll(`{{${k}}}`, v);
153
+ }
154
+
155
+ for (const [k, v] of Object.entries(metaTags)) {
156
+ if (typeof v !== "object" && !escapes[k] && !raws[k]) {
157
+ finalHtml = finalHtml.replaceAll(`{{${k}}}`, this.escapeHtml(String(v)));
158
+ }
159
+ }
160
+
161
+ for (const [k, v] of Object.entries(options)) {
162
+ if (k !== "metaTags" && typeof v !== "object" && !escapes[k] && !raws[k]) {
163
+ finalHtml = finalHtml.replaceAll(`{{${k}}}`, this.escapeHtml(String(v)));
164
+ }
165
+ }
166
+
167
+ if (props.debug) {
168
+ const hotReloadScript = `<script>
169
+ (function(){var s=new EventSource("/__hot-reload");s.onmessage=function(e){if(e.data==="reload")location.reload()};s.onerror=function(){s.close();setTimeout(function(){location.reload()},2000)};})();
170
+ </script>`;
171
+ if (finalHtml.includes("</body>")) {
172
+ finalHtml = finalHtml.replace("</body>", `${hotReloadScript}</body>`);
173
+ } else {
174
+ finalHtml += hotReloadScript;
175
+ }
176
+ }
177
+
178
+ if (minify) {
179
+ finalHtml = this.minifyHtml(finalHtml);
180
+ }
181
+
182
+ if (isCacheEnabled) {
183
+ CACHE_TEMPLATE[finalCacheKey] = {
184
+ html: finalHtml,
185
+ expiry: cacheDuration > 0 ? Date.now() + cacheDuration * 1000 : 0,
186
+ };
187
+ }
188
+ res.send(finalHtml);
189
+ } catch (error) {
190
+ logger.error(`Error rendering HTML template: ${error.message}`);
191
+ res.status(500).send("Internal Server Error");
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Clears cached rendered templates.
197
+ *
198
+ * @static
199
+ * @param {string} [key] - Specific cache key to clear. If omitted, clears all cache.
200
+ * @returns {void}
201
+ */
202
+ static clearCache(key) {
203
+ if (key) {
204
+ delete CACHE_TEMPLATE[key];
205
+ delete FILE_CACHE[key];
206
+ } else {
207
+ for (const k in CACHE_TEMPLATE) delete CACHE_TEMPLATE[k];
208
+ for (const k in FILE_CACHE) delete FILE_CACHE[k];
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Returns all active cache keys for rendered templates.
214
+ *
215
+ * @static
216
+ * @returns {string[]} Array of cache key strings.
217
+ */
218
+ static getCacheKeys() {
219
+ return Object.keys(CACHE_TEMPLATE);
220
+ }
221
+
222
+ /**
223
+ * Removes HTML comments and collapses whitespace for smaller payloads.
224
+ *
225
+ * @static
226
+ * @param {string} html - Raw HTML content.
227
+ * @returns {string} Minified HTML content.
228
+ */
229
+ static minifyHtml(html) {
230
+ return html
231
+ .replace(/<!--(?!\[if).*?-->/gs, "")
232
+ .replace(/>\s+</g, "><")
233
+ .replace(/\s{2,}/g, " ")
234
+ .trim();
235
+ }
236
+
237
+ /**
238
+ * Escapes HTML special characters to prevent XSS.
239
+ *
240
+ * @static
241
+ * @param {string} [str=""] - Unescaped string.
242
+ * @returns {string} Escaped HTML string.
243
+ */
244
+ static escapeHtml(str = "") {
245
+ return String(str)
246
+ .replace(/&/g, "&amp;")
247
+ .replace(/</g, "&lt;")
248
+ .replace(/>/g, "&gt;")
249
+ .replace(/"/g, "&quot;")
250
+ .replace(/'/g, "&#39;");
251
+ }
252
+ }
253
+
254
+ export default Template;
package/core/upload.js ADDED
@@ -0,0 +1,77 @@
1
+ import multer from "multer";
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+ import Config from "./config.js";
5
+
6
+ const __dirname = Config.dirname();
7
+ const props = Config.props();
8
+
9
+ /**
10
+ * Upload helper to manage file uploads using multer.
11
+ */
12
+ class Upload {
13
+ /**
14
+ * Configures storage for uploaded files.
15
+ * @param {string} folder - The destination folder within the static path.
16
+ * @returns {import('multer').StorageEngine}
17
+ * @example
18
+ * const storage = Upload.storage("uploads");
19
+ */
20
+ static storage(folder = "uploads") {
21
+ const dest = path.join(__dirname, props.static.path, folder);
22
+
23
+ if (!fs.existsSync(dest)) {
24
+ fs.mkdirSync(dest, { recursive: true });
25
+ }
26
+
27
+ return multer.diskStorage({
28
+ destination: (req, file, cb) => {
29
+ cb(null, dest);
30
+ },
31
+ filename: (req, file, cb) => {
32
+ const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9);
33
+ cb(null, uniqueSuffix + path.extname(file.originalname));
34
+ }
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Returns a multer instance for single or multiple file uploads.
40
+ * @param {Object} options - Multer options.
41
+ * @param {string} [options.folder='uploads'] - Destination folder.
42
+ * @param {number} [options.fileSize=5000000] - Max file size in bytes (default 5MB).
43
+ * @param {Array<string>} [options.allowedTypes=[]] - Allowed mime types (e.g. ['image/png', 'image/jpeg']).
44
+ * @returns {import('multer').Multer}
45
+ * @example
46
+ * const upload = Upload.disk({ folder: "uploads", fileSize: 5000000, allowedTypes: ["image/png", "image/jpeg"] });
47
+ */
48
+ static disk(options = {}) {
49
+ const { folder = "uploads", fileSize = 5000000, allowedTypes = [] } = options;
50
+
51
+ return multer({
52
+ storage: this.storage(folder),
53
+ limits: { fileSize },
54
+ fileFilter: (req, file, cb) => {
55
+ if (allowedTypes.length > 0 && !allowedTypes.includes(file.mimetype)) {
56
+ return cb(new Error("File type not allowed"), false);
57
+ }
58
+ cb(null, true);
59
+ }
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Helper to get the public URL of an uploaded file.
65
+ * @param {string} filename - The name of the file.
66
+ * @param {string} [folder='uploads'] - The folder where the file is stored.
67
+ * @returns {string} The full public URL.
68
+ * @example
69
+ * const url = Upload.url("file.jpg", "uploads");
70
+ */
71
+ static url(filename, folder = "uploads") {
72
+ const appUrl = props.appUrl ?? `${props.host}:${props.port}`;
73
+ return `${appUrl}/${folder}/${filename}`;
74
+ }
75
+ }
76
+
77
+ export default Upload;