diff --git a/src/lib/core/LocationFull.ts b/src/lib/core/LocationFull.ts index 481629d..664b9ad 100644 --- a/src/lib/core/LocationFull.ts +++ b/src/lib/core/LocationFull.ts @@ -2,6 +2,7 @@ import type { BeforeNavigateEvent, Events, NavigationCancelledEvent, NavigationE import { isConformantState } from "./isConformantState.js"; import { LocationLite } from "./LocationLite.svelte.js"; import { LocationState } from "./LocationState.svelte.js"; +import { logger } from "./Logger.js"; /** * Location implementation of the library's full mode feature. @@ -59,7 +60,7 @@ export class LocationFull extends LocationLite { } } else { if (!isConformantState(event.state)) { - console.warn("Warning: Non-conformant state object passed to history." + method + "State. Previous state will prevail."); + logger.warn(`Warning: Non-conformant state object passed to history.${method}State. Previous state will prevail.`); event.state = this.#innerState.state; } const navFn = method === 'push' ? this.#originalPushState : this.#originalReplaceState; diff --git a/src/lib/core/LocationState.svelte.test.ts b/src/lib/core/LocationState.svelte.test.ts index 1c05e7e..c3eca3e 100644 --- a/src/lib/core/LocationState.svelte.test.ts +++ b/src/lib/core/LocationState.svelte.test.ts @@ -1,9 +1,10 @@ import { describe, test, expect, beforeEach, vi } from 'vitest'; import { LocationState } from './LocationState.svelte.js'; import type { State } from '$lib/types.js'; +import { logger } from './Logger.js'; -// Mock console.warn to capture warnings -const mockConsoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); +// Mock logger.warn to capture warnings +const mockLoggerWarn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); // Mock globalThis.window for testing const mockWindow = { @@ -17,7 +18,7 @@ const mockWindow = { describe('LocationState', () => { beforeEach(() => { - mockConsoleWarn.mockClear(); + mockLoggerWarn.mockClear(); // Reset to default mock state mockWindow.history.state = null; vi.stubGlobal('window', mockWindow); @@ -37,7 +38,7 @@ describe('LocationState', () => { // Assert expect(locationState.state).toStrictEqual(validState); // Deep equality since $state() creates new objects - expect(mockConsoleWarn).not.toHaveBeenCalled(); + expect(mockLoggerWarn).not.toHaveBeenCalled(); }); test('Should handle conformant state with undefined path.', () => { @@ -53,7 +54,7 @@ describe('LocationState', () => { // Assert expect(locationState.state).toStrictEqual(validState); - expect(mockConsoleWarn).not.toHaveBeenCalled(); + expect(mockLoggerWarn).not.toHaveBeenCalled(); }); test('Should handle conformant state with empty hash object.', () => { @@ -69,7 +70,7 @@ describe('LocationState', () => { // Assert expect(locationState.state).toStrictEqual(validState); - expect(mockConsoleWarn).not.toHaveBeenCalled(); + expect(mockLoggerWarn).not.toHaveBeenCalled(); }); test('Should handle conformant state with numeric path.', () => { @@ -85,7 +86,7 @@ describe('LocationState', () => { // Assert expect(locationState.state).toStrictEqual(validState); - expect(mockConsoleWarn).not.toHaveBeenCalled(); + expect(mockLoggerWarn).not.toHaveBeenCalled(); }); }); @@ -102,7 +103,7 @@ describe('LocationState', () => { path: undefined, hash: {} }); - expect(mockConsoleWarn).not.toHaveBeenCalled(); + expect(mockLoggerWarn).not.toHaveBeenCalled(); }); test('Should create clean state when history.state is undefined without warning.', () => { @@ -117,7 +118,7 @@ describe('LocationState', () => { path: undefined, hash: {} }); - expect(mockConsoleWarn).not.toHaveBeenCalled(); + expect(mockLoggerWarn).not.toHaveBeenCalled(); }); }); @@ -155,8 +156,8 @@ describe('LocationState', () => { path: undefined, hash: {} }); - expect(mockConsoleWarn).toHaveBeenCalledOnce(); - expect(mockConsoleWarn).toHaveBeenCalledWith( + expect(mockLoggerWarn).toHaveBeenCalledOnce(); + expect(mockLoggerWarn).toHaveBeenCalledWith( 'Non-conformant state data detected in History API. Resetting to clean state.' ); }); diff --git a/src/lib/core/LocationState.svelte.ts b/src/lib/core/LocationState.svelte.ts index d254b35..7ebcbd5 100644 --- a/src/lib/core/LocationState.svelte.ts +++ b/src/lib/core/LocationState.svelte.ts @@ -1,5 +1,6 @@ import { SvelteURL } from "svelte/reactivity"; import { isConformantState } from "./isConformantState.js"; +import { logger } from "./Logger.js"; /** * Helper class used to manage the reactive data of Location implementations. @@ -14,7 +15,7 @@ export class LocationState { let validState = false; this.state = $state((validState = isConformantState(historyState)) ? historyState : { path: undefined, hash: {} }); if (!validState && historyState != null) { - console.warn('Non-conformant state data detected in History API. Resetting to clean state.'); + logger.warn('Non-conformant state data detected in History API. Resetting to clean state.'); } } } diff --git a/src/lib/core/Logger.test.ts b/src/lib/core/Logger.test.ts new file mode 100644 index 0000000..22f04d2 --- /dev/null +++ b/src/lib/core/Logger.test.ts @@ -0,0 +1,161 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import { logger, setLogger } from "./Logger.js"; +import type { ILogger } from "$lib/types.js"; + +describe("logger", () => { + test("Should default to globalThis.console", () => { + expect(logger).toBe(globalThis.console); + }); +}); + +describe("setLogger", () => { + let originalLogger: ILogger; + let mockLogger: ILogger; + let consoleSpy: { + debug: any; + log: any; + warn: any; + error: any; + }; + + beforeEach(() => { + // Store original logger state + originalLogger = logger; + + // Create mock logger + mockLogger = { + debug: vi.fn(), + log: vi.fn(), + warn: vi.fn(), + error: vi.fn() + }; + + // Create console spies + consoleSpy = { + debug: vi.spyOn(globalThis.console, 'debug').mockImplementation(() => {}), + log: vi.spyOn(globalThis.console, 'log').mockImplementation(() => {}), + warn: vi.spyOn(globalThis.console, 'warn').mockImplementation(() => {}), + error: vi.spyOn(globalThis.console, 'error').mockImplementation(() => {}) + }; + }); + + afterEach(() => { + // Restore original state + setLogger(originalLogger); + vi.restoreAllMocks(); + }); + + describe("Boolean arguments", () => { + test("Should set logger to globalThis.console when true", () => { + setLogger(true); + + expect(logger).toBe(globalThis.console); + + logger.debug("test"); + expect(consoleSpy.debug).toHaveBeenCalledWith("test"); + }); + + test("Should set logger to noop functions when false", () => { + setLogger(false); + + expect(logger).not.toBe(globalThis.console); + + // Should not throw and should not call console + logger.debug("debug message"); + logger.log("log message"); + logger.warn("warn message"); + logger.error("error message"); + + expect(consoleSpy.debug).not.toHaveBeenCalled(); + expect(consoleSpy.log).not.toHaveBeenCalled(); + expect(consoleSpy.warn).not.toHaveBeenCalled(); + expect(consoleSpy.error).not.toHaveBeenCalled(); + }); + + test("Should allow switching between true and false", () => { + setLogger(true); + logger.log("enabled message"); + expect(consoleSpy.log).toHaveBeenCalledWith("enabled message"); + + setLogger(false); + logger.log("disabled message"); + expect(consoleSpy.log).toHaveBeenCalledTimes(1); // Should not be called again + + setLogger(true); + logger.log("re-enabled message"); + expect(consoleSpy.log).toHaveBeenCalledWith("re-enabled message"); + expect(consoleSpy.log).toHaveBeenCalledTimes(2); + }); + }); + + describe("ILogger implementations", () => { + test("Should set custom logger as the global logger", () => { + setLogger(mockLogger); + + expect(logger).toBe(mockLogger); + + logger.debug("custom debug"); + logger.log("custom log"); + logger.warn("custom warn"); + logger.error("custom error"); + + expect(mockLogger.debug).toHaveBeenCalledWith("custom debug"); + expect(mockLogger.log).toHaveBeenCalledWith("custom log"); + expect(mockLogger.warn).toHaveBeenCalledWith("custom warn"); + expect(mockLogger.error).toHaveBeenCalledWith("custom error"); + + // Console should not be called + expect(consoleSpy.debug).not.toHaveBeenCalled(); + expect(consoleSpy.log).not.toHaveBeenCalled(); + expect(consoleSpy.warn).not.toHaveBeenCalled(); + expect(consoleSpy.error).not.toHaveBeenCalled(); + }); + + test("Should work with extended logger implementations", () => { + const extendedLogger = { + ...mockLogger, + trace: vi.fn(), + info: vi.fn() + }; + + setLogger(extendedLogger); + + expect(logger).toBe(extendedLogger); + + logger.debug("debug"); + logger.log("log"); + logger.warn("warn"); + logger.error("error"); + + expect(extendedLogger.debug).toHaveBeenCalledWith("debug"); + expect(extendedLogger.log).toHaveBeenCalledWith("log"); + expect(extendedLogger.warn).toHaveBeenCalledWith("warn"); + expect(extendedLogger.error).toHaveBeenCalledWith("error"); + }); + + test("Should allow switching from custom logger back to stock logger", () => { + setLogger(mockLogger); + logger.log("custom message"); + expect(mockLogger.log).toHaveBeenCalledWith("custom message"); + + setLogger(true); + logger.log("stock message"); + expect(consoleSpy.log).toHaveBeenCalledWith("stock message"); + expect(mockLogger.log).toHaveBeenCalledTimes(1); // Should not be called again + }); + + test("Should handle multiple parameters correctly", () => { + setLogger(mockLogger); + + logger.debug("debug", 123, { key: "value" }); + logger.log("log", true, null); + logger.warn("warn", "multiple", "parameters"); + logger.error("error", { error: "object" }); + + expect(mockLogger.debug).toHaveBeenCalledWith("debug", 123, { key: "value" }); + expect(mockLogger.log).toHaveBeenCalledWith("log", true, null); + expect(mockLogger.warn).toHaveBeenCalledWith("warn", "multiple", "parameters"); + expect(mockLogger.error).toHaveBeenCalledWith("error", { error: "object" }); + }); + }); +}); diff --git a/src/lib/core/Logger.ts b/src/lib/core/Logger.ts new file mode 100644 index 0000000..cad74b5 --- /dev/null +++ b/src/lib/core/Logger.ts @@ -0,0 +1,18 @@ +import type { ILogger } from "$lib/types.js"; + +const stockLogger: ILogger = globalThis.console; + +const noop = () => { }; + +const offLogger: ILogger = { + debug: noop, + log: noop, + warn: noop, + error: noop +}; + +export let logger: ILogger = stockLogger; + +export function setLogger(newLogger: boolean | ILogger) { + logger = newLogger === true ? stockLogger : (newLogger === false ? offLogger : newLogger); +}; diff --git a/src/lib/index.ts b/src/lib/index.ts index 8b66fa0..29d840e 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,8 +1,10 @@ import { setLocation } from "./core/Location.js"; import { LocationFull } from "./core/LocationFull.js"; import { LocationLite } from "./core/LocationLite.svelte.js"; +import { setLogger } from "./core/Logger.js"; import { routingOptions, type RoutingOptions } from "./core/options.js"; import { setTraceOptions, type TraceOptions } from "./core/trace.svelte.js"; +import type { ILogger } from "./types.js"; /** * Library's initialization options. @@ -12,6 +14,15 @@ export type InitOptions = RoutingOptions & { * Tracing options that generally should be off for production builds. */ trace?: TraceOptions; + /** + * Controls logging. If `true`, the default logger that logs to the console is used. If `false`, logging is + * turned off. If an object is provided, it is used as the logger. + * + * Logging is turned on by default. + * + * **TIP**: You can provide your own logger implementation to integrate with your application's logging system. + */ + logger?: boolean | ILogger; } /** @@ -30,6 +41,7 @@ export type InitOptions = RoutingOptions & { */ export function init(options?: InitOptions): () => void { setTraceOptions(options?.trace); + setLogger(options?.logger ?? true); routingOptions.full = options?.full ?? routingOptions.full; routingOptions.hashMode = options?.hashMode ?? routingOptions.hashMode; routingOptions.implicitMode = options?.implicitMode ?? routingOptions.implicitMode; diff --git a/src/lib/types.ts b/src/lib/types.ts index d92d243..2fbbf1a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -321,3 +321,25 @@ export type ActiveState = { * @returns `true` if the fallback content should be shown; `false` to prevent content from being shown. */ export type WhenPredicate = (routeStatus: Record, noMatches: boolean) => boolean; + +/** + * Defines the shape of logger objects that can be given to this library during initialization. + */ +export interface ILogger { + /** + * See `console.debug()` for reference. + */ + debug: (...args: any[]) => void; + /** + * See `console.log()` for reference. + */ + log: (...args: any[]) => void; + /** + * See `console.warn()` for reference. + */ + warn: (...args: any[]) => void; + /** + * See `console.error()` for reference. + */ + error: (...args: any[]) => void; +};