@seip/blue-bird 0.6.3 → 0.7.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.
- package/.env_example +0 -6
- package/AGENTS.md +41 -156
- package/README.md +46 -130
- package/backend/routes/api.js +21 -17
- package/core/app.js +86 -81
- package/core/cli/init.js +120 -11
- package/core/logger.js +77 -78
- package/core/router.js +2 -6
- package/frontend/astro.config.mjs +35 -0
- package/frontend/public/css/app.css +319 -0
- package/frontend/public/favicon.ico +0 -0
- package/frontend/src/http/api.js +19 -0
- package/frontend/src/layouts/Layout.astro +20 -0
- package/frontend/src/pages/about.astro +54 -0
- package/frontend/src/pages/index.astro +104 -0
- package/{backend/index.js → index.js} +11 -4
- package/package.json +12 -4
- package/backend/routes/frontend.js +0 -39
- package/core/seo.js +0 -113
- package/core/template.js +0 -319
- package/frontend/public/js/blue-bird.js +0 -1465
- package/frontend/public/js/tailwind.js +0 -8
- package/frontend/templates/about.html +0 -105
- package/frontend/templates/index.html +0 -146
- package/frontend/templates/preact_example.html +0 -80
package/core/seo.js
DELETED
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import Config from "./config.js";
|
|
2
|
-
|
|
3
|
-
const props = Config.props();
|
|
4
|
-
|
|
5
|
-
/** @type {Array<{path: string, languages: string[]}>} */
|
|
6
|
-
const _seoRoutes = [];
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* SEO utility class for generating sitemaps and robots.txt files.
|
|
10
|
-
* Routes are automatically registered from Router instances created with { seo: true }.
|
|
11
|
-
*/
|
|
12
|
-
class SEO {
|
|
13
|
-
/**
|
|
14
|
-
* Registers a route path for sitemap generation.
|
|
15
|
-
* Called internally by Router when seo option is enabled.
|
|
16
|
-
* @static
|
|
17
|
-
* @param {string} routePath - The route path to register.
|
|
18
|
-
* @param {string[]} [languages=[]] - Language prefixes for this route.
|
|
19
|
-
*/
|
|
20
|
-
static addRoute(routePath, languages = []) {
|
|
21
|
-
const exists = _seoRoutes.find(r => r.path === routePath);
|
|
22
|
-
if (!exists) {
|
|
23
|
-
_seoRoutes.push({ path: routePath, languages });
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Returns all registered SEO routes.
|
|
29
|
-
* @static
|
|
30
|
-
* @returns {Array<{path: string, languages: string[]}>}
|
|
31
|
-
*/
|
|
32
|
-
static getRoutes() {
|
|
33
|
-
return _seoRoutes;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Clears all registered SEO routes.
|
|
38
|
-
* @static
|
|
39
|
-
*/
|
|
40
|
-
static clearRoutes() {
|
|
41
|
-
_seoRoutes.length = 0;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Generates a sitemap.xml string from all registered SEO routes.
|
|
46
|
-
* @static
|
|
47
|
-
* @returns {string} The generated XML sitemap.
|
|
48
|
-
*/
|
|
49
|
-
static generateSitemap() {
|
|
50
|
-
const host = (props.appUrl || `${props.host}:${props.port}`).replace(/\/$/, "");
|
|
51
|
-
const date = new Date().toISOString().split("T")[0];
|
|
52
|
-
|
|
53
|
-
let xml = '<?xml version="1.0" encoding="UTF-8"?>';
|
|
54
|
-
xml +=
|
|
55
|
-
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">';
|
|
56
|
-
|
|
57
|
-
_seoRoutes.forEach((route) => {
|
|
58
|
-
xml += `
|
|
59
|
-
<url>
|
|
60
|
-
<loc>${host}${route.path}</loc>
|
|
61
|
-
<lastmod>${date}</lastmod>
|
|
62
|
-
<priority>${route.path === "/" ? "1.0" : "0.8"}</priority>
|
|
63
|
-
</url>`;
|
|
64
|
-
if (route.languages && route.languages.length > 0) {
|
|
65
|
-
route.languages.forEach((lang) => {
|
|
66
|
-
const langPath = `/${lang}${route.path === "/" ? "" : route.path}`;
|
|
67
|
-
xml += `
|
|
68
|
-
<url>
|
|
69
|
-
<loc>${host}${langPath}</loc>
|
|
70
|
-
<lastmod>${date}</lastmod>
|
|
71
|
-
<priority>1</priority>
|
|
72
|
-
</url>`;
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
xml += "\n</urlset>";
|
|
78
|
-
return xml.trim();
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Generates a robots.txt string.
|
|
83
|
-
* @static
|
|
84
|
-
* @returns {string} The generated robots.txt content.
|
|
85
|
-
*/
|
|
86
|
-
static generateRobots() {
|
|
87
|
-
const host = (props.appUrl || "http://localhost").replace(/\/$/, "");
|
|
88
|
-
return `User-agent: *
|
|
89
|
-
Allow: /
|
|
90
|
-
|
|
91
|
-
Sitemap: ${host}/sitemap.xml
|
|
92
|
-
`;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Registers /sitemap.xml and /robots.txt routes on the given Express router.
|
|
97
|
-
* @static
|
|
98
|
-
* @param {import('express').Router} expressRouter - The Express router instance.
|
|
99
|
-
*/
|
|
100
|
-
static registerEndpoints(expressRouter) {
|
|
101
|
-
expressRouter.get("/sitemap.xml", (req, res) => {
|
|
102
|
-
res.header("Content-Type", "application/xml");
|
|
103
|
-
res.send(SEO.generateSitemap());
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
expressRouter.get("/robots.txt", (req, res) => {
|
|
107
|
-
res.header("Content-Type", "text/plain");
|
|
108
|
-
res.send(SEO.generateRobots());
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export default SEO;
|
package/core/template.js
DELETED
|
@@ -1,319 +0,0 @@
|
|
|
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
|
-
const isSpaRequest =
|
|
79
|
-
res.req &&
|
|
80
|
-
(res.req.headers["x-bluebird-spa"] === "true" ||
|
|
81
|
-
res.req.query?.source === "frontend");
|
|
82
|
-
|
|
83
|
-
const extraKey = res.req ? res.req.originalUrl : "";
|
|
84
|
-
const cachePrefix = isSpaRequest ? "spa:" : "html:";
|
|
85
|
-
const finalCacheKey =
|
|
86
|
-
cacheKey ||
|
|
87
|
-
buildCacheKey(`${cachePrefix}${templateOrContent}`, metaTags, extraKey);
|
|
88
|
-
|
|
89
|
-
const cacheDuration = typeof cache === "number" ? cache : cache ? 60 : 0;
|
|
90
|
-
const isCacheEnabled = !props.debug && cacheDuration > 0;
|
|
91
|
-
|
|
92
|
-
if (isCacheEnabled && CACHE_TEMPLATE[finalCacheKey]) {
|
|
93
|
-
const cached = CACHE_TEMPLATE[finalCacheKey];
|
|
94
|
-
if (cached.expiry === 0 || cached.expiry > Date.now()) {
|
|
95
|
-
if (isSpaRequest) {
|
|
96
|
-
res.removeHeader("Content-Security-Policy");
|
|
97
|
-
res.removeHeader("X-Frame-Options");
|
|
98
|
-
res.removeHeader("X-Content-Type-Options");
|
|
99
|
-
res.type("application/json");
|
|
100
|
-
return res.json(cached.json);
|
|
101
|
-
} else {
|
|
102
|
-
res.type("text/html");
|
|
103
|
-
return res.send(cached.html);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
delete CACHE_TEMPLATE[finalCacheKey];
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const isFile =
|
|
110
|
-
!templateOrContent.includes("<") && templateOrContent.length < 100;
|
|
111
|
-
|
|
112
|
-
let templateStr = "";
|
|
113
|
-
let filePath = "";
|
|
114
|
-
|
|
115
|
-
if (isFile) {
|
|
116
|
-
filePath = path.join(
|
|
117
|
-
__dirname,
|
|
118
|
-
"frontend",
|
|
119
|
-
"templates",
|
|
120
|
-
`${templateOrContent}.html`,
|
|
121
|
-
);
|
|
122
|
-
const fileCacheKey = `file:${templateOrContent}`;
|
|
123
|
-
if (!props.debug && FILE_CACHE[fileCacheKey]) {
|
|
124
|
-
templateStr = FILE_CACHE[fileCacheKey];
|
|
125
|
-
} else if (fs.existsSync(filePath)) {
|
|
126
|
-
templateStr = fs.readFileSync(filePath, "utf-8");
|
|
127
|
-
if (!props.debug) FILE_CACHE[fileCacheKey] = templateStr;
|
|
128
|
-
} else {
|
|
129
|
-
templateStr = templateOrContent;
|
|
130
|
-
}
|
|
131
|
-
} else {
|
|
132
|
-
templateStr = templateOrContent;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const title = metaTags.titleMeta || props.titleMeta || "";
|
|
136
|
-
const description =
|
|
137
|
-
metaTags.descriptionMeta || props.descriptionMeta || "";
|
|
138
|
-
const keywords = metaTags.keywordsMeta || props.keywordsMeta || "";
|
|
139
|
-
const author = metaTags.authorMeta || props.authorMeta || "";
|
|
140
|
-
const canonicalUrl = metaTags.canonicalUrl || props.appUrl || "";
|
|
141
|
-
const lang = langHtml || res.locals.lang || "en";
|
|
142
|
-
|
|
143
|
-
let finalHtml = templateStr;
|
|
144
|
-
|
|
145
|
-
const escapes = {
|
|
146
|
-
title,
|
|
147
|
-
description,
|
|
148
|
-
keywords,
|
|
149
|
-
author,
|
|
150
|
-
canonicalUrl,
|
|
151
|
-
titleMeta: title,
|
|
152
|
-
descriptionMeta: description,
|
|
153
|
-
keywordsMeta: keywords,
|
|
154
|
-
authorMeta: author,
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
const raws = {
|
|
158
|
-
lang,
|
|
159
|
-
langHtml: lang,
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
for (const [k, v] of Object.entries(escapes)) {
|
|
163
|
-
finalHtml = finalHtml.replaceAll(`{{${k}}}`, this.escapeHtml(v));
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
for (const [k, v] of Object.entries(raws)) {
|
|
167
|
-
finalHtml = finalHtml.replaceAll(`{{${k}}}`, v);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
for (const [k, v] of Object.entries(metaTags)) {
|
|
171
|
-
if (typeof v !== "object" && !escapes[k] && !raws[k]) {
|
|
172
|
-
finalHtml = finalHtml.replaceAll(
|
|
173
|
-
`{{${k}}}`,
|
|
174
|
-
this.escapeHtml(String(v)),
|
|
175
|
-
);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
for (const [k, v] of Object.entries(options)) {
|
|
180
|
-
if (
|
|
181
|
-
k !== "metaTags" &&
|
|
182
|
-
typeof v !== "object" &&
|
|
183
|
-
!escapes[k] &&
|
|
184
|
-
!raws[k]
|
|
185
|
-
) {
|
|
186
|
-
finalHtml = finalHtml.replaceAll(
|
|
187
|
-
`{{${k}}}`,
|
|
188
|
-
this.escapeHtml(String(v)),
|
|
189
|
-
);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (props.debug) {
|
|
194
|
-
const hotReloadScript = `<script>
|
|
195
|
-
(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)};})();
|
|
196
|
-
</script>`;
|
|
197
|
-
if (finalHtml.includes("</body>")) {
|
|
198
|
-
finalHtml = finalHtml.replace("</body>", `${hotReloadScript}</body>`);
|
|
199
|
-
} else {
|
|
200
|
-
finalHtml += hotReloadScript;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
if (minify) {
|
|
205
|
-
finalHtml = this.minifyHtml(finalHtml);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
if (isSpaRequest) {
|
|
209
|
-
let bodyContent = "";
|
|
210
|
-
const match = finalHtml.match(
|
|
211
|
-
/<([a-zA-Z0-9\-]+)[^>]*id="blueBird-spa-content"[^>]*>([\s\S]*?)<\/\1>/i,
|
|
212
|
-
);
|
|
213
|
-
if (match) {
|
|
214
|
-
bodyContent = match[2];
|
|
215
|
-
} else {
|
|
216
|
-
bodyContent = finalHtml;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
res.removeHeader("Content-Security-Policy");
|
|
220
|
-
res.removeHeader("X-Frame-Options");
|
|
221
|
-
res.removeHeader("X-Content-Type-Options");
|
|
222
|
-
res.type("application/json");
|
|
223
|
-
|
|
224
|
-
const spaData = {
|
|
225
|
-
meta: {
|
|
226
|
-
title: title,
|
|
227
|
-
description: description,
|
|
228
|
-
keywords: keywords,
|
|
229
|
-
author: author,
|
|
230
|
-
},
|
|
231
|
-
body: bodyContent,
|
|
232
|
-
css: options.linkStyles ? options.linkStyles.map((s) => s.href) : [],
|
|
233
|
-
};
|
|
234
|
-
|
|
235
|
-
if (isCacheEnabled) {
|
|
236
|
-
CACHE_TEMPLATE[finalCacheKey] = {
|
|
237
|
-
json: spaData,
|
|
238
|
-
expiry: cacheDuration > 0 ? Date.now() + cacheDuration * 1000 : 0,
|
|
239
|
-
};
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
return res.json(spaData);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
if (isCacheEnabled) {
|
|
246
|
-
CACHE_TEMPLATE[finalCacheKey] = {
|
|
247
|
-
html: finalHtml,
|
|
248
|
-
expiry: cacheDuration > 0 ? Date.now() + cacheDuration * 1000 : 0,
|
|
249
|
-
};
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
res.type("text/html");
|
|
253
|
-
res.send(finalHtml);
|
|
254
|
-
} catch (error) {
|
|
255
|
-
logger.error(`Error rendering HTML template: ${error.message}`);
|
|
256
|
-
res.status(500).send("Internal Server Error");
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
* Clears cached rendered templates.
|
|
262
|
-
*
|
|
263
|
-
* @static
|
|
264
|
-
* @param {string} [key] - Specific cache key to clear. If omitted, clears all cache.
|
|
265
|
-
* @returns {void}
|
|
266
|
-
*/
|
|
267
|
-
static clearCache(key) {
|
|
268
|
-
if (key) {
|
|
269
|
-
delete CACHE_TEMPLATE[key];
|
|
270
|
-
delete FILE_CACHE[key];
|
|
271
|
-
} else {
|
|
272
|
-
for (const k in CACHE_TEMPLATE) delete CACHE_TEMPLATE[k];
|
|
273
|
-
for (const k in FILE_CACHE) delete FILE_CACHE[k];
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* Returns all active cache keys for rendered templates.
|
|
279
|
-
*
|
|
280
|
-
* @static
|
|
281
|
-
* @returns {string[]} Array of cache key strings.
|
|
282
|
-
*/
|
|
283
|
-
static getCacheKeys() {
|
|
284
|
-
return Object.keys(CACHE_TEMPLATE);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/**
|
|
288
|
-
* Removes HTML comments and collapses whitespace for smaller payloads.
|
|
289
|
-
*
|
|
290
|
-
* @static
|
|
291
|
-
* @param {string} html - Raw HTML content.
|
|
292
|
-
* @returns {string} Minified HTML content.
|
|
293
|
-
*/
|
|
294
|
-
static minifyHtml(html) {
|
|
295
|
-
return html
|
|
296
|
-
.replace(/<!--(?!\[if).*?-->/gs, "")
|
|
297
|
-
.replace(/>\s+</g, "><")
|
|
298
|
-
.replace(/\s{2,}/g, " ")
|
|
299
|
-
.trim();
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* Escapes HTML special characters to prevent XSS.
|
|
304
|
-
*
|
|
305
|
-
* @static
|
|
306
|
-
* @param {string} [str=""] - Unescaped string.
|
|
307
|
-
* @returns {string} Escaped HTML string.
|
|
308
|
-
*/
|
|
309
|
-
static escapeHtml(str = "") {
|
|
310
|
-
return String(str)
|
|
311
|
-
.replace(/&/g, "&")
|
|
312
|
-
.replace(/</g, "<")
|
|
313
|
-
.replace(/>/g, ">")
|
|
314
|
-
.replace(/"/g, """)
|
|
315
|
-
.replace(/'/g, "'");
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
export default Template;
|