@vimcord/internal 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,417 @@
1
+ import ansis from "ansis";
2
+ import { spinners } from "unicode-animations";
3
+
4
+ // --- Types ---
5
+
6
+ export type LogLevel = "debug" | "info" | "success" | "warn" | "error";
7
+
8
+ export type ColorScheme = {
9
+ primary: string;
10
+ success: string;
11
+ warn: string;
12
+ caution: string;
13
+ error: string;
14
+ muted: string;
15
+ info: string;
16
+ text: string;
17
+ };
18
+
19
+ export type LoggerOptions = {
20
+ /** Emoji shown before the prefix. */
21
+ prefixEmoji?: string | null;
22
+ /** Prefix label shown before each message. */
23
+ prefix?: string | null;
24
+ /**
25
+ * Minimum log level to output.
26
+ * @default "debug"
27
+ */
28
+ minLevel?: LogLevel;
29
+ /**
30
+ * Log verbose messages.
31
+ * @default false
32
+ */
33
+ verbose?: boolean;
34
+ /** Color scheme to use. */
35
+ colors?: Partial<ColorScheme>;
36
+ };
37
+
38
+ export type LoaderOptions = {
39
+ /** Returns the next loader message while the spinner is active. */
40
+ cycle?: () => string;
41
+ /** How often `cycle` should be called in milliseconds. */
42
+ interval?: number;
43
+ };
44
+
45
+ type LoaderEntry = {
46
+ cycle?: () => string;
47
+ cycleInterval: number;
48
+ lastCycleAt: number;
49
+ message: string;
50
+ };
51
+
52
+ // --- Constants ---
53
+
54
+ const LEVEL_PRIORITY: Record<LogLevel, number> = {
55
+ debug: 0,
56
+ info: 1,
57
+ success: 2,
58
+ warn: 3,
59
+ error: 4
60
+ };
61
+
62
+ export const DEFAULT_COLORS: ColorScheme = {
63
+ primary: "#5865F2",
64
+ success: "#57F287",
65
+ warn: "#FEE75C",
66
+ caution: "#FF9D00",
67
+ error: "#ED4245",
68
+ muted: "#747F8D",
69
+ info: "#87CEEB",
70
+ text: "#FFFFFF"
71
+ };
72
+
73
+ const SPINNER_FRAMES = spinners.breathe.frames.map(f => ansis.hex(DEFAULT_COLORS.muted)(f));
74
+ const SPINNER_INTERVAL = spinners.breathe.interval;
75
+ const DEFAULT_LOADER_CYCLE_INTERVAL_MS = 3_000;
76
+
77
+ // --- Helpers ---
78
+
79
+ /** Strips ANSI escape codes from a string for accurate length measurement */
80
+ export function stripAnsi(str: string): string {
81
+ return str.replace(/\x1b\[[0-9;]*m/g, "");
82
+ }
83
+
84
+ /** Pads a string by its visible length, ignoring ANSI codes */
85
+ function padVisible(str: string, length: number): string {
86
+ const visible = stripAnsi(str).length;
87
+ return str + " ".repeat(Math.max(0, length - visible));
88
+ }
89
+
90
+ // --- Logger ---
91
+
92
+ /** Timestamped console logger with level filtering, colors, and optional live terminal loaders. */
93
+ export class Logger {
94
+ readonly options: Required<LoggerOptions> & { colors: ColorScheme };
95
+
96
+ private activeLoaders: Map<number, LoaderEntry> = new Map();
97
+ private nextLoaderId = 0;
98
+ private renderInterval: ReturnType<typeof setInterval> | null = null;
99
+ private frameIndex = 0;
100
+
101
+ constructor(options: LoggerOptions = {}) {
102
+ this.options = {
103
+ prefix: null,
104
+ prefixEmoji: null,
105
+ minLevel: "debug",
106
+ verbose: false,
107
+ ...options,
108
+ colors: { ...DEFAULT_COLORS, ...options.colors }
109
+ };
110
+ }
111
+
112
+ private formatPrefix(): string {
113
+ const { prefixEmoji, prefix, colors } = this.options;
114
+ if (!prefix) return "";
115
+ const emoji = prefixEmoji ? `${prefixEmoji} ` : "";
116
+ return ansis.bold.hex(colors.primary)(`${emoji}${prefix}`);
117
+ }
118
+
119
+ private formatLevel(level: LogLevel): string {
120
+ const { colors } = this.options;
121
+ switch (level) {
122
+ case "debug":
123
+ return ansis.hex(colors.muted)("DEBUG");
124
+ case "info":
125
+ return ansis.hex(colors.info)("INFO");
126
+ case "success":
127
+ return ansis.bold.hex(colors.success)("✓ SUCCESS");
128
+ case "warn":
129
+ return ansis.bold.hex(colors.warn)("⚠ WARN");
130
+ case "error":
131
+ return ansis.bold.hex(colors.error)("✕ ERROR");
132
+ }
133
+ }
134
+
135
+ /** Checks if the given log level should be logged. */
136
+ private shouldLog(level: LogLevel): boolean {
137
+ return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[this.options.minLevel];
138
+ }
139
+
140
+ /** Returns a colored `[HH:mm:ss]` timestamp for log prefixes. */
141
+ timestamp(): string {
142
+ const { colors } = this.options;
143
+ const now = new Date();
144
+ const time = now.toLocaleTimeString("en-US", {
145
+ hour12: false,
146
+ hour: "2-digit",
147
+ minute: "2-digit",
148
+ second: "2-digit"
149
+ });
150
+
151
+ return ansis.hex(colors.muted)(`[${time}]`);
152
+ }
153
+
154
+ /** Writes a line using the builtin `console`, keeping active TTY loaders pinned below the new output. */
155
+ write(stream: "log" | "warn" | "error", ...data: unknown[]): void {
156
+ const loaderCount = this.activeLoaders.size;
157
+ const shouldRedrawLoaders = process.stdout.isTTY === true && loaderCount > 0;
158
+
159
+ // TTY loaders are already printed at the bottom. Move up and clear them before writing the real log line.
160
+ if (shouldRedrawLoaders) process.stdout.write(`\x1b[${loaderCount}A\x1b[J`);
161
+
162
+ console[stream](...data);
163
+
164
+ if (!shouldRedrawLoaders) return;
165
+
166
+ const now = Date.now();
167
+ const frame = SPINNER_FRAMES[this.frameIndex]!;
168
+
169
+ // Redraw loaders after normal logs so they remain the last visible lines in the terminal.
170
+ for (const [, loader] of this.activeLoaders) {
171
+ if (loader.cycle && now - loader.lastCycleAt >= loader.cycleInterval) {
172
+ loader.message = loader.cycle();
173
+ loader.lastCycleAt = now;
174
+ }
175
+
176
+ process.stdout.write(`${this.timestamp()} ${this.formatPrefix()} ${frame} ${loader.message}\n`);
177
+ }
178
+ }
179
+
180
+ /** Logs a message without level filtering. */
181
+ log(...data: unknown[]): void {
182
+ this.write("log", this.timestamp(), this.formatPrefix(), ...data);
183
+ }
184
+
185
+ logVerbose(...data: unknown[]): void {
186
+ if (!this.options.verbose) return;
187
+ this.log(...data);
188
+ }
189
+
190
+ debug(message: string, ...data: unknown[]): void {
191
+ if (!this.shouldLog("debug")) return;
192
+ this.write("log", this.timestamp(), this.formatPrefix(), this.formatLevel("debug"), ansis.dim(message), ...data);
193
+ }
194
+
195
+ debugVerbose(message: string, ...data: unknown[]): void {
196
+ if (!this.options.verbose) return;
197
+ this.debug(message, ...data);
198
+ }
199
+
200
+ info(...data: unknown[]): void {
201
+ if (!this.shouldLog("info")) return;
202
+ this.write("log", this.timestamp(), this.formatPrefix(), this.formatLevel("info"), ...data);
203
+ }
204
+
205
+ infoVerbose(...data: unknown[]): void {
206
+ if (!this.options.verbose) return;
207
+ this.info(...data);
208
+ }
209
+
210
+ success(message: string, ...data: unknown[]): void {
211
+ if (!this.shouldLog("success")) return;
212
+ this.write(
213
+ "log",
214
+ this.timestamp(),
215
+ this.formatPrefix(),
216
+ this.formatLevel("success"),
217
+ ansis.hex(this.options.colors.success)(message),
218
+ ...data
219
+ );
220
+ }
221
+
222
+ successVerbose(message: string, ...data: unknown[]): void {
223
+ if (!this.options.verbose) return;
224
+ this.success(message, ...data);
225
+ }
226
+
227
+ warn(message: string, ...data: unknown[]): void {
228
+ if (!this.shouldLog("warn")) return;
229
+ this.write(
230
+ "warn",
231
+ this.timestamp(),
232
+ this.formatPrefix(),
233
+ this.formatLevel("warn"),
234
+ ansis.hex(this.options.colors.warn)(message),
235
+ ...data
236
+ );
237
+ }
238
+
239
+ warnVerbose(message: string, ...data: unknown[]): void {
240
+ if (!this.options.verbose) return;
241
+ this.warn(message, ...data);
242
+ }
243
+
244
+ error(message: string, error?: Error, ...data: unknown[]): void {
245
+ if (!this.shouldLog("error")) return;
246
+ this.write(
247
+ "error",
248
+ this.timestamp(),
249
+ this.formatPrefix(),
250
+ this.formatLevel("error"),
251
+ ansis.hex(this.options.colors.error)(message),
252
+ ...data
253
+ );
254
+ if (error?.stack) {
255
+ this.write("error", ansis.dim(error.stack));
256
+ }
257
+ }
258
+
259
+ errorVerbose(message: string, error?: Error, ...data: unknown[]): void {
260
+ if (!this.options.verbose) return;
261
+ this.error(message, error, ...data);
262
+ }
263
+
264
+ // --- Structured Output ---
265
+ /** @deprecated */
266
+ table(title: string, data: Record<string, unknown>): void {
267
+ const { colors } = this.options;
268
+ this.write("log", this.timestamp(), this.formatPrefix(), ansis.bold(title));
269
+ for (const [key, value] of Object.entries(data)) {
270
+ const formattedKey = padVisible(ansis.hex(colors.warn)(` ${key}`), 25);
271
+ const formattedValue = ansis.hex(colors.muted)(String(value));
272
+ this.write("log", `${formattedKey} ${formattedValue}`);
273
+ }
274
+ }
275
+
276
+ // TODO: Make this prettier, replace it with `header` using the textThroughLine helper
277
+ /** @deprecated */
278
+ section(title: string): void {
279
+ const { colors } = this.options;
280
+ const titleVisible = stripAnsi(title);
281
+ const width = Math.max(30, titleVisible.length + 4);
282
+ const line = "─".repeat(width);
283
+ const paddedTitle = padVisible(ansis.bold.hex(colors.text)(title), width);
284
+
285
+ this.write("log", ansis.hex(colors.muted)(`\n┌─${line}─┐`));
286
+ this.write("log", ansis.hex(colors.muted)("│ ") + paddedTitle + ansis.hex(colors.muted)(" │"));
287
+ this.write("log", ansis.hex(colors.muted)(`└─${line}─┘`));
288
+ }
289
+
290
+ /**
291
+ * Starts a loader and returns a `stop` function.
292
+ *
293
+ * In an interactive TTY, loaders render as live spinners pinned below regular logs. In hosted logs, CI,
294
+ * Docker logs, and other non-TTY streams, loaders degrade to normal start/final lines without ANSI cursor codes.
295
+ * Always call `stop()` with `try/finally` so the render loop can be cleaned up.
296
+ *
297
+ * @param message Initial loader message.
298
+ * @param cycleOrOptions Optional message cycle function or options object.
299
+ * @param interval How often to call the cycle function in milliseconds. Defaults to `3000`.
300
+ * @returns A function that stops the loader. Pass `clear: true` to suppress the success line.
301
+ *
302
+ * @example
303
+ * ```ts
304
+ * const stop = logger.loader("Loading...")
305
+ * try {
306
+ * await doWork()
307
+ * stop("Done!")
308
+ * } catch (e) {
309
+ * stop("Failed")
310
+ * }
311
+ * ```
312
+ *
313
+ * @example
314
+ * ```ts
315
+ * const stop = logger.loader("Starting...", () => "Still starting...", 1000)
316
+ * ```
317
+ */
318
+ loader(message: string, cycle?: () => string, interval?: number): (finalMessage?: string, clear?: boolean) => void;
319
+ loader(message: string, options?: LoaderOptions): (finalMessage?: string, clear?: boolean) => void;
320
+ loader(
321
+ message: string,
322
+ cycleOrOptions?: LoaderOptions | (() => string),
323
+ interval: number = DEFAULT_LOADER_CYCLE_INTERVAL_MS
324
+ ): (finalMessage?: string, clear?: boolean) => void {
325
+ const { colors } = this.options;
326
+ const cycle = typeof cycleOrOptions === "function" ? cycleOrOptions : cycleOrOptions?.cycle;
327
+ const cycleInterval = typeof cycleOrOptions === "function" ? interval : (cycleOrOptions?.interval ?? interval);
328
+ let stopped = false;
329
+ const id = this.nextLoaderId++;
330
+ const prefix = `${this.timestamp()} ${this.formatPrefix()}`;
331
+
332
+ this.activeLoaders.set(id, { cycle, cycleInterval, lastCycleAt: Date.now(), message });
333
+
334
+ if (process.stdout.isTTY !== true) {
335
+ this.write("log", prefix, message);
336
+ } else {
337
+ process.stdout.write(`${prefix} ${SPINNER_FRAMES[0]!} ${message}\n`);
338
+
339
+ if (!this.renderInterval) {
340
+ this.renderInterval = setInterval(() => {
341
+ const loaderCount = this.activeLoaders.size;
342
+ if (!loaderCount) return;
343
+
344
+ this.frameIndex = (this.frameIndex + 1) % SPINNER_FRAMES.length;
345
+
346
+ // Replace the existing loader block in-place. This only runs for real TTY streams.
347
+ process.stdout.write(`\x1b[${loaderCount}A\x1b[J`);
348
+
349
+ const now = Date.now();
350
+ const frame = SPINNER_FRAMES[this.frameIndex]!;
351
+
352
+ for (const [, loader] of this.activeLoaders) {
353
+ if (loader.cycle && now - loader.lastCycleAt >= loader.cycleInterval) {
354
+ loader.message = loader.cycle();
355
+ loader.lastCycleAt = now;
356
+ }
357
+
358
+ process.stdout.write(`${this.timestamp()} ${this.formatPrefix()} ${frame} ${loader.message}\n`);
359
+ }
360
+ }, SPINNER_INTERVAL);
361
+ }
362
+ }
363
+
364
+ return (finalMessage?: string, clear = false) => {
365
+ if (stopped) return;
366
+ stopped = true;
367
+
368
+ this.activeLoaders.delete(id);
369
+
370
+ if (this.activeLoaders.size === 0 && this.renderInterval) {
371
+ clearInterval(this.renderInterval);
372
+ this.renderInterval = null;
373
+ this.frameIndex = 0;
374
+ }
375
+
376
+ if (process.stdout.isTTY !== true) {
377
+ if (clear) {
378
+ if (finalMessage) this.write("log", finalMessage);
379
+ return;
380
+ }
381
+
382
+ this.write(
383
+ "log",
384
+ `${this.timestamp()} ${this.formatPrefix()}`,
385
+ ansis.hex(colors.success)("✓"),
386
+ finalMessage ?? message
387
+ );
388
+ return;
389
+ }
390
+
391
+ // Clear the stopped loader plus any remaining loaders that were printed beneath it.
392
+ process.stdout.write(`\x1b[${this.activeLoaders.size + 1}A\x1b[J`);
393
+
394
+ if (clear) {
395
+ if (finalMessage) process.stdout.write(`${finalMessage}\n`);
396
+ } else {
397
+ const check = ansis.hex(colors.success)("✓");
398
+ process.stdout.write(`${this.timestamp()} ${this.formatPrefix()} ${check} ${finalMessage ?? message}\n`);
399
+ }
400
+
401
+ const frame = SPINNER_FRAMES[this.frameIndex]!;
402
+ for (const [, loader] of this.activeLoaders) {
403
+ process.stdout.write(`${this.timestamp()} ${this.formatPrefix()} ${frame} ${loader.message}\n`);
404
+ }
405
+ };
406
+ }
407
+
408
+ /** Sets the minimum log level required for level-filtered messages. */
409
+ setLevel(level: LogLevel): void {
410
+ this.options.minLevel = level;
411
+ }
412
+
413
+ /** Enables or disables verbose-only log methods. */
414
+ setVerbose(verbose: boolean): void {
415
+ this.options.verbose = verbose;
416
+ }
417
+ }
@@ -0,0 +1,9 @@
1
+ export class VimcordError extends Error {
2
+ constructor(
3
+ message: string,
4
+ public readonly code: string
5
+ ) {
6
+ super(message);
7
+ this.name = "VimcordError";
8
+ }
9
+ }
@@ -0,0 +1,4 @@
1
+ /** Ensures a value is always returned as an array */
2
+ export function forceArray<T>(value: T | T[]): T[] {
3
+ return Array.isArray(value) ? value : [value];
4
+ }
@@ -0,0 +1,78 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ interface ImportedModule<T> {
6
+ module: T;
7
+ path: string;
8
+ }
9
+
10
+ export function getProcessDir(): string {
11
+ const mainPath = process.argv[1];
12
+ if (!mainPath) return "";
13
+ return path.dirname(mainPath);
14
+ }
15
+
16
+ export function isTSOrJS(filename: string, suffix?: string | string[]): boolean {
17
+ if (!suffix) return filename.endsWith(".ts") || filename.endsWith(".js");
18
+ if (Array.isArray(suffix)) {
19
+ return suffix.some(s => filename.endsWith(`${s}.ts`) || filename.endsWith(`${s}.js`));
20
+ } else {
21
+ return filename.endsWith(`${suffix}.ts`) || filename.endsWith(`${suffix}.js`);
22
+ }
23
+ }
24
+
25
+ export async function importModulesFromDir<T>(dir: string, suffix?: string | string[]): Promise<ImportedModule<T>[]> {
26
+ const cwd = getProcessDir();
27
+ const MODULE_RELATIVE_PATH = path.join(cwd, dir);
28
+ const MODULE_LOG_PATH = dir;
29
+
30
+ // Search the directory for event modules
31
+ const files = (() => {
32
+ if (!existsSync(MODULE_RELATIVE_PATH)) return [];
33
+
34
+ const walk = (targetDir: string, base = ""): string[] => {
35
+ return readdirSync(targetDir, { withFileTypes: true }).flatMap(entry => {
36
+ const relativePath = base ? path.join(base, entry.name) : entry.name;
37
+ const fullPath = path.join(targetDir, entry.name);
38
+
39
+ if (entry.isDirectory()) return walk(fullPath, relativePath);
40
+ if (entry.isFile()) return [relativePath];
41
+
42
+ return [];
43
+ });
44
+ };
45
+
46
+ return walk(MODULE_RELATIVE_PATH);
47
+ })().filter(filename => isTSOrJS(filename, suffix));
48
+ if (!files.length) return [];
49
+
50
+ // Import the modules found in the given directory
51
+ const modules = await Promise.all(
52
+ files.map(async fn => {
53
+ const modulePath = path.join(MODULE_RELATIVE_PATH, fn);
54
+ const logPath = `./${path.join(MODULE_LOG_PATH, fn)}`;
55
+
56
+ try {
57
+ const moduleUrl = pathToFileURL(modulePath);
58
+ moduleUrl.searchParams.set("updated", Date.now().toString());
59
+ const importedModule = (await import(moduleUrl.href)) as T;
60
+
61
+ return { module: importedModule, path: logPath };
62
+ } catch (err) {
63
+ // Log the warning to the console
64
+ console.warn(`Failed to import module at '${logPath}'`, err);
65
+ return null;
66
+ }
67
+ })
68
+ );
69
+
70
+ // Filter out modules that failed to import and return
71
+ const filteredModules = modules.filter((m): m is ImportedModule<T> => Boolean(m));
72
+ if (!filteredModules.length) {
73
+ console.warn(`No valid modules were found in directory '${dir}'`);
74
+ }
75
+
76
+ // Return the filtered modules
77
+ return filteredModules;
78
+ }
@@ -0,0 +1,30 @@
1
+ import type { PartialDeep } from "@/types/helpers.js";
2
+
3
+ /** Check if value is a plain object */
4
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
5
+ if (typeof value !== "object" || value === null) return false;
6
+ if (Array.isArray(value)) return false;
7
+ return Object.prototype.toString.call(value) === "[object Object]";
8
+ }
9
+
10
+ /** Deep merge objects - mutates target and returns it */
11
+ export function mergeDeep<T extends object>(target: PartialDeep<T>, ...sources: Array<object | undefined>): T {
12
+ for (const source of sources) {
13
+ if (!source) continue;
14
+
15
+ for (const key in source) {
16
+ if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
17
+
18
+ const sourceValue = (source as Record<string, unknown>)[key];
19
+ const targetValue = (target as Record<string, unknown>)[key];
20
+
21
+ if (isPlainObject(sourceValue) && isPlainObject(targetValue)) {
22
+ mergeDeep(targetValue, sourceValue);
23
+ } else if (sourceValue !== undefined) {
24
+ (target as Record<string, unknown>)[key] = sourceValue;
25
+ }
26
+ }
27
+ }
28
+
29
+ return target as T;
30
+ }
@@ -0,0 +1,12 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /** Reads the `package.json` file from the current working directory. */
5
+ export function getPackageJson(): Record<string, unknown> {
6
+ return JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf-8")) as Record<string, unknown>;
7
+ }
8
+
9
+ /** Checks if the process was ran using the `--dev` flag. */
10
+ export function getDevMode(): boolean {
11
+ return process.argv.includes("--dev");
12
+ }
@@ -0,0 +1,9 @@
1
+ import { humanId } from "human-id";
2
+
3
+ export function createRandomId(): string {
4
+ return `v-${Math.random().toString(36).split(".")[1]!}`;
5
+ }
6
+
7
+ export function createHumanId(): string {
8
+ return humanId({ separator: "-", capitalize: false });
9
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "./src",
5
+ "outDir": "./dist",
6
+ "paths": { "@/*": ["./src/*"] }
7
+ },
8
+ "include": ["src"],
9
+ "exclude": ["tsup.config.ts", "dist", "node_modules"]
10
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from "tsup";
2
+
3
+ export default defineConfig({
4
+ entry: ["src/index.ts"],
5
+ format: ["esm", "cjs"],
6
+ target: "node20",
7
+ outDir: "dist",
8
+ dts: true,
9
+ clean: true,
10
+ splitting: false
11
+ });