stack/packages/shared/src/utils/strings.tsx
BilalG1 c14a9dd3d0
feat(hexclave): PR 5 — internal symbol/path/package renames + brand strings (#1547)
## Stack Auth → Hexclave rename — PR 5 (internal symbols, paths,
packages, brand strings)

PR 5 finishes the **internal / non-wire** half of the Stack→Hexclave
rename. It only touches things where nothing outside the repo depends on
the exact name: internal symbols, file/dir names, the
`@stackframe/template` package, and residual brand strings. Plan +
progress are in `HEXCLAVE-RENAME-PR5-PLAN.md`.

Every step was verified green (`pnpm typecheck` + `pnpm lint`, 28/28)
and committed as its own checkpoint, then a fan-out of review agents
audited all commits and the findings were fixed.

### What changed
- **Internal symbols** (`@hexclave/shared`, `packages/template`, apps):
`stack*`/`Stack*` → `hexclave*`/`Hexclave*` — incl.
`stackGlobalsSymbol`, the `_Stack*AppImpl` classes,
`stackAppInternalsSymbol`, `StackContext`, `getStackStripe`, etc. The
`stack*App` local-variable convention
(`stackServerApp`/`stackClientApp`/…) was renamed across 175
source/example/doc files.
- **File renames**: `hexclave-handler/provider/context.tsx`,
`backend/hexclave.tsx`, `internal-tool/hexclave.ts`,
`hexclave-app-internals.ts`.
- **Directory renames**: `lib/hexclave-app`, `hexclave-companion`,
`[...hexclave]` route segment, `skills/hexclave`,
`dashboard/src/hexclave`, and the package dirs
**`packages/{next,shared,ui,sc,cli}`** (dropping the `stack-` prefix to
match the `@hexclave/*` npm names).
- **Packages**: `@stackframe/template` → `@hexclave/template`; **deleted
`packages/init-stack`** (onboarding lives in `@hexclave/cli init`; the
published npm package is untouched).
- **Brand strings**: reworded `Stack Auth`/`Stack dashboard` prose in
code + docs-mintlify, renamed `hexclave-app.mdx`/`use-hexclave-app.mdx`
with redirects, regenerated OpenAPI, updated coupled e2e assertions;
`doctor`/`init` now prefer `hexclave.config.ts`.

### Intentionally kept (verified, not oversights)
Wire/compat identifiers (`x-stack-*` headers, `stack-*` cookies,
`STACK_*` env names, `*.stack-auth.com`, `stackauth_`, `ask_stack_auth`,
query params), public `Stack*` SDK aliases, crypto/JWT/vault
domain-separation tags, `*-brand-sentinel`s, the
`Symbol.for("StackAuth--…")` string, `_stack_sync_metadata`, Postgres
`stackframe` / docker image names, the `stack-auth-logo*.svg` (used by
the rebrand modal), and `migration.mdx` / "formerly known as Stack Auth"
notes. False positives (Phosphor `StackIcon`/`StackSimple`, `TanStack`,
`OrbStack`, `stackable`/`Stacked` charts) left alone.

### Review pass
Six review agents audited all commits. Found + fixed one real bug — a
build script (`bundle-type-definitions.ts`) hardcoded the old
`lib/stack-app` glob path (not an import, so typecheck/lint were blind),
silently emptying the dashboard AI type bundle — plus stale comments, a
dead CI env var, and stale `.gitignore`/`.dockerignore` entries.
Cross-cutting audit confirmed **zero wire-compat identifiers were
accidentally renamed**.

### ⚠️ Verification note
`typecheck` + `lint` are fully green locally. The **e2e suite was not
run** (needs a live backend+DB), so the brand-string assertion +
OpenAPI-regen changes are verified by grep/codegen only — please let CI
exercise e2e to confirm.

### Base-branch note
This branch was forked from the local-only `cl/friendly-lewin-72293f`
(not on origin, no separate PR), so this PR against `dev` also carries
that branch's ~11 preceding Hexclave-rename commits (config-file rename,
env-var dual-read, AI setup-prompt rebrand). If those should land
separately, re-parent before merge.

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Finishes the internal Stack Auth → Hexclave rename and cleans up
remaining stragglers, including dev-tool and prompt copy. All changes
are internal-only; public/wire APIs remain unchanged. Re-merged `dev`
and resolved the payments create-purchase-url conflict.

