@lexho111/plainblog 0.5.11 → 0.5.13

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 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: "blog.json", // x
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
- 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
- }
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
- #scriptsHash = "";
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
- if (!req.headers.cookie) return false;
128
- const params = new URLSearchParams(req.headers.cookie.replace(/; /g, "&"));
129
- return this.sessions.has(params.get("session"));
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(req.headers.cookie.replace(/; /g, "&"));
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.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
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.createHash("sha256").update(combinedStyles).digest("hex");
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(publicStylePath, finalStyles + `\n/* source-hash: ${srcHash} */`);
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(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()));
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()));
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) console.log("reload scripts and styles on GET-Request");
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) title = this.#title; // use blog title if set
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) promises.push(this.#databaseModel.save(newArticleData));
270
- if (this.#isExternalAPI) promises.push(postData(this.#apiUrl, newArticleData));
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(req.url, `http://${req.headers.host || "localhost"}`);
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, '127.0.0.1', () => {
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(articleData.title, articleData.content, articleData.createdAt);
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
- if(pathname === "/api" || pathname === "/api/") {
454
- res.writeHead(200, { "Content-Type": "application/json" });
455
- const data = {
456
- title: this.title
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
- // POST a new article
479
- } else if (req.method === "POST" && pathname === "/api/articles") {
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) => typeof f === "string" && (f.endsWith(".scss") || f.endsWith(".css")) && !f.endsWith(".min.css")
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
- /** format content to html */
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,32 @@
1
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
2
 
7
- // array of files or a single file
3
+ /**
4
+ * Compiles CSS styles from file content objects.
5
+ * @param {Array<{path: string, content: string}>} fileData - An array of objects containing file paths and content.
6
+ * @returns {Promise<string>} The compiled and minified CSS.
7
+ */
8
8
  export async function compileStyles(fileData) {
9
9
  try {
10
10
  let combinedCss = "";
11
11
 
12
+ // 1. filter out css files
12
13
  if (fileData) {
13
14
  const scssFiles = fileData.filter(
14
15
  (f) =>
15
16
  f.path.endsWith(".scss") && !path.basename(f.path).startsWith("_")
16
17
  );
18
+ if (scssFiles.length > 0) console.error("sass files are not supported.");
17
19
 
18
- for (const file of scssFiles) {
19
- console.error("sass files are not supported.");
20
- }
21
-
20
+ // make one big css file
22
21
  const cssFiles = fileData.filter((f) => f.path.endsWith(".css"));
23
22
  for (const file of cssFiles) {
24
23
  combinedCss += file.content + "\n";
25
24
  }
26
25
  }
27
26
 
28
- // 2. PostCSS (Autoprefixer + CSSNano)
27
+ // 2. minify and uglify with PostCSS
29
28
  if (combinedCss) {
30
- const plugins = [autoprefixer(), cssnano()];
31
- const result = await postcss(plugins).process(combinedCss, {
32
- from: undefined,
33
- });
34
- return result.css;
29
+ return postcss2(combinedCss);
35
30
  }
36
31
  return "";
37
32
  } catch (error) {
@@ -40,16 +35,43 @@ export async function compileStyles(fileData) {
40
35
  }
41
36
  }
42
37
 
38
+ async function postcss2(css) {
39
+ let postcss, autoprefixer, cssnano;
40
+
41
+ try {
42
+ postcss = (await import("postcss")).default;
43
+ autoprefixer = (await import("autoprefixer")).default;
44
+ cssnano = (await import("cssnano")).default;
45
+ } catch (error) {
46
+ console.error(
47
+ "\n\x1b[31m%s\x1b[0m",
48
+ "ERROR: Missing CSS processing dependencies."
49
+ );
50
+ console.error(
51
+ "To use this feature, please install the following packages manually:"
52
+ );
53
+ console.error("\n npm install postcss autoprefixer cssnano\n");
54
+ throw new Error("Missing optional dependencies");
55
+ }
56
+
57
+ const plugins = [autoprefixer(), cssnano()];
58
+ const result = await postcss(plugins).process(css, {
59
+ from: undefined, // do not print source map warning
60
+ });
61
+ return result.css; // final result
62
+ }
63
+
64
+ /**
65
+ * Merges and minifies multiple CSS content strings.
66
+ * @param {...string} cssContents - CSS strings to merge.
67
+ * @returns {Promise<string>} The merged and minified CSS.
68
+ */
43
69
  export async function mergeStyles(...cssContents) {
44
70
  try {
45
71
  const combinedCss = cssContents.join("\n");
46
72
 
47
73
  if (combinedCss) {
48
- const plugins = [autoprefixer(), cssnano()];
49
- const result = await postcss(plugins).process(combinedCss, {
50
- from: undefined,
51
- });
52
- return result.css;
74
+ return postcss2(combinedCss);
53
75
  }
54
76
  return "";
55
77
  } catch (error) {
package/eslint.config.js CHANGED
@@ -1,45 +1,27 @@
1
- import globals from "globals";
2
- import pluginJs from "@eslint/js";
3
- import pluginJest from "eslint-plugin-jest";
4
-
5
- export default [
6
- {
7
- // Configuration for all JavaScript files
8
- files: ["**/*.js"],
9
- languageOptions: {
10
- ecmaVersion: 2022, // Supports modern JavaScript features
11
- sourceType: "module",
12
- globals: {
13
- ...globals.node, // Defines Node.js global variables (e.g., `process`, `require`)
14
- },
15
- },
16
- // ESLint's recommended rules for general JavaScript
17
- rules: {
18
- ...pluginJs.configs.recommended.rules,
19
- // Add or override general JavaScript rules here.
20
- // Example: Enforce semicolons at the end of statements
21
- semi: ["error", "always"],
22
- // Example: Prefer `const` over `let` where variables are not reassigned
23
- "prefer-const": "error",
24
- // Example: Prevent unused variables (can be configured further)
25
- "no-unused-vars": ["warn", { args: "none" }],
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
@@ -0,0 +1,10 @@
1
+ {
2
+ "$schema": "https://unpkg.com/knip@5/schema.json",
3
+ "ignoreExportsUsedInFile": {
4
+ "interface": true,
5
+ "type": true
6
+ },
7
+ "tags": [
8
+ "-lintignore"
9
+ ]
10
+ }
@@ -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 = [];
@@ -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
  }
@@ -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
- //let Sequelize, DataTypes, Op;
18
- /*try {
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
- //Op = sequelizePkg.Op;
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,
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@lexho111/plainblog",
3
- "version": "0.5.11",
3
+ "version": "0.5.13",
4
4
  "description": "A tool for creating and serving a minimalist, single-page blog.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -17,29 +17,15 @@
17
17
  "author": "lexho111",
18
18
  "license": "ISC",
19
19
  "dependencies": {
20
- "autoprefixer": "^10.4.23",
21
- "child_process": "^1.0.2",
22
- "cssnano": "^7.1.2",
23
- "fs": "^0.0.1-security",
24
- "http": "^0.0.1-security",
25
- "node-fetch": "^3.3.2",
26
- "path": "^0.12.7",
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"
20
+ "node-fetch": "^3.3.2"
32
21
  },
33
22
  "devDependencies": {
34
- "dom-parser": "^1.1.5",
35
- "eslint": "^9.8.0",
23
+ "@eslint/js": "^9.39.2",
24
+ "@types/node": "^25.0.3",
25
+ "eslint": "^9.39.2",
36
26
  "eslint-plugin-jest": "^28.6.0",
27
+ "globals": "^17.0.0",
37
28
  "jest": "^29.7.0",
38
- "sqlite3": "^5.1.7"
39
- },
40
- "optionalDependencies": {
41
- "pg": "^8.16.3",
42
- "pg-hstore": "^2.3.4",
43
- "sequelize": "^6.37.7"
29
+ "typescript": "^5.9.3"
44
30
  }
45
31
  }
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
  /*
@@ -1,7 +1,5 @@
1
1
  import http from "http";
2
2
 
3
- const port = 8081;
4
-
5
3
  export const server = http.createServer((req, res) => {
6
4
  // Set CORS headers to allow requests from different origins if needed
7
5
  res.setHeader("Access-Control-Allow-Origin", "*");
package/.eslintignore DELETED
File without changes
package/.eslintrc.json DELETED
File without changes