@heyramzi/utils 0.0.0-stage → 1.13.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/dist/ai/image.js +220 -0
- package/dist/ai/models.js +261 -0
- package/dist/analytics/client.js +57 -0
- package/dist/analytics/server.js +54 -0
- package/dist/analytics/svelte.js +24 -0
- package/dist/analytics/types.js +0 -0
- package/dist/analytics/umami/protocol.js +35 -0
- package/dist/analytics/umami/reporting.js +189 -0
- package/dist/auth/config/otp.js +4 -0
- package/dist/auth/config/rate-limits.js +20 -0
- package/dist/auth/config/trusted-origins.js +15 -0
- package/dist/auth/email/index.js +3 -0
- package/dist/auth/email/layout.js +84 -0
- package/dist/auth/email/resend.js +30 -0
- package/dist/auth/email/template.js +57 -0
- package/dist/auth/email/types.js +0 -0
- package/dist/auth/index.js +3 -0
- package/dist/cache.js +4 -0
- package/dist/cn.js +5 -0
- package/dist/collection.js +8 -0
- package/dist/crypto.js +117 -0
- package/dist/error.js +15 -0
- package/dist/errors/sentry/index.js +15 -0
- package/dist/errors/sentry/protocol.js +130 -0
- package/dist/errors/sentry/sveltekit.js +145 -0
- package/dist/format.js +62 -0
- package/dist/grab.js +112 -0
- package/dist/index.js +10 -0
- package/dist/logger-types.js +0 -0
- package/dist/logger.js +22 -0
- package/dist/math.js +3 -0
- package/dist/og/fonts.js +46 -0
- package/dist/og/index.js +3 -0
- package/dist/og/markup.js +31 -0
- package/dist/og/renderer.js +29 -0
- package/dist/og/types.js +0 -0
- package/dist/result.js +26 -0
- package/dist/string.js +32 -0
- package/dist/testimonials/render-content.js +15 -0
- package/dist/testimonials/server.js +21 -0
- package/dist/testimonials/types.js +0 -0
- package/dist/timing.js +20 -0
- package/dist/vite.js +265 -0
- package/package.json +201 -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 +149 -0
- package/src/errors/sentry/sveltekit.ts +226 -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,22 @@
|
|
|
1
|
+
import { expect, test } from "vite-plus/test";
|
|
2
|
+
import { packageImportsOverKitAlias } from "#src/vite.js";
|
|
3
|
+
|
|
4
|
+
test("package imports survive Kit aliases without removing unrelated aliases", () => {
|
|
5
|
+
const aliases = [
|
|
6
|
+
{ find: /^#src\/(.+)$/, replacement: "/app/src/$1" },
|
|
7
|
+
{ find: /^#scripts\/(.+)$/, replacement: "/app/scripts/$1" },
|
|
8
|
+
{ find: /^\$lib\/(.+)$/, replacement: "/app/lib/$1" },
|
|
9
|
+
{ find: "#src-extra", replacement: "/extra" },
|
|
10
|
+
];
|
|
11
|
+
const config = { resolve: { alias: aliases } };
|
|
12
|
+
packageImportsOverKitAlias().config(config);
|
|
13
|
+
expect(config.resolve.alias).toEqual(aliases.slice(2));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("missing aliases and object aliases remain usable", () => {
|
|
17
|
+
const plugin = packageImportsOverKitAlias();
|
|
18
|
+
plugin.config({});
|
|
19
|
+
const config = { resolve: { alias: { $lib: "/lib" } } };
|
|
20
|
+
plugin.config(config);
|
|
21
|
+
expect(config.resolve.alias).toEqual({ $lib: "/lib" });
|
|
22
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, it, expect } from "vite-plus/test";
|
|
2
|
+
import { Ok, Err, tryCatch, tryCatchAsync } from "#src/result.ts";
|
|
3
|
+
|
|
4
|
+
describe("Ok", () => {
|
|
5
|
+
it("creates a success result with ok: true", () => {
|
|
6
|
+
const result = Ok(42);
|
|
7
|
+
expect(result.ok).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("holds the provided value", () => {
|
|
11
|
+
const result = Ok("hello");
|
|
12
|
+
if (result.ok) {
|
|
13
|
+
expect(result.value).toBe("hello");
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("works with object values", () => {
|
|
18
|
+
const data = { id: 1, name: "test" };
|
|
19
|
+
const result = Ok(data);
|
|
20
|
+
if (result.ok) {
|
|
21
|
+
expect(result.value).toEqual(data);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("works with null value", () => {
|
|
26
|
+
const result = Ok(null);
|
|
27
|
+
if (result.ok) {
|
|
28
|
+
expect(result.value).toBeNull();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("Err", () => {
|
|
34
|
+
it("creates a failure result with ok: false", () => {
|
|
35
|
+
const result = Err(new Error("boom"));
|
|
36
|
+
expect(result.ok).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("holds the provided error", () => {
|
|
40
|
+
const err = new Error("something failed");
|
|
41
|
+
const result = Err(err);
|
|
42
|
+
if (!result.ok) {
|
|
43
|
+
expect(result.error).toBe(err);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("works with string errors", () => {
|
|
48
|
+
const result = Err("custom error string");
|
|
49
|
+
if (!result.ok) {
|
|
50
|
+
expect(result.error).toBe("custom error string");
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("works with custom error objects", () => {
|
|
55
|
+
const result = Err({ code: 404, message: "Not found" });
|
|
56
|
+
if (!result.ok) {
|
|
57
|
+
expect(result.error).toEqual({ code: 404, message: "Not found" });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("tryCatch", () => {
|
|
63
|
+
it("returns Ok when function succeeds", () => {
|
|
64
|
+
const result = tryCatch(() => 42);
|
|
65
|
+
expect(result.ok).toBe(true);
|
|
66
|
+
if (result.ok) {
|
|
67
|
+
expect(result.value).toBe(42);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns Err when function throws an Error", () => {
|
|
72
|
+
const result = tryCatch(() => {
|
|
73
|
+
throw new Error("fail");
|
|
74
|
+
});
|
|
75
|
+
expect(result.ok).toBe(false);
|
|
76
|
+
if (!result.ok) {
|
|
77
|
+
expect(result.error.message).toBe("fail");
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("wraps a thrown non-Error value in an Error", () => {
|
|
82
|
+
const result = tryCatch(() => {
|
|
83
|
+
throw "string error";
|
|
84
|
+
});
|
|
85
|
+
expect(result.ok).toBe(false);
|
|
86
|
+
if (!result.ok) {
|
|
87
|
+
expect(result.error).toBeInstanceOf(Error);
|
|
88
|
+
expect(result.error.message).toBe("string error");
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("wraps a thrown number in an Error", () => {
|
|
93
|
+
const result = tryCatch(() => {
|
|
94
|
+
throw 42;
|
|
95
|
+
});
|
|
96
|
+
expect(result.ok).toBe(false);
|
|
97
|
+
if (!result.ok) {
|
|
98
|
+
expect(result.error).toBeInstanceOf(Error);
|
|
99
|
+
expect(result.error.message).toBe("42");
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("works with a function returning an object", () => {
|
|
104
|
+
const result = tryCatch(() => ({ a: 1, b: 2 }));
|
|
105
|
+
expect(result.ok).toBe(true);
|
|
106
|
+
if (result.ok) {
|
|
107
|
+
expect(result.value).toEqual({ a: 1, b: 2 });
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("tryCatchAsync", () => {
|
|
113
|
+
it("returns Ok when async function resolves", async () => {
|
|
114
|
+
const result = await tryCatchAsync(async () => "resolved");
|
|
115
|
+
expect(result.ok).toBe(true);
|
|
116
|
+
if (result.ok) {
|
|
117
|
+
expect(result.value).toBe("resolved");
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns Err when async function rejects with an Error", async () => {
|
|
122
|
+
const result = await tryCatchAsync(async () => {
|
|
123
|
+
throw new Error("async fail");
|
|
124
|
+
});
|
|
125
|
+
expect(result.ok).toBe(false);
|
|
126
|
+
if (!result.ok) {
|
|
127
|
+
expect(result.error.message).toBe("async fail");
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("wraps a rejected non-Error value in an Error", async () => {
|
|
132
|
+
const result = await tryCatchAsync(async () => {
|
|
133
|
+
throw "rejected string";
|
|
134
|
+
});
|
|
135
|
+
expect(result.ok).toBe(false);
|
|
136
|
+
if (!result.ok) {
|
|
137
|
+
expect(result.error).toBeInstanceOf(Error);
|
|
138
|
+
expect(result.error.message).toBe("rejected string");
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("works with async functions returning objects", async () => {
|
|
143
|
+
const result = await tryCatchAsync(async () => ({ data: [1, 2, 3] }));
|
|
144
|
+
expect(result.ok).toBe(true);
|
|
145
|
+
if (result.ok) {
|
|
146
|
+
expect(result.value).toEqual({ data: [1, 2, 3] });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
package/src/result.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Lightweight Result type for typed error handling
|
|
2
|
+
|
|
3
|
+
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
|
|
4
|
+
|
|
5
|
+
export function Ok<T>(value: T): Result<T, never> {
|
|
6
|
+
return { ok: true, value };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function Err<E>(error: E): Result<never, E> {
|
|
10
|
+
return { ok: false, error };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Wrap a synchronous function that might throw
|
|
14
|
+
export function tryCatch<T>(fn: () => T): Result<T> {
|
|
15
|
+
try {
|
|
16
|
+
return Ok(fn());
|
|
17
|
+
} catch (e) {
|
|
18
|
+
return Err(e instanceof Error ? e : new Error(String(e)));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Wrap an async function that might throw
|
|
23
|
+
export async function tryCatchAsync<T>(fn: () => Promise<T>): Promise<Result<T>> {
|
|
24
|
+
try {
|
|
25
|
+
return Ok(await fn());
|
|
26
|
+
} catch (e) {
|
|
27
|
+
return Err(e instanceof Error ? e : new Error(String(e)));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, it, expect } from "vite-plus/test";
|
|
2
|
+
import {
|
|
3
|
+
slugify,
|
|
4
|
+
generateSlug,
|
|
5
|
+
buildSearchParams,
|
|
6
|
+
parseJSON,
|
|
7
|
+
parseCommaSeparatedList,
|
|
8
|
+
matchesQuery,
|
|
9
|
+
} from "#src/string.ts";
|
|
10
|
+
|
|
11
|
+
describe("slugify", () => {
|
|
12
|
+
it("lowercases the text", () => {
|
|
13
|
+
expect(slugify("Hello World")).toBe("hello-world");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("replaces spaces with hyphens", () => {
|
|
17
|
+
expect(slugify("foo bar baz")).toBe("foo-bar-baz");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("removes special characters", () => {
|
|
21
|
+
expect(slugify("Hello, World!")).toBe("hello-world");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("collapses multiple spaces into a single hyphen", () => {
|
|
25
|
+
expect(slugify("foo bar")).toBe("foo-bar");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("collapses multiple hyphens into one", () => {
|
|
29
|
+
expect(slugify("foo---bar")).toBe("foo-bar");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("handles an empty string", () => {
|
|
33
|
+
expect(slugify("")).toBe("");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("preserves existing hyphens", () => {
|
|
37
|
+
expect(slugify("hello-world")).toBe("hello-world");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("generateSlug", () => {
|
|
42
|
+
it("lowercases text", () => {
|
|
43
|
+
expect(generateSlug("Hello World")).toBe("hello-world");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("replaces non-alphanumeric characters with hyphens", () => {
|
|
47
|
+
expect(generateSlug("Hello, World!")).toBe("hello-world");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("strips leading and trailing hyphens", () => {
|
|
51
|
+
expect(generateSlug("!hello world!")).toBe("hello-world");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("collapses runs of non-alphanumeric characters to a single hyphen", () => {
|
|
55
|
+
expect(generateSlug("foo --- bar")).toBe("foo-bar");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("handles an empty string", () => {
|
|
59
|
+
expect(generateSlug("")).toBe("");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("handles strings that are all special characters", () => {
|
|
63
|
+
expect(generateSlug("!!!")).toBe("");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("preserves numbers", () => {
|
|
67
|
+
expect(generateSlug("Article 42")).toBe("article-42");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("buildSearchParams", () => {
|
|
72
|
+
it("returns empty string when no params", () => {
|
|
73
|
+
expect(buildSearchParams({})).toBe("");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("omits undefined values", () => {
|
|
77
|
+
expect(buildSearchParams({ a: "1", b: undefined })).toBe("?a=1");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns empty string when all values are undefined", () => {
|
|
81
|
+
expect(buildSearchParams({ a: undefined, b: undefined })).toBe("");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("builds a query string from a single param", () => {
|
|
85
|
+
expect(buildSearchParams({ page: "1" })).toBe("?page=1");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("builds a query string from multiple params", () => {
|
|
89
|
+
const result = buildSearchParams({ page: "1", limit: "10" });
|
|
90
|
+
expect(result).toBe("?page=1&limit=10");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("URL-encodes values with special characters", () => {
|
|
94
|
+
const result = buildSearchParams({ q: "hello world" });
|
|
95
|
+
expect(result).toBe("?q=hello%20world");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("encodes special characters in values", () => {
|
|
99
|
+
const result = buildSearchParams({ filter: "name=test" });
|
|
100
|
+
expect(result).toContain("filter=");
|
|
101
|
+
expect(result).toContain("name");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("parseJSON", () => {
|
|
106
|
+
it("parses a valid JSON string", () => {
|
|
107
|
+
expect(parseJSON('{"key":"value"}')).toEqual({ key: "value" });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns undefined for invalid JSON", () => {
|
|
111
|
+
expect(parseJSON("not json")).toBeUndefined();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns undefined for an empty string", () => {
|
|
115
|
+
expect(parseJSON("")).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("parses a JSON array", () => {
|
|
119
|
+
expect(parseJSON("[1,2,3]")).toEqual([1, 2, 3]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("parses a JSON number", () => {
|
|
123
|
+
expect(parseJSON("42")).toBe(42);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("parses a JSON boolean", () => {
|
|
127
|
+
expect(parseJSON("true")).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("returns undefined for truncated JSON", () => {
|
|
131
|
+
expect(parseJSON('{"key":')).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("accepts a type parameter for typed output", () => {
|
|
135
|
+
type User = { name: string; age: number };
|
|
136
|
+
const result = parseJSON<User>('{"name":"Alice","age":30}');
|
|
137
|
+
expect(result).toEqual({ name: "Alice", age: 30 });
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("parseCommaSeparatedList", () => {
|
|
142
|
+
it("splits a comma-separated string into an array", () => {
|
|
143
|
+
expect(parseCommaSeparatedList("a,b,c")).toEqual(["a", "b", "c"]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("trims whitespace from each item", () => {
|
|
147
|
+
expect(parseCommaSeparatedList("a , b , c")).toEqual(["a", "b", "c"]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("filters out empty items", () => {
|
|
151
|
+
expect(parseCommaSeparatedList("a,,b")).toEqual(["a", "b"]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("returns an empty array for undefined", () => {
|
|
155
|
+
expect(parseCommaSeparatedList(undefined)).toEqual([]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("returns an empty array for an empty string", () => {
|
|
159
|
+
expect(parseCommaSeparatedList("")).toEqual([]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("returns a single-item array for a string with no commas", () => {
|
|
163
|
+
expect(parseCommaSeparatedList("hello")).toEqual(["hello"]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("handles commas at the start and end", () => {
|
|
167
|
+
expect(parseCommaSeparatedList(",a,b,")).toEqual(["a", "b"]);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("matchesQuery", () => {
|
|
172
|
+
it("matches a plain substring", () => {
|
|
173
|
+
expect(matchesQuery("Agency glue post 001", "glue")).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("matches every word wherever it sits, not one run of characters", () => {
|
|
177
|
+
expect(matchesQuery("Agency glue post 001", "agency post")).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("rejects a query whose second word is absent", () => {
|
|
181
|
+
expect(matchesQuery("Agency glue post 001", "agency draft")).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("ignores case", () => {
|
|
185
|
+
expect(matchesQuery("Agency Glue", "AGENCY glue")).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("ignores accents on both sides", () => {
|
|
189
|
+
expect(matchesQuery("São Paulo", "sao")).toBe(true);
|
|
190
|
+
expect(matchesQuery("Sao Paulo", "são")).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("matches everything on a blank query", () => {
|
|
194
|
+
expect(matchesQuery("anything", "")).toBe(true);
|
|
195
|
+
expect(matchesQuery("anything", " ")).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("ignores the spacing around the words", () => {
|
|
199
|
+
expect(matchesQuery("Agency glue post", " post agency ")).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("finds nothing in an empty haystack", () => {
|
|
203
|
+
expect(matchesQuery("", "post")).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
});
|
package/src/string.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** Convert text to URL-safe slug */
|
|
2
|
+
export function slugify(text: string): string {
|
|
3
|
+
return text
|
|
4
|
+
.toLowerCase()
|
|
5
|
+
.replace(/[^\w\s-]/g, "")
|
|
6
|
+
.replace(/\s+/g, "-")
|
|
7
|
+
.replace(/-+/g, "-")
|
|
8
|
+
.trim();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Generate a URL-safe slug (strips non-alphanumeric, trims hyphens) */
|
|
12
|
+
export function generateSlug(text: string): string {
|
|
13
|
+
return text
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
16
|
+
.replace(/^-|-$/g, "");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Build query string from params, omitting undefined values */
|
|
20
|
+
export function buildSearchParams(params: Record<string, string | undefined>): string {
|
|
21
|
+
const entries = Object.entries(params).filter(
|
|
22
|
+
(entry): entry is [string, string] => entry[1] !== undefined,
|
|
23
|
+
);
|
|
24
|
+
if (entries.length === 0) return "";
|
|
25
|
+
return `?${entries.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&")}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Safely parse JSON string, returning undefined on failure */
|
|
29
|
+
export function parseJSON<T = unknown>(raw: string): T | undefined {
|
|
30
|
+
if (!raw) return undefined;
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(raw) as T;
|
|
33
|
+
} catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Split comma-separated string into trimmed, non-empty array */
|
|
39
|
+
export function parseCommaSeparatedList(text: string | undefined): string[] {
|
|
40
|
+
if (!text) return [];
|
|
41
|
+
return text
|
|
42
|
+
.split(",")
|
|
43
|
+
.map((s) => s.trim())
|
|
44
|
+
.filter(Boolean);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Lowercase and strip accents, so a search for "sao" reaches "São". */
|
|
48
|
+
function foldForSearch(text: string): string {
|
|
49
|
+
return text
|
|
50
|
+
.normalize("NFD")
|
|
51
|
+
.replace(/\p{Diacritic}/gu, "")
|
|
52
|
+
.toLowerCase();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* True when every word of `query` appears somewhere in `haystack`, accents and case ignored. An empty or blank query matches everything.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/*
|
|
60
|
+
* WHY word by word and not one substring test: a list page joins the fields it searches into one string, so a plain `includes` makes the field order part of
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/*
|
|
64
|
+
* the query. On a set titled "Agency glue post 001", typing "agency post" matched nothing, which reads as the search being broken rather than strict.
|
|
65
|
+
*/
|
|
66
|
+
export function matchesQuery(haystack: string, query: string): boolean {
|
|
67
|
+
const words = foldForSearch(query).split(/\s+/).filter(Boolean);
|
|
68
|
+
if (words.length === 0) return true;
|
|
69
|
+
const target = foldForSearch(haystack);
|
|
70
|
+
return words.every((word) => target.includes(word));
|
|
71
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Safe converter for testimonial content stored with **bold** markdown.
|
|
2
|
+
// Escapes HTML, then replaces **...** with <strong>...</strong>.
|
|
3
|
+
// Output is intended for {@html ...} or dangerouslySetInnerHTML.
|
|
4
|
+
|
|
5
|
+
const HTML_ESCAPES: Record<string, string> = {
|
|
6
|
+
"&": "&",
|
|
7
|
+
"<": "<",
|
|
8
|
+
">": ">",
|
|
9
|
+
'"': """,
|
|
10
|
+
"'": "'",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function escapeHtml(input: string): string {
|
|
14
|
+
return input.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch] ?? ch);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function renderTestimonialContent(content: string | null | undefined): string {
|
|
18
|
+
if (!content) return "";
|
|
19
|
+
const escaped = escapeHtml(content);
|
|
20
|
+
return escaped.replace(/\*\*([^*]+?)\*\*/g, "<strong>$1</strong>");
|
|
21
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PublicCollectionResponse,
|
|
3
|
+
PublicTestimonial,
|
|
4
|
+
CollectionStat,
|
|
5
|
+
} from "#src/testimonials/types.js";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_TESTIMONIALS_BASE_URL = "https://app.upsys-consulting.com";
|
|
8
|
+
|
|
9
|
+
export type FetchPublicTestimonialsOptions = {
|
|
10
|
+
baseUrl?: string;
|
|
11
|
+
locale?: "en" | "fr";
|
|
12
|
+
layout?: string;
|
|
13
|
+
fetchImpl?: typeof fetch;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export async function fetchPublicTestimonialCollection(
|
|
17
|
+
token: string,
|
|
18
|
+
opts: FetchPublicTestimonialsOptions = {},
|
|
19
|
+
): Promise<PublicCollectionResponse | null> {
|
|
20
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
21
|
+
const baseUrl = (opts.baseUrl ?? DEFAULT_TESTIMONIALS_BASE_URL).replace(/\/$/, "");
|
|
22
|
+
const params = new URLSearchParams();
|
|
23
|
+
if (opts.locale) params.set("locale", opts.locale);
|
|
24
|
+
if (opts.layout) params.set("layout", opts.layout);
|
|
25
|
+
const query = params.toString();
|
|
26
|
+
const url = `${baseUrl}/api/testimonials/public/${encodeURIComponent(token)}${query ? `?${query}` : ""}`;
|
|
27
|
+
|
|
28
|
+
const response = await fetchImpl(url);
|
|
29
|
+
if (!response.ok) return null;
|
|
30
|
+
return (await response.json()) as PublicCollectionResponse;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function fetchPublicTestimonials(
|
|
34
|
+
token: string,
|
|
35
|
+
opts: FetchPublicTestimonialsOptions = {},
|
|
36
|
+
): Promise<PublicTestimonial[]> {
|
|
37
|
+
const data = await fetchPublicTestimonialCollection(token, opts);
|
|
38
|
+
return data?.testimonials ?? [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function fetchPublicTestimonialStats(
|
|
42
|
+
token: string,
|
|
43
|
+
opts: FetchPublicTestimonialsOptions = {},
|
|
44
|
+
): Promise<CollectionStat[]> {
|
|
45
|
+
const data = await fetchPublicTestimonialCollection(token, opts);
|
|
46
|
+
return data?.collection.stats ?? [];
|
|
47
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type CollectionLayout = "grid" | "carousel" | "compact" | "highlight";
|
|
2
|
+
|
|
3
|
+
export type CollectionStat = {
|
|
4
|
+
number: string;
|
|
5
|
+
labelEn: string;
|
|
6
|
+
labelFr: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type PublicTestimonial = {
|
|
10
|
+
id: string;
|
|
11
|
+
authorName: string;
|
|
12
|
+
authorRole: string | null;
|
|
13
|
+
authorCompany: string | null;
|
|
14
|
+
authorPhotoUrl: string | null;
|
|
15
|
+
content: string;
|
|
16
|
+
/** Curated pull-quote for surfaces with room for a line or two. Null when unset. */
|
|
17
|
+
excerpt: string | null;
|
|
18
|
+
rating: number | null;
|
|
19
|
+
videoUrl: string | null;
|
|
20
|
+
locale: "en" | "fr";
|
|
21
|
+
tags: string[];
|
|
22
|
+
isFeatured: boolean;
|
|
23
|
+
createdAt: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type PublicCollectionResponse = {
|
|
27
|
+
collection: {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
layout: CollectionLayout;
|
|
31
|
+
isPublic: boolean;
|
|
32
|
+
stats: CollectionStat[];
|
|
33
|
+
};
|
|
34
|
+
testimonials: PublicTestimonial[];
|
|
35
|
+
};
|