scribelog 1.0.1 → 1.0.2
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/README.md +103 -79
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Scribelog 🪵📝
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/scribelog)
|
|
3
|
+
[](https://www.npmjs.com/package/scribelog)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
[](https://github.com/tolongames/scribelog/actions/workflows/node.js.yml)
|
|
5
|
+
[](https://github.com/tolongames/scribelog/actions/workflows/node.js.yml)
|
|
6
6
|
<!-- Add other badges if you have them (e.g., coverage) -->
|
|
7
7
|
|
|
8
8
|
**Scribelog** is an advanced, highly configurable logging library for Node.js applications, written in TypeScript. It offers flexible formatting, support for multiple destinations (transports), child loggers, and automatic error catching, aiming for a great developer experience.
|
|
@@ -36,40 +36,56 @@ pnpm add scribelog
|
|
|
36
36
|
## 🚀 Basic Usage
|
|
37
37
|
|
|
38
38
|
```ts
|
|
39
|
-
|
|
39
|
+
// Import necessary functions and types
|
|
40
|
+
import { createLogger, format, transports } from 'scribelog';
|
|
40
41
|
|
|
41
42
|
// Create a logger with default settings:
|
|
42
43
|
// - Level: 'info'
|
|
43
|
-
// - Format: Simple, colored output to console
|
|
44
|
+
// - Format: Simple, colored output to console (defaultSimpleFormat)
|
|
44
45
|
// - Transport: Console
|
|
45
46
|
const logger = createLogger();
|
|
46
47
|
|
|
47
48
|
// Log messages at different levels
|
|
48
49
|
logger.info('Application started successfully.');
|
|
49
|
-
logger.warn('Warning: Cache memory usage high.', { usage: '85%' });
|
|
50
|
+
logger.warn('Warning: Cache memory usage high.', { usage: '85%' }); // Add metadata
|
|
50
51
|
|
|
51
|
-
//
|
|
52
|
+
// --- Correct way to log Errors ---
|
|
53
|
+
// Pass a message string as the first argument,
|
|
54
|
+
// and the Error object in the metadata (typically under the 'error' key).
|
|
55
|
+
// The `format.errors()` formatter (included in defaults) will handle it.
|
|
52
56
|
const dbError = new Error('Database connection timeout');
|
|
53
|
-
(dbError as any).code = 'DB_TIMEOUT'; //
|
|
54
|
-
logger.error(
|
|
57
|
+
(dbError as any).code = 'DB_TIMEOUT'; // You can add custom properties to errors
|
|
58
|
+
logger.error('Database Error Occurred', { error: dbError });
|
|
59
|
+
|
|
55
60
|
logger.info('Operation completed', { user: 'admin', durationMs: 120 });
|
|
56
61
|
|
|
57
|
-
// Debug logs won't appear
|
|
62
|
+
// Debug logs won't appear with the default 'info' level
|
|
58
63
|
logger.debug('Detailed step for debugging.');
|
|
64
|
+
|
|
65
|
+
// --- Example with JSON format and debug level ---
|
|
66
|
+
const jsonLogger = createLogger({
|
|
67
|
+
level: 'debug', // Log 'debug' and higher levels
|
|
68
|
+
format: format.defaultJsonFormat, // Use predefined JSON format (includes errors, timestamp, etc.)
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
jsonLogger.debug('Debugging operation X', { operationId: 'op-xyz' });
|
|
72
|
+
// Example JSON Output:
|
|
73
|
+
// {"level":"debug","message":"Debugging operation X","timestamp":"...ISO_STRING...","operationId":"op-xyz"}
|
|
59
74
|
```
|
|
60
75
|
|
|
61
|
-
**Example Output (Simple Format with Colors):**
|
|
76
|
+
**Example Output (Default Simple Format with Colors):**
|
|
62
77
|
|
|
63
78
|
```bash
|
|
64
79
|
# (Timestamp will be gray, [INFO] green, [WARN] yellow, [ERROR] red)
|
|
65
80
|
2024-05-01T10:00:00.123Z [INFO]: Application started successfully.
|
|
66
81
|
2024-05-01T10:00:01.456Z [WARN]: Warning: Cache memory usage high. { usage: '85%' }
|
|
67
|
-
2024-05-01T10:00:02.789Z [ERROR]: Database connection timeout { errorName: 'Error', code: 'DB_TIMEOUT' }
|
|
82
|
+
2024-05-01T10:00:02.789Z [ERROR]: Database connection timeout { exception: true, eventType: undefined, errorName: 'Error', code: 'DB_TIMEOUT' }
|
|
68
83
|
Error: Database connection timeout
|
|
69
84
|
at <anonymous>:10:17
|
|
70
85
|
... (stack trace) ...
|
|
71
86
|
2024-05-01T10:00:03.111Z [INFO]: Operation completed { user: 'admin', durationMs: 120 }
|
|
72
87
|
```
|
|
88
|
+
*(Note: `eventType` is undefined here because the error wasn't logged via `handleExceptions`/`handleRejections`)*
|
|
73
89
|
|
|
74
90
|
---
|
|
75
91
|
|
|
@@ -95,28 +111,35 @@ Create and configure your logger using `createLogger(options?: LoggerOptions)`.
|
|
|
95
111
|
import { createLogger, format, transports } from 'scribelog';
|
|
96
112
|
|
|
97
113
|
const prodLogger = createLogger({
|
|
98
|
-
level: 'info', //
|
|
99
|
-
format: format.defaultJsonFormat,
|
|
114
|
+
level: process.env.LOG_LEVEL || 'info', // Read level from environment or default to info
|
|
115
|
+
format: format.defaultJsonFormat, // Use predefined JSON format
|
|
100
116
|
transports: [
|
|
101
117
|
new transports.Console({
|
|
102
|
-
// Console specific options
|
|
103
|
-
// e.g., level: 'info' (though logger level already covers this)
|
|
118
|
+
// Console specific options if needed
|
|
104
119
|
}),
|
|
105
|
-
//
|
|
106
|
-
// new transports.File({ filename: 'app.log', level: 'warn' })
|
|
120
|
+
// Future: new transports.File({ filename: '/var/log/app.log', level: 'warn' })
|
|
107
121
|
],
|
|
108
122
|
defaultMeta: {
|
|
109
|
-
|
|
110
|
-
|
|
123
|
+
service: 'my-prod-service',
|
|
124
|
+
pid: process.pid,
|
|
125
|
+
// You can add more static metadata here
|
|
111
126
|
},
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
// exitOnError: true // Default is true, ensures app exits on fatal errors
|
|
127
|
+
handleExceptions: true, // Recommended for production
|
|
128
|
+
handleRejections: true, // Recommended for production
|
|
129
|
+
// exitOnError: true // Default, recommended for production
|
|
116
130
|
});
|
|
117
131
|
|
|
118
132
|
prodLogger.info('Production logger initialized.');
|
|
119
|
-
|
|
133
|
+
try {
|
|
134
|
+
// Simulate an operation that might fail
|
|
135
|
+
throw new Error('Critical configuration error!');
|
|
136
|
+
} catch (error) {
|
|
137
|
+
// Log the caught error correctly
|
|
138
|
+
prodLogger.error('Failed to apply configuration', { error: error as Error });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Example of an unhandled rejection that would be caught if not caught here
|
|
142
|
+
// Promise.reject('Something failed asynchronously');
|
|
120
143
|
```
|
|
121
144
|
|
|
122
145
|
---
|
|
@@ -126,13 +149,7 @@ prodLogger.error(new Error('Critical configuration error!'));
|
|
|
126
149
|
Scribelog uses standard `npm` logging levels (ordered from most to least severe):
|
|
127
150
|
|
|
128
151
|
```text
|
|
129
|
-
error: 0
|
|
130
|
-
warn: 1
|
|
131
|
-
info: 2
|
|
132
|
-
http: 3
|
|
133
|
-
verbose: 4
|
|
134
|
-
debug: 5
|
|
135
|
-
silly: 6
|
|
152
|
+
error: 0, warn: 1, info: 2, http: 3, verbose: 4, debug: 5, silly: 6
|
|
136
153
|
```
|
|
137
154
|
|
|
138
155
|
Setting the `level` option filters messages *at or above* the specified severity. `level: 'info'` logs `info`, `warn`, and `error`. `level: 'debug'` logs everything.
|
|
@@ -144,7 +161,7 @@ Setting the `level` option filters messages *at or above* the specified severity
|
|
|
144
161
|
Formatters transform the log `info` object before it reaches transports. Use `format.combine(...)` to chain them.
|
|
145
162
|
|
|
146
163
|
**How it Works:**
|
|
147
|
-
`createLogger` -> `log()`/`logEntry()` -> Creates `LogInfo` object -> Passes to `format` function -> `format` function applies its chain -> Result (string or object) passed to `transport.log()`.
|
|
164
|
+
`createLogger` -> `log()`/`logEntry()` -> Creates `LogInfo` object -> Passes to `format` function -> `format` function applies its chain (`combine`) -> Result (string or object) passed to `transport.log()`.
|
|
148
165
|
|
|
149
166
|
### Available Formatters
|
|
150
167
|
|
|
@@ -152,60 +169,66 @@ Formatters transform the log `info` object before it reaches transports. Use `fo
|
|
|
152
169
|
* `alias?: string`: Key for the formatted timestamp (default: `'timestamp'`).
|
|
153
170
|
* `format?: string | ((date: Date) => string)`: `date-fns` format string or custom function (default: ISO 8601).
|
|
154
171
|
```ts
|
|
155
|
-
format.timestamp({ format: 'yyyy-MM-dd HH:mm:ss' }) // -> '2024-05-01 10:30:00'
|
|
156
|
-
format.timestamp({ alias: '@timestamp' })
|
|
172
|
+
format.timestamp({ format: 'yyyy-MM-dd HH:mm:ss' }) // -> Adds { timestamp: '2024-05-01 10:30:00' }
|
|
173
|
+
format.timestamp({ alias: '@timestamp' }) // -> Adds { '@timestamp': '...ISO...' }
|
|
157
174
|
```
|
|
158
175
|
* **`format.level(options?)`**: Adds the log level string.
|
|
159
176
|
* `alias?: string`: Key for the level (default: `'level'`).
|
|
160
177
|
* **`format.message(options?)`**: Adds the log message string.
|
|
161
178
|
* `alias?: string`: Key for the message (default: `'message'`).
|
|
162
|
-
* **`format.errors(options?)`**: Extracts info from an `Error` object (expected at `info.error`). Adds `errorName`, `stack?`, `originalReason?` and potentially other error properties. Sets `info.message` to `error.message` if `info.message` was empty. Removes the original `info.error
|
|
179
|
+
* **`format.errors(options?)`**: Extracts info from an `Error` object (expected at `info.error`). Adds `errorName`, `stack?`, `originalReason?` and potentially other error properties to the `info` object. Sets `info.message` to `error.message` if `info.message` was empty. Removes the original `info.error` field. **Place this early in your `combine` chain.**
|
|
163
180
|
* `stack?: boolean`: Include stack trace (default: `true`).
|
|
164
|
-
* **`format.metadata(options?)`**: Gathers all remaining properties into the main object or under an alias. Excludes standard fields (`level`, `message`, `timestamp`,
|
|
165
|
-
* `alias?: string`: If provided, nest metadata under this key.
|
|
166
|
-
* `exclude?: string[]`: Array of additional keys to exclude from metadata.
|
|
181
|
+
* **`format.metadata(options?)`**: Gathers all remaining properties into the main object or under an alias. Excludes standard fields added by other formatters (`level`, `message`, `timestamp`, `errorName`, `stack`, `exception`, `eventType` etc.).
|
|
182
|
+
* `alias?: string`: If provided, nest metadata under this key and remove original keys.
|
|
183
|
+
* `exclude?: string[]`: Array of additional keys to exclude from metadata collection.
|
|
167
184
|
* **`format.json(options?)`**: **Terminal Formatter.** Serializes the final `info` object to a JSON string.
|
|
168
185
|
* `space?: string | number`: Pretty-printing spaces for `JSON.stringify`.
|
|
169
|
-
* **`format.simple(options?)`**: **Terminal Formatter.** Creates a human-readable, colored (if TTY) string. Includes `timestamp`, `level`, `message`, `{ metadata }`, and `stack` (on a new line).
|
|
170
|
-
* `colors?: boolean`: Force colors on or off (default: auto-detect).
|
|
186
|
+
* **`format.simple(options?)`**: **Terminal Formatter.** Creates a human-readable, colored (if TTY) string. Includes `timestamp`, `level`, `message`, `{ metadata }`, and `stack` (on a new line if present).
|
|
187
|
+
* `colors?: boolean`: Force colors on or off (default: auto-detect based on TTY).
|
|
171
188
|
|
|
172
189
|
### Combining Formatters (`format.combine`)
|
|
173
190
|
|
|
174
|
-
The order matters! Formatters run sequentially
|
|
191
|
+
The order matters! Formatters run sequentially. Terminal formatters (`json`, `simple`) should be last.
|
|
175
192
|
|
|
176
193
|
```ts
|
|
177
194
|
import { createLogger, format } from 'scribelog';
|
|
178
195
|
|
|
179
|
-
// Example: Log only level, message, and custom timestamp
|
|
196
|
+
// Example: Log only level, message, and custom timestamp in simple format
|
|
180
197
|
const minimalFormat = format.combine(
|
|
198
|
+
// Note: errors() is not included here
|
|
181
199
|
format.timestamp({ format: 'HH:mm:ss.SSS' }),
|
|
182
200
|
format.level(),
|
|
183
201
|
format.message(),
|
|
184
|
-
// No metadata()
|
|
185
|
-
format.simple() //
|
|
202
|
+
// No metadata() - ignores other fields like { extra: '...' }
|
|
203
|
+
format.simple() // simple() will only use timestamp, level, message
|
|
186
204
|
);
|
|
187
205
|
const minimalLogger = createLogger({ format: minimalFormat });
|
|
188
|
-
minimalLogger.info('Minimal log', { extra: 'this
|
|
206
|
+
minimalLogger.info('Minimal log', { extra: 'this is ignored'});
|
|
189
207
|
// Output: 10:45:00.123 [INFO]: Minimal log
|
|
190
208
|
|
|
191
209
|
// Example: JSON output with specific fields and nested metadata
|
|
192
210
|
const customJsonFormat = format.combine(
|
|
193
|
-
format.errors({ stack: false }),
|
|
194
|
-
format.timestamp({ alias: '@ts' }),
|
|
195
|
-
format.level({ alias: 'severity' }),
|
|
196
|
-
format.message(),
|
|
197
|
-
format.metadata({ alias: 'data' }),
|
|
198
|
-
format.json()
|
|
211
|
+
format.errors({ stack: false }), // Include basic error info, no stack
|
|
212
|
+
format.timestamp({ alias: '@ts' }), // Rename timestamp field
|
|
213
|
+
format.level({ alias: 'severity' }), // Rename level field
|
|
214
|
+
format.message(), // Keep message field
|
|
215
|
+
format.metadata({ alias: 'data' }), // Nest other data under 'data'
|
|
216
|
+
format.json() // Output as JSON
|
|
199
217
|
);
|
|
200
218
|
const customJsonLogger = createLogger({ format: customJsonFormat });
|
|
201
219
|
customJsonLogger.warn('Warning with nested meta', { user: 'test', id: 1 });
|
|
202
220
|
// Output: {"@ts":"...","severity":"warn","message":"Warning with nested meta","data":{"user":"test","id":1}}
|
|
221
|
+
|
|
222
|
+
const errorExample = new Error("Failed task");
|
|
223
|
+
(errorExample as any).details = { code: 500 };
|
|
224
|
+
customJsonLogger.error("Task failed", { error: errorExample });
|
|
225
|
+
// Output: {"@ts":"...", "severity":"error", "message":"Failed task", "errorName":"Error", "originalReason":undefined, "data":{"details":{"code":500}}}
|
|
203
226
|
```
|
|
204
227
|
|
|
205
228
|
### Predefined Formats
|
|
206
229
|
|
|
207
|
-
* `format.defaultSimpleFormat`: Equivalent to `combine(errors(), timestamp(), level(), message(), metadata(), simple())`. **This is the default format for `createLogger`.**
|
|
208
|
-
* `format.defaultJsonFormat`: Equivalent to `combine(errors(), timestamp(), level(), message(), metadata(), json())`.
|
|
230
|
+
* `format.defaultSimpleFormat`: Equivalent to `combine(errors({ stack: true }), timestamp(), level(), message(), metadata(), simple())`. **This is the default format for `createLogger`.**
|
|
231
|
+
* `format.defaultJsonFormat`: Equivalent to `combine(errors({ stack: true }), timestamp(), level(), message(), metadata(), json())`.
|
|
209
232
|
|
|
210
233
|
---
|
|
211
234
|
|
|
@@ -217,7 +240,7 @@ Define log destinations. You can use multiple transports.
|
|
|
217
240
|
|
|
218
241
|
Logs to `process.stdout` or `process.stderr`.
|
|
219
242
|
|
|
220
|
-
* `level?: string`: Minimum level for this specific transport.
|
|
243
|
+
* `level?: string`: Minimum level for this specific transport. Filters logs *after* the main logger level filter.
|
|
221
244
|
* `format?: LogFormat`: Specific format for this transport. Overrides the logger's format.
|
|
222
245
|
* `useStdErrLevels?: string[]`: Array of levels to direct to `stderr` (default: `['error']`).
|
|
223
246
|
|
|
@@ -227,27 +250,27 @@ Logs to `process.stdout` or `process.stderr`.
|
|
|
227
250
|
import { createLogger, format, transports } from 'scribelog';
|
|
228
251
|
|
|
229
252
|
const logger = createLogger({
|
|
230
|
-
level: 'info', // Logger
|
|
253
|
+
level: 'info', // Logger allows info, warn, error
|
|
231
254
|
transports: [
|
|
232
255
|
// Log INFO and WARN to stdout using simple format
|
|
233
256
|
new transports.Console({
|
|
234
|
-
level: 'warn', //
|
|
257
|
+
level: 'warn', // Only logs warn and error passed from logger
|
|
235
258
|
format: format.simple({ colors: true }),
|
|
236
|
-
useStdErrLevels: [], //
|
|
259
|
+
useStdErrLevels: [], // Nothing from here goes to stderr
|
|
237
260
|
}),
|
|
238
261
|
// Log only ERRORs to stderr using JSON format
|
|
239
262
|
new transports.Console({
|
|
240
|
-
level: 'error', //
|
|
241
|
-
format: format.json(),
|
|
242
|
-
useStdErrLevels: ['error'] //
|
|
263
|
+
level: 'error', // Only logs error passed from logger
|
|
264
|
+
format: format.json(), // Use JSON for errors
|
|
265
|
+
useStdErrLevels: ['error'] // Ensure errors go to stderr
|
|
243
266
|
})
|
|
244
267
|
]
|
|
245
268
|
});
|
|
246
269
|
|
|
247
|
-
logger.info('User logged in'); //
|
|
270
|
+
logger.info('User logged in'); // Filtered out by the first transport's level ('warn')
|
|
248
271
|
logger.warn('Disk space low'); // Goes to first console (stdout, simple)
|
|
249
|
-
logger.error(new Error('
|
|
250
|
-
logger.debug('Should not appear'); //
|
|
272
|
+
logger.error('DB Error', { error: new Error('Connection failed')}); // Goes to BOTH (stdout simple, stderr JSON)
|
|
273
|
+
logger.debug('Should not appear'); // Filtered out by logger's level ('info')
|
|
251
274
|
```
|
|
252
275
|
|
|
253
276
|
---
|
|
@@ -262,19 +285,18 @@ import { createLogger } from 'scribelog';
|
|
|
262
285
|
const baseLogger = createLogger({ level: 'debug', defaultMeta: { app: 'my-api' } });
|
|
263
286
|
|
|
264
287
|
function processUserData(userId: string) {
|
|
265
|
-
|
|
266
|
-
|
|
288
|
+
// Create a logger specific to this user's context
|
|
289
|
+
const userLogger = baseLogger.child({ userId, module: 'userProcessing' });
|
|
267
290
|
|
|
268
|
-
userLogger.debug('Starting data processing');
|
|
291
|
+
userLogger.debug('Starting data processing'); // Includes { app: 'my-api', userId: '...', module: '...' }
|
|
269
292
|
// ...
|
|
270
293
|
userLogger.info('Data processed');
|
|
271
|
-
// Logs will include { app: 'my-api', userId: '...' }
|
|
272
294
|
}
|
|
273
295
|
|
|
274
296
|
function processAdminTask(adminId: string) {
|
|
297
|
+
// Create a logger for admin tasks
|
|
275
298
|
const adminLogger = baseLogger.child({ adminId, scope: 'admin' });
|
|
276
299
|
adminLogger.info('Performing admin task');
|
|
277
|
-
// Logs will include { app: 'my-api', adminId: '...', scope: 'admin' }
|
|
278
300
|
}
|
|
279
301
|
|
|
280
302
|
processUserData('user-77');
|
|
@@ -288,26 +310,30 @@ processAdminTask('admin-01');
|
|
|
288
310
|
Set `handleExceptions: true` and/or `handleRejections: true` in `createLogger` options to automatically log fatal errors.
|
|
289
311
|
|
|
290
312
|
```ts
|
|
291
|
-
import { createLogger } from 'scribelog';
|
|
313
|
+
import { createLogger, format } from 'scribelog';
|
|
292
314
|
|
|
293
315
|
const logger = createLogger({
|
|
294
316
|
level: 'info',
|
|
295
|
-
format: format.defaultJsonFormat, // Log errors as JSON
|
|
317
|
+
format: format.defaultJsonFormat, // Log errors as JSON for easier parsing
|
|
296
318
|
handleExceptions: true,
|
|
297
319
|
handleRejections: true,
|
|
298
|
-
exitOnError: true // Default: Exit after logging fatal error
|
|
320
|
+
exitOnError: true // Default behavior: Exit after logging fatal error
|
|
299
321
|
});
|
|
300
322
|
|
|
301
323
|
logger.info('Application running with error handlers.');
|
|
302
324
|
|
|
303
|
-
//
|
|
304
|
-
// throw new Error('Something broke badly!');
|
|
325
|
+
// Example of what would be caught:
|
|
326
|
+
// setTimeout(() => { throw new Error('Something broke badly!'); }, 50);
|
|
327
|
+
// Output (JSON): {"level":"error","message":"Something broke badly!","timestamp":"...","exception":true,"eventType":"uncaughtException","errorName":"Error","stack":"..."}
|
|
328
|
+
// ... and process exits
|
|
305
329
|
|
|
306
|
-
//
|
|
330
|
+
// Example of what would be caught:
|
|
307
331
|
// Promise.reject('Unhandled promise rejection reason');
|
|
332
|
+
// Output (JSON): {"level":"error","message":"Unhandled promise rejection reason","timestamp":"...","exception":true,"eventType":"unhandledRejection","errorName":"Error","stack":"...","originalReason":"..."}
|
|
333
|
+
// ... and process exits
|
|
308
334
|
```
|
|
309
335
|
|
|
310
|
-
The logger adds `{ exception: true, eventType: '...',
|
|
336
|
+
The logger adds `{ exception: true, eventType: '...', ...errorDetails }` to the log metadata for these events, processed by the `format.errors()` formatter. Remember to have `format.errors()` in your format chain to see detailed error info.
|
|
311
337
|
|
|
312
338
|
---
|
|
313
339
|
|
|
@@ -322,14 +348,12 @@ The logger adds `{ exception: true, eventType: '...', errorName: '...', stack: '
|
|
|
322
348
|
|
|
323
349
|
## 🤝 Contributing
|
|
324
350
|
|
|
325
|
-
Contributions are welcome! Please feel free to submit issues and pull requests.
|
|
326
|
-
|
|
327
|
-
*(Consider adding contribution guidelines)*
|
|
351
|
+
Contributions are welcome! Please feel free to submit issues and pull requests. Check for any existing guidelines or open an issue to discuss larger changes.
|
|
328
352
|
|
|
329
353
|
---
|
|
330
354
|
|
|
331
355
|
## 📄 License
|
|
332
356
|
|
|
333
357
|
MIT License
|
|
334
|
-
Copyright (c)
|
|
358
|
+
Copyright (c) 2025 tolongames
|
|
335
359
|
See [LICENSE](./LICENSE) for details.
|