@heyramzi/utils 0.0.0-stage → 1.12.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.
- package/LICENSE +21 -0
- package/README.md +112 -2
- package/package.json +103 -4
- package/src/ai/image-agnes.test.ts +130 -0
- package/src/ai/image.ts +435 -0
- package/src/ai/models.test.ts +98 -0
- package/src/ai/models.ts +450 -0
- package/src/analytics/client.test.ts +149 -0
- package/src/analytics/client.ts +129 -0
- package/src/analytics/server.test.ts +165 -0
- package/src/analytics/server.ts +88 -0
- package/src/analytics/svelte.test.ts +105 -0
- package/src/analytics/svelte.ts +58 -0
- package/src/analytics/types.ts +26 -0
- package/src/analytics/umami/protocol.test.ts +18 -0
- package/src/analytics/umami/protocol.ts +104 -0
- package/src/analytics/umami/reporting.test.ts +391 -0
- package/src/analytics/umami/reporting.ts +627 -0
- package/src/auth/config/otp.ts +8 -0
- package/src/auth/config/rate-limits.ts +11 -0
- package/src/auth/config/trusted-origins.ts +32 -0
- package/src/auth/email/index.ts +7 -0
- package/src/auth/email/layout.test.ts +73 -0
- package/src/auth/email/layout.ts +122 -0
- package/src/auth/email/resend.test.ts +35 -0
- package/src/auth/email/resend.ts +26 -0
- package/src/auth/email/template.ts +77 -0
- package/src/auth/email/types.ts +74 -0
- package/src/auth/index.ts +5 -0
- package/src/cache.test.ts +67 -0
- package/src/cache.ts +5 -0
- package/src/cn.test.ts +52 -0
- package/src/cn.ts +16 -0
- package/src/collection.test.ts +66 -0
- package/src/collection.ts +8 -0
- package/src/crypto.test.ts +86 -0
- package/src/crypto.ts +157 -0
- package/src/error.test.ts +79 -0
- package/src/error.ts +16 -0
- package/src/errors/sentry/index.ts +20 -0
- package/src/errors/sentry/protocol.test.ts +85 -0
- package/src/errors/sentry/protocol.ts +144 -0
- package/src/errors/sentry/sveltekit.test.ts +88 -0
- package/src/errors/sentry/sveltekit.ts +188 -0
- package/src/format.test.ts +225 -0
- package/src/format.ts +103 -0
- package/src/grab.d.ts +2 -0
- package/src/grab.js +112 -0
- package/src/index.ts +38 -0
- package/src/logger-types.ts +14 -0
- package/src/logger.ts +24 -0
- package/src/math.test.ts +41 -0
- package/src/math.ts +3 -0
- package/src/og/fonts.ts +69 -0
- package/src/og/index.ts +5 -0
- package/src/og/markup.test.ts +73 -0
- package/src/og/markup.ts +90 -0
- package/src/og/renderer.ts +42 -0
- package/src/og/types.ts +26 -0
- package/src/og/wawoff2.d.ts +6 -0
- package/src/package-imports.test.mjs +22 -0
- package/src/result.test.ts +149 -0
- package/src/result.ts +29 -0
- package/src/string.test.ts +205 -0
- package/src/string.ts +71 -0
- package/src/testimonials/render-content.ts +21 -0
- package/src/testimonials/server.ts +47 -0
- package/src/testimonials/types.ts +35 -0
- package/src/timing.test.ts +166 -0
- package/src/timing.ts +28 -0
- package/src/vite.d.ts +12 -0
- package/src/vite.js +265 -0
- package/src/vite.test.js +235 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kit 3 `handleError` hooks over the SDK-free Sentry sender. They read the `kind` field kit 2
|
|
3
|
+
* never sent. The app passes `dev`, `release` and the DSN, so nothing imports kit.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { captureError } from "#src/errors/sentry/index.js";
|
|
7
|
+
import type { SentryScalar } from "#src/errors/sentry/protocol.js";
|
|
8
|
+
|
|
9
|
+
/** The part of kit's `CaughtError & { event }` these hooks read, so kit's hook types accept ours. */
|
|
10
|
+
export type CaughtInput = {
|
|
11
|
+
kind: "app" | "framework" | "validation" | "unknown";
|
|
12
|
+
error: unknown;
|
|
13
|
+
event: { url: URL; route: { id: string | null } };
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type BaseOptions<R> = {
|
|
17
|
+
/** Public client key on the browser, private on the server. Empty or missing sends nothing. */
|
|
18
|
+
dsn: string | undefined;
|
|
19
|
+
/** `dev` from `$app/env`. Reports nothing while true. */
|
|
20
|
+
dev: boolean;
|
|
21
|
+
/** `version` from `$app/env`. Set `kit.version.name` to the commit SHA so a fix is traceable. */
|
|
22
|
+
release: string | undefined;
|
|
23
|
+
/** Becomes the `surface` tag, e.g. "web.server". */
|
|
24
|
+
surface: string;
|
|
25
|
+
/** Defaults to "production". Pass "preview" or "staging" from the app's env where those exist. */
|
|
26
|
+
environment?: string;
|
|
27
|
+
tags?: Record<string, SentryScalar | null | undefined>;
|
|
28
|
+
/** Logs the error to the console before reporting. Pass false when `respond` already logs. */
|
|
29
|
+
log?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* The user-facing `App.Error`, returned for every caught error, reported or not. Return
|
|
32
|
+
* nothing to keep kit's defaults. A throw here is logged and kit's defaults apply.
|
|
33
|
+
*/
|
|
34
|
+
respond?: (input: CaughtInput) => R | Promise<R>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type ClientErrorOptions<R> = BaseOptions<R> & {
|
|
38
|
+
/**
|
|
39
|
+
* Return true to drop an error. Runs only after the status filter, so an expensive check
|
|
40
|
+
* (kit's `updated.check()`) isn't paid on a 404.
|
|
41
|
+
*/
|
|
42
|
+
skip?: (input: CaughtInput) => boolean | Promise<boolean>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type ServerErrorOptions<R> = BaseOptions<R>;
|
|
46
|
+
|
|
47
|
+
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether a caught error should page us. An `app` error comes from `error()` and is expected.
|
|
51
|
+
* A framework or validation error counts from 500 up. Anything unknown is a bug.
|
|
52
|
+
*/
|
|
53
|
+
export function shouldReport({ kind, error }: Pick<CaughtInput, "kind" | "error">): boolean {
|
|
54
|
+
if (kind === "app") return false;
|
|
55
|
+
if (kind === "unknown") return true;
|
|
56
|
+
const status = (error as { status?: unknown } | null)?.status;
|
|
57
|
+
return typeof status === "number" ? status >= 500 : true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Chrome, Safari and Firefox each word a dynamic import that 404s differently.
|
|
61
|
+
const STALE_CHUNK =
|
|
62
|
+
/Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module/;
|
|
63
|
+
|
|
64
|
+
/** A dynamic import failed: a deploy removed the chunk this old page points at, or the network dropped. */
|
|
65
|
+
export function isStaleChunk(error: unknown): boolean {
|
|
66
|
+
const message = (error as { message?: unknown } | null)?.message;
|
|
67
|
+
return typeof message === "string" && STALE_CHUNK.test(message);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const RELOAD_KEY = "heyramzi:stale-chunk-reload";
|
|
71
|
+
/** A tab left open across two deploys reloads on each. Inside this window a repeat means the reload didn't help. */
|
|
72
|
+
const RELOAD_WINDOW_MS = 30_000;
|
|
73
|
+
|
|
74
|
+
type ReloadEnv = {
|
|
75
|
+
storage: Pick<Storage, "getItem" | "setItem">;
|
|
76
|
+
now: () => number;
|
|
77
|
+
reload: () => void;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function browserEnv(): ReloadEnv | null {
|
|
81
|
+
try {
|
|
82
|
+
return { storage: sessionStorage, now: Date.now, reload: () => location.reload() };
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Reload once per window to fetch the new build. False when it already did or storage is
|
|
90
|
+
* blocked, because without the guard a broken chunk loops reloads.
|
|
91
|
+
*/
|
|
92
|
+
export function reloadForStaleChunk(env: ReloadEnv | null = browserEnv()): boolean {
|
|
93
|
+
if (!env) return false;
|
|
94
|
+
try {
|
|
95
|
+
const last = Number(env.storage.getItem(RELOAD_KEY));
|
|
96
|
+
if (env.now() - last < RELOAD_WINDOW_MS) return false;
|
|
97
|
+
env.storage.setItem(RELOAD_KEY, String(env.now()));
|
|
98
|
+
env.reload();
|
|
99
|
+
return true;
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Kit's HttpError isn't an Error, so one with a message becomes one to give Sentry a title and stack. */
|
|
106
|
+
function toError(raw: unknown): unknown {
|
|
107
|
+
if (raw instanceof Error) return raw;
|
|
108
|
+
const message = (raw as { message?: unknown } | null)?.message;
|
|
109
|
+
return typeof message === "string" ? new Error(message) : raw;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isLocalHost(input: CaughtInput): boolean {
|
|
113
|
+
return LOCAL_HOSTS.has(input.event.url.hostname);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function report<R>(
|
|
117
|
+
options: BaseOptions<R>,
|
|
118
|
+
input: CaughtInput,
|
|
119
|
+
extraTags?: Record<string, SentryScalar>,
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
const { dsn, dev, release, surface, environment = "production", tags } = options;
|
|
122
|
+
// WHY the host check: wrangler dev and vite preview serve localhost with `dev` false.
|
|
123
|
+
if (dev || !dsn || isLocalHost(input)) return;
|
|
124
|
+
await captureError({
|
|
125
|
+
dsn,
|
|
126
|
+
error: toError(input.error),
|
|
127
|
+
surface,
|
|
128
|
+
release,
|
|
129
|
+
environment,
|
|
130
|
+
tags: { ...tags, ...extraTags, route: input.event.route.id },
|
|
131
|
+
extra: { pathname: input.event.url.pathname, kind: input.kind },
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function respondTo<R>(options: BaseOptions<R>, input: CaughtInput): Promise<R | undefined> {
|
|
136
|
+
if (!options.respond) return undefined;
|
|
137
|
+
try {
|
|
138
|
+
return await options.respond(input);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
console.error("[handleError] respond threw:", err);
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* `handleError` for `hooks.client.ts`. A stale-chunk error reloads the page once instead
|
|
147
|
+
* of reporting. Everything else that's ours goes to Sentry, tagged with the environment
|
|
148
|
+
* and release.
|
|
149
|
+
*/
|
|
150
|
+
export function createHandleClientError<R = undefined>(options: ClientErrorOptions<R>) {
|
|
151
|
+
return async (input: CaughtInput): Promise<R | undefined> => {
|
|
152
|
+
try {
|
|
153
|
+
let tags: Record<string, SentryScalar> | undefined;
|
|
154
|
+
if (isStaleChunk(input.error)) {
|
|
155
|
+
if (reloadForStaleChunk()) return respondTo(options, input);
|
|
156
|
+
// Already reloaded once: report it, tagged so it filters apart from a real bug.
|
|
157
|
+
tags = { stale_chunk: true };
|
|
158
|
+
}
|
|
159
|
+
if (shouldReport(input)) {
|
|
160
|
+
if (options.log !== false) console.error("[handleError]", input.error);
|
|
161
|
+
if (!(await options.skip?.(input))) await report(options, input, tags);
|
|
162
|
+
}
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.error("[handleError] reporting failed:", err);
|
|
165
|
+
}
|
|
166
|
+
return respondTo(options, input);
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* `handleError` for `hooks.server.ts`. Awaits the send, because a Worker stops when the
|
|
172
|
+
* response ends and would drop a fire-and-forget request. The `cause` chain rides along
|
|
173
|
+
* in the event (see `buildErrorRequest`).
|
|
174
|
+
*/
|
|
175
|
+
export function createHandleServerError<R = undefined>(options: ServerErrorOptions<R>) {
|
|
176
|
+
return async (input: CaughtInput): Promise<R | undefined> => {
|
|
177
|
+
try {
|
|
178
|
+
if (shouldReport(input)) {
|
|
179
|
+
// WHY the error itself, not its stack: Node and workerd print `cause` for an Error.
|
|
180
|
+
if (options.log !== false) console.error("[handleError]", toError(input.error));
|
|
181
|
+
await report(options, input);
|
|
182
|
+
}
|
|
183
|
+
} catch (err) {
|
|
184
|
+
console.error("[handleError] reporting failed:", err);
|
|
185
|
+
}
|
|
186
|
+
return respondTo(options, input);
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vite-plus/test";
|
|
2
|
+
import { formatDate, formatCurrency, formatAmount, formatRelativeTime } from "#src/format.ts";
|
|
3
|
+
|
|
4
|
+
describe("formatDate", () => {
|
|
5
|
+
it("formats a Date object to readable string", () => {
|
|
6
|
+
const date = new Date("2025-01-21T00:00:00.000Z");
|
|
7
|
+
// Use a fixed UTC date because locale output varies; just check it's not '-'
|
|
8
|
+
const result = formatDate(date, "en-US");
|
|
9
|
+
expect(result).not.toBe("-");
|
|
10
|
+
expect(result).toMatch(/2025/);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns '-' for null", () => {
|
|
14
|
+
expect(formatDate(null)).toBe("-");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns '-' for undefined", () => {
|
|
18
|
+
expect(formatDate(undefined)).toBe("-");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("returns '-' for an invalid date string", () => {
|
|
22
|
+
expect(formatDate("not-a-date")).toBe("-");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("formats a numeric string (ClickUp timestamp)", () => {
|
|
26
|
+
const ts = new Date("2024-06-01").getTime();
|
|
27
|
+
const result = formatDate(String(ts), "en-US");
|
|
28
|
+
expect(result).toMatch(/2024/);
|
|
29
|
+
expect(result).not.toBe("-");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("formats a numeric timestamp", () => {
|
|
33
|
+
const ts = new Date("2024-03-15").getTime();
|
|
34
|
+
const result = formatDate(ts, "en-US");
|
|
35
|
+
expect(result).toMatch(/2024/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("formats a date string", () => {
|
|
39
|
+
const result = formatDate("2024-07-04", "en-US");
|
|
40
|
+
expect(result).toMatch(/2024/);
|
|
41
|
+
expect(result).not.toBe("-");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("formatCurrency", () => {
|
|
46
|
+
it("returns '-' for null", () => {
|
|
47
|
+
expect(formatCurrency(null)).toBe("-");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns '-' for undefined", () => {
|
|
51
|
+
expect(formatCurrency(undefined)).toBe("-");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns '-' for zero", () => {
|
|
55
|
+
expect(formatCurrency(0)).toBe("-");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("formats USD by default", () => {
|
|
59
|
+
const result = formatCurrency(5000);
|
|
60
|
+
expect(result).toContain("5");
|
|
61
|
+
expect(result).toMatch(/[$5,]/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("formats EUR with fr-FR locale when no locale provided", () => {
|
|
65
|
+
const result = formatCurrency(1000, "EUR");
|
|
66
|
+
expect(result).toContain("1");
|
|
67
|
+
expect(result).toMatch(/€|EUR/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("respects explicit locale override", () => {
|
|
71
|
+
const result = formatCurrency(1000, "USD", "en-US");
|
|
72
|
+
expect(result).toContain("1");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("formatAmount", () => {
|
|
77
|
+
it("converts cents to dollars and formats", () => {
|
|
78
|
+
const result = formatAmount(1500, "USD", "en-US");
|
|
79
|
+
expect(result).toContain("15");
|
|
80
|
+
expect(result).toContain(".");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("shows two decimal places", () => {
|
|
84
|
+
const result = formatAmount(100, "USD", "en-US");
|
|
85
|
+
expect(result).toMatch(/1\.00/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("handles 0 cents", () => {
|
|
89
|
+
const result = formatAmount(0, "USD", "en-US");
|
|
90
|
+
expect(result).toMatch(/0\.00/);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("formatRelativeTime", () => {
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
vi.useFakeTimers();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterEach(() => {
|
|
100
|
+
vi.useRealTimers();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("returns '-' for null", () => {
|
|
104
|
+
expect(formatRelativeTime(null)).toBe("-");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("returns '-' for undefined", () => {
|
|
108
|
+
expect(formatRelativeTime(undefined)).toBe("-");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("returns '-' for an invalid date string", () => {
|
|
112
|
+
expect(formatRelativeTime("not-a-date")).toBe("-");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("returns 'Today' for today's date", () => {
|
|
116
|
+
const now = new Date("2024-06-01T12:00:00.000Z");
|
|
117
|
+
vi.setSystemTime(now);
|
|
118
|
+
|
|
119
|
+
const today = new Date("2024-06-01T08:00:00.000Z");
|
|
120
|
+
expect(formatRelativeTime(today.toISOString())).toBe("Today");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("returns 'Yesterday' for a date 1 day ago", () => {
|
|
124
|
+
const now = new Date("2024-06-02T12:00:00.000Z");
|
|
125
|
+
vi.setSystemTime(now);
|
|
126
|
+
|
|
127
|
+
const yesterday = new Date("2024-06-01T12:00:00.000Z");
|
|
128
|
+
expect(formatRelativeTime(yesterday.toISOString())).toBe("Yesterday");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("returns 'N days ago' for 2-6 days ago", () => {
|
|
132
|
+
const now = new Date("2024-06-10T12:00:00.000Z");
|
|
133
|
+
vi.setSystemTime(now);
|
|
134
|
+
|
|
135
|
+
const fiveDaysAgo = new Date("2024-06-05T12:00:00.000Z");
|
|
136
|
+
expect(formatRelativeTime(fiveDaysAgo.toISOString())).toBe("5 days ago");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("returns 'N weeks ago' for 7-29 days ago", () => {
|
|
140
|
+
const now = new Date("2024-06-15T12:00:00.000Z");
|
|
141
|
+
vi.setSystemTime(now);
|
|
142
|
+
|
|
143
|
+
const twoWeeksAgo = new Date("2024-06-01T12:00:00.000Z");
|
|
144
|
+
expect(formatRelativeTime(twoWeeksAgo.toISOString())).toBe("2 weeks ago");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("uses singular labels when the count is exactly 1", () => {
|
|
148
|
+
const now = new Date("2024-06-15T12:00:00.000Z");
|
|
149
|
+
vi.setSystemTime(now);
|
|
150
|
+
|
|
151
|
+
const oneWeekAgo = new Date("2024-06-07T12:00:00.000Z");
|
|
152
|
+
expect(formatRelativeTime(oneWeekAgo.toISOString())).toBe("1 week ago");
|
|
153
|
+
|
|
154
|
+
const oneMonthAgo = new Date("2024-05-10T12:00:00.000Z");
|
|
155
|
+
expect(formatRelativeTime(oneMonthAgo.toISOString())).toBe("1 month ago");
|
|
156
|
+
|
|
157
|
+
const oneYearAgo = new Date("2023-06-01T12:00:00.000Z");
|
|
158
|
+
expect(formatRelativeTime(oneYearAgo.toISOString())).toBe("1 year ago");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("falls back to plural labels when no singular label is provided", () => {
|
|
162
|
+
const now = new Date("2024-06-15T12:00:00.000Z");
|
|
163
|
+
vi.setSystemTime(now);
|
|
164
|
+
|
|
165
|
+
const oneWeekAgo = new Date("2024-06-07T12:00:00.000Z");
|
|
166
|
+
const labels = {
|
|
167
|
+
today: "Today",
|
|
168
|
+
yesterday: "Yesterday",
|
|
169
|
+
daysAgo: "{n} days ago",
|
|
170
|
+
weeksAgo: "{n} weeks ago",
|
|
171
|
+
monthsAgo: "{n} months ago",
|
|
172
|
+
yearsAgo: "{n} years ago",
|
|
173
|
+
};
|
|
174
|
+
expect(formatRelativeTime(oneWeekAgo.toISOString(), labels)).toBe("1 weeks ago");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("returns 'N months ago' for 30-364 days ago", () => {
|
|
178
|
+
const now = new Date("2024-09-01T12:00:00.000Z");
|
|
179
|
+
vi.setSystemTime(now);
|
|
180
|
+
|
|
181
|
+
const threeMonthsAgo = new Date("2024-06-01T12:00:00.000Z");
|
|
182
|
+
expect(formatRelativeTime(threeMonthsAgo.toISOString())).toBe("3 months ago");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("returns 'N years ago' for 365+ days ago", () => {
|
|
186
|
+
const now = new Date("2026-01-01T12:00:00.000Z");
|
|
187
|
+
vi.setSystemTime(now);
|
|
188
|
+
|
|
189
|
+
const twoYearsAgo = new Date("2024-01-01T12:00:00.000Z");
|
|
190
|
+
expect(formatRelativeTime(twoYearsAgo.toISOString())).toBe("2 years ago");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("accepts a numeric timestamp", () => {
|
|
194
|
+
const now = new Date("2024-06-01T12:00:00.000Z");
|
|
195
|
+
vi.setSystemTime(now);
|
|
196
|
+
|
|
197
|
+
const todayMs = new Date("2024-06-01T08:00:00.000Z").getTime();
|
|
198
|
+
expect(formatRelativeTime(todayMs)).toBe("Today");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("accepts a numeric-string timestamp", () => {
|
|
202
|
+
const now = new Date("2024-06-01T12:00:00.000Z");
|
|
203
|
+
vi.setSystemTime(now);
|
|
204
|
+
|
|
205
|
+
const todayMs = String(new Date("2024-06-01T08:00:00.000Z").getTime());
|
|
206
|
+
expect(formatRelativeTime(todayMs)).toBe("Today");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("uses custom labels", () => {
|
|
210
|
+
const now = new Date("2024-06-10T12:00:00.000Z");
|
|
211
|
+
vi.setSystemTime(now);
|
|
212
|
+
|
|
213
|
+
const fiveDaysAgo = new Date("2024-06-05T12:00:00.000Z");
|
|
214
|
+
const labels = {
|
|
215
|
+
today: "Aujourd'hui",
|
|
216
|
+
yesterday: "Hier",
|
|
217
|
+
daysAgo: "il y a {n} jours",
|
|
218
|
+
weeksAgo: "il y a {n} semaines",
|
|
219
|
+
monthsAgo: "il y a {n} mois",
|
|
220
|
+
yearsAgo: "il y a {n} ans",
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
expect(formatRelativeTime(fiveDaysAgo.toISOString(), labels)).toBe("il y a 5 jours");
|
|
224
|
+
});
|
|
225
|
+
});
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** Format date to readable string (e.g., "Jan 21, 2025" or "21 janv. 2025") */
|
|
2
|
+
export function formatDate(
|
|
3
|
+
date: Date | string | number | null | undefined,
|
|
4
|
+
locale = "en-US",
|
|
5
|
+
): string {
|
|
6
|
+
if (!date) return "-";
|
|
7
|
+
|
|
8
|
+
// Convert string numbers to actual numbers (e.g. ClickUp timestamps)
|
|
9
|
+
const dateValue = typeof date === "string" && /^\d+$/.test(date) ? parseInt(date, 10) : date;
|
|
10
|
+
|
|
11
|
+
const parsed = new Date(dateValue);
|
|
12
|
+
if (Number.isNaN(parsed.getTime())) return "-";
|
|
13
|
+
|
|
14
|
+
return parsed.toLocaleDateString(locale, {
|
|
15
|
+
month: "short",
|
|
16
|
+
day: "numeric",
|
|
17
|
+
year: "numeric",
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Format currency value (e.g., "$5,000" or "5 000 €") */
|
|
22
|
+
export function formatCurrency(
|
|
23
|
+
value: number | null | undefined,
|
|
24
|
+
currency: string = "USD",
|
|
25
|
+
locale?: string,
|
|
26
|
+
): string {
|
|
27
|
+
if (!value) return "-";
|
|
28
|
+
|
|
29
|
+
const resolvedLocale = locale ?? (currency.toUpperCase() === "EUR" ? "fr-FR" : "en-US");
|
|
30
|
+
|
|
31
|
+
return new Intl.NumberFormat(resolvedLocale, {
|
|
32
|
+
style: "currency",
|
|
33
|
+
currency: currency.toUpperCase(),
|
|
34
|
+
minimumFractionDigits: 0,
|
|
35
|
+
}).format(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Format amount from cents (e.g., 1500 cents → "$15.00") */
|
|
39
|
+
export function formatAmount(cents: number, currency: string, locale = "en-US"): string {
|
|
40
|
+
return new Intl.NumberFormat(locale, {
|
|
41
|
+
style: "currency",
|
|
42
|
+
currency: currency.toUpperCase(),
|
|
43
|
+
minimumFractionDigits: 2,
|
|
44
|
+
}).format(cents / 100);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type RelativeTimeLabels = {
|
|
48
|
+
today: string;
|
|
49
|
+
yesterday: string;
|
|
50
|
+
daysAgo: string;
|
|
51
|
+
weeksAgo: string;
|
|
52
|
+
monthsAgo: string;
|
|
53
|
+
yearsAgo: string;
|
|
54
|
+
/** Singular forms used when the count is exactly 1 (e.g., "1 week ago") */
|
|
55
|
+
weekAgo?: string;
|
|
56
|
+
monthAgo?: string;
|
|
57
|
+
yearAgo?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const DEFAULT_LABELS: RelativeTimeLabels = {
|
|
61
|
+
today: "Today",
|
|
62
|
+
yesterday: "Yesterday",
|
|
63
|
+
daysAgo: "{n} days ago",
|
|
64
|
+
weeksAgo: "{n} weeks ago",
|
|
65
|
+
monthsAgo: "{n} months ago",
|
|
66
|
+
yearsAgo: "{n} years ago",
|
|
67
|
+
weekAgo: "1 week ago",
|
|
68
|
+
monthAgo: "1 month ago",
|
|
69
|
+
yearAgo: "1 year ago",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Format relative time (e.g., "2 days ago") with customizable labels for i18n */
|
|
73
|
+
export function formatRelativeTime(
|
|
74
|
+
date: string | number | null | undefined,
|
|
75
|
+
labels: RelativeTimeLabels = DEFAULT_LABELS,
|
|
76
|
+
): string {
|
|
77
|
+
if (!date) return "-";
|
|
78
|
+
|
|
79
|
+
const dateValue = typeof date === "string" && /^\d+$/.test(date) ? parseInt(date, 10) : date;
|
|
80
|
+
|
|
81
|
+
const parsed = new Date(dateValue);
|
|
82
|
+
if (Number.isNaN(parsed.getTime())) return "-";
|
|
83
|
+
|
|
84
|
+
const diffMs = Date.now() - parsed.getTime();
|
|
85
|
+
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
86
|
+
|
|
87
|
+
if (diffDays === 0) return labels.today;
|
|
88
|
+
if (diffDays === 1) return labels.yesterday;
|
|
89
|
+
if (diffDays < 7) return labels.daysAgo.replace("{n}", String(diffDays));
|
|
90
|
+
if (diffDays < 30) {
|
|
91
|
+
const weeks = Math.floor(diffDays / 7);
|
|
92
|
+
if (weeks === 1 && labels.weekAgo) return labels.weekAgo;
|
|
93
|
+
return labels.weeksAgo.replace("{n}", String(weeks));
|
|
94
|
+
}
|
|
95
|
+
if (diffDays < 365) {
|
|
96
|
+
const months = Math.floor(diffDays / 30);
|
|
97
|
+
if (months === 1 && labels.monthAgo) return labels.monthAgo;
|
|
98
|
+
return labels.monthsAgo.replace("{n}", String(months));
|
|
99
|
+
}
|
|
100
|
+
const years = Math.floor(diffDays / 365);
|
|
101
|
+
if (years === 1 && labels.yearAgo) return labels.yearAgo;
|
|
102
|
+
return labels.yearsAgo.replace("{n}", String(years));
|
|
103
|
+
}
|
package/src/grab.d.ts
ADDED
package/src/grab.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alt+Click any element in a Svelte 5 dev build to copy its HTML and component stack for a coding agent.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Usage in +layout.svelte: if (dev) import("@heyramzi/utils/grab");
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* Plain JS with no imports, so a page can also inline this file as a <script type="module">.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const MAX_HTML = 400;
|
|
14
|
+
|
|
15
|
+
/** @param {Element} el */
|
|
16
|
+
function sourceOf(el) {
|
|
17
|
+
/** @type {Element | null} */
|
|
18
|
+
let node = el;
|
|
19
|
+
while (node && !(/** @type {any} */ (node).__svelte_meta)) node = node.parentElement;
|
|
20
|
+
return node ? /** @type {any} */ (node).__svelte_meta : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @param {{ file: string, line: number, column: number }} at */
|
|
24
|
+
function where(at) {
|
|
25
|
+
return `${at.file}:${at.line}:${at.column}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @param {Element} el */
|
|
29
|
+
function describe(el) {
|
|
30
|
+
// Svelte's hydration markers are <!----> comments, noise to an agent.
|
|
31
|
+
const full = el.outerHTML.replace(/<!--[^]*?-->/g, "");
|
|
32
|
+
const html = full.length > MAX_HTML ? `${full.slice(0, MAX_HTML)}…` : full;
|
|
33
|
+
const meta = sourceOf(el);
|
|
34
|
+
if (!meta) return html;
|
|
35
|
+
|
|
36
|
+
const lines = [html, ` at ${where(meta.loc)}`];
|
|
37
|
+
for (let entry = meta.parent; entry; entry = entry.parent) {
|
|
38
|
+
if (entry.type === "component") lines.push(` in <${entry.componentTag}> at ${where(entry)}`);
|
|
39
|
+
}
|
|
40
|
+
return lines.join("\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @param {Element} el */
|
|
44
|
+
function label(el) {
|
|
45
|
+
const meta = sourceOf(el);
|
|
46
|
+
let entry = meta?.parent;
|
|
47
|
+
while (entry && entry.type !== "component") entry = entry.parent;
|
|
48
|
+
const name = entry ? `<${entry.componentTag}>` : el.tagName.toLowerCase();
|
|
49
|
+
return meta ? `${name} ${meta.loc.file.split("/").pop()}:${meta.loc.line}` : name;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function start() {
|
|
53
|
+
const box = document.createElement("div");
|
|
54
|
+
const tag = document.createElement("div");
|
|
55
|
+
box.style.cssText =
|
|
56
|
+
"position:fixed;pointer-events:none;z-index:2147483647;border:2px solid #7c3aed;background:rgb(124 58 237 / 0.08);border-radius:3px;display:none";
|
|
57
|
+
tag.style.cssText =
|
|
58
|
+
"position:absolute;left:-2px;bottom:100%;margin-bottom:4px;padding:2px 6px;font:12px/1.4 ui-monospace,monospace;color:#fff;background:#7c3aed;border-radius:3px;white-space:nowrap";
|
|
59
|
+
box.append(tag);
|
|
60
|
+
document.body.append(box);
|
|
61
|
+
|
|
62
|
+
/** @type {Element | null} */
|
|
63
|
+
let target = null;
|
|
64
|
+
|
|
65
|
+
/** @param {Element | null} el */
|
|
66
|
+
function show(el) {
|
|
67
|
+
target = el;
|
|
68
|
+
if (!el) {
|
|
69
|
+
box.style.display = "none";
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const r = el.getBoundingClientRect();
|
|
73
|
+
Object.assign(box.style, {
|
|
74
|
+
display: "block",
|
|
75
|
+
left: `${r.left}px`,
|
|
76
|
+
top: `${r.top}px`,
|
|
77
|
+
width: `${r.width}px`,
|
|
78
|
+
height: `${r.height}px`,
|
|
79
|
+
});
|
|
80
|
+
tag.textContent = label(el);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** @param {MouseEvent} e */
|
|
84
|
+
function onMove(e) {
|
|
85
|
+
show(e.altKey ? document.elementFromPoint(e.clientX, e.clientY) : null);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @param {MouseEvent} e */
|
|
89
|
+
function onClick(e) {
|
|
90
|
+
if (!e.altKey || !target) return;
|
|
91
|
+
// Option+click on a link downloads it in Chrome, so the click is swallowed here.
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
e.stopImmediatePropagation();
|
|
94
|
+
if (e.type !== "click") return;
|
|
95
|
+
navigator.clipboard.writeText(describe(target)).then(() => {
|
|
96
|
+
tag.textContent = "Copied";
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
addEventListener("mousemove", onMove, true);
|
|
101
|
+
addEventListener("mousedown", onClick, true);
|
|
102
|
+
addEventListener("click", onClick, true);
|
|
103
|
+
addEventListener("keyup", (e) => e.key === "Alt" && show(null), true);
|
|
104
|
+
addEventListener("blur", () => show(null));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const w = /** @type {any} */ (globalThis);
|
|
108
|
+
if (typeof document !== "undefined" && !w.__heyramziGrab) {
|
|
109
|
+
w.__heyramziGrab = true;
|
|
110
|
+
if (document.body) start();
|
|
111
|
+
else addEventListener("DOMContentLoaded", start);
|
|
112
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export {
|
|
2
|
+
cn,
|
|
3
|
+
type WithElementRef,
|
|
4
|
+
type WithoutChild,
|
|
5
|
+
type WithoutChildren,
|
|
6
|
+
type WithoutChildrenOrChild,
|
|
7
|
+
} from "#src/cn.js";
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
formatDate,
|
|
11
|
+
formatCurrency,
|
|
12
|
+
formatAmount,
|
|
13
|
+
formatRelativeTime,
|
|
14
|
+
type RelativeTimeLabels,
|
|
15
|
+
} from "#src/format.js";
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
slugify,
|
|
19
|
+
generateSlug,
|
|
20
|
+
buildSearchParams,
|
|
21
|
+
parseJSON,
|
|
22
|
+
parseCommaSeparatedList,
|
|
23
|
+
matchesQuery,
|
|
24
|
+
} from "#src/string.js";
|
|
25
|
+
|
|
26
|
+
export { fetchApi } from "#src/error.js";
|
|
27
|
+
|
|
28
|
+
export { isCacheExpired } from "#src/cache.js";
|
|
29
|
+
|
|
30
|
+
export { Ok, Err, tryCatch, tryCatchAsync } from "#src/result.js";
|
|
31
|
+
export type { Result } from "#src/result.js";
|
|
32
|
+
|
|
33
|
+
export type { Logger, LogLevel, LogEntry } from "#src/logger-types.js";
|
|
34
|
+
export { setLogger, log } from "#src/logger.js";
|
|
35
|
+
|
|
36
|
+
export { debounce, throttle, sleep } from "#src/timing.js";
|
|
37
|
+
export { clamp } from "#src/math.js";
|
|
38
|
+
export { groupBy } from "#src/collection.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type LogLevel = "error" | "warn" | "info" | "debug";
|
|
2
|
+
|
|
3
|
+
export type LogEntry = {
|
|
4
|
+
level: LogLevel;
|
|
5
|
+
message: string;
|
|
6
|
+
project?: string;
|
|
7
|
+
stack?: string;
|
|
8
|
+
url?: string;
|
|
9
|
+
[key: string]: unknown;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type Logger = {
|
|
13
|
+
log(entry: LogEntry): void | Promise<void>;
|
|
14
|
+
};
|