- **Refactors**
- Internal symbols: stack*/Stack* → hexclave*/Hexclave* (e.g.,
`getHexclaveServerApp` via `@/hexclave`, `getHexclaveStripe`,
`hexclaveAppInternalsSymbol`, `hexclaveSchemaInfo`, Prisma
`__hexclave_*`, `data-hexclave-handler-page`, Stripe mock
`hexclavePortPrefix`).
- Files/dirs: moved to `lib/hexclave-app`; handler route
`[...hexclave]`; backend entry `src/hexclave.tsx`; dashboard internals
`hexclave-app-internals`; companion `hexclave-companion`; dropped
`stack-` prefix across package dirs
(`packages/{shared,ui,sc,cli,next}`); workflows/emulator paths now
`packages/cli`; Quetzal codegen env at `packages/next/.env.local`.
- Packages/docs: `@stackframe/template` → `@hexclave/template`; removed
`packages/init-stack`; regenerated OpenAPI and updated docs
slugs/redirects for hexclave-app/use-hexclave-app.
- Brand strings/prompts: reworded remaining “Stack” dashboard strings to
Hexclave; updated dev-tool copy and prompts; `doctor/init` now prefer
`hexclave.config.ts`. Kept all wire-compat identifiers and public
aliases (`x-stack-*`, `stack-*` cookies, `STACK_*` env,
`*.stack-auth.com`, `Stack*` SDK names).
- Rebased/merged onto latest `dev`: retained `@hexclave/template`, kept
`src` in published files, refreshed setup-prompt imports and docs JSON,
adopted 1.0.5 version bumps, and re-merged `dev` again (resolved
`create-purchase-url` with `getHexclaveStripe`).

- **Bug Fixes**
- Restored dashboard AI type bundle by pointing the glob to
`packages/template/src/lib/hexclave-app`.
- Addressed rename leftovers: updated lingering `@/stack` imports and
CSS selector, fixed schema/meta and port-prefix expansions, and aligned
emulator commands to `packages/cli`.
- CI/build: removed a dead env var and stale ignore entries; fixed
Docker by renaming `STACK_SKIP_TEMPLATE_GENERATION` →
`HEXCLAVE_SKIP_TEMPLATE_GENERATION`.

<sup>Written for commit 3c1af3bff3.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1547?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
2026-06-03 18:57:09 -07:00

724 lines
28 KiB
TypeScript

