From fa1fedbb0122cc0b09e93d28d067ba57ece191fa Mon Sep 17 00:00:00 2001 From: Jacobtread Date: Sat, 16 May 2026 19:09:21 +1200 Subject: [PATCH] refactor: replace zod for valibot For tree shaking because currently zod has a bunch of extra stuff that shows up negatively on the scorecard --- package.json | 2 +- pnpm-lock.yaml | 23 ++++++---- src/timekeep/parser.ts | 15 +++++-- src/timekeep/schema.test.ts | 3 +- src/timekeep/schema.ts | 86 +++++++++++++++++++++---------------- 5 files changed, 79 insertions(+), 50 deletions(-) diff --git a/package.json b/package.json index ac22b7d..33909dc 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "p-limit": "^7.3.0", "pdfmake": "^0.3.7", "uuid": "^11.0.5", - "zod": "^4.3.6" + "valibot": "^1.4.0" }, "devDependencies": { "@types/node": "^20.19.37", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c69f64..63b38a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,9 +23,9 @@ importers: uuid: specifier: ^11.0.5 version: 11.1.1 - zod: - specifier: ^4.3.6 - version: 4.4.3 + valibot: + specifier: ^1.4.0 + version: 1.4.0(typescript@6.0.2) devDependencies: '@types/node': specifier: ^20.19.37 @@ -931,6 +931,14 @@ packages: resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} hasBin: true + valibot@1.4.0: + resolution: {integrity: sha512-iC/x7fVcSyOwlm/VSt7RlHnzNGLGvR9GnxdifUeWoCJo0q4ZZvrVkIHC6faTlkxG47I2Y4UrFquPuVHCrOnrLg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + vite@8.0.13: resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1047,9 +1055,6 @@ packages: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} - snapshots: '@babel/helper-string-parser@7.27.1': {} @@ -1753,6 +1758,10 @@ snapshots: uuid@11.1.1: {} + valibot@1.4.0(typescript@6.0.2): + optionalDependencies: + typescript: 6.0.2 + vite@8.0.13(@types/node@20.19.41): dependencies: lightningcss: 1.32.0 @@ -1809,5 +1818,3 @@ snapshots: sax: 1.6.0 yocto-queue@1.2.2: {} - - zod@4.4.3: {} diff --git a/src/timekeep/parser.ts b/src/timekeep/parser.ts index 5007e0b..a9377a0 100644 --- a/src/timekeep/parser.ts +++ b/src/timekeep/parser.ts @@ -1,3 +1,5 @@ +import { safeParse } from "valibot"; + import { isEmptyString } from "@/utils/text"; import { TIMEKEEP, Timekeep, stripTimekeepRuntimeData } from "@/timekeep/schema"; @@ -33,15 +35,22 @@ export function load(value: string): LoadResult { } // Parse the data against the schema - const timekeepResult = TIMEKEEP.safeParse(parsedValue); + const timekeepResult = safeParse(TIMEKEEP, parsedValue); if (!timekeepResult.success) { + const error = timekeepResult.issues + .map((issue) => { + const path = issue.path?.map((p) => p.key).join(".") ?? "root"; + return `${path}: ${issue.message}`; + }) + .join("\n"); + return { success: false, - error: timekeepResult.error.toString(), + error, }; } - const timekeep = timekeepResult.data; + const timekeep = timekeepResult.output; return { success: true, timekeep }; } diff --git a/src/timekeep/schema.test.ts b/src/timekeep/schema.test.ts index 78bdee5..dd4da40 100644 --- a/src/timekeep/schema.test.ts +++ b/src/timekeep/schema.test.ts @@ -1,5 +1,6 @@ import moment from "moment"; import { v4 as uuid } from "uuid"; +import { parse } from "valibot"; import { expect, it, describe, vi } from "vitest"; import { TIMEKEEP } from "@/timekeep/schema"; @@ -36,7 +37,7 @@ describe("schema transform", () => { }, ], }; - const result = TIMEKEEP.parse(input); + const result = parse(TIMEKEEP, input); expect(result).toEqual({ entries: [ diff --git a/src/timekeep/schema.ts b/src/timekeep/schema.ts index 2021a66..87b6aa7 100644 --- a/src/timekeep/schema.ts +++ b/src/timekeep/schema.ts @@ -1,75 +1,87 @@ -import moment from "moment"; +import moment, { Moment } from "moment"; import { v4 as uuid } from "uuid"; -import { z } from "zod"; +import * as v from "valibot"; /* * This file contains the strict schema for parsing timekeep data * it also contains the types for each timekeep structure. */ -type TimeEntryGroupBase = z.output; +type RawTimeEntrySingle = v.InferOutput; +type RawTimeEntryGroupBase = v.InferOutput; // Type aliases from inferred zod types -export type TimeEntrySingle = z.output; -export type TimeEntryGroup = TimeEntryGroupBase & { +export type TimeEntrySingle = RawTimeEntrySingle; +export type TimeEntryGroup = RawTimeEntryGroupBase & { id: string; subEntries: TimeEntry[]; }; export type TimeEntry = TimeEntrySingle | TimeEntryGroup; -export type Timekeep = z.output; + +export type Timekeep = v.InferOutput; + +const strToMoment = (value: string | null): Moment | null => + value === null ? null : moment(value); // Schema for a time entry with no children -const TIME_ENTRY_SINGLE = z - .object({ +const TIME_ENTRY_SINGLE = v.pipe( + v.object({ // Name of the entry - name: z.string(), + name: v.string(), + // Start time for this entry - startTime: z - .string() - .nullable() - .transform((value) => (value === null ? null : moment(value))), + startTime: v.pipe(v.nullable(v.string()), v.transform(strToMoment)), + // End time for this entry, null when this entry is not finished - endTime: z - .string() - .nullable() - .transform((value) => (value === null ? null : moment(value))), + endTime: v.pipe(v.nullable(v.string()), v.transform(strToMoment)), + // Single entries have no children - subEntries: z.null(), - }) + subEntries: v.null(), + }), // At runtime a unique ID is inserted - .transform((entry) => ({ + v.transform((entry) => ({ ...entry, id: uuid(), - })); + })) +); // Schema for a time entry with children (Base portion, separate portion is required for recursion) -const TIME_ENTRY_GROUP_BASE = z.object({ - name: z.string(), - startTime: z.null(), - endTime: z.null(), +const TIME_ENTRY_GROUP_BASE = v.object({ + name: v.string(), + startTime: v.null(), + endTime: v.null(), // Optional field to indicate the entry is collapsed - collapsed: z.boolean().optional(), + collapsed: v.optional(v.boolean()), // Optional field to indicate the entry should stay as a group when non-started and should only create // sub entries when starting - folder: z.boolean().optional(), + folder: v.optional(v.boolean()), }); -// Schema for a time entry group -const TIME_ENTRY_GROUP: z.ZodType = TIME_ENTRY_GROUP_BASE.extend({ - subEntries: z.lazy(() => z.array(z.union([TIME_ENTRY_SINGLE, TIME_ENTRY_GROUP]))), -}) +const TIME_ENTRY_GROUP = v.pipe( + v.object({ + ...TIME_ENTRY_GROUP_BASE.entries, + subEntries: v.lazy(() => TIME_ENTRY_ARRAY), + }), // At runtime a unique ID is inserted - /* istanbul ignore next */ - .transform((entry) => ({ + v.transform((entry) => ({ ...entry, id: uuid(), - })); + })) +); // Schema for time entries -const TIME_ENTRY = z.union([TIME_ENTRY_SINGLE, TIME_ENTRY_GROUP]); +const TIME_ENTRY: v.GenericSchema = v.union([ + TIME_ENTRY_SINGLE, + TIME_ENTRY_GROUP, + // Forcefully cast to the type as the type inference is unable to + // properly type this recursive structure +]) as unknown as v.GenericSchema; + +const TIME_ENTRY_ARRAY = v.array(TIME_ENTRY); + // Schema for an entire timekeep -export const TIMEKEEP = z.object({ - entries: z.array(TIME_ENTRY), +export const TIMEKEEP = v.object({ + entries: TIME_ENTRY_ARRAY, }); export function defaultTimekeep(): Timekeep {