@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
package/src/logger.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Logger, LogEntry, LogLevel } from "#src/logger-types.js";
|
|
2
|
+
|
|
3
|
+
let _logger: Logger | null = null;
|
|
4
|
+
|
|
5
|
+
/** Set the global logger. Call once at app startup. */
|
|
6
|
+
export function setLogger(logger: Logger): void {
|
|
7
|
+
_logger = logger;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Fire-and-forget log. Never throws. */
|
|
11
|
+
export function log(level: LogLevel, message: string, data?: Record<string, unknown>): void {
|
|
12
|
+
if (!_logger) return;
|
|
13
|
+
const entry: LogEntry = { level, message, ...data };
|
|
14
|
+
try {
|
|
15
|
+
const result = _logger.log(entry);
|
|
16
|
+
if (result instanceof Promise) {
|
|
17
|
+
result.catch((err: unknown) => {
|
|
18
|
+
console.error("[logs] Logger failed:", err);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
} catch (err) {
|
|
22
|
+
console.error("[logs] Logger failed:", err);
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/math.test.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, it, expect } from "vite-plus/test";
|
|
2
|
+
import { clamp } from "#src/math.ts";
|
|
3
|
+
|
|
4
|
+
describe("clamp", () => {
|
|
5
|
+
it("returns the value when within range", () => {
|
|
6
|
+
expect(clamp(5, 0, 10)).toBe(5);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("returns min when value is below min", () => {
|
|
10
|
+
expect(clamp(-5, 0, 10)).toBe(0);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns max when value is above max", () => {
|
|
14
|
+
expect(clamp(15, 0, 10)).toBe(10);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns value when equal to min", () => {
|
|
18
|
+
expect(clamp(0, 0, 10)).toBe(0);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("returns value when equal to max", () => {
|
|
22
|
+
expect(clamp(10, 0, 10)).toBe(10);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("works with negative ranges", () => {
|
|
26
|
+
expect(clamp(-5, -10, -1)).toBe(-5);
|
|
27
|
+
expect(clamp(-15, -10, -1)).toBe(-10);
|
|
28
|
+
expect(clamp(0, -10, -1)).toBe(-1);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("works with decimal values", () => {
|
|
32
|
+
expect(clamp(0.5, 0, 1)).toBe(0.5);
|
|
33
|
+
expect(clamp(1.5, 0, 1)).toBe(1);
|
|
34
|
+
expect(clamp(-0.1, 0, 1)).toBe(0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("works when min equals max", () => {
|
|
38
|
+
expect(clamp(5, 3, 3)).toBe(3);
|
|
39
|
+
expect(clamp(3, 3, 3)).toBe(3);
|
|
40
|
+
});
|
|
41
|
+
});
|
package/src/math.ts
ADDED
package/src/og/fonts.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Apps compile this source, and only a reference carries the wawoff2 types to them.
|
|
2
|
+
// oxlint-disable-next-line typescript/triple-slash-reference
|
|
3
|
+
/// <reference path="./wawoff2.d.ts" />
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { tryCatch, tryCatchAsync } from "#src/result.js";
|
|
6
|
+
import type { Font } from "satori";
|
|
7
|
+
import type { FontConfig } from "#src/og/types.js";
|
|
8
|
+
|
|
9
|
+
const cache = new Map<string, ArrayBuffer>();
|
|
10
|
+
|
|
11
|
+
function readRaw(path: string): Uint8Array | null {
|
|
12
|
+
const result = tryCatch(() => readFileSync(path));
|
|
13
|
+
return result.ok ? result.value : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
17
|
+
const out = new ArrayBuffer(bytes.byteLength);
|
|
18
|
+
new Uint8Array(out).set(bytes);
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function decodeFont(path: string): Promise<ArrayBuffer | null> {
|
|
23
|
+
const cached = cache.get(path);
|
|
24
|
+
if (cached) return cached;
|
|
25
|
+
|
|
26
|
+
const raw = readRaw(path);
|
|
27
|
+
if (!raw) return null;
|
|
28
|
+
|
|
29
|
+
const isWoff2 = path.toLowerCase().endsWith(".woff2");
|
|
30
|
+
if (!isWoff2) {
|
|
31
|
+
const buf = toArrayBuffer(raw);
|
|
32
|
+
cache.set(path, buf);
|
|
33
|
+
return buf;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const decoded = await tryCatchAsync(async () => {
|
|
37
|
+
const { decompress } = await import("wawoff2");
|
|
38
|
+
return decompress(raw);
|
|
39
|
+
});
|
|
40
|
+
if (!decoded.ok) return null;
|
|
41
|
+
const buf = toArrayBuffer(decoded.value);
|
|
42
|
+
cache.set(path, buf);
|
|
43
|
+
return buf;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Load fonts from disk for satori. Supports .ttf, .otf, and .woff2. woff2 files are decoded to TTF in memory on first load.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/*
|
|
51
|
+
* Results are cached per path for the lifetime of the process. Fonts that fail to load or decode are skipped silently.
|
|
52
|
+
*/
|
|
53
|
+
export async function loadFonts(configs: FontConfig[]): Promise<Font[]> {
|
|
54
|
+
// Decode serially: wawoff2.decompress returns a Uint8Array view into a
|
|
55
|
+
// shared WASM heap, so concurrent calls overwrite each other's output
|
|
56
|
+
// before toArrayBuffer can copy the bytes out.
|
|
57
|
+
const decoded: Font[] = [];
|
|
58
|
+
for (const config of configs) {
|
|
59
|
+
const data = await decodeFont(config.path);
|
|
60
|
+
if (!data) continue;
|
|
61
|
+
decoded.push({
|
|
62
|
+
name: config.name,
|
|
63
|
+
data,
|
|
64
|
+
weight: config.weight,
|
|
65
|
+
style: config.style ?? "normal",
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return decoded;
|
|
69
|
+
}
|
package/src/og/index.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { renderImage, MIME_TYPES } from "#src/og/renderer.js";
|
|
2
|
+
export { loadFonts } from "#src/og/fonts.js";
|
|
3
|
+
export { html, raw } from "#src/og/markup.js";
|
|
4
|
+
export type { HtmlExpression, RawMarkup, SatoriNode } from "#src/og/markup.js";
|
|
5
|
+
export type { Font, FontConfig, ImageFormat, ImageSize, RenderOptions } from "#src/og/types.js";
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
+
import { html, raw } from "#src/og/markup.js";
|
|
3
|
+
|
|
4
|
+
function serialize(node: ReturnType<typeof html>): string {
|
|
5
|
+
return JSON.stringify(node);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe("markup", () => {
|
|
9
|
+
it("interpolates text expressions as escaped text", () => {
|
|
10
|
+
const unsafe = "<script>alert(1)</script>";
|
|
11
|
+
const node = html`<div>${unsafe}</div>`;
|
|
12
|
+
expect(serialize(node)).toContain("<script>alert(1)</script>");
|
|
13
|
+
expect(serialize(node)).not.toContain("<script>");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("inserts raw() fragments as markup", () => {
|
|
17
|
+
const rects = raw(`<rect width="10" height="10"/><rect width="20" height="20"/>`);
|
|
18
|
+
const node = html`<svg>${rects}</svg>`;
|
|
19
|
+
const out = serialize(node);
|
|
20
|
+
expect(out).toContain('"rect"');
|
|
21
|
+
expect(out).not.toContain("<rect");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("inserts raw() fragments with dynamic nested SVG defs", () => {
|
|
25
|
+
const stops = raw(
|
|
26
|
+
[1, 2, 3]
|
|
27
|
+
.map(
|
|
28
|
+
(i) =>
|
|
29
|
+
`<radialGradient id="g-${i}" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#000"/></radialGradient>`,
|
|
30
|
+
)
|
|
31
|
+
.join(""),
|
|
32
|
+
);
|
|
33
|
+
const node = html`<svg><defs>${stops}</defs></svg>`;
|
|
34
|
+
const out = serialize(node);
|
|
35
|
+
expect(out).toContain('"radialGradient"');
|
|
36
|
+
expect(out).toContain("g-1");
|
|
37
|
+
expect(out).toContain("g-3");
|
|
38
|
+
expect(out).not.toContain("<radialGradient");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("skips null / undefined / false expressions", () => {
|
|
42
|
+
const node = html`<div>${null}${undefined}${false}x</div>`;
|
|
43
|
+
expect(serialize(node)).toContain("x");
|
|
44
|
+
expect(serialize(node)).not.toContain("null");
|
|
45
|
+
expect(serialize(node)).not.toContain("undefined");
|
|
46
|
+
expect(serialize(node)).not.toContain("false");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("interpolates arrays: escapes strings, inlines RawMarkup", () => {
|
|
50
|
+
const children = ["<b>escaped</b>", raw("<span>raw</span>"), 42, null];
|
|
51
|
+
const node = html`<div>${children}</div>`;
|
|
52
|
+
const out = serialize(node);
|
|
53
|
+
expect(out).toContain("<b>escaped</b>");
|
|
54
|
+
expect(out).toContain('"span"');
|
|
55
|
+
expect(out).toContain("42");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("preserves attribute-value interpolation as text", () => {
|
|
59
|
+
const color = "#abc123";
|
|
60
|
+
const node = html`<div style="color:${color};"></div>`;
|
|
61
|
+
expect(serialize(node)).toContain('"color":"#abc123"');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Type-level regression: passing the return value of html() into another
|
|
65
|
+
// html() call used to stringify to "[object Object]". The typed signature
|
|
66
|
+
// now rejects it at compile time. Enforce with @ts-expect-error.
|
|
67
|
+
it("type-rejects nested html()", () => {
|
|
68
|
+
const inner = html`<span>hi</span>`;
|
|
69
|
+
// @ts-expect-error -- nested SatoriNode is not a valid HtmlExpression
|
|
70
|
+
const _node = html`<div>${inner}</div>`;
|
|
71
|
+
void _node;
|
|
72
|
+
});
|
|
73
|
+
});
|
package/src/og/markup.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { html as satoriHtml } from "satori-html";
|
|
2
|
+
import { __unsafeHTML } from "ultrahtml";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Branded marker for pre-built HTML/SVG markup that must NOT be HTML-escaped when interpolated into an `html` tagged template.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* Produced by `raw()`. The runtime shape matches ultrahtml's `__unsafeHTML` return value so it flows through satori-html's parser untouched.
|
|
10
|
+
*/
|
|
11
|
+
export type RawMarkup = { readonly value: string; readonly [RAW_MARKUP]: true };
|
|
12
|
+
|
|
13
|
+
const RAW_MARKUP: unique symbol = Symbol.for("@heyramzi/marketing/imagegen/RawMarkup");
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Wrap a string as raw HTML/SVG markup so it bypasses HTML escaping when interpolated into `html\`...\``. Without this, any `<tag>` in an interpolated string becomes `<tag>` text.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/*
|
|
20
|
+
* Use for dynamically-built fragments (lists of `<rect>`, `<radialGradient>`, etc.). Never call with user-supplied content: there is no sanitization.
|
|
21
|
+
*/
|
|
22
|
+
export function raw(markup: string): RawMarkup {
|
|
23
|
+
const marked = __unsafeHTML(markup) as { value: string };
|
|
24
|
+
Object.defineProperty(marked, RAW_MARKUP, { value: true, enumerable: false });
|
|
25
|
+
return marked as RawMarkup;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Expression types allowed inside an `html\`...\`` template literal.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
* `string | number | boolean` is interpolated as escaped text. `RawMarkup` is interpolated as raw markup (wrap fragment strings with `raw()`). `null | undefined` is skipped.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/*
|
|
37
|
+
* Arrays of the above are concatenated.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/*
|
|
41
|
+
* Notably excludes arbitrary objects, which prevents accidentally passing the return value of a nested `html\`...\`` call, which would stringify to `[object Object]` at runtime.
|
|
42
|
+
*/
|
|
43
|
+
export type HtmlExpression =
|
|
44
|
+
| string
|
|
45
|
+
| number
|
|
46
|
+
| boolean
|
|
47
|
+
| null
|
|
48
|
+
| undefined
|
|
49
|
+
| RawMarkup
|
|
50
|
+
| ReadonlyArray<string | number | boolean | null | undefined | RawMarkup>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Satori-compatible markup object. The exact shape is an internal detail of
|
|
54
|
+
* satori-html; treat it as opaque and pass it to `renderImage`.
|
|
55
|
+
*/
|
|
56
|
+
export type SatoriNode = ReturnType<typeof satoriHtml>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Typed tagged template for building satori-compatible markup.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/*
|
|
63
|
+
* Text interpolations (`${stringVar}`) are HTML-escaped. Markup interpolations (`${raw(fragment)}`) are inserted verbatim. Nested `html\`...\`` calls aren't supported, so compose in a single tag.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/*
|
|
67
|
+
* Example: ```ts const stops = raw(items.map((s) => `<stop offset="${s.offset}"/>`).join("")); return html`<svg><defs><radialGradient>${stops}</radialGradient></defs></svg>`; ```
|
|
68
|
+
*/
|
|
69
|
+
export function html(strings: TemplateStringsArray, ...values: HtmlExpression[]): SatoriNode {
|
|
70
|
+
const flattened = values.map((value) => {
|
|
71
|
+
if (Array.isArray(value)) {
|
|
72
|
+
const parts = value.map((item) => {
|
|
73
|
+
if (item == null || item === false) return "";
|
|
74
|
+
if (isRawMarkup(item)) return item.value;
|
|
75
|
+
return escapeHtml(String(item));
|
|
76
|
+
});
|
|
77
|
+
return raw(parts.join(""));
|
|
78
|
+
}
|
|
79
|
+
return value;
|
|
80
|
+
});
|
|
81
|
+
return satoriHtml(strings, ...flattened);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isRawMarkup(value: unknown): value is RawMarkup {
|
|
85
|
+
return typeof value === "object" && value !== null && RAW_MARKUP in value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function escapeHtml(input: string): string {
|
|
89
|
+
return input.replace(/[&<>]/g, (c) => (c === "&" ? "&" : c === "<" ? "<" : ">"));
|
|
90
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import satori from "satori";
|
|
2
|
+
import sharp from "sharp";
|
|
3
|
+
import type { SatoriNode } from "#src/og/markup.js";
|
|
4
|
+
import type { ImageFormat, RenderOptions } from "#src/og/types.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Render satori-compatible markup to an image buffer.
|
|
8
|
+
*
|
|
9
|
+
* - `svg` returns the raw SVG bytes from satori.
|
|
10
|
+
* - `png` / `avif` pipe the SVG through sharp.
|
|
11
|
+
*/
|
|
12
|
+
export async function renderImage(markup: SatoriNode, options: RenderOptions): Promise<Uint8Array> {
|
|
13
|
+
const format: ImageFormat = options.format ?? "png";
|
|
14
|
+
|
|
15
|
+
// satori types its input as React's ReactNode but reads only `{ type, props }`, which is what
|
|
16
|
+
// satori-html's `html` returns. This cast is where the two types disagree.
|
|
17
|
+
const svg = await satori(markup as Parameters<typeof satori>[0], {
|
|
18
|
+
width: options.size.width,
|
|
19
|
+
height: options.size.height,
|
|
20
|
+
fonts: options.fonts,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (format === "svg") {
|
|
24
|
+
return new TextEncoder().encode(svg);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const pipeline = sharp(Buffer.from(svg));
|
|
28
|
+
|
|
29
|
+
if (format === "avif") {
|
|
30
|
+
const buffer = await pipeline.avif({ quality: options.quality ?? 82 }).toBuffer();
|
|
31
|
+
return new Uint8Array(buffer);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const buffer = await pipeline.png({ quality: options.quality ?? 90 }).toBuffer();
|
|
35
|
+
return new Uint8Array(buffer);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const MIME_TYPES: Record<ImageFormat, string> = {
|
|
39
|
+
svg: "image/svg+xml",
|
|
40
|
+
png: "image/png",
|
|
41
|
+
avif: "image/avif",
|
|
42
|
+
};
|
package/src/og/types.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Font } from "satori";
|
|
2
|
+
|
|
3
|
+
export type { Font };
|
|
4
|
+
|
|
5
|
+
export type ImageSize = {
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type ImageFormat = "svg" | "png" | "avif";
|
|
11
|
+
|
|
12
|
+
export type FontConfig = {
|
|
13
|
+
name: string;
|
|
14
|
+
/** Path to .ttf, .otf, or .woff2 file. woff2 is decoded to ttf at load time. */
|
|
15
|
+
path: string;
|
|
16
|
+
weight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
|
|
17
|
+
style?: "normal" | "italic";
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type RenderOptions = {
|
|
21
|
+
fonts: Font[];
|
|
22
|
+
size: ImageSize;
|
|
23
|
+
format?: ImageFormat;
|
|
24
|
+
/** PNG/AVIF compression quality, 1 to 100. Defaults to 90 for PNG, 82 for AVIF. */
|
|
25
|
+
quality?: number;
|
|
26
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
declare module "wawoff2" {
|
|
2
|
+
/** Decompress WOFF2 bytes to a TTF Uint8Array. */
|
|
3
|
+
export function decompress(buffer: Uint8Array | Buffer): Promise<Uint8Array>;
|
|
4
|
+
/** Compress TTF bytes to WOFF2. */
|
|
5
|
+
export function compress(buffer: Uint8Array | Buffer): Promise<Uint8Array>;
|
|
6
|
+
}
|
|
@@ -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
|
+
}
|