Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/two-poems-sit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"arkenv": patch
---

#### Fix browser compatibility by replacing `util.styleText` with cross-platform ANSI codes

Replace Node.js `util.styleText` with cross-platform ANSI color codes to fix the "Module 'node:util' has been externalized for browser compatibility" error in browser environments. The library still maintains zero dependencies!

**Changes:**

- Replaced `node:util.styleText` with custom ANSI implementation
- Added environment detection (uses ANSI in Node, plain text in browsers)
- Respects `NO_COLOR`, `CI` environment variables, and TTY detection
- Organized utilities into `lib/` folder with comprehensive tests

```ts
// No longer throws "node:util has been externalized" error
import { createEnv } from "arkenv";

const env = createEnv({
VITE_API_URL: "string",
VITE_PORT: "number.port",
});
```
2 changes: 1 addition & 1 deletion packages/arkenv/src/create-env.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { styleText } from "node:util";
import { describe, expect, it } from "vitest";
import { createEnv } from "./create-env";
import { styleText } from "./lib/style-text";
import { type } from "./type";
import { indent } from "./utils";

Expand Down
12 changes: 9 additions & 3 deletions packages/arkenv/src/error.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { createEnv } from "./create-env";
import { ArkEnvError } from "./errors";
import { type } from "./type";

// Helper to strip ANSI color codes (ESC character code 27)
const stripAnsi = (str: string) =>
str.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"), "");

