@lexho111/plainblog 0.5.9 → 0.5.11
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 +121 -99
- package/build-styles.js +59 -0
- package/model/PostgresAdapter.js +30 -14
- package/package.json +5 -1
- package/public/styles.min.css +2 -68
- package/test/blog.test.js +55 -9
- package/test/model.test.js +126 -78
- package/test/styles.test.js +19 -3
- package/articles.txt +0 -3
- package/blog.db +0 -0
- package/blog.json +0 -110
- package/bloginfo.json +0 -3
- package/lexho111-plainblog-0.4.1.tgz +0 -0
- package/lexho111-plainblog-0.5.7.tgz +0 -0
package/Blog.js
CHANGED
|
@@ -11,8 +11,9 @@ import path from "path";
|
|
|
11
11
|
import { fileURLToPath } from "url";
|
|
12
12
|
import { exec } from "child_process";
|
|
13
13
|
import { promisify } from "util";
|
|
14
|
+
import { compileStyles, mergeStyles } from "./build-styles.js";
|
|
14
15
|
|
|
15
|
-
const execPromise = promisify(exec);
|
|
16
|
+
const execPromise = promisify(exec); // x
|
|
16
17
|
|
|
17
18
|
export default class Blog {
|
|
18
19
|
constructor() {
|
|
@@ -21,31 +22,53 @@ export default class Blog {
|
|
|
21
22
|
username: "user",
|
|
22
23
|
password: "password",
|
|
23
24
|
host: "localhost",
|
|
24
|
-
dbname: "blog.json",
|
|
25
|
+
dbname: "blog.json", // x
|
|
25
26
|
};
|
|
26
27
|
this.#title = "";
|
|
27
|
-
this
|
|
28
|
-
this.filename = null;
|
|
28
|
+
this.#articles = [];
|
|
29
29
|
this.#server = null;
|
|
30
30
|
this.#password = "admin";
|
|
31
|
-
this
|
|
32
|
-
this.scripts = "";
|
|
31
|
+
this.#styles = "body { font-family: Arial; }";
|
|
32
|
+
//this.scripts = "";
|
|
33
33
|
this.compiledStyles = "";
|
|
34
|
-
this.compiledScripts = "";
|
|
34
|
+
//this.compiledScripts = "";
|
|
35
35
|
this.reloadStylesOnGET = false;
|
|
36
36
|
this.sessions = new Set();
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
console.log(`version: ${version}`);
|
|
38
|
+
this.#version = pkg.version;
|
|
39
|
+
console.log(`version: ${this.#version}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
json() {
|
|
43
|
+
const serverInfo = this.#server ? {
|
|
44
|
+
listening: this.#server.listening,
|
|
45
|
+
address: this.#server.address()
|
|
46
|
+
} : null;
|
|
47
|
+
|
|
48
|
+
const json = {
|
|
49
|
+
"version": this.#version,
|
|
50
|
+
"title": this.#title,
|
|
51
|
+
"articles": this.#articles,
|
|
52
|
+
"server": serverInfo,
|
|
53
|
+
"compiledStyles": this.compiledStyles,
|
|
54
|
+
"sessions": this.sessions,
|
|
55
|
+
"database": this.database,
|
|
56
|
+
"password": this.#password, "styles": this.#styles, "reloadStylesOnGET": this.reloadStylesOnGET
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return JSON.parse(JSON.stringify(json));
|
|
40
60
|
}
|
|
41
61
|
|
|
42
62
|
// Private fields
|
|
63
|
+
#version = null;
|
|
43
64
|
#server = null;
|
|
44
65
|
#password = null;
|
|
45
66
|
#databaseModel;
|
|
46
67
|
#isExternalAPI = false;
|
|
47
68
|
#apiUrl = "";
|
|
48
69
|
#title = "";
|
|
70
|
+
#articles = [];
|
|
71
|
+
#styles = "";
|
|
49
72
|
#stylesHash = "";
|
|
50
73
|
#scriptsHash = "";
|
|
51
74
|
#stylesheetPath = "";
|
|
@@ -59,7 +82,7 @@ export default class Blog {
|
|
|
59
82
|
}
|
|
60
83
|
|
|
61
84
|
setStyle(style) {
|
|
62
|
-
this
|
|
85
|
+
this.#styles += style;
|
|
63
86
|
}
|
|
64
87
|
|
|
65
88
|
set title(t) {
|
|
@@ -88,7 +111,7 @@ export default class Blog {
|
|
|
88
111
|
}
|
|
89
112
|
|
|
90
113
|
set style(style) {
|
|
91
|
-
this
|
|
114
|
+
this.#styles += style;
|
|
92
115
|
}
|
|
93
116
|
|
|
94
117
|
set stylesheetPath(files) {
|
|
@@ -97,68 +120,71 @@ export default class Blog {
|
|
|
97
120
|
}
|
|
98
121
|
|
|
99
122
|
addArticle(article) {
|
|
100
|
-
this
|
|
123
|
+
this.#articles.push(article);
|
|
101
124
|
}
|
|
102
125
|
|
|
103
|
-
isAuthenticated(req) {
|
|
126
|
+
#isAuthenticated(req) {
|
|
104
127
|
if (!req.headers.cookie) return false;
|
|
105
128
|
const params = new URLSearchParams(req.headers.cookie.replace(/; /g, "&"));
|
|
106
129
|
return this.sessions.has(params.get("session"));
|
|
107
|
-
|
|
130
|
+
}
|
|
108
131
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
132
|
+
async #handleLogin(req, res) {
|
|
133
|
+
const body = await new Promise((resolve, reject) => {
|
|
134
|
+
let data = "";
|
|
135
|
+
req.on("data", (chunk) => (data += chunk.toString()));
|
|
136
|
+
req.on("end", () => resolve(data));
|
|
137
|
+
req.on("error", reject);
|
|
138
|
+
});
|
|
139
|
+
const params = new URLSearchParams(body);
|
|
140
|
+
|
|
141
|
+
if (params.get("password") === this.#password) {
|
|
142
|
+
const id = crypto.randomUUID();
|
|
143
|
+
this.sessions.add(id);
|
|
144
|
+
res.writeHead(303, {
|
|
145
|
+
"Set-Cookie": `session=${id}; HttpOnly; Path=/`,
|
|
146
|
+
Location: "/",
|
|
115
147
|
});
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
this.sessions.add(id);
|
|
121
|
-
res.writeHead(303, {
|
|
122
|
-
"Set-Cookie": `session=${id}; HttpOnly; Path=/`,
|
|
123
|
-
Location: "/",
|
|
124
|
-
});
|
|
125
|
-
res.end();
|
|
126
|
-
} else {
|
|
127
|
-
res.writeHead(401, { "Content-Type": "text/html" });
|
|
128
|
-
res.end(`${header("My Blog")}
|
|
148
|
+
res.end();
|
|
149
|
+
} else {
|
|
150
|
+
res.writeHead(401, { "Content-Type": "text/html" });
|
|
151
|
+
res.end(`${header("My Blog")}
|
|
129
152
|
<body>
|
|
130
153
|
<h1>Unauthorized</h1><p>Please enter the password.<form method="POST">
|
|
131
154
|
<input type="password" name="password" placeholder="Password" />
|
|
132
155
|
<button style="margin: 2px;">Login</button></form>
|
|
133
156
|
</body></html>`);
|
|
134
157
|
}
|
|
135
|
-
|
|
158
|
+
}
|
|
136
159
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
160
|
+
#handleLogout(req, res) {
|
|
161
|
+
if (req.headers.cookie) {
|
|
162
|
+
const params = new URLSearchParams(req.headers.cookie.replace(/; /g, "&"));
|
|
163
|
+
const sessionId = params.get("session");
|
|
164
|
+
if (this.sessions.has(sessionId)) {
|
|
165
|
+
this.sessions.delete(sessionId);
|
|
144
166
|
}
|
|
145
|
-
res.writeHead(303, {
|
|
146
|
-
"Set-Cookie": "session=; HttpOnly; Path=/; Max-Age=0",
|
|
147
|
-
Location: "/",
|
|
148
|
-
});
|
|
149
|
-
res.end();
|
|
150
167
|
}
|
|
168
|
+
res.writeHead(303, {
|
|
169
|
+
"Set-Cookie": "session=; HttpOnly; Path=/; Max-Age=0",
|
|
170
|
+
Location: "/",
|
|
171
|
+
});
|
|
172
|
+
res.end();
|
|
173
|
+
}
|
|
151
174
|
|
|
152
175
|
/** initializes database */
|
|
153
176
|
async init() {
|
|
154
|
-
//await this.buildFrontend();
|
|
155
177
|
//this.loadStyles();
|
|
156
178
|
//this.loadScripts();
|
|
179
|
+
// if there is a stylesheet path provided, process it
|
|
157
180
|
if(this.#stylesheetPath != null) {
|
|
158
|
-
|
|
181
|
+
// read file from stylesheet path, compare checksums and write to public/styles.min.css
|
|
182
|
+
await this.#processStylesheets(this.#stylesheetPath);
|
|
159
183
|
}
|
|
160
184
|
if(!this.#stylesheetPath) {
|
|
161
|
-
//
|
|
185
|
+
// this.#styles
|
|
186
|
+
// src/styles.css
|
|
187
|
+
// compile and merge hardcoded styles in "this.#styles" with "src/styles.css" and write to file "styles.min.css"
|
|
162
188
|
// which will be imported by webbrowser via '<link rel="stylesheet" href="styles.min.css"...'
|
|
163
189
|
|
|
164
190
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -167,32 +193,26 @@ export default class Blog {
|
|
|
167
193
|
const publicStylePath = path.join(__dirname, "public", "styles.min.css");
|
|
168
194
|
|
|
169
195
|
let publicHash = null;
|
|
170
|
-
try {
|
|
171
|
-
const publicCSS = await fs.promises.readFile(publicStylePath, "utf8");
|
|
172
|
-
const match = publicCSS.match(/\/\* source-hash: ([a-f0-9]{64}) \*\//);
|
|
173
|
-
if (match) {
|
|
174
|
-
publicHash = match[1];
|
|
175
|
-
}
|
|
176
|
-
} catch (err) {
|
|
177
|
-
// public/styles.min.css doesn't exist, will be created.
|
|
178
|
-
}
|
|
179
|
-
|
|
180
196
|
let srcStyles = "";
|
|
181
|
-
try {
|
|
182
|
-
srcStyles = await fs.promises.readFile(srcStylePath, "utf8");
|
|
183
|
-
} catch (err) {
|
|
184
|
-
// ignore if src/styles.css doesn't exist
|
|
185
|
-
}
|
|
186
197
|
|
|
187
|
-
|
|
198
|
+
await Promise.all([
|
|
199
|
+
fs.promises.readFile(publicStylePath, "utf8").then((publicCSS) => {
|
|
200
|
+
const match = publicCSS.match(/\/\* source-hash: ([a-f0-9]{64}) \*\//);
|
|
201
|
+
if (match) publicHash = match[1];
|
|
202
|
+
}).catch((err) => console.error(err)), // public/styles.min.css doesn't exist, will be created.
|
|
203
|
+
fs.promises.readFile(srcStylePath, "utf8").then((content) => {
|
|
204
|
+
srcStyles = content;
|
|
205
|
+
}).catch((err) => console.error(err)) // ignore if src/styles.css doesn't exist
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
const combinedStyles = this.#styles + srcStyles;
|
|
188
209
|
const srcHash = crypto.createHash("sha256").update(combinedStyles).digest("hex");
|
|
189
210
|
|
|
190
211
|
if (srcHash !== publicHash) {
|
|
191
212
|
console.log("Styles have changed. Recompiling...");
|
|
192
|
-
|
|
193
|
-
const finalStyles = this.styles + " " + srcStyles;
|
|
213
|
+
const finalStyles = await mergeStyles(this.#styles, srcStyles);
|
|
194
214
|
try {
|
|
195
|
-
await fs.promises.mkdir(path.dirname(publicStylePath), { recursive: true });
|
|
215
|
+
//await fs.promises.mkdir(path.dirname(publicStylePath), { recursive: true });
|
|
196
216
|
await fs.promises.writeFile(publicStylePath, finalStyles + `\n/* source-hash: ${srcHash} */`);
|
|
197
217
|
} catch (err) {
|
|
198
218
|
console.error("Failed to write styles to public folder:", err);
|
|
@@ -201,7 +221,7 @@ export default class Blog {
|
|
|
201
221
|
}
|
|
202
222
|
if (this.#isExternalAPI) {
|
|
203
223
|
console.log("external API");
|
|
204
|
-
await this
|
|
224
|
+
await this.#loadFromAPI();
|
|
205
225
|
} else {
|
|
206
226
|
console.log(`database: ${this.database.type}`);
|
|
207
227
|
if (!this.#databaseModel) {
|
|
@@ -209,10 +229,11 @@ export default class Blog {
|
|
|
209
229
|
}
|
|
210
230
|
console.log(`connected to database`);
|
|
211
231
|
await this.#databaseModel.initialize();
|
|
212
|
-
const dbTitle = await
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
232
|
+
const [dbTitle, dbArticles] = await Promise.all([
|
|
233
|
+
this.#databaseModel.getBlogTitle(),
|
|
234
|
+
this.#databaseModel.findAll(),
|
|
235
|
+
]);
|
|
236
|
+
|
|
216
237
|
if(dbArticles.length == 0) {
|
|
217
238
|
dbArticles.push(new Article("Sample Entry #1", "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.", new Date()));
|
|
218
239
|
dbArticles.push(new Article("Sample Entry #2", "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.", new Date()));
|
|
@@ -244,12 +265,14 @@ export default class Blog {
|
|
|
244
265
|
const newArticleData = { title, content };
|
|
245
266
|
try {
|
|
246
267
|
// Save the new article to the database via the ApiServer
|
|
247
|
-
|
|
248
|
-
if (this.#
|
|
268
|
+
const promises = [];
|
|
269
|
+
if (this.#databaseModel) promises.push(this.#databaseModel.save(newArticleData));
|
|
270
|
+
if (this.#isExternalAPI) promises.push(postData(this.#apiUrl, newArticleData));
|
|
271
|
+
await Promise.all(promises);
|
|
249
272
|
// Add the article to the local list for immediate display
|
|
250
|
-
this
|
|
273
|
+
this.#articles.unshift(new Article(title, content, new Date()));
|
|
251
274
|
// remove sample entries
|
|
252
|
-
this
|
|
275
|
+
this.#articles = this.#articles.filter(
|
|
253
276
|
(art) =>
|
|
254
277
|
art.title !== "Sample Entry #1" && art.title !== "Sample Entry #2"
|
|
255
278
|
);
|
|
@@ -286,18 +309,18 @@ export default class Blog {
|
|
|
286
309
|
</body></html>`);
|
|
287
310
|
return;
|
|
288
311
|
} else if (req.method === "POST") {
|
|
289
|
-
await this
|
|
312
|
+
await this.#handleLogin(req, res);
|
|
290
313
|
return;
|
|
291
314
|
}
|
|
292
315
|
}
|
|
293
316
|
|
|
294
317
|
if (req.url === "/logout") {
|
|
295
|
-
this
|
|
318
|
+
this.#handleLogout(req, res);
|
|
296
319
|
return;
|
|
297
320
|
}
|
|
298
321
|
// POST new article
|
|
299
322
|
if (req.method === "POST" && req.url === "/") {
|
|
300
|
-
if (!this
|
|
323
|
+
if (!this.#isAuthenticated(req)) {
|
|
301
324
|
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
302
325
|
res.end("Forbidden");
|
|
303
326
|
return;
|
|
@@ -310,12 +333,12 @@ export default class Blog {
|
|
|
310
333
|
// reload styles and scripts on (every) request
|
|
311
334
|
if(this.reloadStylesOnGET) {
|
|
312
335
|
if (this.#stylesheetPath) {
|
|
313
|
-
await this
|
|
336
|
+
await this.#processStylesheets(this.#stylesheetPath);
|
|
314
337
|
}
|
|
315
338
|
}
|
|
316
339
|
|
|
317
340
|
let loggedin = false;
|
|
318
|
-
if (!this
|
|
341
|
+
if (!this.#isAuthenticated(req)) {
|
|
319
342
|
// login
|
|
320
343
|
loggedin = false;
|
|
321
344
|
} else {
|
|
@@ -374,8 +397,11 @@ export default class Blog {
|
|
|
374
397
|
|
|
375
398
|
this.#server = server;
|
|
376
399
|
|
|
377
|
-
return new Promise((resolve) => {
|
|
378
|
-
|
|
400
|
+
return new Promise((resolve, reject) => {
|
|
401
|
+
const errorHandler = (err) => reject(err);
|
|
402
|
+
this.#server.once("error", errorHandler);
|
|
403
|
+
this.#server.listen(port, '127.0.0.1', () => {
|
|
404
|
+
this.#server.removeListener("error", errorHandler);
|
|
379
405
|
console.log(`server running at http://localhost:${port}/`);
|
|
380
406
|
resolve(); // Resolve the promise when the server is listening
|
|
381
407
|
});
|
|
@@ -386,7 +412,7 @@ export default class Blog {
|
|
|
386
412
|
return new Promise((resolve, reject) => {
|
|
387
413
|
if (this.#server) {
|
|
388
414
|
this.#server.close((err) => {
|
|
389
|
-
if (err) return reject(err);
|
|
415
|
+
if (err && err.code !== "ERR_SERVER_NOT_RUNNING") return reject(err);
|
|
390
416
|
console.log("Server closed.");
|
|
391
417
|
resolve();
|
|
392
418
|
});
|
|
@@ -398,19 +424,19 @@ export default class Blog {
|
|
|
398
424
|
|
|
399
425
|
/** Populates the blog's title and articles from a data object. */
|
|
400
426
|
#applyBlogData(data) {
|
|
401
|
-
this
|
|
427
|
+
this.#articles = []; // Clear existing articles before loading new ones
|
|
402
428
|
this.#title = data.title;
|
|
403
|
-
// Assuming
|
|
429
|
+
// Assuming data contains a title and an array of articles with title and content
|
|
404
430
|
if (data.articles && Array.isArray(data.articles)) {
|
|
405
431
|
for (const articleData of data.articles) {
|
|
406
432
|
const article = new Article(articleData.title, articleData.content, articleData.createdAt);
|
|
407
|
-
article.id = articleData.id;
|
|
433
|
+
article.id = articleData.id; // TODO x
|
|
408
434
|
this.addArticle(article);
|
|
409
435
|
}
|
|
410
436
|
}
|
|
411
437
|
}
|
|
412
438
|
|
|
413
|
-
async loadFromAPI() {
|
|
439
|
+
async #loadFromAPI() {
|
|
414
440
|
const data = await fetchData(this.#apiUrl);
|
|
415
441
|
if (data) {
|
|
416
442
|
this.#applyBlogData(data);
|
|
@@ -451,7 +477,7 @@ export default class Blog {
|
|
|
451
477
|
|
|
452
478
|
// POST a new article
|
|
453
479
|
} else if (req.method === "POST" && pathname === "/api/articles") {
|
|
454
|
-
if (!this
|
|
480
|
+
if (!this.#isAuthenticated(req)) {
|
|
455
481
|
res.writeHead(403, { "Content-Type": "application/json" });
|
|
456
482
|
res.end(JSON.stringify({ error: "Forbidden" }));
|
|
457
483
|
return;
|
|
@@ -481,7 +507,7 @@ export default class Blog {
|
|
|
481
507
|
print() {
|
|
482
508
|
const data = {
|
|
483
509
|
title: this.title,
|
|
484
|
-
articles: this
|
|
510
|
+
articles: this.#articles,
|
|
485
511
|
};
|
|
486
512
|
const markdown = formatMarkdown(data);
|
|
487
513
|
console.log(markdown);
|
|
@@ -491,7 +517,7 @@ export default class Blog {
|
|
|
491
517
|
async toHTML(loggedin) {
|
|
492
518
|
const data = {
|
|
493
519
|
title: this.title,
|
|
494
|
-
articles: this
|
|
520
|
+
articles: this.#articles,
|
|
495
521
|
loggedin,
|
|
496
522
|
login: ""
|
|
497
523
|
};
|
|
@@ -505,19 +531,16 @@ export default class Blog {
|
|
|
505
531
|
}
|
|
506
532
|
|
|
507
533
|
/**
|
|
508
|
-
*
|
|
534
|
+
* read files, compare checksums, compile and write to public/styles.min.css
|
|
509
535
|
* @param {string[]} files - Array of css/scss file paths to process.
|
|
510
536
|
*/
|
|
511
|
-
async processStylesheets(files) {
|
|
537
|
+
async #processStylesheets(files) {
|
|
512
538
|
console.log("process stylesheets")
|
|
513
539
|
|
|
514
540
|
// Normalize input to array (handles string or array)
|
|
515
541
|
// "file1.css" --> ["file1.css"]
|
|
516
542
|
// ["file1.css", "file2.css",...]
|
|
517
543
|
const fileList = Array.isArray(files) ? files : [files];
|
|
518
|
-
|
|
519
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
520
|
-
const __dirname = path.dirname(__filename);
|
|
521
544
|
const styleFiles = fileList.filter(
|
|
522
545
|
(f) => typeof f === "string" && (f.endsWith(".scss") || f.endsWith(".css")) && !f.endsWith(".min.css")
|
|
523
546
|
);
|
|
@@ -552,8 +575,7 @@ export default class Blog {
|
|
|
552
575
|
this.#stylesHash = currentHash;
|
|
553
576
|
|
|
554
577
|
// Compile styles using the standalone script from build-styles.js
|
|
555
|
-
|
|
556
|
-
this.compiledStyles = fileData.map((f) => f.content).join("\n");
|
|
578
|
+
this.compiledStyles = await compileStyles(fileData);
|
|
557
579
|
|
|
558
580
|
// generate a file
|
|
559
581
|
const __filename = fileURLToPath(import.meta.url);
|
package/build-styles.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { pathToFileURL } from "url";
|
|
3
|
+
import postcss from "postcss";
|
|
4
|
+
import autoprefixer from "autoprefixer";
|
|
5
|
+
import cssnano from "cssnano";
|
|
6
|
+
|
|
7
|
+
// array of files or a single file
|
|
8
|
+
export async function compileStyles(fileData) {
|
|
9
|
+
try {
|
|
10
|
+
let combinedCss = "";
|
|
11
|
+
|
|
12
|
+
if (fileData) {
|
|
13
|
+
const scssFiles = fileData.filter(
|
|
14
|
+
(f) =>
|
|
15
|
+
f.path.endsWith(".scss") && !path.basename(f.path).startsWith("_")
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
for (const file of scssFiles) {
|
|
19
|
+
console.error("sass files are not supported.");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const cssFiles = fileData.filter((f) => f.path.endsWith(".css"));
|
|
23
|
+
for (const file of cssFiles) {
|
|
24
|
+
combinedCss += file.content + "\n";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 2. PostCSS (Autoprefixer + CSSNano)
|
|
29
|
+
if (combinedCss) {
|
|
30
|
+
const plugins = [autoprefixer(), cssnano()];
|
|
31
|
+
const result = await postcss(plugins).process(combinedCss, {
|
|
32
|
+
from: undefined,
|
|
33
|
+
});
|
|
34
|
+
return result.css;
|
|
35
|
+
}
|
|
36
|
+
return "";
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error("Build failed:", error);
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function mergeStyles(...cssContents) {
|
|
44
|
+
try {
|
|
45
|
+
const combinedCss = cssContents.join("\n");
|
|
46
|
+
|
|
47
|
+
if (combinedCss) {
|
|
48
|
+
const plugins = [autoprefixer(), cssnano()];
|
|
49
|
+
const result = await postcss(plugins).process(combinedCss, {
|
|
50
|
+
from: undefined,
|
|
51
|
+
});
|
|
52
|
+
return result.css;
|
|
53
|
+
}
|
|
54
|
+
return "";
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error("Merge failed:", error);
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
}
|
package/model/PostgresAdapter.js
CHANGED
|
@@ -12,6 +12,7 @@ export default class PostgresAdapter extends SequelizeAdapter {
|
|
|
12
12
|
this.host = options.host;
|
|
13
13
|
if (options.dbname) this.dbname = options.dbname;
|
|
14
14
|
if (options.dbport) this.dbport = options.dbport;
|
|
15
|
+
else this.dbport = 5432;
|
|
15
16
|
if (!this.username || !this.password || !this.host) {
|
|
16
17
|
throw new Error(
|
|
17
18
|
"PostgreSQL credentials not set. Please provide 'username', 'password', and 'host' in the options."
|
|
@@ -21,24 +22,39 @@ export default class PostgresAdapter extends SequelizeAdapter {
|
|
|
21
22
|
|
|
22
23
|
async initialize() {
|
|
23
24
|
console.log("initialize database");
|
|
25
|
+
const maxRetries = 10;
|
|
26
|
+
const retryDelay = 3000;
|
|
24
27
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
29
|
+
try {
|
|
30
|
+
console.log(
|
|
31
|
+
`postgres://${this.username}:${this.password}@${this.host}:${this.dbport}/${this.dbname}`
|
|
32
|
+
);
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
this.sequelize = new Sequelize(
|
|
35
|
+
`postgres://${this.username}:${this.password}@${this.host}:${this.dbport}/${this.dbname}`,
|
|
36
|
+
{ logging: false }
|
|
37
|
+
);
|
|
38
|
+
await this.sequelize.authenticate();
|
|
39
|
+
await this.initializeModels();
|
|
40
|
+
console.log("Database connection established successfully.");
|
|
41
|
+
return;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (err.message.includes("Please install")) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
"PostgreSQL driver is not installed. Please install it to use PostgresAdapter: npm install pg pg-hstore"
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
console.error(
|
|
49
|
+
`Database connection attempt ${attempt} failed: ${err.message}`
|
|
38
50
|
);
|
|
51
|
+
if (attempt === maxRetries) {
|
|
52
|
+
console.error("Max retries reached. Exiting.");
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
console.log(`Retrying in ${retryDelay / 1000} seconds...`);
|
|
56
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
|
39
57
|
}
|
|
40
|
-
throw err;
|
|
41
58
|
}
|
|
42
|
-
await this.initializeModels();
|
|
43
59
|
}
|
|
44
60
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lexho111/plainblog",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.11",
|
|
4
4
|
"description": "A tool for creating and serving a minimalist, single-page blog.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -17,11 +17,15 @@
|
|
|
17
17
|
"author": "lexho111",
|
|
18
18
|
"license": "ISC",
|
|
19
19
|
"dependencies": {
|
|
20
|
+
"autoprefixer": "^10.4.23",
|
|
20
21
|
"child_process": "^1.0.2",
|
|
22
|
+
"cssnano": "^7.1.2",
|
|
21
23
|
"fs": "^0.0.1-security",
|
|
22
24
|
"http": "^0.0.1-security",
|
|
23
25
|
"node-fetch": "^3.3.2",
|
|
24
26
|
"path": "^0.12.7",
|
|
27
|
+
"postcss": "^8.5.6",
|
|
28
|
+
"postcss-preset-env": "^10.6.0",
|
|
25
29
|
"sass": "^1.97.1",
|
|
26
30
|
"url": "^0.11.4",
|
|
27
31
|
"util": "^0.12.5"
|
package/public/styles.min.css
CHANGED
|
@@ -1,68 +1,2 @@
|
|
|
1
|
-
body
|
|
2
|
-
|
|
3
|
-
display: grid;
|
|
4
|
-
gap: 0.25rem;
|
|
5
|
-
grid-template-columns: 1fr;
|
|
6
|
-
}
|
|
7
|
-
.grid article {
|
|
8
|
-
border: 0 solid #ccc;
|
|
9
|
-
border-radius: 4px;
|
|
10
|
-
min-width: 0;
|
|
11
|
-
overflow-wrap: break-word;
|
|
12
|
-
padding: 0.25rem;
|
|
13
|
-
}
|
|
14
|
-
.grid article h2 {
|
|
15
|
-
color: rgb(53, 53, 53);
|
|
16
|
-
margin-bottom: 5px;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
.grid article .datetime {
|
|
20
|
-
margin: 0;
|
|
21
|
-
color: rgb(117, 117, 117);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
.grid article p {
|
|
25
|
-
margin-top: 10px;
|
|
26
|
-
margin-bottom: 0;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
article a {
|
|
30
|
-
color: rgb(105, 105, 105);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
article a:visited {
|
|
34
|
-
color: rgb(105, 105, 105);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
h1 {
|
|
38
|
-
color: #696969;
|
|
39
|
-
}
|
|
40
|
-
nav a {
|
|
41
|
-
color: #3b40c1;
|
|
42
|
-
font-size: 20px;
|
|
43
|
-
text-decoration: underline;
|
|
44
|
-
}
|
|
45
|
-
nav a:visited {
|
|
46
|
-
color: #3b40c1;
|
|
47
|
-
text-decoration-color: #3b40c1;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
#wrapper {
|
|
51
|
-
max-width: 500px;
|
|
52
|
-
width: 100%;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/* Mobile Layout (screens smaller than 1000px) */
|
|
56
|
-
@media screen and (max-width: 1000px) {
|
|
57
|
-
* {
|
|
58
|
-
font-size: 4vw;
|
|
59
|
-
}
|
|
60
|
-
#wrapper {
|
|
61
|
-
max-width: 100%;
|
|
62
|
-
width: 100%;
|
|
63
|
-
padding: 0 10px; /* Prevents text from touching the edges */
|
|
64
|
-
box-sizing: border-box;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/* source-hash: bcb6644ec5b5c6f9685c9ad6c14ee551a6f908b8a2c372d3294e2d2e80d17fb7 */
|
|
1
|
+
body{background-color:#fdfdfd;font-family:Arial}nav a{color:#3b40c1;font-size:20px;text-decoration:underline}.datetime{color:#434343;font-style:normal}h2{color:#a9a9a9;margin:0 0 5px}p{margin-top:10px}span{margin:0}
|
|
2
|
+
/* source-hash: fa9deb7a7f0781f463cd3e8fd3c3ceddec518535b0a6d13af7309ef9a2f76c32 */
|
package/test/blog.test.js
CHANGED
|
@@ -6,6 +6,46 @@ import Article from "../Article.js";
|
|
|
6
6
|
import { jest } from "@jest/globals";
|
|
7
7
|
|
|
8
8
|
describe("test blog", () => {
|
|
9
|
+
test("blog bootstrap stage 1", () => {
|
|
10
|
+
const myblog = new Blog();
|
|
11
|
+
const json = myblog.json();
|
|
12
|
+
expect(json.version).toContain(".");
|
|
13
|
+
const database = json.database;
|
|
14
|
+
expect(database.type).toBe("file");
|
|
15
|
+
expect(database.username).toBe("user");
|
|
16
|
+
expect(database.password).toBe("password");
|
|
17
|
+
expect(database.host).toBe("localhost");
|
|
18
|
+
expect(database.dbname).toBe("articles.txt"); //TODO switch blog.json to articles.txt, bloginfo.json
|
|
19
|
+
expect(json.password).toBe("admin");
|
|
20
|
+
expect(json.styles).toContain("body { font-family: Arial; }");
|
|
21
|
+
expect(json.reloadStylesOnGET).not.toBeTruthy();
|
|
22
|
+
});
|
|
23
|
+
test("blog bootstrap stage 2", async () => {
|
|
24
|
+
const myblog = new Blog();
|
|
25
|
+
await myblog.init();
|
|
26
|
+
const json = myblog.json();
|
|
27
|
+
console.log(json);
|
|
28
|
+
expect(json.title).toBe("Test Blog Title");
|
|
29
|
+
expect(json.articles.length).toBeGreaterThan(2);
|
|
30
|
+
expect(json.server).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
test("blog bootstrap stage 3", async () => {
|
|
33
|
+
const myblog = new Blog();
|
|
34
|
+
await myblog.init();
|
|
35
|
+
try {
|
|
36
|
+
await myblog.startServer(8080);
|
|
37
|
+
const json = myblog.json();
|
|
38
|
+
console.log(json);
|
|
39
|
+
expect(json.title).toBe("Test Blog Title"); // from bloginfo.json
|
|
40
|
+
expect(json.articles.length).toBeGreaterThan(2);
|
|
41
|
+
expect(json.server.listening).toBeTruthy();
|
|
42
|
+
expect(json.server.address.address).toBe("127.0.0.1");
|
|
43
|
+
expect(json.server.address.family).toBe("IPv4");
|
|
44
|
+
expect(json.server.address.port).toBe(8080);
|
|
45
|
+
} finally {
|
|
46
|
+
await myblog.closeServer();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
9
49
|
test("is valid html", async () => {
|
|
10
50
|
const myblog = new Blog();
|
|
11
51
|
const html = await myblog.toHTML();
|
|
@@ -35,21 +75,27 @@ describe("test blog", () => {
|
|
|
35
75
|
const article = new Article("", "");
|
|
36
76
|
myblog.addArticle(article);
|
|
37
77
|
const html = await myblog.toHTML();
|
|
78
|
+
const json = myblog.json();
|
|
38
79
|
expect(html).toContain("<article");
|
|
39
|
-
expect(
|
|
80
|
+
expect(json.articles).toHaveLength(1);
|
|
40
81
|
});
|
|
41
82
|
test("add articles", async () => {
|
|
42
83
|
const myblog = new Blog();
|
|
43
|
-
|
|
84
|
+
const json = myblog.json();
|
|
85
|
+
expect(json.articles).toHaveLength(0);
|
|
44
86
|
const size = 10;
|
|
45
87
|
for (let i = 1; i <= size; i++) {
|
|
46
88
|
const article = new Article("", "");
|
|
47
89
|
myblog.addArticle(article);
|
|
48
|
-
|
|
90
|
+
const json = myblog.json();
|
|
91
|
+
expect(json.articles).toHaveLength(i);
|
|
92
|
+
}
|
|
93
|
+
{
|
|
94
|
+
const html = await myblog.toHTML();
|
|
95
|
+
expect(html).toContain("<article");
|
|
96
|
+
const json = myblog.json();
|
|
97
|
+
expect(json.articles).toHaveLength(size);
|
|
49
98
|
}
|
|
50
|
-
const html = await myblog.toHTML();
|
|
51
|
-
expect(html).toContain("<article");
|
|
52
|
-
expect(myblog.articles.length).toBe(size);
|
|
53
99
|
});
|
|
54
100
|
const __filename = fileURLToPath(import.meta.url);
|
|
55
101
|
const __dirname = path.dirname(__filename);
|
|
@@ -58,13 +104,13 @@ describe("test blog", () => {
|
|
|
58
104
|
const styles = [
|
|
59
105
|
{
|
|
60
106
|
style: "body { font-family: Courier; }",
|
|
61
|
-
expected: "font-family:
|
|
107
|
+
expected: "font-family:Courier",
|
|
62
108
|
},
|
|
63
109
|
{
|
|
64
110
|
style: "body{ background-color:black; color:white; }",
|
|
65
|
-
expected: "background-color
|
|
111
|
+
expected: "background-color:#000;color:#fff;",
|
|
66
112
|
},
|
|
67
|
-
{ style: "body{ font-size: 1.2em; }", expected: "font-size:
|
|
113
|
+
{ style: "body{ font-size: 1.2em; }", expected: "font-size:1.2em" },
|
|
68
114
|
];
|
|
69
115
|
for (const style of styles) {
|
|
70
116
|
const cssPath = path.join(publicDir, "styles.min.css");
|
package/test/model.test.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "../model/FileModel.js";
|
|
14
14
|
|
|
15
15
|
import SqliteAdapter from "../model/SqliteAdapter.js";
|
|
16
|
+
import FileAdapter from "../model/FileAdapter.js";
|
|
16
17
|
|
|
17
18
|
function generateRandomContent(length) {
|
|
18
19
|
let str = "";
|
|
@@ -25,44 +26,6 @@ function generateRandomContent(length) {
|
|
|
25
26
|
return str;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
describe("File Model test", () => {
|
|
29
|
-
it("should init files and load info", async () => {
|
|
30
|
-
const infoFile = "test_bloginfo.json";
|
|
31
|
-
const articlesFile = "test_articles.txt";
|
|
32
|
-
|
|
33
|
-
// Ensure clean state
|
|
34
|
-
if (fs.existsSync(infoFile)) fs.unlinkSync(infoFile);
|
|
35
|
-
if (fs.existsSync(articlesFile)) fs.unlinkSync(articlesFile);
|
|
36
|
-
|
|
37
|
-
await initFiles(infoFile, articlesFile);
|
|
38
|
-
|
|
39
|
-
const info = await loadInfo(infoFile);
|
|
40
|
-
expect(info.title).toBe("Blog");
|
|
41
|
-
|
|
42
|
-
// Cleanup
|
|
43
|
-
fs.unlinkSync(infoFile);
|
|
44
|
-
fs.unlinkSync(articlesFile);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("should write to articles.txt file", async () => {
|
|
48
|
-
const content = generateRandomContent(200);
|
|
49
|
-
const article = new Article("hello", content, new Date().toISOString());
|
|
50
|
-
const filename = "test_articles.txt";
|
|
51
|
-
|
|
52
|
-
await appendArticle(filename, article);
|
|
53
|
-
const articles = await loadArticles(filename);
|
|
54
|
-
|
|
55
|
-
// Compare properties since 'articles' contains plain objects, not Article instances
|
|
56
|
-
expect(articles.length).toBeGreaterThan(0);
|
|
57
|
-
const lastArticle = articles[articles.length - 1];
|
|
58
|
-
expect(lastArticle.title).toBe(article.title);
|
|
59
|
-
expect(lastArticle.content).toBe(article.content);
|
|
60
|
-
|
|
61
|
-
// Cleanup
|
|
62
|
-
if (fs.existsSync(filename)) fs.unlinkSync(filename);
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
|
|
66
29
|
describe("API Model test", () => {
|
|
67
30
|
beforeAll((done) => {
|
|
68
31
|
const port = 8081;
|
|
@@ -127,55 +90,140 @@ describe("API Model test", () => {
|
|
|
127
90
|
});
|
|
128
91
|
|
|
129
92
|
describe("Database Model test", () => {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
93
|
+
describe("Sqlite Database Adapter test", () => {
|
|
94
|
+
it("should be empty", async () => {
|
|
95
|
+
const blog = new Blog();
|
|
96
|
+
const sqliteAdapter = new SqliteAdapter({
|
|
97
|
+
dbname: "blog",
|
|
98
|
+
});
|
|
99
|
+
blog.setDatabaseAdapter(sqliteAdapter);
|
|
100
|
+
await blog.init();
|
|
101
|
+
|
|
102
|
+
expect(await blog.toHTML()).not.toContain("<article>");
|
|
103
|
+
expect(blog).toBeDefined();
|
|
104
|
+
});
|
|
137
105
|
|
|
138
|
-
|
|
139
|
-
|
|
106
|
+
it("should load blog data from sqlite database", async () => {
|
|
107
|
+
const blog = new Blog();
|
|
108
|
+
const sqliteAdapter = new SqliteAdapter({
|
|
109
|
+
dbname: "blog",
|
|
110
|
+
});
|
|
111
|
+
blog.setDatabaseAdapter(sqliteAdapter);
|
|
112
|
+
await blog.init();
|
|
113
|
+
|
|
114
|
+
const content = generateRandomContent(200);
|
|
115
|
+
|
|
116
|
+
await new Promise((resolve) => {
|
|
117
|
+
const req = {
|
|
118
|
+
on: (event, cb) => {
|
|
119
|
+
if (event === "data") {
|
|
120
|
+
setTimeout(() => cb(`title=hello&content=${content}`), 0);
|
|
121
|
+
}
|
|
122
|
+
if (event === "end") {
|
|
123
|
+
setTimeout(() => cb(), 20);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
off: () => {},
|
|
127
|
+
};
|
|
128
|
+
const res = {
|
|
129
|
+
writeHead: () => {},
|
|
130
|
+
end: resolve,
|
|
131
|
+
};
|
|
132
|
+
blog.postArticle(req, res);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(await blog.toHTML()).toContain(content);
|
|
136
|
+
expect(blog).toBeDefined();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
describe("Postgres Adapter test", () => {});
|
|
140
|
+
describe("File Adapter test", () => {
|
|
141
|
+
it("should update blog title", async () => {
|
|
142
|
+
const title_org = "Test Blog Title";
|
|
143
|
+
const fileAdapter = new FileAdapter();
|
|
144
|
+
await fileAdapter.initialize();
|
|
145
|
+
await fileAdapter.updateBlogTitle(title_org);
|
|
146
|
+
const title = await fileAdapter.getBlogTitle();
|
|
147
|
+
console.log(title);
|
|
148
|
+
expect(title).toBe(title_org);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
describe("File Model test", () => {
|
|
152
|
+
it("should init files and load info", async () => {
|
|
153
|
+
const infoFile = "test_bloginfo.json";
|
|
154
|
+
const articlesFile = "test_articles.txt";
|
|
140
155
|
|
|
141
|
-
|
|
156
|
+
// Ensure clean state
|
|
157
|
+
if (fs.existsSync(infoFile)) fs.unlinkSync(infoFile);
|
|
158
|
+
if (fs.existsSync(articlesFile)) fs.unlinkSync(articlesFile);
|
|
142
159
|
|
|
143
|
-
|
|
144
|
-
expect(blog).toBeDefined();
|
|
145
|
-
});
|
|
160
|
+
await initFiles(infoFile, articlesFile);
|
|
146
161
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
162
|
+
const info = await loadInfo(infoFile);
|
|
163
|
+
expect(info.title).toBe("Blog");
|
|
164
|
+
|
|
165
|
+
// Cleanup
|
|
166
|
+
fs.unlinkSync(infoFile);
|
|
167
|
+
fs.unlinkSync(articlesFile);
|
|
151
168
|
});
|
|
152
|
-
blog.setDatabaseAdapter(sqliteAdapter);
|
|
153
|
-
await blog.init();
|
|
154
169
|
|
|
155
|
-
|
|
170
|
+
it("should read and write to articles.txt file", async () => {
|
|
171
|
+
const content = generateRandomContent(200);
|
|
172
|
+
const article = new Article("hello", content, new Date().toISOString());
|
|
173
|
+
const filename = "test_articles.txt";
|
|
156
174
|
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
};
|
|
169
|
-
const res = {
|
|
170
|
-
writeHead: () => {},
|
|
171
|
-
end: resolve,
|
|
172
|
-
};
|
|
173
|
-
blog.postArticle(req, res);
|
|
175
|
+
await appendArticle(filename, article);
|
|
176
|
+
const articles = await loadArticles(filename);
|
|
177
|
+
|
|
178
|
+
// Compare properties since 'articles' contains plain objects, not Article instances
|
|
179
|
+
expect(articles.length).toBeGreaterThan(0);
|
|
180
|
+
const lastArticle = articles[articles.length - 1];
|
|
181
|
+
expect(lastArticle.title).toBe(article.title);
|
|
182
|
+
expect(lastArticle.content).toBe(article.content);
|
|
183
|
+
|
|
184
|
+
// Cleanup
|
|
185
|
+
if (fs.existsSync(filename)) fs.unlinkSync(filename);
|
|
174
186
|
});
|
|
175
187
|
|
|
176
|
-
|
|
188
|
+
it("should be empty articles.txt", async () => {
|
|
189
|
+
const filename = "test_articles_empty.txt";
|
|
190
|
+
const articles = await loadArticles(filename);
|
|
191
|
+
expect(articles.length).toBe(0);
|
|
192
|
+
// Cleanup
|
|
193
|
+
if (fs.existsSync(filename)) fs.unlinkSync(filename);
|
|
194
|
+
});
|
|
177
195
|
|
|
178
|
-
|
|
179
|
-
|
|
196
|
+
it("should read and write multiple articles to articles.txt file", async () => {
|
|
197
|
+
const filename = "test_articles.txt";
|
|
198
|
+
|
|
199
|
+
// Ensure clean state
|
|
200
|
+
if (fs.existsSync(filename)) fs.unlinkSync(filename);
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
const articles_org = [];
|
|
204
|
+
const count = 20;
|
|
205
|
+
for (let i = 0; i < count; i++) {
|
|
206
|
+
const title = generateRandomContent(20);
|
|
207
|
+
const content = generateRandomContent(200);
|
|
208
|
+
const article = new Article(title, content, new Date().toISOString());
|
|
209
|
+
articles_org.push(article);
|
|
210
|
+
|
|
211
|
+
await appendArticle(filename, article);
|
|
212
|
+
}
|
|
213
|
+
const articles = await loadArticles(filename);
|
|
214
|
+
|
|
215
|
+
// Compare properties since 'articles' contains plain objects, not Article instances
|
|
216
|
+
expect(articles).toHaveLength(count);
|
|
217
|
+
|
|
218
|
+
for (let i = 0; i < articles.length; i++) {
|
|
219
|
+
expect(articles[i].title).toBe(articles_org[i].title);
|
|
220
|
+
expect(articles[i].content).toBe(articles_org[i].content);
|
|
221
|
+
expect(articles[i].createdAt).toBe(articles_org[i].createdAt);
|
|
222
|
+
}
|
|
223
|
+
} finally {
|
|
224
|
+
// Cleanup
|
|
225
|
+
if (fs.existsSync(filename)) fs.unlinkSync(filename);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
180
228
|
});
|
|
181
229
|
});
|
package/test/styles.test.js
CHANGED
|
@@ -16,10 +16,26 @@ describe("Blog Stylesheet Test", () => {
|
|
|
16
16
|
expect(data).toContain("body");
|
|
17
17
|
expect(data).toContain("nav a");
|
|
18
18
|
expect(data).toContain(".datetime");
|
|
19
|
-
expect(data).toContain("font-style: normal");
|
|
20
19
|
expect(data).toContain("color: darkgray");
|
|
21
20
|
});
|
|
22
21
|
|
|
22
|
+
it("should load the stylesheet (.css) file from public", async () => {
|
|
23
|
+
const filepath = path.join(__dirname, "../public/styles.min.css");
|
|
24
|
+
|
|
25
|
+
const data = await fs.promises.readFile(filepath, "utf8");
|
|
26
|
+
console.log(data);
|
|
27
|
+
expect(data).toContain("font-family:Arial");
|
|
28
|
+
expect(data).toContain("h1");
|
|
29
|
+
expect(data).toContain(".grid{");
|
|
30
|
+
expect(data).toContain(".grid article");
|
|
31
|
+
expect(data).toContain("nav a");
|
|
32
|
+
expect(data).toContain(".datetime");
|
|
33
|
+
expect(data).toContain("nav a:visited{");
|
|
34
|
+
expect(data).toContain("@media screen");
|
|
35
|
+
expect(data).toContain("#wrapper{");
|
|
36
|
+
expect(data).not.toContain("color:darkgray");
|
|
37
|
+
});
|
|
38
|
+
|
|
23
39
|
it("should load and compile the stylesheet (.css) correctly", async () => {
|
|
24
40
|
const blog = new Blog();
|
|
25
41
|
blog.title = "My Blog";
|
|
@@ -38,7 +54,7 @@ describe("Blog Stylesheet Test", () => {
|
|
|
38
54
|
expect(publicCSS).toContain("body");
|
|
39
55
|
expect(publicCSS).toContain("nav a");
|
|
40
56
|
expect(publicCSS).toContain(".datetime");
|
|
41
|
-
expect(publicCSS).toContain("font-style:
|
|
57
|
+
expect(publicCSS).toContain("font-style:normal");
|
|
42
58
|
expect(publicCSS).toContain("color:");
|
|
43
59
|
});
|
|
44
60
|
|
|
@@ -60,7 +76,7 @@ describe("Blog Stylesheet Test", () => {
|
|
|
60
76
|
expect(publicCSS).toContain("body");
|
|
61
77
|
expect(publicCSS).toContain("nav a");
|
|
62
78
|
expect(publicCSS).toContain(".datetime");
|
|
63
|
-
expect(publicCSS).toContain("font-style:
|
|
79
|
+
expect(publicCSS).toContain("font-style:normal");
|
|
64
80
|
expect(publicCSS).toContain("color:");
|
|
65
81
|
});
|
|
66
82
|
});
|
package/articles.txt
DELETED
|
@@ -1,3 +0,0 @@
|
|
|
1
|
-
{"title":"Test Title from Jest","content":"This is the content of the test article.","createdAt":"2026-01-08T13:19:39.939Z"}
|
|
2
|
-
{"title":"Test Title from Jest","content":"This is the content of the test article.","createdAt":"2026-01-08T13:34:38.866Z"}
|
|
3
|
-
{"title":"Test Title from Jest","content":"This is the content of the test article.","createdAt":"2026-01-08T13:43:32.343Z"}
|
package/blog.db
DELETED
|
Binary file
|
package/blog.json
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"title": "",
|
|
3
|
-
"articles": [
|
|
4
|
-
{
|
|
5
|
-
"title": "hello",
|
|
6
|
-
"content": "hML`JfDS]ARPVAfsGdogseNVYPXTpZaJhKJkPoMTWA\\q[CkNsXYrUGyGHWTDZpByubZMf_Y\\bMELh[]afM^XMAFqvxuyWat^hqSBMIifvbTdHNOQP_rbwwlWnGvVbPi]jlTIixyAaaU_]ecJYTjr]FqLq`UY\\XoYqhmuY`rvWH]EZO`tBHZSan`mAuhnwpXgdRJtjtH[",
|
|
7
|
-
"createdAt": "2026-01-07T12:49:43.098Z"
|
|
8
|
-
},
|
|
9
|
-
{
|
|
10
|
-
"title": "hello",
|
|
11
|
-
"content": "HgcFKFWDMl]cxiCVVrgFddJaOQCSPF^KYkROxkLTmxSc^ZssZh`ENyOXZn_DtOQe^V_vbu^KRonZ]bGcqsP]FYMmY\\YLjWknhiUksXwIUsvtpfXqViiKJ`xcmScZRXfAdHshvFlEHvPXWOfZRRIDIOxAafPoMD[ZgVlFkl\\Uw]RfubZWQ]`IHIUOKea_RTO`KiZisSA[",
|
|
12
|
-
"createdAt": "2026-01-07T12:49:43.444Z"
|
|
13
|
-
},
|
|
14
|
-
{
|
|
15
|
-
"title": "Test Title from Jest",
|
|
16
|
-
"content": "This is the content of the test article.",
|
|
17
|
-
"createdAt": "2026-01-07T13:10:50.239Z"
|
|
18
|
-
},
|
|
19
|
-
{
|
|
20
|
-
"title": "hello",
|
|
21
|
-
"content": "_\\jAuGsYByS]q\\QsnlqT\\]]TEDDalywtlmwmBZRlL[vtFdGqB\\[dlVx]A^[IV`KjhWU`DW`oorGwUlBU_IMCYoymIFi[`FpXrJfUmLZTRhoisxhXIcWqmk\\SPMV^]dvCuL_Pu^EAcMvBfadWFbUuSxFkjh\\KQyLnNWWsP`agQQkmU\\TAaOvYZAO\\FGtstSbaCJPXRQTO",
|
|
22
|
-
"createdAt": "2026-01-07T13:10:57.249Z"
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
"title": "hello",
|
|
26
|
-
"content": "HRkfwNRL_r\\DF]tyVBM[AMIjfMoBbohiqUf_T^]LwiJo[duXB`mQiRmmTkbMSJxvvHccfbXsdmKMbuYmxo[JGkwfCpqXSbhKiZID[vouGhbnLZ[wjkFFgm]fOaZcxoWduI`TNvin]\\dXoGW]YtueqSYPRLmGJswvUnyPHhfLUtf`]QNfM]hcvqqZasrQZVcXYjAnJxRP",
|
|
27
|
-
"createdAt": "2026-01-07T13:10:57.357Z"
|
|
28
|
-
},
|
|
29
|
-
{
|
|
30
|
-
"title": "hello",
|
|
31
|
-
"content": "UI\\KBodT[CgUg]L^WJ`Tuafq`rS]n\\T_eMqmBQqJVQisfABj^msS]u\\OEoJ]D^CtwE`UY[UbOICHcYPjNqNQ[LlwlJGPOoppSrkaEmDh_tm]CrrPGbBSPxgQQIZ^efcmITQQaAgT_vx[GUepg]LDagmTmihHbOIqHAa_NmRsX_EjxeE]EKhnHwFm]vFUpJTPFiDNU^BE",
|
|
32
|
-
"createdAt": "2026-01-07T13:17:47.559Z"
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
"title": "hello",
|
|
36
|
-
"content": "aMDnSSZDIZjbfrMlOcjMB\\CPup[GfoqHkLWAi]V_Ctkt[ekYRqIaufps[]TOMcw\\ojbVpPNv`lTTH\\[jbOcJXU[_tBSCsZW\\akiWuRf^mZGGrFxAq^oAdZjHwmJBjt^_GUXnHdXfjSrifiqXdAAdo]EM`aBFpJvyVLnLOfqrqoPrAIhRwoPHRy^lRCLyaS^VXChowHCn",
|
|
37
|
-
"createdAt": "2026-01-07T13:17:47.681Z"
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
"title": "Test Title from Jest",
|
|
41
|
-
"content": "This is the content of the test article.",
|
|
42
|
-
"createdAt": "2026-01-07T13:18:50.764Z"
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
"title": "hello",
|
|
46
|
-
"content": "[vL^GSDHJjBq^qdTMyyMm`SGhZhvgXsyTp[kYNkiBDavKevpIkSlWQr`bJaWyH]Qa`LQMKisdHeRvTKm`dviPLkLcJCtWbGPaDnNhJtEcEQM^MmP[RYERk\\uYeXruvPmJMjdvsOxA`btQtTmtBlOvrHLANJhcYRTAXmwOjLhp_SgkhWsB_pcIUQbd^X`RIWAdqUBPVmk",
|
|
47
|
-
"createdAt": "2026-01-07T13:18:52.387Z"
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
"title": "hello",
|
|
51
|
-
"content": "jRaJYEUFnMoEcjrWFREUuIQEoCtLoEuJSxJapEGpdPvq[GyrOqdSLY`VDlqSMTh\\BgybfwxfQQ_QqKkh\\YcEHYpA^W[ttGyGjA[TdZHn]]yoPWnFyFHJfAUkxkSM[RkQVqxAg_AGoJahaoOsNnoSGx^uM\\smWlWVDGvRcWVW\\rI_[lKnZEifvdPwkCcBRLXKGw[aNedN",
|
|
52
|
-
"createdAt": "2026-01-07T13:18:52.971Z"
|
|
53
|
-
},
|
|
54
|
-
{
|
|
55
|
-
"title": "Test Title from Jest",
|
|
56
|
-
"content": "This is the content of the test article.",
|
|
57
|
-
"createdAt": "2026-01-07T13:22:54.227Z"
|
|
58
|
-
},
|
|
59
|
-
{
|
|
60
|
-
"title": "Test Title from Jest",
|
|
61
|
-
"content": "This is the content of the test article.",
|
|
62
|
-
"createdAt": "2026-01-07T13:23:08.140Z"
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
"title": "hello",
|
|
66
|
-
"content": "M_RFGy[auVjFYypv^FdPqesIlcBkg]uoeIwMNLM]TOcDWKw[CFpsPpQWxSAXAupUdVsaiGJgLgIeuJEwBMXk`VG\\OoXIxCERYs`hqGRYGTnP]ZIrOSoJk[MFAxfVjJNMyZycryoRWr\\bhCXlurxgKPFSsTYbhlqZmrqaiJyvXpFsbiLQx^LBcZi`VqZSbjc`TNF_\\Eex",
|
|
67
|
-
"createdAt": "2026-01-07T13:26:02.437Z"
|
|
68
|
-
},
|
|
69
|
-
{
|
|
70
|
-
"title": "hello",
|
|
71
|
-
"content": "\\uRyVjar]vDZKHqRqJbEJpZbdKCWmQOGToNFQAqWdJQxSwmKTAGXywMDF`D\\nBA_eJxmFV^XtTuSGNJZTFjYvYibQIlgZdV[YXZpujmuWBgKNNBjrGZLVC[QlRZOwBdY^QPom\\b^WIIkeawsGBaafELGmfmTaIikRhCnCV_SYKEwogtqdfhkMkJslllumPyeGKqe\\cca",
|
|
72
|
-
"createdAt": "2026-01-07T13:26:02.816Z"
|
|
73
|
-
},
|
|
74
|
-
{
|
|
75
|
-
"title": "Test Title from Jest",
|
|
76
|
-
"content": "This is the content of the test article.",
|
|
77
|
-
"createdAt": "2026-01-07T13:26:07.866Z"
|
|
78
|
-
},
|
|
79
|
-
{
|
|
80
|
-
"title": "hello",
|
|
81
|
-
"content": "UecpUr`PFkpfKJ^V^q[T_lTr\\uP\\WFwcFtZgrdoao[japNpDycoAgWyELuIZtQroKxXSYdI]LrDTQSGqegWjgArWk[[eYXpy_OchWDywkus`GLcdwwr[qHoLINIdUoB^C]JcLfacOEJtMZOeWjRNbGcv\\XCfSAXUKIVqkPwJbaRTjlpGBxCGXxnchUhRoePYoLE]fexb",
|
|
82
|
-
"createdAt": "2026-01-07T13:45:46.304Z"
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
"title": "hello",
|
|
86
|
-
"content": "ef[SK^DuSJrKOlieeJHPLLoGgS[ZO\\gk_dWggGZchFewEnwspxJlhCWQ\\aTEKlDj\\a\\xRj\\uJANSkNLwrnPoOxJ[h]CrLppVtoeCEWPXRpCcoNFU`YA\\RPK\\dk\\Jp^fbdmC`Hpuf[ZYExseoPRbAYAaIolQ_mkJKRfrKsthmsBtCZdeQiBhY\\XAodKo_rOeQvtfNtDQB",
|
|
87
|
-
"createdAt": "2026-01-07T13:45:46.469Z"
|
|
88
|
-
},
|
|
89
|
-
{
|
|
90
|
-
"title": "Test Title from Jest",
|
|
91
|
-
"content": "This is the content of the test article.",
|
|
92
|
-
"createdAt": "2026-01-07T13:45:47.311Z"
|
|
93
|
-
},
|
|
94
|
-
{
|
|
95
|
-
"title": "Test Title from Jest",
|
|
96
|
-
"content": "This is the content of the test article.",
|
|
97
|
-
"createdAt": "2026-01-07T18:19:21.946Z"
|
|
98
|
-
},
|
|
99
|
-
{
|
|
100
|
-
"title": "Test Title from Jest",
|
|
101
|
-
"content": "This is the content of the test article.",
|
|
102
|
-
"createdAt": "2026-01-07T18:21:43.833Z"
|
|
103
|
-
},
|
|
104
|
-
{
|
|
105
|
-
"title": "Test Title from Jest",
|
|
106
|
-
"content": "This is the content of the test article.",
|
|
107
|
-
"createdAt": "2026-01-07T18:24:28.968Z"
|
|
108
|
-
}
|
|
109
|
-
]
|
|
110
|
-
}
|
package/bloginfo.json
DELETED
|
Binary file
|
|
Binary file
|