Detailed changes
@@ -36,7 +36,8 @@
"remark-gfm": "^4.0.0",
"rxjs": "^7.8.2",
"tailwind-merge": "^3.5.0",
- "tw-animate-css": "^1.4.0"
+ "tw-animate-css": "^1.4.0",
+ "valibot": "^1.3.1"
},
"devDependencies": {
"@graphql-codegen/cli": "^6.2.1",
@@ -74,6 +74,9 @@ importers:
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
+ valibot:
+ specifier: ^1.3.1
+ version: 1.3.1(typescript@6.0.2)
devDependencies:
'@graphql-codegen/cli':
specifier: ^6.2.1
@@ -3618,6 +3621,14 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+ valibot@1.3.1:
+ resolution: {integrity: sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==}
+ peerDependencies:
+ typescript: '>=5'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
vfile-location@5.0.3:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
@@ -7549,6 +7560,10 @@ snapshots:
util-deprecate@1.0.2: {}
+ valibot@1.3.1(typescript@6.0.2):
+ optionalDependencies:
+ typescript: 6.0.2
+
vfile-location@5.0.3:
dependencies:
'@types/unist': 3.0.3
@@ -17,19 +17,22 @@
import { gql } from "@apollo/client";
import { useQuery } from "@apollo/client/react";
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
+import * as v from "valibot";
import { useServerConfigQuery } from "@/__generated__/graphql";
+const authUserSchema = v.object({
+ id: v.string(),
+ humanId: v.string(),
+ name: v.nullable(v.string()),
+ displayName: v.string(),
+ avatarUrl: v.nullable(v.string()),
+ email: v.nullable(v.string()),
+ login: v.nullable(v.string()),
+});
+
// AuthUser matches the Identity type fields we care about for auth purposes.
-export interface AuthUser {
- id: string;
- humanId: string;
- name: string | null;
- displayName: string;
- avatarUrl: string | null;
- email: string | null;
- login: string | null;
-}
+export type AuthUser = v.InferOutput<typeof authUserSchema>;
// 'local' — single-user mode, identity from git config
// 'external' — multi-user mode, identity from OAuth/OIDC session
@@ -108,9 +111,7 @@ function ExternalAuthProvider({
.then(async (res) => {
if (res.status === 401) return null;
if (!res.ok) throw new Error(`/auth/user returned ${res.status}`);
- // eslint-disable-next-line typescript-eslint/no-unsafe-assignment
- const data: AuthUser = await res.json();
- return data;
+ return v.parse(authUserSchema, await res.json());
})
.then((u) => setUser(u))
.catch(() => setUser(null))
@@ -6,6 +6,7 @@ import { useQuery, useReadQuery } from "@apollo/client/react";
import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router";
import { AlertCircle, GitCommit } from "lucide-react";
import { useEffect } from "react";
+import * as v from "valibot";
import {
GitObjectType,
@@ -111,28 +112,20 @@ interface BlobQueryData {
} | null;
}
-export type CodePageSearch = {
- ref: string;
- path: string;
- type: "tree" | "blob" | "commits";
-};
+const codePageSearchSchema = v.object({
+ ref: v.fallback(v.string(), ""),
+ path: v.fallback(v.string(), ""),
+ type: v.fallback(v.picklist(["tree", "blob", "commits"]), "tree"),
+});
+
+export type CodePageSearch = v.InferOutput<typeof codePageSearchSchema>;
type ViewMode = CodePageSearch["type"];
export const Route = createFileRoute("/$repo/")({
component: RouteComponent,
pendingComponent: CodePageSkeleton,
- validateSearch: (search: Record<string, unknown>): CodePageSearch => {
- const ref = typeof search.ref === "string" ? search.ref : "";
- const path = typeof search.path === "string" ? search.path : "";
- const typeStr = typeof search.type === "string" ? search.type : "";
- const validTypes: ViewMode[] = ["tree", "blob", "commits"];
- return {
- ref,
- path,
- type: validTypes.find((t) => t === typeStr) ?? "tree",
- };
- },
+ validateSearch: (search) => v.parse(codePageSearchSchema, search),
loader: ({ params: { repo } }) => ({
refsRef: preloadQuery<RefsQueryData>(REFS_QUERY, {
variables: { repo: repo === "_" ? null : repo },
@@ -1,6 +1,7 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { CircleDot, CircleCheck, ChevronLeft, ChevronRight } from "lucide-react";
import { useState } from "react";
+import * as v from "valibot";
import { useBugListQuery } from "@/__generated__/graphql";
import { BugRow } from "@/components/bugs/BugRow";
@@ -13,17 +14,14 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useRepo } from "@/lib/repo";
import { cn } from "@/lib/utils";
-type IssuesSearch = {
- q: string;
- after: string;
-};
+const issuesSearchSchema = v.object({
+ q: v.fallback(v.string(), "status:open"),
+ after: v.fallback(v.string(), ""),
+});
export const Route = createFileRoute("/$repo/issues/")({
component: RouteComponent,
- validateSearch: (search: Record<string, unknown>): IssuesSearch => ({
- q: typeof search.q === "string" ? search.q : "status:open",
- after: typeof search.after === "string" ? search.after : "",
- }),
+ validateSearch: (search) => v.parse(issuesSearchSchema, search),
});
const PAGE_SIZE = 25;
@@ -314,8 +312,8 @@ function tokenizeQuery(input: string): string[] {
// Parse a query string back into structured filter state.
const VALID_SORTS = new Set<string>(["creation-desc", "creation-asc", "edit-desc", "edit-asc"]);
-function isValidSort(v: string): v is SortValue {
- return VALID_SORTS.has(v);
+function isValidSort(val: string): val is SortValue {
+ return VALID_SORTS.has(val);
}
function parseQueryString(input: string): {
@@ -337,8 +335,8 @@ function parseQueryString(input: string): {
else if (token.startsWith("label:")) labels.push(token.slice(6));
else if (token.startsWith("author:")) author = token.slice(7).replace(/^"|"$/g, "");
else if (token.startsWith("sort:")) {
- const v = token.slice(5);
- if (isValidSort(v)) sort = v;
+ const val = token.slice(5);
+ if (isValidSort(val)) sort = val;
} else free.push(token);
}
@@ -9,6 +9,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { UserCircle, Plus, AlertCircle } from "lucide-react";
import { useEffect, useState } from "react";
+import * as v from "valibot";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@@ -17,14 +18,16 @@ export const Route = createFileRoute("/auth/select-identity")({
component: RouteComponent,
});
-interface IdentityItem {
- repoSlug: string;
- id: string;
- humanId: string;
- displayName: string;
- login?: string;
- avatarUrl?: string;
-}
+const identityItemSchema = v.object({
+ repoSlug: v.string(),
+ id: v.string(),
+ humanId: v.string(),
+ displayName: v.string(),
+ login: v.optional(v.string()),
+ avatarUrl: v.optional(v.string()),
+});
+
+type IdentityItem = v.InferOutput<typeof identityItemSchema>;
function RouteComponent() {
const [identities, setIdentities] = useState<IdentityItem[] | null>(null);
@@ -36,8 +39,7 @@ function RouteComponent() {
try {
const res = await fetch("/auth/identities", { credentials: "include" });
if (!res.ok) throw new Error(`unexpected status ${res.status}`);
- // eslint-disable-next-line typescript-eslint/no-unsafe-assignment
- const data: IdentityItem[] = await res.json();
+ const data = v.parse(v.array(identityItemSchema), await res.json());
setIdentities(data);
} catch (e) {
setError(String(e));