refactor: replace zod for valibot

For tree shaking because currently zod has a bunch of extra stuff that
shows up negatively on the scorecard
This commit is contained in:
Jacobtread 2026-05-16 19:09:21 +12:00
parent ee3dfc7101
commit fa1fedbb01
No known key found for this signature in database
GPG key ID: AB9B37C42B33D9C6
5 changed files with 79 additions and 50 deletions

View file

@ -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",

View file

@ -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: {}

View file

@ -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 };
}

View file

@ -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: [

View file

@ -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<typeof TIME_ENTRY_GROUP_BASE>;
type RawTimeEntrySingle = v.InferOutput<typeof TIME_ENTRY_SINGLE>;
type RawTimeEntryGroupBase = v.InferOutput<typeof TIME_ENTRY_GROUP_BASE>;
// Type aliases from inferred zod types
export type TimeEntrySingle = z.output<typeof TIME_ENTRY_SINGLE>;
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<typeof TIMEKEEP>;
export type Timekeep = v.InferOutput<typeof TIMEKEEP>;
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<TimeEntryGroup> = 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<TimeEntry> = 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<TimeEntry>;
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 {