import { findLastIndex, unique } from "./arrays";
import { HexclaveAssertionError } from "./errors";
import { filterUndefined } from "./objects";
export type Join<T extends string[], Separator extends string> =
T extends [] ? ""
: T extends [infer U extends string, ...infer Rest extends string[]]
? `${U}${Rest extends [any, ...any[]] ? `${Separator}${Join<Rest, Separator>}` : ""}`
: "<error-joining-strings>";
export function typedJoin<T extends string[], Separator extends string>(strings: T, separator: Separator): Join<T, Separator> {
return strings.join(separator) as Join<T, Separator>;
}
export function typedToLowercase<S extends string>(s: S): Lowercase<S> {
if (typeof s !== "string") throw new HexclaveAssertionError("Expected a string for typedToLowercase", { s });
return s.toLowerCase() as Lowercase<S>;
}
import.meta.vitest?.test("typedToLowercase", ({ expect }) => {
expect(typedToLowercase("")).toBe("");
expect(typedToLowercase("HELLO")).toBe("hello");
expect(typedToLowercase("Hello World")).toBe("hello world");
expect(typedToLowercase("hello")).toBe("hello");
expect(typedToLowercase("123")).toBe("123");
expect(typedToLowercase("MIXED123case")).toBe("mixed123case");
expect(typedToLowercase("Special@Chars!")).toBe("special@chars!");
expect(() => typedToLowercase(123 as any)).toThrow("Expected a string for typedToLowercase");
});
export function typedToUppercase<S extends string>(s: S): Uppercase<S> {
if (typeof s !== "string") throw new HexclaveAssertionError("Expected a string for typedToUppercase", { s });
return s.toUpperCase() as Uppercase<S>;
}
import.meta.vitest?.test("typedToUppercase", ({ expect }) => {
expect(typedToUppercase("")).toBe("");
expect(typedToUppercase("hello")).toBe("HELLO");
expect(typedToUppercase("Hello World")).toBe("HELLO WORLD");
expect(typedToUppercase("HELLO")).toBe("HELLO");
expect(typedToUppercase("123")).toBe("123");
expect(typedToUppercase("mixed123Case")).toBe("MIXED123CASE");
expect(typedToUppercase("special@chars!")).toBe("SPECIAL@CHARS!");
expect(() => typedToUppercase(123 as any)).toThrow("Expected a string for typedToUppercase");
});
export function typedCapitalize<S extends string>(s: S): Capitalize<S> {
return s.charAt(0).toUpperCase() + s.slice(1) as Capitalize<S>;
}
import.meta.vitest?.test("typedCapitalize", ({ expect }) => {
expect(typedCapitalize("")).toBe("");
expect(typedCapitalize("hello")).toBe("Hello");
expect(typedCapitalize("hello world")).toBe("Hello world");
expect(typedCapitalize("HELLO")).toBe("HELLO");
expect(typedCapitalize("123test")).toBe("123test");
expect(typedCapitalize("already Capitalized")).toBe("Already Capitalized");
expect(typedCapitalize("h")).toBe("H");
});
/**
* Compares two strings in a way that is not dependent on the current locale.
*/
export function stringCompare(a: string, b: string): number {
if (typeof a !== "string" || typeof b !== "string") throw new HexclaveAssertionError(`Expected two strings for stringCompare, found ${typeof a} and ${typeof b}`, { a, b });
const cmp = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0;
return cmp(a.toUpperCase(), b.toUpperCase()) || cmp(b, a);
}
import.meta.vitest?.test("stringCompare", ({ expect }) => {
// Equal strings
expect(stringCompare("a", "a")).toBe(0);
expect(stringCompare("", "")).toBe(0);
// Case comparison - note that this function is NOT case-insensitive
// It compares uppercase versions first, then original strings
expect(stringCompare("a", "A")).toBe(-1); // lowercase comes after uppercase
expect(stringCompare("A", "a")).toBe(1); // uppercase comes before lowercase
expect(stringCompare("abc", "ABC")).toBe(-1);
expect(stringCompare("ABC", "abc")).toBe(1);
// Different strings
expect(stringCompare("a", "b")).toBe(-1);
expect(stringCompare("b", "a")).toBe(1);
// Strings with different lengths
expect(stringCompare("abc", "abcd")).toBe(-1);
expect(stringCompare("abcd", "abc")).toBe(1);
// Strings with numbers
expect(stringCompare("a1", "a2")).toBe(-1);
expect(stringCompare("a10", "a2")).toBe(-1);
// Strings with special characters
expect(stringCompare("a", "a!")).toBe(-1);
expect(stringCompare("a!", "a")).toBe(1);
});
/**
* Returns all whitespace character at the start of the string.
*
* Uses the same definition for whitespace as `String.prototype.trim()`.
*/
export function getWhitespacePrefix(s: string): string {
return s.substring(0, s.length - s.trimStart().length);
}
import.meta.vitest?.test("getWhitespacePrefix", ({ expect }) => {
expect(getWhitespacePrefix("")).toBe("");
expect(getWhitespacePrefix("hello")).toBe("");
expect(getWhitespacePrefix(" hello")).toBe(" ");
expect(getWhitespacePrefix(" hello")).toBe(" ");
expect(getWhitespacePrefix("\thello")).toBe("\t");
expect(getWhitespacePrefix("\n hello")).toBe("\n ");
expect(getWhitespacePrefix(" ")).toBe(" ");
expect(getWhitespacePrefix(" \t\n\r")).toBe(" \t\n\r");
});
/**
* Returns all whitespace character at the end of the string.
*
* Uses the same definition for whitespace as `String.prototype.trim()`.
*/
export function getWhitespaceSuffix(s: string): string {
return s.substring(s.trimEnd().length);
}
import.meta.vitest?.test("getWhitespaceSuffix", ({ expect }) => {
expect(getWhitespaceSuffix("")).toBe("");
expect(getWhitespaceSuffix("hello")).toBe("");
expect(getWhitespaceSuffix("hello ")).toBe(" ");
expect(getWhitespaceSuffix("hello ")).toBe(" ");
expect(getWhitespaceSuffix("hello\t")).toBe("\t");
expect(getWhitespaceSuffix("hello \n")).toBe(" \n");
expect(getWhitespaceSuffix(" ")).toBe(" ");
expect(getWhitespaceSuffix(" \t\n\r")).toBe(" \t\n\r");
});
/**
* Returns a string with all empty or whitespace-only lines at the start removed.
*
* Uses the same definition for whitespace as `String.prototype.trim()`.
*/
export function trimEmptyLinesStart(s: string): string {
const lines = s.split("\n");
const firstNonEmptyLineIndex = lines.findIndex((line) => line.trim() !== "");
// If all lines are empty or whitespace-only, return an empty string
if (firstNonEmptyLineIndex === -1) return "";
return lines.slice(firstNonEmptyLineIndex).join("\n");
}
import.meta.vitest?.test("trimEmptyLinesStart", ({ expect }) => {
expect(trimEmptyLinesStart("")).toBe("");
expect(trimEmptyLinesStart("hello")).toBe("hello");
expect(trimEmptyLinesStart("\nhello")).toBe("hello");
expect(trimEmptyLinesStart("\n\nhello")).toBe("hello");
expect(trimEmptyLinesStart(" \n\t\nhello")).toBe("hello");
expect(trimEmptyLinesStart("\n\nhello\nworld")).toBe("hello\nworld");
expect(trimEmptyLinesStart("hello\n\nworld")).toBe("hello\n\nworld");
expect(trimEmptyLinesStart("hello\nworld\n")).toBe("hello\nworld\n");
expect(trimEmptyLinesStart("\n \n\nhello\n \nworld")).toBe("hello\n \nworld");
// Edge case: all lines are empty
expect(trimEmptyLinesStart("\n\n \n\t")).toBe("");
});
/**
* Returns a string with all empty or whitespace-only lines at the end removed.
*
* Uses the same definition for whitespace as `String.prototype.trim()`.
*/
export function trimEmptyLinesEnd(s: string): string {
const lines = s.split("\n");
const lastNonEmptyLineIndex = findLastIndex(lines, (line) => line.trim() !== "");
return lines.slice(0, lastNonEmptyLineIndex + 1).join("\n");
}
import.meta.vitest?.test("trimEmptyLinesEnd", ({ expect }) => {
expect(trimEmptyLinesEnd("")).toBe("");
expect(trimEmptyLinesEnd("hello")).toBe("hello");
expect(trimEmptyLinesEnd("hello\n")).toBe("hello");
expect(trimEmptyLinesEnd("hello\n\n")).toBe("hello");
expect(trimEmptyLinesEnd("hello\n \n\t")).toBe("hello");
expect(trimEmptyLinesEnd("hello\nworld\n\n")).toBe("hello\nworld");
expect(trimEmptyLinesEnd("hello\n\nworld")).toBe("hello\n\nworld");
expect(trimEmptyLinesEnd("\nhello\nworld")).toBe("\nhello\nworld");
expect(trimEmptyLinesEnd("hello\n \nworld\n\n ")).toBe("hello\n \nworld");
// Edge case: all lines are empty
expect(trimEmptyLinesEnd("\n\n \n\t")).toBe("");
});
/**
* Returns a string with all empty or whitespace-only lines trimmed at the start and end.
*
* Uses the same definition for whitespace as `String.prototype.trim()`.
*/
export function trimLines(s: string): string {
return trimEmptyLinesEnd(trimEmptyLinesStart(s));
}
import.meta.vitest?.test("trimLines", ({ expect }) => {
expect(trimLines("")).toBe("");
expect(trimLines(" ")).toBe("");
expect(trimLines(" \n ")).toBe("");
expect(trimLines(" abc ")).toBe(" abc ");
expect(trimLines("\n \nLine1\nLine2\n \n")).toBe("Line1\nLine2");
expect(trimLines("Line1\n \nLine2")).toBe("Line1\n \nLine2");
expect(trimLines(" \n \n\t")).toBe("");
expect(trimLines(" Hello World")).toBe(" Hello World");
expect(trimLines("\n")).toBe("");
expect(trimLines("\t \n\t\tLine1 \n \nLine2\t\t\n\t ")).toBe("\t\tLine1 \n \nLine2\t\t");
});
/**
* A template literal tag that returns the same string as the template literal without a tag.
*
* Useful for implementing your own template literal tags.
*/
export function templateIdentity(strings: TemplateStringsArray | readonly string[], ...values: string[]): string {
if (values.length !== strings.length - 1) throw new HexclaveAssertionError("Invalid number of values; must be one less than strings", { strings, values });
return strings.reduce((result, str, i) => result + str + (values[i] ?? ''), '');
}
import.meta.vitest?.test("templateIdentity", ({ expect }) => {
expect(templateIdentity`Hello World`).toBe("Hello World");
expect(templateIdentity`${"Hello"}`).toBe("Hello");
const greeting = "Hello";
const subject = "World";
expect(templateIdentity`${greeting}, ${subject}!`).toBe("Hello, World!");
expect(templateIdentity`${"A"}${"B"}${"C"}`).toBe("ABC");
expect(templateIdentity`Start${""}Middle${""}End`).toBe("StartMiddleEnd");
expect(templateIdentity``).toBe("");
expect(templateIdentity`Line1
Line2`).toBe("Line1\nLine2");
expect(templateIdentity(["a ", " scientific ", "gun"], "certain", "rail")).toBe("a certain scientific railgun");
expect(templateIdentity(["only one part"])).toBe("only one part");
expect(() => templateIdentity(["a ", "b", "c"], "only one")).toThrow("Invalid number of values");
expect(() => templateIdentity(["a", "b"], "x", "y")).toThrow("Invalid number of values");
});
export function deindent(code: string): string;
export function deindent(strings: TemplateStringsArray | readonly string[], ...values: any[]): string;
export function deindent(strings: string | readonly string[], ...values: any[]): string {
if (typeof strings === "string") return deindent([strings]);
return templateIdentity(...deindentTemplate(strings, ...values));
}
export function deindentTemplate(strings: TemplateStringsArray | readonly string[], ...values: any[]): [string[], ...string[]] {
if (values.length !== strings.length - 1) throw new HexclaveAssertionError("Invalid number of values; must be one less than strings", { strings, values });
const trimmedStrings = [...strings];
trimmedStrings[0] = trimEmptyLinesStart(trimmedStrings[0] + "+").slice(0, -1);
trimmedStrings[trimmedStrings.length - 1] = trimEmptyLinesEnd("+" + trimmedStrings[trimmedStrings.length - 1]).slice(1);
const indentation = trimmedStrings
.join("${SOME_VALUE}")
.split("\n")
.filter((line) => line.trim() !== "")
.map((line) => getWhitespacePrefix(line).length)
.reduce((min, current) => Math.min(min, current), Infinity);
const deindentedStrings = trimmedStrings
.map((string, stringIndex) => {
return string
.split("\n")
.map((line, lineIndex) => stringIndex !== 0 && lineIndex === 0 ? line : line.substring(indentation))
.join("\n");
});
const indentedValues = values.map((value, i) => {
const firstLineIndentation = getWhitespacePrefix(deindentedStrings[i].split("\n").at(-1)!);
return `${value}`.replaceAll("\n", `\n${firstLineIndentation}`);
});
return [deindentedStrings, ...indentedValues];
}
import.meta.vitest?.test("deindent", ({ expect }) => {
// Test with string input
expect(deindent(" hello")).toBe("hello");
expect(deindent(" hello\n world")).toBe("hello\nworld");
expect(deindent(" hello\n world")).toBe("hello\n world");
expect(deindent("\n hello\n world\n")).toBe("hello\nworld");
// Test with empty input
expect(deindent("")).toBe("");
// Test with template literal
expect(deindent`
hello
world
`).toBe("hello\nworld");
expect(deindent`
hello
world
`).toBe("hello\n world");
// Test with values
const value = "test";
expect(deindent`
hello ${value}
world
`).toBe(`hello ${value}\nworld`);
// Test with multiline values
expect(deindent`
hello
to ${"line1\n line2"}
world
`).toBe(`hello\n to line1\n line2\nworld`);
// Leading whitespace values
expect(deindent`
${" "}A
${" "}B
${" "}C
`).toBe(` A\n B\n C`);
// Trailing whitespaces (note: there are two whitespaces each after A and after C)
expect(deindent`
A
B ${" "}
C
`).toBe(`A \nB \nC `);
// Test with mixed indentation
expect(deindent`
hello
world
!
`).toBe("hello\n world\n !");
// Test error cases
expect(() => deindent(["a", "b", "c"], "too", "many", "values")).toThrow("Invalid number of values");
});
export function extractScopes(scope: string, removeDuplicates=true): string[] {
// TODO what is this for? can we move this into the OAuth code in the backend?
const trimmedString = scope.trim();
const scopesArray = trimmedString.split(/\s+/);
const filtered = scopesArray.filter(scope => scope.length > 0);
return removeDuplicates ? [...new Set(filtered)] : filtered;
}
import.meta.vitest?.test("extractScopes", ({ expect }) => {
// Test with empty string
expect(extractScopes("")).toEqual([]);
// Test with single scope
expect(extractScopes("read")).toEqual(["read"]);
// Test with multiple scopes
expect(extractScopes("read write")).toEqual(["read", "write"]);
// Test with extra whitespace
expect(extractScopes(" read write ")).toEqual(["read", "write"]);
// Test with newlines and tabs
expect(extractScopes("read\nwrite\tdelete")).toEqual(["read", "write", "delete"]);
// Test with duplicates (default behavior)
expect(extractScopes("read write read")).toEqual(["read", "write"]);
// Test with duplicates (explicitly set to remove)
expect(extractScopes("read write read", true)).toEqual(["read", "write"]);
// Test with duplicates (explicitly set to keep)
expect(extractScopes("read write read", false)).toEqual(["read", "write", "read"]);
});
export function mergeScopeStrings(...scopes: string[]): string {
// TODO what is this for? can we move this into the OAuth code in the backend?
const allScope = scopes.map((s) => extractScopes(s)).flat().join(" ");
return extractScopes(allScope).join(" ");
}
import.meta.vitest?.test("mergeScopeStrings", ({ expect }) => {
// Test with empty input
expect(mergeScopeStrings()).toBe("");
// Test with single scope string
expect(mergeScopeStrings("read write")).toBe("read write");
// Test with multiple scope strings
expect(mergeScopeStrings("read", "write")).toBe("read write");
// Test with overlapping scopes
expect(mergeScopeStrings("read write", "write delete")).toBe("read write delete");
// Test with extra whitespace
expect(mergeScopeStrings(" read write ", " delete ")).toBe("read write delete");
// Test with duplicates across strings
expect(mergeScopeStrings("read write", "write delete", "read")).toBe("read write delete");
// Test with empty strings
expect(mergeScopeStrings("read write", "", "delete")).toBe("read write delete");
});
export function escapeTemplateLiteral(s: string): string {
return s.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${");
}
import.meta.vitest?.test("escapeTemplateLiteral", ({ expect }) => {
// Test with empty string
expect(escapeTemplateLiteral("")).toBe("");
// Test with normal string (no special characters)
expect(escapeTemplateLiteral("hello world")).toBe("hello world");
// Test with backtick
const input1 = "hello `world`";
const output1 = escapeTemplateLiteral(input1);
// Verify backticks are escaped
expect(output1).toBe("hello \\`world\\`");
// Test with backslash
const input2 = "hello \\world";
const output2 = escapeTemplateLiteral(input2);
// Verify backslashes are escaped
expect(output2).toBe("hello \\\\world");
// Test with dollar sign
const input3 = "hello $world";
const output3 = escapeTemplateLiteral(input3);
// Verify dollar signs are escaped
expect(output3).toBe("hello $world");
// Test with dollar sign in interpolation
const input4 = "hello ${$world";
const output4 = escapeTemplateLiteral(input4);
// Verify dollar signs are escaped
expect(output4).toBe("hello \\${$world");
// Test with multiple special characters
const input5 = "`hello` ${world\\";
const output5 = escapeTemplateLiteral(input5);
// Verify all special characters are escaped
expect(output5).toBe("\\`hello\\` \\${world\\\\");
// Test with already escaped characters
const input6 = "\\`hello\\`";
const output6 = escapeTemplateLiteral(input6);
expect(output6).toBe("\\\\\\`hello\\\\\\`");
});
/**
* Some classes have different constructor names in different environments (eg. `Headers` is sometimes called `_Headers`,
* so we create an object of overrides to handle these cases.
*/
const nicifiableClassNameOverrides = new Map(Object.entries({
Headers,
} as Record<string, unknown>).map(([k, v]) => [v, k]));
export type Nicifiable = {
getNicifiableKeys?(): PropertyKey[],
getNicifiedObjectExtraLines?(): string[],
};
export type NicifyOptions = {
maxDepth: number,
currentIndent: string,
lineIndent: string,
multiline: boolean,
refs: Map<unknown, string>,
path: string,
parent: null | {
options: NicifyOptions,
value: unknown,
},
keyInParent: PropertyKey | null,
hideFields: PropertyKey[],
overrides: (...args: Parameters<typeof nicify>) => string | null,
};
export function nicify(
value: unknown,
options: Partial<NicifyOptions> = {},
): string {
const fullOptions: NicifyOptions = {
maxDepth: 5,
currentIndent: "",
lineIndent: " ",
multiline: true,
refs: new Map(),
path: "value",
parent: null,
overrides: () => null,
keyInParent: null,
hideFields: [],
...filterUndefined(options),
};
const {
maxDepth,
currentIndent,
lineIndent,
multiline,
refs,
path,
overrides,
hideFields,
} = fullOptions;
const nl = `\n${currentIndent}`;
const overrideResult = overrides(value, options);
if (overrideResult !== null) return overrideResult;
if (["function", "object", "symbol"].includes(typeof value) && value !== null) {
if (refs.has(value)) {
return `Ref<${refs.get(value)}>`;
}
refs.set(value, path);
}
const newOptions: NicifyOptions = {
maxDepth: maxDepth - 1,
currentIndent,
lineIndent,
multiline,
refs,
path: path + "->[unknown property]",
overrides,
parent: { value, options: fullOptions },
keyInParent: null,
hideFields: [],
};
const nestedNicify = (newValue: unknown, newPath: string, keyInParent: PropertyKey | null, options: Partial<NicifyOptions> = {}) => {
return nicify(newValue, {
...newOptions,
path: newPath,
currentIndent: currentIndent + lineIndent,
keyInParent,
...options,
});
};
switch (typeof value) {
case "boolean": case "number": {
return JSON.stringify(value);
}
case "string": {
const isDeindentable = (v: string) => deindent(v) === v && v.includes("\n");
const wrapInDeindent = (v: string) => deindent`
deindent\`
${currentIndent + lineIndent}${escapeTemplateLiteral(v).replaceAll("\n", nl + lineIndent)}
${currentIndent}\`
`;
if (isDeindentable(value)) {
return wrapInDeindent(value);
} else if (value.endsWith("\n") && isDeindentable(value.slice(0, -1))) {
return wrapInDeindent(value.slice(0, -1)) + ' + "\\n"';
} else {
return JSON.stringify(value);
}
}
case "undefined": {
return "undefined";
}
case "symbol": {
return value.toString();
}
case "bigint": {
return `${value}n`;
}
case "function": {
if (value.name) return `function ${value.name}(...) { ... }`;
return `(...) => { ... }`;
}
case "object": {
if (value === null) return "null";
if (Array.isArray(value)) {
const extraLines = getNicifiedObjectExtraLines(value);
const resValueLength = value.length + extraLines.length;
if (resValueLength === 0) return "[]"; // early return in case maxDepth <= 0
if (maxDepth <= 0) return `[...]`;
const resValues = value.map((v, i) => nestedNicify(v, `${path}[${i}]`, i));
resValues.push(...extraLines);
if (resValues.length !== resValueLength) throw new HexclaveAssertionError("nicify of object: resValues.length !== resValueLength", { value, resValues, resValueLength });
const shouldIndent = resValues.length > 4 || resValues.some(x => (resValues.length > 1 && x.length > 4) || x.includes("\n"));
if (shouldIndent) {
return `[${nl}${resValues.map(x => `${lineIndent}${x},${nl}`).join("")}]`;
} else {
return `[${resValues.join(", ")}]`;
}
}
if (value instanceof Date) {
return `Date(${nestedNicify(value.toISOString(), `${path}.toISOString()`, null)})`;
}
if (value instanceof URL) {
return `URL(${nestedNicify(value.toString(), `${path}.toString()`, null)})`;
}
if (ArrayBuffer.isView(value)) {
return `${value.constructor.name}([${value.toString()}])`;
}
if (value instanceof ArrayBuffer) {
return `ArrayBuffer [${new Uint8Array(value).toString()}]`;
}
if (value instanceof Error) {
let stack = value.stack ?? "";
const toString = value.toString();
if (!stack.startsWith(toString)) stack = `${toString}\n${stack}`; // some browsers don't include the error message in the stack, some do
stack = stack.trimEnd();
stack = stack.replace(/\n\s+/g, `\n${lineIndent}${lineIndent}`);
stack = stack.replace("\n", `\n${lineIndent}Stack:\n`);
if (Object.keys(value).length > 0) {
stack += `\n${lineIndent}Extra properties: ${nestedNicify(Object.fromEntries(Object.entries(value)), path, null)}`;
}
if (value.cause) {
stack += `\n${lineIndent}Cause:\n${lineIndent}${lineIndent}${nestedNicify(value.cause, path, null, { currentIndent: currentIndent + lineIndent + lineIndent })}`;
}
stack = stack.replaceAll("\n", `\n${currentIndent}`);
return stack;
}
const constructorName = [null, Object.prototype].includes(Object.getPrototypeOf(value)) ? null : (nicifiableClassNameOverrides.get(value.constructor) ?? value.constructor.name);
const constructorString = constructorName ? `${constructorName} ` : "";
const entries = getNicifiableEntries(value).filter(([k]) => !hideFields.includes(k));
const extraLines = [
...getNicifiedObjectExtraLines(value),
...hideFields.length > 0 ? [`<some fields may have been hidden>`] : [],
];
const resValueLength = entries.length + extraLines.length;
if (resValueLength === 0) return `${constructorString}{}`;
if (maxDepth <= 0) return `${constructorString}{ ... }`;
const resValues = entries.map(([k, v], keyIndex) => {
const keyNicified = nestedNicify(k, `Object.keys(${path})[${keyIndex}]`, null);
const keyInObjectLiteral = typeof k === "string" ? nicifyPropertyString(k) : `[${keyNicified}]`;
if (typeof v === "function" && v.name === k) {
return `${keyInObjectLiteral}(...): { ... }`;
} else {
return `${keyInObjectLiteral}: ${nestedNicify(v, `${path}[${keyNicified}]`, k)}`;
}
});
resValues.push(...extraLines);
if (resValues.length !== resValueLength) throw new HexclaveAssertionError("nicify of object: resValues.length !== resValueLength", { value, resValues, resValueLength });
const shouldIndent = resValues.length > 1 || resValues.some(x => x.includes("\n"));
if (resValues.length === 0) return `${constructorString}{}`;
if (shouldIndent) {
return `${constructorString}{${nl}${resValues.map(x => `${lineIndent}${x},${nl}`).join("")}}`;
} else {
return `${constructorString}{ ${resValues.join(", ")} }`;
}
}
default: {
return `${typeof value}<${value}>`;
}
}
}
export function replaceAll(input: string, searchValue: string, replaceValue: string): string {
if (searchValue === "") throw new HexclaveAssertionError("replaceAll: searchValue is empty");
return input.split(searchValue).join(replaceValue);
}
import.meta.vitest?.test("replaceAll", ({ expect }) => {
expect(replaceAll("hello world", "o", "x")).toBe("hellx wxrld");
expect(replaceAll("aaa", "a", "b")).toBe("bbb");
expect(replaceAll("", "a", "b")).toBe("");
expect(replaceAll("abc", "b", "")).toBe("ac");
expect(replaceAll("test.test.test", ".", "_")).toBe("test_test_test");
expect(replaceAll("a.b*c", ".", "x")).toBe("axb*c");
expect(replaceAll("a*b*c", "*", "x")).toBe("axbxc");
expect(replaceAll("hello hello", "hello", "hi")).toBe("hi hi");
});
function nicifyPropertyString(str: string) {
return JSON.stringify(str);
}
import.meta.vitest?.test("nicifyPropertyString", ({ expect }) => {
// Test valid identifiers
expect(nicifyPropertyString("validName")).toBe('"validName"');
expect(nicifyPropertyString("_validName")).toBe('"_validName"');
expect(nicifyPropertyString("valid123Name")).toBe('"valid123Name"');
// Test invalid identifiers
expect(nicifyPropertyString("123invalid")).toBe('"123invalid"');
expect(nicifyPropertyString("invalid-name")).toBe('"invalid-name"');
expect(nicifyPropertyString("invalid space")).toBe('"invalid space"');
expect(nicifyPropertyString("$invalid")).toBe('"$invalid"');
expect(nicifyPropertyString("")).toBe('""');
// Test with special characters
expect(nicifyPropertyString("property!")).toBe('"property!"');
expect(nicifyPropertyString("property.name")).toBe('"property.name"');
// Test with escaped characters
expect(nicifyPropertyString("\\")).toBe('"\\\\"');
expect(nicifyPropertyString('"')).toBe('"\\""');
});
function getNicifiableKeys(value: Nicifiable | object) {
const overridden = ("getNicifiableKeys" in value ? value.getNicifiableKeys?.bind(value) : null)?.();
if (overridden != null) return overridden;
if (value instanceof Response) {
return ['status', 'headers'];
}
const keys = Object.keys(value).sort();
return unique(keys);
}
import.meta.vitest?.test("getNicifiableKeys", ({ expect }) => {
// Test regular object
expect(getNicifiableKeys({ b: 1, a: 2, c: 3 })).toEqual(["a", "b", "c"]);
// Test empty object
expect(getNicifiableKeys({})).toEqual([]);
expect(getNicifiableKeys(new Response())).toEqual(["status", "headers"]);
// Test object with custom getNicifiableKeys
const customObject = {
a: 1,
b: 2,
getNicifiableKeys() {
return ["customKey1", "customKey2"];
}
};
expect(getNicifiableKeys(customObject)).toEqual(["customKey1", "customKey2"]);
});
function getNicifiableEntries(value: Nicifiable | object): [PropertyKey, unknown][] {
const recordLikes = [Headers];
function isRecordLike(value: unknown): value is InstanceType<typeof recordLikes[number]> {
return recordLikes.some(x => value instanceof x);
}
if (isRecordLike(value)) {
return [...value.entries()].sort(([a], [b]) => stringCompare(`${a}`, `${b}`));
}
const keys = getNicifiableKeys(value);
return keys.map((k) => [k, value[k as never]] as [PropertyKey, unknown]);
}
function getNicifiedObjectExtraLines(value: Nicifiable | object) {
return ("getNicifiedObjectExtraLines" in value ? value.getNicifiedObjectExtraLines : null)?.() ?? [];
}