- {/* Terminal Preview */}
-
-
-
- {SAMPLE_LOGS.map((log, i) => (
-
- ))}
-
-
-
- Theme: {currentThemeName}
-
-
-
-
- {/* Browser Console Preview */}
-
-
-
-
- Browser Console
-
-
-
- {SAMPLE_LOGS.map((log, i) => (
-
- ))}
-
-
-
- Theme: {currentThemeName}
-
-
-
+
+
- {/* Integration Examples */}
Quick Integration
-
-
-
- {`import { getLogsDX } from 'logsdx'
+
-
-
- Theme: {currentThemeName}
-
-
-
-
-
-
-
-
- Auto Theme Detection
-
-
-
- {`import { getLogsDX } from 'logsdx'
+ />
+
-
-
- Theme: {currentThemeName}
-
-
-
+// Logs adapt to user's theme preference
+console.log(logger.processLine('[INFO] Adaptive theming'))`}
+ />
- {/* Popular Logger Integration */}
Logger Integration Examples
-
-
-
- {`import winston from 'winston'
+ {
return logsDX.processLine(info.message)
})
})`}
-
-
-
- Theme: {currentThemeName}
-
-
-
-
-
-
-
- {`import pino from 'pino'
+ />
+
-
-
- Theme: {currentThemeName}
-
-
-
-
-
-
-
-
- Console Override
-
-
-
- {`import { getLogsDX } from 'logsdx'
-
-const logsDX = getLogsDX('${currentThemeName}')
+ />
+ {
)
originalLog(...styled)
}`}
-
-
-
- Theme: {currentThemeName}
-
-
-
+ />
diff --git a/site/components/log-playground/ThemeSelector.tsx b/site/components/log-playground/ThemeSelector.tsx
new file mode 100644
index 0000000..6df715d
--- /dev/null
+++ b/site/components/log-playground/ThemeSelector.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import React from "react";
+import { AVAILABLE_THEMES, THEME_LABELS } from "./constants";
+
+interface ThemeSelectorProps {
+ value: string;
+ onChange: (theme: string) => void;
+}
+
+export function ThemeSelector({ value, onChange }: ThemeSelectorProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/site/components/log-playground/constants.ts b/site/components/log-playground/constants.ts
new file mode 100644
index 0000000..c3aebd9
--- /dev/null
+++ b/site/components/log-playground/constants.ts
@@ -0,0 +1,71 @@
+export const DEFAULT_LOGS = `[2024-01-15 10:23:45] INFO: Application starting...
+[2024-01-15 10:23:46] DEBUG: Loading configuration from /etc/app/config.json
+[2024-01-15 10:23:47] SUCCESS: Database connected to postgres://localhost:5432/myapp
+[2024-01-15 10:23:48] WARN: Memory usage at 75% - consider scaling
+[2024-01-15 10:23:49] ERROR: Failed to connect to Redis: ECONNREFUSED 127.0.0.1:6379
+GET /api/users 200 OK (45ms)
+POST /api/auth/login 401 Unauthorized (12ms)
+{"level":"info","message":"User logged in","userId":123,"timestamp":"2024-01-15T10:23:50Z"}
+Processing batch job... [āāāāāāāāāāāāāāāāāāāā] 100%
+ā All 42 tests passed in 3.2s`;
+
+export const AVAILABLE_THEMES = [
+ "oh-my-zsh",
+ "dracula",
+ "nord",
+ "monokai",
+ "github-light",
+ "github-dark",
+ "solarized-light",
+ "solarized-dark",
+] as const;
+
+export const THEME_LABELS: Record
= {
+ "oh-my-zsh": "Oh My Zsh",
+ dracula: "Dracula",
+ nord: "Nord",
+ monokai: "Monokai",
+ "github-light": "GitHub Light",
+ "github-dark": "GitHub Dark",
+ "solarized-light": "Solarized Light",
+ "solarized-dark": "Solarized Dark",
+};
+
+export const TEXT_TITLE_HIGHLIGHT = "Live";
+export const TEXT_TITLE_REST = "Log Playground";
+export const TEXT_DESCRIPTION =
+ "Paste your logs below and see them transformed in real-time";
+export const TEXT_CARD_TITLE = "Try It Yourself";
+export const TEXT_LABEL_INPUT_LOGS = "Input Logs";
+export const TEXT_LABEL_INPUT_PLACEHOLDER = "Paste your logs here...";
+export const TEXT_LABEL_BROWSER_CONSOLE = "Browser Console";
+export const TEXT_LABEL_TERMINAL = "Terminal";
+export const TEXT_LABEL_RESET = "Reset";
+
+export const CLASS_SECTION = "py-24 bg-white dark:bg-slate-950";
+export const CLASS_CONTAINER = "container mx-auto px-4";
+export const CLASS_WRAPPER = "mx-auto max-w-6xl";
+export const CLASS_HEADER_TITLE =
+ "mb-4 text-center text-5xl lg:text-6xl font-bold";
+export const CLASS_HEADER_GRADIENT =
+ "bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent";
+export const CLASS_HEADER_DESCRIPTION =
+ "mb-12 text-center text-xl text-slate-600 dark:text-slate-400";
+export const CLASS_CARD_HEADER =
+ "flex flex-row items-center justify-between pb-4";
+export const CLASS_CARD_TITLE = "text-xl";
+export const CLASS_CARD_CONTROLS = "flex items-center gap-4";
+export const CLASS_CARD_CONTENT = "pt-0";
+export const CLASS_MAIN_GRID = "grid lg:grid-cols-3 gap-4";
+export const CLASS_PANE_WRAPPER =
+ "border rounded-lg overflow-hidden dark:border-slate-700";
+export const CLASS_PANE_HEADER =
+ "px-4 py-2 bg-slate-800 text-white text-sm font-medium";
+export const CLASS_TEXTAREA =
+ "w-full h-full min-h-[400px] p-4 font-mono text-sm bg-slate-50 dark:bg-slate-900 border-0 resize-none focus:outline-none";
+export const CLASS_OUTPUT_CONTENT =
+ "flex-1 p-4 font-mono text-xs overflow-auto min-h-[400px]";
+
+export const HEADER_DROP_SHADOW = "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))";
+export const LIGHT_BG = "#ffffff";
+export const DARK_BG = "#1e1e1e";
diff --git a/site/components/log-playground/index.tsx b/site/components/log-playground/index.tsx
new file mode 100644
index 0000000..ac9a2ae
--- /dev/null
+++ b/site/components/log-playground/index.tsx
@@ -0,0 +1,325 @@
+"use client";
+
+import React, { useState, useCallback, useMemo } from "react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { RotateCcw } from "lucide-react";
+import { useThemeProcessor } from "@/hooks/useThemeProcessor";
+import { ThemeSelector } from "./ThemeSelector";
+import { GhosttyTerminal } from "../output-comparison/GhosttyTerminal";
+import { themeToGhostty } from "../output-comparison/utils";
+import type { GhosttyTheme } from "../output-comparison/types";
+import type {
+ LogPlaygroundProps,
+ OutputPaneProps,
+ CardControlsProps,
+ TerminalPaneProps,
+ PlaygroundPanelsProps,
+ InputPaneProps,
+} from "./types";
+import {
+ DEFAULT_LOGS,
+ TEXT_TITLE_HIGHLIGHT,
+ TEXT_TITLE_REST,
+ TEXT_DESCRIPTION,
+ TEXT_CARD_TITLE,
+ TEXT_LABEL_INPUT_LOGS,
+ TEXT_LABEL_INPUT_PLACEHOLDER,
+ TEXT_LABEL_BROWSER_CONSOLE,
+ TEXT_LABEL_TERMINAL,
+ TEXT_LABEL_RESET,
+ CLASS_SECTION,
+ CLASS_CONTAINER,
+ CLASS_WRAPPER,
+ CLASS_HEADER_TITLE,
+ CLASS_HEADER_GRADIENT,
+ CLASS_HEADER_DESCRIPTION,
+ CLASS_CARD_HEADER,
+ CLASS_CARD_TITLE,
+ CLASS_CARD_CONTROLS,
+ CLASS_CARD_CONTENT,
+ CLASS_MAIN_GRID,
+ CLASS_PANE_WRAPPER,
+ CLASS_PANE_HEADER,
+ CLASS_TEXTAREA,
+ CLASS_OUTPUT_CONTENT,
+ HEADER_DROP_SHADOW,
+ LIGHT_BG,
+ DARK_BG,
+} from "./constants";
+
+const HEADER_STYLE = { filter: HEADER_DROP_SHADOW };
+
+function OutputPaneHeader({ title }: { title: string }) {
+ return {title}
;
+}
+
+function OutputPaneLoading({ backgroundColor }: { backgroundColor: string }) {
+ return (
+
+ );
+}
+
+function OutputPaneItems({
+ content,
+ backgroundColor,
+}: {
+ content: string[];
+ backgroundColor: string;
+}) {
+ const items = content.map((line, i) => {
+ const htmlContent = { __html: line };
+ return (
+
+ );
+ });
+ return (
+
+ );
+}
+
+function OutputPaneContent({
+ content,
+ backgroundColor,
+ isLoading,
+}: Omit) {
+ if (isLoading) return ;
+ return (
+
+ );
+}
+
+function OutputPane({
+ title,
+ content,
+ backgroundColor,
+ isLoading,
+}: OutputPaneProps) {
+ const wrapperClass = `${CLASS_PANE_WRAPPER} flex flex-col h-full`;
+ return (
+
+
+
+
+ );
+}
+
+function InputPane({ value, onChange, placeholder }: InputPaneProps) {
+ const handleChange = (e: React.ChangeEvent) =>
+ onChange(e.target.value);
+ const wrapperClass = `${CLASS_PANE_WRAPPER} flex flex-col h-full`;
+ return (
+
+
{TEXT_LABEL_INPUT_LOGS}
+
+
+ );
+}
+
+function SectionHeader() {
+ return (
+
+
+ {TEXT_TITLE_HIGHLIGHT}{" "}
+ {TEXT_TITLE_REST}
+
+
{TEXT_DESCRIPTION}
+
+ );
+}
+
+function getBackgroundColor(mode: string | undefined): string {
+ if (mode === "light") return LIGHT_BG;
+ return DARK_BG;
+}
+
+function convertToGhosttyTheme(
+ theme: ReturnType["theme"],
+): GhosttyTheme | null {
+ if (!theme) return null;
+ return themeToGhostty(theme);
+}
+
+function usePlaygroundState(defaultLogs: string, defaultTheme: string) {
+ const [inputText, setInputText] = useState(defaultLogs);
+ const [selectedTheme, setSelectedTheme] = useState(defaultTheme);
+ const logs = useMemo(
+ () => inputText.split("\n").filter((line) => line.trim()),
+ [inputText],
+ );
+ const { processedLogs, isLoading, theme } = useThemeProcessor(
+ selectedTheme,
+ logs,
+ );
+ const handleReset = useCallback(
+ () => setInputText(defaultLogs),
+ [defaultLogs],
+ );
+ const htmlContent = useMemo(
+ () => processedLogs.map((p) => p.html),
+ [processedLogs],
+ );
+ const ansiContent = useMemo(
+ () => processedLogs.map((p) => p.ansi),
+ [processedLogs],
+ );
+ const bgColor = getBackgroundColor(theme?.mode);
+ const ghosttyTheme = useMemo(() => convertToGhosttyTheme(theme), [theme]);
+ return {
+ inputText,
+ setInputText,
+ selectedTheme,
+ setSelectedTheme,
+ htmlContent,
+ ansiContent,
+ isLoading,
+ handleReset,
+ bgColor,
+ ghosttyTheme,
+ };
+}
+
+function CardControls({
+ selectedTheme,
+ onThemeChange,
+ onReset,
+}: CardControlsProps) {
+ return (
+
+
+
+
+ );
+}
+
+function TerminalPaneLoading({ bgColor }: { bgColor: string }) {
+ return (
+
+ );
+}
+
+function TerminalPane({
+ ansiContent,
+ ghosttyTheme,
+ isLoading,
+ bgColor,
+}: TerminalPaneProps) {
+ const wrapperClass = `${CLASS_PANE_WRAPPER} flex flex-col h-full`;
+ const showLoading = !ghosttyTheme || isLoading;
+
+ return (
+
+
+ {showLoading &&
}
+ {ghosttyTheme && !isLoading && (
+
+
+
+ )}
+
+ );
+}
+
+function PlaygroundPanels({
+ inputText,
+ onInputChange,
+ htmlContent,
+ ansiContent,
+ ghosttyTheme,
+ bgColor,
+ isLoading,
+}: PlaygroundPanelsProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+export function LogPlayground({
+ defaultTheme = "dracula",
+ defaultLogs = DEFAULT_LOGS,
+}: LogPlaygroundProps) {
+ const state = usePlaygroundState(defaultLogs, defaultTheme);
+
+ return (
+
+
+
+
+
+
+
+ {TEXT_CARD_TITLE}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export type { LogPlaygroundProps } from "./types";
diff --git a/site/components/log-playground/types.ts b/site/components/log-playground/types.ts
new file mode 100644
index 0000000..18eebda
--- /dev/null
+++ b/site/components/log-playground/types.ts
@@ -0,0 +1,47 @@
+import type { GhosttyTheme } from "../output-comparison/types";
+
+export interface LogPlaygroundProps {
+ defaultTheme?: string;
+ defaultLogs?: string;
+}
+
+export interface ProcessedOutput {
+ html: string;
+ ansi: string;
+}
+
+export interface OutputPaneProps {
+ title: string;
+ content: string[];
+ backgroundColor: string;
+ isLoading: boolean;
+}
+
+export interface CardControlsProps {
+ selectedTheme: string;
+ onThemeChange: (t: string) => void;
+ onReset: () => void;
+}
+
+export interface TerminalPaneProps {
+ ansiContent: string[];
+ ghosttyTheme: GhosttyTheme | null;
+ isLoading: boolean;
+ bgColor: string;
+}
+
+export interface PlaygroundPanelsProps {
+ inputText: string;
+ onInputChange: (v: string) => void;
+ htmlContent: string[];
+ ansiContent: string[];
+ ghosttyTheme: GhosttyTheme | null;
+ bgColor: string;
+ isLoading: boolean;
+}
+
+export interface InputPaneProps {
+ value: string;
+ onChange: (v: string) => void;
+ placeholder: string;
+}
diff --git a/site/components/nav-card/constants.ts b/site/components/nav-card/constants.ts
new file mode 100644
index 0000000..24d8437
--- /dev/null
+++ b/site/components/nav-card/constants.ts
@@ -0,0 +1,10 @@
+export const CLASSES = {
+ card: "group relative overflow-hidden rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 hover:border-blue-500 dark:hover:border-blue-400 transition-all duration-200 cursor-pointer shadow-sm hover:shadow-md",
+ imageWrapper: "aspect-video overflow-hidden bg-slate-100 dark:bg-slate-900",
+ image:
+ "w-full h-full object-cover group-hover:scale-105 transition-transform duration-200",
+ placeholder: "w-full h-full flex items-center justify-center text-slate-400",
+ content: "p-4",
+ title:
+ "text-lg font-semibold text-slate-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors",
+} as const;
diff --git a/site/components/nav-card/index.tsx b/site/components/nav-card/index.tsx
new file mode 100644
index 0000000..86bac84
--- /dev/null
+++ b/site/components/nav-card/index.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import Image from "next/image";
+import { useTheme } from "next-themes";
+import { CLASSES } from "./constants";
+import type { NavCardProps } from "./types";
+
+function PreviewPlaceholder() {
+ return Preview
;
+}
+
+export function NavCard({
+ title,
+ href,
+ previewLight,
+ previewDark,
+}: NavCardProps) {
+ const [mounted, setMounted] = useState(false);
+ const { resolvedTheme } = useTheme();
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ const handleClick = () => {
+ const element = document.querySelector(href);
+ if (element) {
+ element.scrollIntoView({ behavior: "smooth" });
+ }
+ };
+
+ const lightSrc = previewLight || previewDark;
+ const darkSrc = previewDark || previewLight;
+ const isDark = mounted && resolvedTheme === "dark";
+ const preview = isDark ? darkSrc : lightSrc;
+
+ const previewContent = preview ? (
+
+ ) : (
+
+ );
+
+ return (
+
+ );
+}
+
+export type { NavCardProps } from "./types";
diff --git a/site/components/nav-card/types.ts b/site/components/nav-card/types.ts
new file mode 100644
index 0000000..c61c998
--- /dev/null
+++ b/site/components/nav-card/types.ts
@@ -0,0 +1,6 @@
+export interface NavCardProps {
+ title: string;
+ href: string;
+ previewLight?: string;
+ previewDark?: string;
+}
diff --git a/site/components/output-comparison/GhosttyTerminal.tsx b/site/components/output-comparison/GhosttyTerminal.tsx
new file mode 100644
index 0000000..e78c1bb
--- /dev/null
+++ b/site/components/output-comparison/GhosttyTerminal.tsx
@@ -0,0 +1,146 @@
+"use client";
+
+import React, { useEffect, useRef, useState, useCallback } from "react";
+import type { GhosttyTerminalProps } from "./types";
+import { TERMINAL } from "./constants";
+
+export function GhosttyTerminal({
+ ansiOutputs,
+ isLoading,
+ theme,
+}: GhosttyTerminalProps) {
+ const containerRef = useRef(null);
+ const terminalRef = useRef(null);
+ const [isInitialized, setIsInitialized] = useState(false);
+ const [error, setError] = useState(null);
+ const [retryCount, setRetryCount] = useState(0);
+
+ const initTerminal = useCallback(
+ async (mounted: { current: boolean }) => {
+ if (!containerRef.current) return;
+
+ setError(null);
+
+ try {
+ const initPromise = (async () => {
+ const ghostty = await import("ghostty-web");
+ await ghostty.init();
+ return ghostty;
+ })();
+
+ const timeoutPromise = new Promise((_, reject) => {
+ setTimeout(
+ () => reject(new Error("Terminal initialization timed out")),
+ TERMINAL.initTimeoutMs,
+ );
+ });
+
+ const ghostty = (await Promise.race([
+ initPromise,
+ timeoutPromise,
+ ])) as typeof import("ghostty-web");
+
+ if (!mounted.current || !containerRef.current) return;
+
+ containerRef.current.innerHTML = "";
+
+ const term = new ghostty.Terminal({
+ fontSize: TERMINAL.fontSize,
+ fontFamily: TERMINAL.fontFamily,
+ theme,
+ });
+
+ term.open(containerRef.current);
+ terminalRef.current = term;
+ setIsInitialized(true);
+ } catch (err) {
+ console.error("Failed to initialize Ghostty terminal:", err);
+ if (mounted.current) {
+ setError(
+ err instanceof Error ? err.message : "Failed to load terminal",
+ );
+ }
+ }
+ },
+ [theme],
+ );
+
+ const handleRetry = useCallback(() => {
+ setRetryCount((c) => c + 1);
+ setError(null);
+ setIsInitialized(false);
+ }, []);
+
+ useEffect(() => {
+ const mounted = { current: true };
+
+ initTerminal(mounted);
+
+ return () => {
+ mounted.current = false;
+ if (
+ terminalRef.current &&
+ typeof (terminalRef.current as { dispose?: () => void }).dispose ===
+ "function"
+ ) {
+ (terminalRef.current as { dispose: () => void }).dispose();
+ }
+ };
+ }, [initTerminal, retryCount]);
+
+ useEffect(() => {
+ if (!isInitialized || !terminalRef.current || isLoading) return;
+
+ const term = terminalRef.current as {
+ write: (data: string) => void;
+ clear: () => void;
+ };
+
+ term.clear();
+
+ for (const output of ansiOutputs) {
+ term.write(output + "\r\n");
+ }
+ }, [ansiOutputs, isInitialized, isLoading]);
+
+ if (error) {
+ return (
+
+
+
Terminal failed to load
+
{error}
+
+
+
+ );
+ }
+
+ const showLoading = isLoading || !isInitialized;
+ const containerClassName = showLoading
+ ? `${TERMINAL.minHeight} invisible`
+ : TERMINAL.minHeight;
+
+ const loadingOverlay = (
+
+
+
Loading terminal...
+
Powered by Ghostty WASM
+
+
+ );
+
+ return (
+
+ {showLoading && loadingOverlay}
+
+
+ );
+}
diff --git a/site/components/output-comparison/constants.ts b/site/components/output-comparison/constants.ts
new file mode 100644
index 0000000..ac00d58
--- /dev/null
+++ b/site/components/output-comparison/constants.ts
@@ -0,0 +1,129 @@
+import type { OutputView, ViewMode, GhosttyTheme } from "./types";
+
+export const TEXT = {
+ title: {
+ highlight: "Real",
+ rest: "Output Comparison",
+ },
+ description: "See exactly what logsDX outputs for terminal vs browser",
+ labels: {
+ theme: "Theme",
+ terminalOutput: "Terminal Output",
+ browserOutput: "Browser Output",
+ processing: "Processing...",
+ loadingTerminal: "Loading terminal...",
+ significance:
+ "The Terminal panel uses Ghostty, a real WebAssembly terminal emulatorānot fake styling. What you see is exactly how these logs render in an actual terminal.",
+ },
+} as const;
+
+export const CLASSES = {
+ section: "py-24",
+ container: "container mx-auto px-4",
+ wrapper: "mx-auto max-w-6xl",
+ header: {
+ title: "mb-4 text-center text-5xl lg:text-6xl font-bold",
+ gradient:
+ "bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent",
+ description: "mb-12 text-center text-xl text-slate-600 dark:text-slate-400",
+ },
+ grid: "grid gap-8 lg:grid-cols-3",
+ sidebar: "lg:col-span-1 space-y-6",
+ content: "lg:col-span-2",
+ label: "block text-sm font-medium mb-2 text-slate-700 dark:text-slate-300",
+ significanceText:
+ "text-sm text-slate-500 dark:text-slate-400 mt-4 leading-relaxed",
+ tab: {
+ base: "px-4 py-2 rounded-lg text-sm font-medium transition-colors",
+ active: "bg-blue-600 text-white",
+ inactive:
+ "bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300 hover:bg-slate-300 dark:hover:bg-slate-600",
+ },
+ tabDescription: "text-sm text-slate-500 dark:text-slate-400 mb-4",
+ terminal: {
+ wrapper: "rounded-lg overflow-hidden border border-slate-700",
+ header: "bg-slate-800 px-4 py-2 flex items-center gap-2",
+ dots: "flex gap-1.5",
+ dot: {
+ red: "w-3 h-3 rounded-full bg-red-500",
+ yellow: "w-3 h-3 rounded-full bg-yellow-500",
+ green: "w-3 h-3 rounded-full bg-green-500",
+ },
+ title: "text-xs text-white/60 ml-2",
+ content: "p-4 min-h-[300px] overflow-auto",
+ },
+ outputCard: "bg-slate-100 dark:bg-slate-800 rounded-lg p-4",
+ outputTitle: "text-sm font-semibold mb-2",
+ outputCode: "text-xs text-slate-600 dark:text-slate-400 block",
+ outputFormat: "text-xs text-slate-500 mt-2",
+} as const;
+
+export const STYLES = {
+ headerDropShadow: "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))",
+ terminalBg: "#1e1e1e",
+} as const;
+
+export const TERMINAL = {
+ initTimeoutMs: 10000,
+ minHeight: "min-h-[300px]",
+ fontSize: 14,
+ fontFamily: "JetBrains Mono, Menlo, Monaco, Consolas, monospace",
+} as const;
+
+export const SAMPLE_LOGS = [
+ "[INFO] Server started on port 3000",
+ "[WARN] Memory usage: 85%",
+ "[ERROR] Connection failed: timeout",
+ "[DEBUG] Request id=abc123 processed",
+ "[SUCCESS] Deploy complete ā",
+];
+
+export const VIEWS: { id: OutputView; label: string }[] = [
+ { id: "terminal", label: "Terminal" },
+ { id: "html", label: "HTML" },
+];
+
+export const VIEW_MODES: { id: ViewMode; label: string }[] = [
+ { id: "rendered", label: "Rendered" },
+ { id: "source", label: "Source" },
+];
+
+export const THEME_OPTIONS = [
+ "dracula",
+ "github-dark",
+ "github-light",
+ "nord",
+ "monokai",
+ "solarized-dark",
+ "solarized-light",
+ "oh-my-zsh",
+];
+
+export const ANSI_ESCAPE_REPLACEMENTS: [RegExp, string][] = [
+ [/\x1b/g, "\\x1b"],
+ [/\[/g, "["],
+];
+
+export const DEFAULT_GHOSTTY_THEME: GhosttyTheme = {
+ background: "#282a36",
+ foreground: "#f8f8f2",
+ cursor: "#f8f8f2",
+ cursorAccent: "#282a36",
+ selectionBackground: "#44475a",
+ black: "#000000",
+ red: "#ff5555",
+ green: "#50fa7b",
+ yellow: "#ffb86c",
+ blue: "#ff79c6",
+ magenta: "#bd93f9",
+ cyan: "#8be9fd",
+ white: "#f8f8f2",
+ brightBlack: "#6272a4",
+ brightRed: "#ff6e6e",
+ brightGreen: "#69ff94",
+ brightYellow: "#ffca85",
+ brightBlue: "#ff92d0",
+ brightMagenta: "#d6acff",
+ brightCyan: "#a4ffff",
+ brightWhite: "#ffffff",
+};
diff --git a/site/components/output-comparison/index.tsx b/site/components/output-comparison/index.tsx
new file mode 100644
index 0000000..075ac43
--- /dev/null
+++ b/site/components/output-comparison/index.tsx
@@ -0,0 +1,410 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import dynamic from "next/dynamic";
+import { getTheme } from "logsdx";
+import {
+ SAMPLE_LOGS,
+ THEME_OPTIONS,
+ TEXT,
+ CLASSES,
+ STYLES,
+ DEFAULT_GHOSTTY_THEME,
+} from "./constants";
+import { themeToGhostty, processLogsWithTheme } from "./utils";
+import type { ViewMode, ProcessedOutput, GhosttyTheme } from "./types";
+
+const TerminalLoader = () => (
+
+ {TEXT.labels.loadingTerminal}
+
+);
+
+const GhosttyTerminal = dynamic(
+ () => import("./GhosttyTerminal").then((mod) => mod.GhosttyTerminal),
+ { ssr: false, loading: TerminalLoader },
+);
+
+function escapeHtmlForDisplay(html: string): string {
+ return html
+ .replace(/&/g, "&")
+ .replace(//g, ">");
+}
+
+const WINDOW_DOTS = ["red", "yellow", "green"] as const;
+const MODE_BUTTONS = [
+ { id: "rendered", label: "Rendered" },
+ { id: "source", label: "Source" },
+] as const;
+
+interface TerminalWindowProps {
+ title: string;
+ mode: ViewMode;
+ onModeChange: (mode: ViewMode) => void;
+ bgColor: string;
+ children: React.ReactNode;
+}
+
+function TerminalWindowDots() {
+ const dots = WINDOW_DOTS.map((color) => {
+ const dotClass = CLASSES.terminal.dot[color];
+ return ;
+ });
+ return {dots}
;
+}
+
+interface ModeButtonsProps {
+ mode: ViewMode;
+ onModeChange: (mode: ViewMode) => void;
+}
+
+function getModeButtonClass(isActive: boolean): string {
+ const base = "px-2 py-1 text-xs rounded transition-colors";
+ if (isActive) return `${base} bg-white/20 text-white`;
+ return `${base} text-white/60 hover:text-white hover:bg-white/10`;
+}
+
+function ModeButtons({ mode, onModeChange }: ModeButtonsProps) {
+ const buttons = MODE_BUTTONS.map(({ id, label }) => {
+ const isActive = mode === id;
+ const className = getModeButtonClass(isActive);
+ const handleClick = () => onModeChange(id as ViewMode);
+ return (
+
+ );
+ });
+ return {buttons}
;
+}
+
+function TerminalWindow({
+ title,
+ mode,
+ onModeChange,
+ bgColor,
+ children,
+}: TerminalWindowProps) {
+ const wrapperClass = `${CLASSES.terminal.wrapper} h-full flex flex-col`;
+ const contentClass = `${CLASSES.terminal.content} flex-1`;
+ const contentStyle = { backgroundColor: bgColor };
+
+ return (
+
+
+
+ {title}
+
+
+
+ {children}
+
+
+ );
+}
+
+interface ThemeButtonProps {
+ theme: string;
+ isSelected: boolean;
+ onClick: () => void;
+}
+
+function getThemeButtonClass(isSelected: boolean): string {
+ const base =
+ "w-full px-3 py-2 text-left text-sm rounded-lg transition-colors";
+ const selected = "bg-blue-600 text-white font-medium";
+ const unselected =
+ "bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700";
+ if (isSelected) return `${base} ${selected}`;
+ return `${base} ${unselected}`;
+}
+
+function ThemeButton({ theme, isSelected, onClick }: ThemeButtonProps) {
+ const buttonClass = getThemeButtonClass(isSelected);
+ return (
+
+ );
+}
+
+interface ThemeSidebarProps {
+ themeName: string;
+ onThemeChange: (theme: string) => void;
+}
+
+function ThemeSidebar({ themeName, onThemeChange }: ThemeSidebarProps) {
+ const themeButtons = THEME_OPTIONS.map((t) => {
+ const isSelected = themeName === t;
+ const handleClick = () => onThemeChange(t);
+ return (
+
+ );
+ });
+
+ return (
+
+
+
+
{themeButtons}
+
+
{TEXT.labels.significance}
+
+ );
+}
+
+interface TerminalContentProps {
+ isLoading: boolean;
+ mode: ViewMode;
+ outputs: ProcessedOutput[];
+ ghosttyTheme: GhosttyTheme;
+}
+
+function TerminalContentSource({ outputs }: { outputs: ProcessedOutput[] }) {
+ const items = outputs.map((output, i) => (
+
+ {output.ansiVisible}
+
+ ));
+ return {items}
;
+}
+
+function TerminalContent({
+ isLoading,
+ mode,
+ outputs,
+ ghosttyTheme,
+}: TerminalContentProps) {
+ if (isLoading) {
+ return (
+
+ {TEXT.labels.processing}
+
+ );
+ }
+ if (mode === "rendered") {
+ const ansiOutputs = outputs.map((o) => o.ansi);
+ return (
+
+ );
+ }
+ return ;
+}
+
+interface BrowserContentProps {
+ isLoading: boolean;
+ mode: ViewMode;
+ outputs: ProcessedOutput[];
+}
+
+function BrowserContentRendered({ outputs }: { outputs: ProcessedOutput[] }) {
+ const items = outputs.map((output, i) => {
+ const htmlContent = { __html: output.html };
+ return (
+
+ );
+ });
+ return {items}
;
+}
+
+function BrowserContentSource({ outputs }: { outputs: ProcessedOutput[] }) {
+ const items = outputs.map((output, i) => {
+ const escaped = escapeHtmlForDisplay(output.html);
+ return (
+
+ {escaped}
+
+ );
+ });
+ return {items}
;
+}
+
+function BrowserContent({ isLoading, mode, outputs }: BrowserContentProps) {
+ if (isLoading) {
+ return (
+
+ {TEXT.labels.processing}
+
+ );
+ }
+ if (mode === "rendered") {
+ return ;
+ }
+ return ;
+}
+
+function useThemeLoader(themeName: string) {
+ const [outputs, setOutputs] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [ghosttyTheme, setGhosttyTheme] = useState(DEFAULT_GHOSTTY_THEME);
+
+ useEffect(() => {
+ let cancelled = false;
+ setIsLoading(true);
+
+ getTheme(themeName)
+ .then((loadedTheme) => {
+ if (cancelled) return;
+ setGhosttyTheme(themeToGhostty(loadedTheme));
+ setOutputs(processLogsWithTheme(SAMPLE_LOGS, loadedTheme));
+ })
+ .catch(
+ (err) => !cancelled && console.error("Failed to process logs:", err),
+ )
+ .finally(() => !cancelled && setIsLoading(false));
+
+ return () => {
+ cancelled = true;
+ };
+ }, [themeName]);
+
+ return { outputs, isLoading, ghosttyTheme };
+}
+
+function SectionHeader() {
+ const headerStyle = { filter: STYLES.headerDropShadow };
+ return (
+ <>
+
+ {TEXT.title.highlight}{" "}
+ {TEXT.title.rest}
+
+ {TEXT.description}
+ >
+ );
+}
+
+interface OutputPanelsProps {
+ terminalMode: ViewMode;
+ browserMode: ViewMode;
+ onTerminalModeChange: (mode: ViewMode) => void;
+ onBrowserModeChange: (mode: ViewMode) => void;
+ outputs: ProcessedOutput[];
+ isLoading: boolean;
+ ghosttyTheme: GhosttyTheme;
+}
+
+function OutputPanels({
+ terminalMode,
+ browserMode,
+ onTerminalModeChange,
+ onBrowserModeChange,
+ outputs,
+ isLoading,
+ ghosttyTheme,
+}: OutputPanelsProps) {
+ const bgColor = ghosttyTheme.background;
+ return (
+
+ );
+}
+
+function useOutputComparisonState() {
+ const [themeName, setThemeName] = useState("dracula");
+ const [terminalMode, setTerminalMode] = useState("rendered");
+ const [browserMode, setBrowserMode] = useState("rendered");
+ const themeData = useThemeLoader(themeName);
+ return {
+ themeName,
+ setThemeName,
+ terminalMode,
+ setTerminalMode,
+ browserMode,
+ setBrowserMode,
+ ...themeData,
+ };
+}
+
+function buildPanelProps(
+ state: ReturnType,
+): OutputPanelsProps {
+ return {
+ terminalMode: state.terminalMode,
+ browserMode: state.browserMode,
+ onTerminalModeChange: state.setTerminalMode,
+ onBrowserModeChange: state.setBrowserMode,
+ outputs: state.outputs,
+ isLoading: state.isLoading,
+ ghosttyTheme: state.ghosttyTheme,
+ };
+}
+
+function OutputComparisonContent() {
+ const state = useOutputComparisonState();
+ const panelProps = buildPanelProps(state);
+
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
+
+export function OutputComparison() {
+ return (
+
+ );
+}
+
+export { GhosttyTerminal } from "./GhosttyTerminal";
+export type {
+ OutputComparisonProps,
+ OutputView,
+ ViewMode,
+ GhosttyTerminalProps,
+} from "./types";
diff --git a/site/components/output-comparison/types.ts b/site/components/output-comparison/types.ts
new file mode 100644
index 0000000..5005f4d
--- /dev/null
+++ b/site/components/output-comparison/types.ts
@@ -0,0 +1,42 @@
+export interface OutputComparisonProps {
+ initialTheme?: string;
+}
+
+export interface ProcessedOutput {
+ ansi: string;
+ html: string;
+ ansiVisible: string;
+}
+
+export type OutputView = "terminal" | "html";
+export type ViewMode = "rendered" | "source";
+
+export interface GhosttyTerminalProps {
+ ansiOutputs: string[];
+ isLoading: boolean;
+ theme: GhosttyTheme;
+}
+
+export interface GhosttyTheme {
+ background: string;
+ foreground: string;
+ cursor: string;
+ cursorAccent: string;
+ selectionBackground: string;
+ black: string;
+ red: string;
+ green: string;
+ yellow: string;
+ blue: string;
+ magenta: string;
+ cyan: string;
+ white: string;
+ brightBlack: string;
+ brightRed: string;
+ brightGreen: string;
+ brightYellow: string;
+ brightBlue: string;
+ brightMagenta: string;
+ brightCyan: string;
+ brightWhite: string;
+}
diff --git a/site/components/output-comparison/utils.ts b/site/components/output-comparison/utils.ts
new file mode 100644
index 0000000..6f8dcbb
--- /dev/null
+++ b/site/components/output-comparison/utils.ts
@@ -0,0 +1,133 @@
+import type { Theme } from "logsdx";
+import { renderLine } from "logsdx";
+import type { GhosttyTheme, ProcessedOutput } from "./types";
+import { ANSI_ESCAPE_REPLACEMENTS } from "./constants";
+
+function escapeAnsiForDisplay(ansi: string): string {
+ return ANSI_ESCAPE_REPLACEMENTS.reduce(
+ (s, [pattern, replacement]) => s.replace(pattern, replacement),
+ ansi,
+ );
+}
+
+export function processLogsWithTheme(
+ logs: string[],
+ theme: Theme,
+): ProcessedOutput[] {
+ return logs.map((log) => {
+ const ansi = renderLine(log, theme, { outputFormat: "ansi" });
+ const html = renderLine(log, theme, {
+ outputFormat: "html",
+ htmlStyleFormat: "css",
+ escapeHtml: true,
+ });
+ return { ansi, html, ansiVisible: escapeAnsiForDisplay(ansi) };
+ });
+}
+
+function adjustBrightness(hex: string, percent: number): string {
+ const num = parseInt(hex.replace("#", ""), 16);
+ const r = Math.min(
+ 255,
+ Math.max(0, ((num >> 16) & 255) + Math.round((255 * percent) / 100)),
+ );
+ const g = Math.min(
+ 255,
+ Math.max(0, ((num >> 8) & 255) + Math.round((255 * percent) / 100)),
+ );
+ const b = Math.min(
+ 255,
+ Math.max(0, (num & 255) + Math.round((255 * percent) / 100)),
+ );
+ return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`;
+}
+
+const DARK = {
+ bg: "#1e1e1e",
+ fg: "#d4d4d4",
+ black: "#000000",
+ white: "",
+ selBrightness: 20,
+};
+const LIGHT = {
+ bg: "#ffffff",
+ fg: "#1e1e1e",
+ black: "",
+ white: "#e5e5e5",
+ selBrightness: -15,
+};
+const ANSI = {
+ red: "#cd3131",
+ green: "#0dbc79",
+ yellow: "#e5e510",
+ blue: "#2472c8",
+ magenta: "#bc3fbc",
+ cyan: "#11a8cd",
+ muted: "#666666",
+};
+
+function or(a: T | undefined, b: T): T {
+ if (a !== undefined) return a;
+ return b;
+}
+
+function getModeDefaults(theme: Theme) {
+ if (theme.mode === "dark" || theme.mode === "auto") return DARK;
+ return LIGHT;
+}
+
+function getSemanticColors(colors: NonNullable) {
+ const blue = or(colors.primary, or(colors.info, ANSI.blue));
+ const cyan = or(colors.info, or(colors.secondary, ANSI.cyan));
+ return {
+ red: or(colors.error, ANSI.red),
+ green: or(colors.success, ANSI.green),
+ yellow: or(colors.warning, ANSI.yellow),
+ blue,
+ magenta: or(colors.debug, ANSI.magenta),
+ cyan,
+ muted: or(colors.muted, ANSI.muted),
+ };
+}
+
+function getBrightColors(base: ReturnType) {
+ return {
+ brightRed: adjustBrightness(base.red, 15),
+ brightGreen: adjustBrightness(base.green, 15),
+ brightYellow: adjustBrightness(base.yellow, 15),
+ brightBlue: adjustBrightness(base.blue, 15),
+ brightMagenta: adjustBrightness(base.magenta, 15),
+ brightCyan: adjustBrightness(base.cyan, 15),
+ };
+}
+
+function getDerivedColors(mode: typeof DARK, bg: string, fg: string) {
+ const black = mode.black || adjustBrightness(bg, -10);
+ const white = mode.white || fg;
+ const selection = adjustBrightness(bg, mode.selBrightness);
+ return { black, white, selection };
+}
+
+export function themeToGhostty(theme: Theme): GhosttyTheme {
+ const mode = getModeDefaults(theme);
+ const colors = theme.colors || {};
+ const bg = or(colors.background, mode.bg);
+ const fg = or(colors.text, mode.fg);
+ const semantic = getSemanticColors(colors);
+ const bright = getBrightColors(semantic);
+ const derived = getDerivedColors(mode, bg, fg);
+
+ return {
+ background: bg,
+ foreground: fg,
+ cursor: fg,
+ cursorAccent: bg,
+ selectionBackground: derived.selection,
+ black: derived.black,
+ white: derived.white,
+ ...semantic,
+ brightBlack: semantic.muted,
+ ...bright,
+ brightWhite: "#ffffff",
+ };
+}
diff --git a/site/components/schema-viz/constants.ts b/site/components/schema-viz/constants.ts
new file mode 100644
index 0000000..9d3ab1e
--- /dev/null
+++ b/site/components/schema-viz/constants.ts
@@ -0,0 +1,228 @@
+import type { SchemaSection } from "./types";
+
+export const TEXT = {
+ title: {
+ highlight: "Theme",
+ rest: "Schema",
+ },
+ description: "Understand how themes work under the hood",
+ labels: {
+ matchingPriority: "Matching Priority",
+ exampleTheme: "Example Theme",
+ howMatching: "How Matching Works",
+ required: "required",
+ themeJson: "my-theme.json",
+ },
+ matchingSteps: [
+ "Log line is tokenized into individual words and symbols",
+ "Each token is checked against matching rules in priority order",
+ "First matching rule determines the token's style",
+ "Unmatched tokens use defaultStyle",
+ "Styled tokens are rendered as ANSI or HTML",
+ ],
+} as const;
+
+export const CLASSES = {
+ section: "py-24",
+ container: "container mx-auto px-4",
+ wrapper: "mx-auto max-w-6xl",
+ header: {
+ title: "mb-4 text-center text-5xl lg:text-6xl font-bold",
+ gradient:
+ "bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent",
+ description: "mb-12 text-center text-xl text-slate-600 dark:text-slate-400",
+ },
+ grid: "grid gap-8 lg:grid-cols-2",
+ tabs: {
+ wrapper: "flex gap-2 mb-6 flex-wrap",
+ button: {
+ base: "px-4 py-2 rounded-lg text-sm font-medium transition-colors",
+ active: "bg-blue-600 text-white",
+ inactive:
+ "bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300 hover:bg-slate-300 dark:hover:bg-slate-600",
+ },
+ },
+ card: "bg-white dark:bg-slate-800 rounded-lg p-6 border border-slate-200 dark:border-slate-700",
+ sectionTitle: "text-xl font-bold mb-2 text-blue-600 dark:text-blue-400",
+ sectionDescription: "text-slate-600 dark:text-slate-400 mb-6",
+ propertyList: "space-y-4",
+ property: {
+ wrapper: "border-l-2 border-blue-600/30 pl-4",
+ header: "flex items-center gap-2 mb-1",
+ name: "text-blue-600 dark:text-blue-400 font-semibold",
+ required:
+ "text-xs bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 px-1.5 py-0.5 rounded",
+ type: "text-xs text-slate-500 dark:text-slate-400",
+ description: "text-sm text-slate-600 dark:text-slate-400",
+ example: "text-xs text-slate-500 dark:text-slate-500 mt-1 block",
+ },
+ priority: {
+ wrapper: "space-y-2",
+ item: "flex items-center gap-3",
+ number:
+ "w-6 h-6 rounded-full bg-blue-600/20 text-blue-600 dark:text-blue-400 text-xs flex items-center justify-center font-bold",
+ name: "text-sm text-blue-600 dark:text-blue-400",
+ description: "text-xs text-slate-500",
+ },
+ terminal: {
+ wrapper: "rounded-lg overflow-hidden border border-slate-700",
+ header: "bg-slate-800 px-4 py-2 flex items-center gap-2",
+ dots: "flex gap-1.5",
+ dot: {
+ red: "w-3 h-3 rounded-full bg-red-500",
+ yellow: "w-3 h-3 rounded-full bg-yellow-500",
+ green: "w-3 h-3 rounded-full bg-green-500",
+ },
+ title: "text-xs text-white/60 ml-2",
+ content: "p-4 bg-slate-900 text-sm overflow-auto max-h-[600px]",
+ code: "text-slate-300",
+ },
+ howMatching: {
+ wrapper:
+ "mt-6 bg-gradient-to-r from-blue-600/10 to-purple-600/10 rounded-lg p-6 border border-blue-600/20",
+ title: "font-semibold mb-3 text-blue-600 dark:text-blue-400",
+ list: "space-y-3 text-sm text-slate-600 dark:text-slate-400",
+ item: "flex gap-2",
+ number: "text-blue-600",
+ },
+ sectionLabel: "font-semibold mb-4 text-slate-900 dark:text-white",
+} as const;
+
+export const STYLES = {
+ headerDropShadow: "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))",
+} as const;
+
+export const SCHEMA_SECTIONS: SchemaSection[] = [
+ {
+ title: "Theme",
+ description: "Root theme object that defines styling rules",
+ properties: [
+ {
+ name: "name",
+ type: "string",
+ description: "Unique identifier for the theme",
+ required: true,
+ example: '"dracula"',
+ },
+ {
+ name: "description",
+ type: "string",
+ description: "Human-readable description",
+ example: '"A dark theme inspired by Dracula"',
+ },
+ {
+ name: "mode",
+ type: '"light" | "dark" | "auto"',
+ description: "Theme color mode for terminal detection",
+ example: '"dark"',
+ },
+ {
+ name: "schema",
+ type: "SchemaConfig",
+ description: "Matching rules and styling definitions",
+ required: true,
+ },
+ {
+ name: "colors",
+ type: "Record",
+ description: "Named color palette for the theme",
+ example: '{ error: "#ff5555", info: "#8be9fd" }',
+ },
+ ],
+ },
+ {
+ title: "SchemaConfig",
+ description: "Defines how log content is matched and styled",
+ properties: [
+ {
+ name: "defaultStyle",
+ type: "StyleOptions",
+ description: "Default styling for unmatched content",
+ example: '{ color: "#f8f8f2" }',
+ },
+ {
+ name: "matchWords",
+ type: "Record",
+ description: "Exact word matches (case-insensitive)",
+ example: '{ "ERROR": { color: "#ff5555", styleCodes: ["bold"] } }',
+ },
+ {
+ name: "matchStartsWith",
+ type: "Record",
+ description: "Match tokens starting with a prefix",
+ example: '{ "[": { color: "#6272a4" } }',
+ },
+ {
+ name: "matchEndsWith",
+ type: "Record",
+ description: "Match tokens ending with a suffix",
+ example: '{ "ms": { color: "#bd93f9" } }',
+ },
+ {
+ name: "matchContains",
+ type: "Record",
+ description: "Match tokens containing a substring",
+ example: '{ "://": { color: "#8be9fd" } }',
+ },
+ {
+ name: "matchPatterns",
+ type: "PatternMatch[]",
+ description: "Regex patterns for complex matching",
+ example: '{ pattern: /\\d+\\.\\d+/, options: { color: "#bd93f9" } }',
+ },
+ ],
+ },
+ {
+ title: "StyleOptions",
+ description: "Styling applied to matched content",
+ properties: [
+ {
+ name: "color",
+ type: "string",
+ description: "Hex color code for the text",
+ required: true,
+ example: '"#ff5555"',
+ },
+ {
+ name: "styleCodes",
+ type: "StyleCode[]",
+ description: "Text decorations: bold, italic, underline, dim, etc.",
+ example: '["bold", "underline"]',
+ },
+ {
+ name: "htmlStyleFormat",
+ type: '"css" | "className"',
+ description: "HTML output format preference",
+ example: '"css"',
+ },
+ ],
+ },
+];
+
+export const MATCHING_PRIORITY = [
+ { name: "matchPatterns", description: "Checked first, highest priority" },
+ { name: "matchWords", description: "Exact word match" },
+ { name: "matchStartsWith", description: "Prefix match" },
+ { name: "matchEndsWith", description: "Suffix match" },
+ { name: "matchContains", description: "Substring match" },
+ { name: "defaultStyle", description: "Fallback for unmatched tokens" },
+];
+
+export const EXAMPLE_THEME = `{
+ "name": "my-theme",
+ "mode": "dark",
+ "schema": {
+ "defaultStyle": { "color": "#f8f8f2" },
+ "matchWords": {
+ "ERROR": { "color": "#ff5555", "styleCodes": ["bold"] },
+ "WARN": { "color": "#ffb86c" },
+ "INFO": { "color": "#8be9fd" }
+ },
+ "matchPatterns": [
+ {
+ "pattern": "\\\\d{4}-\\\\d{2}-\\\\d{2}",
+ "options": { "color": "#6272a4" }
+ }
+ ]
+ }
+}`;
diff --git a/site/components/schema-viz/index.tsx b/site/components/schema-viz/index.tsx
new file mode 100644
index 0000000..aad6985
--- /dev/null
+++ b/site/components/schema-viz/index.tsx
@@ -0,0 +1,146 @@
+"use client";
+
+import React, { useState } from "react";
+import {
+ SCHEMA_SECTIONS,
+ MATCHING_PRIORITY,
+ EXAMPLE_THEME,
+ TEXT,
+ CLASSES,
+ STYLES,
+} from "./constants";
+
+export function SchemaVisualization() {
+ const [activeSection, setActiveSection] = useState(0);
+ const section = SCHEMA_SECTIONS[activeSection];
+
+ return (
+
+
+
+
+
+ {TEXT.title.highlight}
+ {" "}
+ {TEXT.title.rest}
+
+
{TEXT.description}
+
+
+
+
+ {SCHEMA_SECTIONS.map((s, i) => (
+
+ ))}
+
+
+
+
{section.title}
+
+ {section.description}
+
+
+
+ {section.properties.map((prop) => (
+
+
+
+ {prop.name}
+
+ {prop.required && (
+
+ {TEXT.labels.required}
+
+ )}
+
+ {prop.type}
+
+
+
+ {prop.description}
+
+ {prop.example && (
+
+ {prop.example}
+
+ )}
+
+ ))}
+
+
+
+
+
+ {TEXT.labels.matchingPriority}
+
+
+ {MATCHING_PRIORITY.map((item, i) => (
+
+ {i + 1}
+ {item.name}
+
+ {item.description}
+
+
+ ))}
+
+
+
+
+
+
+ {TEXT.labels.exampleTheme}
+
+
+
+
+
+ {TEXT.labels.themeJson}
+
+
+
+ {EXAMPLE_THEME}
+
+
+
+
+
+ {TEXT.labels.howMatching}
+
+
+ {TEXT.matchingSteps.map((step, i) => (
+ -
+
+ {i + 1}.
+
+ {step}
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
+
+export type { SchemaSection, SchemaProperty } from "./types";
diff --git a/site/components/schema-viz/types.ts b/site/components/schema-viz/types.ts
new file mode 100644
index 0000000..361886f
--- /dev/null
+++ b/site/components/schema-viz/types.ts
@@ -0,0 +1,13 @@
+export interface SchemaProperty {
+ name: string;
+ type: string;
+ description: string;
+ required?: boolean;
+ example?: string;
+}
+
+export interface SchemaSection {
+ title: string;
+ description: string;
+ properties: SchemaProperty[];
+}
diff --git a/site/components/theme-card.tsx b/site/components/theme-card.tsx
deleted file mode 100644
index 7ebed91..0000000
--- a/site/components/theme-card.tsx
+++ /dev/null
@@ -1,296 +0,0 @@
-"use client";
-
-import React from "react";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-
-interface ThemeCardProps {
- themeName: string;
- isVisible?: boolean;
-}
-
-// Enhanced sample logs with different levels for better theme demonstration
-const sampleLogs = [
- "[2024-01-15 10:23:45] INFO: Server started on port 3000",
- "GET /api/users 200 OK (123ms)",
- "WARN: Memory usage high: 85% (1.7GB/2GB)",
- "[ERROR] Database connection failed: ECONNREFUSED 127.0.0.1:5432",
- "DEBUG: SQL Query executed in 45ms",
- "ā All tests passed (42 tests, 0 failures)",
- "Processing batch job... [āāāāāāāāāāāāāāāāāāāā] 100%",
- "š Deployment completed to production environment",
-];
-
-// Format theme name for display
-const formatThemeName = (name: string) => {
- return name
- .split("-")
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
- .join(" ");
-};
-
-// Get theme colors with proper contrast - using direct theme definitions
-const getThemeColors = (themeName: string) => {
- const themeColors: Record<
- string,
- { bg: string; text: string; border: string; mode: "light" | "dark" }
- > = {
- "oh-my-zsh": {
- bg: "#2c3e50",
- text: "#ecf0f1",
- border: "#34495e",
- mode: "dark",
- },
- dracula: {
- bg: "#282a36",
- text: "#f8f8f2",
- border: "#44475a",
- mode: "dark",
- },
- "github-light": {
- bg: "#ffffff",
- text: "#1f2328",
- border: "#d1d9e0",
- mode: "light",
- },
- "github-dark": {
- bg: "#0d1117",
- text: "#e6edf3",
- border: "#30363d",
- mode: "dark",
- },
- "solarized-light": {
- bg: "#fdf6e3",
- text: "#657b83",
- border: "#eee8d5",
- mode: "light",
- },
- "solarized-dark": {
- bg: "#002b36",
- text: "#839496",
- border: "#073642",
- mode: "dark",
- },
- nord: {
- bg: "#2e3440",
- text: "#eceff4",
- border: "#4c566a",
- mode: "dark",
- },
- monokai: {
- bg: "#272822",
- text: "#f8f8f2",
- border: "#75715e",
- mode: "dark",
- },
- };
-
- return (
- themeColors[themeName] || {
- bg: "#1a1a1a",
- text: "#ffffff",
- border: "#333333",
- mode: "dark",
- }
- );
-};
-
-// Simple theme-based log styling
-const getStyledLogs = (themeName: string) => {
- const colors = getThemeColors(themeName);
-
- // Theme-specific color mappings
- const getLogColors = (logType: string) => {
- const colorMaps: Record> = {
- "oh-my-zsh": {
- info: "#3498db",
- warn: "#f39c12",
- error: "#e74c3c",
- success: "#27ae60",
- debug: "#2ecc71",
- },
- dracula: {
- info: "#8be9fd",
- warn: "#ffb86c",
- error: "#ff5555",
- success: "#50fa7b",
- debug: "#bd93f9",
- },
- "github-light": {
- info: "#0969da",
- warn: "#fb8500",
- error: "#cf222e",
- success: "#1f883d",
- debug: "#8250df",
- },
- "github-dark": {
- info: "#58a6ff",
- warn: "#f0883e",
- error: "#f85149",
- success: "#3fb950",
- debug: "#a5a5ff",
- },
- "solarized-light": {
- info: "#268bd2",
- warn: "#cb4b16",
- error: "#dc322f",
- success: "#859900",
- debug: "#6c71c4",
- },
- "solarized-dark": {
- info: "#268bd2",
- warn: "#cb4b16",
- error: "#dc322f",
- success: "#859900",
- debug: "#6c71c4",
- },
- nord: {
- info: "#5e81ac",
- warn: "#d08770",
- error: "#bf616a",
- success: "#a3be8c",
- debug: "#b48ead",
- },
- monokai: {
- info: "#66d9ef",
- warn: "#fd971f",
- error: "#f92672",
- success: "#a6e22e",
- debug: "#ae81ff",
- },
- };
-
- return colorMaps[themeName]?.[logType] || colors.text;
- };
-
- return sampleLogs.map((log) => {
- let styledLog = log;
-
- if (log.includes("WARN") || log.includes("Memory usage")) {
- styledLog = `${log}`;
- } else if (log.includes("ERROR") || log.includes("failed")) {
- styledLog = `${log}`;
- } else if (
- log.includes("ā") ||
- log.includes("š") ||
- log.includes("successful")
- ) {
- styledLog = `${log}`;
- } else if (log.includes("DEBUG")) {
- styledLog = `${log}`;
- } else {
- styledLog = `${log}`;
- }
-
- return styledLog;
- });
-};
-
-export function ThemeCard({ themeName, isVisible = true }: ThemeCardProps) {
- const colors = getThemeColors(themeName);
- const styledLogs = getStyledLogs(themeName);
-
- if (!isVisible) return null;
-
- return (
-
-
- {formatThemeName(themeName)}
-
-
- {/* Dual-pane layout */}
-
- {/* Browser Pane */}
-
-
- {/* Browser header */}
-
-
- Browser
-
-
-
- {/* Browser content with styled logs */}
-
- {styledLogs.slice(0, 6).map((log, i) => (
-
- ))}
-
-
-
-
- {/* Terminal Pane */}
-
-
- {/* Terminal header */}
-
-
- Terminal
-
-
-
- {/* Terminal content with styled logs */}
-
- {styledLogs.slice(0, 6).map((log, i) => (
-
- ))}
-
-
-
-
-
-
- );
-}
diff --git a/site/components/theme-card/LogPane/constants.ts b/site/components/theme-card/LogPane/constants.ts
new file mode 100644
index 0000000..3ea1bfb
--- /dev/null
+++ b/site/components/theme-card/LogPane/constants.ts
@@ -0,0 +1,9 @@
+export const HEADER_GRADIENTS = {
+ light: "linear-gradient(to bottom, rgba(0,0,0,0.05), transparent)",
+ dark: "linear-gradient(to bottom, rgba(255,255,255,0.1), transparent)",
+} as const;
+
+export const HEADER_TEXT_COLORS = {
+ light: "rgba(0,0,0,0.6)",
+ dark: "rgba(255,255,255,0.6)",
+} as const;
diff --git a/site/components/theme-card/LogPane/index.tsx b/site/components/theme-card/LogPane/index.tsx
new file mode 100644
index 0000000..b3d7b11
--- /dev/null
+++ b/site/components/theme-card/LogPane/index.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import React from "react";
+import type { LogPaneProps } from "./types";
+import { HEADER_GRADIENTS, HEADER_TEXT_COLORS } from "./constants";
+
+export function LogPane({
+ title,
+ logs,
+ backgroundColor,
+ mode,
+ isLoading = false,
+}: LogPaneProps) {
+ const headerGradient = HEADER_GRADIENTS[mode];
+ const headerText = HEADER_TEXT_COLORS[mode];
+
+ return (
+
+
+
+ {title}
+
+
+
+
+ {isLoading ? (
+
+ ) : (
+ logs.map((log, i) => (
+
+ ))
+ )}
+
+
+ );
+}
+
+export type { LogPaneProps } from "./types";
diff --git a/site/components/theme-card/LogPane/types.ts b/site/components/theme-card/LogPane/types.ts
new file mode 100644
index 0000000..7d9b4a7
--- /dev/null
+++ b/site/components/theme-card/LogPane/types.ts
@@ -0,0 +1,7 @@
+export interface LogPaneProps {
+ title: string;
+ logs: string[];
+ backgroundColor: string;
+ mode: "light" | "dark";
+ isLoading?: boolean;
+}
diff --git a/site/components/theme-card/constants.ts b/site/components/theme-card/constants.ts
new file mode 100644
index 0000000..1456e92
--- /dev/null
+++ b/site/components/theme-card/constants.ts
@@ -0,0 +1,26 @@
+import type { ThemeBackground } from "./types";
+
+export const SAMPLE_LOGS = [
+ "[2024-01-15 10:23:45] INFO: Server started on port 3000",
+ "GET /api/users 200 OK (123ms)",
+ "WARN: Memory usage high: 85% (1.7GB/2GB)",
+ "[ERROR] Database connection failed: ECONNREFUSED 127.0.0.1:5432",
+ "DEBUG: SQL Query executed in 45ms",
+ "SUCCESS: All tests passed (42 tests, 0 failures)",
+];
+
+export const THEME_BACKGROUNDS: Record = {
+ "oh-my-zsh": { bg: "#2c3e50", mode: "dark" },
+ dracula: { bg: "#282a36", mode: "dark" },
+ "github-light": { bg: "#ffffff", mode: "light" },
+ "github-dark": { bg: "#0d1117", mode: "dark" },
+ "solarized-light": { bg: "#fdf6e3", mode: "light" },
+ "solarized-dark": { bg: "#002b36", mode: "dark" },
+ nord: { bg: "#2e3440", mode: "dark" },
+ monokai: { bg: "#272822", mode: "dark" },
+};
+
+export const DEFAULT_BACKGROUND: ThemeBackground = {
+ bg: "#1a1a1a",
+ mode: "dark",
+};
diff --git a/site/components/theme-card/index.tsx b/site/components/theme-card/index.tsx
new file mode 100644
index 0000000..04b4285
--- /dev/null
+++ b/site/components/theme-card/index.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import React, { useMemo } from "react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { useThemeProcessor } from "@/hooks/useThemeProcessor";
+import { LogPane } from "./LogPane";
+import { formatThemeName } from "./utils";
+import {
+ SAMPLE_LOGS,
+ THEME_BACKGROUNDS,
+ DEFAULT_BACKGROUND,
+} from "./constants";
+import type { ThemeCardProps } from "./types";
+
+export function ThemeCard({ themeName, isVisible = true }: ThemeCardProps) {
+ const logs = useMemo(() => SAMPLE_LOGS, []);
+ const { processedLogs, isLoading, theme } = useThemeProcessor(
+ themeName,
+ logs,
+ );
+ const colors = THEME_BACKGROUNDS[themeName] || DEFAULT_BACKGROUND;
+
+ if (!isVisible) return null;
+
+ const htmlLogs = processedLogs.map((log) => log.html);
+
+ return (
+
+
+
+ {formatThemeName(themeName)}
+ {theme?.mode && (
+
+ {theme.mode}
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+export type { ThemeCardProps } from "./types";
diff --git a/site/components/theme-card/types.ts b/site/components/theme-card/types.ts
new file mode 100644
index 0000000..85fd615
--- /dev/null
+++ b/site/components/theme-card/types.ts
@@ -0,0 +1,9 @@
+export interface ThemeCardProps {
+ themeName: string;
+ isVisible?: boolean;
+}
+
+export interface ThemeBackground {
+ bg: string;
+ mode: "light" | "dark";
+}
diff --git a/site/components/theme-card/utils.ts b/site/components/theme-card/utils.ts
new file mode 100644
index 0000000..94687bc
--- /dev/null
+++ b/site/components/theme-card/utils.ts
@@ -0,0 +1,6 @@
+export const formatThemeName = (name: string): string => {
+ return name
+ .split("-")
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(" ");
+};
diff --git a/site/components/ui/animated-section.tsx b/site/components/ui/animated-section.tsx
new file mode 100644
index 0000000..1c0caf1
--- /dev/null
+++ b/site/components/ui/animated-section.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+
+interface AnimatedSectionProps {
+ children: React.ReactNode;
+ className?: string;
+ delay?: number;
+}
+
+export function AnimatedSection({
+ children,
+ className = "",
+ delay = 0,
+}: AnimatedSectionProps) {
+ const [isVisible, setIsVisible] = useState(false);
+ const ref = useRef(null);
+
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting) {
+ setTimeout(() => setIsVisible(true), delay);
+ observer.disconnect();
+ }
+ },
+ { threshold: 0.1 },
+ );
+
+ if (ref.current) observer.observe(ref.current);
+ return () => observer.disconnect();
+ }, [delay]);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/site/content/docs/api/logsdx.mdx b/site/content/docs/api/logsdx.mdx
new file mode 100644
index 0000000..f04312f
--- /dev/null
+++ b/site/content/docs/api/logsdx.mdx
@@ -0,0 +1,165 @@
+---
+title: LogsDX Class
+description: Main API for processing and styling logs
+---
+
+## Overview
+
+The `LogsDX` class is the primary interface for processing and styling log content. It provides a singleton instance that manages themes and output formats.
+
+## Quick Start
+
+```typescript
+import { getLogsDX } from "logsdx";
+
+const logsdx = await getLogsDX({ theme: "dracula" });
+const styled = logsdx.processLine("[INFO] Application started");
+console.log(styled);
+```
+
+## Factory Function
+
+### getLogsDX(options?)
+
+Creates or retrieves the LogsDX singleton instance.
+
+```typescript
+const logsdx = await getLogsDX({
+ theme: "nord",
+ outputFormat: "ansi",
+});
+```
+
+## Options
+
+| Option | Type | Default | Description |
+| -------------------- | ------------------------------ | ------------- | ----------------------------------- |
+| `theme` | `string \| Theme \| ThemePair` | `'oh-my-zsh'` | Theme to use for styling |
+| `outputFormat` | `'ansi' \| 'html'` | `'ansi'` | Output format |
+| `htmlStyleFormat` | `'css' \| 'className'` | `'css'` | HTML styling method |
+| `escapeHtml` | `boolean` | `true` | Escape HTML entities |
+| `autoAdjustTerminal` | `boolean` | `true` | Auto-adjust for terminal background |
+| `debug` | `boolean` | `false` | Enable debug logging |
+
+## Instance Methods
+
+### processLine(line)
+
+Process a single log line with current theme.
+
+```typescript
+const styled = logsdx.processLine("[ERROR] Connection failed");
+```
+
+### processLines(lines)
+
+Process multiple log lines.
+
+```typescript
+const lines = ["[INFO] Start", "[WARN] Memory high"];
+const styled = logsdx.processLines(lines);
+```
+
+### processLog(content)
+
+Process entire log content (string with newlines).
+
+```typescript
+const content = fs.readFileSync("app.log", "utf-8");
+const styled = logsdx.processLog(content);
+```
+
+### setTheme(theme)
+
+Change the current theme.
+
+```typescript
+await logsdx.setTheme("github-dark");
+// or with custom theme
+await logsdx.setTheme({
+ name: "custom",
+ schema: {
+ /* ... */
+ },
+});
+```
+
+### getCurrentTheme()
+
+Get the currently active theme.
+
+```typescript
+const theme = logsdx.getCurrentTheme();
+console.log(theme.name); // 'dracula'
+```
+
+### setOutputFormat(format)
+
+Switch between ANSI and HTML output.
+
+```typescript
+logsdx.setOutputFormat("html");
+```
+
+### setHtmlStyleFormat(format)
+
+Switch HTML styling method.
+
+```typescript
+logsdx.setHtmlStyleFormat("className");
+```
+
+### getAllThemes()
+
+Get all available themes.
+
+```typescript
+const themes = logsdx.getAllThemes();
+// { dracula: Theme, nord: Theme, ... }
+```
+
+### getThemeNames()
+
+Get list of theme names.
+
+```typescript
+const names = logsdx.getThemeNames();
+// ['dracula', 'nord', 'monokai', ...]
+```
+
+## Static Methods
+
+### LogsDX.getInstance(options?)
+
+Get or create the singleton instance.
+
+```typescript
+const logsdx = await LogsDX.getInstance({ theme: "nord" });
+```
+
+### LogsDX.resetInstance()
+
+Reset the singleton (useful for testing).
+
+```typescript
+LogsDX.resetInstance();
+```
+
+## Theme Pair
+
+Use different themes based on terminal background:
+
+```typescript
+const logsdx = await getLogsDX({
+ theme: {
+ light: "github-light",
+ dark: "github-dark",
+ },
+});
+```
+
+## TypeScript Types
+
+```typescript
+import type { Theme, ThemePair, StyleOptions, LogsDXOptions } from "logsdx";
+```
diff --git a/site/content/docs/api/renderer.mdx b/site/content/docs/api/renderer.mdx
new file mode 100644
index 0000000..22306ad
--- /dev/null
+++ b/site/content/docs/api/renderer.mdx
@@ -0,0 +1,203 @@
+---
+title: Renderer API
+description: Convert styled tokens to ANSI or HTML output
+---
+
+## Overview
+
+The renderer converts styled tokens into ANSI escape codes for terminals or HTML markup for browsers.
+
+## Functions
+
+### renderLine(line, theme?, options?)
+
+Render a single log line with styling.
+
+```typescript
+import { renderLine, getTheme } from "logsdx";
+
+const theme = await getTheme("dracula");
+const ansi = renderLine("[INFO] Started", theme);
+const html = renderLine("[INFO] Started", theme, { outputFormat: "html" });
+```
+
+### Render Options
+
+| Option | Type | Default | Description |
+| ----------------- | ---------------------- | -------- | -------------------- |
+| `outputFormat` | `'ansi' \| 'html'` | `'ansi'` | Output format |
+| `htmlStyleFormat` | `'css' \| 'className'` | `'css'` | HTML styling method |
+| `escapeHtml` | `boolean` | `true` | Escape HTML entities |
+
+## Token Rendering
+
+### tokensToString(tokens, forceColors?)
+
+Convert tokens to ANSI string.
+
+```typescript
+import { tokensToString } from "logsdx";
+
+const ansi = tokensToString(styledTokens);
+```
+
+### tokensToHtml(tokens, options?)
+
+Convert tokens to HTML with inline styles.
+
+```typescript
+import { tokensToHtml } from "logsdx";
+
+const html = tokensToHtml(styledTokens);
+// ERROR
+```
+
+### tokensToClassNames(tokens, options?)
+
+Convert tokens to HTML with CSS classes.
+
+```typescript
+import { tokensToClassNames } from "logsdx";
+
+const html = tokensToClassNames(styledTokens);
+// ERROR
+```
+
+## Background Detection
+
+### detectBackground()
+
+Detect terminal/browser background color.
+
+```typescript
+import { detectBackground } from "logsdx";
+
+const info = detectBackground();
+// { scheme: 'dark', confidence: 0.9 }
+```
+
+### isDarkBackground()
+
+Check if background is dark.
+
+```typescript
+import { isDarkBackground } from "logsdx";
+
+if (isDarkBackground()) {
+ // Use dark theme
+}
+```
+
+### getRecommendedThemeMode()
+
+Get recommended theme mode based on environment.
+
+```typescript
+import { getRecommendedThemeMode } from "logsdx";
+
+const mode = getRecommendedThemeMode();
+// 'dark' or 'light'
+```
+
+### watchBackgroundChanges(callback)
+
+Watch for background color changes (browser).
+
+```typescript
+import { watchBackgroundChanges } from "logsdx";
+
+const unsubscribe = watchBackgroundChanges((info) => {
+ console.log("Background changed:", info.scheme);
+});
+
+// Later: unsubscribe();
+```
+
+## Style Application
+
+### applyColor(text, color)
+
+Apply color to text (ANSI).
+
+```typescript
+import { applyColor } from "logsdx";
+
+const colored = applyColor("ERROR", "#ff5555");
+```
+
+### Style Functions
+
+```typescript
+import { applyBold, applyItalic, applyUnderline, applyDim } from "logsdx";
+
+const bold = applyBold("Important");
+const italic = applyItalic("Note");
+const underlined = applyUnderline("Link");
+const dimmed = applyDim("Debug info");
+```
+
+## LightBox Rendering
+
+For highlighted/boxed output:
+
+### renderLightBox(content, options?)
+
+Render content in a highlighted box.
+
+```typescript
+import { renderLightBox } from "logsdx";
+
+const boxed = renderLightBox("Important message");
+```
+
+### renderLightBoxLine(line)
+
+Render a single line with highlight background.
+
+```typescript
+import { renderLightBoxLine } from "logsdx";
+
+const highlighted = renderLightBoxLine("[CRITICAL] System failure");
+```
+
+## Color Utilities
+
+### fg256(code)
+
+Generate 256-color ANSI code.
+
+```typescript
+import { fg256 } from "logsdx";
+
+const orange = fg256(208);
+```
+
+### fgRGB(r, g, b)
+
+Generate true color ANSI code.
+
+```typescript
+import { fgRGB } from "logsdx";
+
+const custom = fgRGB(255, 128, 0);
+```
+
+## TypeScript Types
+
+```typescript
+import type { RenderOptions, BackgroundInfo, ColorScheme } from "logsdx";
+
+interface RenderOptions {
+ outputFormat?: "ansi" | "html";
+ htmlStyleFormat?: "css" | "className";
+ escapeHtml?: boolean;
+ theme?: Theme;
+}
+
+interface BackgroundInfo {
+ scheme: ColorScheme;
+ confidence: number;
+}
+
+type ColorScheme = "dark" | "light" | "unknown";
+```
diff --git a/site/content/docs/api/themes.mdx b/site/content/docs/api/themes.mdx
new file mode 100644
index 0000000..9f4b4fe
--- /dev/null
+++ b/site/content/docs/api/themes.mdx
@@ -0,0 +1,219 @@
+---
+title: Themes API
+description: Theme loading, registration, and creation utilities
+---
+
+## Overview
+
+logsDX provides utilities for loading built-in themes, registering custom themes, and creating new themes programmatically.
+
+## Loading Themes
+
+### getTheme(name)
+
+Load a theme by name (async).
+
+```typescript
+import { getTheme } from "logsdx";
+
+const theme = await getTheme("dracula");
+```
+
+### getAllThemes()
+
+Get all loaded themes.
+
+```typescript
+import { getAllThemes } from "logsdx";
+
+const themes = getAllThemes();
+// { dracula: Theme, nord: Theme, ... }
+```
+
+### getThemeNames()
+
+Get list of available theme names.
+
+```typescript
+import { getThemeNames } from "logsdx";
+
+const names = getThemeNames();
+// ['dracula', 'nord', 'monokai', ...]
+```
+
+## Preloading
+
+### preloadTheme(name)
+
+Preload a theme for faster access.
+
+```typescript
+import { preloadTheme } from "logsdx";
+
+await preloadTheme("dracula");
+```
+
+### preloadAllThemes()
+
+Preload all built-in themes.
+
+```typescript
+import { preloadAllThemes } from "logsdx";
+
+await preloadAllThemes();
+```
+
+## Registration
+
+### registerTheme(theme)
+
+Register a custom theme.
+
+```typescript
+import { registerTheme } from "logsdx";
+
+registerTheme({
+ name: "my-theme",
+ mode: "dark",
+ schema: {
+ defaultStyle: { color: "#f8f8f2" },
+ matchWords: {
+ ERROR: { color: "#ff5555", styleCodes: ["bold"] },
+ },
+ },
+});
+```
+
+### registerThemeLoader(name, loader)
+
+Register a lazy-loaded theme.
+
+```typescript
+import { registerThemeLoader } from "logsdx";
+
+registerThemeLoader("my-theme", async () => {
+ const response = await fetch("/themes/my-theme.json");
+ return response.json();
+});
+```
+
+## Theme Builder
+
+### ThemeBuilder
+
+Fluent API for building themes.
+
+```typescript
+import { ThemeBuilder } from "logsdx";
+
+const theme = new ThemeBuilder("my-theme")
+ .mode("dark")
+ .defaultStyle({ color: "#f8f8f2" })
+ .matchWord("ERROR", { color: "#ff5555", styleCodes: ["bold"] })
+ .matchWord("WARN", { color: "#ffb86c" })
+ .matchWord("INFO", { color: "#8be9fd" })
+ .matchPattern(/\d{4}-\d{2}-\d{2}/, { color: "#6272a4" })
+ .build();
+```
+
+### createTheme(config)
+
+Create a theme from configuration.
+
+```typescript
+import { createTheme } from "logsdx";
+
+const theme = createTheme({
+ name: "my-theme",
+ mode: "dark",
+ colors: {
+ error: "#ff5555",
+ warning: "#ffb86c",
+ info: "#8be9fd",
+ },
+ schema: {
+ defaultStyle: { color: "#f8f8f2" },
+ matchWords: {
+ ERROR: { color: "#ff5555" },
+ },
+ },
+});
+```
+
+### createSimpleTheme(config)
+
+Create a theme with simplified configuration.
+
+```typescript
+import { createSimpleTheme } from "logsdx";
+
+const theme = createSimpleTheme({
+ name: "simple",
+ mode: "dark",
+ colors: {
+ error: "#ff0000",
+ warning: "#ffff00",
+ info: "#00ffff",
+ success: "#00ff00",
+ debug: "#808080",
+ },
+});
+```
+
+### extendTheme(base, overrides)
+
+Extend an existing theme.
+
+```typescript
+import { extendTheme, getTheme } from "logsdx";
+
+const dracula = await getTheme("dracula");
+const customDracula = extendTheme(dracula, {
+ name: "dracula-custom",
+ schema: {
+ matchWords: {
+ CUSTOM: { color: "#ff79c6" },
+ },
+ },
+});
+```
+
+## Built-in Themes
+
+| Theme | Mode | Description |
+| ----------------- | ----- | -------------------- |
+| `dracula` | dark | Vibrant dark theme |
+| `github-dark` | dark | GitHub dark mode |
+| `github-light` | light | GitHub light mode |
+| `nord` | dark | Arctic color palette |
+| `monokai` | dark | Classic Monokai |
+| `solarized-dark` | dark | Solarized dark |
+| `solarized-light` | light | Solarized light |
+| `oh-my-zsh` | dark | Oh My Zsh inspired |
+
+## Theme Validation
+
+### validateTheme(theme)
+
+Validate a theme object.
+
+```typescript
+import { validateTheme } from "logsdx";
+
+const validated = validateTheme(myTheme);
+```
+
+### validateThemeSafe(theme)
+
+Validate without throwing errors.
+
+```typescript
+import { validateThemeSafe } from "logsdx";
+
+const result = validateThemeSafe(myTheme);
+if (result.success) {
+ console.log(result.data);
+} else {
+ console.error(result.error);
+}
+```
diff --git a/site/content/docs/api/tokenizer.mdx b/site/content/docs/api/tokenizer.mdx
new file mode 100644
index 0000000..0fae41d
--- /dev/null
+++ b/site/content/docs/api/tokenizer.mdx
@@ -0,0 +1,164 @@
+---
+title: Tokenizer API
+description: Low-level tokenization and theme application
+---
+
+## Overview
+
+The tokenizer breaks log lines into tokens and applies theme-based styling. This is the core engine that powers logsDX's pattern matching.
+
+## Functions
+
+### tokenize(line, theme?)
+
+Tokenize a log line into individual tokens.
+
+```typescript
+import { tokenize } from "logsdx";
+
+const tokens = tokenize("[INFO] Server started", theme);
+// Returns TokenList array
+```
+
+### applyTheme(tokens, theme)
+
+Apply theme styling to tokens.
+
+```typescript
+import { tokenize, applyTheme } from "logsdx";
+
+const tokens = tokenize(line, theme);
+const styledTokens = applyTheme(tokens, theme);
+```
+
+## Token Structure
+
+Each token has the following structure:
+
+```typescript
+interface Token {
+ content: string;
+ metadata?: {
+ matchType: MatcherType;
+ matchPattern: string;
+ style?: StyleOptions;
+ };
+}
+
+type TokenList = Token[];
+```
+
+## Match Types
+
+Tokens are categorized by how they were matched:
+
+| Type | Description |
+| ----------- | ----------------------------- |
+| `word` | Matched via `matchWords` |
+| `regex` | Matched via `matchPatterns` |
+| `timestamp` | Timestamp pattern |
+| `level` | Log level (INFO, ERROR, etc.) |
+| `space` | Single space |
+| `spaces` | Multiple spaces |
+| `tab` | Tab character |
+| `newline` | Newline character |
+| `default` | No specific match |
+
+## Lexer API
+
+For advanced use cases, you can create custom lexers:
+
+### createLexer(theme?)
+
+Create a lexer instance.
+
+```typescript
+import { createLexer } from "logsdx/tokenizer";
+
+const lexer = createLexer(theme);
+const tokens = lexer.tokenize(line);
+```
+
+### SimpleLexer
+
+Low-level lexer class.
+
+```typescript
+import { SimpleLexer } from "logsdx/tokenizer";
+
+const lexer = new SimpleLexer();
+
+// Add rules
+lexer.rule(/\d+/, (ctx) => {
+ ctx.accept("number");
+});
+
+lexer.rule(/[a-z]+/i, (ctx) => {
+ ctx.accept("word");
+});
+
+// Tokenize
+const tokens = lexer.tokenize("hello 123 world");
+```
+
+### Adding Rules
+
+```typescript
+import {
+ addWhitespaceRules,
+ addDefaultLogRules,
+ addWordMatchRules,
+ addPatternMatchRules,
+} from "logsdx/tokenizer";
+
+const lexer = new SimpleLexer();
+addWhitespaceRules(lexer);
+addDefaultLogRules(lexer);
+addWordMatchRules(lexer, { ERROR: { color: "#ff0000" } });
+```
+
+## Pattern Matching Priority
+
+When tokenizing, patterns are matched in this order:
+
+1. **matchPatterns** - Regex patterns (highest priority)
+2. **matchWords** - Exact word matches
+3. **matchStartsWith** - Prefix matches
+4. **matchEndsWith** - Suffix matches
+5. **matchContains** - Substring matches
+6. **defaultStyle** - Fallback (lowest priority)
+
+## Example: Custom Tokenization
+
+```typescript
+import { tokenize, applyTheme } from "logsdx";
+
+const theme = {
+ name: "custom",
+ schema: {
+ defaultStyle: { color: "#ffffff" },
+ matchWords: {
+ ERROR: { color: "#ff0000", styleCodes: ["bold"] },
+ WARN: { color: "#ffff00" },
+ },
+ matchPatterns: [
+ {
+ pattern: "\\d{4}-\\d{2}-\\d{2}",
+ options: { color: "#888888" },
+ },
+ ],
+ },
+};
+
+const line = "[2024-01-15] ERROR: Connection failed";
+const tokens = tokenize(line, theme);
+const styled = applyTheme(tokens, theme);
+
+// styled tokens now have style metadata attached
+```
+
+## TypeScript Types
+
+```typescript
+import type { Token, TokenList, MatcherType } from "logsdx";
+```
diff --git a/site/content/docs/guides/cli-usage.mdx b/site/content/docs/guides/cli-usage.mdx
new file mode 100644
index 0000000..2c59d3c
--- /dev/null
+++ b/site/content/docs/guides/cli-usage.mdx
@@ -0,0 +1,92 @@
+---
+title: CLI Usage
+description: Style your logs from anywhere with a single command
+---
+
+## Installation
+
+Install logsDX globally using your preferred package manager:
+
+```bash
+# npm
+npm install -g logsdx
+
+# pnpm
+pnpm add -g logsdx
+
+# bun
+bun add -g logsdx
+```
+
+## Commands
+
+### Pipe Logs
+
+Pipe any log output through logsdx for instant styling:
+
+```bash
+cat server.log | logsdx --theme dracula
+```
+
+### Process Files
+
+Process log files directly with your chosen theme:
+
+```bash
+logsdx process app.log --theme github-dark
+```
+
+### Interactive Theme Creator
+
+Create custom themes with an interactive wizard:
+
+```bash
+logsdx create-theme
+```
+
+### Preview Themes
+
+Preview any theme with sample logs before using:
+
+```bash
+logsdx preview --theme nord
+```
+
+### Tail with Style
+
+Follow logs in real-time with beautiful styling:
+
+```bash
+tail -f /var/log/app.log | logsdx --theme monokai
+```
+
+### List Themes
+
+View all available built-in themes:
+
+```bash
+logsdx themes
+```
+
+## Available Themes
+
+logsDX comes with several built-in themes:
+
+- **dracula** - Dark theme with vibrant colors
+- **github-dark** - GitHub's dark mode
+- **github-light** - GitHub's light mode
+- **nord** - Arctic, north-bluish color palette
+- **monokai** - Classic Monokai colors
+- **solarized-dark** - Solarized dark variant
+- **solarized-light** - Solarized light variant
+- **oh-my-zsh** - Inspired by Oh My Zsh
+
+## Options
+
+| Option | Description |
+| ----------------- | ------------------------------- |
+| `--theme ` | Specify theme to use |
+| `--output ` | Save output to file |
+| `--format ` | Output format: `ansi` or `html` |
+| `--help` | Show help information |
+| `--version` | Show version number |
diff --git a/site/content/docs/guides/custom-themes.mdx b/site/content/docs/guides/custom-themes.mdx
new file mode 100644
index 0000000..88cc68a
--- /dev/null
+++ b/site/content/docs/guides/custom-themes.mdx
@@ -0,0 +1,239 @@
+---
+title: Custom Themes
+description: Create and customize your own logsDX themes
+---
+
+## Overview
+
+logsDX themes define how log content is styled. You can create custom themes to match your preferences or brand.
+
+## Theme Structure
+
+A theme consists of:
+
+```typescript
+interface Theme {
+ name: string;
+ description?: string;
+ mode?: "light" | "dark" | "auto";
+ schema: SchemaConfig;
+ colors?: Record;
+}
+```
+
+## Creating a Theme
+
+### Using ThemeBuilder
+
+The easiest way to create a theme:
+
+```typescript
+import { ThemeBuilder } from "logsdx";
+
+const theme = new ThemeBuilder("my-theme")
+ .mode("dark")
+ .description("My custom dark theme")
+ .defaultStyle({ color: "#f8f8f2" })
+ .matchWord("ERROR", { color: "#ff5555", styleCodes: ["bold"] })
+ .matchWord("WARN", { color: "#ffb86c" })
+ .matchWord("INFO", { color: "#8be9fd" })
+ .matchWord("DEBUG", { color: "#6272a4" })
+ .matchWord("SUCCESS", { color: "#50fa7b" })
+ .matchPattern(/\d{4}-\d{2}-\d{2}/, { color: "#6272a4" })
+ .matchPattern(/\d{2}:\d{2}:\d{2}/, { color: "#6272a4" })
+ .build();
+```
+
+### Using JSON
+
+Create a JSON file:
+
+```json
+{
+ "name": "my-theme",
+ "mode": "dark",
+ "description": "My custom theme",
+ "schema": {
+ "defaultStyle": { "color": "#f8f8f2" },
+ "matchWords": {
+ "ERROR": { "color": "#ff5555", "styleCodes": ["bold"] },
+ "WARN": { "color": "#ffb86c" },
+ "INFO": { "color": "#8be9fd" },
+ "DEBUG": { "color": "#6272a4" }
+ },
+ "matchPatterns": [
+ {
+ "pattern": "\\d{4}-\\d{2}-\\d{2}",
+ "options": { "color": "#6272a4" }
+ }
+ ]
+ }
+}
+```
+
+## Matching Rules
+
+### matchWords
+
+Exact word matches (case-insensitive):
+
+```typescript
+matchWords: {
+ 'ERROR': { color: '#ff5555', styleCodes: ['bold'] },
+ 'WARN': { color: '#ffb86c' },
+ 'INFO': { color: '#8be9fd' },
+}
+```
+
+### matchStartsWith
+
+Match tokens starting with a prefix:
+
+```typescript
+matchStartsWith: {
+ '[': { color: '#6272a4' },
+ 'http': { color: '#8be9fd' },
+}
+```
+
+### matchEndsWith
+
+Match tokens ending with a suffix:
+
+```typescript
+matchEndsWith: {
+ 'ms': { color: '#bd93f9' },
+ 's': { color: '#bd93f9' },
+}
+```
+
+### matchContains
+
+Match tokens containing a substring:
+
+```typescript
+matchContains: {
+ '://': { color: '#8be9fd' },
+ '@': { color: '#ff79c6' },
+}
+```
+
+### matchPatterns
+
+Regex patterns for complex matching:
+
+```typescript
+matchPatterns: [
+ {
+ name: "timestamp",
+ pattern: "\\d{4}-\\d{2}-\\d{2}",
+ options: { color: "#6272a4" },
+ },
+ {
+ name: "ip-address",
+ pattern: "\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}",
+ options: { color: "#bd93f9" },
+ },
+ {
+ name: "uuid",
+ pattern: "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
+ options: { color: "#ff79c6" },
+ },
+];
+```
+
+## Style Options
+
+Each match can have these style options:
+
+```typescript
+interface StyleOptions {
+ color: string; // Hex color code
+ styleCodes?: StyleCode[]; // Text decorations
+ htmlStyleFormat?: "css" | "className";
+}
+
+type StyleCode =
+ | "bold"
+ | "italic"
+ | "underline"
+ | "dim"
+ | "blink"
+ | "reverse"
+ | "strikethrough";
+```
+
+## Extending Themes
+
+Extend an existing theme:
+
+```typescript
+import { extendTheme, getTheme } from "logsdx";
+
+const dracula = await getTheme("dracula");
+
+const myTheme = extendTheme(dracula, {
+ name: "dracula-extended",
+ schema: {
+ matchWords: {
+ CUSTOM: { color: "#ff79c6" },
+ SPECIAL: { color: "#50fa7b", styleCodes: ["bold"] },
+ },
+ },
+});
+```
+
+## Registering Themes
+
+### Register Immediately
+
+```typescript
+import { registerTheme } from "logsdx";
+
+registerTheme(myTheme);
+```
+
+### Lazy Loading
+
+```typescript
+import { registerThemeLoader } from "logsdx";
+
+registerThemeLoader("my-theme", async () => {
+ const response = await fetch("/themes/my-theme.json");
+ return response.json();
+});
+```
+
+## Color Palette
+
+Define reusable colors:
+
+```typescript
+const theme = {
+ name: "my-theme",
+ colors: {
+ background: "#282a36",
+ foreground: "#f8f8f2",
+ error: "#ff5555",
+ warning: "#ffb86c",
+ info: "#8be9fd",
+ success: "#50fa7b",
+ debug: "#6272a4",
+ },
+ schema: {
+ defaultStyle: { color: "#f8f8f2" },
+ matchWords: {
+ ERROR: { color: "#ff5555" },
+ // Use colors from palette
+ },
+ },
+};
+```
+
+## Best Practices
+
+1. **Use semantic names** for patterns (`timestamp`, `log-level`)
+2. **Test with real logs** before deploying
+3. **Consider accessibility** - ensure sufficient contrast
+4. **Start simple** - add patterns as needed
+5. **Use `mode`** to help auto-detection work correctly
diff --git a/site/content/docs/guides/integrations.mdx b/site/content/docs/guides/integrations.mdx
new file mode 100644
index 0000000..4a60f9d
--- /dev/null
+++ b/site/content/docs/guides/integrations.mdx
@@ -0,0 +1,291 @@
+---
+title: Integrations
+description: Use logsDX with popular tools and frameworks
+---
+
+## Node.js
+
+### Basic Usage
+
+```typescript
+import { getLogsDX } from "logsdx";
+
+const logsdx = await getLogsDX({ theme: "dracula" });
+
+// Style console output
+const originalLog = console.log;
+console.log = (...args) => {
+ const styled = args.map((arg) =>
+ typeof arg === "string" ? logsdx.processLine(arg) : arg,
+ );
+ originalLog(...styled);
+};
+```
+
+### With Winston
+
+```typescript
+import winston from "winston";
+import { getLogsDX } from "logsdx";
+
+const logsdx = await getLogsDX({ theme: "nord" });
+
+const styledFormat = winston.format((info) => {
+ info.message = logsdx.processLine(info.message);
+ return info;
+});
+
+const logger = winston.createLogger({
+ format: winston.format.combine(styledFormat(), winston.format.simple()),
+ transports: [new winston.transports.Console()],
+});
+```
+
+### With Pino
+
+```typescript
+import pino from "pino";
+import { getLogsDX } from "logsdx";
+
+const logsdx = await getLogsDX({ theme: "monokai" });
+
+const logger = pino({
+ transport: {
+ target: "pino-pretty",
+ options: {
+ messageFormat: (log) => logsdx.processLine(log.msg),
+ },
+ },
+});
+```
+
+## Browser
+
+### React
+
+```tsx
+import { useEffect, useState } from "react";
+import { getLogsDX } from "logsdx";
+
+function LogViewer({ logs }: { logs: string[] }) {
+ const [styled, setStyled] = useState([]);
+
+ useEffect(() => {
+ async function styleLogs() {
+ const logsdx = await getLogsDX({
+ theme: "dracula",
+ outputFormat: "html",
+ });
+ setStyled(logs.map((log) => logsdx.processLine(log)));
+ }
+ styleLogs();
+ }, [logs]);
+
+ return (
+
+ {styled.map((html, i) => (
+
+ ))}
+
+ );
+}
+```
+
+### Vue
+
+```vue
+
+
+
+
+
+```
+
+### Vanilla JavaScript
+
+```html
+
+```
+
+## Build Tools
+
+### Vite
+
+```typescript
+// vite.config.ts
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ optimizeDeps: {
+ include: ["logsdx"],
+ },
+});
+```
+
+### Next.js
+
+```typescript
+// next.config.js
+module.exports = {
+ transpilePackages: ["logsdx"],
+};
+```
+
+## Terminal Tools
+
+### With chalk
+
+```typescript
+import chalk from "chalk";
+import { getLogsDX } from "logsdx";
+
+const logsdx = await getLogsDX({ theme: "dracula" });
+
+// logsDX handles ANSI colors, chalk adds extras
+const prefix = chalk.gray("[app]");
+console.log(prefix, logsdx.processLine("[INFO] Started"));
+```
+
+### File Processing
+
+```typescript
+import fs from "fs";
+import readline from "readline";
+import { getLogsDX } from "logsdx";
+
+const logsdx = await getLogsDX({ theme: "nord" });
+
+const rl = readline.createInterface({
+ input: fs.createReadStream("app.log"),
+});
+
+for await (const line of rl) {
+ console.log(logsdx.processLine(line));
+}
+```
+
+### Streaming
+
+```typescript
+import { Transform } from "stream";
+import { getLogsDX } from "logsdx";
+
+const logsdx = await getLogsDX({ theme: "monokai" });
+
+const styleTransform = new Transform({
+ transform(chunk, encoding, callback) {
+ const lines = chunk.toString().split("\n");
+ const styled = lines
+ .map((line) => (line ? logsdx.processLine(line) : ""))
+ .join("\n");
+ callback(null, styled);
+ },
+});
+
+process.stdin.pipe(styleTransform).pipe(process.stdout);
+```
+
+## Docker
+
+```dockerfile
+FROM node:20-alpine
+
+WORKDIR /app
+COPY package*.json ./
+RUN npm install
+
+COPY . .
+
+# Use logsDX in your scripts
+CMD ["node", "index.js"]
+```
+
+```javascript
+// index.js
+import { getLogsDX } from "logsdx";
+
+const logsdx = await getLogsDX({ theme: "nord" });
+
+// Force colors in Docker
+process.env.FORCE_COLOR = "1";
+
+console.log(logsdx.processLine("[INFO] Container started"));
+```
+
+## Testing
+
+### Jest
+
+```typescript
+import { getLogsDX, LogsDX } from "logsdx";
+
+describe("Log styling", () => {
+ beforeEach(() => {
+ LogsDX.resetInstance();
+ });
+
+ it("styles error logs", async () => {
+ const logsdx = await getLogsDX({ theme: "dracula" });
+ const result = logsdx.processLine("[ERROR] Test");
+ expect(result).toContain("\x1b["); // Contains ANSI codes
+ });
+});
+```
+
+### Vitest
+
+```typescript
+import { describe, it, expect, beforeEach } from "vitest";
+import { getLogsDX, LogsDX } from "logsdx";
+
+describe("Log styling", () => {
+ beforeEach(() => {
+ LogsDX.resetInstance();
+ });
+
+ it("produces HTML output", async () => {
+ const logsdx = await getLogsDX({
+ theme: "nord",
+ outputFormat: "html",
+ });
+ const result = logsdx.processLine("[INFO] Test");
+ expect(result).toContain("` | No | Named color palette for the theme |
+
+## SchemaConfig
+
+Defines how log content is matched and styled:
+
+| Property | Type | Description |
+| ----------------- | ------------------------------ | ------------------------------------- |
+| `defaultStyle` | `StyleOptions` | Default styling for unmatched content |
+| `matchWords` | `Record` | Exact word matches (case-insensitive) |
+| `matchStartsWith` | `Record` | Match tokens starting with a prefix |
+| `matchEndsWith` | `Record` | Match tokens ending with a suffix |
+| `matchContains` | `Record` | Match tokens containing a substring |
+| `matchPatterns` | `PatternMatch[]` | Regex patterns for complex matching |
+
+## StyleOptions
+
+Styling applied to matched content:
+
+| Property | Type | Required | Description |
+| ----------------- | ---------------------- | -------- | ---------------------------------------------------- |
+| `color` | `string` | Yes | Hex color code for the text |
+| `styleCodes` | `StyleCode[]` | No | Text decorations: bold, italic, underline, dim, etc. |
+| `htmlStyleFormat` | `"css" \| "className"` | No | HTML output format preference |
+
+## Matching Priority
+
+When a token is being styled, matchers are checked in this order:
+
+1. **matchPatterns** - Checked first, highest priority
+2. **matchWords** - Exact word match
+3. **matchStartsWith** - Prefix match
+4. **matchEndsWith** - Suffix match
+5. **matchContains** - Substring match
+6. **defaultStyle** - Fallback for unmatched tokens
+
+## How Matching Works
+
+1. Log line is tokenized into individual words and symbols
+2. Each token is checked against matching rules in priority order
+3. First matching rule determines the token's style
+4. Unmatched tokens use defaultStyle
+5. Styled tokens are rendered as ANSI or HTML
+
+## Example Theme
+
+```json
+{
+ "name": "my-theme",
+ "mode": "dark",
+ "schema": {
+ "defaultStyle": { "color": "#f8f8f2" },
+ "matchWords": {
+ "ERROR": { "color": "#ff5555", "styleCodes": ["bold"] },
+ "WARN": { "color": "#ffb86c" },
+ "INFO": { "color": "#8be9fd" }
+ },
+ "matchPatterns": [
+ {
+ "pattern": "\\d{4}-\\d{2}-\\d{2}",
+ "options": { "color": "#6272a4" }
+ }
+ ]
+ }
+}
+```
+
+## Style Codes
+
+Available style codes for text decoration:
+
+- `bold` - Bold text
+- `dim` - Dimmed text
+- `italic` - Italic text
+- `underline` - Underlined text
+- `blink` - Blinking text (terminal only)
+- `inverse` - Inverted colors
+- `hidden` - Hidden text
+- `strikethrough` - Strikethrough text
diff --git a/site/content/navigation.ts b/site/content/navigation.ts
index 1504ef8..2ad73ad 100644
--- a/site/content/navigation.ts
+++ b/site/content/navigation.ts
@@ -29,6 +29,7 @@ export const docsNavigation: NavItem[] = [
items: [
{ title: "Custom Themes", href: "/docs/guides/custom-themes" },
{ title: "CLI Usage", href: "/docs/guides/cli-usage" },
+ { title: "Theme Schema", href: "/docs/guides/theme-schema" },
{ title: "Integrations", href: "/docs/guides/integrations" },
],
},
diff --git a/site/hooks/useThemeProcessor.ts b/site/hooks/useThemeProcessor.ts
new file mode 100644
index 0000000..27676a3
--- /dev/null
+++ b/site/hooks/useThemeProcessor.ts
@@ -0,0 +1,140 @@
+import { useState, useEffect, useCallback } from "react";
+import { getTheme, renderLine } from "logsdx";
+import type { Theme } from "logsdx";
+
+interface ProcessedLog {
+ html: string;
+ ansi: string;
+}
+
+interface ThemeProcessorResult {
+ processedLogs: ProcessedLog[];
+ isLoading: boolean;
+ error: string | null;
+ theme: Theme | null;
+}
+
+const LOG_CACHE = new Map();
+
+export function useThemeProcessor(
+ themeName: string,
+ logs: string[],
+): ThemeProcessorResult {
+ const [processedLogs, setProcessedLogs] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [theme, setTheme] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function processLogs() {
+ const cacheKey = `${themeName}:${logs.join("|")}`;
+
+ if (LOG_CACHE.has(cacheKey)) {
+ setProcessedLogs(LOG_CACHE.get(cacheKey)!);
+ setIsLoading(false);
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const loadedTheme = await getTheme(themeName);
+
+ if (cancelled) return;
+ setTheme(loadedTheme);
+
+ const results: ProcessedLog[] = [];
+
+ for (const log of logs) {
+ if (cancelled) return;
+
+ const html = renderLine(log, loadedTheme, {
+ outputFormat: "html",
+ htmlStyleFormat: "css",
+ escapeHtml: true,
+ });
+
+ const ansi = renderLine(log, loadedTheme, {
+ outputFormat: "ansi",
+ });
+
+ results.push({ html, ansi });
+ }
+
+ LOG_CACHE.set(cacheKey, results);
+ setProcessedLogs(results);
+ } catch (err) {
+ if (!cancelled) {
+ setError(
+ err instanceof Error ? err.message : "Failed to process logs",
+ );
+ }
+ } finally {
+ if (!cancelled) {
+ setIsLoading(false);
+ }
+ }
+ }
+
+ processLogs();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [themeName, logs]);
+
+ return { processedLogs, isLoading, error, theme };
+}
+
+export function useLogProcessor() {
+ const [isProcessing, setIsProcessing] = useState(false);
+
+ const processLog = useCallback(
+ async (
+ log: string,
+ themeName: string,
+ format: "html" | "ansi" = "html",
+ ): Promise => {
+ setIsProcessing(true);
+ try {
+ const theme = await getTheme(themeName);
+ return renderLine(log, theme, {
+ outputFormat: format,
+ htmlStyleFormat: "css",
+ escapeHtml: true,
+ });
+ } finally {
+ setIsProcessing(false);
+ }
+ },
+ [],
+ );
+
+ const processLogs = useCallback(
+ async (
+ logs: string[],
+ themeName: string,
+ format: "html" | "ansi" = "html",
+ ): Promise => {
+ setIsProcessing(true);
+ try {
+ const theme = await getTheme(themeName);
+ return logs.map((log) =>
+ renderLine(log, theme, {
+ outputFormat: format,
+ htmlStyleFormat: "css",
+ escapeHtml: true,
+ }),
+ );
+ } finally {
+ setIsProcessing(false);
+ }
+ },
+ [],
+ );
+
+ return { processLog, processLogs, isProcessing };
+}
diff --git a/site/package.json b/site/package.json
index 1766cf0..d639ea5 100644
--- a/site/package.json
+++ b/site/package.json
@@ -3,9 +3,10 @@
"version": "0.1.0",
"private": true,
"scripts": {
+ "postinstall": "cp ../node_modules/ghostty-web/ghostty-vt.wasm public/ 2>/dev/null || true",
"dev": "next dev -p 8573",
"build": "next build",
- "start": "next start -p 8573",
+ "start": "npx serve out -p 8573",
"lint": "oxlint",
"test": "bun test",
"test:watch": "bun test --watch",
@@ -26,12 +27,14 @@
"@radix-ui/react-tabs": "^1.1.13",
"@shikijs/rehype": "^3.12.2",
"@shikijs/transformers": "^3.12.2",
+ "@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.9",
"@tanstack/react-store": "^0.8.0",
"@tanstack/store": "^0.8.0",
"ansi-to-html": "^0.7.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
+ "ghostty-web": "^0.4.0",
"github-slugger": "^2.0.0",
"gray-matter": "^4.0.3",
"idb": "^8.0.3",
diff --git a/site/public/demos-full-2026-01-19T08-54-29-338Z.png b/site/public/demos-full-2026-01-19T08-54-29-338Z.png
new file mode 100644
index 0000000..6d360f6
Binary files /dev/null and b/site/public/demos-full-2026-01-19T08-54-29-338Z.png differ
diff --git a/site/public/images/demos/log-playground-dark.png b/site/public/images/demos/log-playground-dark.png
new file mode 100644
index 0000000..fdb5227
Binary files /dev/null and b/site/public/images/demos/log-playground-dark.png differ
diff --git a/site/public/images/demos/log-playground-light.png b/site/public/images/demos/log-playground-light.png
new file mode 100644
index 0000000..70e90d1
Binary files /dev/null and b/site/public/images/demos/log-playground-light.png differ
diff --git a/site/public/images/demos/output-comparison-dark.png b/site/public/images/demos/output-comparison-dark.png
new file mode 100644
index 0000000..499b50c
Binary files /dev/null and b/site/public/images/demos/output-comparison-dark.png differ
diff --git a/site/public/images/demos/output-comparison-light.png b/site/public/images/demos/output-comparison-light.png
new file mode 100644
index 0000000..e6f1407
Binary files /dev/null and b/site/public/images/demos/output-comparison-light.png differ
diff --git a/site/tailwind.config.ts b/site/tailwind.config.ts
index bbe518d..280ebc0 100644
--- a/site/tailwind.config.ts
+++ b/site/tailwind.config.ts
@@ -1,4 +1,5 @@
import type { Config } from "tailwindcss";
+import typography from "@tailwindcss/typography";
const config: Config = {
darkMode: ["class"],
@@ -78,6 +79,6 @@ const config: Config = {
},
},
},
- plugins: [],
+ plugins: [typography],
};
export default config;
diff --git a/site/test-setup.ts b/site/test-setup.ts
index 02b17e2..cafd50d 100644
--- a/site/test-setup.ts
+++ b/site/test-setup.ts
@@ -1,3 +1,53 @@
+import { mock } from "bun:test";
+
+const logsdxMock = {
+ createSimpleTheme: (
+ name: string,
+ colors: unknown,
+ options?: { mode?: string },
+ ) => ({
+ name,
+ colors,
+ mode: options?.mode || "dark",
+ schema: {},
+ }),
+ registerTheme: () => {},
+ getLogsDX: async () => ({
+ processLine: (line: string) =>
+ `${line}`,
+ processLines: (lines: string[]) =>
+ lines.map((line: string) => `${line}`),
+ setTheme: () => {},
+ getCurrentTheme: () => {},
+ }),
+ getTheme: async () => ({
+ name: "mock-theme",
+ mode: "dark",
+ schema: { defaultStyle: { color: "#f8f8f2" } },
+ }),
+ renderLine: (
+ line: string,
+ _theme: unknown,
+ options?: { outputFormat?: string },
+ ) => {
+ if (options?.outputFormat === "html") {
+ return `${line}`;
+ }
+ return line;
+ },
+ getAllThemes: () => ({}),
+ getThemeNames: () => [],
+ LogsDX: class {
+ static getInstance = async () => ({
+ processLine: (line: string) => line,
+ });
+ static resetInstance = () => {};
+ },
+};
+
+// Mock logsdx BEFORE any other imports to ensure it's hoisted
+mock.module("logsdx", () => logsdxMock);
+
import { GlobalRegistrator } from "@happy-dom/global-registrator";
import "@testing-library/jest-dom";
diff --git a/site/tests/__mocks__/logsdx.ts b/site/tests/__mocks__/logsdx.ts
index 7736f1f..3cb0a26 100644
--- a/site/tests/__mocks__/logsdx.ts
+++ b/site/tests/__mocks__/logsdx.ts
@@ -1,7 +1,7 @@
-import { vi } from "bun:test";
+import { mock } from "bun:test";
-export const createSimpleTheme = vi.fn(
- (name: string, colors: any, options?: any) => ({
+export const createSimpleTheme = mock(
+ (name: string, colors: unknown, options?: { mode?: string }) => ({
name,
colors,
mode: options?.mode || "dark",
@@ -9,16 +9,30 @@ export const createSimpleTheme = vi.fn(
}),
);
-export const registerTheme = vi.fn();
+export const registerTheme = mock(() => {});
-export const getLogsDX = vi.fn().mockResolvedValue({
+export const getLogsDX = mock(async () => ({
processLine: (line: string) => `${line}`,
processLines: (lines: string[]) =>
lines.map((line) => `${line}`),
- setTheme: vi.fn(),
- getCurrentTheme: vi.fn(),
-});
+ setTheme: mock(() => {}),
+ getCurrentTheme: mock(() => {}),
+}));
-export const getTheme = vi.fn();
-export const getAllThemes = vi.fn(() => ({}));
-export const getThemeNames = vi.fn(() => []);
+export const getTheme = mock(async () => ({
+ name: "mock-theme",
+ mode: "dark",
+ schema: { defaultStyle: { color: "#f8f8f2" } },
+}));
+
+export const renderLine = mock(
+ (line: string, _theme: unknown, options?: { outputFormat?: string }) => {
+ if (options?.outputFormat === "html") {
+ return `${line}`;
+ }
+ return line;
+ },
+);
+
+export const getAllThemes = mock(() => ({}));
+export const getThemeNames = mock(() => []);
diff --git a/site/tests/components/CustomThemeCreator.test.tsx b/site/tests/components/CustomThemeCreator.test.tsx
index a164496..5052f5b 100644
--- a/site/tests/components/CustomThemeCreator.test.tsx
+++ b/site/tests/components/CustomThemeCreator.test.tsx
@@ -2,11 +2,10 @@ import {
describe,
it,
expect,
- vi,
+ mock,
beforeEach,
afterEach,
beforeAll,
- mock,
} from "bun:test";
import React from "react";
import {
@@ -21,14 +20,7 @@ import {
themeEditorActions,
} from "@/stores/useThemeEditorStore";
-// Mock logsdx before any imports that use it
-mock.module("logsdx", () => ({
- createSimpleTheme: vi.fn((name: string) => ({ name, schema: {} })),
- registerTheme: vi.fn(),
- getLogsDX: vi.fn().mockResolvedValue({
- processLine: (line: string) => `${line}`,
- }),
-}));
+// logsdx mock is handled by test-setup.ts preload
// Mock the log preview hook to avoid actual LogsDX processing in tests
mock.module("@/hooks/useLogPreview", () => ({
@@ -44,7 +36,7 @@ mock.module("@/hooks/useLogPreview", () => ({
// Mock theme creation hook
mock.module("@/hooks/useThemes", () => ({
useCreateTheme: () => ({
- mutate: vi.fn(),
+ mutate: () => {},
isPending: false,
}),
}));
@@ -124,7 +116,7 @@ describe("CustomThemeCreator - Integration Tests", () => {
});
it("provides copy code functionality", async () => {
- const mockWriteText = vi.fn().mockResolvedValue(undefined);
+ const mockWriteText = mock(() => Promise.resolve(undefined));
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: mockWriteText,
@@ -187,7 +179,7 @@ describe("CustomThemeCreator - Integration Tests", () => {
});
it("provides share theme functionality", async () => {
- const mockWriteText = vi.fn().mockResolvedValue(undefined);
+ const mockWriteText = mock(() => Promise.resolve(undefined));
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: mockWriteText,
diff --git a/site/tests/components/cli-demo/CliDemo.test.tsx b/site/tests/components/cli-demo/CliDemo.test.tsx
new file mode 100644
index 0000000..a3b7fe3
--- /dev/null
+++ b/site/tests/components/cli-demo/CliDemo.test.tsx
@@ -0,0 +1,54 @@
+import { describe, it, expect, beforeEach, afterEach } from "bun:test";
+import { render, screen, cleanup, fireEvent } from "../../utils/test-utils";
+import { CliDemo } from "@/components/cli-demo";
+
+describe("CliDemo", () => {
+ beforeEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("renders section heading", () => {
+ render();
+ expect(screen.getByText("Powerful")).toBeDefined();
+ expect(screen.getByText("CLI")).toBeDefined();
+ });
+
+ it("renders package manager buttons", () => {
+ render();
+ expect(screen.getByText("npm")).toBeDefined();
+ expect(screen.getByText("pnpm")).toBeDefined();
+ expect(screen.getByText("bun")).toBeDefined();
+ });
+
+ it("renders all CLI features", () => {
+ render();
+ expect(screen.getByText("Pipe Logs")).toBeDefined();
+ expect(screen.getByText("Process Files")).toBeDefined();
+ expect(screen.getByText("Interactive Theme Creator")).toBeDefined();
+ expect(screen.getByText("Preview Themes")).toBeDefined();
+ });
+
+ it("changes install command when package manager is clicked", () => {
+ render();
+ const bunButton = screen.getByText("bun");
+ fireEvent.click(bunButton);
+ expect(screen.getByText("bun add -g logsdx")).toBeDefined();
+ });
+
+ it("changes terminal output when feature is clicked", () => {
+ render();
+ const processFilesButton = screen.getByText("Process Files");
+ fireEvent.click(processFilesButton);
+ expect(screen.getByText(/Processing app.log/)).toBeDefined();
+ });
+
+ it("shows terminal window with controls", () => {
+ const { container } = render();
+ const terminalDots = container.querySelectorAll(".rounded-full");
+ expect(terminalDots.length).toBeGreaterThanOrEqual(3);
+ });
+});
diff --git a/site/tests/components/interactive/CodeExample.test.tsx b/site/tests/components/interactive/CodeExample.test.tsx
new file mode 100644
index 0000000..f971822
--- /dev/null
+++ b/site/tests/components/interactive/CodeExample.test.tsx
@@ -0,0 +1,46 @@
+import { describe, it, expect, beforeEach, afterEach } from "bun:test";
+import { render, screen, cleanup } from "../../utils/test-utils";
+import { CodeExample } from "@/components/interactive/CodeExample";
+
+describe("CodeExample", () => {
+ const defaultProps = {
+ title: "Basic Usage",
+ code: `import { getLogsDX } from 'logsdx'
+const logger = getLogsDX('dracula')`,
+ themeName: "dracula",
+ };
+
+ beforeEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("renders title", () => {
+ render();
+ expect(screen.getByText("Basic Usage")).toBeDefined();
+ });
+
+ it("renders code content", () => {
+ render();
+ expect(screen.getByText(/import.*getLogsDX/)).toBeDefined();
+ });
+
+ it("renders theme name in footer", () => {
+ render();
+ expect(screen.getByText("Theme: dracula")).toBeDefined();
+ });
+
+ it("renders window control buttons", () => {
+ const { container } = render();
+ const buttons = container.querySelectorAll(".rounded-full");
+ expect(buttons.length).toBe(3);
+ });
+
+ it("handles unknown theme gracefully", () => {
+ render();
+ expect(screen.getByText("Theme: unknown-theme")).toBeDefined();
+ });
+});
diff --git a/site/tests/components/interactive/PreviewPane.test.tsx b/site/tests/components/interactive/PreviewPane.test.tsx
new file mode 100644
index 0000000..6914c3a
--- /dev/null
+++ b/site/tests/components/interactive/PreviewPane.test.tsx
@@ -0,0 +1,66 @@
+import { describe, it, expect, beforeEach, afterEach } from "bun:test";
+import { render, screen, cleanup } from "../../utils/test-utils";
+import { PreviewPane } from "@/components/interactive/PreviewPane";
+
+describe("PreviewPane", () => {
+ const defaultProps = {
+ title: "Terminal",
+ themeName: "dracula",
+ logs: ["INFO: Test log", "ERROR: Test error"],
+ backgroundColor: "#282a36",
+ headerBg: "#1e1f29",
+ borderColor: "#44475a",
+ };
+
+ beforeEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("renders title", () => {
+ render();
+ expect(screen.getByText("Terminal")).toBeDefined();
+ });
+
+ it("renders theme name in footer", () => {
+ render();
+ expect(screen.getByText("Theme: dracula")).toBeDefined();
+ });
+
+ it("renders logs as HTML", () => {
+ render();
+ expect(screen.getAllByText("INFO: Test log").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("ERROR: Test error").length).toBeGreaterThan(0);
+ });
+
+ it("shows loading state", () => {
+ render();
+ expect(screen.getByText("Loading...")).toBeDefined();
+ });
+
+ it("hides logs when loading", () => {
+ render();
+ expect(screen.queryByText("INFO: Test log")).toBeNull();
+ });
+
+ it("renders window control buttons", () => {
+ const { container } = render();
+ const buttons = container.querySelectorAll(".rounded-full");
+ expect(buttons.length).toBe(3);
+ });
+
+ it("renders with showBorder prop", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.firstChild).toBeDefined();
+ });
+
+ it("renders empty logs array", () => {
+ render();
+ expect(screen.getByText("Terminal")).toBeDefined();
+ });
+});
diff --git a/site/tests/components/interactive/ThemeControls.test.tsx b/site/tests/components/interactive/ThemeControls.test.tsx
new file mode 100644
index 0000000..95ce71f
--- /dev/null
+++ b/site/tests/components/interactive/ThemeControls.test.tsx
@@ -0,0 +1,59 @@
+import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test";
+import { render, screen, cleanup, fireEvent } from "../../utils/test-utils";
+import { ThemeControls } from "@/components/interactive/ThemeControls";
+
+describe("ThemeControls", () => {
+ const mockOnThemeChange = mock(() => {});
+ const mockOnColorModeChange = mock(() => {});
+
+ const defaultProps = {
+ selectedTheme: "GitHub",
+ colorMode: "system" as const,
+ isDarkOnly: false,
+ onThemeChange: mockOnThemeChange,
+ onColorModeChange: mockOnColorModeChange,
+ };
+
+ beforeEach(() => {
+ document.body.innerHTML = "";
+ mockOnThemeChange.mockClear();
+ mockOnColorModeChange.mockClear();
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("renders all theme buttons", () => {
+ render();
+ expect(screen.getByText("GitHub")).toBeDefined();
+ expect(screen.getByText("Dracula")).toBeDefined();
+ expect(screen.getByText("Nord")).toBeDefined();
+ });
+
+ it("highlights selected theme", () => {
+ render();
+ const draculaButton = screen.getByText("Dracula");
+ expect(draculaButton.closest("button")).toBeDefined();
+ });
+
+ it("calls onThemeChange when theme button clicked", () => {
+ render();
+ fireEvent.click(screen.getByText("Dracula"));
+ expect(mockOnThemeChange).toHaveBeenCalledWith("Dracula");
+ });
+
+ it("renders color mode buttons when not dark only", () => {
+ const { container } = render();
+ const iconButtons = container.querySelectorAll("button.h-8.w-8");
+ expect(iconButtons.length).toBe(3);
+ });
+
+ it("hides color mode buttons when dark only", () => {
+ const { container } = render(
+ ,
+ );
+ const iconButtons = container.querySelectorAll("button.h-8.w-8");
+ expect(iconButtons.length).toBe(0);
+ });
+});
diff --git a/site/tests/components/log-playground/LogPlayground.test.tsx b/site/tests/components/log-playground/LogPlayground.test.tsx
new file mode 100644
index 0000000..1380ef6
--- /dev/null
+++ b/site/tests/components/log-playground/LogPlayground.test.tsx
@@ -0,0 +1,63 @@
+import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test";
+
+mock.module("@/hooks/useThemeProcessor", () => ({
+ useThemeProcessor: () => ({
+ processedLogs: [
+ { html: "INFO: Test", ansi: "\x1b[34mINFO: Test\x1b[0m" },
+ ],
+ isLoading: false,
+ error: null,
+ theme: { name: "dracula", mode: "dark" },
+ }),
+}));
+
+import { render, screen, cleanup, fireEvent } from "../../utils/test-utils";
+import { LogPlayground } from "@/components/log-playground";
+
+describe("LogPlayground", () => {
+ beforeEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("renders the playground title", () => {
+ render();
+ expect(screen.getByText("Live")).toBeDefined();
+ expect(screen.getByText("Log Playground")).toBeDefined();
+ });
+
+ it("renders theme selector", () => {
+ render();
+ expect(screen.getByRole("combobox")).toBeDefined();
+ });
+
+ it("renders input textarea", () => {
+ render();
+ expect(screen.getByLabelText("Input Logs")).toBeDefined();
+ });
+
+ it("renders browser and terminal panes", () => {
+ render();
+ expect(screen.getByText("Browser Console")).toBeDefined();
+ expect(screen.getByText("Terminal")).toBeDefined();
+ });
+
+ it("renders reset button", () => {
+ render();
+ expect(screen.getByText("Reset")).toBeDefined();
+ });
+
+ it("shows usage code example", () => {
+ render();
+ expect(screen.getByText("Usage")).toBeDefined();
+ });
+
+ it("uses default theme from props", () => {
+ render();
+ const select = screen.getByRole("combobox") as HTMLSelectElement;
+ expect(select.value).toBe("nord");
+ });
+});
diff --git a/site/tests/components/output-comparison/OutputComparison.test.tsx b/site/tests/components/output-comparison/OutputComparison.test.tsx
new file mode 100644
index 0000000..5338129
--- /dev/null
+++ b/site/tests/components/output-comparison/OutputComparison.test.tsx
@@ -0,0 +1,57 @@
+import { describe, it, expect, beforeEach, afterEach } from "bun:test";
+import { render, screen, cleanup, fireEvent } from "../../utils/test-utils";
+import { OutputComparison } from "@/components/output-comparison";
+
+describe("OutputComparison", () => {
+ beforeEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("renders section heading", () => {
+ render();
+ expect(screen.getByText("Real")).toBeDefined();
+ expect(screen.getByText("Output Comparison")).toBeDefined();
+ });
+
+ it("renders Terminal and HTML view tabs", () => {
+ render();
+ expect(screen.getByRole("button", { name: "Terminal" })).toBeDefined();
+ expect(screen.getByRole("button", { name: "HTML" })).toBeDefined();
+ });
+
+ it("renders Rendered and Source mode tabs", () => {
+ render();
+ expect(screen.getByRole("button", { name: "Rendered" })).toBeDefined();
+ expect(screen.getByRole("button", { name: "Source" })).toBeDefined();
+ });
+
+ it("renders theme selector", () => {
+ render();
+ expect(screen.getByText("Theme")).toBeDefined();
+ expect(screen.getByRole("combobox")).toBeDefined();
+ });
+
+ it("renders custom log input", () => {
+ render();
+ expect(
+ screen.getByPlaceholderText("Paste your own logs here..."),
+ ).toBeDefined();
+ });
+
+ it("switches to HTML view when clicked", () => {
+ render();
+ const htmlTab = screen.getByRole("button", { name: "HTML" });
+ fireEvent.click(htmlTab);
+ expect(screen.getByText("Browser")).toBeDefined();
+ });
+
+ it("shows format descriptions", () => {
+ render();
+ expect(screen.getByText("Escape codes for terminals")).toBeDefined();
+ expect(screen.getByText("Styled spans for browsers")).toBeDefined();
+ });
+});
diff --git a/site/tests/components/schema-viz/SchemaVisualization.test.tsx b/site/tests/components/schema-viz/SchemaVisualization.test.tsx
new file mode 100644
index 0000000..1606b78
--- /dev/null
+++ b/site/tests/components/schema-viz/SchemaVisualization.test.tsx
@@ -0,0 +1,67 @@
+import { describe, it, expect, beforeEach, afterEach } from "bun:test";
+import { render, screen, cleanup, fireEvent } from "../../utils/test-utils";
+import { SchemaVisualization } from "@/components/schema-viz";
+
+describe("SchemaVisualization", () => {
+ beforeEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("renders section heading", () => {
+ render();
+ const themeElements = screen.getAllByText("Theme");
+ expect(themeElements.length).toBeGreaterThan(0);
+ expect(screen.getByText("Schema")).toBeDefined();
+ });
+
+ it("renders schema section tabs", () => {
+ render();
+ const schemaConfigElements = screen.getAllByText("SchemaConfig");
+ expect(schemaConfigElements.length).toBeGreaterThan(0);
+ const styleOptionsElements = screen.getAllByText("StyleOptions");
+ expect(styleOptionsElements.length).toBeGreaterThan(0);
+ });
+
+ it("shows Theme section by default", () => {
+ render();
+ expect(
+ screen.getByText("Root theme object that defines styling rules"),
+ ).toBeDefined();
+ });
+
+ it("switches sections when tab is clicked", () => {
+ render();
+ const schemaConfigButton = screen.getByRole("button", {
+ name: "SchemaConfig",
+ });
+ fireEvent.click(schemaConfigButton);
+ expect(
+ screen.getByText("Defines how log content is matched and styled"),
+ ).toBeDefined();
+ });
+
+ it("renders matching priority list", () => {
+ render();
+ expect(screen.getByText("Matching Priority")).toBeDefined();
+ const matchPatterns = screen.getAllByText("matchPatterns");
+ expect(matchPatterns.length).toBeGreaterThan(0);
+ const matchWords = screen.getAllByText("matchWords");
+ expect(matchWords.length).toBeGreaterThan(0);
+ });
+
+ it("renders example theme code", () => {
+ render();
+ expect(screen.getByText("Example Theme")).toBeDefined();
+ expect(screen.getByText("my-theme.json")).toBeDefined();
+ });
+
+ it("shows required badge for required properties", () => {
+ render();
+ const requiredBadges = screen.getAllByText("required");
+ expect(requiredBadges.length).toBeGreaterThan(0);
+ });
+});
diff --git a/site/tests/components/theme-card/LogPane.test.tsx b/site/tests/components/theme-card/LogPane.test.tsx
new file mode 100644
index 0000000..843a6df
--- /dev/null
+++ b/site/tests/components/theme-card/LogPane.test.tsx
@@ -0,0 +1,62 @@
+import { describe, it, expect, beforeEach, afterEach } from "bun:test";
+import { render, screen, cleanup } from "../../utils/test-utils";
+import { LogPane } from "@/components/theme-card/LogPane";
+
+describe("LogPane", () => {
+ const defaultProps = {
+ title: "Browser",
+ logs: ["Log line 1", "Log line 2"],
+ backgroundColor: "#282a36",
+ mode: "dark" as const,
+ };
+
+ beforeEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("renders title", () => {
+ render();
+ expect(screen.getByText("Browser")).toBeDefined();
+ });
+
+ it("renders logs as HTML", () => {
+ render();
+ expect(screen.getAllByText("Log line 1").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("Log line 2").length).toBeGreaterThan(0);
+ });
+
+ it("shows loading state", () => {
+ render();
+ expect(screen.getByText("Loading...")).toBeDefined();
+ });
+
+ it("hides logs when loading", () => {
+ render();
+ expect(screen.queryByText("Log line 1")).toBeNull();
+ });
+
+ it("renders with dark mode", () => {
+ const { container } = render();
+ expect(container.firstChild).toBeDefined();
+ });
+
+ it("renders with light mode", () => {
+ const { container } = render();
+ expect(container.firstChild).toBeDefined();
+ });
+
+ it("applies background color", () => {
+ const { container } = render();
+ const pane = container.firstChild as HTMLElement;
+ expect(pane).toBeDefined();
+ });
+
+ it("renders empty logs array", () => {
+ render();
+ expect(screen.getByText("Browser")).toBeDefined();
+ });
+});
diff --git a/site/tests/components/theme-card/ThemeCard.test.tsx b/site/tests/components/theme-card/ThemeCard.test.tsx
new file mode 100644
index 0000000..a490e21
--- /dev/null
+++ b/site/tests/components/theme-card/ThemeCard.test.tsx
@@ -0,0 +1,54 @@
+import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test";
+
+mock.module("@/hooks/useThemeProcessor", () => ({
+ useThemeProcessor: () => ({
+ processedLogs: [
+ { html: "INFO: Test log", ansi: "INFO: Test log" },
+ { html: "ERROR: Test error", ansi: "ERROR: Test error" },
+ ],
+ isLoading: false,
+ error: null,
+ theme: { name: "dracula", mode: "dark" },
+ }),
+}));
+
+import { render, screen, cleanup } from "../../utils/test-utils";
+import { ThemeCard } from "@/components/theme-card";
+
+describe("ThemeCard", () => {
+ beforeEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("renders theme name formatted", () => {
+ render();
+ expect(screen.getByText("Oh My Zsh")).toBeDefined();
+ });
+
+ it("renders theme mode badge", () => {
+ render();
+ expect(screen.getByText("dark")).toBeDefined();
+ });
+
+ it("renders browser and terminal panes", () => {
+ render();
+ expect(screen.getByText("Browser")).toBeDefined();
+ expect(screen.getByText("Terminal")).toBeDefined();
+ });
+
+ it("returns null when not visible", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("renders processed logs", () => {
+ render();
+ expect(screen.getAllByText("INFO: Test log").length).toBeGreaterThan(0);
+ });
+});
diff --git a/src/cli/commands.ts b/src/cli/commands.ts
index 510a8cb..2d9431a 100644
--- a/src/cli/commands.ts
+++ b/src/cli/commands.ts
@@ -127,9 +127,9 @@ export async function createInteractiveTheme(
const mode = await select({
message: "Theme mode:",
choices: [
- { name: "š Dark (for dark terminals)", value: "dark" },
- { name: "āļø Light (for light terminals)", value: "light" },
- { name: "š Auto (system preference)", value: "auto" },
+ { name: "Dark (for dark terminals)", value: "dark" },
+ { name: "Light (for light terminals)", value: "light" },
+ { name: "Auto (system preference)", value: "auto" },
],
default: "dark",
});
@@ -143,7 +143,7 @@ export async function createInteractiveTheme(
const preset = await select({
message: "Choose a color preset:",
choices: Object.keys(COLOR_PRESETS).map((name) => ({
- name: name === "Custom" ? "šØ Custom (define your own)" : `šØ ${name}`,
+ name: name === "Custom" ? "Custom (define your own)" : name,
value: name,
})),
});
@@ -216,23 +216,15 @@ export async function createInteractiveTheme(
message: "Select features to highlight:",
choices: [
{
- name: "š Log levels (ERROR, WARN, INFO)",
+ name: "Log levels (ERROR, WARN, INFO)",
value: "logLevels",
checked: true,
},
- {
- name: "š¢ Numbers and numeric values",
- value: "numbers",
- checked: true,
- },
- { name: "š
Dates and timestamps", value: "dates", checked: true },
- { name: "ā
Boolean values", value: "booleans", checked: true },
- {
- name: "š¤ Brackets and punctuation",
- value: "brackets",
- checked: true,
- },
- { name: "š¬ Quoted strings", value: "strings", checked: false },
+ { name: "Numbers and numeric values", value: "numbers", checked: true },
+ { name: "Dates and timestamps", value: "dates", checked: true },
+ { name: "Boolean values", value: "booleans", checked: true },
+ { name: "Brackets and punctuation", value: "brackets", checked: true },
+ { name: "Quoted strings", value: "strings", checked: false },
],
});
@@ -250,7 +242,7 @@ export async function createInteractiveTheme(
createSpinner.succeed("Theme created!");
console.log("\n");
- await renderPreview(theme, `⨠${theme.name} Preview`);
+ await renderPreview(theme, `${theme.name} Preview`);
const checkAccessibility = await confirm({
message: "Check accessibility compliance?",
@@ -263,14 +255,14 @@ export async function createInteractiveTheme(
accessSpinner.stop();
const accessBox = boxen(
- `WCAG Level: ${result.level} ${result.level === "AAA" ? "š" : result.level === "AA" ? "ā
" : result.level === "A" ? "ā ļø" : "ā"}\n` +
+ `WCAG Level: ${result.level}\n` +
`Min Contrast Ratio: ${result.details.normalText.ratio.toFixed(2)}\n` +
(result.recommendations.length > 0
? "\nRecommendations:\n" +
- result.recommendations.map((r: string) => `⢠${r}`).join("\n")
- : "\nā
No issues found!"),
+ result.recommendations.map((r: string) => `- ${r}`).join("\n")
+ : "\nNo issues found"),
{
- title: "āæ Accessibility Report",
+ title: "Accessibility Report",
padding: 1,
borderStyle: "round",
borderColor:
@@ -301,11 +293,11 @@ export async function createInteractiveTheme(
const saveOption = await select({
message: "How would you like to save the theme?",
choices: [
- { name: "š¾ Export as JSON file", value: "json" },
- { name: "š Export as TypeScript file", value: "typescript" },
- { name: "š Copy to clipboard", value: "clipboard" },
- { name: "š Register for immediate use", value: "register" },
- { name: "ā Don't save", value: "none" },
+ { name: "Export as JSON file", value: "json" },
+ { name: "Export as TypeScript file", value: "typescript" },
+ { name: "Copy to clipboard", value: "clipboard" },
+ { name: "Register for immediate use", value: "register" },
+ { name: "Don't save", value: "none" },
],
});
@@ -315,7 +307,7 @@ export async function createInteractiveTheme(
console.log(
boxen(
- colorUtil.green("š Theme creation complete!\n\n") +
+ colorUtil.green("Theme creation complete!\n\n") +
colorUtil.dim(
`Use your theme with: ${colorUtil.cyan(`logsdx --theme ${theme.name}`)}`,
),
@@ -353,7 +345,7 @@ async function saveTheme(theme: Theme, saveOption: string) {
}
writeFileSync(filepath, JSON.stringify(themeData, null, 2));
- console.log(colorUtil.green(`ā
Saved to ${filepath}`));
+ console.log(colorUtil.green(`Saved to ${filepath}`));
} else if (saveOption === "typescript") {
const filepath = await input({
message: "Save as:",
@@ -371,6 +363,6 @@ export const ${theme.name.replace(/[^a-zA-Z0-9]/g, "_")}Theme: Theme = ${JSON.st
`;
writeFileSync(filepath, tsContent);
- console.log(colorUtil.green(`ā
Saved to ${filepath}`));
+ console.log(colorUtil.green(`Saved to ${filepath}`));
}
}
diff --git a/src/cli/constants.ts b/src/cli/constants.ts
index 261dd07..3d4ca99 100644
--- a/src/cli/constants.ts
+++ b/src/cli/constants.ts
@@ -2,15 +2,6 @@ export const CLI_NAME = "logsdx";
export const CLI_VERSION = "0.1.1";
export const CLI_DESCRIPTION = "Enhanced log styling and visualization tool";
-export const SUCCESS_ICON = "ā
";
-export const ERROR_ICON = "ā";
-export const INFO_ICON = "ā¹ļø";
-export const WARNING_ICON = "ā ļø";
-export const FILE_ICON = "š";
-export const STATS_ICON = "š";
-export const SIZE_ICON = "š";
-export const LIGHTBULB_ICON = "š”";
-
export const DEFAULT_THEME = "default";
export const DEFAULT_OUTPUT = "styled";
diff --git a/src/cli/index.ts b/src/cli/index.ts
index c948e50..bc6fdf6 100755
--- a/src/cli/index.ts
+++ b/src/cli/index.ts
@@ -1,11 +1,7 @@
import fs from "fs";
import path from "path";
import { LogsDX, getThemeNames } from "../index";
-import {
- type CliOptions,
- type CommanderOptions,
- cliOptionsSchema,
-} from "./types";
+import type { CliOptions, CommanderOptions } from "./types";
import type { LogsDXOptions } from "../types";
import { ui } from "./ui";
import type { InteractiveConfig } from "./interactive";
@@ -15,6 +11,9 @@ import {
listPatternPresetsCommand,
} from "./theme-gen";
import { exportTheme, importTheme, listThemeFiles } from "./theme-gen";
+import { createLogger } from "../utils/logger";
+
+const log = createLogger("cli");
export function loadConfig(configPath?: string): LogsDXOptions {
const defaultConfig: LogsDXOptions = {
@@ -41,7 +40,7 @@ export function loadConfig(configPath?: string): LogsDXOptions {
}
}
} catch (error) {
- console.warn(`Failed to load config: ${error}`);
+ log.debug(`Failed to load config: ${error}`);
}
return defaultConfig;
@@ -185,30 +184,28 @@ export async function main(
input: string | undefined,
rawOptions: CommanderOptions,
): Promise {
- const validatedOptions = cliOptionsSchema.parse(rawOptions);
-
- const options: CliOptions = cliOptionsSchema.parse({
+ const options: CliOptions = {
input,
- output: validatedOptions.output,
- theme: validatedOptions.theme,
- config: validatedOptions.config,
- debug: validatedOptions.debug,
- quiet: validatedOptions.quiet,
- listThemes: validatedOptions.listThemes,
- interactive: validatedOptions.interactive,
- preview: validatedOptions.preview,
- noSpinner: validatedOptions.noSpinner,
- generateTheme: validatedOptions.generateTheme,
- listPalettes: validatedOptions.listPalettes,
- listPatterns: validatedOptions.listPatterns,
- exportTheme: validatedOptions.exportTheme,
- importTheme: validatedOptions.importTheme,
- listThemeFiles: validatedOptions.listThemeFiles,
+ output: rawOptions.output,
+ theme: rawOptions.theme,
+ config: rawOptions.config,
+ debug: rawOptions.debug ?? false,
+ quiet: rawOptions.quiet ?? false,
+ listThemes: rawOptions.listThemes ?? false,
+ interactive: rawOptions.interactive ?? false,
+ preview: rawOptions.preview ?? false,
+ noSpinner: rawOptions.noSpinner ?? false,
+ generateTheme: rawOptions.generateTheme ?? false,
+ listPalettes: rawOptions.listPalettes ?? false,
+ listPatterns: rawOptions.listPatterns ?? false,
+ exportTheme: rawOptions.exportTheme,
+ importTheme: rawOptions.importTheme,
+ listThemeFiles: rawOptions.listThemeFiles ?? false,
format:
- validatedOptions.format === "ansi" || validatedOptions.format === "html"
- ? validatedOptions.format
+ rawOptions.format === "ansi" || rawOptions.format === "html"
+ ? rawOptions.format
: undefined,
- });
+ };
if (options.interactive) {
try {
const { runInteractiveMode } = await import("./interactive");
@@ -289,8 +286,8 @@ export async function main(
getThemeNames().forEach((theme) => {
console.log(` ⢠${theme}`);
});
- console.log("\nš” Use --preview to see themes with sample logs");
- console.log("š” Use --interactive for guided selection");
+ console.log("\nUse --preview to see themes with sample logs");
+ console.log("Use --interactive for guided selection");
}
return;
}
diff --git a/src/cli/interactive.ts b/src/cli/interactive.ts
index 35871d6..b179aef 100644
--- a/src/cli/interactive.ts
+++ b/src/cli/interactive.ts
@@ -1,28 +1,23 @@
import { select, confirm } from "../utils/prompts";
import { LogsDX, getThemeNames, getTheme } from "../index";
import { ui } from "./ui";
-import chalk from "chalk";
-import { z } from "zod";
+import colors from "../utils/colors";
-export const interactiveConfigSchema = z.object({
- theme: z.string(),
- outputFormat: z.enum(["ansi", "html"]),
- preview: z.boolean(),
-});
+export type InteractiveConfig = {
+ theme: string;
+ outputFormat: "ansi" | "html";
+ preview: boolean;
+};
-export type InteractiveConfig = z.infer;
-
-export const themeChoiceSchema = z.object({
- name: z.string(),
- value: z.string(),
- description: z.string(),
-});
-
-export type ThemeChoice = z.infer;
+export type ThemeChoice = {
+ name: string;
+ value: string;
+ description: string;
+};
const SAMPLE_LOG = `2024-01-15 10:30:45 INFO [server] Application started successfully
2024-01-15 10:30:46 DEBUG [auth] Loading user credentials from /etc/config
-2024-01-15 10:30:47 WARN [database] Connection pool at 80% capacity
+2024-01-15 10:30:47 WARN [database] Connection pool at 80% capacity
2024-01-15 10:30:48 ERROR [api] Failed to process request: /users/123/profile
2024-01-15 10:30:49 INFO [cache] Cache hit ratio: 94.5%
GET /api/users/123 200 142ms - "Mozilla/5.0"
@@ -31,10 +26,10 @@ POST /api/auth/login 401 23ms - Invalid credentials
export async function runInteractiveMode(): Promise {
ui.showHeader();
- ui.showInfo("Welcome to LogsDX Interactive Mode! š");
+ ui.showInfo("Welcome to LogsDX Interactive Mode!");
console.log(
- chalk.dim(
+ colors.dim(
"This wizard will help you select the perfect theme and settings for your logs.\n",
),
);
@@ -42,7 +37,7 @@ export async function runInteractiveMode(): Promise {
const themeNames = getThemeNames();
const themeChoices: ThemeChoice[] = await Promise.all(
themeNames.map(async (name: string) => ({
- name: chalk.cyan(name),
+ name: colors.cyan(name),
value: name,
description:
(await getTheme(name))?.description || "No description available",
@@ -50,11 +45,11 @@ export async function runInteractiveMode(): Promise {
);
const selectedTheme = await select({
- message: "šØ Choose a theme:",
+ message: "Choose a theme:",
choices: [
...themeChoices,
{
- name: chalk.yellow("š Preview themes"),
+ name: colors.yellow("Preview themes"),
value: "__preview__",
description: "See how each theme looks with sample logs",
},
@@ -76,21 +71,21 @@ export async function runInteractiveMode(): Promise {
}
finalTheme = await select({
- message: "šØ Now choose your theme:",
+ message: "Now choose your theme:",
choices: themeChoices,
});
}
const outputFormat = await select({
- message: "š¤ Choose output format:",
+ message: "Choose output format:",
choices: [
{
- name: chalk.green("ANSI") + chalk.dim(" (terminal colors)"),
+ name: colors.green("ANSI") + colors.dim(" (terminal colors)"),
value: "ansi" as const,
description: "Perfect for terminal output with colors and styling",
},
{
- name: chalk.blue("HTML") + chalk.dim(" (web/browser)"),
+ name: colors.blue("HTML") + colors.dim(" (web/browser)"),
value: "html" as const,
description: "Generates HTML with inline styles for web display",
},
@@ -98,12 +93,12 @@ export async function runInteractiveMode(): Promise {
});
const wantPreview = await confirm({
- message: "š Show a preview with your settings?",
+ message: "Show a preview with your settings?",
default: true,
});
if (wantPreview) {
- console.log("\n" + chalk.bold("š¬ Preview with your selected settings:"));
+ console.log("\n" + colors.bold("Preview with your selected settings:"));
const logsDX = await LogsDX.getInstance({
theme: finalTheme,
outputFormat: outputFormat as "ansi" | "html",
@@ -116,7 +111,7 @@ export async function runInteractiveMode(): Promise {
}
const saveConfig = await confirm({
- message: "š¾ Save these settings as default?",
+ message: "Save these settings as default?",
default: false,
});
@@ -124,22 +119,20 @@ export async function runInteractiveMode(): Promise {
ui.showInfo("Configuration saved to ~/.logsdxrc.json");
}
- const result = interactiveConfigSchema.parse({
+ return {
theme: finalTheme,
- outputFormat,
+ outputFormat: outputFormat as "ansi" | "html",
preview: wantPreview,
- });
-
- return result;
+ };
}
export async function selectThemeInteractively(): Promise {
const themeNames = getThemeNames();
return await select({
- message: "šØ Select a theme:",
+ message: "Select a theme:",
choices: themeNames.map((name: string) => ({
- name: chalk.cyan(name),
+ name: colors.cyan(name),
value: name,
})),
});
@@ -162,17 +155,15 @@ export async function showThemeList(): Promise {
`INFO Sample log with ${themeName} theme - GET /api/test 200 OK`,
);
- console.log(chalk.bold.cyan(`\n${sample}:`));
+ console.log(colors.bold.cyan(`\n${sample}:`));
if (theme?.description) {
- console.log(chalk.dim(` ${theme.description}`));
+ console.log(colors.dim(` ${theme.description}`));
}
console.log(` ${styledSample}`);
}
+ console.log(colors.yellow("\nUse --interactive for guided theme selection"));
console.log(
- chalk.yellow("\nš” Use --interactive for guided theme selection"),
- );
- console.log(
- chalk.yellow("š” Use --preview to see all themes with sample logs"),
+ colors.yellow("Use --preview to see all themes with sample logs"),
);
}
diff --git a/src/cli/theme-gen.ts b/src/cli/theme-gen.ts
index 2eb8fba..8d8f20b 100644
--- a/src/cli/theme-gen.ts
+++ b/src/cli/theme-gen.ts
@@ -1,6 +1,6 @@
import { select, input, checkbox, confirm } from "../utils/prompts";
import { ui } from "./ui";
-import chalk from "chalk";
+import colors, { hex } from "../utils/colors";
import fs from "fs";
import path from "path";
import {
@@ -13,15 +13,15 @@ import {
} from "../themes/presets";
import { registerTheme, getAllThemes, getTheme } from "../themes";
import type { Theme, PatternMatch } from "../types";
-import { themePresetSchema } from "../schema";
+import { parseTheme } from "../schema";
import { LogsDX } from "../index";
export async function runThemeGenerator(): Promise {
ui.showHeader();
- ui.showInfo("šØ Welcome to the LogsDX Theme Generator!");
+ ui.showInfo("Welcome to the LogsDX Theme Generator");
console.log(
- chalk.dim(
+ colors.dim(
"Create custom themes by combining color palettes with pattern presets.\n",
),
);
@@ -43,9 +43,9 @@ export async function runThemeGenerator(): Promise {
const palettes = listColorPalettes();
const selectedPalette = await select({
- message: "šØ Choose a color palette:",
+ message: "Choose a color palette:",
choices: palettes.map((palette) => ({
- name: `${chalk.bold(palette.name)} - ${palette.description}`,
+ name: `${colors.bold(palette.name)} - ${palette.description}`,
value: palette.name,
description: `Contrast: ${palette.accessibility.contrastRatio.toFixed(1)}, ${
palette.accessibility.colorBlindSafe
@@ -66,7 +66,7 @@ export async function runThemeGenerator(): Promise {
);
const selectedPresets = await checkbox({
- message: "š Select pattern presets to include:",
+ message: "Select pattern presets to include:",
choices: Object.entries(presetsByCategory).flatMap(
([category, categoryPresets]) =>
categoryPresets.map((preset) => ({
@@ -79,7 +79,7 @@ export async function runThemeGenerator(): Promise {
const filteredPresets = selectedPresets;
const addCustomPatterns = await confirm({
- message: "ā Add custom patterns?",
+ message: "Add custom patterns?",
default: false,
});
@@ -89,7 +89,7 @@ export async function runThemeGenerator(): Promise {
}
const addCustomWords = await confirm({
- message: "ā Add custom word matches?",
+ message: "Add custom word matches?",
default: false,
});
@@ -115,7 +115,7 @@ export async function runThemeGenerator(): Promise {
await showThemePreview(theme, palette);
const shouldSave = await confirm({
- message: "š¾ Save this theme?",
+ message: "Save this theme?",
default: true,
});
@@ -146,11 +146,11 @@ export async function runThemeGenerator(): Promise {
if (saveLocation === "file" || saveLocation === "both") {
const filename = `${themeName}.theme.json`;
fs.writeFileSync(filename, JSON.stringify(theme, null, 2));
- ui.showSuccess(`Theme saved to ${chalk.cyan(filename)}`);
+ ui.showSuccess(`Theme saved to ${colors.cyan(filename)}`);
}
console.log(
- chalk.green(
+ colors.green(
`\n⨠Your theme "${themeName}" is ready to use!\nTry it with: logsdx --theme ${themeName} your-log-file.log`,
),
);
@@ -291,7 +291,7 @@ async function collectCustomWords(): Promise<
}
async function showThemePreview(theme: Theme, palette: ColorPalette) {
- console.log(chalk.bold("\nš¬ Theme Preview:\n"));
+ console.log(colors.bold("\nTheme Preview:\n"));
const sampleLogs = [
"2024-01-15 10:30:45 INFO API server started on port 3000",
@@ -313,53 +313,51 @@ async function showThemePreview(theme: Theme, palette: ColorPalette) {
console.log(` ${logsDX.processLine(log)}`);
});
- console.log(chalk.bold("\nš Color Palette Details:"));
- console.log(` Name: ${chalk.cyan(palette.name)}`);
+ console.log(colors.bold("\nColor Palette Details:"));
+ console.log(` Name: ${colors.cyan(palette.name)}`);
console.log(` Description: ${palette.description}`);
console.log(
- ` Contrast Ratio: ${chalk.yellow(palette.accessibility.contrastRatio.toFixed(1))}`,
+ ` Contrast Ratio: ${colors.yellow(palette.accessibility.contrastRatio.toFixed(1))}`,
);
console.log(
` Color-blind Safe: ${
palette.accessibility.colorBlindSafe
- ? chalk.green("Yes")
- : chalk.red("No")
+ ? colors.green("Yes")
+ : colors.red("No")
}`,
);
console.log(
- ` Mode: ${palette.accessibility.darkMode ? chalk.blue("Dark") : chalk.yellow("Light")}`,
+ ` Mode: ${palette.accessibility.darkMode ? colors.blue("Dark") : colors.yellow("Light")}`,
);
}
export function listColorPalettesCommand(): void {
- ui.showInfo("šØ Available Color Palettes:\n");
+ ui.showInfo("Available Color Palettes:\n");
const palettes = listColorPalettes();
palettes.forEach((palette, index) => {
- console.log(chalk.bold.cyan(`${index + 1}. ${palette.name}`));
+ console.log(colors.bold.cyan(`${index + 1}. ${palette.name}`));
console.log(` ${palette.description}`);
console.log(
- ` ${chalk.dim(`Contrast: ${palette.accessibility.contrastRatio.toFixed(1)}`)} ${chalk.dim(
+ ` ${colors.dim(`Contrast: ${palette.accessibility.contrastRatio.toFixed(1)}`)} ${colors.dim(
`| ${palette.accessibility.colorBlindSafe ? "Color-blind safe" : "Not color-blind safe"}`,
- )} ${chalk.dim(`| ${palette.accessibility.darkMode ? "Dark" : "Light"} mode`)}`,
+ )} ${colors.dim(`| ${palette.accessibility.darkMode ? "Dark" : "Light"} mode`)}`,
);
console.log(" Colors:");
Object.entries(palette.colors).forEach(([role, color]) => {
- console.log(` ${role}: ${chalk.hex(color)(color)}`);
+ console.log(` ${role}: ${hex(color)(color)}`);
});
console.log();
});
console.log(
- chalk.yellow(
- "š” Use --generate-theme to create a theme with these palettes",
- ),
+ colors.yellow("Use --generate-theme to create a theme with these palettes"),
);
}
export function listPatternPresetsCommand(): void {
- ui.showInfo("š Available Pattern Presets:\n");
+ ui.showInfo("Available Pattern Presets:\n");
const presets = listPatternPresets();
const presetsByCategory = presets.reduce(
@@ -372,19 +370,19 @@ export function listPatternPresetsCommand(): void {
);
Object.entries(presetsByCategory).forEach(([category, categoryPresets]) => {
- console.log(chalk.bold.yellow(`\n${category.toUpperCase()}:`));
+ console.log(colors.bold.yellow(`\n${category.toUpperCase()}:`));
categoryPresets.forEach((preset) => {
- console.log(chalk.bold.cyan(` ${preset.name}`));
+ console.log(colors.bold.cyan(` ${preset.name}`));
console.log(` ${preset.description}`);
console.log(
- ` ${chalk.dim(`${preset.patterns.length} patterns, ${Object.keys(preset.matchWords).length} word matches`)}`,
+ ` ${colors.dim(`${preset.patterns.length} patterns, ${Object.keys(preset.matchWords).length} word matches`)}`,
);
});
});
console.log(
- chalk.yellow(
- "\nš” Use --generate-theme to create a theme with these presets",
+ colors.yellow(
+ "\nUse --generate-theme to create a theme with these presets",
),
);
}
@@ -576,7 +574,7 @@ export async function exportTheme(themeName?: string): Promise {
(await select({
message: "Select theme to export:",
choices: availableThemes.map((name) => ({
- name: chalk.cyan(name),
+ name: colors.cyan(name),
value: name,
})),
}));
@@ -608,7 +606,7 @@ export async function exportTheme(themeName?: string): Promise {
fs.writeFileSync(filename, JSON.stringify(exportData, null, 2));
ui.showSuccess(
- `Theme "${themeToExport}" exported to ${chalk.cyan(filename)}`,
+ `Theme "${themeToExport}" exported to ${colors.cyan(filename)}`,
);
const showPreview = await confirm({
@@ -617,10 +615,10 @@ export async function exportTheme(themeName?: string): Promise {
});
if (showPreview) {
- console.log(chalk.dim("\nFile contents:"));
- console.log(chalk.dim("ā".repeat(50)));
+ console.log(colors.dim("\nFile contents:"));
+ console.log(colors.dim("ā".repeat(50)));
console.log(JSON.stringify(exportData, null, 2));
- console.log(chalk.dim("ā".repeat(50)));
+ console.log(colors.dim("ā".repeat(50)));
}
} catch (error) {
ui.showError(
@@ -712,9 +710,9 @@ export async function importTheme(filename?: string): Promise {
const fileContent = fs.readFileSync(themeFile, "utf8");
const themeData = JSON.parse(fileContent);
- const validatedTheme = themePresetSchema.parse(themeData);
+ const validatedTheme = parseTheme(themeData);
- ui.showInfo(`Importing theme: ${chalk.cyan(validatedTheme.name)}`);
+ ui.showInfo(`Importing theme: ${colors.cyan(validatedTheme.name)}`);
if (validatedTheme.description) {
console.log(`Description: ${validatedTheme.description}`);
}
@@ -754,7 +752,7 @@ export async function importTheme(filename?: string): Promise {
registerTheme(validatedTheme);
ui.showSuccess(`Theme "${validatedTheme.name}" imported successfully!`);
console.log(
- chalk.green(
+ colors.green(
`\n⨠Use your imported theme with: logsdx --theme ${validatedTheme.name} your-log-file.log`,
),
);
@@ -783,7 +781,7 @@ export async function importTheme(filename?: string): Promise {
}
async function previewImportedTheme(theme: Theme) {
- console.log(chalk.bold("\nš¬ Theme Preview:\n"));
+ console.log(colors.bold("\nTheme Preview:\n"));
const sampleLogs = [
"2024-01-15 10:30:45 INFO Starting application server",
@@ -804,22 +802,22 @@ async function previewImportedTheme(theme: Theme) {
console.log(` ${logsDX.processLine(log)}`);
});
- console.log(chalk.bold("\nš Theme Details:"));
- console.log(` Name: ${chalk.cyan(theme.name)}`);
+ console.log(colors.bold("\nTheme Details:"));
+ console.log(` Name: ${colors.cyan(theme.name)}`);
if (theme.description) {
console.log(` Description: ${theme.description}`);
}
const exportedTheme = theme as Theme & { exportedAt?: string };
if (exportedTheme.exportedAt) {
console.log(
- ` Exported: ${chalk.dim(new Date(exportedTheme.exportedAt).toLocaleString())}`,
+ ` Exported: ${colors.dim(new Date(exportedTheme.exportedAt).toLocaleString())}`,
);
}
const wordCount = Object.keys(theme.schema.matchWords || {}).length;
const patternCount = (theme.schema.matchPatterns || []).length;
console.log(
- ` Patterns: ${chalk.yellow(patternCount)}, Words: ${chalk.yellow(wordCount)}`,
+ ` Patterns: ${colors.yellow(patternCount)}, Words: ${colors.yellow(wordCount)}`,
);
}
@@ -867,7 +865,7 @@ export function listThemeFilesCommand(directory = "."): void {
if (files.length === 0) {
ui.showInfo("No theme files found in current directory");
console.log(
- chalk.dim("Theme files should have the extension .theme.json"),
+ colors.dim("Theme files should have the extension .theme.json"),
);
return;
}
@@ -879,28 +877,28 @@ export function listThemeFilesCommand(directory = "."): void {
const content = fs.readFileSync(file, "utf8");
const themeData = JSON.parse(content);
- console.log(chalk.bold.cyan(`${index + 1}. ${path.basename(file)}`));
+ console.log(colors.bold.cyan(`${index + 1}. ${path.basename(file)}`));
console.log(` Theme: ${themeData.name || "Unknown"}`);
if (themeData.description) {
console.log(` Description: ${themeData.description}`);
}
if (themeData.exportedAt) {
console.log(
- ` Exported: ${chalk.dim(new Date(themeData.exportedAt).toLocaleString())}`,
+ ` Exported: ${colors.dim(new Date(themeData.exportedAt).toLocaleString())}`,
);
}
- console.log(` File: ${chalk.dim(file)}`);
+ console.log(` File: ${colors.dim(file)}`);
console.log();
} catch {
- console.log(chalk.bold.red(`${index + 1}. ${path.basename(file)}`));
- console.log(chalk.red(` Error: Invalid theme file`));
- console.log(` File: ${chalk.dim(file)}`);
+ console.log(colors.bold.red(`${index + 1}. ${path.basename(file)}`));
+ console.log(colors.red(` Error: Invalid theme file`));
+ console.log(` File: ${colors.dim(file)}`);
console.log();
}
});
console.log(
- chalk.yellow("š” Use --import-theme to import a theme"),
+ colors.yellow("Use --import-theme to import a theme"),
);
} catch (error) {
ui.showError(
diff --git a/src/cli/types.ts b/src/cli/types.ts
index b852979..1e17315 100644
--- a/src/cli/types.ts
+++ b/src/cli/types.ts
@@ -1,27 +1,23 @@
-import { z } from "zod";
+export type CliOptions = {
+ input?: string;
+ output?: string;
+ theme?: string;
+ config?: string;
+ debug?: boolean;
+ quiet?: boolean;
+ listThemes?: boolean;
+ interactive?: boolean;
+ preview?: boolean;
+ noSpinner?: boolean;
+ format?: "ansi" | "html";
+ generateTheme?: boolean;
+ listPalettes?: boolean;
+ listPatterns?: boolean;
+ exportTheme?: string;
+ importTheme?: string;
+ listThemeFiles?: boolean;
+};
-export const cliOptionsSchema = z.object({
- input: z.string().optional(),
- output: z.string().optional(),
- theme: z.string().optional(),
- config: z.string().optional(),
- debug: z.boolean().optional().default(false),
- quiet: z.boolean().optional().default(false),
- listThemes: z.boolean().optional().default(false),
- interactive: z.boolean().optional().default(false),
- preview: z.boolean().optional().default(false),
- noSpinner: z.boolean().optional().default(false),
- format: z.enum(["ansi", "html"]).optional(),
-
- generateTheme: z.boolean().optional().default(false),
- listPalettes: z.boolean().optional().default(false),
- listPatterns: z.boolean().optional().default(false),
- exportTheme: z.string().optional(),
- importTheme: z.string().optional(),
- listThemeFiles: z.boolean().optional().default(false),
-});
-
-export type CliOptions = z.infer;
export type CommanderOptions = CliOptions;
export interface SpinnerLike {
diff --git a/src/cli/ui.ts b/src/cli/ui.ts
index 389e00a..a54e4ee 100644
--- a/src/cli/ui.ts
+++ b/src/cli/ui.ts
@@ -56,22 +56,22 @@ export class CliUI {
}
showSuccess(message: string) {
- console.log(colors.green("ā
"), colors.bold(message));
+ console.log(colors.green("[ok]"), colors.bold(message));
}
showError(message: string, suggestion?: string) {
- console.log(colors.red("ā"), colors.bold.red("Error:"), message);
+ console.log(colors.red("[error]"), colors.bold.red(message));
if (suggestion) {
- console.log(colors.yellow("š”"), colors.italic(suggestion));
+ console.log(colors.yellow(" hint:"), colors.italic(suggestion));
}
}
showWarning(message: string) {
- console.log(colors.yellow("ā ļø"), colors.bold.yellow("Warning:"), message);
+ console.log(colors.yellow("[warn]"), colors.bold.yellow(message));
}
showInfo(message: string) {
- console.log(colors.blue("ā¹ļø"), message);
+ console.log(colors.blue("[info]"), message);
}
showThemePreview(themeName: string, sample: string) {
@@ -87,9 +87,9 @@ export class CliUI {
showFileStats(filename: string, lineCount: number, fileSize: number) {
const stats = [
- `š File: ${colors.cyan(filename)}`,
- `š Lines: ${colors.yellow(lineCount.toLocaleString())}`,
- `š Size: ${colors.green(this.formatFileSize(fileSize))}`,
+ `File: ${colors.cyan(filename)}`,
+ `Lines: ${colors.yellow(lineCount.toLocaleString())}`,
+ `Size: ${colors.green(this.formatFileSize(fileSize))}`,
].join(" ");
console.log(
diff --git a/src/core.ts b/src/core.ts
new file mode 100644
index 0000000..7765cf3
--- /dev/null
+++ b/src/core.ts
@@ -0,0 +1,166 @@
+/**
+ * LogsDX Core - Pure JavaScript module for minimal JS engines (QuickJS, etc.)
+ *
+ * This module exports core functionality without:
+ * - Dynamic imports
+ * - Node.js-specific APIs (process, fs, path)
+ * - Browser-specific APIs (window, matchMedia)
+ *
+ * Themes are bundled statically for QuickJS compatibility.
+ */
+
+import { tokenize, applyTheme } from "./tokenizer";
+import {
+ tokensToString,
+ tokensToHtml,
+ tokensToClassNames,
+ renderLine,
+ renderLines,
+} from "./renderer";
+import { validateTheme, validateThemeSafe } from "./schema/validator";
+import {
+ isValidationError,
+ formatValidationIssues,
+ ValidationError,
+} from "./lib/validate";
+import {
+ createTheme,
+ createSimpleTheme,
+ extendTheme,
+ ThemeBuilder,
+ THEME_PRESETS,
+} from "./themes/builder";
+
+import { ohMyZsh } from "./themes/presets/oh-my-zsh";
+import { dracula } from "./themes/presets/dracula";
+import { nord } from "./themes/presets/nord";
+import { monokai } from "./themes/presets/monokai";
+import { githubLight } from "./themes/presets/github-light";
+import { githubDark } from "./themes/presets/github-dark";
+import { solarizedLight } from "./themes/presets/solarized-light";
+import { solarizedDark } from "./themes/presets/solarized-dark";
+
+import type { Theme, ThemePair, StyleOptions, SchemaConfig } from "./types";
+import type { Token, TokenList } from "./schema/types";
+import type {
+ RenderOptions,
+ OutputFormat,
+ HtmlStyleFormat,
+ MatchType,
+ TokenWithStyle,
+} from "./renderer/types";
+
+export const BUNDLED_THEMES = {
+ "oh-my-zsh": ohMyZsh,
+ dracula,
+ nord,
+ monokai,
+ "github-light": githubLight,
+ "github-dark": githubDark,
+ "solarized-light": solarizedLight,
+ "solarized-dark": solarizedDark,
+} as const;
+
+export type BundledThemeName = keyof typeof BUNDLED_THEMES;
+
+const customThemes = new Map();
+
+export function getTheme(name: string): Theme {
+ const bundled = BUNDLED_THEMES[name as BundledThemeName];
+ if (bundled) return bundled;
+
+ const custom = customThemes.get(name);
+ if (custom) return custom;
+
+ return BUNDLED_THEMES["oh-my-zsh"];
+}
+
+export function registerTheme(theme: Theme): void {
+ customThemes.set(theme.name, theme);
+}
+
+export function getAllThemes(): Record {
+ const result: Record = { ...BUNDLED_THEMES };
+ customThemes.forEach((theme, name) => {
+ result[name] = theme;
+ });
+ return result;
+}
+
+export function getThemeNames(): string[] {
+ return [...Object.keys(BUNDLED_THEMES), ...customThemes.keys()];
+}
+
+export function processLine(line: string, theme: Theme): string {
+ const tokens = tokenize(line, theme);
+ const styled = applyTheme(tokens, theme);
+ return tokensToString(styled, true);
+}
+
+export function processLineHtml(
+ line: string,
+ theme: Theme,
+ useClasses = false,
+): string {
+ const tokens = tokenize(line, theme);
+ const styled = applyTheme(tokens, theme);
+ return useClasses ? tokensToClassNames(styled) : tokensToHtml(styled);
+}
+
+export function processLines(lines: string[], theme: Theme): string[] {
+ return lines.map((line) => processLine(line, theme));
+}
+
+export function processLog(content: string, theme: Theme): string {
+ const lines = content.split("\n");
+ const processed = lines.map((line) => processLine(line, theme));
+ return processed.join("\n");
+}
+
+export {
+ tokenize,
+ applyTheme,
+ tokensToString,
+ tokensToHtml,
+ tokensToClassNames,
+ renderLine,
+ renderLines,
+ validateTheme,
+ validateThemeSafe,
+ isValidationError,
+ formatValidationIssues,
+ ValidationError,
+ createTheme,
+ createSimpleTheme,
+ extendTheme,
+ ThemeBuilder,
+ THEME_PRESETS,
+};
+
+export type {
+ Theme,
+ ThemePair,
+ StyleOptions,
+ SchemaConfig,
+ Token,
+ TokenList,
+ RenderOptions,
+ OutputFormat,
+ HtmlStyleFormat,
+ MatchType,
+ TokenWithStyle,
+};
+
+export default {
+ getTheme,
+ registerTheme,
+ getAllThemes,
+ getThemeNames,
+ processLine,
+ processLineHtml,
+ processLines,
+ processLog,
+ tokenize,
+ applyTheme,
+ BUNDLED_THEMES,
+};
diff --git a/src/index.ts b/src/index.ts
index 60f78f5..8971727 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -16,8 +16,15 @@ import {
} from "./themes";
import { validateTheme, validateThemeSafe } from "./schema/validator";
import { tokenize, applyTheme } from "./tokenizer";
+import { createLogger } from "./utils/logger";
import type { TokenList } from "./schema/types";
-import type { RenderOptions } from "./renderer/types";
+import type {
+ RenderOptions,
+ OutputFormat,
+ HtmlStyleFormat,
+ MatchType,
+ TokenWithStyle,
+} from "./renderer/types";
import type {
LineParser,
ParsedLine,
@@ -37,6 +44,8 @@ import {
getRecommendedThemeMode,
} from "./renderer";
+const log = createLogger("logsdx");
+
/**
* LogsDX - A powerful log processing and styling tool
*
@@ -161,9 +170,7 @@ export class LogsDX {
try {
return validateTheme(theme as Theme);
} catch (error) {
- if (this.options.debug) {
- console.warn("Invalid custom theme:", error);
- }
+ log.debug(`Invalid custom theme: ${error}`);
return {
name: "none",
@@ -307,9 +314,7 @@ export class LogsDX {
this.currentTheme = await this.resolveTheme(theme);
return true;
} catch (error) {
- if (this.options.debug) {
- console.warn("Invalid theme:", error);
- }
+ log.debug(`Invalid theme: ${error}`);
return false;
}
}
@@ -354,6 +359,15 @@ export type {
TokenList,
LineParser,
ParsedLine,
+ LogsDXOptions,
+};
+
+export type {
+ OutputFormat,
+ HtmlStyleFormat,
+ MatchType,
+ TokenWithStyle,
+ RenderOptions,
};
export {
@@ -374,6 +388,12 @@ export {
THEME_PRESETS,
};
+export {
+ isValidationError,
+ formatValidationIssues,
+ ValidationError,
+} from "./schema";
+
export { tokenize, applyTheme };
export {
@@ -381,6 +401,7 @@ export {
renderLightBox,
renderLightBoxLine,
isLightThemeRenderer as isLightThemeStyle,
+ isLightThemeRenderer as isLightTheme,
};
export {
diff --git a/src/lib/validate.ts b/src/lib/validate.ts
new file mode 100644
index 0000000..8ddff3a
--- /dev/null
+++ b/src/lib/validate.ts
@@ -0,0 +1,187 @@
+type ValidationResult =
+ | { success: true; data: T }
+ | { success: false; error: ValidationError };
+
+export class ValidationError extends Error {
+ constructor(
+ message: string,
+ public path: string[] = [],
+ public issues: { path: string[]; message: string }[] = [],
+ ) {
+ super(message);
+ this.name = "ValidationError";
+ }
+}
+
+type Validator = {
+ parse: (value: unknown) => T;
+ safeParse: (value: unknown) => ValidationResult;
+ optional: () => Validator;
+};
+
+function createValidator(
+ validate: (value: unknown, path: string[]) => T,
+): Validator {
+ return {
+ parse(value: unknown): T {
+ return validate(value, []);
+ },
+ safeParse(value: unknown): ValidationResult {
+ try {
+ return { success: true, data: validate(value, []) };
+ } catch (e) {
+ return { success: false, error: e as ValidationError };
+ }
+ },
+ optional(): Validator {
+ return createValidator((v, path) =>
+ v === undefined ? undefined : validate(v, path),
+ );
+ },
+ };
+}
+
+function fail(message: string, path: string[]): never {
+ throw new ValidationError(message, path, [{ path, message }]);
+}
+
+export const v = {
+ string(): Validator {
+ return createValidator((value, path) => {
+ if (typeof value !== "string")
+ fail(`Expected string, got ${typeof value}`, path);
+ return value;
+ });
+ },
+
+ number(): Validator {
+ return createValidator((value, path) => {
+ if (typeof value !== "number")
+ fail(`Expected number, got ${typeof value}`, path);
+ return value;
+ });
+ },
+
+ boolean(): Validator {
+ return createValidator((value, path) => {
+ if (typeof value !== "boolean")
+ fail(`Expected boolean, got ${typeof value}`, path);
+ return value;
+ });
+ },
+
+ literal(expected: T): Validator {
+ return createValidator((value, path) => {
+ if (value !== expected)
+ fail(`Expected ${String(expected)}, got ${String(value)}`, path);
+ return expected;
+ });
+ },
+
+ enum(values: readonly T[]): Validator {
+ return createValidator((value, path) => {
+ if (typeof value !== "string" || !values.includes(value as T)) {
+ fail(`Expected one of: ${values.join(", ")}`, path);
+ }
+ return value as T;
+ });
+ },
+
+ array(itemValidator: Validator): Validator {
+ return createValidator((value, path) => {
+ if (!Array.isArray(value)) fail("Expected array", path);
+ return value.map((item, i) => itemValidator.parse(item));
+ });
+ },
+
+ object>>(
+ shape: T,
+ ): Validator<{
+ [K in keyof T]: T[K] extends Validator ? U : never;
+ }> {
+ return createValidator((value, path) => {
+ if (typeof value !== "object" || value === null)
+ fail("Expected object", path);
+ const result: Record = {};
+ const obj = value as Record;
+
+ for (const [key, validator] of Object.entries(shape)) {
+ try {
+ result[key] = validator.parse(obj[key]);
+ } catch (e) {
+ if (e instanceof ValidationError) {
+ fail(e.message, [...path, key]);
+ }
+ throw e;
+ }
+ }
+ return result as {
+ [K in keyof T]: T[K] extends Validator ? U : never;
+ };
+ });
+ },
+
+ record(valueValidator: Validator): Validator> {
+ return createValidator((value, path) => {
+ if (typeof value !== "object" || value === null)
+ fail("Expected object", path);
+ const result: Record = {};
+ for (const [key, val] of Object.entries(value)) {
+ result[key] = valueValidator.parse(val);
+ }
+ return result;
+ });
+ },
+
+ union[]>(
+ ...validators: T
+ ): Validator ? U : never> {
+ return createValidator((value, path) => {
+ for (const validator of validators) {
+ const result = validator.safeParse(value);
+ if (result.success)
+ return result.data as T[number] extends Validator
+ ? U
+ : never;
+ }
+ fail("Value did not match any variant", path);
+ });
+ },
+
+ refine(
+ validator: Validator,
+ check: (value: T) => boolean,
+ message: string,
+ ): Validator {
+ return createValidator((value, path) => {
+ const parsed = validator.parse(value);
+ if (!check(parsed)) fail(message, path);
+ return parsed;
+ });
+ },
+
+ withDefault(validator: Validator, defaultValue: T): Validator {
+ return createValidator((value, path) => {
+ if (value === undefined) return defaultValue;
+ return validator.parse(value);
+ });
+ },
+};
+
+export function isValidationError(error: unknown): error is ValidationError {
+ return (
+ error instanceof ValidationError ||
+ (typeof error === "object" &&
+ error !== null &&
+ "issues" in error &&
+ Array.isArray((error as ValidationError).issues))
+ );
+}
+
+export function formatValidationIssues(
+ issues: { path: string[]; message: string }[],
+): string {
+ return issues
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
+ .join(", ");
+}
diff --git a/src/renderer/constants.ts b/src/renderer/constants.ts
index ec0b07d..5f95601 100644
--- a/src/renderer/constants.ts
+++ b/src/renderer/constants.ts
@@ -36,44 +36,35 @@ export const CLASS_ITALIC = "logsdx-italic";
export const CLASS_UNDERLINE = "logsdx-underline";
export const CLASS_DIM = "logsdx-dim";
-export function supportsColors(): boolean {
- if (process.env.NO_COLOR) {
- return false;
- }
-
- if (process.env.FORCE_COLOR) {
- return true;
- }
+function getEnv(key: string): string | undefined {
+ const hasProcess = typeof process !== "undefined" && process.env;
+ return hasProcess ? process.env[key] : undefined;
+}
- if (process.stdout && process.stdout.isTTY === false) {
- return false;
- }
+function isTTY(): boolean {
+ const hasProcess = typeof process !== "undefined" && process.stdout;
+ return hasProcess ? process.stdout.isTTY !== false : true;
+}
- const term = process.env.TERM;
- if (!term) {
- if (typeof Bun !== "undefined" || "Deno" in globalThis) {
- return true;
- }
- return false;
- }
+function isColorTerm(term: string): boolean {
+ const colorTerms = ["xterm", "screen", "tmux"];
+ const hasColorKeyword =
+ term.includes("color") || term.includes("256") || term.includes("ansi");
+ return (
+ hasColorKeyword || colorTerms.includes(term) || Boolean(getEnv("COLORTERM"))
+ );
+}
- if (term === "dumb") {
- return false;
- }
+export function supportsColors(): boolean {
+ if (getEnv("NO_COLOR")) return false;
+ if (getEnv("FORCE_COLOR")) return true;
+ if (!isTTY()) return false;
- if (
- term.includes("color") ||
- term.includes("256") ||
- term.includes("ansi") ||
- term === "xterm" ||
- term === "screen" ||
- term === "tmux" ||
- process.env.COLORTERM
- ) {
- return true;
- }
+ const term = getEnv("TERM");
+ if (!term) return true;
+ if (term === "dumb") return false;
- return false;
+ return isColorTerm(term);
}
export const DARK_TERMINALS: ReadonlyArray = [
diff --git a/src/renderer/detectBackground.ts b/src/renderer/detectBackground.ts
index 8f7897a..fcfd136 100644
--- a/src/renderer/detectBackground.ts
+++ b/src/renderer/detectBackground.ts
@@ -12,77 +12,57 @@ import {
DEFAULT_AUTO_BACKGROUND,
} from "./constants";
+function getEnv(key: string): string | undefined {
+ const hasProcess = typeof process !== "undefined" && process.env;
+ return hasProcess ? process.env[key] : undefined;
+}
+
+function getPlatform(): string | undefined {
+ const hasProcess = typeof process !== "undefined";
+ return hasProcess ? process.platform : undefined;
+}
+
function createBackgroundInfo(
scheme: ColorScheme,
confidence: ConfidenceLevel,
source: BackgroundInfo["source"],
details?: BackgroundInfo["details"],
): BackgroundInfo {
- return {
- scheme,
- confidence,
- source,
- ...(details && { details }),
- } as const;
+ return { scheme, confidence, source, ...(details && { details }) } as const;
}
function detectFromColorFgBg(): BackgroundInfo | undefined {
- const colorFgBg = process.env.COLORFGBG;
-
- if (!colorFgBg) {
- return undefined;
- }
+ const colorFgBg = getEnv("COLORFGBG");
+ if (!colorFgBg) return undefined;
const bgColor = parseColorFgBg(colorFgBg);
-
- if (bgColor === undefined) {
- return undefined;
- }
+ if (bgColor === undefined) return undefined;
const scheme = isLightBgColor(bgColor) ? "light" : "dark";
-
return createBackgroundInfo(scheme, "high", "terminal", { colorFgBg });
}
-function isTerminalInList(
- termProgram: string,
- list: ReadonlyArray,
-): boolean {
- return list.includes(termProgram);
-}
-
function detectFromTermProgram(): BackgroundInfo | undefined {
- const termProgram = process.env.TERM_PROGRAM;
-
- if (!termProgram) {
- return undefined;
- }
+ const termProgram = getEnv("TERM_PROGRAM");
+ if (!termProgram) return undefined;
- if (isTerminalInList(termProgram, DARK_TERMINALS)) {
+ if (DARK_TERMINALS.includes(termProgram)) {
return createBackgroundInfo("dark", "medium", "terminal", { termProgram });
}
-
- if (isTerminalInList(termProgram, LIGHT_TERMINALS)) {
+ if (LIGHT_TERMINALS.includes(termProgram)) {
return createBackgroundInfo("light", "medium", "terminal", { termProgram });
}
-
return undefined;
}
function isVSCode(): boolean {
- const hasVscodePid = Boolean(process.env.VSCODE_PID);
- const hasVscodeVersion = Boolean(
- process.env.TERM_PROGRAM_VERSION?.includes("vscode"),
- );
-
- return hasVscodePid || hasVscodeVersion;
+ const hasVscodePid = Boolean(getEnv("VSCODE_PID"));
+ const versionStr = getEnv("TERM_PROGRAM_VERSION") || "";
+ return hasVscodePid || versionStr.includes("vscode");
}
function detectFromVSCode(): BackgroundInfo | undefined {
- if (!isVSCode()) {
- return undefined;
- }
-
+ if (!isVSCode()) return undefined;
return createBackgroundInfo("auto", "low", "terminal", {
termProgram: "vscode",
});
@@ -141,40 +121,28 @@ export function detectBrowserBackground(): BackgroundInfo {
}
function detectFromMacOS(): BackgroundInfo | undefined {
- if (process.platform !== "darwin") {
- return undefined;
- }
+ if (getPlatform() !== "darwin") return undefined;
- const appleInterfaceStyle = process.env.APPLE_INTERFACE_STYLE;
-
- if (!appleInterfaceStyle) {
- return undefined;
- }
+ const appleInterfaceStyle = getEnv("APPLE_INTERFACE_STYLE");
+ if (!appleInterfaceStyle) return undefined;
const scheme =
appleInterfaceStyle.toLowerCase() === "dark" ? "dark" : "light";
-
return createBackgroundInfo(scheme, "high", "system", {
systemPreference: appleInterfaceStyle,
});
}
function detectFromWindows(): BackgroundInfo | undefined {
- if (process.platform !== "win32") {
- return undefined;
- }
-
+ if (getPlatform() !== "win32") return undefined;
return createBackgroundInfo("auto", "low", "system");
}
function detectFromLinux(): BackgroundInfo | undefined {
- const desktopSession = process.env.DESKTOP_SESSION;
- const xdgCurrentDesktop = process.env.XDG_CURRENT_DESKTOP;
-
- if (!desktopSession && !xdgCurrentDesktop) {
- return undefined;
- }
+ const desktopSession = getEnv("DESKTOP_SESSION");
+ const xdgCurrentDesktop = getEnv("XDG_CURRENT_DESKTOP");
+ if (!desktopSession && !xdgCurrentDesktop) return undefined;
return createBackgroundInfo("auto", "medium", "system", {
systemPreference: desktopSession || xdgCurrentDesktop,
});
diff --git a/src/schema/index.ts b/src/schema/index.ts
index da18542..e65da90 100644
--- a/src/schema/index.ts
+++ b/src/schema/index.ts
@@ -1,7 +1,11 @@
-import { z } from "zod";
+import {
+ v,
+ ValidationError,
+ isValidationError,
+ formatValidationIssues,
+} from "../lib/validate";
import {
COLOR_VALIDATION_MESSAGE,
- EMPTY_COLOR_MESSAGE,
STYLE_CODES,
WHITESPACE_OPTIONS,
NEWLINE_OPTIONS,
@@ -9,61 +13,131 @@ import {
HTML_STYLE_FORMATS,
DEFAULT_WHITESPACE,
DEFAULT_NEWLINE,
- THEME_MODE_DESCRIPTION,
- TOKEN_CONTENT_DESCRIPTION,
- TOKEN_METADATA_DESCRIPTION,
- TOKEN_STYLE_DESCRIPTION,
- HTML_STYLE_FORMAT_DESCRIPTION,
} from "./constants";
import { isValidColorFormat } from "./utils";
+import type { StyleOptions, PatternMatch, SchemaConfig, Theme } from "../types";
+
+const styleOptionsValidator = v.object({
+ color: v.refine(v.string(), isValidColorFormat, COLOR_VALIDATION_MESSAGE),
+ styleCodes: v.array(v.enum(STYLE_CODES)).optional(),
+ htmlStyleFormat: v.enum(HTML_STYLE_FORMATS).optional(),
+});
+
+const patternMatchValidator = v.object({
+ name: v.string(),
+ pattern: v.string(),
+ options: styleOptionsValidator,
+});
-export const styleOptionsSchema = z.object({
- color: z.string().min(1, EMPTY_COLOR_MESSAGE).refine(isValidColorFormat, {
- message: COLOR_VALIDATION_MESSAGE,
- }),
- styleCodes: z.array(z.enum(STYLE_CODES)).optional(),
- htmlStyleFormat: z
- .enum(HTML_STYLE_FORMATS)
- .optional()
- .describe(HTML_STYLE_FORMAT_DESCRIPTION),
+const schemaConfigValidator = v.object({
+ defaultStyle: styleOptionsValidator.optional(),
+ matchWords: v.record(styleOptionsValidator).optional(),
+ matchStartsWith: v.record(styleOptionsValidator).optional(),
+ matchEndsWith: v.record(styleOptionsValidator).optional(),
+ matchContains: v.record(styleOptionsValidator).optional(),
+ matchPatterns: v.array(patternMatchValidator).optional(),
+ whiteSpace: v.withDefault(v.enum(WHITESPACE_OPTIONS), DEFAULT_WHITESPACE),
+ newLine: v.withDefault(v.enum(NEWLINE_OPTIONS), DEFAULT_NEWLINE),
});
-export const tokenMetadataSchema = z
+const themePresetValidator = v.object({
+ name: v.string(),
+ description: v.string().optional(),
+ mode: v.enum(THEME_MODES).optional(),
+ schema: schemaConfigValidator,
+});
+
+const tokenMetadataValidator = v
.object({
- style: styleOptionsSchema.optional().describe(TOKEN_STYLE_DESCRIPTION),
+ style: styleOptionsValidator.optional(),
})
- .catchall(z.unknown())
.optional();
-export const patternMatchSchema = z.object({
- name: z.string(),
- pattern: z.string(),
- options: styleOptionsSchema,
+const tokenValidator = v.object({
+ content: v.string(),
+ metadata: tokenMetadataValidator,
});
-export const schemaConfigSchema = z.object({
- defaultStyle: styleOptionsSchema.optional(),
- matchWords: z.record(z.string(), styleOptionsSchema).optional(),
- matchStartsWith: z.record(z.string(), styleOptionsSchema).optional(),
- matchEndsWith: z.record(z.string(), styleOptionsSchema).optional(),
- matchContains: z.record(z.string(), styleOptionsSchema).optional(),
- matchPatterns: z.array(patternMatchSchema).optional(),
- whiteSpace: z.enum(WHITESPACE_OPTIONS).optional().default(DEFAULT_WHITESPACE),
- newLine: z.enum(NEWLINE_OPTIONS).optional().default(DEFAULT_NEWLINE),
-});
+const tokenListValidator = v.array(tokenValidator);
-export const themePresetSchema = z.object({
- name: z.string(),
- description: z.string().optional(),
- mode: z.enum(THEME_MODES).optional().describe(THEME_MODE_DESCRIPTION),
- schema: schemaConfigSchema,
-});
+export type TokenMetadata = {
+ style?: StyleOptions;
+ matchType?: string;
+ matchPattern?: string;
+ pattern?: string | RegExp;
+ trimmed?: boolean;
+ originalLength?: number;
+};
-export const tokenSchema = z.object({
- content: z.string().describe(TOKEN_CONTENT_DESCRIPTION),
- metadata: tokenMetadataSchema.describe(TOKEN_METADATA_DESCRIPTION),
-});
+export type Token = { content: string; metadata?: TokenMetadata };
+export type TokenList = Token[];
+
+export function parseToken(token: unknown): Token {
+ return tokenValidator.parse(token) as Token;
+}
+
+export function parseTokenSafe(token: unknown): {
+ success: boolean;
+ data?: Token;
+ error?: ValidationError;
+} {
+ const result = tokenValidator.safeParse(token);
+ if (result.success) return { success: true, data: result.data as Token };
+ return { success: false, error: result.error };
+}
+
+export function parseTokenList(tokens: unknown): TokenList {
+ return tokenListValidator.parse(tokens) as TokenList;
+}
+
+export function parseTokenListSafe(tokens: unknown): {
+ success: boolean;
+ data?: TokenList;
+ error?: ValidationError;
+} {
+ const result = tokenListValidator.safeParse(tokens);
+ if (result.success) return { success: true, data: result.data as TokenList };
+ return { success: false, error: result.error };
+}
+
+export function parseTheme(theme: unknown): Theme {
+ return themePresetValidator.parse(theme) as Theme;
+}
+
+export function parseThemeSafe(theme: unknown): {
+ success: boolean;
+ data?: Theme;
+ error?: ValidationError;
+} {
+ const result = themePresetValidator.safeParse(theme);
+ if (result.success) return { success: true, data: result.data as Theme };
+ return { success: false, error: result.error };
+}
+
+export function createThemeValidationError(error: unknown): Error {
+ if (!isValidationError(error)) {
+ return error instanceof Error ? error : new Error(String(error));
+ }
+ const message = `Theme validation failed: ${formatValidationIssues(error.issues)}`;
+ const err = new Error(message);
+ err.cause = error;
+ return err;
+}
+
+export function validateTheme(theme: unknown): Theme {
+ try {
+ return parseTheme(theme);
+ } catch (error) {
+ throw createThemeValidationError(error);
+ }
+}
-export const tokenListSchema = z.array(tokenSchema);
+export function validateThemeSafe(theme: unknown): {
+ success: boolean;
+ data?: Theme;
+ error?: ValidationError;
+} {
+ return parseThemeSafe(theme);
+}
-export * from "./validator";
+export { isValidationError, formatValidationIssues, ValidationError };
diff --git a/src/schema/types.ts b/src/schema/types.ts
index 15a7b6a..d4d75d5 100644
--- a/src/schema/types.ts
+++ b/src/schema/types.ts
@@ -1,9 +1,7 @@
-import { z } from "zod";
-import { styleOptionsSchema, tokenSchema, tokenListSchema } from "./index";
+import type { StyleOptions } from "../types";
+import type { Token, TokenList } from "./index";
-export type StyleOptions = z.infer;
-export type Token = z.infer;
-export type TokenList = z.infer;
+export type { StyleOptions, Token, TokenList };
export type JsonSchemaOptions = {
name?: string;
diff --git a/src/schema/utils.ts b/src/schema/utils.ts
index 618030c..357929d 100644
--- a/src/schema/utils.ts
+++ b/src/schema/utils.ts
@@ -1,27 +1,18 @@
-import type { z } from "zod";
import { COLOR_PATTERN } from "./constants";
+import {
+ ValidationError,
+ isValidationError,
+ formatValidationIssues,
+} from "../lib/validate";
export function isValidColorFormat(color: string): boolean {
return COLOR_PATTERN.test(color);
}
-export function formatZodIssues(issues: ReadonlyArray): string {
- return issues
- .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
- .join(", ");
-}
-
export function createValidationError(message: string, cause: Error): Error {
const error = new Error(message);
error.cause = cause;
return error;
}
-export function isZodError(error: unknown): error is z.ZodError {
- return (
- typeof error === "object" &&
- error !== null &&
- "issues" in error &&
- Array.isArray((error as z.ZodError).issues)
- );
-}
+export { isValidationError, formatValidationIssues, ValidationError };
diff --git a/src/schema/validator.ts b/src/schema/validator.ts
index d2ff437..bb2c468 100644
--- a/src/schema/validator.ts
+++ b/src/schema/validator.ts
@@ -1,120 +1,32 @@
-import type { z } from "zod";
-import { zodToJsonSchema } from "zod-to-json-schema";
-import { tokenSchema, tokenListSchema, themePresetSchema } from "./index";
-import type { JsonSchemaOptions } from "./types";
-import type { Theme } from "../types";
+export {
+ parseToken,
+ parseTokenSafe,
+ parseTokenList,
+ parseTokenListSafe,
+ parseTheme,
+ parseThemeSafe,
+ createThemeValidationError,
+ validateTheme,
+ validateThemeSafe,
+ isValidationError,
+ formatValidationIssues,
+ ValidationError,
+} from "./index";
+
+export type { Token, TokenList } from "./index";
+export type { JsonSchemaOptions } from "./types";
+
import {
TOKEN_SCHEMA_NAME,
TOKEN_SCHEMA_DESCRIPTION,
THEME_SCHEMA_NAME,
THEME_SCHEMA_DESCRIPTION,
} from "./constants";
-import { formatZodIssues, createValidationError, isZodError } from "./utils";
-
-export function parseToken(token: unknown): z.infer {
- return tokenSchema.parse(token);
-}
-
-export function parseTokenSafe(token: unknown): {
- success: boolean;
- data?: z.infer;
- error?: z.ZodError;
-} {
- const result = tokenSchema.safeParse(token);
-
- if (result.success) {
- return { success: true, data: result.data };
- }
-
- return { success: false, error: result.error };
-}
-
-export function parseTokenList(
- tokens: unknown,
-): z.infer {
- return tokenListSchema.parse(tokens);
-}
-
-export function parseTokenListSafe(tokens: unknown): {
- success: boolean;
- data?: z.infer;
- error?: z.ZodError;
-} {
- const result = tokenListSchema.safeParse(tokens);
-
- if (result.success) {
- return { success: true, data: result.data };
- }
-
- return { success: false, error: result.error };
-}
-
-export function createTokenJsonSchemaOptions(): JsonSchemaOptions {
- return {
- name: TOKEN_SCHEMA_NAME,
- description: TOKEN_SCHEMA_DESCRIPTION,
- };
-}
-
-export function convertTokenSchemaToJson() {
- const options = createTokenJsonSchemaOptions();
- return zodToJsonSchema(tokenSchema, options);
-}
-
-export function parseTheme(theme: unknown): Theme {
- return themePresetSchema.parse(theme) as Theme;
-}
-
-export function parseThemeSafe(theme: unknown): {
- success: boolean;
- data?: Theme;
- error?: z.ZodError;
-} {
- const result = themePresetSchema.safeParse(theme);
-
- if (result.success) {
- return { success: true, data: result.data as Theme };
- }
-
- return { success: false, error: result.error };
-}
-
-export function createThemeValidationError(error: unknown): Error {
- if (!isZodError(error)) {
- if (error instanceof Error) {
- return error;
- }
- return new Error(String(error));
- }
-
- const message = `Theme validation failed: ${formatZodIssues(error.issues)}`;
- return createValidationError(message, error);
-}
-
-export function validateTheme(theme: unknown): Theme {
- try {
- return parseTheme(theme);
- } catch (error) {
- throw createThemeValidationError(error);
- }
-}
-
-export function validateThemeSafe(theme: unknown): {
- success: boolean;
- data?: Theme;
- error?: z.ZodError;
-} {
- return parseThemeSafe(theme);
-}
-export function createThemeJsonSchemaOptions(): JsonSchemaOptions {
- return {
- name: THEME_SCHEMA_NAME,
- description: THEME_SCHEMA_DESCRIPTION,
- };
+export function createTokenJsonSchemaOptions() {
+ return { name: TOKEN_SCHEMA_NAME, description: TOKEN_SCHEMA_DESCRIPTION };
}
-export function convertThemeSchemaToJson() {
- const options = createThemeJsonSchemaOptions();
- return zodToJsonSchema(themePresetSchema, options);
+export function createThemeJsonSchemaOptions() {
+ return { name: THEME_SCHEMA_NAME, description: THEME_SCHEMA_DESCRIPTION };
}
diff --git a/src/themes/index.ts b/src/themes/index.ts
index ed5065f..bf70fab 100644
--- a/src/themes/index.ts
+++ b/src/themes/index.ts
@@ -1,5 +1,4 @@
import type { Theme } from "../types";
-import { DEFAULT_THEME } from "./constants";
import {
createTheme,
createSimpleTheme,
diff --git a/src/themes/presets.ts b/src/themes/presets.ts
index f2a38d6..f9a263f 100644
--- a/src/themes/presets.ts
+++ b/src/themes/presets.ts
@@ -1,133 +1,75 @@
-import { z } from "zod";
import type { ThemePreset, StyleOptions, PatternMatch } from "../types";
import { filterStyleCodes } from "../types";
-export const colorPaletteSchema = z.object({
- name: z.string(),
- description: z.string(),
- colors: z.object({
- primary: z.string().describe("Main accent color"),
- secondary: z.string().describe("Secondary accent color"),
- success: z.string().describe("Success/OK color"),
- warning: z.string().describe("Warning/attention color"),
- error: z.string().describe("Error/danger color"),
- info: z.string().describe("Information color"),
- muted: z.string().describe("Muted/subtle color"),
- background: z.string().describe("Background color"),
- text: z.string().describe("Primary text color"),
- accent: z.string().optional().describe("Additional accent color"),
- }),
- accessibility: z.object({
- contrastRatio: z.number().min(1).max(21).describe("WCAG contrast ratio"),
- colorBlindSafe: z.boolean().describe("Safe for color blind users"),
- darkMode: z.boolean().describe("Optimized for dark backgrounds"),
- }),
-});
+type ColorRole =
+ | "primary"
+ | "secondary"
+ | "success"
+ | "warning"
+ | "error"
+ | "info"
+ | "muted"
+ | "accent";
-export type ColorPalette = z.infer;
-
-export const patternPresetSchema = z.object({
- name: z.string(),
- description: z.string(),
- category: z.enum([
- "api",
- "system",
- "application",
- "security",
- "database",
- "generic",
- ]),
- patterns: z.array(
- z.object({
- name: z.string(),
- pattern: z.string(),
- description: z.string(),
- colorRole: z.enum([
- "primary",
- "secondary",
- "success",
- "warning",
- "error",
- "info",
- "muted",
- "accent",
- ]),
- styleCodes: z.array(z.string()).optional(),
- }),
- ),
- matchWords: z.record(
- z.string(),
- z.object({
- colorRole: z.enum([
- "primary",
- "secondary",
- "success",
- "warning",
- "error",
- "info",
- "muted",
- "accent",
- ]),
- styleCodes: z.array(z.string()).optional(),
- }),
- ),
-});
-
-export type PatternPreset = z.infer;
+export type ColorPalette = {
+ name: string;
+ description: string;
+ colors: {
+ primary: string;
+ secondary: string;
+ success: string;
+ warning: string;
+ error: string;
+ info: string;
+ muted: string;
+ background: string;
+ text: string;
+ accent?: string;
+ };
+ accessibility: {
+ contrastRatio: number;
+ colorBlindSafe: boolean;
+ darkMode: boolean;
+ };
+};
-export const themeGeneratorConfigSchema = z.object({
- name: z.string(),
- description: z.string().optional(),
- colorPalette: z.string().describe("Color palette name to use"),
- patternPresets: z
- .array(z.string())
- .describe("Pattern preset names to combine"),
- customPatterns: z
- .array(
- z.object({
- name: z.string(),
- pattern: z.string(),
- colorRole: z.enum([
- "primary",
- "secondary",
- "success",
- "warning",
- "error",
- "info",
- "muted",
- "accent",
- ]),
- styleCodes: z.array(z.string()).optional(),
- }),
- )
- .optional(),
- customWords: z
- .record(
- z.string(),
- z.object({
- colorRole: z.enum([
- "primary",
- "secondary",
- "success",
- "warning",
- "error",
- "info",
- "muted",
- "accent",
- ]),
- styleCodes: z.array(z.string()).optional(),
- }),
- )
- .optional(),
- options: z
- .object({
- whiteSpace: z.enum(["preserve", "trim"]).optional(),
- newLine: z.enum(["preserve", "trim"]).optional(),
- })
- .optional(),
-});
+export type PatternPreset = {
+ name: string;
+ description: string;
+ category:
+ | "api"
+ | "system"
+ | "application"
+ | "security"
+ | "database"
+ | "generic";
+ patterns: {
+ name: string;
+ pattern: string;
+ description: string;
+ colorRole: ColorRole;
+ styleCodes?: string[];
+ }[];
+ matchWords: Record;
+};
-export type ThemeGeneratorConfig = z.infer;
+export type ThemeGeneratorConfig = {
+ name: string;
+ description?: string;
+ colorPalette: string;
+ patternPresets: string[];
+ customPatterns?: {
+ name: string;
+ pattern: string;
+ colorRole: ColorRole;
+ styleCodes?: string[];
+ }[];
+ customWords?: Record;
+ options?: {
+ whiteSpace?: "preserve" | "trim";
+ newLine?: "preserve" | "trim";
+ };
+};
export const COLOR_PALETTES: ColorPalette[] = [
{
diff --git a/src/themes/registry.ts b/src/themes/registry.ts
index 41fb32c..8c22636 100644
--- a/src/themes/registry.ts
+++ b/src/themes/registry.ts
@@ -1,4 +1,7 @@
import type { Theme } from "../types";
+import { createLogger } from "../utils/logger";
+
+const log = createLogger("themes");
type ThemeLoader = () => Promise<{ default: Theme }>;
@@ -40,7 +43,7 @@ class ThemeRegistry {
try {
await this.preloadTheme(this.defaultThemeName);
} catch (error) {
- console.warn("Failed to preload default theme:", error);
+ log.debug(`Failed to preload default theme: ${error}`);
}
}
diff --git a/src/tokenizer/index.ts b/src/tokenizer/index.ts
index cdbdd73..befa8a5 100644
--- a/src/tokenizer/index.ts
+++ b/src/tokenizer/index.ts
@@ -1,6 +1,7 @@
-import type { Token, TokenList } from "../schema/types";
+import type { Token, TokenList, StyleOptions } from "../schema/types";
import type { Theme } from "../types";
import type { MatcherType } from "./types";
+import { createLogger } from "../utils/logger";
import {
TIMESTAMP_PATTERN,
LOG_LEVEL_PATTERN,
@@ -38,6 +39,8 @@ import {
isValidMatchPatternsArray,
} from "./utils";
+const log = createLogger("tokenizer");
+
export class TokenContext {
public value?: unknown;
public ignored?: boolean;
@@ -269,7 +272,7 @@ export function addPatternMatchRules(
pattern: string | RegExp;
name?: string;
identifier?: string;
- options?: unknown;
+ options?: StyleOptions;
}>,
): void {
for (let index = 0; index < matchPatterns.length; index++) {
@@ -302,7 +305,7 @@ export function addPatternMatchRules(
: patternObj.pattern;
if (!regex) {
- console.warn(`Invalid regex pattern in theme: ${patternObj.pattern}`);
+ log.debug(`Invalid regex pattern in theme: ${patternObj.pattern}`);
continue;
}
@@ -344,11 +347,11 @@ export function addThemeRules(lexer: SimpleLexer, theme: Theme): void {
pattern: string | RegExp;
name?: string;
identifier?: string;
- options?: unknown;
+ options?: StyleOptions;
}>,
);
} else if (schema.matchPatterns) {
- console.warn("matchPatterns is not an array in theme schema");
+ log.debug("matchPatterns is not an array in theme schema");
}
}
@@ -468,7 +471,7 @@ export function tokenize(line: string, theme?: Theme): TokenList {
const lexerTokens = lexer.tokenize(line);
return convertLexerTokens(lexerTokens);
} catch (error) {
- console.warn("Tokenization failed:", error);
+ log.debug(`Tokenization failed: ${error}`);
return [createDefaultToken(line)];
}
}
diff --git a/src/tokenizer/utils.ts b/src/tokenizer/utils.ts
index f72bd5b..1e8e015 100644
--- a/src/tokenizer/utils.ts
+++ b/src/tokenizer/utils.ts
@@ -1,4 +1,7 @@
import type { Token } from "../schema/types";
+import { createLogger } from "../utils/logger";
+
+const log = createLogger("tokenizer:utils");
export function escapeRegexPattern(pattern: string): string {
return pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -42,7 +45,8 @@ export function isTrimmedWhitespace(value: unknown): boolean {
export function createSafeRegex(pattern: string): RegExp | undefined {
try {
return new RegExp(pattern);
- } catch {
+ } catch (error) {
+ log.debug(`Invalid regex pattern "${pattern}": ${error}`);
return undefined;
}
}
diff --git a/src/utils/colors.ts b/src/utils/colors.ts
index 1eeaa46..95e08d2 100644
--- a/src/utils/colors.ts
+++ b/src/utils/colors.ts
@@ -28,15 +28,15 @@ const styles = {
};
function createColorFunction(style: string) {
- return (text: string) => `${style}${text}${styles.reset}`;
+ return (text: unknown) => `${style}${String(text)}${styles.reset}`;
}
function createChainableColor(
appliedStyles: string[] = [],
): ChainableColorFunction {
- const fn = ((text: string) => {
+ const fn = ((text: unknown) => {
const prefix = appliedStyles.join("");
- return `${prefix}${text}${styles.reset}`;
+ return `${prefix}${String(text)}${styles.reset}`;
}) as ChainableColorFunction;
Object.keys(styles).forEach((key) => {
@@ -67,4 +67,12 @@ export const gray = createColorFunction(styles.gray);
export const dim = createColorFunction(styles.dim);
export const bold = createColorFunction(styles.bold);
+export function hex(color: string): (text: string) => string {
+ const c = color.startsWith("#") ? color.slice(1) : color;
+ const r = parseInt(c.slice(0, 2), 16);
+ const g = parseInt(c.slice(2, 4), 16);
+ const b = parseInt(c.slice(4, 6), 16);
+ return (text: string) => `\x1B[38;2;${r};${g};${b}m${text}${styles.reset}`;
+}
+
export default colors;
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 7458374..19c2fbd 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -3,7 +3,13 @@ export type { Spinner } from "./spinner";
export { default as colors } from "./colors";
export { default as gradient } from "./gradient";
export { default as ascii } from "./ascii";
-export { default as logger } from "./logger";
+export {
+ default as logger,
+ createLogger,
+ setLogLevel,
+ getLogLevel,
+} from "./logger";
+export type { LogLevel } from "./logger";
export { CONTRAST } from "./constants";
export {
hexContrastRatio,
diff --git a/src/utils/logger.ts b/src/utils/logger.ts
index 8041495..de6d47e 100644
--- a/src/utils/logger.ts
+++ b/src/utils/logger.ts
@@ -1,25 +1,76 @@
import colors from "./colors";
-export const logger = {
- info(message: string) {
- console.log(colors.blue("ā¹"), message);
- },
-
- success(message: string) {
- console.log(colors.green("ā"), message);
- },
-
- warn(message: string) {
- console.log(colors.yellow("ā "), message);
- },
-
- error(message: string) {
- console.error(colors.red("ā"), message);
- },
-
- debug(message: string) {
- console.log(colors.gray("ā"), message);
- },
+export type LogLevel = "silent" | "error" | "warn" | "info" | "debug";
+
+const LOG_LEVEL_PRIORITY: Record = {
+ silent: 0,
+ error: 1,
+ warn: 2,
+ info: 3,
+ debug: 4,
+};
+
+interface LoggerConfig {
+ level: LogLevel;
+ prefix?: string;
+}
+
+let globalConfig: LoggerConfig = {
+ level: "info",
};
+function shouldLog(messageLevel: LogLevel): boolean {
+ return (
+ LOG_LEVEL_PRIORITY[messageLevel] <= LOG_LEVEL_PRIORITY[globalConfig.level]
+ );
+}
+
+function formatMessage(prefix: string | undefined, message: string): string {
+ return prefix ? `[${prefix}] ${message}` : message;
+}
+
+export function setLogLevel(level: LogLevel): void {
+ globalConfig.level = level;
+}
+
+export function getLogLevel(): LogLevel {
+ return globalConfig.level;
+}
+
+export function createLogger(prefix?: string) {
+ return {
+ info(message: string): void {
+ if (shouldLog("info")) {
+ console.log(colors.blue("[info]"), formatMessage(prefix, message));
+ }
+ },
+
+ success(message: string): void {
+ if (shouldLog("info")) {
+ console.log(colors.green("[ok]"), formatMessage(prefix, message));
+ }
+ },
+
+ warn(message: string): void {
+ if (shouldLog("warn")) {
+ console.log(colors.yellow("[warn]"), formatMessage(prefix, message));
+ }
+ },
+
+ error(message: string): void {
+ if (shouldLog("error")) {
+ console.error(colors.red("[error]"), formatMessage(prefix, message));
+ }
+ },
+
+ debug(message: string): void {
+ if (shouldLog("debug")) {
+ console.log(colors.gray("[debug]"), formatMessage(prefix, message));
+ }
+ },
+ };
+}
+
+export const logger = createLogger();
+
export default logger;
diff --git a/src/utils/types.ts b/src/utils/types.ts
index 0ae1c1e..5a8501f 100644
--- a/src/utils/types.ts
+++ b/src/utils/types.ts
@@ -27,6 +27,6 @@ const styles = {
export type StyleName = keyof typeof styles;
-export type ChainableColorFunction = ((text: string) => string) & {
+export type ChainableColorFunction = ((text: unknown) => string) & {
[K in Exclude]: ChainableColorFunction;
};
diff --git a/tests/unit/cli/index.test.ts b/tests/unit/cli/index.test.ts
index e63d619..74b6af5 100644
--- a/tests/unit/cli/index.test.ts
+++ b/tests/unit/cli/index.test.ts
@@ -1,6 +1,5 @@
-import { expect, test, describe } from "bun:test";
-import { parseArgs, loadConfig } from "../../../src/cli/index";
-import { cliOptionsSchema } from "../../../src/cli/types";
+import { expect, test, describe, beforeEach, afterEach, mock } from "bun:test";
+import { parseArgs, loadConfig, main } from "../../../src/cli/index";
import fs from "fs";
import os from "os";
import path from "path";
@@ -303,54 +302,155 @@ describe("loadConfig", () => {
});
});
-describe("Zod schema validation", () => {
- test("cliOptionsSchema should validate valid options", () => {
- const validOptions = {
- theme: "dracula",
- debug: true,
- output: "result.log",
- format: "ansi" as const,
- };
+describe("main", () => {
+ const originalLog = console.log;
+ const originalError = console.error;
+ const originalExit = process.exit;
- const result = cliOptionsSchema.parse(validOptions);
- expect(result.theme).toBe("dracula");
- expect(result.debug).toBe(true);
- expect(result.output).toBe("result.log");
- expect(result.format).toBe("ansi");
+ beforeEach(() => {
+ console.log = mock(() => {});
+ console.error = mock(() => {});
+ process.exit = mock(() => {
+ throw new Error("process.exit called");
+ }) as unknown as typeof process.exit;
});
- test("cliOptionsSchema should apply defaults", () => {
- const minimalOptions = {};
+ afterEach(() => {
+ console.log = originalLog;
+ console.error = originalError;
+ process.exit = originalExit;
+ });
- const result = cliOptionsSchema.parse(minimalOptions);
- expect(result.debug).toBe(false);
- expect(result.quiet).toBe(false);
- expect(result.listThemes).toBe(false);
- expect(result.interactive).toBe(false);
- expect(result.preview).toBe(false);
- expect(result.noSpinner).toBe(false);
+ test("should handle listPalettes option", async () => {
+ await main(undefined, { listPalettes: true });
+
+ expect(console.log).toHaveBeenCalled();
});
- test("cliOptionsSchema should reject invalid format", () => {
- const invalidOptions = {
- format: "invalid",
- };
+ test("should handle listPatterns option", async () => {
+ await main(undefined, { listPatterns: true });
- expect(() => cliOptionsSchema.parse(invalidOptions)).toThrow();
+ expect(console.log).toHaveBeenCalled();
});
- test("cliOptionsSchema should validate commander options", () => {
- const commanderOptions = {
- theme: "oh-my-zsh",
- debug: true,
- interactive: false,
- format: "html",
- };
+ test("should handle listThemeFiles option", async () => {
+ await main(undefined, { listThemeFiles: true });
+ expect(true).toBe(true);
+ });
- const result = cliOptionsSchema.parse(commanderOptions);
- expect(result.theme).toBe("oh-my-zsh");
- expect(result.debug).toBe(true);
- expect(result.interactive).toBe(false);
- expect(result.format).toBe("html");
+ test("should process log file input", async () => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+ const logFile = path.join(tempDir, "test.log");
+
+ fs.writeFileSync(
+ logFile,
+ "2024-01-15 10:30:45 INFO Test message\n2024-01-15 10:30:46 ERROR Error message",
+ );
+
+ try {
+ await main(logFile, { theme: "oh-my-zsh" });
+
+ expect(console.log).toHaveBeenCalled();
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+
+ test("should write output to file when output option is provided", async () => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+ const logFile = path.join(tempDir, "input.log");
+ const outputFile = path.join(tempDir, "output.log");
+
+ fs.writeFileSync(logFile, "2024-01-15 10:30:45 INFO Test message");
+
+ try {
+ await main(logFile, { theme: "oh-my-zsh", output: outputFile });
+
+ expect(fs.existsSync(outputFile)).toBe(true);
+ const content = fs.readFileSync(outputFile, "utf8");
+ expect(content.length).toBeGreaterThan(0);
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+
+ test("should handle non-existent file error", async () => {
+ try {
+ await main("/nonexistent/file.log", { theme: "oh-my-zsh" });
+ } catch (e) {
+ expect((e as Error).message).toBe("process.exit called");
+ }
+ });
+
+ test("should handle listThemes option without preview", async () => {
+ await main(undefined, { listThemes: true, quiet: false });
+
+ const calls = (console.log as ReturnType).mock.calls;
+ const allOutput = calls.map((call) => call.join(" ")).join("\n");
+ expect(allOutput).toContain("themes");
+ });
+
+ test("should handle quiet mode with listThemes", async () => {
+ await main(undefined, { listThemes: true, quiet: true });
+
+ const beforeCallCount = (console.log as ReturnType).mock.calls
+ .length;
+ expect(beforeCallCount).toBe(0);
+ });
+
+ test("should handle html output format", async () => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+ const logFile = path.join(tempDir, "test.log");
+ const outputFile = path.join(tempDir, "output.html");
+
+ fs.writeFileSync(logFile, "2024-01-15 10:30:45 INFO Test message");
+
+ try {
+ await main(logFile, {
+ theme: "oh-my-zsh",
+ output: outputFile,
+ format: "html",
+ });
+
+ expect(fs.existsSync(outputFile)).toBe(true);
+ const content = fs.readFileSync(outputFile, "utf8");
+ expect(content).toContain("span");
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+
+ test("should auto-detect html format from output filename", async () => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+ const logFile = path.join(tempDir, "test.log");
+ const outputFile = path.join(tempDir, "output.html");
+
+ fs.writeFileSync(logFile, "INFO Test message");
+
+ try {
+ await main(logFile, { theme: "oh-my-zsh", output: outputFile });
+
+ const content = fs.readFileSync(outputFile, "utf8");
+ expect(content).toContain("span");
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+
+ test("should use config file settings", async () => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+ const configFile = path.join(tempDir, "config.json");
+ const logFile = path.join(tempDir, "test.log");
+
+ fs.writeFileSync(configFile, JSON.stringify({ theme: "dracula" }));
+ fs.writeFileSync(logFile, "INFO Test message");
+
+ try {
+ await main(logFile, { config: configFile });
+
+ expect(console.log).toHaveBeenCalled();
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
});
});
diff --git a/tests/unit/cli/interactive.test.ts b/tests/unit/cli/interactive.test.ts
index 64be571..37c8a14 100644
--- a/tests/unit/cli/interactive.test.ts
+++ b/tests/unit/cli/interactive.test.ts
@@ -1,112 +1,69 @@
-import { expect, test, describe } from "bun:test";
+import { expect, test, describe, beforeEach, afterEach, mock } from "bun:test";
import {
- interactiveConfigSchema,
- themeChoiceSchema,
showThemeList,
+ selectThemeInteractively,
+ InteractiveConfig,
+ ThemeChoice,
} from "../../../src/cli/interactive";
+import { getThemeNames, getTheme, LogsDX } from "../../../src/index";
-describe("Interactive schemas", () => {
- describe("interactiveConfigSchema", () => {
- test("should validate valid interactive config", () => {
- const validConfig = {
- theme: "oh-my-zsh",
- outputFormat: "ansi" as const,
- preview: true,
- };
-
- const result = interactiveConfigSchema.parse(validConfig);
- expect(result.theme).toBe("oh-my-zsh");
- expect(result.outputFormat).toBe("ansi");
- expect(result.preview).toBe(true);
- });
-
- test("should validate with html output format", () => {
- const validConfig = {
- theme: "dracula",
- outputFormat: "html" as const,
- preview: false,
- };
-
- const result = interactiveConfigSchema.parse(validConfig);
- expect(result.theme).toBe("dracula");
- expect(result.outputFormat).toBe("html");
- expect(result.preview).toBe(false);
- });
+const originalLog = console.log;
- test("should reject invalid output format", () => {
- const invalidConfig = {
- theme: "oh-my-zsh",
- outputFormat: "invalid",
- preview: true,
- };
+beforeEach(() => {
+ console.log = mock(() => {});
+});
- expect(() => interactiveConfigSchema.parse(invalidConfig)).toThrow();
- });
+afterEach(() => {
+ console.log = originalLog;
+});
- test("should require all fields", () => {
- const incompleteConfig = {
- theme: "oh-my-zsh",
- outputFormat: "ansi" as const,
- };
+describe("showThemeList", () => {
+ test("should not throw when called", async () => {
+ let error: Error | undefined;
+ try {
+ await showThemeList();
+ } catch (e) {
+ error = e as Error;
+ }
+ expect(error).toBeUndefined();
+ });
- expect(() => interactiveConfigSchema.parse(incompleteConfig)).toThrow();
- });
+ test("should display all available themes", async () => {
+ await showThemeList();
- test("should reject non-boolean preview", () => {
- const invalidConfig = {
- theme: "oh-my-zsh",
- outputFormat: "ansi" as const,
- preview: "yes",
- };
+ const calls = (console.log as ReturnType).mock.calls;
+ const allOutput = calls.map((call) => call.join(" ")).join("\n");
- expect(() => interactiveConfigSchema.parse(invalidConfig)).toThrow();
- });
+ const themeNames = getThemeNames();
+ expect(themeNames.length).toBeGreaterThan(0);
});
- describe("themeChoiceSchema", () => {
- test("should validate valid theme choice", () => {
- const validChoice = {
- name: "Dracula Theme",
- value: "dracula",
- description: "A dark theme with purple accents",
- };
-
- const result = themeChoiceSchema.parse(validChoice);
- expect(result.name).toBe("Dracula Theme");
- expect(result.value).toBe("dracula");
- expect(result.description).toBe("A dark theme with purple accents");
- });
+ test("should show theme descriptions if available", async () => {
+ await showThemeList();
- test("should require all fields", () => {
- const incompleteChoice = {
- name: "Theme Name",
- value: "theme-value",
- };
+ const calls = (console.log as ReturnType).mock.calls;
+ expect(calls.length).toBeGreaterThan(0);
+ });
- expect(() => themeChoiceSchema.parse(incompleteChoice)).toThrow();
- });
+ test("should include usage hints", async () => {
+ await showThemeList();
- test("should reject non-string fields", () => {
- const invalidChoice = {
- name: 123,
- value: "theme-value",
- description: "Description",
- };
+ const calls = (console.log as ReturnType).mock.calls;
+ const allOutput = calls.map((call) => call.join(" ")).join("\n");
- expect(() => themeChoiceSchema.parse(invalidChoice)).toThrow();
- });
+ expect(allOutput).toContain("interactive");
});
});
-describe("showThemeList", () => {
- test("should not throw when called", () => {
- expect(() => showThemeList()).not.toThrow();
+describe("selectThemeInteractively", () => {
+ test("should be a function", () => {
+ expect(typeof selectThemeInteractively).toBe("function");
});
});
describe("Type exports", () => {
test("should export InteractiveConfig type", () => {
- const config: typeof import("./interactive").InteractiveConfig = {
+ const config: InteractiveConfig = {
theme: "test",
outputFormat: "ansi",
preview: false,
@@ -118,7 +75,7 @@ describe("Type exports", () => {
});
test("should export ThemeChoice type", () => {
- const choice: typeof import("./interactive").ThemeChoice = {
+ const choice: ThemeChoice = {
name: "Test Theme",
value: "test",
description: "A test theme",
@@ -128,4 +85,86 @@ describe("Type exports", () => {
expect(choice.value).toBe("test");
expect(choice.description).toBe("A test theme");
});
+
+ test("should allow html outputFormat", () => {
+ const config: InteractiveConfig = {
+ theme: "dracula",
+ outputFormat: "html",
+ preview: true,
+ };
+
+ expect(config.outputFormat).toBe("html");
+ expect(config.preview).toBe(true);
+ });
+});
+
+describe("Theme processing", () => {
+ test("should get theme names", () => {
+ const themeNames = getThemeNames();
+ expect(themeNames.length).toBeGreaterThan(0);
+ expect(themeNames).toContain("oh-my-zsh");
+ });
+
+ test("should get theme by name", async () => {
+ const theme = await getTheme("oh-my-zsh");
+ expect(theme).toBeDefined();
+ expect(theme?.name).toBe("oh-my-zsh");
+ });
+
+ test("should process log with theme", async () => {
+ const logsDX = await LogsDX.getInstance({
+ theme: "oh-my-zsh",
+ outputFormat: "ansi",
+ });
+
+ const result = logsDX.processLine("INFO Test message");
+ expect(result.length).toBeGreaterThan(0);
+ });
+
+ test("should process log with html format", async () => {
+ const logsDX = await LogsDX.getInstance({
+ theme: "dracula",
+ outputFormat: "html",
+ });
+
+ const result = logsDX.processLine("ERROR Something failed");
+ expect(result).toContain("span");
+ });
+});
+
+describe("Theme choices building", () => {
+ test("should build theme choices from theme names", async () => {
+ const themeNames = getThemeNames();
+ const choices: ThemeChoice[] = await Promise.all(
+ themeNames.map(async (name: string) => ({
+ name,
+ value: name,
+ description:
+ (await getTheme(name))?.description || "No description available",
+ })),
+ );
+
+ expect(choices.length).toBe(themeNames.length);
+ expect(choices[0]).toHaveProperty("name");
+ expect(choices[0]).toHaveProperty("value");
+ expect(choices[0]).toHaveProperty("description");
+ });
+
+ test("should handle theme without description", async () => {
+ const themeNames = getThemeNames();
+ const choices = await Promise.all(
+ themeNames.slice(0, 3).map(async (name: string) => {
+ const theme = await getTheme(name);
+ return {
+ name,
+ value: name,
+ description: theme?.description || "No description available",
+ };
+ }),
+ );
+
+ choices.forEach((choice) => {
+ expect(typeof choice.description).toBe("string");
+ });
+ });
});
diff --git a/tests/unit/cli/theme/generator.test.ts b/tests/unit/cli/theme/generator.test.ts
index 0a5ca3d..e607b5a 100644
--- a/tests/unit/cli/theme/generator.test.ts
+++ b/tests/unit/cli/theme/generator.test.ts
@@ -1,15 +1,23 @@
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test";
+import fs from "fs";
+import os from "os";
+import path from "path";
import {
generateTemplateFromAnswers,
validateColorInput,
generatePatternFromPreset,
listColorPalettesCommand,
listPatternPresetsCommand,
+ exportThemeToFile,
+ importThemeFromFile,
+ listThemeFilesCommand,
+ getThemeFiles,
} from "../../../../src/cli/theme-gen";
import {
COLOR_PALETTES,
PATTERN_PRESETS,
} from "../../../../src/themes/presets";
+import type { Theme } from "../../../../src/types";
const originalLog = console.log;
const originalWarn = console.warn;
@@ -360,4 +368,285 @@ describe("Theme Generator", () => {
expect(allOutput).toContain("word matches");
});
});
+
+ describe("exportThemeToFile", () => {
+ it("should export theme as JSON", () => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+ const filePath = path.join(tempDir, "test-theme.json");
+
+ const theme: Theme = {
+ name: "export-test",
+ description: "Test theme for export",
+ schema: {
+ defaultStyle: { color: "#ffffff" },
+ matchWords: { ERROR: { color: "#ff0000" } },
+ },
+ };
+
+ try {
+ exportThemeToFile(theme, filePath, "json");
+
+ expect(fs.existsSync(filePath)).toBe(true);
+ const content = JSON.parse(fs.readFileSync(filePath, "utf8"));
+ expect(content.name).toBe("export-test");
+ expect(content.schema.matchWords.ERROR.color).toBe("#ff0000");
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+
+ it("should export theme as TypeScript", () => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+ const filePath = path.join(tempDir, "test-theme.ts");
+
+ const theme: Theme = {
+ name: "ts-export-test",
+ schema: {
+ defaultStyle: { color: "#ffffff" },
+ },
+ };
+
+ try {
+ exportThemeToFile(theme, filePath, "typescript");
+
+ expect(fs.existsSync(filePath)).toBe(true);
+ const content = fs.readFileSync(filePath, "utf8");
+ expect(content).toContain("import type { Theme }");
+ expect(content).toContain("export const theme");
+ expect(content).toContain("export default theme");
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+
+ it("should create directory if it does not exist", () => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+ const nestedDir = path.join(tempDir, "nested", "deep");
+ const filePath = path.join(nestedDir, "test-theme.json");
+
+ const theme: Theme = {
+ name: "nested-test",
+ schema: { defaultStyle: { color: "#ffffff" } },
+ };
+
+ try {
+ exportThemeToFile(theme, filePath);
+
+ expect(fs.existsSync(nestedDir)).toBe(true);
+ expect(fs.existsSync(filePath)).toBe(true);
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+ });
+
+ describe("importThemeFromFile", () => {
+ it("should import theme from JSON file", () => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+ const filePath = path.join(tempDir, "theme.json");
+
+ const themeData = {
+ name: "imported-theme",
+ description: "An imported theme",
+ schema: {
+ defaultStyle: { color: "#ffffff" },
+ matchWords: { INFO: { color: "#00ff00" } },
+ },
+ };
+
+ fs.writeFileSync(filePath, JSON.stringify(themeData));
+
+ try {
+ const theme = importThemeFromFile(filePath);
+
+ expect(theme.name).toBe("imported-theme");
+ expect(theme.description).toBe("An imported theme");
+ expect(theme.schema.matchWords?.INFO?.color).toBe("#00ff00");
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+
+ it("should throw error for invalid JSON theme missing required fields", () => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+ const filePath = path.join(tempDir, "invalid.json");
+
+ fs.writeFileSync(filePath, JSON.stringify({ invalid: true }));
+
+ try {
+ expect(() => importThemeFromFile(filePath)).toThrow(
+ "Invalid theme JSON: Missing required fields",
+ );
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+
+ it("should throw error for JSON theme missing name", () => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+ const filePath = path.join(tempDir, "no-name.json");
+
+ fs.writeFileSync(
+ filePath,
+ JSON.stringify({ schema: { defaultStyle: { color: "#fff" } } }),
+ );
+
+ try {
+ expect(() => importThemeFromFile(filePath)).toThrow("'name'");
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+
+ it("should throw error for JSON theme missing schema", () => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+ const filePath = path.join(tempDir, "no-schema.json");
+
+ fs.writeFileSync(filePath, JSON.stringify({ name: "test" }));
+
+ try {
+ expect(() => importThemeFromFile(filePath)).toThrow("'schema'");
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+
+ it("should throw error for invalid TypeScript theme file", () => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+ const filePath = path.join(tempDir, "invalid.ts");
+
+ fs.writeFileSync(filePath, "const foo = 'bar';");
+
+ try {
+ expect(() => importThemeFromFile(filePath)).toThrow(
+ "Failed to parse theme file",
+ );
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+ });
+
+ describe("getThemeFiles", () => {
+ it("should find theme files in directory", () => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+
+ fs.writeFileSync(
+ path.join(tempDir, "my.theme.json"),
+ JSON.stringify({ name: "test", schema: {} }),
+ );
+ fs.writeFileSync(
+ path.join(tempDir, "other.theme.ts"),
+ "export const theme = {}",
+ );
+ fs.writeFileSync(path.join(tempDir, "not-a-theme.txt"), "hello");
+
+ try {
+ const files = getThemeFiles(tempDir);
+
+ expect(files.length).toBe(2);
+ expect(files.some((f) => f.includes("my.theme.json"))).toBe(true);
+ expect(files.some((f) => f.includes("other.theme.ts"))).toBe(true);
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+
+ it("should return empty array for non-existent directory", () => {
+ const files = getThemeFiles("/non/existent/path");
+ expect(files).toEqual([]);
+ });
+
+ it("should find theme files in nested directories", () => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+ const nestedDir = path.join(tempDir, "themes");
+ fs.mkdirSync(nestedDir);
+
+ fs.writeFileSync(
+ path.join(nestedDir, "nested.theme.json"),
+ JSON.stringify({ name: "nested", schema: {} }),
+ );
+
+ try {
+ const files = getThemeFiles(tempDir);
+
+ expect(files.length).toBe(1);
+ expect(files[0]).toContain("nested.theme.json");
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+
+ it("should handle errors gracefully", () => {
+ const files = getThemeFiles("/root/no-permission");
+ expect(files).toEqual([]);
+ });
+ });
+
+ describe("listThemeFilesCommand", () => {
+ it("should show message when no theme files found", () => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+ const originalCwd = process.cwd();
+
+ try {
+ process.chdir(tempDir);
+ listThemeFilesCommand(tempDir);
+
+ const calls = (console.log as ReturnType).mock.calls;
+ const allOutput = calls.map((call) => call.join(" ")).join("\n");
+ expect(allOutput).toContain("No theme files found");
+ } finally {
+ process.chdir(originalCwd);
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+
+ it("should list theme files with details", () => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+
+ const themeData = {
+ name: "list-test",
+ description: "A test theme",
+ exportedAt: new Date().toISOString(),
+ };
+ fs.writeFileSync(
+ path.join(tempDir, "test.theme.json"),
+ JSON.stringify(themeData),
+ );
+
+ try {
+ listThemeFilesCommand(tempDir);
+
+ const calls = (console.log as ReturnType).mock.calls;
+ const allOutput = calls.map((call) => call.join(" ")).join("\n");
+
+ expect(allOutput).toContain("test.theme.json");
+ expect(allOutput).toContain("list-test");
+ expect(allOutput).toContain("A test theme");
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+
+ it("should handle invalid theme files gracefully", () => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+
+ fs.writeFileSync(
+ path.join(tempDir, "invalid.theme.json"),
+ "not valid json",
+ );
+
+ try {
+ listThemeFilesCommand(tempDir);
+
+ const calls = (console.log as ReturnType).mock.calls;
+ const allOutput = calls.map((call) => call.join(" ")).join("\n");
+
+ expect(allOutput).toContain("invalid.theme.json");
+ expect(allOutput).toContain("Error");
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+ });
});
diff --git a/tests/unit/cli/theme/interactive-gen.test.ts b/tests/unit/cli/theme/interactive-gen.test.ts
new file mode 100644
index 0000000..da1be67
--- /dev/null
+++ b/tests/unit/cli/theme/interactive-gen.test.ts
@@ -0,0 +1,284 @@
+import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test";
+import fs from "fs";
+import os from "os";
+import path from "path";
+
+const mockInput = mock(() => Promise.resolve("test-theme"));
+const mockSelect = mock(() => Promise.resolve("github-dark"));
+const mockCheckbox = mock(() => Promise.resolve(["log-levels"]));
+const mockConfirm = mock(() => Promise.resolve(false));
+
+const originalLog = console.log;
+
+beforeEach(() => {
+ console.log = mock(() => {});
+ mockInput.mockClear();
+ mockSelect.mockClear();
+ mockCheckbox.mockClear();
+ mockConfirm.mockClear();
+});
+
+afterEach(() => {
+ console.log = originalLog;
+});
+
+describe("Theme Generator Interactive Functions", () => {
+ describe("runThemeGenerator flow", () => {
+ it("should generate theme with basic inputs", async () => {
+ mockInput
+ .mockResolvedValueOnce("my-theme")
+ .mockResolvedValueOnce("My custom theme");
+ mockSelect
+ .mockResolvedValueOnce("github-dark")
+ .mockResolvedValueOnce("global");
+ mockCheckbox.mockResolvedValueOnce(["log-levels"]);
+ mockConfirm
+ .mockResolvedValueOnce(false)
+ .mockResolvedValueOnce(false)
+ .mockResolvedValueOnce(true);
+
+ const { generateTemplateFromAnswers } = await import(
+ "../../../../src/cli/theme-gen"
+ );
+
+ const theme = generateTemplateFromAnswers({
+ name: "my-theme",
+ description: "My custom theme",
+ palette: "github-dark",
+ patterns: ["log-levels"],
+ features: [],
+ });
+
+ expect(theme.name).toBe("my-theme");
+ expect(theme.description).toBe("My custom theme");
+ });
+ });
+
+ describe("exportTheme edge cases", () => {
+ it("should handle theme not found", async () => {
+ const { exportThemeToFile } = await import(
+ "../../../../src/cli/theme-gen"
+ );
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+ const filePath = path.join(tempDir, "test.json");
+
+ const theme = {
+ name: "edge-case-theme",
+ schema: { defaultStyle: { color: "#fff" } },
+ };
+
+ try {
+ exportThemeToFile(theme, filePath);
+ expect(fs.existsSync(filePath)).toBe(true);
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+
+ it("should handle export to existing directory", async () => {
+ const { exportThemeToFile } = await import(
+ "../../../../src/cli/theme-gen"
+ );
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+ const filePath = path.join(tempDir, "existing.json");
+
+ fs.writeFileSync(filePath, "{}");
+
+ const theme = {
+ name: "overwrite-theme",
+ schema: { defaultStyle: { color: "#000" } },
+ };
+
+ try {
+ exportThemeToFile(theme, filePath);
+ const content = JSON.parse(fs.readFileSync(filePath, "utf8"));
+ expect(content.name).toBe("overwrite-theme");
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+ });
+
+ describe("importThemeFromFile edge cases", () => {
+ it("should import valid TypeScript theme with export const", async () => {
+ const { importThemeFromFile } = await import(
+ "../../../../src/cli/theme-gen"
+ );
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+ const filePath = path.join(tempDir, "theme.ts");
+
+ const tsContent = `export const theme: Theme = {"name": "ts-theme", "schema": {"defaultStyle": {"color": "#fff"}}}`;
+ fs.writeFileSync(filePath, tsContent);
+
+ try {
+ const theme = importThemeFromFile(filePath);
+ expect(theme.name).toBe("ts-theme");
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+
+ it("should import valid TypeScript theme with export default", async () => {
+ const { importThemeFromFile } = await import(
+ "../../../../src/cli/theme-gen"
+ );
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+ const filePath = path.join(tempDir, "theme.ts");
+
+ const tsContent = `export default {"name": "default-theme", "schema": {"defaultStyle": {"color": "#000"}}}`;
+ fs.writeFileSync(filePath, tsContent);
+
+ try {
+ const theme = importThemeFromFile(filePath);
+ expect(theme.name).toBe("default-theme");
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+
+ it("should import JavaScript theme file", async () => {
+ const { importThemeFromFile } = await import(
+ "../../../../src/cli/theme-gen"
+ );
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+ const filePath = path.join(tempDir, "theme.js");
+
+ const jsContent = `export const theme: Theme = {"name": "js-theme", "schema": {"defaultStyle": {"color": "#123"}}};`;
+ fs.writeFileSync(filePath, jsContent);
+
+ try {
+ const theme = importThemeFromFile(filePath);
+ expect(theme.name).toBe("js-theme");
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+
+ it("should throw for malformed TypeScript that looks like it has export", async () => {
+ const { importThemeFromFile } = await import(
+ "../../../../src/cli/theme-gen"
+ );
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+ const filePath = path.join(tempDir, "bad.ts");
+
+ const tsContent = `export const notTheme = "just a string"`;
+ fs.writeFileSync(filePath, tsContent);
+
+ try {
+ expect(() => importThemeFromFile(filePath)).toThrow("Failed to parse");
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+ });
+
+ describe("getThemeFiles edge cases", () => {
+ it("should find files with theme in name", async () => {
+ const { getThemeFiles } = await import("../../../../src/cli/theme-gen");
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+
+ fs.writeFileSync(
+ path.join(tempDir, "custom-theme.json"),
+ JSON.stringify({ name: "custom" }),
+ );
+
+ try {
+ const files = getThemeFiles(tempDir);
+ expect(files.some((f) => f.includes("custom-theme.json"))).toBe(true);
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+
+ it("should handle deeply nested theme files", async () => {
+ const { getThemeFiles } = await import("../../../../src/cli/theme-gen");
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+ const deepDir = path.join(tempDir, "a", "b", "c");
+ fs.mkdirSync(deepDir, { recursive: true });
+
+ fs.writeFileSync(
+ path.join(deepDir, "deep.theme.json"),
+ JSON.stringify({ name: "deep" }),
+ );
+
+ try {
+ const files = getThemeFiles(tempDir);
+ expect(files.some((f) => f.includes("deep.theme.json"))).toBe(true);
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+ });
+
+ describe("listThemeFilesCommand edge cases", () => {
+ it("should display theme with exportedAt date", async () => {
+ const { listThemeFilesCommand } = await import(
+ "../../../../src/cli/theme-gen"
+ );
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+
+ fs.writeFileSync(
+ path.join(tempDir, "dated.theme.json"),
+ JSON.stringify({
+ name: "dated",
+ description: "Has date",
+ exportedAt: "2024-01-15T10:30:00Z",
+ }),
+ );
+
+ try {
+ listThemeFilesCommand(tempDir);
+
+ const calls = (console.log as ReturnType).mock.calls;
+ const allOutput = calls.map((call) => call.join(" ")).join("\n");
+ expect(allOutput).toContain("dated");
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+
+ it("should handle theme without description", async () => {
+ const { listThemeFilesCommand } = await import(
+ "../../../../src/cli/theme-gen"
+ );
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+
+ fs.writeFileSync(
+ path.join(tempDir, "nodesc.theme.json"),
+ JSON.stringify({ name: "nodesc" }),
+ );
+
+ try {
+ listThemeFilesCommand(tempDir);
+
+ const calls = (console.log as ReturnType).mock.calls;
+ const allOutput = calls.map((call) => call.join(" ")).join("\n");
+ expect(allOutput).toContain("nodesc");
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+
+ it("should handle theme with unknown name field", async () => {
+ const { listThemeFilesCommand } = await import(
+ "../../../../src/cli/theme-gen"
+ );
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "logsdx-test-"));
+
+ fs.writeFileSync(
+ path.join(tempDir, "noname.theme.json"),
+ JSON.stringify({ schema: {} }),
+ );
+
+ try {
+ listThemeFilesCommand(tempDir);
+
+ const calls = (console.log as ReturnType).mock.calls;
+ const allOutput = calls.map((call) => call.join(" ")).join("\n");
+ expect(allOutput).toContain("Unknown");
+ } finally {
+ fs.rmSync(tempDir, { recursive: true });
+ }
+ });
+ });
+});
diff --git a/tests/unit/lib/validate.test.ts b/tests/unit/lib/validate.test.ts
new file mode 100644
index 0000000..30d418c
--- /dev/null
+++ b/tests/unit/lib/validate.test.ts
@@ -0,0 +1,334 @@
+import { describe, it, expect } from "bun:test";
+import {
+ v,
+ ValidationError,
+ isValidationError,
+ formatValidationIssues,
+} from "../../../src/lib/validate";
+
+describe("ValidationError", () => {
+ it("should create error with message", () => {
+ const error = new ValidationError("test error");
+ expect(error.message).toBe("test error");
+ expect(error.name).toBe("ValidationError");
+ expect(error.path).toEqual([]);
+ expect(error.issues).toEqual([]);
+ });
+
+ it("should create error with path and issues", () => {
+ const issues = [{ path: ["a", "b"], message: "error" }];
+ const error = new ValidationError("test error", ["a", "b"], issues);
+ expect(error.path).toEqual(["a", "b"]);
+ expect(error.issues).toEqual(issues);
+ });
+});
+
+describe("v.string()", () => {
+ it("should parse valid string", () => {
+ const result = v.string().parse("hello");
+ expect(result).toBe("hello");
+ });
+
+ it("should throw for non-string", () => {
+ expect(() => v.string().parse(123)).toThrow("Expected string, got number");
+ });
+
+ it("should return success for safeParse with valid string", () => {
+ const result = v.string().safeParse("hello");
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data).toBe("hello");
+ }
+ });
+
+ it("should return error for safeParse with invalid value", () => {
+ const result = v.string().safeParse(123);
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ expect(result.error).toBeInstanceOf(ValidationError);
+ }
+ });
+});
+
+describe("v.number()", () => {
+ it("should parse valid number", () => {
+ const result = v.number().parse(42);
+ expect(result).toBe(42);
+ });
+
+ it("should throw for non-number", () => {
+ expect(() => v.number().parse("42")).toThrow("Expected number, got string");
+ });
+});
+
+describe("v.boolean()", () => {
+ it("should parse valid boolean", () => {
+ expect(v.boolean().parse(true)).toBe(true);
+ expect(v.boolean().parse(false)).toBe(false);
+ });
+
+ it("should throw for non-boolean", () => {
+ expect(() => v.boolean().parse("true")).toThrow(
+ "Expected boolean, got string",
+ );
+ });
+});
+
+describe("v.literal()", () => {
+ it("should parse matching literal", () => {
+ expect(v.literal("hello").parse("hello")).toBe("hello");
+ expect(v.literal(42).parse(42)).toBe(42);
+ expect(v.literal(true).parse(true)).toBe(true);
+ });
+
+ it("should throw for non-matching literal", () => {
+ expect(() => v.literal("hello").parse("world")).toThrow(
+ "Expected hello, got world",
+ );
+ });
+});
+
+describe("v.optional()", () => {
+ it("should return undefined for undefined value", () => {
+ const result = v.string().optional().parse(undefined);
+ expect(result).toBeUndefined();
+ });
+
+ it("should parse valid value", () => {
+ const result = v.string().optional().parse("hello");
+ expect(result).toBe("hello");
+ });
+
+ it("should throw for invalid value", () => {
+ expect(() => v.string().optional().parse(123)).toThrow();
+ });
+});
+
+describe("v.enum()", () => {
+ it("should parse valid enum value", () => {
+ const validator = v.enum(["a", "b", "c"] as const);
+ expect(validator.parse("a")).toBe("a");
+ expect(validator.parse("b")).toBe("b");
+ });
+
+ it("should throw for invalid enum value", () => {
+ const validator = v.enum(["a", "b", "c"] as const);
+ expect(() => validator.parse("d")).toThrow("Expected one of: a, b, c");
+ });
+
+ it("should throw for non-string value", () => {
+ const validator = v.enum(["a", "b"] as const);
+ expect(() => validator.parse(123)).toThrow("Expected one of: a, b");
+ });
+});
+
+describe("v.array()", () => {
+ it("should parse valid array", () => {
+ const validator = v.array(v.string());
+ expect(validator.parse(["a", "b"])).toEqual(["a", "b"]);
+ });
+
+ it("should throw for non-array", () => {
+ const validator = v.array(v.string());
+ expect(() => validator.parse("not array")).toThrow("Expected array");
+ });
+
+ it("should throw for array with invalid items", () => {
+ const validator = v.array(v.string());
+ expect(() => validator.parse(["a", 123, "b"])).toThrow();
+ });
+
+ it("should handle empty array", () => {
+ const validator = v.array(v.string());
+ expect(validator.parse([])).toEqual([]);
+ });
+});
+
+describe("v.object()", () => {
+ it("should parse valid object", () => {
+ const validator = v.object({
+ name: v.string(),
+ age: v.number(),
+ });
+ const result = validator.parse({ name: "John", age: 30 });
+ expect(result).toEqual({ name: "John", age: 30 });
+ });
+
+ it("should throw for non-object", () => {
+ const validator = v.object({ name: v.string() });
+ expect(() => validator.parse("not object")).toThrow("Expected object");
+ });
+
+ it("should throw for null", () => {
+ const validator = v.object({ name: v.string() });
+ expect(() => validator.parse(null)).toThrow("Expected object");
+ });
+
+ it("should throw with path for nested validation error", () => {
+ const validator = v.object({
+ user: v.object({
+ name: v.string(),
+ }),
+ });
+ expect(() => validator.parse({ user: { name: 123 } })).toThrow();
+ });
+
+ it("should handle optional fields", () => {
+ const validator = v.object({
+ name: v.string(),
+ age: v.number().optional(),
+ });
+ const result = validator.parse({ name: "John" });
+ expect(result).toEqual({ name: "John", age: undefined });
+ });
+});
+
+describe("v.record()", () => {
+ it("should parse valid record", () => {
+ const validator = v.record(v.number());
+ const result = validator.parse({ a: 1, b: 2 });
+ expect(result).toEqual({ a: 1, b: 2 });
+ });
+
+ it("should throw for non-object", () => {
+ const validator = v.record(v.string());
+ expect(() => validator.parse("not object")).toThrow("Expected object");
+ });
+
+ it("should throw for null", () => {
+ const validator = v.record(v.string());
+ expect(() => validator.parse(null)).toThrow("Expected object");
+ });
+
+ it("should throw for invalid record values", () => {
+ const validator = v.record(v.string());
+ expect(() => validator.parse({ a: "valid", b: 123 })).toThrow();
+ });
+
+ it("should handle empty record", () => {
+ const validator = v.record(v.string());
+ expect(validator.parse({})).toEqual({});
+ });
+});
+
+describe("v.union()", () => {
+ it("should parse first matching variant", () => {
+ const validator = v.union(v.string(), v.number());
+ expect(validator.parse("hello")).toBe("hello");
+ expect(validator.parse(42)).toBe(42);
+ });
+
+ it("should throw when no variant matches", () => {
+ const validator = v.union(v.string(), v.number());
+ expect(() => validator.parse(true)).toThrow(
+ "Value did not match any variant",
+ );
+ });
+
+ it("should handle complex union types", () => {
+ const validator = v.union(
+ v.object({ type: v.literal("a"), value: v.string() }),
+ v.object({ type: v.literal("b"), value: v.number() }),
+ );
+ expect(validator.parse({ type: "a", value: "hello" })).toEqual({
+ type: "a",
+ value: "hello",
+ });
+ expect(validator.parse({ type: "b", value: 42 })).toEqual({
+ type: "b",
+ value: 42,
+ });
+ });
+});
+
+describe("v.refine()", () => {
+ it("should pass when refinement check passes", () => {
+ const validator = v.refine(
+ v.string(),
+ (s) => s.length >= 3,
+ "Must be at least 3 characters",
+ );
+ expect(validator.parse("hello")).toBe("hello");
+ });
+
+ it("should throw when refinement check fails", () => {
+ const validator = v.refine(
+ v.string(),
+ (s) => s.length >= 3,
+ "Must be at least 3 characters",
+ );
+ expect(() => validator.parse("hi")).toThrow(
+ "Must be at least 3 characters",
+ );
+ });
+
+ it("should throw base validator error first", () => {
+ const validator = v.refine(
+ v.string(),
+ (s) => s.length >= 3,
+ "Must be at least 3 characters",
+ );
+ expect(() => validator.parse(123)).toThrow("Expected string, got number");
+ });
+});
+
+describe("v.withDefault()", () => {
+ it("should return default value for undefined", () => {
+ const validator = v.withDefault(v.string(), "default");
+ expect(validator.parse(undefined)).toBe("default");
+ });
+
+ it("should parse provided value", () => {
+ const validator = v.withDefault(v.string(), "default");
+ expect(validator.parse("hello")).toBe("hello");
+ });
+
+ it("should throw for invalid non-undefined value", () => {
+ const validator = v.withDefault(v.string(), "default");
+ expect(() => validator.parse(123)).toThrow();
+ });
+});
+
+describe("isValidationError()", () => {
+ it("should return true for ValidationError instance", () => {
+ const error = new ValidationError("test");
+ expect(isValidationError(error)).toBe(true);
+ });
+
+ it("should return true for error-like object with issues array", () => {
+ const errorLike = { issues: [] };
+ expect(isValidationError(errorLike)).toBe(true);
+ });
+
+ it("should return false for non-error objects", () => {
+ expect(isValidationError("error")).toBe(false);
+ expect(isValidationError(null)).toBe(false);
+ expect(isValidationError(undefined)).toBe(false);
+ expect(isValidationError({})).toBe(false);
+ expect(isValidationError({ issues: "not array" })).toBe(false);
+ });
+});
+
+describe("formatValidationIssues()", () => {
+ it("should format single issue", () => {
+ const issues = [{ path: ["user", "name"], message: "Required" }];
+ expect(formatValidationIssues(issues)).toBe("user.name: Required");
+ });
+
+ it("should format multiple issues", () => {
+ const issues = [
+ { path: ["a"], message: "Error 1" },
+ { path: ["b", "c"], message: "Error 2" },
+ ];
+ expect(formatValidationIssues(issues)).toBe("a: Error 1, b.c: Error 2");
+ });
+
+ it("should handle empty path", () => {
+ const issues = [{ path: [], message: "Root error" }];
+ expect(formatValidationIssues(issues)).toBe(": Root error");
+ });
+
+ it("should handle empty issues array", () => {
+ expect(formatValidationIssues([])).toBe("");
+ });
+});
diff --git a/tests/unit/schema/index.test.ts b/tests/unit/schema/index.test.ts
index f7bed66..360ce0d 100644
--- a/tests/unit/schema/index.test.ts
+++ b/tests/unit/schema/index.test.ts
@@ -1,129 +1,75 @@
import { expect, test, describe } from "bun:test";
import {
- styleOptionsSchema,
- tokenMetadataSchema,
- patternMatchSchema,
- schemaConfigSchema,
- themePresetSchema,
- tokenSchema,
- tokenListSchema,
+ parseToken,
+ parseTokenSafe,
+ parseTokenList,
+ parseTokenListSafe,
+ parseTheme,
+ parseThemeSafe,
+ ValidationError,
} from "../../../src/schema/index";
describe("Schema Definitions", () => {
- describe("styleOptionsSchema", () => {
- test("validates valid style options", () => {
- const validStyle = {
- color: "red",
- styleCodes: ["bold", "underline"],
- htmlStyleFormat: "css",
+ describe("parseToken", () => {
+ test("validates token with style", () => {
+ const validToken = {
+ content: "error",
+ metadata: {
+ style: { color: "red" },
+ },
};
- const result = styleOptionsSchema.safeParse(validStyle);
+ const result = parseTokenSafe(validToken);
expect(result.success).toBe(true);
});
- test("requires color property", () => {
- const invalidStyle = {
- styleCodes: ["bold"],
+ test("requires content property", () => {
+ const missingContent = {
+ metadata: { style: { color: "red" } },
};
- const result = styleOptionsSchema.safeParse(invalidStyle);
+ const result = parseTokenSafe(missingContent);
expect(result.success).toBe(false);
});
- test("validates htmlStyleFormat enum values", () => {
- const validStyle = { color: "blue", htmlStyleFormat: "className" };
- const invalidStyle = { color: "blue", htmlStyleFormat: "invalid" };
-
- expect(styleOptionsSchema.safeParse(validStyle).success).toBe(true);
- expect(styleOptionsSchema.safeParse(invalidStyle).success).toBe(false);
- });
- });
-
- describe("tokenMetadataSchema", () => {
- test("validates metadata with style", () => {
- const validMetadata = {
- style: { color: "green" },
- matchType: "word",
- };
-
- const result = tokenMetadataSchema.safeParse(validMetadata);
- expect(result.success).toBe(true);
- });
-
- test("allows additional properties", () => {
- const metadataWithExtra = {
- style: { color: "blue" },
- customField: "value",
- matchType: "regex",
+ test("validates token without metadata", () => {
+ const tokenWithoutMetadata = {
+ content: "just content",
};
- const result = tokenMetadataSchema.safeParse(metadataWithExtra);
+ const result = parseTokenSafe(tokenWithoutMetadata);
expect(result.success).toBe(true);
});
});
- describe("patternMatchSchema", () => {
- test("validates pattern match definition", () => {
- const validPattern = {
- name: "errorPattern",
- pattern: "Error:\\s.*",
- options: { color: "red" },
- };
-
- const result = patternMatchSchema.safeParse(validPattern);
- expect(result.success).toBe(true);
- });
-
- test("requires all properties", () => {
- const missingOptions = {
- name: "errorPattern",
- pattern: "Error:\\s.*",
- };
-
- const result = patternMatchSchema.safeParse(missingOptions);
- expect(result.success).toBe(false);
- });
- });
+ describe("parseTokenList", () => {
+ test("validates token list", () => {
+ const validList = [
+ { content: "error", metadata: { style: { color: "red" } } },
+ { content: " message", metadata: { style: { color: "white" } } },
+ ];
- describe("schemaConfigSchema", () => {
- test("validates empty config", () => {
- const result = schemaConfigSchema.safeParse({});
+ const result = parseTokenListSafe(validList);
expect(result.success).toBe(true);
});
- test("validates full config", () => {
- const fullConfig = {
- defaultStyle: { color: "white" },
- matchWords: { error: { color: "red" } },
- matchStartsWith: { "[ERR]": { color: "red" } },
- matchEndsWith: { failed: { color: "red" } },
- matchContains: { warning: { color: "yellow" } },
- matchPatterns: [
- {
- name: "timestamp",
- pattern: "\\d{4}-\\d{2}-\\d{2}",
- options: { color: "blue" },
- },
- ],
- whiteSpace: "preserve",
- newLine: "trim",
- };
-
- const result = schemaConfigSchema.safeParse(fullConfig);
+ test("validates empty list", () => {
+ const result = parseTokenListSafe([]);
expect(result.success).toBe(true);
});
- test("validates default values", () => {
- const emptyConfig = {};
- const result = schemaConfigSchema.parse(emptyConfig);
+ test("fails on invalid tokens", () => {
+ const invalidList = [
+ { content: "valid" },
+ { invalidProp: "not a token" },
+ ];
- expect(result.whiteSpace).toBe("preserve");
- expect(result.newLine).toBe("preserve");
+ const result = parseTokenListSafe(invalidList);
+ expect(result.success).toBe(false);
});
});
- describe("themePresetSchema", () => {
+ describe("parseTheme", () => {
test("validates theme preset", () => {
const validTheme = {
name: "Dark Theme",
@@ -134,7 +80,7 @@ describe("Schema Definitions", () => {
},
};
- const result = themePresetSchema.safeParse(validTheme);
+ const result = parseThemeSafe(validTheme);
expect(result.success).toBe(true);
});
@@ -143,58 +89,131 @@ describe("Schema Definitions", () => {
name: "Dark Theme",
};
- const result = themePresetSchema.safeParse(missingSchema);
+ const result = parseThemeSafe(missingSchema);
expect(result.success).toBe(false);
});
+
+ test("validates full config", () => {
+ const fullConfig = {
+ name: "Full Theme",
+ schema: {
+ defaultStyle: { color: "white" },
+ matchWords: { error: { color: "red" } },
+ matchStartsWith: { "[ERR]": { color: "red" } },
+ matchEndsWith: { failed: { color: "red" } },
+ matchContains: { warning: { color: "yellow" } },
+ matchPatterns: [
+ {
+ name: "timestamp",
+ pattern: "\\d{4}-\\d{2}-\\d{2}",
+ options: { color: "blue" },
+ },
+ ],
+ whiteSpace: "preserve",
+ newLine: "trim",
+ },
+ };
+
+ const result = parseThemeSafe(fullConfig);
+ expect(result.success).toBe(true);
+ });
+
+ test("validates default values for whiteSpace and newLine", () => {
+ const minimalTheme = {
+ name: "Minimal",
+ schema: {},
+ };
+ const result = parseTheme(minimalTheme);
+
+ expect(result.schema.whiteSpace).toBe("preserve");
+ expect(result.schema.newLine).toBe("preserve");
+ });
});
- describe("tokenSchema", () => {
- test("validates token", () => {
+ describe("Style validation", () => {
+ test("validates valid style in token", () => {
const validToken = {
- content: "error",
+ content: "test",
metadata: {
- style: { color: "red" },
- matchType: "word",
+ style: {
+ color: "red",
+ styleCodes: ["bold", "underline"],
+ htmlStyleFormat: "css",
+ },
},
};
- const result = tokenSchema.safeParse(validToken);
+ const result = parseTokenSafe(validToken);
expect(result.success).toBe(true);
});
- test("requires content property", () => {
- const missingContent = {
- metadata: { style: { color: "red" } },
+ test("requires color in style", () => {
+ const invalidToken = {
+ content: "test",
+ metadata: {
+ style: {
+ styleCodes: ["bold"],
+ },
+ },
};
- const result = tokenSchema.safeParse(missingContent);
+ const result = parseTokenSafe(invalidToken);
expect(result.success).toBe(false);
});
- });
- describe("tokenListSchema", () => {
- test("validates token list", () => {
- const validList = [
- { content: "error", metadata: { style: { color: "red" } } },
- { content: " message", metadata: { style: { color: "white" } } },
- ];
+ test("validates htmlStyleFormat enum values", () => {
+ const validCss = {
+ content: "test",
+ metadata: { style: { color: "blue", htmlStyleFormat: "css" } },
+ };
+ const validClassName = {
+ content: "test",
+ metadata: { style: { color: "blue", htmlStyleFormat: "className" } },
+ };
+ const invalidFormat = {
+ content: "test",
+ metadata: { style: { color: "blue", htmlStyleFormat: "invalid" } },
+ };
- const result = tokenListSchema.safeParse(validList);
- expect(result.success).toBe(true);
+ expect(parseTokenSafe(validCss).success).toBe(true);
+ expect(parseTokenSafe(validClassName).success).toBe(true);
+ expect(parseTokenSafe(invalidFormat).success).toBe(false);
});
+ });
- test("validates empty list", () => {
- const result = tokenListSchema.safeParse([]);
+ describe("Pattern match validation", () => {
+ test("validates pattern match in theme", () => {
+ const theme = {
+ name: "Test",
+ schema: {
+ matchPatterns: [
+ {
+ name: "errorPattern",
+ pattern: "Error:\\s.*",
+ options: { color: "red" },
+ },
+ ],
+ },
+ };
+
+ const result = parseThemeSafe(theme);
expect(result.success).toBe(true);
});
- test("fails on invalid tokens", () => {
- const invalidList = [
- { content: "valid" },
- { invalidProp: "not a token" },
- ];
+ test("requires all pattern match properties", () => {
+ const theme = {
+ name: "Test",
+ schema: {
+ matchPatterns: [
+ {
+ name: "errorPattern",
+ pattern: "Error:\\s.*",
+ },
+ ],
+ },
+ };
- const result = tokenListSchema.safeParse(invalidList);
+ const result = parseThemeSafe(theme);
expect(result.success).toBe(false);
});
});
diff --git a/tests/unit/schema/validator.test.ts b/tests/unit/schema/validator.test.ts
index 1d32bd5..ebc5568 100644
--- a/tests/unit/schema/validator.test.ts
+++ b/tests/unit/schema/validator.test.ts
@@ -6,11 +6,13 @@ import {
parseTokenListSafe,
validateTheme,
validateThemeSafe,
- convertTokenSchemaToJson,
- convertThemeSchemaToJson,
createThemeValidationError,
+ ValidationError,
+} from "../../../src/schema";
+import {
+ createTokenJsonSchemaOptions,
+ createThemeJsonSchemaOptions,
} from "../../../src/schema/validator";
-import { z } from "zod";
describe("Schema Validator", () => {
describe("parseToken", () => {
@@ -19,12 +21,12 @@ describe("Schema Validator", () => {
content: "error",
metadata: {
style: { color: "red" },
- matchType: "word",
},
};
const result = parseToken(validToken);
- expect(result).toEqual(validToken);
+ expect(result.content).toBe("error");
+ expect(result.metadata?.style?.color).toBe("red");
});
test("throws on invalid token", () => {
@@ -57,7 +59,7 @@ describe("Schema Validator", () => {
const result = parseTokenSafe(invalidToken);
expect(result.success).toBe(false);
- expect(result.error).toBeInstanceOf(z.ZodError);
+ expect(result.error).toBeInstanceOf(ValidationError);
});
});
@@ -102,7 +104,7 @@ describe("Schema Validator", () => {
const result = parseTokenListSafe(invalidList);
expect(result.success).toBe(false);
- expect(result.error).toBeInstanceOf(z.ZodError);
+ expect(result.error).toBeInstanceOf(ValidationError);
});
});
@@ -157,33 +159,7 @@ describe("Schema Validator", () => {
const result = validateThemeSafe(invalidTheme);
expect(result.success).toBe(false);
- expect(result.error).toBeInstanceOf(z.ZodError);
- });
- });
-
- describe("convertTokenSchemaToJson", () => {
- test("converts token schema to JSON schema", () => {
- const jsonSchema = convertTokenSchemaToJson();
-
- expect(jsonSchema).toHaveProperty("$schema");
-
- expect(typeof jsonSchema).toBe("object");
-
- const hasNameReference = JSON.stringify(jsonSchema).includes("Token");
- expect(hasNameReference).toBe(true);
- });
- });
-
- describe("convertThemeSchemaToJson", () => {
- test("converts theme schema to JSON schema", () => {
- const jsonSchema = convertThemeSchemaToJson();
-
- expect(jsonSchema).toHaveProperty("$schema");
-
- expect(typeof jsonSchema).toBe("object");
-
- const hasNameReference = JSON.stringify(jsonSchema).includes("Theme");
- expect(hasNameReference).toBe(true);
+ expect(result.error).toBeInstanceOf(ValidationError);
});
});
@@ -215,7 +191,7 @@ describe("Schema Validator", () => {
expect(result.message).toBe("404");
});
- test("formats ZodError with validation message", () => {
+ test("formats ValidationError with validation message", () => {
const invalidTheme = {
name: "test",
};
@@ -230,4 +206,26 @@ describe("Schema Validator", () => {
}
});
});
+
+ describe("createTokenJsonSchemaOptions", () => {
+ test("returns token schema options", () => {
+ const options = createTokenJsonSchemaOptions();
+
+ expect(options).toHaveProperty("name");
+ expect(options).toHaveProperty("description");
+ expect(typeof options.name).toBe("string");
+ expect(typeof options.description).toBe("string");
+ });
+ });
+
+ describe("createThemeJsonSchemaOptions", () => {
+ test("returns theme schema options", () => {
+ const options = createThemeJsonSchemaOptions();
+
+ expect(options).toHaveProperty("name");
+ expect(options).toHaveProperty("description");
+ expect(typeof options.name).toBe("string");
+ expect(typeof options.description).toBe("string");
+ });
+ });
});
diff --git a/tests/unit/themes/presets.test.ts b/tests/unit/themes/presets.test.ts
index 682497a..a7b71f0 100644
--- a/tests/unit/themes/presets.test.ts
+++ b/tests/unit/themes/presets.test.ts
@@ -1,8 +1,5 @@
import { expect, test, describe } from "bun:test";
import {
- colorPaletteSchema,
- patternPresetSchema,
- themeGeneratorConfigSchema,
getColorPalette,
getPatternPreset,
listColorPalettes,
@@ -12,94 +9,6 @@ import {
PATTERN_PRESETS,
} from "../../../src/themes/presets";
-describe("Theme Generator Schemas", () => {
- test("colorPaletteSchema should validate valid palette", () => {
- const validPalette = {
- name: "test-palette",
- description: "A test palette",
- colors: {
- primary: "#007acc",
- secondary: "#28a745",
- success: "#1a7f37",
- warning: "#bf8700",
- error: "#d1242f",
- info: "#0969da",
- muted: "#656d76",
- background: "#ffffff",
- text: "#24292f",
- },
- accessibility: {
- contrastRatio: 7.2,
- colorBlindSafe: true,
- darkMode: false,
- },
- };
-
- const result = colorPaletteSchema.parse(validPalette);
- expect(result.name).toBe("test-palette");
- expect(result.colors.primary).toBe("#007acc");
- expect(result.accessibility.contrastRatio).toBe(7.2);
- });
-
- test("patternPresetSchema should validate valid preset", () => {
- const validPreset = {
- name: "test-preset",
- description: "A test preset",
- category: "api" as const,
- patterns: [
- {
- name: "status-code",
- pattern: "\\b\\d{3}\\b",
- description: "HTTP status codes",
- colorRole: "primary" as const,
- styleCodes: ["bold"],
- },
- ],
- matchWords: {
- GET: {
- colorRole: "primary" as const,
- },
- },
- };
-
- const result = patternPresetSchema.parse(validPreset);
- expect(result.name).toBe("test-preset");
- expect(result.category).toBe("api");
- expect(result.patterns[0].colorRole).toBe("primary");
- });
-
- test("themeGeneratorConfigSchema should validate valid config", () => {
- const validConfig = {
- name: "test-theme",
- description: "A test theme",
- colorPalette: "github-light",
- patternPresets: ["log-levels", "http-api"],
- customPatterns: [
- {
- name: "custom-pattern",
- pattern: "\\bCUSTOM\\b",
- colorRole: "warning" as const,
- },
- ],
- customWords: {
- CUSTOM: {
- colorRole: "error" as const,
- styleCodes: ["bold"],
- },
- },
- options: {
- whiteSpace: "preserve" as const,
- newLine: "preserve" as const,
- },
- };
-
- const result = themeGeneratorConfigSchema.parse(validConfig);
- expect(result.name).toBe("test-theme");
- expect(result.patternPresets).toEqual(["log-levels", "http-api"]);
- expect(result.customWords?.CUSTOM.colorRole).toBe("error");
- });
-});
-
describe("Color Palette Functions", () => {
test("getColorPalette should return palette by name", () => {
const palette = getColorPalette("github-light");
@@ -119,9 +28,11 @@ describe("Color Palette Functions", () => {
expect(palettes).toEqual(COLOR_PALETTES);
});
- test("all built-in palettes should be valid", () => {
+ test("all built-in palettes should have required fields", () => {
COLOR_PALETTES.forEach((palette) => {
- expect(() => colorPaletteSchema.parse(palette)).not.toThrow();
+ expect(palette.name).toBeDefined();
+ expect(palette.colors.primary).toBeDefined();
+ expect(palette.colors.error).toBeDefined();
});
});
});
@@ -153,9 +64,11 @@ describe("Pattern Preset Functions", () => {
});
});
- test("all built-in presets should be valid", () => {
+ test("all built-in presets should have required fields", () => {
PATTERN_PRESETS.forEach((preset) => {
- expect(() => patternPresetSchema.parse(preset)).not.toThrow();
+ expect(preset.name).toBeDefined();
+ expect(preset.category).toBeDefined();
+ expect(preset.patterns).toBeDefined();
});
});
});
diff --git a/tests/unit/utils/logger.test.ts b/tests/unit/utils/logger.test.ts
index 06e8492..accd2f0 100644
--- a/tests/unit/utils/logger.test.ts
+++ b/tests/unit/utils/logger.test.ts
@@ -1,60 +1,79 @@
-import { describe, expect, test, spyOn, afterEach } from "bun:test";
-import { logger } from "../../../src/utils/logger";
+import { describe, expect, test, spyOn, afterEach, beforeEach } from "bun:test";
+import { logger, setLogLevel, getLogLevel } from "../../../src/utils/logger";
+import type { LogLevel } from "../../../src/utils/logger";
describe("logger", () => {
- afterEach(() => {});
+ let originalLevel: LogLevel;
- test("info() logs with blue info icon", () => {
+ beforeEach(() => {
+ originalLevel = getLogLevel();
+ });
+
+ afterEach(() => {
+ setLogLevel(originalLevel);
+ });
+
+ test("info() logs with blue info label", () => {
const spy = spyOn(console, "log");
logger.info("test message");
expect(spy).toHaveBeenCalledWith(
- expect.stringContaining("ā¹"),
+ expect.stringContaining("[info]"),
"test message",
);
spy.mockRestore();
});
- test("success() logs with green check icon", () => {
+ test("success() logs with green ok label", () => {
const spy = spyOn(console, "log");
logger.success("operation completed");
expect(spy).toHaveBeenCalledWith(
- expect.stringContaining("ā"),
+ expect.stringContaining("[ok]"),
"operation completed",
);
spy.mockRestore();
});
- test("warn() logs with yellow warning icon", () => {
+ test("warn() logs with yellow warn label", () => {
const spy = spyOn(console, "log");
logger.warn("warning message");
expect(spy).toHaveBeenCalledWith(
- expect.stringContaining("ā "),
+ expect.stringContaining("[warn]"),
"warning message",
);
spy.mockRestore();
});
- test("error() logs with red X icon", () => {
+ test("error() logs with red error label", () => {
const spy = spyOn(console, "error");
logger.error("error message");
expect(spy).toHaveBeenCalledWith(
- expect.stringContaining("ā"),
+ expect.stringContaining("[error]"),
"error message",
);
spy.mockRestore();
});
- test("debug() logs with gray gear icon", () => {
+ test("debug() logs with gray debug label when log level is debug", () => {
+ setLogLevel("debug");
const spy = spyOn(console, "log");
logger.debug("debug message");
expect(spy).toHaveBeenCalledWith(
- expect.stringContaining("ā"),
+ expect.stringContaining("[debug]"),
"debug message",
);
spy.mockRestore();
});
- test("all methods handle empty strings", () => {
+ test("debug() does not log when log level is info", () => {
+ setLogLevel("info");
+ const spy = spyOn(console, "log");
+ logger.debug("debug message");
+ expect(spy).not.toHaveBeenCalled();
+ spy.mockRestore();
+ });
+
+ test("all methods handle empty strings at debug level", () => {
+ setLogLevel("debug");
const logSpy = spyOn(console, "log");
const errorSpy = spyOn(console, "error");
@@ -77,10 +96,68 @@ describe("logger", () => {
logger.info(multiline);
expect(logSpy).toHaveBeenCalledWith(
- expect.stringContaining("ā¹"),
+ expect.stringContaining("[info]"),
multiline,
);
logSpy.mockRestore();
});
+
+ test("setLogLevel and getLogLevel work correctly", () => {
+ setLogLevel("error");
+ expect(getLogLevel()).toBe("error");
+
+ setLogLevel("debug");
+ expect(getLogLevel()).toBe("debug");
+ });
+
+ test("silent level suppresses all output", () => {
+ setLogLevel("silent");
+ const logSpy = spyOn(console, "log");
+ const errorSpy = spyOn(console, "error");
+
+ logger.info("info");
+ logger.success("success");
+ logger.warn("warn");
+ logger.error("error");
+ logger.debug("debug");
+
+ expect(logSpy).not.toHaveBeenCalled();
+ expect(errorSpy).not.toHaveBeenCalled();
+
+ logSpy.mockRestore();
+ errorSpy.mockRestore();
+ });
+
+ test("error level only shows errors", () => {
+ setLogLevel("error");
+ const logSpy = spyOn(console, "log");
+ const errorSpy = spyOn(console, "error");
+
+ logger.info("info");
+ logger.warn("warn");
+ logger.error("error");
+
+ expect(logSpy).not.toHaveBeenCalled();
+ expect(errorSpy).toHaveBeenCalledTimes(1);
+
+ logSpy.mockRestore();
+ errorSpy.mockRestore();
+ });
+
+ test("warn level shows warn and error", () => {
+ setLogLevel("warn");
+ const logSpy = spyOn(console, "log");
+ const errorSpy = spyOn(console, "error");
+
+ logger.info("info");
+ logger.warn("warn");
+ logger.error("error");
+
+ expect(logSpy).toHaveBeenCalledTimes(1);
+ expect(errorSpy).toHaveBeenCalledTimes(1);
+
+ logSpy.mockRestore();
+ errorSpy.mockRestore();
+ });
});