@lexho111/plainblog 0.5.11 → 0.5.12
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/Blog.js +154 -92
- package/Formatter.js +20 -3
- package/build-styles.js +24 -17
- package/eslint.config.js +27 -45
- package/knip.json +10 -0
- package/model/FileAdapter.js +5 -5
- package/model/FileModel.js +4 -0
- package/model/PostgresAdapter.js +2 -2
- package/model/SequelizeAdapter.js +25 -20
- package/model/SqliteAdapter.js +2 -2
- package/package.json +10 -14
- package/test/blog.test.js +68 -0
- package/test/simpleServer.js +0 -2
- package/.eslintignore +0 -0
- package/.eslintrc.json +0 -0
package/Blog.js
CHANGED
|
@@ -5,16 +5,11 @@ import { URLSearchParams } from "url";
|
|
|
5
5
|
import Article from "./Article.js";
|
|
6
6
|
import DatabaseModel from "./model/DatabaseModel.js";
|
|
7
7
|
import { fetchData, postData } from "./model/APIModel.js";
|
|
8
|
-
import { formatHTML, header, formatMarkdown, validate } from "./Formatter.js";
|
|
9
|
-
import pkg from "./package.json" with { type: "json" };
|
|
8
|
+
import { formatHTML, header, formatMarkdown, validate } from "./Formatter.js"; // import pkg from "./package.json" with { type: "json" };
|
|
10
9
|
import path from "path";
|
|
11
10
|
import { fileURLToPath } from "url";
|
|
12
|
-
import { exec } from "child_process";
|
|
13
|
-
import { promisify } from "util";
|
|
14
11
|
import { compileStyles, mergeStyles } from "./build-styles.js";
|
|
15
12
|
|
|
16
|
-
const execPromise = promisify(exec); // x
|
|
17
|
-
|
|
18
13
|
export default class Blog {
|
|
19
14
|
constructor() {
|
|
20
15
|
this.database = {
|
|
@@ -22,7 +17,7 @@ export default class Blog {
|
|
|
22
17
|
username: "user",
|
|
23
18
|
password: "password",
|
|
24
19
|
host: "localhost",
|
|
25
|
-
dbname: "
|
|
20
|
+
dbname: "articles.txt", // x
|
|
26
21
|
};
|
|
27
22
|
this.#title = "";
|
|
28
23
|
this.#articles = [];
|
|
@@ -35,26 +30,31 @@ export default class Blog {
|
|
|
35
30
|
this.reloadStylesOnGET = false;
|
|
36
31
|
this.sessions = new Set();
|
|
37
32
|
|
|
38
|
-
this.#version = pkg.version;
|
|
33
|
+
this.#version = "0.0.1"; //pkg.version;
|
|
39
34
|
console.log(`version: ${this.#version}`);
|
|
40
35
|
}
|
|
41
36
|
|
|
37
|
+
/** @returns a json representation of the blog */
|
|
42
38
|
json() {
|
|
43
|
-
const serverInfo = this.#server
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
39
|
+
const serverInfo = this.#server
|
|
40
|
+
? {
|
|
41
|
+
listening: this.#server.listening,
|
|
42
|
+
address: this.#server.address(),
|
|
43
|
+
}
|
|
44
|
+
: null;
|
|
45
|
+
|
|
46
|
+
const json = {
|
|
47
|
+
version: this.#version,
|
|
48
|
+
title: this.#title,
|
|
49
|
+
articles: this.#articles,
|
|
50
|
+
server: serverInfo,
|
|
51
|
+
compiledStyles: this.compiledStyles,
|
|
52
|
+
sessions: this.sessions,
|
|
53
|
+
database: this.database,
|
|
54
|
+
password: this.#password,
|
|
55
|
+
styles: this.#styles,
|
|
56
|
+
reloadStylesOnGET: this.reloadStylesOnGET,
|
|
57
|
+
};
|
|
58
58
|
|
|
59
59
|
return JSON.parse(JSON.stringify(json));
|
|
60
60
|
}
|
|
@@ -70,7 +70,7 @@ export default class Blog {
|
|
|
70
70
|
#articles = [];
|
|
71
71
|
#styles = "";
|
|
72
72
|
#stylesHash = "";
|
|
73
|
-
|
|
73
|
+
//#scriptsHash = "";
|
|
74
74
|
#stylesheetPath = "";
|
|
75
75
|
|
|
76
76
|
setTitle(title) {
|
|
@@ -91,7 +91,7 @@ export default class Blog {
|
|
|
91
91
|
this.#databaseModel = new DatabaseModel(this.database);
|
|
92
92
|
}
|
|
93
93
|
console.log(`connected to database`);
|
|
94
|
-
if(t != this.#title && t.length == 0)
|
|
94
|
+
if (t != this.#title && t.length == 0)
|
|
95
95
|
this.#databaseModel.updateBlogTitle(t);
|
|
96
96
|
}
|
|
97
97
|
|
|
@@ -103,6 +103,10 @@ export default class Blog {
|
|
|
103
103
|
this.#password = x;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* allows you to inject a specific database implementation
|
|
108
|
+
* @param {*} adapter a database adapter like PostgresAdapter or SqliteAdapter
|
|
109
|
+
*/
|
|
106
110
|
setDatabaseAdapter(adapter) {
|
|
107
111
|
if (!this.#databaseModel) {
|
|
108
112
|
this.#databaseModel = new DatabaseModel(this.database);
|
|
@@ -110,13 +114,21 @@ export default class Blog {
|
|
|
110
114
|
this.#databaseModel.setDatabaseAdapter(adapter);
|
|
111
115
|
}
|
|
112
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Appends CSS rules to the \<style\>-tag.
|
|
119
|
+
* @param {string} style - A string containing CSS rules.
|
|
120
|
+
*/
|
|
113
121
|
set style(style) {
|
|
114
122
|
this.#styles += style;
|
|
115
123
|
}
|
|
116
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Sets the path(s) to custom CSS or SCSS files to be compiled and used by the blog.
|
|
127
|
+
* @param {string|string[]} files - A single file path or an array of file paths.
|
|
128
|
+
*/
|
|
117
129
|
set stylesheetPath(files) {
|
|
118
130
|
this.#stylesheetPath = files;
|
|
119
|
-
console.log(`this.#stylesheetPath: ${this.#stylesheetPath}`)
|
|
131
|
+
console.log(`this.#stylesheetPath: ${this.#stylesheetPath}`);
|
|
120
132
|
}
|
|
121
133
|
|
|
122
134
|
addArticle(article) {
|
|
@@ -124,11 +136,11 @@ export default class Blog {
|
|
|
124
136
|
}
|
|
125
137
|
|
|
126
138
|
#isAuthenticated(req) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
139
|
+
if (!req.headers.cookie) return false;
|
|
140
|
+
const params = new URLSearchParams(req.headers.cookie.replace(/; /g, "&"));
|
|
141
|
+
return this.sessions.has(params.get("session"));
|
|
130
142
|
}
|
|
131
|
-
|
|
143
|
+
|
|
132
144
|
async #handleLogin(req, res) {
|
|
133
145
|
const body = await new Promise((resolve, reject) => {
|
|
134
146
|
let data = "";
|
|
@@ -154,12 +166,14 @@ export default class Blog {
|
|
|
154
166
|
<input type="password" name="password" placeholder="Password" />
|
|
155
167
|
<button style="margin: 2px;">Login</button></form>
|
|
156
168
|
</body></html>`);
|
|
157
|
-
|
|
169
|
+
}
|
|
158
170
|
}
|
|
159
|
-
|
|
171
|
+
|
|
160
172
|
#handleLogout(req, res) {
|
|
161
173
|
if (req.headers.cookie) {
|
|
162
|
-
const params = new URLSearchParams(
|
|
174
|
+
const params = new URLSearchParams(
|
|
175
|
+
req.headers.cookie.replace(/; /g, "&")
|
|
176
|
+
);
|
|
163
177
|
const sessionId = params.get("session");
|
|
164
178
|
if (this.sessions.has(sessionId)) {
|
|
165
179
|
this.sessions.delete(sessionId);
|
|
@@ -177,16 +191,16 @@ export default class Blog {
|
|
|
177
191
|
//this.loadStyles();
|
|
178
192
|
//this.loadScripts();
|
|
179
193
|
// if there is a stylesheet path provided, process it
|
|
180
|
-
if(this.#stylesheetPath != null) {
|
|
194
|
+
if (this.#stylesheetPath != null) {
|
|
181
195
|
// read file from stylesheet path, compare checksums and write to public/styles.min.css
|
|
182
196
|
await this.#processStylesheets(this.#stylesheetPath);
|
|
183
197
|
}
|
|
184
|
-
if(!this.#stylesheetPath) {
|
|
198
|
+
if (!this.#stylesheetPath) {
|
|
185
199
|
// this.#styles
|
|
186
200
|
// src/styles.css
|
|
187
201
|
// compile and merge hardcoded styles in "this.#styles" with "src/styles.css" and write to file "styles.min.css"
|
|
188
202
|
// which will be imported by webbrowser via '<link rel="stylesheet" href="styles.min.css"...'
|
|
189
|
-
|
|
203
|
+
|
|
190
204
|
const __filename = fileURLToPath(import.meta.url);
|
|
191
205
|
const __dirname = path.dirname(__filename);
|
|
192
206
|
const srcStylePath = path.join(__dirname, "src", "styles.css");
|
|
@@ -196,24 +210,38 @@ export default class Blog {
|
|
|
196
210
|
let srcStyles = "";
|
|
197
211
|
|
|
198
212
|
await Promise.all([
|
|
199
|
-
fs.promises
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
213
|
+
fs.promises
|
|
214
|
+
.readFile(publicStylePath, "utf8")
|
|
215
|
+
.then((publicCSS) => {
|
|
216
|
+
const match = publicCSS.match(
|
|
217
|
+
/\/\* source-hash: ([a-f0-9]{64}) \*\//
|
|
218
|
+
);
|
|
219
|
+
if (match) publicHash = match[1];
|
|
220
|
+
})
|
|
221
|
+
.catch((err) => console.error(err)), // public/styles.min.css doesn't exist, will be created.
|
|
222
|
+
fs.promises
|
|
223
|
+
.readFile(srcStylePath, "utf8")
|
|
224
|
+
.then((content) => {
|
|
225
|
+
srcStyles = content;
|
|
226
|
+
})
|
|
227
|
+
.catch((err) => console.error(err)), // ignore if src/styles.css doesn't exist
|
|
206
228
|
]);
|
|
207
229
|
|
|
208
230
|
const combinedStyles = this.#styles + srcStyles;
|
|
209
|
-
const srcHash = crypto
|
|
231
|
+
const srcHash = crypto
|
|
232
|
+
.createHash("sha256")
|
|
233
|
+
.update(combinedStyles)
|
|
234
|
+
.digest("hex");
|
|
210
235
|
|
|
211
236
|
if (srcHash !== publicHash) {
|
|
212
237
|
console.log("Styles have changed. Recompiling...");
|
|
213
238
|
const finalStyles = await mergeStyles(this.#styles, srcStyles);
|
|
214
239
|
try {
|
|
215
240
|
//await fs.promises.mkdir(path.dirname(publicStylePath), { recursive: true });
|
|
216
|
-
await fs.promises.writeFile(
|
|
241
|
+
await fs.promises.writeFile(
|
|
242
|
+
publicStylePath,
|
|
243
|
+
finalStyles + `\n/* source-hash: ${srcHash} */`
|
|
244
|
+
);
|
|
217
245
|
} catch (err) {
|
|
218
246
|
console.error("Failed to write styles to public folder:", err);
|
|
219
247
|
}
|
|
@@ -234,13 +262,27 @@ export default class Blog {
|
|
|
234
262
|
this.#databaseModel.findAll(),
|
|
235
263
|
]);
|
|
236
264
|
|
|
237
|
-
if(dbArticles.length == 0) {
|
|
238
|
-
dbArticles.push(
|
|
239
|
-
|
|
265
|
+
if (dbArticles.length == 0) {
|
|
266
|
+
dbArticles.push(
|
|
267
|
+
new Article(
|
|
268
|
+
"Sample Entry #1",
|
|
269
|
+
"Prow scuttle parrel provost Sail ho shrouds spirits boom mizzenmast yardarm. Pinnace holystone mizzenmast quarter crow's nest nipperkin grog yardarm hempen halter furl. Swab barque interloper chantey doubloon starboard grog black jack gangway rutters.",
|
|
270
|
+
new Date()
|
|
271
|
+
)
|
|
272
|
+
);
|
|
273
|
+
dbArticles.push(
|
|
274
|
+
new Article(
|
|
275
|
+
"Sample Entry #2",
|
|
276
|
+
"Deadlights jack lad schooner scallywag dance the hempen jig carouser broadside cable strike colors. Bring a spring upon her cable holystone blow the man down spanker Shiver me timbers to go on account lookout wherry doubloon chase. Belay yo-ho-ho keelhaul squiffy black spot yardarm spyglass sheet transom heave to.",
|
|
277
|
+
new Date()
|
|
278
|
+
)
|
|
279
|
+
);
|
|
240
280
|
}
|
|
241
|
-
if(this.reloadStylesOnGET)
|
|
281
|
+
if (this.reloadStylesOnGET)
|
|
282
|
+
console.log("reload scripts and styles on GET-Request");
|
|
242
283
|
let title = "";
|
|
243
|
-
if (this.#title != null && this.#title.length > 0)
|
|
284
|
+
if (this.#title != null && this.#title.length > 0)
|
|
285
|
+
title = this.#title; // use blog title if set
|
|
244
286
|
else title = dbTitle; // use title from the database
|
|
245
287
|
const responseData = { title: title, articles: dbArticles };
|
|
246
288
|
this.#applyBlogData(responseData);
|
|
@@ -266,8 +308,10 @@ export default class Blog {
|
|
|
266
308
|
try {
|
|
267
309
|
// Save the new article to the database via the ApiServer
|
|
268
310
|
const promises = [];
|
|
269
|
-
if (this.#databaseModel)
|
|
270
|
-
|
|
311
|
+
if (this.#databaseModel)
|
|
312
|
+
promises.push(this.#databaseModel.save(newArticleData));
|
|
313
|
+
if (this.#isExternalAPI)
|
|
314
|
+
promises.push(postData(this.#apiUrl, newArticleData));
|
|
271
315
|
await Promise.all(promises);
|
|
272
316
|
// Add the article to the local list for immediate display
|
|
273
317
|
this.#articles.unshift(new Article(title, content, new Date()));
|
|
@@ -331,14 +375,14 @@ export default class Blog {
|
|
|
331
375
|
// load articles
|
|
332
376
|
|
|
333
377
|
// reload styles and scripts on (every) request
|
|
334
|
-
if(this.reloadStylesOnGET) {
|
|
378
|
+
if (this.reloadStylesOnGET) {
|
|
335
379
|
if (this.#stylesheetPath) {
|
|
336
380
|
await this.#processStylesheets(this.#stylesheetPath);
|
|
337
381
|
}
|
|
338
382
|
}
|
|
339
383
|
|
|
340
384
|
let loggedin = false;
|
|
341
|
-
if (!this.#isAuthenticated(req)) {
|
|
385
|
+
if (!this.#isAuthenticated(req)) {
|
|
342
386
|
// login
|
|
343
387
|
loggedin = false;
|
|
344
388
|
} else {
|
|
@@ -361,7 +405,10 @@ export default class Blog {
|
|
|
361
405
|
const __filename = fileURLToPath(import.meta.url);
|
|
362
406
|
const __dirname = path.dirname(__filename);
|
|
363
407
|
const publicDir = path.join(__dirname, "public");
|
|
364
|
-
const parsedUrl = new URL(
|
|
408
|
+
const parsedUrl = new URL(
|
|
409
|
+
req.url,
|
|
410
|
+
`http://${req.headers.host || "localhost"}`
|
|
411
|
+
);
|
|
365
412
|
const filePath = path.join(publicDir, parsedUrl.pathname);
|
|
366
413
|
|
|
367
414
|
if (filePath.startsWith(publicDir)) {
|
|
@@ -386,6 +433,7 @@ export default class Blog {
|
|
|
386
433
|
}
|
|
387
434
|
}
|
|
388
435
|
} catch (err) {
|
|
436
|
+
console.error(err);
|
|
389
437
|
// Continue to 404
|
|
390
438
|
}
|
|
391
439
|
|
|
@@ -400,7 +448,7 @@ export default class Blog {
|
|
|
400
448
|
return new Promise((resolve, reject) => {
|
|
401
449
|
const errorHandler = (err) => reject(err);
|
|
402
450
|
this.#server.once("error", errorHandler);
|
|
403
|
-
this.#server.listen(port,
|
|
451
|
+
this.#server.listen(port, "127.0.0.1", () => {
|
|
404
452
|
this.#server.removeListener("error", errorHandler);
|
|
405
453
|
console.log(`server running at http://localhost:${port}/`);
|
|
406
454
|
resolve(); // Resolve the promise when the server is listening
|
|
@@ -411,12 +459,14 @@ export default class Blog {
|
|
|
411
459
|
async closeServer() {
|
|
412
460
|
return new Promise((resolve, reject) => {
|
|
413
461
|
if (this.#server) {
|
|
462
|
+
// if server is running
|
|
414
463
|
this.#server.close((err) => {
|
|
415
464
|
if (err && err.code !== "ERR_SERVER_NOT_RUNNING") return reject(err);
|
|
416
465
|
console.log("Server closed.");
|
|
417
466
|
resolve();
|
|
418
467
|
});
|
|
419
468
|
} else {
|
|
469
|
+
// server is not running
|
|
420
470
|
resolve(); // Nothing to close
|
|
421
471
|
}
|
|
422
472
|
});
|
|
@@ -429,7 +479,11 @@ export default class Blog {
|
|
|
429
479
|
// Assuming data contains a title and an array of articles with title and content
|
|
430
480
|
if (data.articles && Array.isArray(data.articles)) {
|
|
431
481
|
for (const articleData of data.articles) {
|
|
432
|
-
const article = new Article(
|
|
482
|
+
const article = new Article(
|
|
483
|
+
articleData.title,
|
|
484
|
+
articleData.content,
|
|
485
|
+
articleData.createdAt
|
|
486
|
+
);
|
|
433
487
|
article.id = articleData.id; // TODO x
|
|
434
488
|
this.addArticle(article);
|
|
435
489
|
}
|
|
@@ -449,34 +503,39 @@ export default class Blog {
|
|
|
449
503
|
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
|
450
504
|
const pathname = url.pathname;
|
|
451
505
|
|
|
452
|
-
if(req.method === "GET") {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
506
|
+
if (req.method === "GET") {
|
|
507
|
+
if (pathname === "/api" || pathname === "/api/") {
|
|
508
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
509
|
+
const data = {
|
|
510
|
+
title: this.title,
|
|
511
|
+
};
|
|
512
|
+
res.end(JSON.stringify(data));
|
|
513
|
+
}
|
|
514
|
+
// GET all blog data
|
|
515
|
+
if (pathname === "/api/articles") {
|
|
516
|
+
// Use 'offset' param as startId (filter) to get items starting at ID
|
|
517
|
+
const pStartID = parseInt(url.searchParams.get("startID"));
|
|
518
|
+
const startID = !isNaN(pStartID) ? pStartID : null;
|
|
519
|
+
const pEndID = parseInt(url.searchParams.get("endID"));
|
|
520
|
+
const endID = !isNaN(pEndID) ? pEndID : null;
|
|
521
|
+
const limit = parseInt(url.searchParams.get("limit")) || 10;
|
|
522
|
+
// controller
|
|
523
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
524
|
+
const dbArticles = await this.#databaseModel.findAll(
|
|
525
|
+
limit,
|
|
526
|
+
0,
|
|
527
|
+
startID,
|
|
528
|
+
endID
|
|
529
|
+
);
|
|
530
|
+
const responseData = {
|
|
531
|
+
title: this.title, // Keep the title from the original constant
|
|
532
|
+
articles: dbArticles,
|
|
533
|
+
};
|
|
534
|
+
res.end(JSON.stringify(responseData));
|
|
457
535
|
}
|
|
458
|
-
res.end(JSON.stringify(data));
|
|
459
|
-
}
|
|
460
|
-
// GET all blog data
|
|
461
|
-
if (pathname === "/api/articles") {
|
|
462
|
-
// Use 'offset' param as startId (filter) to get items starting at ID
|
|
463
|
-
const pStartID = parseInt(url.searchParams.get("startID"));
|
|
464
|
-
const startID = !isNaN(pStartID) ? pStartID : null;
|
|
465
|
-
const pEndID = parseInt(url.searchParams.get("endID"));
|
|
466
|
-
const endID = !isNaN(pEndID) ? pEndID : null;
|
|
467
|
-
const limit = parseInt(url.searchParams.get("limit")) || 10;
|
|
468
|
-
// controller
|
|
469
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
470
|
-
const dbArticles = await this.#databaseModel.findAll(limit, 0, startID, endID);
|
|
471
|
-
const responseData = {
|
|
472
|
-
title: this.title, // Keep the title from the original constant
|
|
473
|
-
articles: dbArticles,
|
|
474
|
-
};
|
|
475
|
-
res.end(JSON.stringify(responseData));
|
|
476
|
-
}
|
|
477
536
|
|
|
478
|
-
|
|
479
|
-
|
|
537
|
+
// POST a new article
|
|
538
|
+
} else if (req.method === "POST" && pathname === "/api/articles") {
|
|
480
539
|
if (!this.#isAuthenticated(req)) {
|
|
481
540
|
res.writeHead(403, { "Content-Type": "application/json" });
|
|
482
541
|
res.end(JSON.stringify({ error: "Forbidden" }));
|
|
@@ -519,10 +578,10 @@ export default class Blog {
|
|
|
519
578
|
title: this.title,
|
|
520
579
|
articles: this.#articles,
|
|
521
580
|
loggedin,
|
|
522
|
-
login: ""
|
|
581
|
+
login: "",
|
|
523
582
|
};
|
|
524
583
|
|
|
525
|
-
if(loggedin) data.login = `<a href="/logout">logout</a>`;
|
|
584
|
+
if (loggedin) data.login = `<a href="/logout">logout</a>`;
|
|
526
585
|
else data.login = `<a href="/login">login</a>`;
|
|
527
586
|
|
|
528
587
|
const html = formatHTML(data);
|
|
@@ -535,14 +594,17 @@ export default class Blog {
|
|
|
535
594
|
* @param {string[]} files - Array of css/scss file paths to process.
|
|
536
595
|
*/
|
|
537
596
|
async #processStylesheets(files) {
|
|
538
|
-
console.log("process stylesheets")
|
|
539
|
-
|
|
597
|
+
console.log("process stylesheets");
|
|
598
|
+
|
|
540
599
|
// Normalize input to array (handles string or array)
|
|
541
600
|
// "file1.css" --> ["file1.css"]
|
|
542
601
|
// ["file1.css", "file2.css",...]
|
|
543
602
|
const fileList = Array.isArray(files) ? files : [files];
|
|
544
603
|
const styleFiles = fileList.filter(
|
|
545
|
-
(f) =>
|
|
604
|
+
(f) =>
|
|
605
|
+
typeof f === "string" &&
|
|
606
|
+
(f.endsWith(".scss") || f.endsWith(".css")) &&
|
|
607
|
+
!f.endsWith(".min.css")
|
|
546
608
|
);
|
|
547
609
|
//const scriptFiles = files.filter((f) => f.endsWith(".js") && !f.endsWith(".min.js"));
|
|
548
610
|
|
|
@@ -552,7 +614,7 @@ export default class Blog {
|
|
|
552
614
|
const fileData = await Promise.all(
|
|
553
615
|
styleFiles.sort().map(async (f) => {
|
|
554
616
|
const content = await fs.promises.readFile(f, "utf-8");
|
|
555
|
-
if(content == "") throw new Error("Invalid Filepath or empty file!");
|
|
617
|
+
if (content == "") throw new Error("Invalid Filepath or empty file!");
|
|
556
618
|
return { path: f, content };
|
|
557
619
|
})
|
|
558
620
|
);
|
|
@@ -573,7 +635,7 @@ export default class Blog {
|
|
|
573
635
|
if (currentHash !== this.#stylesHash) {
|
|
574
636
|
console.log("Style assets have changed. Recompiling...");
|
|
575
637
|
this.#stylesHash = currentHash;
|
|
576
|
-
|
|
638
|
+
|
|
577
639
|
// Compile styles using the standalone script from build-styles.js
|
|
578
640
|
this.compiledStyles = await compileStyles(fileData);
|
|
579
641
|
|
|
@@ -581,13 +643,13 @@ export default class Blog {
|
|
|
581
643
|
const __filename = fileURLToPath(import.meta.url);
|
|
582
644
|
const __dirname = path.dirname(__filename);
|
|
583
645
|
const publicDir = path.join(__dirname, "public");
|
|
584
|
-
|
|
646
|
+
|
|
585
647
|
await fs.promises.writeFile(
|
|
586
648
|
path.join(publicDir, "styles.min.css"),
|
|
587
649
|
this.compiledStyles + `\n/* source-hash: ${currentHash} */`
|
|
588
650
|
);
|
|
589
651
|
} else {
|
|
590
|
-
console.log("styles are up-to-date")
|
|
652
|
+
console.log("styles are up-to-date");
|
|
591
653
|
}
|
|
592
654
|
}
|
|
593
655
|
}
|
package/Formatter.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* generates the header of the generated html file
|
|
3
|
+
* @param {*} title title of the blog
|
|
4
|
+
* @returns the header for the generated html file
|
|
5
|
+
*/
|
|
1
6
|
export function header(title) {
|
|
2
7
|
return `<!DOCTYPE html>
|
|
3
8
|
<html lang="de">
|
|
@@ -11,10 +16,12 @@ export function header(title) {
|
|
|
11
16
|
</head>`;
|
|
12
17
|
}
|
|
13
18
|
|
|
14
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* renders content like articles into a browser-ready HTML string.
|
|
21
|
+
* @param {*} data blog data like blogtitle, articles, login information
|
|
22
|
+
* @returns valid html code with article data implanted
|
|
23
|
+
*/
|
|
15
24
|
export function formatHTML(data) {
|
|
16
|
-
//console.log(`${data} ${script} ${style}`);
|
|
17
|
-
//export function formatHTML(data) {
|
|
18
25
|
//const button = `<button type="button" onClick="fillWithContent();" style="margin: 4px;">generate random text</button>`;
|
|
19
26
|
const button = "";
|
|
20
27
|
let form1 = "";
|
|
@@ -59,6 +66,11 @@ export function formatHTML(data) {
|
|
|
59
66
|
</html>`;
|
|
60
67
|
}
|
|
61
68
|
|
|
69
|
+
/**
|
|
70
|
+
* format content like articles to markdown
|
|
71
|
+
* @param {*} data blog data like blogtitle and articles
|
|
72
|
+
* @returns valid markdown
|
|
73
|
+
*/
|
|
62
74
|
export function formatMarkdown(data) {
|
|
63
75
|
let markdown = "";
|
|
64
76
|
markdown += `# ${data.title}\n`;
|
|
@@ -70,6 +82,11 @@ export function formatMarkdown(data) {
|
|
|
70
82
|
return markdown;
|
|
71
83
|
}
|
|
72
84
|
|
|
85
|
+
/**
|
|
86
|
+
* html validator
|
|
87
|
+
* @param {*} html
|
|
88
|
+
* @returns true if param html is valid html
|
|
89
|
+
*/
|
|
73
90
|
export function validate(html) {
|
|
74
91
|
let test = true; // all tests passed
|
|
75
92
|
if (!(html.includes("<html") && html.includes("</html"))) {
|
package/build-styles.js
CHANGED
|
@@ -1,37 +1,35 @@
|
|
|
1
1
|
import path from "path";
|
|
2
|
-
import { pathToFileURL } from "url";
|
|
3
2
|
import postcss from "postcss";
|
|
4
3
|
import autoprefixer from "autoprefixer";
|
|
5
4
|
import cssnano from "cssnano";
|
|
6
5
|
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Compiles CSS styles from file content objects.
|
|
8
|
+
* @param {Array<{path: string, content: string}>} fileData - An array of objects containing file paths and content.
|
|
9
|
+
* @returns {Promise<string>} The compiled and minified CSS.
|
|
10
|
+
*/
|
|
8
11
|
export async function compileStyles(fileData) {
|
|
9
12
|
try {
|
|
10
13
|
let combinedCss = "";
|
|
11
14
|
|
|
15
|
+
// 1. filter out css files
|
|
12
16
|
if (fileData) {
|
|
13
17
|
const scssFiles = fileData.filter(
|
|
14
18
|
(f) =>
|
|
15
19
|
f.path.endsWith(".scss") && !path.basename(f.path).startsWith("_")
|
|
16
20
|
);
|
|
21
|
+
if (scssFiles.length > 0) console.error("sass files are not supported.");
|
|
17
22
|
|
|
18
|
-
|
|
19
|
-
console.error("sass files are not supported.");
|
|
20
|
-
}
|
|
21
|
-
|
|
23
|
+
// make one big css file
|
|
22
24
|
const cssFiles = fileData.filter((f) => f.path.endsWith(".css"));
|
|
23
25
|
for (const file of cssFiles) {
|
|
24
26
|
combinedCss += file.content + "\n";
|
|
25
27
|
}
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
// 2.
|
|
30
|
+
// 2. minify and uglify with PostCSS
|
|
29
31
|
if (combinedCss) {
|
|
30
|
-
|
|
31
|
-
const result = await postcss(plugins).process(combinedCss, {
|
|
32
|
-
from: undefined,
|
|
33
|
-
});
|
|
34
|
-
return result.css;
|
|
32
|
+
return postcss2(combinedCss);
|
|
35
33
|
}
|
|
36
34
|
return "";
|
|
37
35
|
} catch (error) {
|
|
@@ -40,16 +38,25 @@ export async function compileStyles(fileData) {
|
|
|
40
38
|
}
|
|
41
39
|
}
|
|
42
40
|
|
|
41
|
+
async function postcss2(css) {
|
|
42
|
+
const plugins = [autoprefixer(), cssnano()];
|
|
43
|
+
const result = await postcss(plugins).process(css, {
|
|
44
|
+
from: undefined, // do not print source map warning
|
|
45
|
+
});
|
|
46
|
+
return result.css; // final result
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Merges and minifies multiple CSS content strings.
|
|
51
|
+
* @param {...string} cssContents - CSS strings to merge.
|
|
52
|
+
* @returns {Promise<string>} The merged and minified CSS.
|
|
53
|
+
*/
|
|
43
54
|
export async function mergeStyles(...cssContents) {
|
|
44
55
|
try {
|
|
45
56
|
const combinedCss = cssContents.join("\n");
|
|
46
57
|
|
|
47
58
|
if (combinedCss) {
|
|
48
|
-
|
|
49
|
-
const result = await postcss(plugins).process(combinedCss, {
|
|
50
|
-
from: undefined,
|
|
51
|
-
});
|
|
52
|
-
return result.css;
|
|
59
|
+
return postcss2(combinedCss);
|
|
53
60
|
}
|
|
54
61
|
return "";
|
|
55
62
|
} catch (error) {
|
package/eslint.config.js
CHANGED
|
@@ -1,45 +1,27 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
},
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
{
|
|
29
|
-
// Configuration specifically for Jest test files
|
|
30
|
-
files: ["**/*.test.js", "**/*.spec.js"],
|
|
31
|
-
languageOptions: {
|
|
32
|
-
globals: {
|
|
33
|
-
...globals.jest, // Defines Jest global variables (e.g., `describe`, `it`, `expect`)
|
|
34
|
-
},
|
|
35
|
-
},
|
|
36
|
-
plugins: {
|
|
37
|
-
jest: pluginJest,
|
|
38
|
-
},
|
|
39
|
-
// Recommended Jest rules from `eslint-plugin-jest`
|
|
40
|
-
rules: {
|
|
41
|
-
...pluginJest.configs.recommended.rules,
|
|
42
|
-
// Add or override Jest-specific rules here.
|
|
43
|
-
},
|
|
44
|
-
},
|
|
45
|
-
];
|
|
1
|
+
import js from "@eslint/js";
|
|
2
|
+
import globals from "globals";
|
|
3
|
+
import { defineConfig } from "eslint/config";
|
|
4
|
+
import pluginJest from "eslint-plugin-jest";
|
|
5
|
+
|
|
6
|
+
export default defineConfig([
|
|
7
|
+
{
|
|
8
|
+
// Must be in a separate object to apply globally
|
|
9
|
+
ignores: ["public/scripts.min.js", "dist/**/*"],
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
files: ["**/*.{js,mjs,cjs}"],
|
|
13
|
+
plugins: { js },
|
|
14
|
+
extends: ["js/recommended"],
|
|
15
|
+
languageOptions: { globals: globals.browser },
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
files: ["**/*.test.js", "**/*.spec.js"],
|
|
19
|
+
plugins: { jest: pluginJest },
|
|
20
|
+
languageOptions: {
|
|
21
|
+
globals: pluginJest.environments.globals.globals, // Loads all Jest globals
|
|
22
|
+
},
|
|
23
|
+
rules: {
|
|
24
|
+
...pluginJest.configs["flat/recommended"].rules,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
]);
|
package/knip.json
ADDED
package/model/FileAdapter.js
CHANGED
|
@@ -8,9 +8,9 @@ import {
|
|
|
8
8
|
|
|
9
9
|
export default class FileAdapter {
|
|
10
10
|
dbtype = "file";
|
|
11
|
-
constructor(options) {
|
|
12
|
-
this.infoFile = "bloginfo.json";
|
|
13
|
-
this.articlesFile = "articles.txt";
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.infoFile = options.infoFilename || "bloginfo.json";
|
|
13
|
+
this.articlesFile = options.articlesFilename || "articles.txt";
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
async initialize() {
|
|
@@ -41,8 +41,8 @@ export default class FileAdapter {
|
|
|
41
41
|
async findAll(
|
|
42
42
|
limit = 4,
|
|
43
43
|
offset = 0,
|
|
44
|
-
startId = null,
|
|
45
|
-
endId = null,
|
|
44
|
+
//startId = null,
|
|
45
|
+
//endId = null,
|
|
46
46
|
order = "DESC"
|
|
47
47
|
) {
|
|
48
48
|
let dbArticles = [];
|
package/model/FileModel.js
CHANGED
|
@@ -11,6 +11,7 @@ export async function loadInfo(filename) {
|
|
|
11
11
|
const data = await fs.readFile(filename, "utf8");
|
|
12
12
|
return JSON.parse(data);
|
|
13
13
|
} catch (err) {
|
|
14
|
+
console.error(err);
|
|
14
15
|
return { title: "Blog" };
|
|
15
16
|
}
|
|
16
17
|
}
|
|
@@ -29,6 +30,7 @@ export async function loadArticles(filename) {
|
|
|
29
30
|
.filter((line) => line.trim() !== "")
|
|
30
31
|
.map((line) => JSON.parse(line));
|
|
31
32
|
} catch (err) {
|
|
33
|
+
console.error(err);
|
|
32
34
|
return [];
|
|
33
35
|
}
|
|
34
36
|
}
|
|
@@ -38,12 +40,14 @@ export async function initFiles(infoFilename, articlesFilename) {
|
|
|
38
40
|
try {
|
|
39
41
|
await fs.access(infoFilename);
|
|
40
42
|
} catch (err) {
|
|
43
|
+
console.error(err);
|
|
41
44
|
await saveInfo(infoFilename, { title: "Blog" });
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
try {
|
|
45
48
|
await fs.access(articlesFilename);
|
|
46
49
|
} catch (err) {
|
|
50
|
+
console.error(err);
|
|
47
51
|
await fs.writeFile(articlesFilename, "");
|
|
48
52
|
}
|
|
49
53
|
}
|
package/model/PostgresAdapter.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { Sequelize, DataTypes, Op } from "sequelize";
|
|
2
1
|
import SequelizeAdapter from "./SequelizeAdapter.js";
|
|
3
2
|
|
|
4
3
|
export default class PostgresAdapter extends SequelizeAdapter {
|
|
@@ -22,6 +21,7 @@ export default class PostgresAdapter extends SequelizeAdapter {
|
|
|
22
21
|
|
|
23
22
|
async initialize() {
|
|
24
23
|
console.log("initialize database");
|
|
24
|
+
await this.loadSequelize();
|
|
25
25
|
const maxRetries = 10;
|
|
26
26
|
const retryDelay = 3000;
|
|
27
27
|
|
|
@@ -31,7 +31,7 @@ export default class PostgresAdapter extends SequelizeAdapter {
|
|
|
31
31
|
`postgres://${this.username}:${this.password}@${this.host}:${this.dbport}/${this.dbname}`
|
|
32
32
|
);
|
|
33
33
|
|
|
34
|
-
this.sequelize = new Sequelize(
|
|
34
|
+
this.sequelize = new this.Sequelize(
|
|
35
35
|
`postgres://${this.username}:${this.password}@${this.host}:${this.dbport}/${this.dbname}`,
|
|
36
36
|
{ logging: false }
|
|
37
37
|
);
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { Sequelize, DataTypes, Op } from "sequelize";
|
|
2
|
-
|
|
3
1
|
export default class SequelizeAdapter {
|
|
4
2
|
username;
|
|
5
3
|
password;
|
|
@@ -11,38 +9,44 @@ export default class SequelizeAdapter {
|
|
|
11
9
|
Article;
|
|
12
10
|
BlogInfo;
|
|
13
11
|
|
|
12
|
+
// Dynamic properties
|
|
13
|
+
Sequelize;
|
|
14
|
+
DataTypes;
|
|
15
|
+
Op;
|
|
16
|
+
|
|
14
17
|
constructor(options = {}) {
|
|
15
18
|
console.log(JSON.stringify(options));
|
|
19
|
+
}
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
async loadSequelize() {
|
|
22
|
+
if (this.Sequelize) return;
|
|
23
|
+
try {
|
|
19
24
|
const sequelizePkg = await import("sequelize");
|
|
20
|
-
Sequelize = sequelizePkg.Sequelize;
|
|
21
|
-
DataTypes = sequelizePkg.DataTypes;
|
|
22
|
-
|
|
23
|
-
//this.#Op = Op;
|
|
25
|
+
this.Sequelize = sequelizePkg.Sequelize;
|
|
26
|
+
this.DataTypes = sequelizePkg.DataTypes;
|
|
27
|
+
this.Op = sequelizePkg.Op;
|
|
24
28
|
} catch (err) {
|
|
29
|
+
console.error(err);
|
|
25
30
|
throw new Error(
|
|
26
31
|
"Sequelize is not installed. Please install it to use PostgresAdapter: npm install sequelize"
|
|
27
32
|
);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// throw new Error(`Error! ${databasetype} is an unknown database type.`);
|
|
33
|
+
}
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
async initializeModels() {
|
|
37
|
+
await this.loadSequelize();
|
|
34
38
|
this.Article = this.sequelize.define(
|
|
35
39
|
"Article",
|
|
36
40
|
{
|
|
37
|
-
title: DataTypes.STRING,
|
|
38
|
-
content: DataTypes.TEXT,
|
|
41
|
+
title: this.DataTypes.STRING,
|
|
42
|
+
content: this.DataTypes.TEXT,
|
|
39
43
|
createdAt: {
|
|
40
|
-
type: DataTypes.DATE,
|
|
41
|
-
defaultValue: DataTypes.NOW,
|
|
44
|
+
type: this.DataTypes.DATE,
|
|
45
|
+
defaultValue: this.DataTypes.NOW,
|
|
42
46
|
},
|
|
43
47
|
updatedAt: {
|
|
44
|
-
type: DataTypes.DATE,
|
|
45
|
-
defaultValue: DataTypes.NOW,
|
|
48
|
+
type: this.DataTypes.DATE,
|
|
49
|
+
defaultValue: this.DataTypes.NOW,
|
|
46
50
|
},
|
|
47
51
|
},
|
|
48
52
|
{
|
|
@@ -53,7 +57,7 @@ export default class SequelizeAdapter {
|
|
|
53
57
|
this.BlogInfo = this.sequelize.define(
|
|
54
58
|
"BlogInfo",
|
|
55
59
|
{
|
|
56
|
-
title: DataTypes.STRING,
|
|
60
|
+
title: this.DataTypes.STRING,
|
|
57
61
|
},
|
|
58
62
|
{
|
|
59
63
|
timestamps: false,
|
|
@@ -80,13 +84,14 @@ export default class SequelizeAdapter {
|
|
|
80
84
|
endId = null,
|
|
81
85
|
order = "DESC"
|
|
82
86
|
) {
|
|
87
|
+
await this.loadSequelize();
|
|
83
88
|
const where = {};
|
|
84
89
|
if (startId !== null && endId !== null) {
|
|
85
90
|
where.id = {
|
|
86
|
-
[Op.between]: [Math.min(startId, endId), Math.max(startId, endId)],
|
|
91
|
+
[this.Op.between]: [Math.min(startId, endId), Math.max(startId, endId)],
|
|
87
92
|
};
|
|
88
93
|
} else if (startId !== null) {
|
|
89
|
-
where.id = { [order === "DESC" ? Op.lte : Op.gte]: startId };
|
|
94
|
+
where.id = { [order === "DESC" ? this.Op.lte : this.Op.gte]: startId };
|
|
90
95
|
}
|
|
91
96
|
const options = {
|
|
92
97
|
where,
|
package/model/SqliteAdapter.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { Sequelize } from "sequelize";
|
|
2
1
|
import SequelizeAdapter from "./SequelizeAdapter.js";
|
|
3
2
|
|
|
4
3
|
export default class SqliteAdapter extends SequelizeAdapter {
|
|
@@ -11,8 +10,9 @@ export default class SqliteAdapter extends SequelizeAdapter {
|
|
|
11
10
|
}
|
|
12
11
|
|
|
13
12
|
async initialize() {
|
|
13
|
+
await this.loadSequelize();
|
|
14
14
|
try {
|
|
15
|
-
this.sequelize = new Sequelize({
|
|
15
|
+
this.sequelize = new this.Sequelize({
|
|
16
16
|
dialect: "sqlite",
|
|
17
17
|
storage: this.dbname + ".db",
|
|
18
18
|
logging: false,
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lexho111/plainblog",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.12",
|
|
4
4
|
"description": "A tool for creating and serving a minimalist, single-page blog.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"dev": "node index.js",
|
|
9
9
|
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
|
|
10
|
-
"lint": "eslint ."
|
|
10
|
+
"lint": "eslint .",
|
|
11
|
+
"knip": "knip"
|
|
11
12
|
},
|
|
12
13
|
"keywords": [
|
|
13
14
|
"blog",
|
|
@@ -18,26 +19,21 @@
|
|
|
18
19
|
"license": "ISC",
|
|
19
20
|
"dependencies": {
|
|
20
21
|
"autoprefixer": "^10.4.23",
|
|
21
|
-
"child_process": "^1.0.2",
|
|
22
22
|
"cssnano": "^7.1.2",
|
|
23
|
-
"fs": "^0.0.1-security",
|
|
24
|
-
"http": "^0.0.1-security",
|
|
25
23
|
"node-fetch": "^3.3.2",
|
|
26
|
-
"
|
|
27
|
-
"postcss": "^8.5.6",
|
|
28
|
-
"postcss-preset-env": "^10.6.0",
|
|
29
|
-
"sass": "^1.97.1",
|
|
30
|
-
"url": "^0.11.4",
|
|
31
|
-
"util": "^0.12.5"
|
|
24
|
+
"postcss": "^8.5.6"
|
|
32
25
|
},
|
|
33
26
|
"devDependencies": {
|
|
34
|
-
"
|
|
35
|
-
"
|
|
27
|
+
"@eslint/js": "^9.39.2",
|
|
28
|
+
"@types/node": "^25.0.3",
|
|
29
|
+
"eslint": "^9.39.2",
|
|
36
30
|
"eslint-plugin-jest": "^28.6.0",
|
|
31
|
+
"globals": "^17.0.0",
|
|
37
32
|
"jest": "^29.7.0",
|
|
38
|
-
"
|
|
33
|
+
"typescript": "^5.9.3"
|
|
39
34
|
},
|
|
40
35
|
"optionalDependencies": {
|
|
36
|
+
"sqlite3": "^5.1.7",
|
|
41
37
|
"pg": "^8.16.3",
|
|
42
38
|
"pg-hstore": "^2.3.4",
|
|
43
39
|
"sequelize": "^6.37.7"
|
package/test/blog.test.js
CHANGED
|
@@ -4,6 +4,7 @@ import fs from "node:fs";
|
|
|
4
4
|
import Blog from "../Blog.js";
|
|
5
5
|
import Article from "../Article.js";
|
|
6
6
|
import { jest } from "@jest/globals";
|
|
7
|
+
import DatabaseModel from "../model/DatabaseModel.js";
|
|
7
8
|
|
|
8
9
|
describe("test blog", () => {
|
|
9
10
|
test("blog bootstrap stage 1", () => {
|
|
@@ -147,6 +148,73 @@ describe("test blog", () => {
|
|
|
147
148
|
// Clean up the spy to restore the original console.log
|
|
148
149
|
consoleSpy.mockRestore();
|
|
149
150
|
});
|
|
151
|
+
test("json() returns a deep copy (immutability check)", () => {
|
|
152
|
+
const myblog = new Blog();
|
|
153
|
+
const article = new Article("Original Title", "Content", new Date());
|
|
154
|
+
myblog.addArticle(article);
|
|
155
|
+
|
|
156
|
+
const json = myblog.json();
|
|
157
|
+
|
|
158
|
+
// Attempt to modify the returned JSON
|
|
159
|
+
json.title = "Modified Title";
|
|
160
|
+
json.articles[0].title = "Modified Article";
|
|
161
|
+
json.database.host = "evil.com";
|
|
162
|
+
|
|
163
|
+
// Verify the internal state is unchanged
|
|
164
|
+
const newJson = myblog.json();
|
|
165
|
+
expect(newJson.title).not.toBe("Modified Title");
|
|
166
|
+
expect(newJson.articles[0].title).toBe("Original Title");
|
|
167
|
+
expect(newJson.database.host).toBe("localhost");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("closeServer handles non-running server gracefully", async () => {
|
|
171
|
+
const myblog = new Blog();
|
|
172
|
+
// Should not throw even if server was never started
|
|
173
|
+
await expect(myblog.closeServer()).resolves.not.toThrow();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("init() runs database operations concurrently", async () => {
|
|
177
|
+
const myblog = new Blog();
|
|
178
|
+
// Avoid file operations to isolate database timing
|
|
179
|
+
myblog.stylesheetPath = [];
|
|
180
|
+
|
|
181
|
+
const delay = 100;
|
|
182
|
+
|
|
183
|
+
// Mock DatabaseModel methods to simulate slow DB operations
|
|
184
|
+
const getTitleSpy = jest
|
|
185
|
+
.spyOn(DatabaseModel.prototype, "getBlogTitle")
|
|
186
|
+
.mockImplementation(async () => {
|
|
187
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
188
|
+
return "Mock Title";
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const findAllSpy = jest
|
|
192
|
+
.spyOn(DatabaseModel.prototype, "findAll")
|
|
193
|
+
.mockImplementation(async () => {
|
|
194
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
195
|
+
return [];
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Mock initialize to avoid side effects
|
|
199
|
+
const initSpy = jest
|
|
200
|
+
.spyOn(DatabaseModel.prototype, "initialize")
|
|
201
|
+
.mockResolvedValue();
|
|
202
|
+
|
|
203
|
+
const start = Date.now();
|
|
204
|
+
await myblog.init();
|
|
205
|
+
const end = Date.now();
|
|
206
|
+
const duration = end - start;
|
|
207
|
+
|
|
208
|
+
// If sequential: delay + delay = 200ms. If concurrent: ~100ms.
|
|
209
|
+
expect(duration).toBeLessThan(delay * 1.8);
|
|
210
|
+
expect(getTitleSpy).toHaveBeenCalled();
|
|
211
|
+
expect(findAllSpy).toHaveBeenCalled();
|
|
212
|
+
|
|
213
|
+
// Cleanup
|
|
214
|
+
getTitleSpy.mockRestore();
|
|
215
|
+
findAllSpy.mockRestore();
|
|
216
|
+
initSpy.mockRestore();
|
|
217
|
+
});
|
|
150
218
|
});
|
|
151
219
|
|
|
152
220
|
/*
|
package/test/simpleServer.js
CHANGED
package/.eslintignore
DELETED
|
File without changes
|
package/.eslintrc.json
DELETED
|
File without changes
|