describe("createEnv + type + errors + utils integration", () => {
afterEach(() => {
vi.unstubAllEnvs();
Expand Down Expand Up @@ -127,12 +131,14 @@ describe("createEnv + type + errors + utils integration", () => {
expect(errorLine).toBeDefined();
// Indented lines should start with spaces (from indent function, default is 2 spaces)
if (errorLine) {
// Strip ANSI codes for testing
const strippedLine = stripAnsi(errorLine);
// Check that the line contains PORT
expect(errorLine).toContain("PORT");
expect(strippedLine).toContain("PORT");
// Check that the line starts with leading spaces (indentation)
expect(errorLine).toMatch(/^\s+PORT/);
expect(strippedLine).toMatch(/^\s+PORT/);
// Verify it has at least 2 spaces (default indent amount)
expect(errorLine.startsWith(" ")).toBe(true);
expect(strippedLine.startsWith(" ")).toBe(true);
}
}
});
Expand Down
2 changes: 1 addition & 1 deletion packages/arkenv/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { styleText } from "node:util";
import type { ArkErrors } from "arktype";
import { styleText } from "./lib/style-text";
import { indent } from "./utils";

/**
Expand Down
17 changes: 17 additions & 0 deletions packages/arkenv/src/lib/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Library Utilities

This directory contains reusable utility libraries preconfigured for the arkenv application.

These are internal utilities that provide cross-platform functionality and common patterns used throughout the codebase.

## Contents

- **`style-text.ts`** - Cross-platform text styling utility that uses ANSI colors in Node environments and falls back to plain text in browsers.

## Philosophy

- **Zero dependencies** - All utilities are self-contained
- **Cross-platform** - Works in Node, browsers, Bun, Deno, etc.
- **Minimal** - Focused on specific use cases
- **Tested** - Each utility has dedicated unit tests

174 changes: 174 additions & 0 deletions packages/arkenv/src/lib/style-text.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { styleText } from "./style-text";

describe("styleText", () => {
describe("in Node environment", () => {
beforeEach(() => {
// Ensure we're in a Node environment with TTY and no color-disabling env vars
vi.stubGlobal("process", {
versions: { node: "22.0.0" },
env: {},
stdout: { isTTY: true },
});
});

afterEach(() => {
vi.unstubAllGlobals();
});

it("should apply red ANSI color code", () => {
const result = styleText("red", "error message");
expect(result).toBe("\x1b[31merror message\x1b[0m");
});

it("should apply yellow ANSI color code", () => {
const result = styleText("yellow", "warning message");
expect(result).toBe("\x1b[33mwarning message\x1b[0m");
});

it("should apply cyan ANSI color code", () => {
const result = styleText("cyan", "info message");
expect(result).toBe("\x1b[36minfo message\x1b[0m");
});

it("should handle empty strings", () => {
const result = styleText("red", "");
expect(result).toBe("\x1b[31m\x1b[0m");
});

it("should handle special characters", () => {
const result = styleText("yellow", "test\n\ttab");
expect(result).toBe("\x1b[33mtest\n\ttab\x1b[0m");
});

it("should handle unicode characters", () => {
const result = styleText("cyan", "🎉 success");
expect(result).toBe("\x1b[36m🎉 success\x1b[0m");
});
});

describe("in browser environment", () => {
beforeEach(() => {
// Simulate browser environment (no process.versions.node)
vi.stubGlobal("process", undefined);
});

afterEach(() => {
vi.unstubAllGlobals();
});

it("should return plain text for red", () => {
const result = styleText("red", "error message");
expect(result).toBe("error message");
});

it("should return plain text for yellow", () => {
const result = styleText("yellow", "warning message");
expect(result).toBe("warning message");
});

it("should return plain text for cyan", () => {
const result = styleText("cyan", "info message");
expect(result).toBe("info message");
});

it("should return plain text for empty strings", () => {
const result = styleText("red", "");
expect(result).toBe("");
});

it("should preserve special characters", () => {
const result = styleText("yellow", "test\n\ttab");
expect(result).toBe("test\n\ttab");
});

it("should preserve unicode characters", () => {
const result = styleText("cyan", "🎉 success");
expect(result).toBe("🎉 success");
});
});

describe("edge cases", () => {
it("should handle process.versions being null", () => {
vi.stubGlobal("process", { versions: null });

const result = styleText("red", "test");
expect(result).toBe("test");

vi.unstubAllGlobals();
});

it("should handle process.versions.node being null", () => {
vi.stubGlobal("process", { versions: { node: null } });

const result = styleText("red", "test");
expect(result).toBe("test");

vi.unstubAllGlobals();
});

it("should handle long text", () => {
vi.stubGlobal("process", {
versions: { node: "22.0.0" },
env: {},
stdout: { isTTY: true },
});

const longText = "a".repeat(10000);
const result = styleText("red", longText);
expect(result).toBe(`\x1b[31m${longText}\x1b[0m`);

vi.unstubAllGlobals();
});
});

describe("color disabling", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("should disable colors when NO_COLOR is set", () => {
vi.stubGlobal("process", {
versions: { node: "22.0.0" },
env: { NO_COLOR: "1" },
stdout: { isTTY: true },
});

const result = styleText("red", "test");
expect(result).toBe("test");
});

it("should disable colors when CI is set", () => {
vi.stubGlobal("process", {
versions: { node: "22.0.0" },
env: { CI: "true" },
stdout: { isTTY: true },
});

const result = styleText("red", "test");
expect(result).toBe("test");
});

it("should disable colors when not writing to TTY", () => {
vi.stubGlobal("process", {
versions: { node: "22.0.0" },
env: {},
stdout: { isTTY: false },
});

const result = styleText("red", "test");
expect(result).toBe("test");
});

it("should enable colors when all conditions are met", () => {
vi.stubGlobal("process", {
versions: { node: "22.0.0" },
env: {},
stdout: { isTTY: true },
});

const result = styleText("red", "test");
expect(result).toBe("\x1b[31mtest\x1b[0m");
});
});
});
59 changes: 59 additions & 0 deletions packages/arkenv/src/lib/style-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Cross-platform text styling utility
* Uses ANSI colors in Node environments, plain text in browsers
* Respects NO_COLOR, CI environment variables, and TTY detection
*/

// ANSI color codes for Node environments
const colors = {
red: "\x1b[31m",
yellow: "\x1b[33m",
cyan: "\x1b[36m",
reset: "\x1b[0m",
} as const;

/**
* Check if we're in a Node environment (not browser)
* Checked dynamically to allow for testing with mocked globals
*/
const isNode = (): boolean =>
typeof process !== "undefined" &&
process.versions != null &&
process.versions.node != null;

/**
* Check if colors should be disabled based on environment
* Respects NO_COLOR, CI environment variables, and TTY detection
*/
const shouldDisableColors = (): boolean => {
if (!isNode()) return true;

// Respect NO_COLOR environment variable (https://no-color.org/)
if (process.env.NO_COLOR !== undefined) return true;

// Disable colors in CI environments by default
if (process.env.CI !== undefined) return true;

// Disable colors if not writing to a TTY
if (process.stdout && !process.stdout.isTTY) return true;

return false;
};

/**
* Style text with color. Uses ANSI codes in Node, plain text in browsers.
* @param color - The color to apply
* @param text - The text to style
* @returns Styled text in Node (if colors enabled), plain text otherwise
*/
export const styleText = (
color: "red" | "yellow" | "cyan",
text: string,
): string => {
// Use ANSI colors only in Node environments with colors enabled
if (isNode() && !shouldDisableColors()) {
return `${colors[color]}${text}${colors.reset}`;
}
// Fall back to plain text in browsers or when colors are disabled
return text;
};
Loading