@lexho111/plainblog 0.6.10 → 0.6.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.
@@ -0,0 +1,979 @@
1
+ import http from "http";
2
+ import crypto from "node:crypto";
3
+ import fs from "fs";
4
+ import { URLSearchParams } from "url";
5
+ import process from "node:process";
6
+ import path from "path";
7
+ import { fileURLToPath } from "url";
8
+ import pkg from "./package.json" with { type: "json" };
9
+ import Article from "./Article.js";
10
+ import DatabaseModel from "./model/DatabaseModel.js";
11
+ import { fetchData, postData } from "./model/APIModel.js";
12
+ import { formatHTML, header, formatMarkdown, validate } from "./Formatter.js";
13
+ import { compileStyles, mergeStyles } from "./build-styles.js";
14
+ import { compileScripts } from "./build-scripts.js";
15
+ import FileAdapter from "./model/FileAdapter.js";
16
+ import DataModel from "./model/DataModel.js";
17
+ import { BinarySearchTreeHashMap } from "./model/datastructures/BinarySearchTreeHashMap.js";
18
+ import createDebug from "./debug-loader.js";
19
+ import { readFile } from "node:fs/promises";
20
+ import { login, logout, api, new1, getPages } from "./router.js";
21
+
22
+ // Initialize the debugger with a specific namespace
23
+ const debug = createDebug("plainblog:Blog");
24
+
25
+ export default class Blog {
26
+ #makeDataModel() {
27
+ return new BinarySearchTreeHashMap();
28
+ }
29
+
30
+ setDataModel(datamodel) {
31
+ this.#articles = datamodel;
32
+ }
33
+
34
+ constructor() {
35
+ this.database = {
36
+ type: "file",
37
+ username: "user",
38
+ password: "password",
39
+ host: "localhost",
40
+ dbname: "articles.txt", // x
41
+ };
42
+ this.#title = "";
43
+ this.#articles = new DataModel(this.#makeDataModel());
44
+ this.#server = null;
45
+ this.#password = "admin";
46
+ this.#styles = "body { font-family: Arial; }";
47
+ //this.scripts = "";
48
+ this.compiledStyles = "";
49
+ //this.compiledScripts = "";
50
+ this.reloadStylesOnGET = false;
51
+ this.angular = false;
52
+ this.sessions = new Set();
53
+
54
+ this.#version = pkg.version;
55
+ console.log(`version: ${this.#version}`);
56
+ }
57
+
58
+ /** @returns a json representation of the blog */
59
+ json() {
60
+ const serverInfo = this.#server
61
+ ? {
62
+ listening: this.#server.listening,
63
+ address: this.#server.address(),
64
+ }
65
+ : null;
66
+
67
+ const json = {
68
+ version: this.#version,
69
+ title: this.#title,
70
+ articles: this.#articles.getAllArticles(),
71
+ server: serverInfo,
72
+ compiledStyles: this.compiledStyles,
73
+ sessions: this.sessions,
74
+ database: this.database,
75
+ password: this.#password,
76
+ styles: this.#styles,
77
+ reloadStylesOnGET: this.reloadStylesOnGET,
78
+ };
79
+
80
+ return JSON.parse(JSON.stringify(json)); // make json read-only
81
+ }
82
+
83
+ // Private fields
84
+ #version = null;
85
+ #server = null;
86
+ #password = null;
87
+ #databaseModel;
88
+ #isExternalAPI = false;
89
+ #apiUrl = "";
90
+ #title = "";
91
+ #articles = [];
92
+ #styles = "";
93
+ #stylesHash = "";
94
+ #scriptsHash = "";
95
+ #stylesheetPath = "";
96
+ compilestyle = false;
97
+ #initPromise = null;
98
+ #publicDir = path.join(process.cwd(), "public");
99
+
100
+ setTitle(title) {
101
+ this.#title = title;
102
+ }
103
+
104
+ setPassword(password) {
105
+ this.#password = password;
106
+ }
107
+
108
+ /**
109
+ * Appends CSS rules to the \<style\>-tag.
110
+ * @param {string} style - A string containing CSS rules.
111
+ */
112
+ setStyle(style) {
113
+ this.#styles += style;
114
+ this.compilestyle = true;
115
+ }
116
+
117
+ set title(t) {
118
+ this.#title = t;
119
+ }
120
+
121
+ get title() {
122
+ return this.#title;
123
+ }
124
+
125
+ set password(x) {
126
+ this.#password = x;
127
+ }
128
+
129
+ /**
130
+ * allows you to inject a specific database implementation
131
+ * @param {*} adapter a database adapter like PostgresAdapter or SqliteAdapter
132
+ */
133
+ setDatabaseAdapter(adapter) {
134
+ if (!this.#databaseModel) {
135
+ if (this.database.type === "file") {
136
+ const adapter = new FileAdapter(this.database);
137
+ this.#databaseModel = new DatabaseModel(adapter);
138
+ }
139
+ }
140
+ this.#databaseModel.setDatabaseAdapter(adapter);
141
+ }
142
+
143
+ /**
144
+ * Appends CSS rules to the \<style\>-tag.
145
+ * @param {string} style - A string containing CSS rules.
146
+ */
147
+ set style(style) {
148
+ this.#styles += style;
149
+ this.compilestyle = true;
150
+ }
151
+
152
+ /**
153
+ * Sets the path(s) to custom CSS or SCSS files to be compiled and used by the blog.
154
+ * @param {string|string[]} files - A single file path or an array of file paths.
155
+ */
156
+ set stylesheetPath(files) {
157
+ this.#stylesheetPath = files;
158
+ console.log(`this.#stylesheetPath: ${this.#stylesheetPath}`);
159
+ this.compilestyle = true;
160
+ }
161
+
162
+ addArticle(article) {
163
+ this.#articles.insert(article);
164
+ }
165
+
166
+ #isAuthenticated(req) {
167
+ if (!req.headers.cookie) return false;
168
+ const match = req.headers.cookie.match(/(?:^|;\s*)session=([^;]*)/);
169
+ return match ? this.sessions.has(match[1]) : false;
170
+ }
171
+
172
+ async #handleLogin(req, res) {
173
+ debug("handle login");
174
+ const body = await new Promise((resolve, reject) => {
175
+ let data = "";
176
+ req.on("data", (chunk) => (data += chunk.toString()));
177
+ req.on("end", () => resolve(data));
178
+ req.on("error", reject);
179
+ });
180
+ const params = new URLSearchParams(body);
181
+
182
+ if (params.get("password") === this.#password) {
183
+ const id = crypto.randomUUID();
184
+ this.sessions.add(id);
185
+ res.writeHead(303, {
186
+ "Set-Cookie": `session=${id}; HttpOnly; Path=/`,
187
+ Location: "/",
188
+ });
189
+ res.end();
190
+ } else {
191
+ debug("login failed");
192
+ debug("password did not match %s", params.get("password"));
193
+ res.writeHead(401, { "Content-Type": "application/json" });
194
+
195
+ // Send the JSON string
196
+ res.end(
197
+ JSON.stringify({
198
+ error: "unauthorized",
199
+ message: "Please enter the correct password.",
200
+ }),
201
+ );
202
+ }
203
+ }
204
+
205
+ #handleLogout(req, res) {
206
+ debug("handle logout");
207
+ if (req.headers.cookie) {
208
+ const params = new URLSearchParams(
209
+ req.headers.cookie.replace(/; /g, "&"),
210
+ );
211
+ const sessionId = params.get("session");
212
+ if (this.sessions.has(sessionId)) {
213
+ this.sessions.delete(sessionId);
214
+ }
215
+ }
216
+ res.writeHead(303, {
217
+ "Set-Cookie": "session=; HttpOnly; Path=/; Max-Age=0",
218
+ Location: "/",
219
+ });
220
+ res.end();
221
+ }
222
+
223
+ /** initializes database */
224
+ async init() {
225
+ if (this.#initPromise) return this.#initPromise;
226
+ this.#initPromise = (async () => {
227
+ //this.loadStyles();
228
+ //this.loadScripts();
229
+ // if there is a stylesheet path provided, process it
230
+ if (this.#stylesheetPath != null && this.compilestyle) {
231
+ // read file from stylesheet path, compare checksums and write to public/styles.min.css
232
+ await this.#processStylesheets(this.#stylesheetPath);
233
+ }
234
+ if (!this.#stylesheetPath) {
235
+ // this.#styles
236
+ // src/styles.css
237
+ // compile and merge hardcoded styles in "this.#styles" with "src/styles.css" and write to file "styles.min.css"
238
+ // which will be imported by webbrowser via '<link rel="stylesheet" href="styles.min.css"...'
239
+
240
+ const __filename = fileURLToPath(import.meta.url);
241
+ const __dirname = path.dirname(__filename);
242
+ const srcStylePath = path.join(__dirname, "src", "styles.css");
243
+ const publicStylePath = path.join(this.#publicDir, "styles.min.css");
244
+
245
+ let publicHash = null;
246
+ let srcStyles = "";
247
+
248
+ await Promise.all([
249
+ fs.promises
250
+ .readFile(publicStylePath, "utf8")
251
+ .then((publicCSS) => {
252
+ const match = publicCSS.match(
253
+ /\/\* source-hash: ([a-f0-9]{64}) \*\//,
254
+ );
255
+ if (match) publicHash = match[1];
256
+ })
257
+ .catch((err) => console.error(err)), // public/styles.min.css doesn't exist, will be created.
258
+ fs.promises
259
+ .readFile(srcStylePath, "utf8")
260
+ .then((content) => {
261
+ srcStyles = content;
262
+ })
263
+ .catch((err) => {
264
+ if (err.code !== "ENOENT") console.error(err);
265
+ }), // ignore if src/styles.css doesn't exist
266
+ ]);
267
+
268
+ const combinedStyles = this.#styles + srcStyles;
269
+ const srcHash = crypto
270
+ .createHash("sha256")
271
+ .update(combinedStyles)
272
+ .digest("hex");
273
+
274
+ if (srcHash !== publicHash && this.compilestyle) {
275
+ console.log("Styles have changed. Recompiling...");
276
+ const finalStyles = await mergeStyles(this.#styles, srcStyles);
277
+ try {
278
+ await fs.promises.mkdir(path.dirname(publicStylePath), {
279
+ recursive: true,
280
+ });
281
+ await fs.promises.writeFile(
282
+ publicStylePath,
283
+ finalStyles + `\n/* source-hash: ${srcHash} */`,
284
+ );
285
+ } catch (err) {
286
+ console.error("Failed to write styles to public folder:", err);
287
+ }
288
+ }
289
+ }
290
+
291
+ // Process Scripts
292
+ const __filename = fileURLToPath(import.meta.url);
293
+ const __dirname = path.dirname(__filename);
294
+ const srcScriptPath = path.join(__dirname, "src", "fetchData.js");
295
+ await this.#processScripts(srcScriptPath);
296
+
297
+ if (this.#isExternalAPI) {
298
+ console.log("external API");
299
+ await this.#loadFromAPI();
300
+ } else {
301
+ console.log(`database: ${this.database.type}`);
302
+ if (!this.#databaseModel) {
303
+ if (this.database.type === "file") {
304
+ const adapter = new FileAdapter(this.database);
305
+ this.#databaseModel = new DatabaseModel(adapter);
306
+ }
307
+ }
308
+ console.log(`connected to database`);
309
+ await this.#databaseModel.initialize();
310
+ this.#articles.setDatabase(this.#databaseModel);
311
+ const [dbTitle, dbArticles] = await Promise.all([
312
+ this.#databaseModel.getBlogTitle(),
313
+ this.#databaseModel.findAll(),
314
+ ]);
315
+
316
+ debug("dbArticles.size(): %d", dbArticles.length);
317
+ //log(filename, "dbArticles.size(): " + dbArticles.length);
318
+ //log(filename, "dbArticles.size(): " + dbArticles.length, "Blog.js");
319
+ debug("all articles in Blog after loading from db");
320
+
321
+ // Displays a beautiful table in the console
322
+ //table(dbArticles)
323
+
324
+ if (dbArticles.length == 0) {
325
+ dbArticles.push(
326
+ new Article(
327
+ 1,
328
+ "Sample Entry #1",
329
+ "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.",
330
+ new Date(),
331
+ ),
332
+ );
333
+ dbArticles.push(
334
+ new Article(
335
+ 2,
336
+ "Sample Entry #2",
337
+ "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.",
338
+ new Date(),
339
+ ),
340
+ );
341
+ }
342
+ if (this.reloadStylesOnGET)
343
+ console.log("reload scripts and styles on GET-Request");
344
+ let title = "";
345
+ if (this.#title != null && this.#title.length > 0)
346
+ title = this.#title; // use blog title if set
347
+ else title = dbTitle; // use title from the database
348
+ const responseData = { title: title, articles: dbArticles };
349
+ this.#applyBlogData(responseData);
350
+ }
351
+ })();
352
+ return this.#initPromise;
353
+ }
354
+
355
+ async postArticle(newArticle) {
356
+ try {
357
+ // Save the new article to the database via the ApiServer
358
+ const promises = [];
359
+ //if (this.#databaseModel)
360
+ // promises.push(this.#databaseModel.save(newArticle));
361
+ if (this.#isExternalAPI)
362
+ promises.push(postData(this.#apiUrl, newArticle));
363
+ await Promise.all(promises);
364
+ const title = newArticle.title;
365
+ const content = newArticle.content;
366
+ // Add the article to the local list for immediate display
367
+ this.#articles.insert(Article.createNew(title, content));
368
+ // remove sample entries
369
+ this.#articles.remove(1); // "Sample Entry #1"
370
+ this.#articles.remove(2); // "Sample Entry #2"
371
+ } catch (error) {
372
+ console.error("Failed to post new article to API:", error);
373
+ }
374
+ }
375
+
376
+ /** start a http server with default port 8080 */
377
+ async startServer(port = 8080) {
378
+ await this.init();
379
+
380
+ const server = http.createServer(async (req, res) => {
381
+ //debug("query %s", req.url);
382
+ await login(
383
+ req,
384
+ res,
385
+ async (req, res) => {
386
+ await this.#handleLogin(req, res);
387
+ },
388
+ async (req, res) => {
389
+ res.end(`${header("My Blog")}<body>
390
+ <form class="loginform" id="loginForm">
391
+ <h1>Blog</h1>
392
+ <!-- Message container -->
393
+ <div id="statusMessage"></div>
394
+ <label for="username">Username</label>
395
+ <input type="username" class="form_element" name="username" placeholder="username" required />
396
+ <label for="password">Password</label>
397
+ <input type="password" class="form_element" name="password" placeholder="password" required />
398
+ <button class="btn" type="submit">Login</button>
399
+ </form>
400
+
401
+ <script>
402
+ const loginForm = document.getElementById('loginForm');
403
+ const statusDiv = document.getElementById('statusMessage');
404
+
405
+ loginForm.addEventListener('submit', async (e) => {
406
+ e.preventDefault(); // Prevent page reload
407
+
408
+ // Clear previous messages
409
+ statusDiv.innerHTML = '';
410
+
411
+ const formData = new FormData(loginForm);
412
+ const body = new URLSearchParams(formData); // Replicates form-urlencoded
413
+
414
+ try {
415
+ const response = await fetch('/login', {
416
+ method: 'POST',
417
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
418
+ body: body.toString()
419
+ });
420
+
421
+ if (response.ok) {
422
+ window.location.href = '/'; // Redirect on success
423
+ } else if (response.status === 401) {
424
+ // Handle Unauthorized status
425
+ statusDiv.innerHTML = '<h2>Unauthorized</h2><p>Please enter the correct password.</p>';
426
+ } else {
427
+ statusDiv.innerHTML = '<p>Something went wrong. Please try again.</p>';
428
+ }
429
+ } catch (error) {
430
+ console.error('Network Error:', error);
431
+ statusDiv.innerHTML = '<p>Unable to connect to server.</p>';
432
+ }
433
+ });
434
+ </script>
435
+ </body></html>`);
436
+ },
437
+ );
438
+ if (res.headersSent) return;
439
+
440
+ await logout(req, res, async (req, res) => {
441
+ this.#handleLogout(req, res);
442
+ });
443
+ if (res.headersSent) return;
444
+
445
+ await api(req, res, async (req, res) => {
446
+ await this.#jsonAPI(req, res);
447
+ });
448
+ if (res.headersSent) return;
449
+
450
+ await new1(req, res, (req, res) => {
451
+ return new Promise((resolve, reject) => {
452
+ if (!this.#isAuthenticated(req)) {
453
+ debug("not authenticated");
454
+ res.writeHead(403, { "Content-Type": "application/json" });
455
+ res.end(JSON.stringify({ error: "Forbidden" }));
456
+ resolve();
457
+ return;
458
+ }
459
+ let body = "";
460
+
461
+ // 1. Collect data chunks
462
+ req.on("data", (chunk) => {
463
+ body += chunk.toString();
464
+ });
465
+
466
+ req.on("end", async () => {
467
+ try {
468
+ // 2. Parse x-www-form-urlencoded using URLSearchParams
469
+ const params = new URLSearchParams(body);
470
+
471
+ // 3. Convert to a plain object
472
+ const articleData = Object.fromEntries(params.entries());
473
+
474
+ console.log("New Article Data:", articleData);
475
+ // local
476
+ await this.#databaseModel.save(articleData); // --> to api server
477
+ this.postArticle(articleData);
478
+ // external
479
+
480
+ // Success response
481
+ res.writeHead(302, { Location: "/" });
482
+ res.end();
483
+ resolve();
484
+ } catch (err) {
485
+ if (!res.headersSent) {
486
+ res.writeHead(400, { "Content-Type": "application/json" });
487
+ res.end(JSON.stringify({ error: "Failed to parse form data" }));
488
+ }
489
+ resolve();
490
+ }
491
+ });
492
+ req.on("error", reject);
493
+ });
494
+
495
+ /*await this.#handleLogin(req, res, async () => {
496
+ await this.#handleLogin(req, res);
497
+ })*/
498
+ });
499
+ if (res.headersSent) return;
500
+
501
+ await getPages(
502
+ req,
503
+ res,
504
+ async (req, res) => {
505
+ // reload styles and scripts on (every) request
506
+ if (this.reloadStylesOnGET) {
507
+ if (this.#stylesheetPath != null && this.compilestyle) {
508
+ await this.#processStylesheets(this.#stylesheetPath);
509
+ }
510
+ const __filename = fileURLToPath(import.meta.url);
511
+ const __dirname = path.dirname(__filename);
512
+ const srcScriptPath = path.join(__dirname, "src", "fetchData.js");
513
+ await this.#processScripts(srcScriptPath);
514
+ }
515
+
516
+ let loggedin = false;
517
+ if (!this.#isAuthenticated(req)) {
518
+ // login
519
+ loggedin = false;
520
+ } else {
521
+ // logout
522
+ loggedin = true;
523
+ }
524
+
525
+ if (this.angular) {
526
+ // use angular frontend
527
+ const filePath = path.join(this.#publicDir, "index.html");
528
+
529
+ debug("%s", filePath);
530
+ try {
531
+ const data = await readFile(filePath);
532
+ // Manual MIME type detection (simplified)
533
+ const ext = path.extname(filePath);
534
+ const mimeTypes = {
535
+ ".html": "text/html",
536
+ ".css": "text/css",
537
+ ".js": "text/javascript",
538
+ };
539
+
540
+ res.writeHead(200, {
541
+ "Content-Type": mimeTypes[ext] || "application/octet-stream",
542
+ });
543
+ res.end(data);
544
+ } catch (err) {
545
+ if (err) {
546
+ res.writeHead(404);
547
+ return res.end("Index-File Not Found");
548
+ }
549
+ }
550
+ } else {
551
+ // use built in view engine
552
+ try {
553
+ const html = await this.toHTML(loggedin); // render this blog to HTML
554
+ res.writeHead(200, {
555
+ "Content-Type": "text/html; charset=UTF-8",
556
+ });
557
+ res.end(html);
558
+ return;
559
+ } catch (err) {
560
+ console.error(err);
561
+ if (!res.headersSent) {
562
+ res.writeHead(500, { "Content-Type": "text/plain" });
563
+ res.end("Internal Server Error");
564
+ }
565
+ return;
566
+ }
567
+ }
568
+ },
569
+ this.#publicDir,
570
+ );
571
+ });
572
+
573
+ this.#server = server;
574
+
575
+ return new Promise((resolve, reject) => {
576
+ const errorHandler = (err) => reject(err);
577
+ this.#server.once("error", errorHandler);
578
+ this.#server.listen(port, "0.0.0.0", () => {
579
+ // <-- for docker 0.0.0.0, localhost 127.0.0.1
580
+ this.#server.removeListener("error", errorHandler);
581
+ console.log(`server running at http://localhost:${port}/`);
582
+ resolve(); // Resolve the promise when the server is listening
583
+ });
584
+ });
585
+ } // http server
586
+
587
+ async closeServer() {
588
+ return new Promise((resolve, reject) => {
589
+ if (this.#server) {
590
+ // if server is running
591
+ this.#server.close((err) => {
592
+ if (err && err.code !== "ERR_SERVER_NOT_RUNNING") return reject(err);
593
+ console.log("Server closed.");
594
+ resolve();
595
+ });
596
+ } else {
597
+ // server is not running
598
+ resolve(); // Nothing to close
599
+ }
600
+ });
601
+ }
602
+
603
+ /** Populates the blog's title and articles from a data object. */
604
+ #applyBlogData(data) {
605
+ debug("applyBlogData");
606
+ if (this.#articles.storage.clear) {
607
+ this.#articles.storage.clear();
608
+ } else {
609
+ //this.#articles.setDataModel(this.#makeDataModel()); // Fallback if clear() isn't implemented
610
+ }
611
+ this.#title = data.title;
612
+ // Assuming data contains a title and an array of articles with title and content
613
+ if (this.#articles && data.articles && Array.isArray(data.articles)) {
614
+ if (this.#articles.getStorageName().includes("BinarySearchTree")) {
615
+ debug("using special insert method for BST");
616
+ const insertBalanced = (start, end) => {
617
+ if (start > end) return;
618
+ const mid = Math.floor((start + end) / 2);
619
+ const articleData = data.articles[mid];
620
+ const article = new Article(
621
+ articleData.id,
622
+ articleData.title,
623
+ articleData.content,
624
+ articleData.createdAt,
625
+ );
626
+ this.addArticle(article);
627
+ insertBalanced(start, mid - 1);
628
+ insertBalanced(mid + 1, end);
629
+ };
630
+ insertBalanced(0, data.articles.length - 1);
631
+ } else {
632
+ for (const articleData of data.articles) {
633
+ const article = new Article(
634
+ articleData.id,
635
+ articleData.title,
636
+ articleData.content,
637
+ articleData.createdAt,
638
+ );
639
+ this.addArticle(article);
640
+ }
641
+ }
642
+ }
643
+ }
644
+
645
+ async #loadFromAPI() {
646
+ const data = await fetchData(this.#apiUrl);
647
+ if (data) {
648
+ this.#applyBlogData(data);
649
+ }
650
+ }
651
+
652
+ // controller
653
+ /** everything that happens in /api */
654
+ async #jsonAPI(req, res) {
655
+ const origin = req.headers.origin;
656
+ if (origin) {
657
+ res.setHeader("Access-Control-Allow-Origin", origin);
658
+ res.setHeader("Access-Control-Allow-Credentials", "true");
659
+ res.setHeader(
660
+ "Access-Control-Allow-Methods",
661
+ "GET, POST, PUT, DELETE, OPTIONS",
662
+ );
663
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
664
+ }
665
+ if (req.method === "OPTIONS") {
666
+ res.writeHead(204);
667
+ res.end();
668
+ return;
669
+ }
670
+ const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
671
+ const pathname = url.pathname;
672
+
673
+ if (req.method === "GET") {
674
+ if (pathname === "/api" || pathname === "/api/") {
675
+ debug("GET /api");
676
+ res.writeHead(200, { "Content-Type": "application/json" });
677
+ const data = {
678
+ title: this.title,
679
+ };
680
+ res.end(JSON.stringify(data));
681
+ }
682
+ // Search
683
+ if (url.searchParams.has("q")) {
684
+ debug("GET search article by query");
685
+ const query = url.searchParams.get("q");
686
+ const pLimit = parseInt(url.searchParams.get("limit"));
687
+ const limit = !isNaN(pLimit) ? pLimit : null;
688
+ debug("GET search article by query %s with limit %s", query, limit);
689
+ res.writeHead(200, { "Content-Type": "application/json" });
690
+ const results = this.#articles.search(query, limit);
691
+ res.end(JSON.stringify({ articles: results }));
692
+ return;
693
+ }
694
+ // GET article by ID
695
+ // /api/articles/1
696
+ const match = pathname.match(/^\/api\/articles\/(\d+)$/);
697
+ if (match) {
698
+ debug("GET article by id");
699
+ const id = parseInt(match[1]);
700
+ debug("GET article by id %d", id);
701
+ //console.log(this.#articles.getAllArticles());
702
+ const article = this.#articles.get(id);
703
+ if (article) {
704
+ res.writeHead(200, { "Content-Type": "application/json" });
705
+ res.end(JSON.stringify(article));
706
+ } else {
707
+ res.writeHead(404, { "Content-Type": "application/json" });
708
+ res.end(JSON.stringify({ error: "Not Found" }));
709
+ }
710
+ return;
711
+ }
712
+ // GET all blog data
713
+ if (pathname === "/api/articles") {
714
+ debug("GET all articles");
715
+ // Use 'offset' param as startId (filter) to get items starting at ID
716
+ const pLimit = parseInt(url.searchParams.get("limit"));
717
+ const limit = !isNaN(pLimit) ? pLimit : null;
718
+
719
+ const start = url.searchParams.get("startdate");
720
+ const end = url.searchParams.get("enddate");
721
+
722
+ debug("startdate: %d, enddate: %d, limit: %d", start, end, limit);
723
+
724
+ //const parsedStart = parseDateParam(qStartdate);
725
+ //const parsedEnd = parseDateParam(qEnddate, true);
726
+
727
+ //const effectiveStart = parsedStart !== null ? parsedStart : startID;
728
+ //const effectiveEnd = parsedEnd !== null ? parsedEnd : endID;
729
+
730
+ // controller
731
+ res.writeHead(200, { "Content-Type": "application/json" });
732
+ const dbArticles = await this.#articles.findAll(start, end, limit);
733
+ const responseData = {
734
+ title: this.title, // Keep the title from the original constant
735
+ articles: dbArticles,
736
+ };
737
+ res.end(JSON.stringify(responseData));
738
+ }
739
+
740
+ // POST a new article
741
+ } else if (req.method === "POST" && pathname === "/api/articles") {
742
+ debug("POST an article");
743
+ if (!this.#isAuthenticated(req)) {
744
+ debug("not authenticated");
745
+ res.writeHead(403, { "Content-Type": "application/json" });
746
+ res.end(JSON.stringify({ error: "Forbidden" }));
747
+ return;
748
+ }
749
+ const body = await new Promise((resolve, reject) => {
750
+ let data = "";
751
+ req.on("data", (chunk) => (data += chunk.toString()));
752
+ req.on("end", () => resolve(data));
753
+ req.on("error", reject);
754
+ });
755
+ const newArticle = JSON.parse(body);
756
+ debug("new article: %s", newArticle.title);
757
+ // local
758
+ await this.#databaseModel.save(newArticle); // --> to api server
759
+ this.postArticle(newArticle);
760
+ // external
761
+ res.writeHead(201, { "Content-Type": "application/json" });
762
+ res.end(JSON.stringify(newArticle));
763
+ } else if (req.method === "DELETE") {
764
+ debug("DELETE an article");
765
+ const match = pathname.match(/^\/api\/articles\/(\d+)$/);
766
+ if (pathname === "/api/articles" || match) {
767
+ if (!this.#isAuthenticated(req)) {
768
+ res.writeHead(403, { "Content-Type": "application/json" });
769
+ res.end(JSON.stringify({ error: "Forbidden" }));
770
+ return;
771
+ }
772
+ const id = match ? match[1] : url.searchParams.get("id");
773
+ debug("delete an article by id $d", id);
774
+ if (id) {
775
+ this.#articles.remove(parseInt(id));
776
+ if (this.#databaseModel) {
777
+ await this.#databaseModel.remove(parseInt(id));
778
+ }
779
+ res.writeHead(200, { "Content-Type": "application/json" });
780
+ res.end(JSON.stringify({ status: "deleted", id }));
781
+ }
782
+ }
783
+ } else if (req.method === "PUT") {
784
+ debug("PUT an article");
785
+ if (!this.#isAuthenticated(req)) {
786
+ res.writeHead(403, { "Content-Type": "application/json" });
787
+ res.end(JSON.stringify({ error: "Forbidden" }));
788
+ return;
789
+ }
790
+ const match = pathname.match(/^\/api\/articles\/(\d+)$/);
791
+ if (pathname === "/api/articles" || match) {
792
+ const id = match ? match[1] : url.searchParams.get("id");
793
+ debug("PUT article id: %d", id);
794
+ const body = await new Promise((resolve) => {
795
+ let data = "";
796
+ req.on("data", (chunk) => (data += chunk));
797
+ req.on("end", () => resolve(data));
798
+ });
799
+ const { title, content } = JSON.parse(body);
800
+ this.#articles.update(parseInt(id), title, content);
801
+ if (this.#databaseModel) {
802
+ await this.#databaseModel.update(parseInt(id), { title, content });
803
+ }
804
+ res.writeHead(200, { "Content-Type": "application/json" });
805
+ res.end(JSON.stringify({ status: "updated", id }));
806
+ }
807
+ }
808
+ }
809
+
810
+ /** set external API */
811
+ setAPI(APIUrl) {
812
+ this.#apiUrl = APIUrl;
813
+ this.#isExternalAPI = true;
814
+ }
815
+
816
+ /** print markdown to the console */
817
+ print() {
818
+ const data = {
819
+ title: this.title,
820
+ articles: this.#articles.getAllArticles(),
821
+ };
822
+ const markdown = formatMarkdown(data);
823
+ console.log(markdown);
824
+ }
825
+
826
+ /** render this blog content to valid html */
827
+ async toHTML(loggedin) {
828
+ const articles = this.#articles.getAllArticles();
829
+ const articles_after = articles.slice(0, 50);
830
+ // prettier-ignore
831
+ debug("slice articles from %d to %d", articles.length, articles_after.length);
832
+ const data = {
833
+ title: this.title,
834
+ articles: articles_after,
835
+ loggedin,
836
+ login: "",
837
+ };
838
+
839
+ if (loggedin)
840
+ data.login = `<form action="/logout" method="POST" style="display:inline;">
841
+ <button type="submit" class="btn">
842
+ logout
843
+ </button>
844
+ </form>`;
845
+ else data.login = `<a class="btn login" href="/login">login</a>`;
846
+
847
+ //debug("typeof data: %o", typeof data);
848
+ //debug("typeof data.articles: %o", typeof data.articles);
849
+ //debug("typeof data.articles: %O", data.articles);
850
+ const article = data.articles[0];
851
+ debug("first article: ");
852
+ debug("%d %s %s", article.id, article.title, article.getContentVeryShort());
853
+ const html = formatHTML(data);
854
+ if (validate(html)) return html;
855
+ throw new Error("Error. Invalid HTML!");
856
+ }
857
+
858
+ /**
859
+ * read files, compare checksums, compile and write to public/styles.min.css
860
+ * @param {string[]} files - Array of css/scss file paths to process.
861
+ */
862
+ async #processStylesheets(files) {
863
+ console.log("process stylesheets");
864
+
865
+ // Normalize input to array (handles string or array)
866
+ // "file1.css" --> ["file1.css"]
867
+ // ["file1.css", "file2.css",...]
868
+ const fileList = Array.isArray(files) ? files : [files];
869
+ const styleFiles = fileList.filter(
870
+ (f) =>
871
+ typeof f === "string" &&
872
+ (f.endsWith(".scss") || f.endsWith(".css")) &&
873
+ !f.endsWith(".min.css"),
874
+ );
875
+ //const scriptFiles = files.filter((f) => f.endsWith(".js") && !f.endsWith(".min.js"));
876
+
877
+ // --- Process Styles ---
878
+ if (styleFiles.length > 0) {
879
+ // read file
880
+ const fileData = await Promise.all(
881
+ styleFiles.sort().map(async (f) => {
882
+ const content = await fs.promises.readFile(f, "utf-8");
883
+ if (content == "") throw new Error("Invalid Filepath or empty file!");
884
+ return { path: f, content };
885
+ }),
886
+ );
887
+
888
+ // compute hash
889
+ const currentHash = crypto
890
+ .createHash("sha256")
891
+ .update(
892
+ fileData
893
+ .map((f) =>
894
+ crypto.createHash("sha256").update(f.content).digest("hex"),
895
+ )
896
+ .join(""),
897
+ )
898
+ .digest("hex");
899
+
900
+ // check if hash matches
901
+ if (currentHash !== this.#stylesHash && this.compilestyle) {
902
+ console.log("Style assets have changed. Recompiling...");
903
+ this.#stylesHash = currentHash;
904
+
905
+ // Compile styles using the standalone script from build-styles.js
906
+ this.compiledStyles = await compileStyles(fileData);
907
+
908
+ // generate a file
909
+ await fs.promises.mkdir(this.#publicDir, { recursive: true });
910
+ await fs.promises.writeFile(
911
+ path.join(this.#publicDir, "styles.min.css"),
912
+ this.compiledStyles + `\n/* source-hash: ${currentHash} */`,
913
+ );
914
+ } else {
915
+ console.log("styles are up-to-date");
916
+ }
917
+ }
918
+ }
919
+
920
+ /**
921
+ * read files, compare checksums, compile and write to public/scripts.min.js
922
+ * @param {string|string[]} files - File path(s) to process.
923
+ */
924
+ async #processScripts(files) {
925
+ // Normalize input to array
926
+ const fileList = Array.isArray(files) ? files : [files];
927
+ const scriptFiles = fileList.filter(
928
+ (f) =>
929
+ typeof f === "string" && f.endsWith(".js") && !f.endsWith(".min.js"),
930
+ );
931
+
932
+ if (scriptFiles.length > 0) {
933
+ const fileData = await Promise.all(
934
+ scriptFiles.map(async (f) => {
935
+ const content = await fs.promises.readFile(f, "utf-8");
936
+ if (content == "") throw new Error("Invalid Filepath or empty file!");
937
+ return { path: f, content };
938
+ }),
939
+ );
940
+
941
+ const currentHash = crypto
942
+ .createHash("sha256")
943
+ .update(
944
+ fileData
945
+ .map((f) =>
946
+ crypto.createHash("sha256").update(f.content).digest("hex"),
947
+ )
948
+ .join(""),
949
+ )
950
+ .digest("hex");
951
+
952
+ if (!this.#scriptsHash) {
953
+ try {
954
+ const existing = await fs.promises.readFile(
955
+ path.join(this.#publicDir, "scripts.min.js"),
956
+ "utf-8",
957
+ );
958
+ const match = existing.match(/\/\* source-hash: ([a-f0-9]{64}) \*\//);
959
+ if (match) this.#scriptsHash = match[1];
960
+ } catch (err) {
961
+ // ignore
962
+ }
963
+ }
964
+
965
+ if (currentHash !== this.#scriptsHash) {
966
+ console.log("Script assets have changed. Recompiling...");
967
+ this.#scriptsHash = currentHash;
968
+
969
+ const compiledScripts = await compileScripts(fileData);
970
+
971
+ await fs.promises.mkdir(this.#publicDir, { recursive: true });
972
+ await fs.promises.writeFile(
973
+ path.join(this.#publicDir, "scripts.min.js"),
974
+ compiledScripts + `\n/* source-hash: ${currentHash} */`,
975
+ );
976
+ }
977
+ }
978
+ }
979
+ }
package/Blog.js CHANGED
@@ -169,6 +169,15 @@ export default class Blog {
169
169
  return match ? this.sessions.has(match[1]) : false;
170
170
  }
171
171
 
172
+ async #readBody(req) {
173
+ return new Promise((resolve, reject) => {
174
+ let data = "";
175
+ req.on("data", (chunk) => (data += chunk.toString()));
176
+ req.on("end", () => resolve(data));
177
+ req.on("error", reject);
178
+ });
179
+ }
180
+
172
181
  async #handleLogin(req, res) {
173
182
  debug("handle login");
174
183
  const body = await new Promise((resolve, reject) => {
@@ -379,6 +388,11 @@ export default class Blog {
379
388
 
380
389
  const server = http.createServer(async (req, res) => {
381
390
  //debug("query %s", req.url);
391
+ await api(req, res, async (req, res) => {
392
+ await this.#jsonAPI(req, res);
393
+ });
394
+ if (res.headersSent) return;
395
+
382
396
  await login(
383
397
  req,
384
398
  res,
@@ -442,59 +456,34 @@ export default class Blog {
442
456
  });
443
457
  if (res.headersSent) return;
444
458
 
445
- await api(req, res, async (req, res) => {
446
- await this.#jsonAPI(req, res);
447
- });
448
- if (res.headersSent) return;
459
+ await new1(req, res, async (req, res) => {
460
+ if (!this.#isAuthenticated(req)) {
461
+ debug("not authenticated");
462
+ res.writeHead(403, { "Content-Type": "application/json" });
463
+ res.end(JSON.stringify({ error: "Forbidden" }));
464
+ return;
465
+ }
449
466
 
450
- await new1(req, res, (req, res) => {
451
- return new Promise((resolve, reject) => {
452
- if (!this.#isAuthenticated(req)) {
453
- debug("not authenticated");
454
- res.writeHead(403, { "Content-Type": "application/json" });
455
- res.end(JSON.stringify({ error: "Forbidden" }));
456
- resolve();
457
- return;
467
+ try {
468
+ const body = await this.#readBody(req);
469
+ const params = new URLSearchParams(body);
470
+ const articleData = Object.fromEntries(params.entries());
471
+
472
+ console.log("New Article Data:", articleData);
473
+ // local
474
+ await this.#databaseModel.save(articleData); // --> to api server
475
+ this.postArticle(articleData);
476
+ // external
477
+
478
+ // Success response
479
+ res.writeHead(302, { Location: "/" });
480
+ res.end();
481
+ } catch (err) {
482
+ if (!res.headersSent) {
483
+ res.writeHead(400, { "Content-Type": "application/json" });
484
+ res.end(JSON.stringify({ error: "Failed to parse form data" }));
458
485
  }
459
- let body = "";
460
-
461
- // 1. Collect data chunks
462
- req.on("data", (chunk) => {
463
- body += chunk.toString();
464
- });
465
-
466
- req.on("end", async () => {
467
- try {
468
- // 2. Parse x-www-form-urlencoded using URLSearchParams
469
- const params = new URLSearchParams(body);
470
-
471
- // 3. Convert to a plain object
472
- const articleData = Object.fromEntries(params.entries());
473
-
474
- console.log("New Article Data:", articleData);
475
- // local
476
- await this.#databaseModel.save(articleData); // --> to api server
477
- this.postArticle(articleData);
478
- // external
479
-
480
- // Success response
481
- res.writeHead(302, { Location: "/" });
482
- res.end();
483
- resolve();
484
- } catch (err) {
485
- if (!res.headersSent) {
486
- res.writeHead(400, { "Content-Type": "application/json" });
487
- res.end(JSON.stringify({ error: "Failed to parse form data" }));
488
- }
489
- resolve();
490
- }
491
- });
492
- req.on("error", reject);
493
- });
494
-
495
- /*await this.#handleLogin(req, res, async () => {
496
- await this.#handleLogin(req, res);
497
- })*/
486
+ }
498
487
  });
499
488
  if (res.headersSent) return;
500
489
 
@@ -568,6 +557,12 @@ export default class Blog {
568
557
  },
569
558
  this.#publicDir,
570
559
  );
560
+ if (res.headersSent) return;
561
+
562
+ // If no route was matched, send a 404
563
+ debug("unmatched route: %s %s", req.method, req.url);
564
+ res.writeHead(404, { "Content-Type": "text/plain" });
565
+ res.end("Not Found");
571
566
  });
572
567
 
573
568
  this.#server = server;
@@ -849,7 +844,12 @@ export default class Blog {
849
844
  //debug("typeof data.articles: %O", data.articles);
850
845
  const article = data.articles[0];
851
846
  debug("first article: ");
852
- debug("%d %s %s", article.id, article.title, article.getContentVeryShort());
847
+ debug(
848
+ "%d %s %s...",
849
+ article.id,
850
+ article.title,
851
+ article.getContentVeryShort(),
852
+ );
853
853
  const html = formatHTML(data);
854
854
  if (validate(html)) return html;
855
855
  throw new Error("Error. Invalid HTML!");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lexho111/plainblog",
3
- "version": "0.6.10",
3
+ "version": "0.6.11",
4
4
  "description": "A tool for creating and serving a minimalist, single-page blog.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -1,2 +1,2 @@
1
- body{font-family:Arial,sans-serif}h1{color:#333}:root{--black:#111;--clearwhite:#fefefe;--white:#eee;--darkgray:rgba(54,54,54,.5);--text-primary:var(--black);--text-secondary:var(--clearwhite)}body,html{margin:0}html{font-size:clamp(.85rem,1vw + .5rem,1.1rem)}body{background:var(--white_darker);color:var(--text-primary);font-family:Arial}nav{align-items:center;background:#ebebeb;display:flex;margin-top:10px;max-width:800px;padding:5px}#header h1,nav{margin-left:25px}button,form input,form textarea{box-sizing:border-box;padding:5px 10px}button{width:-moz-fit-content;width:fit-content;field-sizing:content;padding:10px}input,input:focus,textarea,textarea:focus{box-shadow:6px 6px 1px 1px rgba(50,50,50,.2)}input:focus,textarea:focus{background-color:var(--clearwhite);border:2px solid #3b40c1;outline:none}.form_element{display:block;font-family:monospace;font-size:103%;margin-bottom:10px;padding:4px}.wide{width:100%}.password{width:250px}.new_title{height:35px;padding:10px}.new_content{height:300px;padding:10px;resize:none}#createNew{max-width:800px;width:100%}#search{border:1px solid var(--text-primary);box-shadow:none;font-size:1rem;margin-left:auto;padding:.4rem 1rem}hr{margin:40px 0}.articles,hr{max-width:800px}.articles{border:0 solid #000;display:grid;gap:.25rem;grid-template-columns:1fr}.articles article{border:2px solid #a9a9a9;border-radius:4px;margin-bottom:10px;min-width:0;overflow-wrap:break-word;padding:.4rem}.articles article h2{color:#353535;margin-bottom:5px}.articles article .datetime{color:#757575;margin:0}.articles article p{margin-bottom:0;margin-top:10px}article a,article a:visited,h1{color:#696969}h2{border:0 solid #000;margin-top:0}nav a{color:#3b40c1;font-size:20px;text-decoration:underline}nav a:visited{color:#3b40c1;text-decoration-color:#3b40c1}.loginform{margin-left:25px}#wrapper{margin-left:5px;max-width:1200px;padding:0 20px}#wrapper,.buttons{box-sizing:border-box;width:100%}.buttons{align-items:center;border:0 solid #000;display:flex;gap:5px;height:25px;list-style:none;margin:0 0 16px;padding:15px 15px 15px 0}.box{margin-left:25px}.btn{border:none;border:1px solid var(--text-primary);border-radius:0;box-shadow:3px 2px 2px var(--darkgray);color:var(--text-primary);cursor:pointer;font-size:1rem;font-weight:500;padding:.4rem 1rem;text-decoration:none;width:-moz-fit-content;width:fit-content}.btn:hover{background-color:#fff;border:2px solid var(--black);color:#000}.light{background:var(--clearwhite);color:var(--black)}.light:hover{background:var(--black);color:var(--clearwhite)}.hide-image{display:none}.edit{background-color:blue}.delete,.edit{color:var(--clearwhite)}.delete{background-color:red}@media screen and (min-width:1000px){#wrapper,body{font-size:.75rem;margin:0 auto;max-width:1400px;padding:0 40px;width:90%}.articles article p,.form_element{font-size:105%}}
2
- /* source-hash: a15768c8883273ae7a9f42583fad69fb48bd68a95492f42d5ec1734153d2de5e */
1
+ body{font-family:Arial,sans-serif}h1{color:#333}:root{--black:#111;--clearwhite:#fefefe;--white:#eee;--darkgray:rgba(54,54,54,.5);--text-primary:var(--black);--text-secondary:var(--clearwhite);--max-width:600px}body,html{margin:0;overflow-x:hidden}html{font-size:clamp(.85rem,1vw + .5rem,1.1rem)}body{background:var(--white_darker);color:var(--text-primary);font-family:Arial}nav{align-items:center;background:#ebebeb;box-sizing:border-box;display:flex;margin-top:10px;max-width:var(--max-width);padding:5px}button,form input,form textarea{box-sizing:border-box;padding:5px 10px}button{width:-moz-fit-content;width:fit-content;field-sizing:content;padding:10px}input,textarea{border:1px solid #ababab}input,input:focus,textarea,textarea:focus{box-shadow:6px 6px 1px 1px rgba(50,50,50,.2)}input:focus,textarea:focus{background-color:var(--clearwhite);border:2px solid #3b40c1;outline:none}.form_element{display:block;font-family:monospace;font-size:103%;margin-bottom:10px;padding:4px}.wide{width:100%}.password{width:250px}.new_title{height:35px;padding:10px}.new_content{height:300px;padding:10px;resize:none}#createNew{max-width:var(--max-width);width:100%}#search{border:1px solid var(--text-primary);box-shadow:none;font-size:1rem;margin-left:auto;padding:.4rem 1rem}hr{margin:40px 0}.articles,hr{max-width:var(--max-width)}.articles{border:0 solid #000;display:grid;gap:.25rem;grid-template-columns:1fr}.articles article{border:2px solid #a9a9a9;border-radius:4px;margin-bottom:10px;min-width:0;overflow-wrap:break-word;padding:.4rem}.articles article h2{color:#353535;margin-bottom:5px}.articles article .datetime{color:#757575;margin:0}.articles article p{margin-bottom:0;margin-top:10px}article a,article a:visited,h1{color:#696969}h2{border:0 solid #000;margin-top:0}nav a{color:#3b40c1;font-size:20px;text-decoration:underline}nav a:visited{color:#3b40c1;text-decoration-color:#3b40c1}.loginform{margin-left:25px}#wrapper{margin-left:0;max-width:1200px;padding:0 20px}#wrapper,.buttons{box-sizing:border-box;width:100%}.buttons{align-items:center;border:0 solid #000;display:flex;gap:5px;height:25px;list-style:none;margin:0 0 16px;padding:15px 15px 15px 0}.btn{border:none;border:1px solid var(--text-primary);border-radius:0;box-shadow:3px 2px 2px var(--darkgray);color:var(--text-primary);cursor:pointer;font-size:1rem;font-weight:500;padding:.4rem 1rem;text-decoration:none;width:-moz-fit-content;width:fit-content}.btn:hover{background-color:#fff;border:2px solid var(--black);color:#000}.light{background:var(--clearwhite);color:var(--black)}.light:hover{background:var(--black);color:var(--clearwhite)}.login{font-weight:600}.hide-image{display:none}.edit{background-color:blue}.delete,.edit{color:var(--clearwhite)}.delete{background-color:red}@media screen and (min-width:1000px){#wrapper,body{font-size:.75rem;margin:0 auto;max-width:1400px;padding:0 40px;width:90%}.articles article p,.form_element{font-size:105%}}
2
+ /* source-hash: 9dc9009bca6e59acfb055334bd4573d078c478e8fc31b6ca204010e282075c24 */
package/router.js CHANGED
@@ -61,13 +61,12 @@ export async function handleLogin(req, res, cb) {
61
61
  }
62
62
 
63
63
  export async function getPages(req, res, cb, publicDir) {
64
- if (req.method === "GET" && req.url === "/") {
65
- debug(`${req.method} ${req.url}`);
66
- await cb(req, res);
67
- return;
68
- } else {
69
- //debug(`serve static files from public folder: ${req.method} ${req.url}`);
70
- //await cb2(req, res);
64
+ if (req.method === "GET") {
65
+ if (req.url === "/") {
66
+ debug(`${req.method} ${req.url}`);
67
+ await cb(req, res);
68
+ return;
69
+ }
71
70
  // Try to serve static files from public folder
72
71
  // Normalize path to prevent directory traversal attacks
73
72
  const safePath = path.normalize(req.url).replace(/^(\.\.[\/\\])+/, "");
package/src/styles.css CHANGED
@@ -5,11 +5,13 @@
5
5
  --darkgray: rgba(54, 54, 54, 0.5);
6
6
  --text-primary: var(--black);
7
7
  --text-secondary: var(--clearwhite);
8
+ --max-width: 600px;
8
9
  }
9
10
 
10
11
  html,
11
12
  body {
12
13
  margin: 0;
14
+ overflow-x: hidden;
13
15
  }
14
16
  html {
15
17
  font-size: clamp(0.85rem, 1vw + 0.5rem, 1.1rem);
@@ -23,14 +25,19 @@ nav {
23
25
  margin-top: 10px;
24
26
  display: flex;
25
27
  align-items: center;
26
- max-width: 800px;
28
+ max-width: var(--max-width);
27
29
  background: hsl(0deg 0% 92.27%);
28
30
  padding: 5px;
31
+ box-sizing: border-box;
29
32
  }
30
33
  nav,
31
34
  #header h1 {
32
35
  }
33
36
 
37
+ #header {
38
+ max-width: var(--max-width);
39
+ }
40
+
34
41
  form input,
35
42
  form textarea,
36
43
  button {
@@ -44,6 +51,10 @@ button {
44
51
  padding: 10px;
45
52
  }
46
53
 
54
+ img {
55
+ max-width: 100%;
56
+ }
57
+
47
58
  input,
48
59
  textarea {
49
60
  box-shadow: 6px 6px 1px 1px rgb(50 50 50 / 0.2);
@@ -87,7 +98,7 @@ textarea:focus {
87
98
 
88
99
  #createNew {
89
100
  width: 100%;
90
- max-width: 800px;
101
+ max-width: var(--max-width);
91
102
  }
92
103
 
93
104
  #search {
@@ -99,7 +110,7 @@ textarea:focus {
99
110
  }
100
111
 
101
112
  hr {
102
- max-width: 800px;
113
+ max-width: var(--max-width);
103
114
  margin-left: 0;
104
115
  margin: 40px 0;
105
116
  }
@@ -109,7 +120,7 @@ hr {
109
120
  display: grid;
110
121
  gap: 0.25rem;
111
122
  grid-template-columns: 1fr;
112
- max-width: 800px;
123
+ max-width: var(--max-width);
113
124
  }
114
125
  .articles article {
115
126
  border: 0 solid #ccc;
@@ -172,7 +183,7 @@ nav a:visited {
172
183
  /*margin: 0 auto; /* Centers the layout on PC */
173
184
  padding: 0 20px; /* Consistent spacing on edges */
174
185
  width: 100%;
175
- margin-left: 5px;
186
+ margin-left: 0;
176
187
  box-sizing: border-box;
177
188
  }
178
189