From f9a77c5c27aede4e5978eb55d9b7af781b680a1d Mon Sep 17 00:00:00 2001 From: JoΓ£o Augusto Costa Branco Marado Torres Date: Tue, 24 Jun 2025 12:08:41 -0300 Subject: feat!: initial commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: JoΓ£o Augusto Costa Branco Marado Torres --- src/utils/anonymous.test.ts | 130 ++++++++++++++++++++++++++++++++++++++++++++ src/utils/anonymous.ts | 25 +++++++++ src/utils/bases.test.ts | 32 +++++++++++ src/utils/bases.ts | 11 ++++ src/utils/datetime.test.ts | 63 +++++++++++++++++++++ src/utils/datetime.ts | 43 +++++++++++++++ src/utils/index.ts | 19 +++++++ src/utils/iterator.test.ts | 122 +++++++++++++++++++++++++++++++++++++++++ src/utils/iterator.ts | 52 ++++++++++++++++++ src/utils/lang.test.ts | 97 +++++++++++++++++++++++++++++++++ src/utils/lang.ts | 56 +++++++++++++++++++ 11 files changed, 650 insertions(+) create mode 100644 src/utils/anonymous.test.ts create mode 100644 src/utils/anonymous.ts create mode 100644 src/utils/bases.test.ts create mode 100644 src/utils/bases.ts create mode 100644 src/utils/datetime.test.ts create mode 100644 src/utils/datetime.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/iterator.test.ts create mode 100644 src/utils/iterator.ts create mode 100644 src/utils/lang.test.ts create mode 100644 src/utils/lang.ts (limited to 'src/utils') diff --git a/src/utils/anonymous.test.ts b/src/utils/anonymous.test.ts new file mode 100644 index 0000000..2da613f --- /dev/null +++ b/src/utils/anonymous.test.ts @@ -0,0 +1,130 @@ +import { assert, assertEquals, assertFalse } from "@std/assert"; +import { describe, it } from "@std/testing/bdd"; +import { + defined, + equal, + extremeBy, + get, + getCall, + identity, + instanciate, + pass, +} from "./anonymous.ts"; +import { assertSpyCalls, spy } from "@std/testing/mock"; +import { FALSE, TRUE } from "../../tests/fixtures/test_data.ts"; + +describe("identity", () => { + it("returns the same value", () => { + assertEquals(identity(42), 42); + assertEquals(identity("hello"), "hello"); + const obj = { a: 1 }; + assertEquals(identity(obj), obj); + }); +}); + +describe("defined", () => { + it("returns true for non-null/undefined values", () => { + assert(defined(0)); + assert(defined("")); + const FALSE = false; + assert(defined(FALSE)); + }); + + it("returns false for null and undefined", () => { + assertFalse(defined(undefined)); + assertFalse(defined(null)); + }); +}); + +describe("instanciate", () => { + class MyClass { + constructor(public value: number) {} + } + + it("creates a new instance with the given argument", () => { + const create = instanciate(MyClass); + const instance = create(10); + assert(instance instanceof MyClass); + assertEquals(instance.value, 10); + }); +}); + +describe("get", () => { + it("returns the value at the specified key", () => { + const obj = { a: 123, b: "hello" }; + const getA = get("a"); + const getB = get("b"); + + assertEquals(getA(obj), 123); + assertEquals(getB(obj), "hello"); + }); +}); + +describe("getCall", () => { + it("returns the return value at the specified key", () => { + const obj = { a: () => "a", b: (c: unknown) => c }; + const getA = getCall("a"); + const getB = getCall("b", "d"); + + assertEquals(getA(obj), "a"); + assertEquals(getB(obj), "d"); + }); +}); + +describe("pass", () => { + it("calls the given function and returns the input", () => { + let a: number | null = null; + const f = spy((x: number) => a = x); + + const result = pass(f)(5); + assertSpyCalls(f, 1); + assertEquals(f.calls[0].args[0], 5); + assertEquals(result, 5); + assertEquals(a, 5); + }); +}); + +describe("equal", () => { + it("returns true when primitive values are strictly equal", () => { + const isFive = equal(5); + assert(isFive(5)); + assertFalse(isFive(6)); + + const isHello = equal("hello"); + assert(isHello("hello")); + assertFalse(isHello("world")); + }); + + it("returns true only for same object reference", () => { + const obj = { a: 1 }; + const isObj = equal(obj); + assert(isObj(obj)); + assertFalse(isObj({ a: 1 })); + }); + + it("handles boolean values correctly", () => { + const isTrue = equal(TRUE); + assert(isTrue(TRUE)); + assertFalse(isTrue(FALSE)); + }); +}); + +describe("extremeBy", () => { + it("returns the maximum value from projected numbers", () => { + const data = [1, 3, 2]; + const result = extremeBy(data, "max"); + assertEquals(result, 3); + }); + + it("returns the minimum value from projected numbers", () => { + const data = [10, 4, 7]; + const result = extremeBy(data, "min"); + assertEquals(result, 4); + }); + + it("returns -Infinity/Infinity for empty array", () => { + const data: number[] = []; + assertEquals(extremeBy(data, "max"), -Infinity); + assertEquals(extremeBy(data, "min"), Infinity); + }); +}); diff --git a/src/utils/anonymous.ts b/src/utils/anonymous.ts new file mode 100644 index 0000000..ddd28bd --- /dev/null +++ b/src/utils/anonymous.ts @@ -0,0 +1,25 @@ +export const identity = (x: T): T => x; +export const defined = (x: T | undefined | null): x is T => + x !== undefined && x !== null; +export const instanciate = (C: new (arg: A) => T): (arg: A) => T => { + return (arg: A): T => new C(arg); +}; +export const get = , K extends PropertyKey>( + key: K, +): (obj: T) => T[K] => +(obj: T): T[K] => obj[key]; +export const getCall = < + T extends Record unknown>, + K extends PropertyKey, +>( + key: K, + ...args: Parameters +): (obj: T) => ReturnType => +(obj: T): ReturnType => obj[key](...args) as ReturnType; +export const pass = (fn: (x: T) => void): (x: T) => T => (x: T): T => { + fn(x); + return x; +}; +export const equal = (x: T): (y: T) => boolean => (y: T): boolean => x === y; +export const extremeBy = (arr: number[], mode: "max" | "min"): number => + Math[mode](...arr); diff --git a/src/utils/bases.test.ts b/src/utils/bases.test.ts new file mode 100644 index 0000000..9341b18 --- /dev/null +++ b/src/utils/bases.test.ts @@ -0,0 +1,32 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { describe, it } from "@std/testing/bdd"; +import { bufferToBase } from "./bases.ts"; + +describe("bufferToBase", () => { + it("returns an empty string for an empty Uint8Array", () => { + assertEquals(bufferToBase(new Uint8Array([]), 16), ""); + }); + + it("converts bytes to hexadecimal (base 16)", () => { + const input = new Uint8Array([0, 1, 15, 16, 255]); + const expected = "00010f10ff"; + assertEquals(bufferToBase(input, 16), expected); + }); + + it("converts bytes to binary (base 2)", () => { + const input = new Uint8Array([255, 0, 1]); + const expected = "111111110000000000000001"; + assertEquals(bufferToBase(input, 2), expected); + }); + + it("converts bytes to octal (base 8)", () => { + const input = new Uint8Array([8, 64, 255]); + const expected = "010100377"; + assertEquals(bufferToBase(input, 8), expected); + }); + + it("throws on invalid base", () => { + assertThrows(() => bufferToBase(new Uint8Array([1, 2]), 1), RangeError); + assertThrows(() => bufferToBase(new Uint8Array([1, 2]), 37), RangeError); + }); +}); diff --git a/src/utils/bases.ts b/src/utils/bases.ts new file mode 100644 index 0000000..a610d13 --- /dev/null +++ b/src/utils/bases.ts @@ -0,0 +1,11 @@ +export const bufferToBase = (buf: Uint8Array, base = 10): string => { + if (base < 2 || base > 36) { + throw new RangeError("Base must be between 2 and 36."); + } + + const max = Math.ceil(8 / Math.log2(base)); // Math.log2(1 << 8) = 8 + + return Array.from(buf, (byte) => byte.toString(base).padStart(max, "0")).join( + "", + ); +}; diff --git a/src/utils/datetime.test.ts b/src/utils/datetime.test.ts new file mode 100644 index 0000000..dd239b2 --- /dev/null +++ b/src/utils/datetime.test.ts @@ -0,0 +1,63 @@ +import { assertEquals, assertMatch } from "@std/assert"; +import { describe, it } from "@std/testing/bdd"; +import { toIso8601Full, toIso8601FullUTC } from "./datetime.ts"; +import { FakeTime } from "@std/testing/time"; + +describe("toIso8601Full", () => { + it("formats current local time with offset", () => { + const date = new Date(); + const result = toIso8601Full(date); + + assertMatch( + result, + /^[+-]\d{6}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}(Z|[+-]\d{2}:\d{2})$/, + ); + }); + + it("handles dates before year 0 (BC)", () => { + const date = new Date(-2000, 0, 1, 0, 0, 0, 0); + const result = toIso8601Full(date); + + assertMatch(result, /^-\d{6}-01-01T00:00:00\.000(Z|[+-]\d{2}:\d{2})$/); + }); + + it("pads components correctly", () => { + const date = new Date(7, 0, 2, 3, 4, 5, 6); + const result = toIso8601Full(date); + + assertMatch(result, /^\+001907-01-02T03:04:05\.006(Z|[+-]\d{2}:\d{2})$/); + }); + + it("handles positive and negative timezone offsets", () => { + const date = new Date("2025-06-17T12:00:00Z"); + const result = toIso8601Full(date); + + assertMatch( + result, + /^[+-]\d{6}-06-17T\d{2}:\d{2}:\d{2}\.\d{3}(Z|[+-]\d{2}:\d{2})$/, + ); + }); +}); + +describe("toIso8601FullUTC", () => { + it("always formats in UTC with 'Z'", () => { + const date = new Date(Date.UTC(2025, 11, 31, 23, 59, 59, 999)); + const result = toIso8601FullUTC(date); + + assertEquals(result, "+002025-12-31T23:59:59.999Z"); + }); + + it("pads milliseconds and components correctly", () => { + const date = new Date(Date.UTC(7, 0, 2, 3, 4, 5, 6)); + const result = toIso8601FullUTC(date); + + assertEquals(result, "+001907-01-02T03:04:05.006Z"); + }); + + it("handles BC dates (negative years)", () => { + const date = new Date(Date.UTC(-44, 2, 15, 12, 0, 0, 0)); + const result = toIso8601FullUTC(date); + + assertMatch(result, /^-\d{6}-03-15T12:00:00\.000Z$/); + }); +}); diff --git a/src/utils/datetime.ts b/src/utils/datetime.ts new file mode 100644 index 0000000..3a2cd25 --- /dev/null +++ b/src/utils/datetime.ts @@ -0,0 +1,43 @@ +export function toIso8601Full(date: Date): string { + const yearN = date.getFullYear(); + const isNegativeYear = yearN <= 0; + const year = isNegativeYear ? pad(1 - yearN, 6) : pad(yearN, 6); + const signedYear = (isNegativeYear ? "-" : "+") + year; + + const month = pad(date.getMonth() + 1); + const day = pad(date.getDate()); + const hour = pad(date.getHours()); + const minute = pad(date.getMinutes()); + const second = pad(date.getSeconds()); + const ms = pad(date.getMilliseconds(), 3); + + const dateString = + `${signedYear}-${month}-${day}T${hour}:${minute}:${second}.${ms}`; + const tzOffset = -date.getTimezoneOffset(); + if (tzOffset === 0) { + return `${dateString}Z`; + } else { + const offsetSign = tzOffset > 0 ? "+" : "-"; + const offsetHours = pad(Math.floor(Math.abs(tzOffset) / 60)); + const offsetMinutes = pad(Math.abs(tzOffset) % 60); + return `${dateString}${offsetSign}${offsetHours}:${offsetMinutes}`; + } +} + +export function toIso8601FullUTC(date: Date): string { + const yearN = date.getUTCFullYear(); + const isNegativeYear = yearN <= 0; + const year = isNegativeYear ? pad(1 - yearN, 6) : pad(yearN, 6); + const signedYear = (isNegativeYear ? "-" : "+") + year; + + const month = pad(date.getUTCMonth() + 1); + const day = pad(date.getUTCDate()); + const hour = pad(date.getUTCHours()); + const minute = pad(date.getUTCMinutes()); + const second = pad(date.getUTCSeconds()); + const ms = pad(date.getUTCMilliseconds(), 3); + + return `${signedYear}-${month}-${day}T${hour}:${minute}:${second}.${ms}Z`; +} + +const pad = (num: number, len = 2) => String(Math.abs(num)).padStart(len, "0"); diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..5a083d5 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,19 @@ +import { trailingSlash } from "astro:config/client"; + +export function addForwardSlash(path: string): string { + if (trailingSlash === "always") { + return path.endsWith("/") ? path : path + "/"; + } else { + return path; + } +} + +export const enum Level { + OK, + INFO, + WARN, + DEBUG, + ERROR, +} + +export type MaybePromise = Promise | T; diff --git a/src/utils/iterator.test.ts b/src/utils/iterator.test.ts new file mode 100644 index 0000000..dda0e0a --- /dev/null +++ b/src/utils/iterator.test.ts @@ -0,0 +1,122 @@ +import { describe, it } from "@std/testing/bdd"; +import { + createAsyncIterator, + filterDuplicate, + findMapAsync, + surelyIterable, +} from "./iterator.ts"; +import { assertEquals } from "@std/assert"; + +describe("surelyIterable", () => { + it("returns the iterable as-is if input is already iterable", () => { + const input = [1, 2, 3]; + const result = surelyIterable(input); + assertEquals([...result], [1, 2, 3]); + }); + + it("wraps a non-iterable value in an array", () => { + const input = 42; + const result = surelyIterable(input); + assertEquals([...result], [42]); + }); + + it("wraps null in an array", () => { + const input = null; + const result = surelyIterable(input); + assertEquals([...result], [null]); + }); + + it("wraps undefined in an array", () => { + const input = undefined; + const result = surelyIterable(input); + assertEquals([...result], [undefined]); + }); + + it("wraps an object that is not iterable", () => { + const input = { a: 1 }; + const result = surelyIterable(input); + assertEquals([...result], [{ a: 1 }]); + }); + + it("handles a Set correctly", () => { + const input = new Set([1, 2, 3]); + const result = surelyIterable(input); + assertEquals([...result], [1, 2, 3]); + }); +}); + +describe("createAsyncIterator", () => { + it("yields resolved values in order", async () => { + const values = [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)]; + const results: number[] = []; + for await (const value of createAsyncIterator(values)) { + results.push(value); + } + assertEquals(results, [1, 2, 3]); + }); + + it("handles empty array", async () => { + const results: unknown[] = []; + for await (const value of createAsyncIterator([])) { + results.push(value); + } + assertEquals(results, []); + }); +}); + +describe("filterDuplicate", () => { + it("filters duplicate objects by key", () => { + const items = [ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + { id: 1, name: "c" }, + ]; + const result = filterDuplicate(items, (i) => i.id); + assertEquals(result.length, 2); + assertEquals(result[0].name, "a"); + assertEquals(result[1].name, "b"); + }); + + it("handles empty iterable", () => { + const result = filterDuplicate([], (x) => x); + assertEquals(result, []); + }); + + it("keeps first occurrence only", () => { + const input = [1, 2, 3, 1, 2, 4]; + const result = filterDuplicate(input, (x) => x); + assertEquals(result, [1, 2, 3, 4]); + }); +}); + +describe("findMapAsync", () => { + it("returns first successful result", async () => { + const arr = [1, 2, 3]; + const i = 2; + const result = await findMapAsync(arr, (x) => { + if (x === i) return Promise.resolve(x); + throw new Error("not found"); + }); + assertEquals(result, i); + }); + + it("returns undefined if all reject", async () => { + const arr = [1, 2]; + const result = await findMapAsync(arr, () => { + throw new Error("fail"); + }); + assertEquals(result, undefined); + }); + + it("short-circuits after first success", async () => { + const calls: number[] = []; + const arr = [1, 2, 3]; + const i = arr.length - 1; + await findMapAsync(arr, (x) => { + calls.push(x); + if (x === i) return Promise.resolve("ok"); + throw new Error("fail"); + }); + assertEquals(calls, arr.slice(0, i)); + }); +}); diff --git a/src/utils/iterator.ts b/src/utils/iterator.ts new file mode 100644 index 0000000..fa58fc9 --- /dev/null +++ b/src/utils/iterator.ts @@ -0,0 +1,52 @@ +export type MaybeIterable = T | Iterable; +export type NonEmptyArray = [T, ...T[]]; +export type AsyncYieldType = T extends AsyncGenerator ? U : never; + +export function surelyIterable(maybe: MaybeIterable): Iterable { + return typeof maybe === "object" && maybe !== null && Symbol.iterator in maybe + ? maybe + : [maybe]; +} + +export async function* createAsyncIterator( + promises: Promise[], +): AsyncGenerator { + for (const promise of promises) { + yield promise; + } +} + +export function filterDuplicate( + array: Iterable, + key: (i: T) => K, +): T[] { + const seen = new Map(); + for (const i of array) { + const id = key(i); + if (!seen.has(id)) { + seen.set(id, i); + } + } + return Array.from(seen.values()); +} + +export async function findMapAsync( + iter: Iterable, + predicate: (value: T) => Promise, +): Promise { + const arr = Array.from(iter); + + async function tryNext(index: number): Promise { + if (index >= arr.length) { + return await Promise.resolve(undefined); + } + + try { + return await predicate(arr[index]); + } catch { + return tryNext(index + 1); + } + } + + return await tryNext(0); +} diff --git a/src/utils/lang.test.ts b/src/utils/lang.test.ts new file mode 100644 index 0000000..eac5948 --- /dev/null +++ b/src/utils/lang.test.ts @@ -0,0 +1,97 @@ +import { assert, assertEquals, assertFalse } from "@std/assert"; +import { describe, it } from "@std/testing/bdd"; +import { + getFlagEmojiFromLocale, + getLanguageNameFromLocale, + isValidLocale, + LANGUAGE_DEFAULTS, +} from "./lang.ts"; + +describe("getFlagEmojiFromLocale", () => { + it("returns πŸ‡ΊπŸ‡Έ for 'en-US'", () => { + assertEquals(getFlagEmojiFromLocale("en-US"), "πŸ‡ΊπŸ‡Έ"); + }); + + it("returns πŸ‡§πŸ‡· for 'pt-BR'", () => { + assertEquals(getFlagEmojiFromLocale("pt-BR"), "πŸ‡§πŸ‡·"); + }); + + it("returns πŸ‡«πŸ‡· for 'fr-FR'", () => { + assertEquals(getFlagEmojiFromLocale("fr-FR"), "πŸ‡«πŸ‡·"); + }); + + it("uses fallback country from LANGUAGE_DEFAULTS when no region", () => { + for (const i in LANGUAGE_DEFAULTS) { + if (i in LANGUAGE_DEFAULTS) { + assertEquals( + getFlagEmojiFromLocale(i), + getFlagEmojiFromLocale( + `${i}-${LANGUAGE_DEFAULTS[i as keyof typeof LANGUAGE_DEFAULTS]}`, + ), + ); + } + } + }); + + it("returns empty string for unsupported languages", () => { + assertEquals(getFlagEmojiFromLocale("xx"), ""); + assertEquals(getFlagEmojiFromLocale("de"), ""); + }); + + it("is case-insensitive", () => { + assertEquals(getFlagEmojiFromLocale("EN-us"), "πŸ‡ΊπŸ‡Έ"); + assertEquals(getFlagEmojiFromLocale("Pt"), "πŸ‡΅πŸ‡Ή"); + }); +}); + +describe("getLanguageNameFromLocale", () => { + it("returns 'English' for 'en'", () => { + const result = getLanguageNameFromLocale("en"); + assertEquals(typeof result, "string"); + assert(result.length > 0); + }); + + it("returns '' for invalid locale", () => { + assertEquals(getLanguageNameFromLocale(new Date().toLocaleString()), ""); + }); + + it("returns name in the correct locale", () => { + const fr = getLanguageNameFromLocale("fr"); + const pt = getLanguageNameFromLocale("pt"); + + assertEquals(typeof fr, "string"); + assertEquals(typeof pt, "string"); + assert(fr.length > 0); + assert(pt.length > 0); + }); +}); + +describe("isValidLocale", () => { + it("returns true for valid simple language tags", () => { + assert(isValidLocale("en")); + assert(isValidLocale("fr")); + assert(isValidLocale("pt")); + }); + + it("returns true for valid language-region tags", () => { + assert(isValidLocale("en-US")); + assert(isValidLocale("pt-BR")); + assert(isValidLocale("fr-FR")); + }); + + it("returns true for valid locale with script", () => { + assert(isValidLocale("zh-Hant")); + assert(isValidLocale("sr-Cyrl")); + }); + + it("returns false for invalid formats", () => { + assertFalse(isValidLocale("EN_us")); + assertFalse(isValidLocale("xx-YY-ZZ")); + assertFalse(isValidLocale("123")); + assertFalse(isValidLocale("")); + }); + + it("is case-insensitive and accepts well-formed mixed cases", () => { + assert(isValidLocale("eN-uS")); + }); +}); diff --git a/src/utils/lang.ts b/src/utils/lang.ts new file mode 100644 index 0000000..2ce8fe4 --- /dev/null +++ b/src/utils/lang.ts @@ -0,0 +1,56 @@ +export const LANGUAGE_DEFAULTS = Object.freeze({ + pt: "PT", + en: "GB", + fr: "FR", +}); + +/** + * AI thought me this. + * + * Explanation: + * * Each letter in a 2-letter country code is converted to a Regional + * Indicator Symbol, which together form the emoji flag. + * * 'A'.charCodeAt(0) is 65, and 'πŸ‡¦' starts at 0x1F1E6 β†’ offset of 127397 + * (0x1F1A5). + * * So 'A' β†’ 'πŸ‡¦', 'B' β†’ 'πŸ‡§', etc. + * + * The flags are the combination of those emojis making the country code like + * Portugal -> PT. + */ +export function getFlagEmojiFromLocale(locale: string): string { + let countryCode: string | undefined; + + const parts = locale.split("-"); + const lang = parts[0].toLowerCase(); + if (parts.length === 2) { + countryCode = parts[1].toUpperCase(); + } else if (lang in LANGUAGE_DEFAULTS) { + countryCode = LANGUAGE_DEFAULTS[lang as keyof typeof LANGUAGE_DEFAULTS]; + } + + if (!countryCode) return ""; + + return [...countryCode] + .map((c) => String.fromCodePoint(c.charCodeAt(0) + 127397)) + .join(""); +} + +export function getLanguageNameFromLocale(locale: string): string { + try { + return new Intl.DisplayNames([locale], { + type: "language", + fallback: "code", + }).of(locale) ?? ""; + } catch { + return ""; + } +} + +export function isValidLocale(locale: string): boolean { + try { + Intl.getCanonicalLocales(locale); + return true; + } catch { + return false; + } +} -- cgit v1.2.3