@kyvrixon/utils 0.0.5 → 1.0.10

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 CHANGED
@@ -1,7 +1,9 @@
1
- # Utils
2
-
3
- General utility files I use for alot of projects! Does not include any larger scale items, just the little things
4
-
5
- ```bash
6
- bun install @kyvrixon/utils@latest
7
- ```
1
+ # @kyvrixon/utils
2
+
3
+ General utility files I use for alot of projects! Designed for use with the [bun runtime](https://bun.sh/)!
4
+
5
+ ```bash
6
+ bun install @kyvrixon/utils
7
+ # or
8
+ bun install github:kyvrixon/utils#1.0.10
9
+ ```
package/package.json CHANGED
@@ -1,31 +1,38 @@
1
1
  {
2
- "name": "@kyvrixon/utils",
3
- "main": "./src/index.ts",
4
- "version": "0.0.5",
5
- "type": "module",
6
- "private": false,
7
- "license": "MIT",
8
- "files": [
9
- "src"
10
- ],
11
- "scripts": {
12
- "pub": "bun buildIndex.ts && bun publish --access public"
13
- },
14
- "packageManager": "bun@1.3.6",
15
- "types": "./src/index.ts",
16
- "devDependencies": {
17
- "@types/bun": "1.3.6"
18
- },
19
- "peerDependencies": {
20
- "typescript": "^5.9.3"
21
- },
22
- "dependencies": {
23
- "chalk": "^5.6.2"
24
- },
25
- "exports": {
26
- ".": {
27
- "types": "./src/index.ts",
28
- "import": "./src/index.ts"
29
- }
30
- }
2
+ "name": "@kyvrixon/utils",
3
+ "main": "./src/index.ts",
4
+ "version": "1.0.10",
5
+ "type": "module",
6
+ "private": false,
7
+ "license": "MIT",
8
+ "files": [
9
+ "src"
10
+ ],
11
+ "engines": {
12
+ "bun": "1.3.10"
13
+ },
14
+ "repository": {
15
+ "url": "https://github.com/kyvrixon/utils"
16
+ },
17
+ "scripts": {
18
+ "pub": "bun prebuild.ts && bun publish --access public",
19
+ "pretty": "bun biome format --write ."
20
+ },
21
+ "packageManager": "bun@1.3.10",
22
+ "types": "./src/index.ts",
23
+ "devDependencies": {
24
+ "@biomejs/biome": "2.4.6",
25
+ "@types/bun": "1.3.10",
26
+ "typescript": "5.9.3"
27
+ },
28
+ "dependencies": {
29
+ "chalk": "5.6.2",
30
+ "discord.js": "14.25.1"
31
+ },
32
+ "exports": {
33
+ ".": {
34
+ "types": "./src/index.ts",
35
+ "import": "./src/index.ts"
36
+ }
37
+ }
31
38
  }
package/src/index.ts CHANGED
@@ -1,3 +1,6 @@
1
1
  export * from "./lib/formatSeconds";
2
- export * from "./lib/logger";
3
- export * from "./lib/toOrdinal";
2
+ export * from "./lib/toOrdinal";
3
+ export * from "./lib/discord-utils/Command";
4
+ export * from "./lib/discord-utils/createPagination";
5
+ export * from "./lib/discord-utils/Event";
6
+ export * from "./lib/modules/LoggerModule";
@@ -0,0 +1,33 @@
1
+ import type {
2
+ AutocompleteInteraction,
3
+ ChatInputCommandInteraction,
4
+ Client,
5
+ SlashCommandBuilder,
6
+ SlashCommandOptionsOnlyBuilder,
7
+ SlashCommandSubcommandsOnlyBuilder,
8
+ } from "discord.js";
9
+
10
+ export class DiscordCommand<C extends Client<boolean>> {
11
+ public readonly data:
12
+ | SlashCommandBuilder
13
+ | SlashCommandOptionsOnlyBuilder
14
+ | SlashCommandSubcommandsOnlyBuilder;
15
+ public readonly execute: (
16
+ client: C,
17
+ interaction: ChatInputCommandInteraction,
18
+ ) => Promise<void>;
19
+ public readonly autocomplete?: (
20
+ client: C,
21
+ interaction: AutocompleteInteraction,
22
+ ) => Promise<void>;
23
+
24
+ constructor(ops: {
25
+ data: DiscordCommand<C>["data"];
26
+ execute: DiscordCommand<C>["execute"];
27
+ autocomplete?: DiscordCommand<C>["autocomplete"];
28
+ }) {
29
+ this.data = ops.data;
30
+ this.execute = ops.execute;
31
+ this.autocomplete = ops.autocomplete;
32
+ }
33
+ }
@@ -0,0 +1,61 @@
1
+ /** biome-ignore-all lint/suspicious/noExplicitAny: Its fine */
2
+ import type { Client, ClientEvents, RestEvents } from "discord.js";
3
+
4
+ // biome-ignore lint/suspicious/noEmptyInterface: Its fine
5
+ export interface DiscordEventCustomType {}
6
+ /**
7
+ * To define custom types:
8
+ * @example
9
+ * declare module "@kyvrixon/utils" {
10
+ * interface DiscordEventCustomType {
11
+ * myEventName: [data: string];
12
+ * }
13
+ * }
14
+ * // You can also add these to `ClientEvents` if you wish for better typing like so:
15
+ * declare module "discord.js" {
16
+ * interface ClientEvents extends DiscordEventCustomType {}
17
+ * }
18
+ */
19
+ export class DiscordEvent<
20
+ V extends Client,
21
+ T extends "client" | "custom" | "rest" = "client",
22
+ K extends T extends "custom"
23
+ ? keyof DiscordEventCustomType
24
+ : T extends "client"
25
+ ? keyof ClientEvents
26
+ : keyof RestEvents = T extends "custom"
27
+ ? keyof DiscordEventCustomType
28
+ : T extends "client"
29
+ ? keyof ClientEvents
30
+ : keyof RestEvents,
31
+ > {
32
+ public readonly type: T;
33
+ public readonly name: K;
34
+ public readonly once: boolean;
35
+ public readonly method: (
36
+ client: V,
37
+ ...args: T extends "client"
38
+ ? K extends keyof ClientEvents
39
+ ? ClientEvents[K]
40
+ : any[]
41
+ : T extends "rest"
42
+ ? K extends keyof RestEvents
43
+ ? RestEvents[K]
44
+ : any[]
45
+ : K extends keyof DiscordEventCustomType
46
+ ? DiscordEventCustomType[K]
47
+ : any[]
48
+ ) => Promise<void>;
49
+
50
+ constructor(opts: {
51
+ type: T;
52
+ name: K;
53
+ once?: boolean;
54
+ method: DiscordEvent<V, T, K>["method"];
55
+ }) {
56
+ this.type = opts.type;
57
+ this.name = opts.name;
58
+ this.once = opts.once ?? false;
59
+ this.method = opts.method;
60
+ }
61
+ }
@@ -0,0 +1,327 @@
1
+ import { randomUUIDv7 } from "bun";
2
+ import {
3
+ ActionRowBuilder,
4
+ ButtonBuilder,
5
+ type ButtonInteraction,
6
+ ButtonStyle,
7
+ type ChannelSelectMenuBuilder,
8
+ type ChatInputCommandInteraction,
9
+ ComponentType,
10
+ ContainerBuilder,
11
+ type FileBuilder,
12
+ LabelBuilder,
13
+ type MediaGalleryBuilder,
14
+ type MentionableSelectMenuBuilder,
15
+ ModalBuilder,
16
+ type RoleSelectMenuBuilder,
17
+ type SectionBuilder,
18
+ type SeparatorBuilder,
19
+ type StringSelectMenuBuilder,
20
+ TextDisplayBuilder,
21
+ TextInputBuilder,
22
+ TextInputStyle,
23
+ type UserSelectMenuBuilder,
24
+ } from "discord.js";
25
+
26
+ export type MessageActionRow = ActionRowBuilder<
27
+ | ButtonBuilder
28
+ | StringSelectMenuBuilder
29
+ | UserSelectMenuBuilder
30
+ | RoleSelectMenuBuilder
31
+ | ChannelSelectMenuBuilder
32
+ | MentionableSelectMenuBuilder
33
+ >;
34
+
35
+ export type LeaderboardComponentType =
36
+ | { type: "buttons" }
37
+ | { type: "display"; component: TextDisplayBuilder }
38
+ | { type: "section"; component: SectionBuilder }
39
+ | { type: "separator"; component: SeparatorBuilder }
40
+ | { type: "file"; component: FileBuilder }
41
+ | { type: "gallery"; component: MediaGalleryBuilder }
42
+ | { type: "actionrow"; component: MessageActionRow };
43
+
44
+ export interface LeaderboardOptions {
45
+ contentMarker?: string;
46
+ entriesPerPage?: number;
47
+ replacements?: Record<string, string>;
48
+ styling?: Array<{ accent_color?: number; spoiler?: boolean }>;
49
+ ephemeral?: boolean;
50
+ }
51
+
52
+ function applyReplacements(
53
+ content: string,
54
+ replacements?: Record<string, string>,
55
+ ): string {
56
+ if (!replacements) return content;
57
+ return Object.entries(replacements).reduce(
58
+ (acc, [key, value]) => acc.replaceAll(key, value),
59
+ content,
60
+ );
61
+ }
62
+
63
+ function getPaginationRow(
64
+ prefix: string,
65
+ currentIndex: number,
66
+ entriesPerPage: number,
67
+ totalPages: number,
68
+ listLength: number,
69
+ ended: boolean,
70
+ ): ActionRowBuilder<ButtonBuilder> {
71
+ return new ActionRowBuilder<ButtonBuilder>().addComponents(
72
+ new ButtonBuilder()
73
+ .setCustomId(`${prefix}back`)
74
+ .setLabel("Prev")
75
+ .setStyle(ButtonStyle.Secondary)
76
+ .setDisabled(ended || currentIndex === 0),
77
+ new ButtonBuilder()
78
+ .setCustomId(`${prefix}info`)
79
+ .setLabel(
80
+ `${Math.floor(currentIndex / entriesPerPage) + 1}/${totalPages}`,
81
+ )
82
+ .setStyle(ButtonStyle.Secondary)
83
+ .setDisabled(ended || totalPages === 1),
84
+ new ButtonBuilder()
85
+ .setCustomId(`${prefix}forward`)
86
+ .setLabel("Next")
87
+ .setStyle(ButtonStyle.Secondary)
88
+ .setDisabled(ended || currentIndex + entriesPerPage >= listLength),
89
+ );
90
+ }
91
+
92
+ export async function createPagination(
93
+ list: Array<string>,
94
+ structure: Array<Array<LeaderboardComponentType>>,
95
+ interaction: ButtonInteraction | ChatInputCommandInteraction,
96
+ options: LeaderboardOptions = {},
97
+ ) {
98
+ const {
99
+ entriesPerPage = 5,
100
+ replacements,
101
+ styling,
102
+ ephemeral = false,
103
+ contentMarker = "--DATA_INPUT--",
104
+ } = options;
105
+
106
+ if (entriesPerPage <= 0)
107
+ throw new Error("entriesPerPage must be greater than 0");
108
+
109
+ const uID = randomUUIDv7();
110
+ const prefix = `~PAGINATION_${uID}_`;
111
+ let currentIndex = 0;
112
+ let ended = false;
113
+
114
+ if (!list.length) {
115
+ await interaction
116
+ .reply({
117
+ allowedMentions: {
118
+ parse: [],
119
+ repliedUser: false,
120
+ },
121
+ components: [
122
+ new ContainerBuilder().addTextDisplayComponents(
123
+ new TextDisplayBuilder().setContent("No data to show"),
124
+ ),
125
+ ],
126
+ flags: ephemeral ? ["Ephemeral", "IsComponentsV2"] : ["IsComponentsV2"],
127
+ })
128
+ .catch(() => {});
129
+ return;
130
+ }
131
+
132
+ const totalPages = Math.ceil(list.length / entriesPerPage);
133
+ const lastPage = structure[structure.length - 1];
134
+
135
+ if (!lastPage) throw new Error("createLeaderboard is in a corrupted state");
136
+
137
+ while (structure.length < totalPages) {
138
+ structure.push(
139
+ lastPage.map((comp) => {
140
+ if (comp.type === "display") {
141
+ return {
142
+ type: "display",
143
+ component: new TextDisplayBuilder(comp.component.toJSON()),
144
+ };
145
+ }
146
+ return comp;
147
+ }),
148
+ );
149
+ }
150
+
151
+ function generateContainer(page: number): ContainerBuilder {
152
+ const pageStructure = structure[page];
153
+ if (!pageStructure) throw new Error(`Page ${page} structure not found`);
154
+
155
+ return new ContainerBuilder({
156
+ components: pageStructure.map((comp) => {
157
+ switch (comp.type) {
158
+ case "buttons":
159
+ return getPaginationRow(
160
+ prefix,
161
+ currentIndex,
162
+ entriesPerPage,
163
+ totalPages,
164
+ list.length,
165
+ ended,
166
+ ).toJSON();
167
+
168
+ case "display": {
169
+ if (comp.component.data.content !== contentMarker) {
170
+ return comp.component.toJSON();
171
+ }
172
+
173
+ const content = list
174
+ .slice(
175
+ page * entriesPerPage,
176
+ page * entriesPerPage + entriesPerPage,
177
+ )
178
+ .join("\n");
179
+ comp.component.data.content = applyReplacements(
180
+ content,
181
+ replacements,
182
+ );
183
+ return comp.component.toJSON();
184
+ }
185
+
186
+ case "section":
187
+ case "separator":
188
+ case "file":
189
+ case "gallery":
190
+ case "actionrow":
191
+ return comp.component.toJSON();
192
+
193
+ default: {
194
+ return comp;
195
+ }
196
+ }
197
+ }),
198
+ accent_color: styling?.[page]?.accent_color,
199
+ spoiler: styling?.[page]?.spoiler,
200
+ });
201
+ }
202
+
203
+ if (!interaction.replied && !interaction.deferred) {
204
+ await interaction
205
+ .deferReply({
206
+ withResponse: true,
207
+ flags: ephemeral ? ["Ephemeral"] : [],
208
+ })
209
+ .catch(() => null);
210
+ }
211
+
212
+ const channel = interaction.channel;
213
+ if (!channel || !("createMessageComponentCollector" in channel)) {
214
+ throw new Error("Invalid channel type");
215
+ }
216
+
217
+ async function render(): Promise<void> {
218
+ try {
219
+ await interaction.editReply({
220
+ components: [
221
+ generateContainer(Math.floor(currentIndex / entriesPerPage)),
222
+ ],
223
+ flags: ["IsComponentsV2"],
224
+ allowedMentions: {
225
+ parse: [],
226
+ repliedUser: false,
227
+ },
228
+ });
229
+ } catch (error) {
230
+ const e = error as Error;
231
+ if (!e.message.includes("Unknown Message")) {
232
+ console.error("Failed to render leaderboard:", error);
233
+ }
234
+ }
235
+ }
236
+
237
+ await render();
238
+
239
+ const collector = channel.createMessageComponentCollector({
240
+ componentType: ComponentType.Button,
241
+ time: 60000,
242
+ });
243
+
244
+ collector.on("collect", async (btn) => {
245
+ if (btn.user.id !== interaction.user.id) {
246
+ return void btn.deferUpdate();
247
+ }
248
+
249
+ if (!btn.customId.startsWith(prefix)) return;
250
+
251
+ ended = false;
252
+ collector.resetTimer();
253
+
254
+ if (btn.customId === `${prefix}info`) {
255
+ const modal = new ModalBuilder()
256
+ .setCustomId(`${prefix}modal`)
257
+ .setTitle("Page Indexer")
258
+ .addLabelComponents(
259
+ new LabelBuilder()
260
+ // .setDescription("Input a page number")
261
+ .setLabel("Input a page number")
262
+ .setTextInputComponent(
263
+ new TextInputBuilder()
264
+ .setCustomId(`${prefix}number`)
265
+ .setRequired(true)
266
+ .setMinLength(1)
267
+ .setStyle(TextInputStyle.Short),
268
+ ),
269
+ );
270
+
271
+ await btn.showModal(modal).catch((e) => console.error(e));
272
+ const modalSubmit = await btn
273
+ .awaitModalSubmit({ time: 60_000 })
274
+ .catch((e) => console.error(e));
275
+
276
+ if (!modalSubmit) {
277
+ await btn
278
+ .followUp({
279
+ content: "Modal timed out.",
280
+ flags: ["Ephemeral"],
281
+ })
282
+ .catch(() => null);
283
+ return;
284
+ }
285
+
286
+ await modalSubmit.deferUpdate().catch(() => null);
287
+ const pageNumber = Number(
288
+ modalSubmit.fields.getTextInputValue(`${prefix}number`),
289
+ );
290
+
291
+ if (
292
+ Number.isNaN(pageNumber) ||
293
+ pageNumber < 1 ||
294
+ pageNumber > totalPages
295
+ ) {
296
+ await modalSubmit
297
+ .reply({
298
+ content: `Invalid page! Choose a number between **1** and **${totalPages}**.`,
299
+ flags: ["Ephemeral"],
300
+ allowedMentions: {
301
+ parse: [],
302
+ repliedUser: false,
303
+ },
304
+ })
305
+ .catch(() => null);
306
+ return;
307
+ }
308
+
309
+ currentIndex = (pageNumber - 1) * entriesPerPage;
310
+ } else {
311
+ currentIndex +=
312
+ btn.customId === `${prefix}back` ? -entriesPerPage : entriesPerPage;
313
+ currentIndex = Math.max(
314
+ 0,
315
+ Math.min(currentIndex, (totalPages - 1) * entriesPerPage),
316
+ );
317
+ await btn.deferUpdate().catch(() => {});
318
+ }
319
+
320
+ await render();
321
+ });
322
+
323
+ collector.on("end", async () => {
324
+ ended = true;
325
+ await render();
326
+ });
327
+ }
@@ -4,12 +4,12 @@ const UNITS: Record<
4
4
  TimeUnitTypes,
5
5
  { label: string; short: string; ms: number }
6
6
  > = {
7
- y: { label: "year", short: "y", ms: 0 },
8
- mo: { label: "month", short: "mo", ms: 0 },
9
- w: { label: "week", short: "w", ms: 7 * 24 * 3600 * 1000 },
10
- d: { label: "day", short: "d", ms: 24 * 3600 * 1000 },
11
- h: { label: "hour", short: "h", ms: 3600 * 1000 },
12
- m: { label: "minute", short: "m", ms: 60 * 1000 },
7
+ y: { label: "year", short: "y", ms: 31536000000 },
8
+ mo: { label: "month", short: "mo", ms: 2628000000 },
9
+ w: { label: "week", short: "w", ms: 604800000 },
10
+ d: { label: "day", short: "d", ms: 86400000 },
11
+ h: { label: "hour", short: "h", ms: 3600000 },
12
+ m: { label: "minute", short: "m", ms: 60000 },
13
13
  s: { label: "second", short: "s", ms: 1000 },
14
14
  ms: { label: "millisecond", short: "ms", ms: 1 },
15
15
  };
@@ -25,7 +25,7 @@ const ALL_UNITS_ORDER: Array<TimeUnitTypes> = [
25
25
  "ms",
26
26
  ];
27
27
 
28
- export const formatSeconds = (
28
+ export function formatSeconds(
29
29
  seconds: number,
30
30
  options: {
31
31
  includeZeroUnits?: boolean;
@@ -37,99 +37,81 @@ export const formatSeconds = (
37
37
  label: string,
38
38
  ) => string;
39
39
  } = {},
40
- ): string => {
41
- const includeZeroUnits = options.includeZeroUnits ?? false;
42
- const onlyUnits = options.onlyUnits ?? [];
43
- const format = options.format ?? "long";
44
- const customFormatter = options.customFormatter;
45
-
46
- const totalMs = Math.max(0, Number(seconds) * 1000);
40
+ ): string {
41
+ const {
42
+ includeZeroUnits = false,
43
+ onlyUnits = [],
44
+ format = "long",
45
+ customFormatter,
46
+ } = options;
47
+ let totalMs = Math.max(0, Math.round(seconds * 1000));
47
48
  const unitsToDisplay = ALL_UNITS_ORDER.filter((u) =>
48
49
  onlyUnits.length ? onlyUnits.includes(u) : true,
49
50
  );
50
51
 
51
- if (totalMs === 0) {
52
- const zeroParts: Array<string> = [];
53
- for (const u of unitsToDisplay) {
54
- if (format === "short") {
55
- zeroParts.push(`0${UNITS[u].short}`);
56
- } else {
57
- zeroParts.push(`0 ${UNITS[u].label}`);
58
- }
59
- }
60
- return zeroParts.join(format === "short" ? " " : ", ");
61
- }
62
-
52
+ const diff: Partial<Record<TimeUnitTypes, number>> = {};
63
53
  const now = new Date();
64
54
  const end = new Date(now.getTime() + totalMs);
65
55
 
66
- let years = 0;
67
- if (unitsToDisplay.includes("y") || unitsToDisplay.includes("mo")) {
68
- years = end.getFullYear() - now.getFullYear();
56
+ if (unitsToDisplay.includes("y")) {
57
+ let y = end.getFullYear() - now.getFullYear();
58
+ const tempDate = new Date(now);
59
+ tempDate.setFullYear(now.getFullYear() + y);
60
+ if (tempDate > end) y--;
61
+ diff.y = y;
62
+ totalMs -= new Date(now).setFullYear(now.getFullYear() + y) - now.getTime();
69
63
  }
70
64
 
71
- let months = 0;
72
65
  if (unitsToDisplay.includes("mo")) {
73
- months = end.getMonth() - now.getMonth();
74
- if (years < 0) months += 12;
75
- }
66
+ const startTotalMonths = now.getFullYear() * 12 + now.getMonth();
67
+ const endTotalMonths = end.getFullYear() * 12 + end.getMonth();
68
+ let mo = endTotalMonths - startTotalMonths;
69
+ if (diff.y) mo -= diff.y * 12;
76
70
 
77
- const remainingMs =
78
- end.getTime() -
79
- new Date(
80
- now.getFullYear() + years,
81
- now.getMonth() + months,
82
- now.getDate(),
83
- now.getHours(),
84
- now.getMinutes(),
85
- now.getSeconds(),
86
- now.getMilliseconds(),
87
- ).getTime();
71
+ const tempDate = new Date(now);
72
+ tempDate.setFullYear(now.getFullYear() + (diff.y || 0));
73
+ tempDate.setMonth(now.getMonth() + mo);
74
+ if (tempDate > end) mo--;
88
75
 
89
- const diff: Record<TimeUnitTypes, number> = {
90
- y: years,
91
- mo: months,
92
- w: Math.floor(remainingMs / UNITS.w.ms),
93
- d: Math.floor((remainingMs % UNITS.w.ms) / UNITS.d.ms),
94
- h: Math.floor((remainingMs % UNITS.d.ms) / UNITS.h.ms),
95
- m: Math.floor((remainingMs % UNITS.h.ms) / UNITS.m.ms),
96
- s: Math.floor((remainingMs % UNITS.m.ms) / UNITS.s.ms),
97
- ms: remainingMs % 1000,
98
- };
76
+ diff.mo = Math.max(0, mo);
77
+ const jumpDate = new Date(now);
78
+ jumpDate.setFullYear(now.getFullYear() + (diff.y || 0));
79
+ jumpDate.setMonth(now.getMonth() + (diff.mo || 0));
80
+ totalMs = end.getTime() - jumpDate.getTime();
81
+ }
99
82
 
100
- const showZeros = includeZeroUnits || onlyUnits.length > 0;
83
+ for (const unit of ["w", "d", "h", "m", "s", "ms"] as const) {
84
+ if (unitsToDisplay.includes(unit)) {
85
+ diff[unit] = Math.floor(totalMs / UNITS[unit].ms);
86
+ totalMs %= UNITS[unit].ms;
87
+ }
88
+ }
101
89
 
102
- const parts: Array<string> = [];
90
+ const parts: string[] = [];
103
91
  for (const unit of unitsToDisplay) {
104
92
  const value = diff[unit] ?? 0;
105
- if (value || showZeros) {
106
- let label = "";
107
- if (format === "short") {
108
- label = UNITS[unit].short;
109
- } else {
110
- if (value === 1) {
111
- label = UNITS[unit].label;
112
- } else {
113
- label = `${UNITS[unit].label}s`;
114
- }
115
- }
93
+ if (value > 0 || includeZeroUnits) {
94
+ const label =
95
+ format === "short"
96
+ ? UNITS[unit].short
97
+ : value === 1
98
+ ? UNITS[unit].label
99
+ : `${UNITS[unit].label}s`;
116
100
 
117
- if (customFormatter) {
118
- parts.push(customFormatter(unit, value, label));
119
- } else {
120
- if (format === "short") {
121
- parts.push(`${value}${label}`);
122
- } else {
123
- parts.push(`${value} ${label}`);
124
- }
125
- }
101
+ parts.push(
102
+ customFormatter
103
+ ? customFormatter(unit, value, label)
104
+ : format === "short"
105
+ ? `${value}${label}`
106
+ : `${value} ${label}`,
107
+ );
126
108
  }
127
109
  }
128
110
 
111
+ if (parts.length === 0) return format === "short" ? "0s" : "0 seconds";
129
112
  if (format === "long" && parts.length > 1) {
130
113
  const last = parts.pop();
131
114
  return `${parts.join(", ")} and ${last}`;
132
115
  }
133
-
134
- return parts.join(format === "short" ? " " : ",");
135
- };
116
+ return parts.join(format === "short" ? " " : ", ");
117
+ }
@@ -0,0 +1,96 @@
1
+ import chalk from "chalk";
2
+
3
+ type LogLevel = "notif" | "alert" | "error" | "debug";
4
+ const { cyan, yellow, red, magenta, dim, gray, bold } = chalk;
5
+ const formatter = new Intl.DateTimeFormat("en-AU", {
6
+ weekday: "short",
7
+ hour: "2-digit",
8
+ minute: "2-digit",
9
+ second: "2-digit",
10
+ hour12: false,
11
+ });
12
+
13
+ export class LoggerModule {
14
+ private readonly colors: Record<LogLevel, typeof chalk> = {
15
+ notif: cyan,
16
+ alert: yellow,
17
+ error: red,
18
+ debug: magenta,
19
+ };
20
+
21
+ private readonly logMethods: Record<
22
+ LogLevel,
23
+ "log" | "warn" | "error" | "debug"
24
+ > = {
25
+ notif: "log",
26
+ alert: "warn",
27
+ error: "error",
28
+ debug: "debug",
29
+ };
30
+
31
+ private getTimestamp(): string {
32
+ const d = new Date();
33
+ return `${formatter.format(d)}.${d.getMilliseconds().toString().padStart(3, "0")}`;
34
+ }
35
+
36
+ private formatMessage(level: LogLevel, message: unknown): string {
37
+ const timestamp = gray(`[${this.getTimestamp()}]`);
38
+ const levelLabel = bold(this.colors[level](level.toUpperCase().padEnd(5)));
39
+
40
+ const content =
41
+ message instanceof Error
42
+ ? `${red(message.message)}\n${this.sanitizeStack(message.stack || "")}`
43
+ : String(message);
44
+
45
+ return `${timestamp} ${levelLabel} ${dim("»")} ${content}`;
46
+ }
47
+
48
+ private sanitizeStack(stack: string): string {
49
+ return stack
50
+ .split("\n")
51
+ .slice(1)
52
+ .filter((line) => line.includes(":") && !line.includes("node_modules"))
53
+ .map((line) => {
54
+ return line
55
+ .trim()
56
+ .replace(/\\/g, "/")
57
+ .replace(
58
+ /at\s+(.+?)\s+\((.+):(\d+):(\d+)\)/,
59
+ (_, fn, f, l, c) => ` └─ ${fn} ${dim(f)} ${bold(`(L${l} C${c})`)}`,
60
+ )
61
+ .replace(
62
+ /at\s+(.+):(\d+):(\d+)/,
63
+ (_, f, l, c) => ` └─ ${dim(f)} ${bold(`(L${l} C${c})`)}`,
64
+ );
65
+ })
66
+ .join("\n");
67
+ }
68
+
69
+ private log(
70
+ level: LogLevel,
71
+ message: unknown,
72
+ raw = false,
73
+ ): string | undefined {
74
+ const msg = this.formatMessage(level, message);
75
+ if (raw) return msg;
76
+ console[this.logMethods[level]](msg);
77
+ }
78
+
79
+ public notif(m: unknown, raw = false) {
80
+ return this.log("notif", m, raw);
81
+ }
82
+ public alert(m: unknown, raw = false) {
83
+ return this.log("alert", m, raw);
84
+ }
85
+ public error(m: unknown, e?: Error, raw = false) {
86
+ return this.log("error", e ?? m, raw);
87
+ }
88
+ public debug(m: unknown, raw = false) {
89
+ return this.log("debug", m, raw);
90
+ }
91
+
92
+ public divider(text: string): void {
93
+ const line = dim("─".repeat(Math.max(0, (50 - text.length - 2) / 2)));
94
+ console.log(`\n${line} ${bold(text.trim())} ${line}`);
95
+ }
96
+ }
@@ -1,16 +1,14 @@
1
- const suffixes: Record<string, string> = {
2
- one: "st",
3
- two: "nd",
4
- few: "rd",
5
- other: "th",
6
- };
7
-
8
- /**
9
- * Converts a number to its ordinal value
10
- *
11
- * @param n Number to convert
12
- * @returns string
13
- */
14
- export function toOrdinal(n: number): string {
15
- return `${n}${suffixes[new Intl.PluralRules("en-US", { type: "ordinal" }).select(n)]}`;
16
- }
1
+ const pr = new Intl.PluralRules("en-US", { type: "ordinal" });
2
+ const suffixes: Record<Intl.LDMLPluralRule, string> = {
3
+ one: "st",
4
+ two: "nd",
5
+ few: "rd",
6
+ other: "th",
7
+ // Included for type safety
8
+ zero: "th",
9
+ many: "th",
10
+ };
11
+
12
+ export function toOrdinal(n: number): string {
13
+ return `${n}${suffixes[pr.select(n)]}`;
14
+ }
package/src/lib/logger.ts DELETED
@@ -1,124 +0,0 @@
1
- import { env } from "bun";
2
- import chalk from "chalk";
3
-
4
- type LogLevel = "notif" | "alert" | "error" | "debug";
5
- const { cyan, yellow, red, magenta, dim, gray, bold } = chalk;
6
- const formatter = new Intl.DateTimeFormat("en-AU", {
7
- weekday: "short",
8
- hour: "2-digit",
9
- minute: "2-digit",
10
- second: "2-digit",
11
- hour12: false,
12
- });
13
-
14
- /**
15
- * To enable debuging make the env variable `__DEBUG_MODE` equal to "1"
16
- */
17
- export class Logger {
18
- private readonly colors: Record<LogLevel, typeof chalk> = {
19
- notif: cyan,
20
- alert: yellow,
21
- error: red,
22
- debug: magenta,
23
- };
24
-
25
- private readonly logMethods: Record<
26
- LogLevel,
27
- "log" | "warn" | "error" | "debug"
28
- > = {
29
- notif: "log",
30
- alert: "warn",
31
- error: "error",
32
- debug: "debug",
33
- };
34
-
35
- private getTimestamp(): string {
36
- const now = new Date();
37
- const ms = now.getMilliseconds().toString().padStart(3, "0");
38
- const base = formatter.format(now).replace(",", " @");
39
- return `${base}.${ms}`;
40
- }
41
-
42
- private formatMessage(level: LogLevel, message: string | Error): string {
43
- const timestamp = gray(`[${this.getTimestamp()}]`);
44
- const levelLabel = bold(this.colors[level](level.toUpperCase().padEnd(5)));
45
-
46
- const content =
47
- message instanceof Error
48
- ? `${red(message.message)}\n${dim(this.sanitizeStack(message.stack || ""))}`
49
- : message;
50
-
51
- return `${timestamp} ${levelLabel} ${dim("»")} ${content}`;
52
- }
53
-
54
- private sanitizeStack(stack: string): string {
55
- return stack
56
- .split("\n")
57
- .filter((line) => !line.includes("(native") && line.includes(":"))
58
- .map((line, index) => {
59
- if (index === 0) return false;
60
- return (
61
- line
62
- .trim()
63
- // .replace(/\\/g, "/")
64
- // .replace(
65
- // /(.*):(\d+):(\d+)/,
66
- // (_, f, l, c) => `${dim(f)} ${bold(`(L${l} C${c})`)}`,
67
- // )
68
- .replace(/at\s+/, " └─ ")
69
- );
70
- })
71
- .filter(Boolean)
72
- .join("\n");
73
- }
74
-
75
- private log(level: LogLevel, message: unknown, forceError?: boolean): void {
76
- if (forceError) return void console.error(message);
77
- void console[this.logMethods[level]](
78
- this.formatMessage(level, message as string | Error),
79
- );
80
- }
81
-
82
- /**
83
- * Pring a regular (info) message
84
- *
85
- * @param m Message to display
86
- */
87
- public notif(m: unknown) {
88
- this.log("notif", m);
89
- }
90
-
91
- /**
92
- * Print an alert (warning)
93
- *
94
- * @param m Message to display
95
- */
96
- public alert(m: unknown) {
97
- this.log("alert", m);
98
- }
99
-
100
- /**
101
- * Print an error
102
- *
103
- * @param m Message to display
104
- * @param e Error (optional)
105
- * @param f Force error - directly calls `console.error()`
106
- */
107
- public error(m: unknown, e?: Error, f?: boolean) {
108
- this.log("error", e ?? m, f);
109
- }
110
-
111
- /**
112
- * Print a debug message
113
- *
114
- * @param m Message to display
115
- */
116
- public debug(m: unknown) {
117
- if ("__DEBUG_MODE" in env && env.__DEBUG_MODE === "1") this.log("debug", m);
118
- }
119
-
120
- public divider(text: string): void {
121
- const line = dim("─".repeat(Math.max(0, (50 - text.length - 2) / 2)));
122
- console.log(`\n${line} ${bold(text.trim())} ${line}`);
123
- }
124
- }