@bwog/bwog 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,99 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+
3
+ class SqliteDatabase {
4
+ constructor(file) {
5
+ this.file = file;
6
+ }
7
+
8
+ async connect() {
9
+ this.db = new DatabaseSync(this.file);
10
+ }
11
+
12
+ async makeFromJsonDatabase(JsonDatabase) {
13
+ this.db.exec(`
14
+ CREATE TABLE posts (
15
+ id TEXT PRIMARY KEY,
16
+ author TEXT NOT NULL,
17
+ created_at TEXT NOT NULL,
18
+ content JSONB NOT NULL
19
+ );
20
+ `);
21
+
22
+ const insertPost = this.db.prepare('INSERT INTO posts (id, author, created_at, content) VALUES (?, ?, ?, jsonb(?));');
23
+
24
+ JsonDatabase.getAll().forEach(post => {
25
+ const content = {...post};
26
+ delete content.id;
27
+ delete content.author;
28
+ delete content.createdAt;
29
+ insertPost.run(post.id, post.author, post.createdAt, JSON.stringify(content));
30
+ });
31
+ }
32
+
33
+ async getAll() {
34
+ const rawPosts = this.db.prepare(`
35
+ SELECT
36
+ id,
37
+ author,
38
+ created_at,
39
+ json(content) as content
40
+ FROM posts
41
+ ORDER BY created_at DESC;
42
+ `).all();
43
+
44
+ return rawPosts.map(post => ({
45
+ id: post.id,
46
+ author: post.author,
47
+ createdAt: post.created_at,
48
+ ...JSON.parse(post.content)
49
+ }));
50
+ }
51
+
52
+ async getLastTwoMonthsAtLeast20() {
53
+ const rawPosts = this.db.prepare(`
54
+ WITH latest_two_months AS (
55
+ SELECT DISTINCT strftime('%Y-%m', created_at) AS month
56
+ FROM posts
57
+ ORDER BY month DESC
58
+ LIMIT 2
59
+ ),
60
+ suitability AS (
61
+ SELECT *,
62
+ CASE
63
+ WHEN created_at IN latest_two_months THEN 1
64
+ ELSE 0
65
+ END AS in_latest_two_months,
66
+ ROW_NUMBER() OVER (ORDER BY created_at DESC) AS recency
67
+ FROM posts
68
+ )
69
+ SELECT
70
+ id,
71
+ author,
72
+ created_at,
73
+ json(content) as content
74
+ FROM suitability
75
+ WHERE in_latest_two_months = 1
76
+ OR recency <= 20
77
+ ORDER BY created_at DESC;
78
+ `).all();
79
+
80
+ return rawPosts.map(post => ({
81
+ id: post.id,
82
+ author: post.author,
83
+ createdAt: post.created_at,
84
+ ...JSON.parse(post.content)
85
+ }));
86
+ }
87
+
88
+ async createPost(post) {
89
+ const insertPost = this.db.prepare('INSERT INTO posts (id, author, created_at, content) VALUES (?, ?, ?, jsonb(?));');
90
+
91
+ const content = {...post};
92
+ delete content.id;
93
+ delete content.author;
94
+ delete content.createdAt;
95
+ insertPost.run(post.id, post.author, post.createdAt, JSON.stringify(content));
96
+ }
97
+ }
98
+
99
+ export default SqliteDatabase;
package/generator.js ADDED
@@ -0,0 +1,460 @@
1
+ import { Eta } from 'eta';
2
+ import beautify from 'js-beautify';
3
+ import { minify } from 'html-minifier-next';
4
+ import {createTranslator} from 'short-uuid';
5
+
6
+ import templates from './templates.js';
7
+
8
+ class Generator {
9
+ constructor(store, database) {
10
+ this.store = store;
11
+ this.database = database;
12
+ this.eta = new Eta();
13
+ }
14
+
15
+ async generate(siteUrl, authorName, authorEmail, enableAP, apHandle, apUsername, apPublicKey) {
16
+ const posts = await this.database.getAll();
17
+ const sitemap = [];
18
+
19
+ const postsByMonth = posts.reduce((acc, post) => {
20
+ let currentMonth = null;
21
+ if (acc.length > 0) {
22
+ const lastDate = new Date(acc[acc.length - 1][0].createdAt);
23
+ const currentDate = new Date(post.createdAt);
24
+ if (lastDate.getFullYear() === currentDate.getFullYear() && lastDate.getMonth() === currentDate.getMonth()) {
25
+ currentMonth = acc[acc.length - 1];
26
+ }
27
+ }
28
+
29
+ if (currentMonth === null) {
30
+ currentMonth = [];
31
+ acc.push(currentMonth);
32
+ }
33
+
34
+ currentMonth.push(post);
35
+
36
+ return acc;
37
+ }, []);
38
+
39
+ try {
40
+ await this.generateIndex(siteUrl, posts, sitemap);
41
+ await this.writeAtom(siteUrl, authorName, authorEmail, posts, 'feed.atom');
42
+ } catch (error) {
43
+ console.error(error);
44
+ return;
45
+ }
46
+
47
+ let previousMonth = postsByMonth[0][0].createdAt;
48
+ let currentMonth = null;
49
+ let nextMonth = null;
50
+ for (let i = 0; i < postsByMonth.length; ++i) {
51
+ nextMonth = currentMonth;
52
+ currentMonth = previousMonth;
53
+
54
+ if (postsByMonth.length > i + 1) {
55
+ previousMonth = postsByMonth[i + 1][0].createdAt;
56
+ } else {
57
+ previousMonth = null;
58
+ }
59
+
60
+ try {
61
+ await this.writePage(postsByMonth[i],
62
+ this.displayMonthFromIsoTimestamp(currentMonth),
63
+ `${siteUrl}/${this.filenameFromIsoTimestamp(currentMonth)}`,
64
+ this.filenameFromIsoTimestamp(currentMonth),
65
+ previousMonth === null ? null : this.filenameFromIsoTimestamp(previousMonth),
66
+ nextMonth === null ? null : this.filenameFromIsoTimestamp(nextMonth));
67
+
68
+ sitemap.push({
69
+ url: `${siteUrl}/${this.filenameFromIsoTimestamp(currentMonth)}`,
70
+ updatedAt: currentMonth,
71
+ });
72
+
73
+ if (enableAP) {
74
+ await this.writeAPOutboxPage(postsByMonth[i],
75
+ siteUrl,
76
+ this.filenameFromIsoTimestamp(currentMonth),
77
+ nextMonth === null ? null : this.filenameFromIsoTimestamp(nextMonth),
78
+ previousMonth === null ? null : this.filenameFromIsoTimestamp(previousMonth));
79
+ }
80
+ } catch (error) {
81
+ console.error(error);
82
+ return;
83
+ }
84
+ }
85
+
86
+ await this.writeSitemap(sitemap, 'sitemap.xml');
87
+
88
+ if (enableAP) {
89
+ await this.writeAPOutbox(posts, siteUrl, 'ap/outbox.json');
90
+ await this.writeAPWebfinger(siteUrl, apHandle, 'ap/webfinger.json');
91
+ await this.writeAPActor(siteUrl, apUsername, apPublicKey, authorName, 'ap/actor.json');
92
+ }
93
+ }
94
+
95
+ async generatePartial(siteUrl, authorName, authorEmail, enableAP, newPost) {
96
+ const posts = await this.database.getLastTwoMonthsAtLeast20();
97
+ const sitemap = [];
98
+
99
+ this.generateIndex(siteUrl, posts, sitemap);
100
+ await this.writeAtom(siteUrl, authorName, authorEmail, posts, 'feed.atom');
101
+
102
+ const newPostDate = new Date(newPost.createdAt);
103
+ const thisMonthPosts = posts.filter(post => {
104
+ const postDate = new Date(post.createdAt);
105
+
106
+ return postDate.getFullYear() === newPostDate.getFullYear()
107
+ && postDate.getMonth() === newPostDate.getMonth();
108
+ });
109
+ const isNewMonth = thisMonthPosts.length === 1;
110
+
111
+ try {
112
+ await this.writePage(thisMonthPosts,
113
+ this.displayMonthFromIsoTimestamp(newPost.createdAt),
114
+ `${siteUrl}/${this.filenameFromIsoTimestamp(newPost.createdAt)}`,
115
+ this.filenameFromIsoTimestamp(newPost.createdAt),
116
+ posts.length > thisMonthPosts.length ? this.filenameFromIsoTimestamp(posts[thisMonthPosts.length].createdAt) : null,
117
+ null);
118
+
119
+ if (enableAP) {
120
+ await this.writeAPOutboxPage(thisMonthPosts,
121
+ siteUrl,
122
+ this.filenameFromIsoTimestamp(newPost.createdAt),
123
+ null,
124
+ posts.length > thisMonthPosts.length ? this.filenameFromIsoTimestamp(posts[thisMonthPosts.length].createdAt) : null);
125
+ }
126
+ } catch (error) {
127
+ console.error(error);
128
+ }
129
+
130
+ if (isNewMonth) {
131
+ if (posts.length > 1) {
132
+ const prevPostDate = new Date(posts[1].createdAt);
133
+ const lastMonthPosts = posts.filter(post => {
134
+ const postDate = new Date(post.createdAt);
135
+ return postDate.getFullYear() === prevPostDate.getFullYear()
136
+ && postDate.getMonth() === prevPostDate.getMonth()
137
+ });
138
+
139
+ try {
140
+ await this.writePage(lastMonthPosts,
141
+ this.displayMonthFromIsoTimestamp(posts[1].createdAt),
142
+ `${siteUrl}/${this.filenameFromIsoTimestamp(posts[1].createdAt)}`,
143
+ this.filenameFromIsoTimestamp(posts[1].createdAt),
144
+ posts.length > lastMonthPosts.length + 1 ? this.filenameFromIsoTimestamp(posts[lastMonthPosts.length + 1].createdAt) : null,
145
+ this.filenameFromIsoTimestamp(newPost.createdAt));
146
+
147
+ if (enableAP) {
148
+ await this.writeAPOutboxPage(lastMonthPosts,
149
+ siteUrl,
150
+ this.filenameFromIsoTimestamp(posts[1].createdAt),
151
+ this.filenameFromIsoTimestamp(newPost.createdAt),
152
+ posts.length > lastMonthPosts.length + 1 ? this.filenameFromIsoTimestamp(posts[lastMonthPosts.length + 1].createdAt) : null);
153
+ }
154
+ } catch (error) {
155
+ console.error(error);
156
+ }
157
+ }
158
+
159
+ const months = posts.reduce((acc, post) => {
160
+ if (acc.length === 0) {
161
+ return [post.createdAt];
162
+ }
163
+
164
+ const postDate = new Date(post.createdAt);
165
+ const lastDate = new Date(acc[acc.length - 1]);
166
+
167
+ if (postDate.getFullYear() === lastDate.getFullYear() && postDate.getMonth() === lastDate.getMonth()) {
168
+ return acc;
169
+ }
170
+
171
+ return [...acc, post.createdAt];
172
+ }, []);
173
+
174
+ months.forEach(month => sitemap.push({
175
+ url: `${siteUrl}/${this.filenameFromIsoTimestamp(month)}`,
176
+ updatedAt: month,
177
+ }));
178
+
179
+ await this.writeSitemap(sitemap, 'sitemap.xml');
180
+
181
+ if (enableAP) {
182
+ await this.writeAPOutbox(posts, 'ap/outbox.json');
183
+ }
184
+ }
185
+ }
186
+
187
+ async generateIndex(siteUrl, posts, sitemap) {
188
+ const mainPage = posts.slice(0, 10);
189
+
190
+ try {
191
+ await this.writePage(mainPage,
192
+ null,
193
+ `${siteUrl}/`,
194
+ 'index',
195
+ this.filenameFromIsoTimestamp(posts[10].createdAt),
196
+ null);
197
+ sitemap.push({
198
+ url: `${siteUrl}/`,
199
+ updatedAt: mainPage[0].createdAt,
200
+ });
201
+ } catch (error) {
202
+ throw(error);
203
+ }
204
+ }
205
+
206
+ async writeSitemap(sitemap, file) {
207
+ const xml = beautify.html(
208
+ templates.sitemap.call(this.eta, {pages: sitemap}),
209
+ {end_with_newline: true},
210
+ );
211
+
212
+ try {
213
+ await this.store.write(file, xml, 'application/xml');
214
+ } catch (err) {
215
+ throw (`Failed to write file: ${file}`);
216
+ }
217
+ }
218
+
219
+ async writePage(posts, title, canonical, file, prevPage, nextPage) {
220
+ const html = beautify.html(
221
+ templates.default.call(this.eta, {posts, title, canonical, prevPage, nextPage}, {tab_size: 4, ignore: ['style', 'script']}),
222
+ {
223
+ end_with_newline: true,
224
+ space_after_anon_function: true,
225
+ operator_position: 'after-newline',
226
+ extra_liners: [],
227
+ },
228
+ );
229
+
230
+ try {
231
+ await this.store.write(file, html, 'text/html')
232
+ } catch (err) {
233
+ throw (`Failed to write file: ${file}`);
234
+ }
235
+ }
236
+
237
+ async writeAtom(siteUrl, authorName, authorEmail, posts, file) {
238
+ const translator = createTranslator();
239
+
240
+ const latestPosts = posts.slice(0, 20);
241
+
242
+ const postsWithDerivedData = await Promise.all(latestPosts.map(async post => ({
243
+ ...post,
244
+ title: post.text.replace(/(https:\/\/\S*)/g, '').replaceAll('\n', ' ').replace(/\s\s+/g, ' ').trimEnd() || 'link',
245
+ uuid: translator.toUUID(post.id),
246
+ archivePage: `${siteUrl}/${this.filenameFromIsoTimestamp(post.createdAt)}`,
247
+ html: await minify(templates.basic_html.call(this.eta, {post})),
248
+ })));
249
+
250
+ const xml = beautify.html(
251
+ templates.atom_feed.call(this.eta, {
252
+ siteUrl,
253
+ authorName,
254
+ authorEmail,
255
+ posts: postsWithDerivedData
256
+ }),
257
+ {end_with_newline: true},
258
+ );
259
+
260
+ try {
261
+ await this.store.write(file, xml, 'application/atom+xml')
262
+ } catch (err) {
263
+ throw (`Failed to write file: ${file}`);
264
+ }
265
+ }
266
+
267
+ async writeAPWebfinger(siteUrl, apHandle, file) {
268
+ const webfinger = {
269
+ "subject": `acct:${apHandle}`,
270
+ "aliases": [
271
+ siteUrl
272
+ ],
273
+ "links": [
274
+ {
275
+ "rel": "self",
276
+ "type": "application/activity+json",
277
+ "href": `${siteUrl}/ap/actor`
278
+ },
279
+ {
280
+ "rel": "http://webfinger.net/rel/profile-page",
281
+ "type": "text/html",
282
+ "href": siteUrl
283
+ }
284
+ ]
285
+ };
286
+
287
+ const json = beautify.js(
288
+ JSON.stringify(webfinger),
289
+ {end_with_newline: true},
290
+ );
291
+ try {
292
+ await this.store.write(file, json, 'application/jrd+json');
293
+ } catch (err) {
294
+ throw (`Failed to write file: ${file}`);
295
+ }
296
+ }
297
+
298
+ async writeAPActor(siteUrl, apUsername, apPublicKey, authorName, file) {
299
+ const actor = {
300
+ "@context": [
301
+ "https://www.w3.org/ns/activitystreams",
302
+ "https://w3id.org/security/v1"
303
+ ],
304
+ "id": `${siteUrl}/ap/actor`,
305
+ "type": "Person",
306
+ "preferredUsername": apUsername,
307
+ "name": authorName,
308
+ "inbox": `${siteUrl}/ap/inbox`,
309
+ "outbox": `${siteUrl}/ap/outbox`,
310
+ "icon": {
311
+ "type": "Image",
312
+ "mediaType": "image/png",
313
+ "url": `${siteUrl}/favicon.png`
314
+ },
315
+ "url": siteUrl,
316
+ "publicKey": {
317
+ "id": `${siteUrl}/ap/actor#main-key`,
318
+ "owner": `${siteUrl}/ap/actor`,
319
+ "publicKeyPem": apPublicKey
320
+ }
321
+ }
322
+
323
+ const json = beautify.js(
324
+ JSON.stringify(actor),
325
+ {end_with_newline: true},
326
+ );
327
+ try {
328
+ await this.store.write(file, json, 'application/activity+json');
329
+ } catch (err) {
330
+ throw (`Failed to write file: ${file}`);
331
+ }
332
+ }
333
+
334
+ async writeAPOutbox(posts, siteUrl, file) {
335
+ const url = `${siteUrl}/ap/outbox`;
336
+ const first = this.filenameFromIsoTimestamp(new Date(posts[0].createdAt));
337
+ const last = this.filenameFromIsoTimestamp(new Date(posts[posts.length - 1].createdAt));
338
+
339
+ const outbox = {
340
+ "@context": "https://www.w3.org/ns/activitystreams",
341
+ "type": "OrderedCollection",
342
+ "totalItems": posts.length,
343
+ "first": `${url}?page=${first}`,
344
+ "last": `${url}?page=${last}`
345
+ }
346
+
347
+ const json = beautify.js(
348
+ JSON.stringify(outbox),
349
+ {end_with_newline: true},
350
+ );
351
+ try {
352
+ await this.store.write(file, json, 'application/activity+json');
353
+ } catch (err) {
354
+ throw (`Failed to write file: ${file}`);
355
+ }
356
+ }
357
+
358
+ async writeAPOutboxPage(posts, siteUrl, currentMonth, prevPage, nextPage) {
359
+ const page = {
360
+ "@context": "https://www.w3.org/ns/activitystreams",
361
+ "type": "OrderedCollectionPage",
362
+ "id": `${siteUrl}/ap/outbox?page=${currentMonth}`,
363
+ "partOf": `${siteUrl}/ap/outbox`,
364
+ "prev": `${siteUrl}/ap/outbox?page=${prevPage}`,
365
+ "next": `${siteUrl}/ap/outbox?page=${nextPage}`,
366
+ "orderedItems": []
367
+ };
368
+
369
+ if (!prevPage) {
370
+ delete page.prev;
371
+ }
372
+
373
+ if (!nextPage) {
374
+ delete page.next;
375
+ }
376
+
377
+ for (const post of posts) {
378
+ const url = `${siteUrl}/ap/notes/${post.id}`;
379
+
380
+ const noteHtml = await minify(templates.basic_html.call(this.eta, {post}))
381
+
382
+ const note = {
383
+ "id": url,
384
+ "type": "Note",
385
+ "published": post.createdAt,
386
+ "url": `${siteUrl}/${currentMonth}`,
387
+ "attributedTo": `${siteUrl}/ap/actor`,
388
+ "to": [
389
+ `${siteUrl}/ap/followers`
390
+ ],
391
+ "cc": [
392
+ "https://www.w3.org/ns/activitystreams#Public"
393
+ ],
394
+ "sensitive": false,
395
+ "content": noteHtml
396
+ };
397
+
398
+ const create = {
399
+ "id": `${url}?activity=true`,
400
+ "type": "Create",
401
+ "actor": `${siteUrl}/ap/actor`,
402
+ "published": post.createdAt,
403
+ "to": [
404
+ `${siteUrl}/ap/followers`
405
+ ],
406
+ "cc": [
407
+ "https://www.w3.org/ns/activitystreams#Public"
408
+ ],
409
+ "object": note
410
+ };
411
+
412
+ page.orderedItems.push(create);
413
+
414
+ const notePath = `ap/notes/${post.id}.json`;
415
+ const noteJson = beautify.js(
416
+ JSON.stringify(note),
417
+ {end_with_newline: true},
418
+ );
419
+ try {
420
+ await this.store.write(notePath, noteJson, 'application/activity+json');
421
+ } catch (err) {
422
+ throw (`Failed to write file: ${notePath}`);
423
+ }
424
+
425
+ const createPath = `ap/creates/${post.id}.json`;
426
+ const createJson = beautify.js(
427
+ JSON.stringify(create),
428
+ {end_with_newline: true},
429
+ );
430
+ try {
431
+ await this.store.write(createPath, createJson, 'application/activity+json');
432
+ } catch (err) {
433
+ throw (`Failed to write file: ${createPath}`);
434
+ }
435
+ }
436
+
437
+ const pagePath = `ap/outbox_pages/${currentMonth}.json`;
438
+ const pageJson = beautify.js(
439
+ JSON.stringify(page),
440
+ {end_with_newline: true},
441
+ );
442
+ try {
443
+ await this.store.write(pagePath, pageJson, 'application/activity+json');
444
+ } catch (err) {
445
+ throw (`Failed to write file: ${pagePath}`);
446
+ }
447
+ }
448
+
449
+ filenameFromIsoTimestamp(timestamp) {
450
+ const date = new Date(timestamp);
451
+ return `${date.getFullYear()}-${date.getMonth() + 1}`;
452
+ }
453
+
454
+ displayMonthFromIsoTimestamp(timestamp) {
455
+ const date = new Date(timestamp);
456
+ return date.toLocaleDateString(undefined, {year: 'numeric', month: 'long'});
457
+ }
458
+ }
459
+
460
+ export default Generator;
Binary file
Binary file
package/nginx.config ADDED
@@ -0,0 +1,61 @@
1
+ map $arg_resource $webfinger_resource {
2
+ "acct:username@example.com" /ap/webfinger.json;
3
+ "acct%3Ausername%40example.com" /ap/webfinger.json;
4
+ }
5
+
6
+ map $arg_page $outbox {
7
+ "" /ap/outbox.json;
8
+ "~^(?<i>[0-9]+-[0-9]+)?" /ap/outbox_pages/$i.json;
9
+ }
10
+
11
+ map $uri $note_id {
12
+ "~^/ap/notes/(?<id>.+)$" $id;
13
+ }
14
+
15
+ map $arg_activity $notes {
16
+ "true" /ap/creates/$note_id.json;
17
+ default /ap/notes/$note_id.json;
18
+ }
19
+
20
+ server {
21
+ listen 443 ssl;
22
+ ssl_certificate /etc/ssl/certs/example.com.crt;
23
+ ssl_certificate_key /etc/ssl/private/example.com.key;
24
+
25
+ server_name example.com;
26
+ root /var/www/example;
27
+ index index.html;
28
+
29
+ location / {
30
+ try_files $uri.html $uri $uri/ =404;
31
+ }
32
+
33
+ location ~ ^/(post|login|logout|oauth2/redirect/google) {
34
+ proxy_pass http://localhost:3000;
35
+ proxy_http_version 1.1;
36
+ proxy_set_header Upgrade $http_upgrade;
37
+ proxy_set_header Connection 'upgrade';
38
+ proxy_set_header Host $host;
39
+ proxy_cache_bypass $http_upgrade;
40
+ }
41
+
42
+ location /.well-known/webfinger {
43
+ default_type "application/jrd+json";
44
+ try_files $webfinger_resource =404;
45
+ }
46
+
47
+ location /ap {
48
+ types { } default_type "application/activity+json";
49
+ try_files $uri.json =404;
50
+ }
51
+
52
+ location /ap/outbox {
53
+ types { } default_type "application/activity+json";
54
+ try_files $outbox =404;
55
+ }
56
+
57
+ location /ap/notes {
58
+ types { } default_type "application/activity+json";
59
+ try_files $notes =404;
60
+ }
61
+ }
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
- {
2
- "name": "@bwog/bwog",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "@bwog/bwog",
3
+ "version": "0.1.0",
4
+ "description": "bwog is a toy static site generator for a tumblelog/microblog",
5
+ "keywords": [
6
+ "blog",
7
+ "cms",
8
+ "fediverse",
9
+ "microblog",
10
+ "tumblelog",
11
+ "smallweb",
12
+ "static-site-generator",
13
+ "ssg"
14
+ ],
15
+ "author": "Chase Patterson (https://github.com/chapatt)",
16
+ "homepage": "https://github.com/chapatt/bwog",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/chapatt/bwog.git"
20
+ },
21
+ "bugs": "https://github.com/chapatt/bwog/issues",
22
+ "license": "MIT",
23
+ "engines": {
24
+ "node": ">=22.23.5"
25
+ },
26
+ "type": "module",
27
+ "dependencies": {
28
+ "@hono/oauth-providers": "^0.9.1",
29
+ "@minify-html/wasm": "^0.18.1",
30
+ "commander": "^13.1.0",
31
+ "dotenv": "^18.0.10",
32
+ "eta": "^4.6.0",
33
+ "express": "^5.1.0",
34
+ "express-session": "^1.18.1",
35
+ "hono": "^4.13.18",
36
+ "html-minifier-next": "^9.1.2",
37
+ "js-beautify": "^2.0.3",
38
+ "passport": "^0.7.0",
39
+ "passport-google-oauth20": "^2.0.0",
40
+ "short-uuid": "^6.0.3"
41
+ },
42
+ "scripts": {
43
+ "build": "node scripts/compile_templates.js"
44
+ },
45
+ "bin": {
46
+ "bwog": "./cli.js"
47
+ }
48
+ }