refactor: Progressive migration Client SDK (#4227)

* Implement code changes to enhance functionality and improve performance

Signed-off-by: Innei <tukon479@gmail.com>

* chore: update @follow-app/client-sdk to version 0.2.6 and add new dependencies
refactor: adjust types in server configs and features to use ExtractResponseData
feat: implement followApi for server configurations and API client context

Signed-off-by: Innei <tukon479@gmail.com>

* feat: move to client-sdk

Signed-off-by: Innei <tukon479@gmail.com>

* feat: update @follow-app/client-sdk to version 0.3.22 and add ShrinkingFocusBorder component

- Updated the version of @follow-app/client-sdk in pnpm-workspace.yaml from 0.3.15 to 0.3.22.
- Introduced a new ShrinkingFocusBorder component that animates a focus border effect using a canvas element.
- Implemented resize observer for persistent border and animation logic for visibility changes.

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-07-23 01:21:34 +08:00 committed by GitHub
parent 26c96c2f22
commit 01ed1c63b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 1113 additions and 3332 deletions

View File

@ -1,18 +1,13 @@
import { fileURLToPath } from "node:url"
import { dirname, resolve } from "pathe"
import { tsImport } from "tsx/esm/api"
import type { UserConfig } from "vite"
import { routeBuilderPlugin } from "vite-plugin-route-builder"
import { cleanupUnnecessaryFilesPlugin } from "../plugins/vite/cleanup"
import { createPlatformSpecificImportPlugin } from "../plugins/vite/specific-import"
import { viteRenderBaseConfig } from "./vite.render.config"
const routeBuilderPluginV2 = await tsImport(
"@follow-app/vite-plugin-route-builder",
import.meta.url,
).then((m) => m.default)
const root = resolve(fileURLToPath(dirname(import.meta.url)), "..")
const VITE_ROOT = resolve(root, "layer/renderer")
@ -26,7 +21,7 @@ export default {
plugins: [
...viteRenderBaseConfig.plugins,
createPlatformSpecificImportPlugin("electron"),
routeBuilderPluginV2({
routeBuilderPlugin({
pagePattern: "src/pages/**/*.tsx",
outputPath: "src/generated-routes.ts",
enableInDev: true,

View File

@ -12,16 +12,18 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@ai-sdk/openai": "2.0.0-beta.5",
"@ai-sdk/react": "2.0.0-beta.11",
"@ai-sdk/openai": "2.0.0-beta.11",
"@ai-sdk/react": "2.0.0-beta.25",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
"@electron-toolkit/preload": "3.0.2",
"@follow-app/client-sdk": "catalog:",
"@follow/database": "workspace:*",
"@follow/electron-main": "workspace:*",
"@follow/shared": "workspace:*",
"@follow/store": "workspace:*",
"@follow/tracker": "workspace:*",
"@folo-services/constants": "0.1.6",
"@fontsource/sn-pro": "5.2.5",
"@hcaptcha/react-hcaptcha": "1.12.0",
"@headlessui/react": "2.2.4",
@ -48,7 +50,7 @@
"@use-gesture/react": "10.3.1",
"@welldone-software/why-did-you-render": "10.0.1",
"@yornaath/batshit": "0.10.1",
"ai": "5.0.0-beta.11",
"ai": "5.0.0-beta.25",
"camelcase-keys": "9.1.3",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
@ -114,6 +116,7 @@
"@follow/models": "workspace:*",
"@follow/types": "workspace:*",
"@follow/utils": "workspace:*",
"@folo-services/ai-tools": "0.2.14",
"@types/node": "24.0.10",
"@vite-pwa/assets-generator": "1.0.0",
"fake-indexeddb": "6.0.1",

View File

@ -1,11 +1,11 @@
import type { ServerConfigs } from "@follow/models/types"
import type { ExtractResponseData, GetStatusConfigsResponse } from "@follow-app/client-sdk"
import PKG from "@pkg"
import { atom } from "jotai"
import { createAtomHooks } from "~/lib/jotai"
export const [, , useServerConfigs, , getServerConfigs, setServerConfigs] = createAtomHooks(
atom<Nullable<ServerConfigs>>(null),
atom<Nullable<ExtractResponseData<GetStatusConfigsResponse>>>(null),
)
export const useIsInMASReview = () => {

View File

@ -0,0 +1,98 @@
import { env } from "@follow/shared/env.desktop"
import { userActions } from "@follow/store/user/store"
import { createDesktopAPIHeaders } from "@follow/utils/headers"
import { FollowClient } from "@follow-app/client-sdk"
import PKG from "@pkg"
import { createElement } from "react"
import { toast } from "sonner"
import { NetworkStatus, setApiStatus } from "~/atoms/network"
import { setLoginModalShow } from "~/atoms/user"
import { NeedActivationToast } from "~/modules/activation/NeedActivationToast"
import { getClientId, getSessionId } from "./client-session"
export const followClient = new FollowClient({
credentials: "include",
timeout: 10000,
baseURL: env.VITE_API_URL,
fetch: async (input, options = {}) =>
fetch(input.toString(), {
...options,
cache: "no-store",
}),
})
export const followApi = followClient.api
followClient.addRequestInterceptor(async (ctx) => {
const { options } = ctx
const header = options.headers || {}
header["X-Client-Id"] = getClientId()
header["X-Session-Id"] = getSessionId()
const apiHeader = createDesktopAPIHeaders({ version: PKG.version })
options.headers = {
...header,
...apiHeader,
}
return ctx
})
followClient.addResponseInterceptor(({ response }) => {
setApiStatus(NetworkStatus.ONLINE)
return response
})
followClient.addErrorInterceptor(async ({ error, response }) => {
const { router } = window
// If api is down
if ((!response || response.status === 0) && navigator.onLine) {
setApiStatus(NetworkStatus.OFFLINE)
} else {
setApiStatus(NetworkStatus.ONLINE)
}
if (!response) {
return error
}
if (response.status === 401) {
// Or we can present LoginModal here.
// router.navigate("/login")
// If any response status is 401, we can set auth fail. Maybe some bug, but if navigate to login page, had same issues
setLoginModalShow(true)
userActions.removeCurrentUser()
}
try {
const json = await response.clone().json()
if (response.status === 400 && json.code === 1003) {
router.navigate("/invitation")
}
if (json.code.toString().startsWith("11")) {
setTimeout(() => {
const toastId = toast.error(
createElement(NeedActivationToast, {
dimiss: () => {
toast.dismiss(toastId)
},
}),
{
closeButton: true,
duration: 10e4,
classNames: {
content: tw`w-full`,
},
},
)
}, 500)
}
} catch {
// ignore
}
return error
})

View File

@ -1,11 +1,12 @@
import type { ServerConfigs } from "@follow/models/types"
import type { ExtractResponseData, GetStatusConfigsResponse } from "@follow-app/client-sdk"
import type { FC } from "react"
import { useFeature } from "~/hooks/biz/useFeature"
export const featureConfigMap: Record<string, keyof ServerConfigs> = {
ai: "AI_CHAT_ENABLED",
}
export const featureConfigMap: Record<string, keyof ExtractResponseData<GetStatusConfigsResponse>> =
{
ai: "AI_CHAT_ENABLED",
}
export const withFeature =
(feature: keyof typeof featureConfigMap) =>

View File

@ -4,9 +4,10 @@ import "./styles/main.css"
import { IN_ELECTRON, WEB_BUILD } from "@follow/shared/constants"
import {
apiClientSimpleContext,
authClientSimpleContext,
queryClientSimpleContext,
apiClientContext,
apiContext,
authClientContext,
queryClientContext,
} from "@follow/store/context"
import { getOS } from "@follow/utils/utils"
import * as React from "react"
@ -20,12 +21,14 @@ import { setAppIsReady } from "./atoms/app"
import { ElECTRON_CUSTOM_TITLEBAR_HEIGHT } from "./constants"
import { initializeApp } from "./initialize"
import { registerAppGlobalShortcuts } from "./initialize/global-shortcuts"
import { followApi } from "./lib/api-client"
import { queryClient } from "./lib/query-client"
import { router } from "./router"
apiClientSimpleContext.provide(apiClient)
authClientSimpleContext.provide(authClient)
queryClientSimpleContext.provide(queryClient)
apiClientContext.provide(apiClient)
authClientContext.provide(authClient)
queryClientContext.provide(queryClient)
apiContext.provide(followApi)
initializeApp().finally(() => {
import("./push-notification").then(({ registerWebPushNotifications }) => {

View File

@ -1,5 +1,4 @@
import type { tools as honoTools } from "@follow/shared/hono"
import type { Tool, UIDataTypes, UIMessage } from "ai"
import type { BizUITools, ToolWithState } from "@folo-services/ai-tools"
export interface AIChatContextBlock {
id: string
@ -18,35 +17,9 @@ export interface AIChatContextBlocks {
blocks: AIChatContextBlock[]
}
// TypeScript utility to transform Tool<Input, Output> to { input: Input, output: Output }
type TransformTool<T> =
T extends Tool<infer Input, infer Output>
? {
input: Input
output: Output
}
: never
// Transform the tools object to UITools format
type TransformTools<T> = {
[K in keyof T]: TransformTool<T[K]>
}
// Apply the transformation to the hono tools
export type BizUITools = TransformTools<typeof honoTools>
export type BizUIMetadata = {
startTime?: string
finishTime?: string
totalTokens?: number
duration?: number
}
export type BizUIMessage = UIMessage<BizUIMetadata, UIDataTypes, BizUITools>
type ToolWithState<T> = T & {
state: "input-streaming" | "input-available" | "output-available" | "output-error"
}
export type AIDisplayAnalyticsTool = ToolWithState<BizUITools["displayAnalytics"]>
export type AIDisplayFeedsTool = ToolWithState<BizUITools["displayFeeds"]>
export type AIDisplayEntriesTool = ToolWithState<BizUITools["displayEntries"]>
export type AIDisplaySubscriptionsTool = ToolWithState<BizUITools["displaySubscriptions"]>
export { type BizUIMessage, type BizUIMetadata } from "@folo-services/ai-tools"

View File

@ -1,5 +1,5 @@
import type { UserRole } from "@follow/constants"
import type { ServerConfigs } from "@follow/models"
import type { ExtractResponseData, GetStatusConfigsResponse } from "@follow-app/client-sdk"
export interface SettingPageContext {
role: Nullable<UserRole>
@ -17,10 +17,13 @@ export interface SettingPageConfig {
title?: I18nKeysForSettings
priority: number
headerIcon?: string | React.ReactNode
hideIf?: (ctx: SettingPageContext, serverConfigs?: ServerConfigs | null) => boolean
hideIf?: (
ctx: SettingPageContext,
serverConfigs?: ExtractResponseData<GetStatusConfigsResponse> | null,
) => boolean
disableIf?: (
ctx: SettingPageContext,
serverConfigs?: ServerConfigs | null,
serverConfigs?: ExtractResponseData<GetStatusConfigsResponse> | null,
) => [boolean, DisableWhy]
viewportClassName?: string
}

View File

@ -1,87 +0,0 @@
import { Button } from "@follow/components/ui/button/index.js"
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@follow/components/ui/form/index.jsx"
import { Input } from "@follow/components/ui/input/index.js"
import { subscriptionSyncService } from "@follow/store/subscription/store"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation } from "@tanstack/react-query"
import { useEffect } from "react"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { useCurrentModal } from "~/components/ui/modal/stacked/hooks"
import { apiClient } from "~/lib/api-fetch"
const formSchema = z.object({
category: z.string(),
})
export function CategoryRenameContent({
feedIdList,
onSuccess,
category,
}: {
feedIdList: string[]
onSuccess?: () => void
category: string
}) {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
category,
},
})
const renameMutation = useMutation({
mutationFn: async (values: z.infer<typeof formSchema>) =>
apiClient.categories.$patch({
json: {
feedIdList,
category: values.category,
},
}),
onSuccess: () => {
subscriptionSyncService.fetch()
onSuccess?.()
},
})
function onSubmit(values: z.infer<typeof formSchema>) {
renameMutation.mutate(values)
}
const { setClickOutSideToDismiss } = useCurrentModal()
useEffect(() => {
setClickOutSideToDismiss(!form.formState.isDirty)
}, [form.formState.isDirty])
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit" isLoading={renameMutation.isPending}>
Rename
</Button>
</div>
</form>
</Form>
)
}

View File

@ -3,6 +3,7 @@ import { useMobile } from "@follow/components/hooks/useMobile.js"
import { MotionButtonBase } from "@follow/components/ui/button/index.js"
import { LoadingCircle } from "@follow/components/ui/loading/index.jsx"
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
import { ShrinkingFocusBorder } from "@follow/components/ui/shrinking-focus-border/index.js"
import type { FeedViewType } from "@follow/constants"
import { views } from "@follow/constants"
import { useInputComposition, useRefValue } from "@follow/hooks"
@ -16,7 +17,7 @@ import { subscriptionActions, subscriptionSyncService } from "@follow/store/subs
import { getDefaultCategory } from "@follow/store/subscription/utils"
import { useSortedIdsByUnread, useUnreadByIds } from "@follow/store/unread/hooks"
import { unreadSyncService } from "@follow/store/unread/store"
import { stopPropagation } from "@follow/utils/dom"
import { nextFrame, stopPropagation } from "@follow/utils/dom"
import { cn, sortByAlphabet } from "@follow/utils/utils"
import { useMutation } from "@tanstack/react-query"
import { AnimatePresence, m } from "motion/react"
@ -410,6 +411,8 @@ const RenameCategoryForm: FC<{
},
})
const formRef = useRef<HTMLFormElement | null>(null)
const [isFocused, setIsFocused] = useState(false)
useOnClickOutside(
formRef as React.RefObject<HTMLElement>,
() => {
@ -419,7 +422,10 @@ const RenameCategoryForm: FC<{
)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
inputRef.current?.focus()
nextFrame(() => {
inputRef.current?.focus()
setIsFocused(true)
})
}, [])
const compositionInputProps = useInputComposition({
onKeyDown: (e) => {
@ -429,33 +435,37 @@ const RenameCategoryForm: FC<{
},
})
return (
<form
ref={formRef}
className="ml-3 flex w-full items-center"
onSubmit={(e) => {
e.preventDefault()
<div className="relative ml-3 flex h-8 w-full items-center">
<ShrinkingFocusBorder isVisible={isFocused} containerRef={inputRef} persistBorder />
<form
className="flex w-full items-center"
onSubmit={(e) => {
e.preventDefault()
return renameMutation.mutateAsync({
lastCategory: currentCategory!,
newCategory: e.currentTarget.category.value,
})
}}
>
<input
{...compositionInputProps}
ref={inputRef}
name="category"
autoFocus
defaultValue={currentCategory}
className="caret-accent w-full appearance-none bg-transparent"
/>
<MotionButtonBase
type="submit"
className="center hover:bg-material-ultra-thick text-green -mr-1 flex size-5 shrink-0 rounded-lg"
return renameMutation.mutateAsync({
lastCategory: currentCategory!,
newCategory: e.currentTarget.category.value,
})
}}
>
<i className="i-mgc-check-filled size-3" />
</MotionButtonBase>
</form>
<input
{...compositionInputProps}
ref={inputRef}
name="category"
autoFocus
defaultValue={currentCategory}
className="caret-accent w-full appearance-none bg-transparent px-2 py-1"
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
<MotionButtonBase
type="submit"
className="center hover:bg-material-ultra-thick text-green -mr-1 flex size-5 shrink-0 rounded-lg"
>
<i className="i-mgc-check-filled size-3" />
</MotionButtonBase>
</form>
</div>
)
}

View File

@ -1,11 +1,11 @@
import { useQuery } from "@tanstack/react-query"
import { apiClient } from "~/lib/api-fetch"
import { followApi } from "~/lib/api-client"
export const useServerConfigsQuery = () => {
const { data } = useQuery({
queryKey: ["server-configs"],
queryFn: () => apiClient.status.configs.$get(),
queryFn: () => followApi.status.getConfigs(),
})
return data?.data
}

View File

@ -44,7 +44,6 @@
"@electron-forge/plugin-fuses": "7.8.0",
"@electron-forge/publisher-github": "7.8.0",
"@electron-toolkit/tsconfig": "1.0.1",
"@follow-app/vite-plugin-route-builder": "workspace:*",
"@follow/components": "workspace:*",
"@follow/configs": "workspace:*",
"@follow/constants": "workspace:*",
@ -64,7 +63,7 @@
"bufferutil": "4.0.9",
"code-inspector-plugin": "0.20.15",
"cssnano": "7.0.7",
"drizzle-orm": "0.44.2",
"drizzle-orm": "0.44.3",
"electron": "37.2.0",
"electron-devtools-installer": "4.0.0",
"electron-packager-languages": "0.6.0",
@ -86,6 +85,7 @@
"vite-bundle-analyzer": "1.0.0",
"vite-plugin-mkcert": "1.17.8",
"vite-plugin-pwa": "1.0.1",
"vite-plugin-route-builder": "0.3.0",
"vite-tsconfig-paths": "5.1.4"
},
"productName": "Folo",

View File

@ -7,12 +7,12 @@ import { minify as htmlMinify } from "html-minifier-terser"
import { cyan, dim, green } from "kolorist"
import { parseHTML } from "linkedom"
import { resolve } from "pathe"
import { tsImport } from "tsx/esm/api"
import type { PluginOption, ResolvedConfig, ViteDevServer } from "vite"
import { defineConfig, loadEnv } from "vite"
import { analyzer } from "vite-bundle-analyzer"
import mkcert from "vite-plugin-mkcert"
import { VitePWA } from "vite-plugin-pwa"
import { routeBuilderPlugin } from "vite-plugin-route-builder"
import { viteRenderBaseConfig } from "./configs/vite.render.config"
import { createDependencyChunksPlugin } from "./plugins/vite/deps"
@ -21,11 +21,6 @@ import { localesPlugin } from "./plugins/vite/locales"
import manifestPlugin from "./plugins/vite/manifest"
import { createPlatformSpecificImportPlugin } from "./plugins/vite/specific-import"
const routeBuilderPluginV2 = await tsImport(
"@follow-app/vite-plugin-route-builder",
import.meta.url,
).then((m) => m.default)
const __dirname = fileURLToPath(new URL(".", import.meta.url))
const isCI = process.env.CI === "true" || process.env.CI === "1"
const ROOT = resolve(__dirname, "./layer/renderer")
@ -142,7 +137,7 @@ export default ({ mode }) => {
plugins: [
...((viteRenderBaseConfig.plugins ?? []) as any),
routeBuilderPluginV2({
routeBuilderPlugin({
pagePattern: "src/pages/**/*.tsx",
outputPath: "src/generated-routes.ts",
enableInDev: true,

View File

@ -43,7 +43,6 @@
"zod": "3.25.75"
},
"devDependencies": {
"@follow-app/vite-plugin-route-builder": "workspace:*",
"@follow/components": "workspace:*",
"@follow/configs": "workspace:*",
"@follow/constants": "workspace:*",
@ -70,6 +69,7 @@
"tsdown": "0.12.9",
"tsx": "4.20.3",
"typescript": "catalog:",
"vite": "7.0.2"
"vite": "7.0.2",
"vite-plugin-route-builder": "0.3.0"
}
}

View File

@ -3,17 +3,12 @@ import { fileURLToPath } from "node:url"
import react from "@vitejs/plugin-react"
import { codeInspectorPlugin } from "code-inspector-plugin"
import { dirname, resolve } from "pathe"
import { tsImport } from "tsx/esm/api"
import { defineConfig } from "vite"
import { routeBuilderPlugin } from "vite-plugin-route-builder"
import { viteRenderBaseConfig } from "../desktop/configs/vite.render.config"
import { astPlugin } from "../desktop/plugins/vite/ast"
const routeBuilderPluginV2 = await tsImport(
"@follow-app/vite-plugin-route-builder",
import.meta.url,
).then((m) => m.default)
const __dirname = dirname(fileURLToPath(import.meta.url))
export default defineConfig({
@ -38,7 +33,7 @@ export default defineConfig({
},
},
plugins: [
routeBuilderPluginV2({
routeBuilderPlugin({
pagePattern: "client/pages/**/*.tsx",
outputPath: "client/generated-routes.ts",
enableInDev: true,

View File

@ -0,0 +1,262 @@
import type { FC } from "react"
import { useEffect, useRef, useState } from "react"
import { RootPortal } from "../portal"
export interface ShrinkingFocusBorderProps {
isVisible: boolean
containerRef: React.RefObject<HTMLElement | null>
persistBorder?: boolean
radius?: number
}
export const ShrinkingFocusBorder: FC<ShrinkingFocusBorderProps> = ({
isVisible,
containerRef,
persistBorder = false,
radius = 6,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const animationFrameRef = useRef<number>(undefined)
const startTimeRef = useRef<number>(undefined)
const resizeObserverRef = useRef<ResizeObserver>(undefined)
const [currentRect, setCurrentRect] = useState<DOMRect | null>(null)
const [isAnimating, setIsAnimating] = useState(false)
const transitionStartRef = useRef<number>(undefined)
const previousRectRef = useRef<DOMRect | null>(null)
// Reset animation state when visibility changes
useEffect(() => {
if (isVisible) {
setIsAnimating(true)
previousRectRef.current = null
} else {
setIsAnimating(false)
setCurrentRect(null)
}
}, [isVisible])
// Setup resize observer for persistent border
useEffect(() => {
if (!persistBorder || !containerRef.current || !canvasRef.current) {
return
}
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const newRect = entry.target.getBoundingClientRect()
if (
currentRect &&
(Math.abs(newRect.width - currentRect.width) > 1 ||
Math.abs(newRect.height - currentRect.height) > 1 ||
Math.abs(newRect.left - currentRect.left) > 1 ||
Math.abs(newRect.top - currentRect.top) > 1)
) {
previousRectRef.current = currentRect
setCurrentRect(newRect)
transitionStartRef.current = Date.now()
}
}
})
observer.observe(containerRef.current)
resizeObserverRef.current = observer
return () => {
observer.disconnect()
}
}, [persistBorder, containerRef, currentRect])
useEffect(() => {
if (!isVisible || !containerRef.current || !canvasRef.current) {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
return
}
// Delay animation start to ensure proper positioning
const animationTimeout = setTimeout(() => {
const canvas = canvasRef.current
const container = containerRef.current
if (!canvas || !container) return
const ctx = canvas.getContext("2d")
if (!ctx) return
// Get fresh rect after DOM has settled
const rect = container.getBoundingClientRect()
setCurrentRect(rect)
// Canvas positioned fixed to viewport
canvas.width = rect.width + 100
canvas.height = rect.height + 100
// Position canvas relative to viewport
canvas.style.left = `${rect.left - 50}px`
canvas.style.top = `${rect.top - 50}px`
startTimeRef.current = Date.now()
const drawBorder = () => {
if (!ctx || !canvas) return
const now = Date.now()
const elapsed = (now - (startTimeRef.current || 0)) / 1000
const duration = 0.4 // Animation duration in seconds
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height)
let borderWidth = rect.width
let borderHeight = rect.height
let borderX = canvas.width / 2 - borderWidth / 2
let borderY = canvas.height / 2 - borderHeight / 2
if (isAnimating) {
if (elapsed >= duration) {
// Animation complete
setIsAnimating(false)
if (!persistBorder) {
// Stop animation completely for non-persistent border
return
}
// For persistent border, continue with final dimensions
borderWidth = rect.width
borderHeight = rect.height
borderX = canvas.width / 2 - borderWidth / 2
borderY = canvas.height / 2 - borderHeight / 2
} else {
// Initial shrinking animation in progress
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3)
const progress = Math.min(elapsed / duration, 1)
const easedProgress = easeOutCubic(progress)
const startWidth = rect.width + 80
const startHeight = rect.height + 80
borderWidth = startWidth - (startWidth - rect.width) * easedProgress
borderHeight = startHeight - (startHeight - rect.height) * easedProgress
borderX = canvas.width / 2 - borderWidth / 2
borderY = canvas.height / 2 - borderHeight / 2
}
} else if (persistBorder && previousRectRef.current && transitionStartRef.current) {
// Resize transition animation
const transitionElapsed = (now - transitionStartRef.current) / 1000
const transitionDuration = 0.3
if (transitionElapsed <= transitionDuration) {
const progress = Math.min(transitionElapsed / transitionDuration, 1)
const easeInOutCubic = (t: number) =>
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
const easedProgress = easeInOutCubic(progress)
const prevRect = previousRectRef.current
borderWidth = prevRect.width + (rect.width - prevRect.width) * easedProgress
borderHeight = prevRect.height + (rect.height - prevRect.height) * easedProgress
// Update canvas position during transition
const currentLeft = prevRect.left + (rect.left - prevRect.left) * easedProgress
const currentTop = prevRect.top + (rect.top - prevRect.top) * easedProgress
canvas.style.left = `${currentLeft - 50}px`
canvas.style.top = `${currentTop - 50}px`
canvas.width = borderWidth + 100
canvas.height = borderHeight + 100
borderX = canvas.width / 2 - borderWidth / 2
borderY = canvas.height / 2 - borderHeight / 2
if (progress >= 1) {
previousRectRef.current = null
transitionStartRef.current = undefined
}
}
} else if (persistBorder) {
// Update canvas position for current rect
canvas.style.left = `${rect.left - 50}px`
canvas.style.top = `${rect.top - 50}px`
canvas.width = rect.width + 100
canvas.height = rect.height + 100
borderX = canvas.width / 2 - borderWidth / 2
borderY = canvas.height / 2 - borderHeight / 2
}
// Draw border only if animating or persistBorder is true
if (isAnimating || persistBorder) {
// Get dynamic color from CSS variable
const computedStyle = getComputedStyle(document.documentElement)
const foColor = computedStyle.getPropertyValue("--fo-a").trim()
// Parse HSL string (e.g., "21.6 100% 50%") and convert to usable format
const hslMatch = foColor.match(/^(\d+(?:\.\d+)?)\s+(\d+)%\s+(\d+)%$/)
let strokeColor = "rgba(59, 130, 246, 0.8)" // fallback
let shadowColor = "rgba(59, 130, 246, 0.5)" // fallback
if (hslMatch) {
const [, h, s, l] = hslMatch
strokeColor = `hsla(${h}, ${s}%, ${l}%, 0.8)`
shadowColor = `hsla(${h}, ${s}%, ${l}%, 0.5)`
}
// Border style
ctx.strokeStyle = strokeColor
ctx.lineWidth = 2
ctx.shadowColor = shadowColor
ctx.shadowBlur = 6
// Draw rounded rectangle border
ctx.beginPath()
ctx.roundRect(borderX, borderY, borderWidth, borderHeight, radius)
ctx.stroke()
}
// Continue animation if needed
if (
isAnimating ||
(persistBorder && previousRectRef.current && transitionStartRef.current) ||
(persistBorder && elapsed > duration)
) {
animationFrameRef.current = requestAnimationFrame(drawBorder)
}
}
drawBorder()
}, 16) // One frame delay to ensure DOM positioning
return () => {
clearTimeout(animationTimeout)
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
}
}, [isVisible, containerRef, isAnimating, persistBorder, radius])
// Cleanup
useEffect(() => {
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect()
}
}
}, [])
if (!isVisible) return null
// If not persisting border and animation is complete, don't render
if (!persistBorder && !isAnimating) return null
return (
<RootPortal>
<canvas
ref={canvasRef}
className="pointer-events-none fixed z-50"
style={{ borderRadius: `${radius}px` }}
/>
</RootPortal>
)
}

View File

@ -11,6 +11,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@follow-app/client-sdk": "catalog:",
"@follow/configs": "workspace:*",
"@follow/types": "workspace:*"
}

View File

@ -1,12 +1,4 @@
export enum FeedViewType {
Articles = 0,
SocialMedia = 1,
Pictures = 2,
Videos = 3,
Audios = 4,
Notifications = 5,
}
export { FeedViewType } from "@follow-app/client-sdk"
export enum Routes {
Timeline = "/timeline",
Discover = "/discover",

View File

@ -32,11 +32,12 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@follow-app/client-sdk": "catalog:",
"@follow/constants": "workspace:*",
"@follow/models": "workspace:*",
"@follow/shared": "workspace:*",
"ai": "5.0.0-beta.7",
"drizzle-orm": "0.44.2",
"drizzle-orm": "0.44.3",
"expo-sqlite": "15.2.12",
"sqlocal": "npm:@hyoban/sqlocal@0.14.1-fork.4",
"wa-sqlite": "git+https://github.com/rhashimoto/wa-sqlite.git#v1.0.8"

View File

@ -1,6 +1,6 @@
import type { FeedViewType } from "@follow/constants"
import type { ActionSettings } from "@follow/models/types"
import type { SupportedActionLanguage } from "@follow/shared/language"
import type { EntrySettings } from "@follow-app/client-sdk"
import type { UIMessage } from "ai"
import { sql } from "drizzle-orm"
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"
@ -82,7 +82,6 @@ export const usersTable = sqliteTable("users", {
discord?: string
}>(),
})
export const entriesTable = sqliteTable("entries", {
id: text("id").primaryKey(),
title: text("title"),
@ -108,7 +107,7 @@ export const entriesTable = sqliteTable("entries", {
inboxHandle: text("inbox_handle"),
read: integer("read", { mode: "boolean" }),
sources: text("sources", { mode: "json" }).$type<string[]>(),
settings: text("settings", { mode: "json" }).$type<ActionSettings>(),
settings: text("settings", { mode: "json" }).$type<EntrySettings>(),
})
export const collectionsTable = sqliteTable("collections", {

View File

@ -20,6 +20,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@follow-app/client-sdk": "catalog:",
"@follow/constants": "workspace:*",
"@follow/shared": "workspace:*",
"@follow/types": "workspace:*",

View File

@ -65,8 +65,6 @@ export type EntriesResponse = Array<
| Exclude<Awaited<ReturnType<typeof _apiClient.entries.inbox.$post>>["data"], undefined>
>[number]
export type ActionSettings = Exclude<EntriesResponse[number]["settings"], undefined>
export type CombinedEntryModel = Omit<EntriesResponse[number], "feeds"> & {
entries: {
content?: string | null

View File

@ -38,7 +38,7 @@
"@t3-oss/env-core": "0.13.8",
"ai": "5.0.0-beta.6",
"better-auth": "1.2.9",
"drizzle-orm": "0.44.2",
"drizzle-orm": "0.44.3",
"hono": "4.8.1",
"sonner": "2.0.6",
"stripe": "18.2.1",

View File

@ -114,6 +114,7 @@
"react": "19.0.0"
},
"dependencies": {
"@follow-app/client-sdk": "catalog:",
"@follow/configs": "workspace:*",
"@follow/constants": "workspace:*",
"@follow/database": "workspace:*",

View File

@ -1,12 +1,12 @@
import type { AuthClient } from "@follow/shared/auth"
import type { QueryClient } from "@tanstack/react-query"
import type { APIClient } from "./types"
import type { APIClient, FollowAPI } from "./types"
const NO_VALUE_DEFAULT = Symbol("NO_VALUE_DEFAULT")
type ContextValue<T> = T | typeof NO_VALUE_DEFAULT
function createSimpleContext<T>() {
function createJSContext<T>() {
let contextValue: ContextValue<T> = NO_VALUE_DEFAULT
const provide = (value: T) => {
@ -26,9 +26,11 @@ function createSimpleContext<T>() {
}
}
export const apiClientSimpleContext = createSimpleContext<APIClient>()
export const authClientSimpleContext = createSimpleContext<AuthClient>()
export const queryClientSimpleContext = createSimpleContext<QueryClient>()
export const apiClient = apiClientSimpleContext.consumer
export const authClient = authClientSimpleContext.consumer
export const queryClient = queryClientSimpleContext.consumer
export const apiClientContext = createJSContext<APIClient>()
export const apiContext = createJSContext<FollowAPI>()
export const authClientContext = createJSContext<AuthClient>()
export const queryClientContext = createJSContext<QueryClient>()
export const apiClient = apiClientContext.consumer
export const api = apiContext.consumer
export const authClient = authClientContext.consumer
export const queryClient = queryClientContext.consumer

View File

@ -5,10 +5,11 @@ import { debounce } from "es-toolkit/compat"
import { clearAllFeedUnreadDirty, clearFeedUnreadDirty } from "../atoms/feed"
import { collectionActions } from "../collection/store"
import { apiClient } from "../context"
import { api, apiClient } from "../context"
import { feedActions } from "../feed/store"
import type { Hydratable, Resetable } from "../internal/base"
import { createImmerSetter, createTransaction, createZustandStore } from "../internal/helper"
import { apiMorph } from "../morph/api"
import { dbStoreMorph } from "../morph/db-store"
import { honoMorph } from "../morph/hono"
import { storeDbMorph } from "../morph/store-db"
@ -471,25 +472,21 @@ class EntrySyncServices {
})
const res = params.inboxId
? await apiClient().entries.inbox.$post({
json: {
publishedAfter: pageParam,
read,
limit,
isCollection,
inboxId: params.inboxId,
...params,
},
? await api().entries.inbox.list({
publishedAfter: pageParam,
read,
limit,
isCollection,
inboxId: params.inboxId,
...params,
})
: await apiClient().entries.$post({
json: {
publishedAfter: pageParam,
read,
limit,
isCollection,
excludePrivate,
...params,
},
: await api().entries.list({
publishedAfter: pageParam,
read,
limit,
isCollection,
excludePrivate,
...params,
})
// Mark feed unread dirty, so re-fetch the unread data when view feed unread entires in the next time
@ -507,7 +504,7 @@ class EntrySyncServices {
}
}
const entries = honoMorph.toEntryList(res.data)
const entries = apiMorph.toEntryList(res.data)
const entriesInDB = await EntryService.getEntryMany(entries.map((e) => e.id))
for (const entry of entries) {
const entryInDB = entriesInDB.find((e) => e.id === entry.id)
@ -521,7 +518,7 @@ class EntrySyncServices {
await entryActions.upsertMany(entries)
if (typeof view === "number") {
const { collections, entryIdsNotInCollections } = honoMorph.toCollections(res.data, view)
const { collections, entryIdsNotInCollections } = apiMorph.toCollections(res.data, view)
await collectionActions.upsertMany(collections, {
reset: params.isCollection && !pageParam,
})
@ -529,7 +526,7 @@ class EntrySyncServices {
}
const dataFeeds = res.data?.map((e) => e.feeds).filter((f) => f.type === "feed")
const feeds = dataFeeds?.map((f) => honoMorph.toFeed(f)) ?? []
const feeds = dataFeeds?.map((f) => apiMorph.toFeed(f)) ?? []
const users = dataFeeds?.flatMap((f) => f.tipUsers).filter((u) => !!u) ?? []
feedActions.upsertMany(feeds)
userActions.upsertMany(

View File

@ -0,0 +1,184 @@
import type { FeedSchema, InboxSchema } from "@follow/database/schemas/types"
import type {
EntryListResponse,
EntryWithFeed,
ExtractResponseData,
FeedViewType,
InboxListEntry,
InboxListEntryResponse,
InboxSubscriptionResponse,
ListSubscriptionResponse,
SubscriptionWithFeed,
} from "@follow-app/client-sdk"
import type { CollectionModel } from "../collection/types"
import type { EntryModel } from "../entry/types"
import type { FeedModel } from "../feed/types"
import type { ListModel } from "../list/types"
import type { SubscriptionModel } from "../subscription/types"
class APIMorph {
toSubscription(
data: (SubscriptionWithFeed | ListSubscriptionResponse | InboxSubscriptionResponse)[],
) {
const subscriptions: SubscriptionModel[] = []
const collections = {
feeds: [],
inboxes: [],
lists: [],
} as {
feeds: FeedSchema[]
inboxes: InboxSchema[]
lists: ListModel[]
}
for (const item of data) {
const baseSubscription = {
category: item.category!,
userId: item.userId,
view: item.view,
isPrivate: item.isPrivate,
title: item.title,
createdAt: item.createdAt,
} as SubscriptionModel
if ("feeds" in item) {
baseSubscription.feedId = item.feedId
baseSubscription.type = "feed"
const feed = item.feeds
collections.feeds.push({
description: feed.description!,
id: feed.id,
errorAt: feed.errorAt!,
errorMessage: feed.errorMessage!,
image: feed.image!,
ownerUserId: feed.ownerUserId!,
siteUrl: feed.siteUrl!,
title: feed.title!,
url: feed.url,
})
}
if ("inboxes" in item) {
baseSubscription.inboxId = item.inboxId
baseSubscription.type = "inbox"
const inbox = item.inboxes
collections.inboxes.push({
id: inbox.id,
title: inbox.title,
secret: inbox.secret,
})
}
if ("lists" in item) {
baseSubscription.listId = item.listId
baseSubscription.type = "list"
const list = item.lists
if (list.owner)
collections.lists.push({
id: list.id,
title: list.title!,
userId: list.owner!.id,
description: list.description!,
view: list.view,
image: list.image!,
ownerUserId: list.owner.id,
feedIds: list.feedIds!,
fee: list.fee!,
subscriptionCount: null,
purchaseAmount: null,
type: "list",
})
}
subscriptions.push(baseSubscription)
}
return { subscriptions, collections }
}
toCollections(
data: ExtractResponseData<InboxListEntryResponse | EntryListResponse>,
view: FeedViewType,
): {
collections: CollectionModel[]
entryIdsNotInCollections: string[]
} {
if (!data) return { collections: [], entryIdsNotInCollections: [] }
const collections: CollectionModel[] = []
const entryIdsNotInCollections: string[] = []
for (const item of data) {
if (!("collections" in item)) {
entryIdsNotInCollections.push((item as EntryWithFeed).entries.id)
continue
}
if (item.collections)
collections.push({
createdAt: item.collections.createdAt,
entryId: item.entries.id,
feedId: item.feeds.id,
view,
})
}
return {
collections,
entryIdsNotInCollections,
}
}
toEntryList(data?: InboxListEntry[] | EntryWithFeed[]): EntryModel[] {
const entries: EntryModel[] = []
for (const item of data ?? []) {
entries.push({
id: item.entries.id,
title: item.entries.title,
url: item.entries.url,
content: null,
readabilityContent: null,
description: item.entries.description,
guid: item.entries.guid,
author: item.entries.author,
authorUrl: item.entries.authorUrl,
authorAvatar: item.entries.authorAvatar,
insertedAt: new Date(item.entries.insertedAt),
publishedAt: new Date(item.entries.publishedAt),
media: item.entries.media ?? null,
categories: item.entries.categories ?? null,
attachments: item.entries.attachments ?? null,
extra: item.entries.extra
? {
links: item.entries.extra.links ?? undefined,
}
: null,
language: item.entries.language,
feedId: item.feeds.id,
inboxHandle: item.feeds.type === "inbox" ? item.feeds.id : null,
read: item.read,
sources: "from" in item ? (item.from ?? null) : null,
settings: item.settings ?? null,
})
}
return entries
}
toFeed(data: EntryWithFeed["feeds"]): FeedModel {
return {
type: "feed",
id: data.id,
title: data.title,
url: data.url,
image: data.image,
description: data.description,
ownerUserId: data.ownerUserId,
errorAt: data.errorAt,
errorMessage: data.errorMessage,
siteUrl: data.siteUrl,
tipUserIds: data.tipUsers ? data.tipUsers.map((user) => user.id) : [],
}
}
}
export const apiMorph = new APIMorph()

View File

@ -1,95 +1,13 @@
import type { FeedViewType } from "@follow/constants"
import type { FeedSchema, InboxSchema } from "@follow/database/schemas/types"
import type { CollectionModel } from "../collection/types"
import type { EntryModel } from "../entry/types"
import type { FeedModel } from "../feed/types"
import type { ListModel } from "../list/types"
import type { SubscriptionModel } from "../subscription/types"
import type { MeModel } from "../user/store"
import type { HonoApiClient } from "./types"
class Morph {
toSubscription(data: HonoApiClient.Subscription_Get) {
const subscriptions: SubscriptionModel[] = []
// TODO list inbox
const collections = {
feeds: [],
inboxes: [],
lists: [],
} as {
feeds: FeedSchema[]
inboxes: InboxSchema[]
lists: ListModel[]
}
for (const item of data) {
const baseSubscription = {
category: item.category!,
userId: item.userId,
view: item.view,
isPrivate: item.isPrivate,
title: item.title,
createdAt: item.createdAt,
} as SubscriptionModel
if ("feeds" in item) {
baseSubscription.feedId = item.feedId
baseSubscription.type = "feed"
const feed = item.feeds
collections.feeds.push({
description: feed.description!,
id: feed.id,
errorAt: feed.errorAt!,
errorMessage: feed.errorMessage!,
image: feed.image!,
ownerUserId: feed.ownerUserId!,
siteUrl: feed.siteUrl!,
title: feed.title!,
url: feed.url,
})
}
if ("inboxes" in item) {
baseSubscription.inboxId = item.inboxId
baseSubscription.type = "inbox"
const inbox = item.inboxes
collections.inboxes.push({
id: inbox.id,
title: inbox.title,
secret: inbox.secret,
})
}
if ("lists" in item) {
baseSubscription.listId = item.listId
baseSubscription.type = "list"
const list = item.lists
if (list.owner)
collections.lists.push({
id: list.id,
title: list.title!,
userId: list.owner!.id,
description: list.description!,
view: list.view,
image: list.image!,
ownerUserId: list.owner.id,
feedIds: list.feedIds!,
fee: list.fee!,
subscriptionCount: null,
purchaseAmount: null,
type: "list",
})
}
subscriptions.push(baseSubscription)
}
return { subscriptions, ...collections }
}
/**
* @deprecated
*/
class LegacyHonoMorph {
toList(data: HonoApiClient.List_Get["list"] | HonoApiClient.List_List_Get): ListModel {
return {
id: data.id,
@ -110,71 +28,6 @@ class Morph {
}
}
toEntryList(data?: HonoApiClient.Entry_Post | HonoApiClient.Entry_Inbox_Post): EntryModel[] {
const entries: EntryModel[] = []
for (const item of data ?? []) {
entries.push({
id: item.entries.id,
title: item.entries.title,
url: item.entries.url,
content: null,
readabilityContent: null,
description: item.entries.description,
guid: item.entries.guid,
author: item.entries.author,
authorUrl: item.entries.authorUrl,
authorAvatar: item.entries.authorAvatar,
insertedAt: new Date(item.entries.insertedAt),
publishedAt: new Date(item.entries.publishedAt),
media: item.entries.media ?? null,
categories: item.entries.categories ?? null,
attachments: item.entries.attachments ?? null,
extra: item.entries.extra
? {
links: item.entries.extra.links ?? undefined,
}
: null,
language: item.entries.language,
feedId: item.feeds.id,
inboxHandle: item.feeds.type === "inbox" ? item.feeds.id : null,
read: item.read,
sources: "from" in item ? (item.from ?? null) : null,
settings: item.settings ?? null,
})
}
return entries
}
toCollections(
data: HonoApiClient.Entry_Post | HonoApiClient.Entry_Inbox_Post | undefined,
view: FeedViewType,
): {
collections: CollectionModel[]
entryIdsNotInCollections: string[]
} {
if (!data) return { collections: [], entryIdsNotInCollections: [] }
const collections: CollectionModel[] = []
const entryIdsNotInCollections: string[] = []
for (const item of data) {
if (!item.collections) {
entryIdsNotInCollections.push(item.entries.id)
continue
}
collections.push({
createdAt: item.collections.createdAt,
entryId: item.entries.id,
feedId: item.feeds.id,
view,
})
}
return {
collections,
entryIdsNotInCollections,
}
}
toEntry(data?: HonoApiClient.Entry_Get | HonoApiClient.Entry_Inbox_Get): EntryModel | null {
if (!data) return null
@ -240,5 +93,7 @@ class Morph {
}
}
}
export const honoMorph = new Morph()
/**
* @deprecated
*/
export const honoMorph = new LegacyHonoMorph()

View File

@ -3,7 +3,7 @@ import { SubscriptionService } from "@follow/database/services/subscription"
import { tracker } from "@follow/tracker"
import { omit } from "es-toolkit"
import { apiClient } from "../context"
import { api } from "../context"
import { invalidateEntriesQuery } from "../entry/hooks"
import { getFeedById } from "../feed/getter"
import { feedActions } from "../feed/store"
@ -12,8 +12,8 @@ import type { Hydratable, Resetable } from "../internal/base"
import { createImmerSetter, createTransaction, createZustandStore } from "../internal/helper"
import { getListById } from "../list/getters"
import { listActions } from "../list/store"
import { apiMorph } from "../morph/api"
import { dbStoreMorph } from "../morph/db-store"
import { honoMorph } from "../morph/hono"
import { buildSubscriptionDbId, storeDbMorph } from "../morph/store-db"
import { whoami } from "../user/getters"
import { getCategoryFeedIds } from "./getter"
@ -182,30 +182,23 @@ class SubscriptionActions implements Hydratable, Resetable {
class SubscriptionSyncService {
async fetch(view?: FeedViewType) {
const res = await apiClient().subscriptions.$get({
query: {
view: view !== undefined ? String(view) : undefined,
},
const { data } = await api().subscriptions.get({
view: view !== undefined ? view : undefined,
})
const { subscriptions, feeds, lists, inboxes } = honoMorph.toSubscription(res.data)
const { subscriptions, collections } = apiMorph.toSubscription(data)
await SubscriptionService.deleteNotExists(
subscriptions.map((s) => buildSubscriptionDbId(s)),
view,
)
feedActions.upsertMany(feeds)
feedActions.upsertMany(collections.feeds)
subscriptionActions.upsertMany(subscriptions, {
resetBeforeUpsert: typeof view === "number" ? view : true,
})
listActions.upsertMany(lists)
listActions.upsertMany(collections.lists)
inboxActions.upsertMany(inboxes)
inboxActions.upsertMany(collections.inboxes)
return {
subscriptions,
feeds,
feeds: collections.feeds,
}
}
@ -251,15 +244,13 @@ class SubscriptionSyncService {
})
})
tx.request(async () => {
await apiClient().subscriptions.$patch({
json: {
view: subscription.view,
feedId: subscription.feedId ?? undefined,
isPrivate: subscription.isPrivate ?? undefined,
listId: subscription.listId ?? undefined,
category: subscription.category ?? undefined,
title: subscription.title ?? undefined,
},
await api().subscriptions.update({
view: subscription.view,
feedId: subscription.feedId ?? undefined,
isPrivate: subscription.isPrivate ?? undefined,
listId: subscription.listId ?? undefined,
category: subscription.category ?? undefined,
title: subscription.title ?? undefined,
})
})
@ -271,15 +262,14 @@ class SubscriptionSyncService {
}
async subscribe(subscription: SubscriptionForm) {
const data = await apiClient().subscriptions.$post({
json: {
url: subscription.url,
view: subscription.view,
category: subscription.category,
isPrivate: subscription.isPrivate,
title: subscription.title,
listId: subscription.listId,
},
const { data } = await api().subscriptions.create({
url: subscription.url,
view: subscription.view,
category: subscription.category,
isPrivate: subscription.isPrivate,
title: subscription.title,
listId: subscription.listId,
})
if (data.feed) {
@ -346,11 +336,9 @@ class SubscriptionSyncService {
tx.request(async () => {
const feedIdList = feedSubscriptions.map((s) => s.feedId).filter((i) => typeof i === "string")
await apiClient().subscriptions.$delete({
json: {
feedIdList: feedIdList.length > 0 ? feedIdList : undefined,
listId: listSubscriptions.at(0)?.listId || undefined,
},
await api().subscriptions.delete({
feedIdList: feedIdList.length > 0 ? feedIdList : undefined,
listId: listSubscriptions.at(0)?.listId || undefined,
})
})
@ -426,12 +414,10 @@ class SubscriptionSyncService {
})
tx.request(async () => {
await apiClient().subscriptions.batch.$patch({
json: {
feedIds,
category: newCategory,
view: newView,
},
await api().subscriptions.batchUpdate({
feedIds,
category: newCategory,
view: newView,
})
})
@ -494,11 +480,9 @@ class SubscriptionSyncService {
})
tx.request(async () => {
await apiClient().subscriptions.$patch({
json: {
view,
listId,
},
await api().subscriptions.update({
view,
listId,
})
})
@ -542,11 +526,9 @@ class SubscriptionSyncService {
})
tx.request(async () => {
await apiClient().categories.$delete({
json: {
feedIdList: feedIds,
deleteSubscriptions: false,
},
await api().categories.delete({
feedIdList: feedIds,
deleteSubscriptions: false,
})
})
@ -626,11 +608,9 @@ class SubscriptionSyncService {
})
tx.request(async () => {
await apiClient().categories.$patch({
json: {
feedIdList: feedIds,
category: newCategory,
},
await api().categories.update({
feedIdList: feedIds,
category: newCategory,
})
})

View File

@ -1,4 +1,5 @@
import type { AppType } from "@follow/shared/hono"
import type { ModuleAPIs } from "@follow-app/client-sdk"
import type { hc } from "hono/client"
export type APIClient = ReturnType<typeof hc<AppType>>
@ -11,3 +12,5 @@ export type GeneralMutationOptions = {
export type GeneralQueryOptions = {
enabled?: boolean
}
export type FollowAPI = ModuleAPIs

View File

@ -1,92 +0,0 @@
# Vite Plugin Route Builder - Tests
This document describes the test suite for the `@follow/vite-plugin-route-builder` package.
## Test Structure
### Core Route Building (`src/__tests__/route-builder.test.ts`)
Tests the core route building logic from `utils/route-builder.ts`:
- **Simple file structure**: Basic index and named pages
- **Grouped routes**: Routes with `(group)` syntax
- **Nested routes**: Multi-level directory structures
- **Dynamic routes**: `[param]` and `[...spread]` syntax
- **Sync loading**: `.sync.tsx` files that should be loaded synchronously
- **File prioritization**: Sync files take precedence over async files
### Vite Plugin Integration (`src/__tests__/vite-plugin.test.ts`)
Tests the Vite plugin interface and lifecycle:
- **Plugin creation**: Default and custom options
- **File discovery**: Finding page files with glob patterns
- **Build lifecycle**: `configResolved` and `buildStart` hooks
- **Dev mode**: File watching and hot reload
- **Code generation**: Output file creation and logging
### Code Generation (`src/__tests__/code-generation.test.ts`)
Tests the route code generation logic:
- **Import statements**: Sync (`import * as`) vs async (`const lazy = () => import()`)
- **Route transformation**: Converting internal structures to React Router format
- **String processing**: Template replacement and cleanup
- **File content**: Complete generated file structure
### Route Builder Utils (`src/utils/route-builder.test.ts`)
Tests the utility functions:
- **Basic routing**: Simple file-to-route mapping
- **Sync flag**: Ensuring `.sync.tsx` files get marked correctly
## Test Features
### Mocking
- File system operations (`writeFileSync`)
- Fast-glob for file discovery
- Vite plugin hooks and configuration
### Sync vs Async Testing
Key feature of the plugin is distinguishing between:
- **Async files** (`.tsx`): Lazy loaded with `() => import()`
- **Sync files** (`.sync.tsx`): Synchronously imported with `import * as`
### Code Generation Validation
Tests ensure generated code:
- Has correct import syntax for both sync and async
- Removes undefined loader properties
- Formats route objects properly
- Includes necessary TypeScript annotations
## Running Tests
```bash
# Run all tests
npm run test
# Run tests in watch mode
npm run test:run
# Run specific test file
npm run test:run src/__tests__/route-builder.test.ts
```
## Test Coverage
The test suite covers:
- ✅ Core route building logic
- ✅ Plugin lifecycle and integration
- ✅ Code generation and templating
- ✅ Sync vs async file handling
- ✅ Error scenarios and edge cases
- ✅ File watching and dev mode
All tests use Vitest with Node.js environment for proper file system mocking.

View File

@ -1,94 +0,0 @@
import { defineConfig } from "vite"
import { routeBuilderPluginV2 } from "./src/vite-plugin-route-builder"
export default defineConfig({
plugins: [
routeBuilderPluginV2({
// Glob pattern for page files
pagePattern: "./src/pages/**/*.{tsx,sync.tsx}",
// Output path for generated routes
outputPath: "./src/generated-routes.ts",
// Enable in dev mode for hot reload
enableInDev: true,
// Debug logging
debug: true,
// Custom segment group ordering: main routes first, then admin, then external
// Both formats supported: with or without parentheses
segmentGroupOrder: ["(main)", "(admin)", "(external)"],
// Optional: transform file paths
transformPath: (path: string) => {
// Example: rename admin pages
return path.replace("/admin/", "/dashboard/")
},
}),
],
})
/*
File structure example:
src/pages/
index.tsx / (lazy loaded)
about.tsx /about (lazy loaded)
critical.sync.tsx /critical (sync loaded)
settings/
layout.tsx /settings (lazy loaded)
index.tsx /settings/ (lazy loaded)
profile.sync.tsx /settings/profile (sync loaded)
(main)/ Route group (sorted first due to segmentGroupOrder)
layout.tsx / (lazy loaded)
dashboard.tsx /dashboard (lazy loaded)
(admin)/ Route group (sorted second due to segmentGroupOrder)
layout.tsx / (lazy loaded)
users.tsx /users (lazy loaded)
(external)/ Route group (sorted third due to segmentGroupOrder)
layout.tsx / (lazy loaded)
api-docs.tsx /api-docs (lazy loaded)
Generated output (route groups ordered by segmentGroupOrder):
import * as SyncComponent0 from "./pages/critical.sync"
import * as SyncComponent1 from "./pages/settings/profile.sync"
const lazy0 = () => import("./pages/index")
const lazy1 = () => import("./pages/about")
const lazy2 = () => import("./pages/(main)/layout")
const lazy3 = () => import("./pages/(admin)/layout")
const lazy4 = () => import("./pages/(external)/layout")
// ... more imports
export const routes: RouteObject[] = [
// Non-grouped routes first
{
path: "",
lazy: lazy0,
},
{
path: "critical",
Component: SyncComponent0.Component,
loader: SyncComponent0.loader,
},
// Route groups ordered by segmentGroupOrder: main, admin, external
{
path: "",
lazy: lazy2, // (main) group layout
children: [] // main routes
},
{
path: "",
lazy: lazy3, // (admin) group layout
children: [] // admin routes
},
{
path: "",
lazy: lazy4, // (external) group layout
children: [] // external routes
},
// ... more routes
]
*/

View File

@ -1,41 +0,0 @@
{
"name": "@follow-app/vite-plugin-route-builder",
"type": "module",
"version": "0.1.1",
"exports": {
".": "./src/index.ts"
},
"main": "src/index.ts",
"types": "src/index.ts",
"files": [
"src"
],
"scripts": {
"build": "tsdown",
"test": "vitest",
"test:run": "vitest run"
},
"devDependencies": {
"@types/node": "24.0.10",
"es-toolkit": "1.39.6",
"fast-glob": "3.3.3",
"happy-dom": "18.0.1",
"react-router": "7.6.3",
"tsdown": "0.12.9",
"typescript": "catalog:",
"vite": "7.0.2",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4"
},
"publishConfig": {
"access": "public",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
}
}

View File

@ -1,472 +0,0 @@
# Vite Route Builder Plugin - Technical Documentation
## Overview
The Vite Route Builder plugin is a build-time code generation tool that creates React Router route configurations from a file system-based routing structure, similar to Next.js App Router. It transforms a `pages/` directory structure into optimized route objects with lazy loading and proper component imports.
## Core Architecture
### Plugin Flow
1. **Build-time Generation**: The plugin runs during Vite's build process to generate static route configurations
2. **File System Scanning**: Uses glob patterns to discover page and layout files
3. **Route Tree Building**: Leverages the existing `route-builder.ts` logic to create route hierarchy
4. **Code Generation**: Produces a TypeScript file with lazy-loaded route objects
### Key Components
- **`vite-plugin-route-builder.ts`**: Main Vite plugin implementation
- **`route-builder.ts`**: Core routing logic for transforming file paths to route objects
- **`generated-routes.ts`**: Auto-generated output file containing route configurations
## File System Routing Conventions
### Directory Structure
```
pages/
├── (main)/ # Route group (doesn't affect URL path)
│ ├── layout.tsx # Layout component for grouped routes
│ ├── index.tsx # / route
│ └── discover.tsx # /discover route
├── settings/
│ ├── layout.tsx # Layout for /settings/*
│ ├── index.tsx # /settings route
│ └── profile.tsx # /settings/profile route
├── (settings)/ # Another route group
│ ├── layout.tsx # Layout component
│ └── general.tsx # /general route
└── [...404].tsx # Catch-all route
```
### Route Mapping Rules
1. **Index Routes**: `index.tsx` files create routes for their parent directory path
2. **Named Routes**: File names become route paths (e.g., `profile.tsx``/profile`)
3. **Grouped Routes**: Directories with parentheses `(name)` group routes without affecting URL paths
4. **Dynamic Routes**: `[param].tsx` creates dynamic parameter routes
5. **Catch-all Routes**: `[...name].tsx` creates catch-all routes
6. **Layouts**: `layout.tsx` files provide wrapper components for child routes
7. **Sync Loading**: Files with `.sync.tsx` extension are loaded synchronously instead of lazy loading
### Synchronous vs Asynchronous Loading
The plugin supports two loading strategies:
- **Asynchronous Loading (default)**: Files with `.tsx` extension are lazy-loaded for optimal performance
- **Synchronous Loading**: Files with `.sync.tsx` extension are imported directly and loaded synchronously
```
pages/
├── home.tsx # Lazy loaded: const lazy1 = () => import("./pages/home")
├── critical.sync.tsx # Sync loaded: import SyncComponent1 from "./pages/critical"
└── settings/
├── layout.sync.tsx # Sync loaded layout
└── profile.tsx # Lazy loaded page
```
#### When to Use Sync Loading
Use `.sync.tsx` extension for:
- Critical above-the-fold components
- Small, lightweight components that don't benefit from code splitting
- Components that need to be immediately available (no loading state)
- Layout components that are always needed
## Implementation Details
### 1. Route Discovery Process
```typescript
// Plugin discovers files using glob patterns
const pageFiles = glob.sync("./src/pages/**/*.{ts,tsx}", {
ignore: ["**/*.d.ts", "**/*.test.*", "**/*.spec.*"],
})
```
### 2. Route Tree Generation
The plugin uses the existing `route-builder.ts` logic:
```typescript
import { buildRoute } from "../route-builder"
// Transform file paths to route objects
const routes = buildRoute(pageFiles)
```
### 3. Path Resolution Strategy
The plugin implements sophisticated path matching to connect route objects with their corresponding files:
```typescript
function findFileForRoute(route: RouteObject, pageFiles: string[]): string | null {
const routePath = route.handle?.fs as string
if (!routePath) return null
// Strategy 1: Direct file match (fs.tsx)
let targetFile = `./src/pages/${routePath}.tsx`
if (pageFiles.includes(targetFile)) return targetFile
// Strategy 2: Layout file for grouped routes (fs/layout.tsx)
targetFile = `./src/pages/${routePath}/layout.tsx`
if (pageFiles.includes(targetFile)) return targetFile
// Strategy 3: Index file (fs/index.tsx)
targetFile = `./src/pages/${routePath}/index.tsx`
if (pageFiles.includes(targetFile)) return targetFile
// Strategy 4: Handle paths ending with '/' (index pages)
if (routePath.endsWith("/")) {
const cleanPath = routePath.slice(0, -1)
targetFile = `./src/pages/${cleanPath}/index.tsx`
if (pageFiles.includes(targetFile)) return targetFile
}
return null
}
```
### 4. Loading Strategy Implementation
The plugin generates different import strategies based on file extensions:
#### Asynchronous Loading (.tsx files)
```typescript
// Generate lazy imports for .tsx files
const lazyComponents = new Map<string, string>()
let lazyCounter = 1
function collectLazyFunctions(route: RouteObject, pageFiles: string[]) {
const file = findFileForRoute(route, pageFiles)
if (file && !file.endsWith(".sync.tsx") && !lazyComponents.has(file)) {
const relativePath = path.relative(
path.dirname("./src/generated-routes.ts"),
file.replace("./src/", "./src/"),
)
const varName = `LazyComponent${lazyCounter++}`
lazyComponents.set(file, varName)
return `const ${varName} = () => import("${relativePath}")`
}
}
```
#### Synchronous Loading (.sync.tsx files)
```typescript
// Generate direct imports for .sync.tsx files
const syncComponents = new Map<string, string>()
let syncCounter = 1
function collectSyncImports(route: RouteObject, pageFiles: string[]) {
const file = findFileForRoute(route, pageFiles)
if (file && file.endsWith(".sync.tsx") && !syncComponents.has(file)) {
const relativePath = path.relative(
path.dirname("./src/generated-routes.ts"),
file.replace("./src/", "./src/"),
)
const varName = `SyncComponent${syncCounter++}`
syncComponents.set(file, varName)
return `import ${varName} from "${relativePath}"`
}
}
```
#### Route Object Assignment
Routes use different properties based on loading strategy:
```typescript
function assignComponentToRoute(route: RouteObject, file: string) {
if (file.endsWith(".sync.tsx")) {
// Sync components use Component property
route.Component = syncComponents.get(file)
} else {
// Async components use lazy property
route.lazy = lazyComponents.get(file)
}
}
```
### 5. Code Generation
The final step generates a complete TypeScript file:
```typescript
const output = `
// Do not edit manually
/* eslint-disable */
// @ts-nocheck
import type { RouteObject } from "react-router"
import { lazy } from "react"
${lazyImports.join("\n")}
export const routes: RouteObject[] = ${serializedRoutes}
`
```
## Route Object Structure
### Generated Route Format
```typescript
interface RouteObject {
path?: string
index?: boolean
children?: RouteObject[]
element?: React.ComponentType
// Internal properties removed in final output
handle?: { fs: string } // Removed during serialization
}
```
### Example Generated Output
```typescript
// Synchronous imports (loaded immediately)
import SyncComponent1 from "./pages/(main)/layout"
import SyncComponent2 from "./pages/critical-page"
// Asynchronous imports (lazy loaded)
const LazyComponent1 = () => import("./pages/(main)/index")
const LazyComponent2 = () => import("./pages/settings/layout")
const LazyComponent3 = () => import("./pages/settings/profile")
export const routes: RouteObject[] = [
{
path: "/",
Component: SyncComponent1, // Sync loaded layout
children: [
{
index: true,
lazy: LazyComponent1, // Lazy loaded page
},
{
path: "critical",
Component: SyncComponent2, // Sync loaded critical page
},
],
},
{
path: "/settings",
lazy: LazyComponent2, // Lazy loaded layout
children: [
{
path: "profile",
lazy: LazyComponent3, // Lazy loaded page
},
],
},
]
```
## Problem Solving Approach
### Common Issues and Solutions
1. **Path Mismatch**: Routes generated without corresponding lazy functions
- **Solution**: Enhanced file matching with multiple strategies and path normalization
2. **Incorrect Import Paths**: Absolute paths causing import failures
- **Solution**: Proper relative path calculation using `path.relative()`
3. **Unused Lazy Variables**: Too many lazy imports for non-existent files
- **Solution**: Only generate lazy imports for routes with actual file matches
4. **Index Route Handling**: Paths ending with `/` causing mapping issues
- **Solution**: Special handling for index pages and path cleaning
### Debugging Strategy
The plugin includes comprehensive logging for troubleshooting:
```typescript
console.log("📁 Page files found:", pageFiles.length)
console.log("🎯 Routes with lazy functions:", routesWithLazy)
console.log("📝 Generated lazy imports:", lazyComponents.size)
```
## Performance Considerations
### Build-time Optimization
- **Static Generation**: All routing logic runs at build time, not runtime
- **Lazy Loading**: Components are loaded on-demand, reducing initial bundle size
- **Tree Shaking**: Unused routes and components are eliminated during bundling
### Memory Efficiency
- **File Caching**: Plugin processes files once and caches results
- **Selective Import**: Only imports files that are actually used in routes
## Configuration Options
The plugin accepts the following configuration options:
### RouteBuilderPluginOptions
```typescript
interface RouteBuilderPluginOptions {
/** Page files glob pattern */
pagePattern?: string
/** Output path for generated routes */
outputPath?: string
/** Whether to enable in dev mode */
enableInDev?: boolean
/** Custom file to route path transformation logic */
transformPath?: (path: string) => string
/** Whether to disable logging */
debug?: boolean
/** Custom order for segment groups in route tree. Array of group names (without parentheses). Default: filesystem order */
segmentGroupOrder?: string[]
}
```
### Option Descriptions
- **`pagePattern`** (default: `"./pages/**/*.{tsx,sync.tsx}"`): Glob pattern for discovering page files
- **`outputPath`** (default: `"./src/generated-routes.ts"`): Output path for the generated route configuration
- **`enableInDev`** (default: `true`): Whether to enable route generation in development mode
- **`transformPath`**: Custom function to transform file paths before route generation
- **`debug`** (default: `false`): Enable detailed logging for troubleshooting
- **`segmentGroupOrder`** (default: `[]`): Custom ordering for route segment groups
### Segment Group Ordering
By default, route groups (directories with parentheses like `(main)`, `(external)`) are ordered alphabetically based on the filesystem. The `segmentGroupOrder` option allows you to specify a custom order:
```typescript
// Example: Place (main) routes before (external) routes
routeBuilder({
segmentGroupOrder: ["main", "external"], // Without parentheses
})
// Or with parentheses (both formats supported)
routeBuilder({
segmentGroupOrder: ["(main)", "(external)"], // With parentheses
})
```
#### Default Behavior (Filesystem Order)
```
pages/
├── (admin)/ # Third
├── (external)/ # First (alphabetically)
├── (main)/ # Second
└── (settings)/ # Fourth
```
#### With Custom Order
```typescript
// Configuration
segmentGroupOrder: ['main', 'admin']
// Result:
pages/
├── (main)/ # First (specified in order)
├── (admin)/ # Second (specified in order)
├── (external)/ # Third (not specified, filesystem order)
└── (settings)/ # Fourth (not specified, filesystem order)
```
#### Usage Examples
```typescript
// Basic usage with default options
import routeBuilder from "./vite-plugin-route-builder"
export default defineConfig({
plugins: [routeBuilder()],
})
// Advanced configuration with parentheses format
export default defineConfig({
plugins: [
routeBuilder({
pagePattern: "./src/pages/**/*.{tsx,sync.tsx}",
outputPath: "./src/router/generated-routes.ts",
enableInDev: true,
debug: true,
segmentGroupOrder: ["(main)", "(dashboard)", "(settings)", "(external)"],
transformPath: (path) => path.replace(/\.sync\.tsx$/, ".tsx"),
}),
],
})
// Mixed format is also supported
export default defineConfig({
plugins: [
routeBuilder({
segmentGroupOrder: ["(main)", "dashboard", "settings", "(external)"],
}),
],
})
```
## Integration Points
### Vite Integration
```typescript
// vite.config.ts
import { defineConfig } from "vite"
import routeBuilder from "./plugins/vite/vite-plugin-route-builder"
export default defineConfig({
plugins: [
routeBuilder(), // Add the route builder plugin
// ... other plugins
],
})
```
### React Router Integration
```typescript
// App.tsx
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import { routes } from './generated-routes'
const router = createBrowserRouter(routes)
export default function App() {
return <RouterProvider router={router} />
}
```
## Future Enhancements
### Potential Improvements
1. **Watch Mode**: Real-time route regeneration during development
2. **TypeScript Validation**: Compile-time route validation
3. **Route Metadata**: Support for route-level metadata and guards
4. **Custom Conventions**: Configurable file naming conventions
5. **Nested Layouts**: Support for multiple layout levels
### Extensibility
The plugin architecture allows for easy extension:
- Custom file processors for different route types
- Pluggable path resolution strategies
- Configurable code generation templates
- Integration with other meta-frameworks
## Conclusion
The Vite Route Builder plugin successfully transforms file system-based routing into optimized React Router configurations. By leveraging build-time generation, it provides excellent performance while maintaining developer ergonomics similar to Next.js App Router. The robust path matching and lazy loading implementation ensures reliable route generation for complex application structures.
## License
2025 © Innei, Released under the MIT License.
> [Personal Website](https://innei.in/) · GitHub [@Innei](https://github.com/innei/)

View File

@ -1,230 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`routeBuilderPluginV2 > Generated Routes Snapshots > should generate correct routes for mixed sync and async files > mixed-routes-structure 1`] = `
"// This file is auto-generated by vite-plugin-route-builder
// Do not edit manually
/* eslint-disable */
// @ts-nocheck
import type { RouteObject } from "react-router"
// Imports for page components
import * as SyncComponent0 from ".//project/src/pages/about.sync"
import * as SyncComponent1 from ".//project/src/pages/dashboard/admin.sync"
import * as SyncComponent2 from ".//project/src/pages/settings/profile.sync"
const lazy0 = () => import(".//project/src/pages/blog/[id]")
const lazy1 = () => import(".//project/src/pages/settings/layout")
const lazy2 = () => import(".//project/src/pages/index")
// Generated route configuration
export const routes: RouteObject[] = [
{
"path": "about",
"Component": SyncComponent0.Component,
"loader": SyncComponent0.loader
},
{
"path": "blog",
"children": [
{
"path": ":id",
"lazy": lazy0
}
]
},
{
"path": "dashboard",
"children": [
{
"path": "admin",
"Component": SyncComponent1.Component,
"loader": SyncComponent1.loader
}
]
},
{
"path": "settings",
"children": [
{
"path": "",
"lazy": lazy1,
"children": [
{
"path": "profile",
"Component": SyncComponent2.Component,
"loader": SyncComponent2.loader
}
]
}
]
},
{
"path": "",
"lazy": lazy2
}
]
export default routes
"
`;
exports[`routeBuilderPluginV2 > Generated Routes Snapshots > should generate correct routes for only async files > async-only-routes 1`] = `
"// This file is auto-generated by vite-plugin-route-builder
// Do not edit manually
/* eslint-disable */
// @ts-nocheck
import type { RouteObject } from "react-router"
// Imports for page components
const lazy0 = () => import(".//project/src/pages/about")
const lazy1 = () => import(".//project/src/pages/blog/[slug]")
const lazy2 = () => import(".//project/src/pages/contact")
const lazy3 = () => import(".//project/src/pages/index")
// Generated route configuration
export const routes: RouteObject[] = [
{
"path": "about",
"lazy": lazy0
},
{
"path": "blog",
"children": [
{
"path": ":slug",
"lazy": lazy1
}
]
},
{
"path": "contact",
"lazy": lazy2
},
{
"path": "",
"lazy": lazy3
}
]
export default routes
"
`;
exports[`routeBuilderPluginV2 > Generated Routes Snapshots > should generate correct routes for only sync files > sync-only-routes 1`] = `
"// This file is auto-generated by vite-plugin-route-builder
// Do not edit manually
/* eslint-disable */
// @ts-nocheck
import type { RouteObject } from "react-router"
// Imports for page components
import * as SyncComponent0 from ".//project/src/pages/critical.sync"
import * as SyncComponent1 from ".//project/src/pages/important.sync"
import * as SyncComponent2 from ".//project/src/pages/settings/config.sync"
// Generated route configuration
export const routes: RouteObject[] = [
{
"path": "critical",
"Component": SyncComponent0.Component,
"loader": SyncComponent0.loader
},
{
"path": "important",
"Component": SyncComponent1.Component,
"loader": SyncComponent1.loader
},
{
"path": "settings",
"children": [
{
"path": "config",
"Component": SyncComponent2.Component,
"loader": SyncComponent2.loader
}
]
}
]
export default routes
"
`;
exports[`routeBuilderPluginV2 > Generated Routes Snapshots > should generate correct routes with custom transformPath > custom-transform-routes 1`] = `
"// This file is auto-generated by vite-plugin-route-builder
// Do not edit manually
/* eslint-disable */
// @ts-nocheck
import type { RouteObject } from "react-router"
// Imports for page components
// Generated route configuration
export const routes: RouteObject[] = [
{
"path": "",
"children": [
{
"path": "demo"
},
{
"path": "test"
}
]
}
]
export default routes
"
`;
exports[`routeBuilderPluginV2 > Generated Routes Snapshots > should generate empty routes array when no files found > empty-routes 1`] = `
"// This file is auto-generated by vite-plugin-route-builder
// Do not edit manually
/* eslint-disable */
// @ts-nocheck
import type { RouteObject } from "react-router"
// Imports for page components
// Generated route configuration
export const routes: RouteObject[] = []
export default routes
"
`;
exports[`routeBuilderPluginV2 > should generate correct import statements for sync files > sync-and-async-routes 1`] = `
"// This file is auto-generated by vite-plugin-route-builder
// Do not edit manually
/* eslint-disable */
// @ts-nocheck
import type { RouteObject } from "react-router"
// Imports for page components
import * as SyncComponent0 from ".//project/src/pages/critical.sync"
const lazy0 = () => import(".//project/src/pages/normal")
// Generated route configuration
export const routes: RouteObject[] = [
{
"path": "critical",
"Component": SyncComponent0.Component,
"loader": SyncComponent0.loader
},
{
"path": "normal",
"lazy": lazy0
}
]
export default routes
"
`;

View File

@ -1,134 +0,0 @@
import { describe, expect, it } from "vitest"
// We'll test the internal logic by creating a simplified version
// since the actual generateRouteFileContent is internal to the plugin
describe("Code Generation", () => {
it("should generate correct imports for sync files", () => {
const syncImports = new Set(["./pages/critical.sync.tsx"])
const syncImportMap = new Map([["./pages/critical.sync.tsx", "SyncComponent0"]])
const fileToImportMap: Record<string, string> = {
"./pages/critical.sync.tsx": "./pages/critical.sync",
}
const imports: string[] = []
syncImports.forEach((key) => {
const importPath = fileToImportMap[key]
const syncImportName = syncImportMap.get(key)
if (importPath && syncImportName) {
imports.push(`import * as ${syncImportName} from "${importPath}"`)
}
})
expect(imports).toEqual(['import * as SyncComponent0 from "./pages/critical.sync"'])
})
it("should generate correct imports for async files", () => {
const lazyImports = new Set(["./pages/normal.tsx"])
const lazyImportMap = new Map([["./pages/normal.tsx", "lazy0"]])
const fileToImportMap: Record<string, string> = {
"./pages/normal.tsx": "./pages/normal",
}
const imports: string[] = []
lazyImports.forEach((key) => {
const importPath = fileToImportMap[key]
const lazyFuncName = lazyImportMap.get(key)
if (importPath && lazyFuncName) {
imports.push(`const ${lazyFuncName} = () => import("${importPath}")`)
}
})
expect(imports).toEqual(['const lazy0 = () => import("./pages/normal")'])
})
it("should handle route object transformation", () => {
const route = {
path: "test",
Component: "__SYNC_SyncComponent0.Component__",
loader: "__SYNC_SyncComponent0.loader__",
}
const routesString = JSON.stringify([route], null, 2)
.replaceAll(/"__SYNC_([^.]+)\.Component__"/g, '"$1.Component"')
.replaceAll(/"__SYNC_([^.]+)\.loader__"/g, '"$1.loader"')
expect(routesString).toContain('"Component": "SyncComponent0.Component"')
expect(routesString).toContain('"loader": "SyncComponent0.loader"')
})
it("should remove undefined loader properties", () => {
const routeString = `{
"path": "test",
"Component": "SyncComponent0.Component",
"loader": undefined
}`
const cleaned = routeString.replaceAll(/,?\s*"loader":\s*undefined/g, "")
expect(cleaned).toContain('"Component": "SyncComponent0.Component"')
expect(cleaned).not.toContain('"loader"')
})
it("should generate complete route file content", () => {
const syncImports = ['import * as SyncComponent0 from "./pages/critical.sync"']
const lazyImports = ['const lazy0 = () => import("./pages/normal")']
const routes = [
{
path: "critical",
Component: "SyncComponent0.Component",
loader: "SyncComponent0.loader",
},
{
path: "normal",
lazy: "lazy0",
},
]
const content = `// This file is auto-generated by vite-plugin-route-builder
// Do not edit manually
/* eslint-disable */
// @ts-nocheck
import type { RouteObject } from "react-router"
// Imports for page components
${[...syncImports, ...lazyImports].join("\n")}
// Generated route configuration
export const routes: RouteObject[] = ${JSON.stringify(routes, null, 2)}
export default routes
`
expect(content).toContain("import * as SyncComponent0")
expect(content).toContain("const lazy0 = () => import")
expect(content).toContain('"Component": "SyncComponent0.Component"')
expect(content).toContain('"loader": "SyncComponent0.loader"')
expect(content).toContain('"lazy": "lazy0"')
expect(content).toContain("export const routes: RouteObject[]")
})
it("should handle file extension replacement correctly", () => {
const testCases = [
{
input: "./pages/test.sync.tsx",
expected: "./pages/test.sync",
},
{
input: "./pages/test.tsx",
expected: "./pages/test",
},
]
testCases.forEach(({ input, expected }) => {
let result: string
if (input.endsWith(".sync.tsx")) {
result = input.replace(/\.tsx$/, "")
} else {
result = input.replace(/\.tsx$/, "")
}
expect(result).toBe(expected)
})
})
})

View File

@ -1,533 +0,0 @@
import { describe, expect, test } from "vitest"
import { buildGlobRoutes } from "../utils/route-builder"
const fakePromise = () => Promise.resolve({ default: () => {} })
describe("test route builder", () => {
test("match snapshot with default filesystem order", () => {
expect(
buildGlobRoutes({
"./pages/(external)/layout.tsx": fakePromise,
"./pages/(external)/(with-layout)/index.tsx": fakePromise,
"./pages/(external)/(with-layout)/layout.tsx": fakePromise,
"./pages/(external)/(with-layout)/feed/[id]/index.tsx": fakePromise,
"./pages/(external)/(with-layout)/feed/[id]/layout.tsx": fakePromise,
"./pages/(main)/layout.tsx": fakePromise,
"./pages/(main)/(context)/layout.tsx": fakePromise,
"./pages/(main)/(context)/discover/layout.tsx": fakePromise,
"./pages/(main)/(context)/discover/index.tsx": fakePromise,
"./pages/preview.tsx": fakePromise,
"./pages/add/layout.tsx": fakePromise,
"./pages/add/index.tsx": fakePromise,
}),
).toMatchInlineSnapshot(`
[
{
"children": [
{
"children": [
{
"handle": {
"fs": "./pages/add/index/",
"fullPath": "/add/",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
],
"handle": {
"fs": "./pages/add/layout",
"fullPath": "/add",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
],
"handle": {
"fs": "./pages/add/add",
"fullPath": "/add",
},
"path": "add",
},
{
"handle": {
"fs": "./pages/preview/preview",
"fullPath": "/preview",
"isSync": false,
},
"lazy": [Function],
"path": "preview",
},
{
"children": [
{
"children": [
{
"children": [
{
"children": [
{
"children": [
{
"handle": {
"fs": "./pages/(external)/(with-layout)/feed/[id]/index/",
"fullPath": "/feed/:id/",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
],
"handle": {
"fs": "./pages/(external)/(with-layout)/feed/[id]/layout",
"fullPath": "/feed/:id",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
],
"handle": {
"fs": "./pages/(external)/(with-layout)/feed/[id]/:id",
"fullPath": "/feed/:id",
},
"path": ":id",
},
],
"handle": {
"fs": "./pages/(external)/(with-layout)/feed/feed",
"fullPath": "/feed",
},
"path": "feed",
},
{
"handle": {
"fs": "./pages/(external)/(with-layout)/index/",
"fullPath": "/",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
],
"handle": {
"fs": "./pages/(external)/(with-layout)",
"fullPath": "",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
],
"handle": {
"fs": "./pages/(external)",
"fullPath": "",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
{
"children": [
{
"children": [
{
"children": [
{
"children": [
{
"handle": {
"fs": "./pages/(main)/(context)/discover/index/",
"fullPath": "/discover/",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
],
"handle": {
"fs": "./pages/(main)/(context)/discover/layout",
"fullPath": "/discover",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
],
"handle": {
"fs": "./pages/(main)/(context)/discover/discover",
"fullPath": "/discover",
},
"path": "discover",
},
],
"handle": {
"fs": "./pages/(main)/(context)",
"fullPath": "",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
],
"handle": {
"fs": "./pages/(main)",
"fullPath": "",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
]
`)
})
test("match snapshot with custom segment group order", () => {
expect(
buildGlobRoutes(
{
"./pages/(external)/layout.tsx": fakePromise,
"./pages/(external)/(with-layout)/index.tsx": fakePromise,
"./pages/(external)/(with-layout)/layout.tsx": fakePromise,
"./pages/(external)/(with-layout)/feed/[id]/index.tsx": fakePromise,
"./pages/(external)/(with-layout)/feed/[id]/layout.tsx": fakePromise,
"./pages/(main)/layout.tsx": fakePromise,
"./pages/(main)/(context)/layout.tsx": fakePromise,
"./pages/(main)/(context)/discover/layout.tsx": fakePromise,
"./pages/(main)/(context)/discover/index.tsx": fakePromise,
"./pages/preview.tsx": fakePromise,
"./pages/add/layout.tsx": fakePromise,
"./pages/add/index.tsx": fakePromise,
},
{ segmentGroupOrder: ["main", "external"] },
),
).toMatchInlineSnapshot(`
[
{
"children": [
{
"children": [
{
"handle": {
"fs": "./pages/add/index/",
"fullPath": "/add/",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
],
"handle": {
"fs": "./pages/add/layout",
"fullPath": "/add",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
],
"handle": {
"fs": "./pages/add/add",
"fullPath": "/add",
},
"path": "add",
},
{
"handle": {
"fs": "./pages/preview/preview",
"fullPath": "/preview",
"isSync": false,
},
"lazy": [Function],
"path": "preview",
},
{
"children": [
{
"children": [
{
"children": [
{
"children": [
{
"handle": {
"fs": "./pages/(main)/(context)/discover/index/",
"fullPath": "/discover/",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
],
"handle": {
"fs": "./pages/(main)/(context)/discover/layout",
"fullPath": "/discover",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
],
"handle": {
"fs": "./pages/(main)/(context)/discover/discover",
"fullPath": "/discover",
},
"path": "discover",
},
],
"handle": {
"fs": "./pages/(main)/(context)",
"fullPath": "",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
],
"handle": {
"fs": "./pages/(main)",
"fullPath": "",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
{
"children": [
{
"children": [
{
"children": [
{
"children": [
{
"children": [
{
"handle": {
"fs": "./pages/(external)/(with-layout)/feed/[id]/index/",
"fullPath": "/feed/:id/",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
],
"handle": {
"fs": "./pages/(external)/(with-layout)/feed/[id]/layout",
"fullPath": "/feed/:id",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
],
"handle": {
"fs": "./pages/(external)/(with-layout)/feed/[id]/:id",
"fullPath": "/feed/:id",
},
"path": ":id",
},
],
"handle": {
"fs": "./pages/(external)/(with-layout)/feed/feed",
"fullPath": "/feed",
},
"path": "feed",
},
{
"handle": {
"fs": "./pages/(external)/(with-layout)/index/",
"fullPath": "/",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
],
"handle": {
"fs": "./pages/(external)/(with-layout)",
"fullPath": "",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
],
"handle": {
"fs": "./pages/(external)",
"fullPath": "",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
]
`)
})
test("match snapshot with partial custom segment group order", () => {
expect(
buildGlobRoutes(
{
"./pages/(admin)/layout.tsx": fakePromise,
"./pages/(external)/layout.tsx": fakePromise,
"./pages/(main)/layout.tsx": fakePromise,
"./pages/(settings)/layout.tsx": fakePromise,
},
{ segmentGroupOrder: ["main"] },
),
).toMatchInlineSnapshot(`
[
{
"children": [],
"handle": {
"fs": "./pages/(main)",
"fullPath": "",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
{
"children": [],
"handle": {
"fs": "./pages/(admin)",
"fullPath": "",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
{
"children": [],
"handle": {
"fs": "./pages/(external)",
"fullPath": "",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
{
"children": [],
"handle": {
"fs": "./pages/(settings)",
"fullPath": "",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
]
`)
})
test("match snapshot with custom segment group order using parentheses format", () => {
expect(
buildGlobRoutes(
{
"./pages/(external)/layout.tsx": fakePromise,
"./pages/(main)/layout.tsx": fakePromise,
"./pages/(login)/layout.tsx": fakePromise,
},
{ segmentGroupOrder: ["(main)", "(login)"] },
),
).toMatchInlineSnapshot(`
[
{
"children": [],
"handle": {
"fs": "./pages/(main)",
"fullPath": "",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
{
"children": [],
"handle": {
"fs": "./pages/(login)",
"fullPath": "",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
{
"children": [],
"handle": {
"fs": "./pages/(external)",
"fullPath": "",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
]
`)
})
test("match snapshot with mixed format segment group order", () => {
expect(
buildGlobRoutes(
{
"./pages/(external)/layout.tsx": fakePromise,
"./pages/(main)/layout.tsx": fakePromise,
"./pages/(login)/layout.tsx": fakePromise,
"./pages/(settings)/layout.tsx": fakePromise,
},
{ segmentGroupOrder: ["(main)", "login", "external"] },
),
).toMatchInlineSnapshot(`
[
{
"children": [],
"handle": {
"fs": "./pages/(main)",
"fullPath": "",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
{
"children": [],
"handle": {
"fs": "./pages/(login)",
"fullPath": "",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
{
"children": [],
"handle": {
"fs": "./pages/(external)",
"fullPath": "",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
{
"children": [],
"handle": {
"fs": "./pages/(settings)",
"fullPath": "",
"isSync": false,
},
"lazy": [Function],
"path": "",
},
]
`)
})
})

View File

@ -1,455 +0,0 @@
import * as fs from "node:fs"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { routeBuilderPluginV2 } from "../vite-plugin-route-builder"
// Mock file system and glob
vi.mock("node:fs", () => ({
writeFileSync: vi.fn(),
promises: {
access: vi.fn(),
readFile: vi.fn(),
writeFile: vi.fn(),
},
resolve: vi.fn(),
dirname: vi.fn(),
relative: vi.fn(),
}))
vi.mock("fast-glob", () => ({
default: {
sync: vi.fn(),
},
}))
vi.mock("pathe", () => ({
resolve: vi.fn((root: string, path: string) => `${root}/${path}`),
relative: vi.fn((from: string, to: string) => to.replace(from, "")),
dirname: vi.fn((path: string) => path.split("/").slice(0, -1).join("/")),
}))
vi.mock("./utils/route-builder", () => ({
buildGlobRoutes: vi.fn(() => [
{
path: "test",
lazy: () => Promise.resolve({ default: () => null }),
handle: {
fs: "./pages/test",
fullPath: "/test",
isSync: false,
},
},
]),
}))
// Helper function to capture generated content
function captureGeneratedContent(): { content: string; filePath: string } | null {
const mockWriteFileSync = vi.mocked(fs.writeFileSync)
if (!mockWriteFileSync.mock || mockWriteFileSync.mock.calls.length === 0) {
return null
}
const lastCall = mockWriteFileSync.mock.calls.at(-1)
if (!lastCall) {
return null
}
return {
filePath: lastCall[0] as string,
content: lastCall[1] as string,
}
}
describe("routeBuilderPluginV2", () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it("should create plugin with default options", () => {
const plugin = routeBuilderPluginV2()
expect(plugin.name).toBe("vite-plugin-route-builder-v2")
expect(plugin).toHaveProperty("configResolved")
expect(plugin).toHaveProperty("buildStart")
expect(plugin).toHaveProperty("configureServer")
})
it("should use custom options", () => {
const customOptions = {
pagePattern: "./custom/**/*.tsx",
outputPath: "./custom/routes.ts",
enableInDev: false,
debug: true,
}
const plugin = routeBuilderPluginV2(customOptions)
expect(plugin.name).toBe("vite-plugin-route-builder-v2")
})
it("should generate routes for sync and async files", async () => {
const mockFiles = [
"/project/src/pages/index.tsx",
"/project/src/pages/about.sync.tsx",
"/project/src/pages/settings/layout.tsx",
"/project/src/pages/settings/profile.sync.tsx",
]
const glob = await import("fast-glob")
vi.mocked(glob.default.sync).mockReturnValue(mockFiles)
const plugin = routeBuilderPluginV2({
pagePattern: "./src/pages/**/*.{tsx,sync.tsx}",
outputPath: "./src/generated-routes.ts",
})
const mockConfig = {
command: "build" as const,
root: "/project",
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}
// Simulate plugin lifecycle
if (typeof plugin.configResolved === "function") {
plugin.configResolved(mockConfig as any)
}
if (typeof plugin.buildStart === "function") {
await plugin.buildStart.call({} as any, {} as any)
}
expect(glob.default.sync).toHaveBeenCalledWith("./src/pages/**/*.{tsx,sync.tsx}", {
cwd: "/project",
absolute: true,
})
})
it("should generate correct import statements for sync files", async () => {
const mockFiles = ["/project/src/pages/critical.sync.tsx", "/project/src/pages/normal.tsx"]
const glob = await import("fast-glob")
vi.mocked(glob.default.sync).mockReturnValue(mockFiles)
const plugin = routeBuilderPluginV2({
outputPath: "./src/generated-routes.ts",
debug: true,
})
const mockConfig = {
command: "build" as const,
root: "/project",
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}
const { writeFileSync } = await import("node:fs")
const mockWriteFileSync = vi.mocked(writeFileSync)
if (typeof plugin.configResolved === "function") {
plugin.configResolved(mockConfig as any)
}
if (typeof plugin.buildStart === "function") {
await plugin.buildStart.call({} as any, {} as any)
}
expect(mockWriteFileSync).toHaveBeenCalled()
expect(mockConfig.logger.info).toHaveBeenCalledWith(
expect.stringContaining("Generated routes:"),
)
// Verify generated content with snapshot
const generatedContent = captureGeneratedContent()
expect(generatedContent).toBeTruthy()
expect(generatedContent!.content).toMatchSnapshot("sync-and-async-routes")
})
it("should handle transformPath option", async () => {
const mockFiles = ["/project/src/pages/test.tsx"]
const glob = await import("fast-glob")
vi.mocked(glob.default.sync).mockReturnValue(mockFiles)
const plugin = routeBuilderPluginV2({
transformPath: (path) => path.replace("./pages/", "./custom/"),
debug: true,
})
const mockConfig = {
command: "build" as const,
root: "/project",
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}
if (typeof plugin.configResolved === "function") {
plugin.configResolved(mockConfig as any)
}
if (typeof plugin.buildStart === "function") {
await plugin.buildStart.call({} as any, {} as any)
}
expect(mockConfig.logger.info).toHaveBeenCalledWith(
expect.stringContaining("./custom/test.tsx"),
)
})
it("should watch files in dev mode", () => {
const plugin = routeBuilderPluginV2({
enableInDev: true,
})
const mockWatcher = {
add: vi.fn(),
on: vi.fn(),
}
const mockServer = {
watcher: mockWatcher,
ws: {
send: vi.fn(),
},
}
const mockConfig = {
command: "serve" as const,
root: "/project",
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}
if (typeof plugin.configResolved === "function") {
plugin.configResolved(mockConfig as any)
}
if (typeof plugin.configureServer === "function") {
plugin.configureServer(mockServer as any)
}
expect(mockWatcher.add).toHaveBeenCalled()
expect(mockWatcher.on).toHaveBeenCalledWith("add", expect.any(Function))
expect(mockWatcher.on).toHaveBeenCalledWith("unlink", expect.any(Function))
})
it("should not generate in dev mode when disabled", async () => {
const plugin = routeBuilderPluginV2({
enableInDev: false,
})
const mockConfig = {
command: "serve" as const,
root: "/project",
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}
const glob = await import("fast-glob")
if (typeof plugin.configResolved === "function") {
plugin.configResolved(mockConfig as any)
}
if (typeof plugin.buildStart === "function") {
await plugin.buildStart.call({} as any, {} as any)
}
expect(glob.default.sync).not.toHaveBeenCalled()
})
describe("Generated Routes Snapshots", () => {
it("should generate correct routes for mixed sync and async files", async () => {
const mockFiles = [
"/project/src/pages/index.tsx",
"/project/src/pages/about.sync.tsx",
"/project/src/pages/settings/layout.tsx",
"/project/src/pages/settings/profile.sync.tsx",
"/project/src/pages/blog/[id].tsx",
"/project/src/pages/dashboard/admin.sync.tsx",
]
const glob = await import("fast-glob")
vi.mocked(glob.default.sync).mockReturnValue(mockFiles)
const plugin = routeBuilderPluginV2({
pagePattern: "./src/pages/**/*.{tsx,sync.tsx}",
outputPath: "./src/generated-routes.ts",
})
const mockConfig = {
command: "build" as const,
root: "/project",
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}
if (typeof plugin.configResolved === "function") {
plugin.configResolved(mockConfig as any)
}
if (typeof plugin.buildStart === "function") {
await plugin.buildStart.call({} as any, {} as any)
}
const generatedContent = captureGeneratedContent()
expect(generatedContent).toBeTruthy()
expect(generatedContent!.content).toMatchSnapshot("mixed-routes-structure")
})
it("should generate correct routes for only sync files", async () => {
const mockFiles = [
"/project/src/pages/critical.sync.tsx",
"/project/src/pages/important.sync.tsx",
"/project/src/pages/settings/config.sync.tsx",
]
const glob = await import("fast-glob")
vi.mocked(glob.default.sync).mockReturnValue(mockFiles)
const plugin = routeBuilderPluginV2({
outputPath: "./src/generated-routes.ts",
})
const mockConfig = {
command: "build" as const,
root: "/project",
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}
if (typeof plugin.configResolved === "function") {
plugin.configResolved(mockConfig as any)
}
if (typeof plugin.buildStart === "function") {
await plugin.buildStart.call({} as any, {} as any)
}
const generatedContent = captureGeneratedContent()
expect(generatedContent).toBeTruthy()
expect(generatedContent!.content).toMatchSnapshot("sync-only-routes")
})
it("should generate correct routes for only async files", async () => {
const mockFiles = [
"/project/src/pages/index.tsx",
"/project/src/pages/about.tsx",
"/project/src/pages/contact.tsx",
"/project/src/pages/blog/[slug].tsx",
]
const glob = await import("fast-glob")
vi.mocked(glob.default.sync).mockReturnValue(mockFiles)
const plugin = routeBuilderPluginV2({
outputPath: "./src/generated-routes.ts",
})
const mockConfig = {
command: "build" as const,
root: "/project",
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}
if (typeof plugin.configResolved === "function") {
plugin.configResolved(mockConfig as any)
}
if (typeof plugin.buildStart === "function") {
await plugin.buildStart.call({} as any, {} as any)
}
const generatedContent = captureGeneratedContent()
expect(generatedContent).toBeTruthy()
expect(generatedContent!.content).toMatchSnapshot("async-only-routes")
})
it("should generate correct routes with custom transformPath", async () => {
const mockFiles = ["/project/src/pages/test.tsx", "/project/src/pages/demo.sync.tsx"]
const glob = await import("fast-glob")
vi.mocked(glob.default.sync).mockReturnValue(mockFiles)
const plugin = routeBuilderPluginV2({
transformPath: (path) => path.replace("./pages/", "./custom/"),
outputPath: "./src/generated-routes.ts",
})
const mockConfig = {
command: "build" as const,
root: "/project",
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}
if (typeof plugin.configResolved === "function") {
plugin.configResolved(mockConfig as any)
}
if (typeof plugin.buildStart === "function") {
await plugin.buildStart.call({} as any, {} as any)
}
const generatedContent = captureGeneratedContent()
expect(generatedContent).toBeTruthy()
expect(generatedContent!.content).toMatchSnapshot("custom-transform-routes")
})
it("should generate empty routes array when no files found", async () => {
const mockFiles: string[] = []
const glob = await import("fast-glob")
vi.mocked(glob.default.sync).mockReturnValue(mockFiles)
const plugin = routeBuilderPluginV2({
outputPath: "./src/generated-routes.ts",
})
const mockConfig = {
command: "build" as const,
root: "/project",
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}
if (typeof plugin.configResolved === "function") {
plugin.configResolved(mockConfig as any)
}
if (typeof plugin.buildStart === "function") {
await plugin.buildStart.call({} as any, {} as any)
}
const generatedContent = captureGeneratedContent()
expect(generatedContent).toBeTruthy()
expect(generatedContent!.content).toMatchSnapshot("empty-routes")
})
})
})

View File

@ -1 +0,0 @@
export { default } from "./vite-plugin-route-builder"

View File

@ -1,276 +0,0 @@
import { get, omit } from "es-toolkit/compat"
type NestedStructure = { [key: string]: NestedStructure }
function nestPaths(paths: string[]): NestedStructure {
const result: NestedStructure = {}
paths.forEach((path) => {
// Remove the './pages' prefix and the file extension
const prefix = "./pages/"
let suffix = ".tsx"
let trimmedPath: string
// Check if it's a .sync.tsx file
if (path.endsWith(".sync.tsx")) {
suffix = ".sync.tsx"
trimmedPath = path.slice(prefix.length, -suffix.length)
} else {
trimmedPath = path.slice(prefix.length, -suffix.length)
}
const parts = trimmedPath.split("/")
let currentLevel = result
for (const part of parts) {
if (!currentLevel[part]) {
currentLevel[part] = {}
}
currentLevel = currentLevel[part]
}
})
return result
}
// Extended RouteObject to include sync loading information
interface ExtendedRouteObject {
path?: string
index?: boolean
children?: ExtendedRouteObject[]
lazy?: any
handle?: {
fs: string
fullPath: string
isSync?: boolean
}
}
export function buildGlobRoutes(
glob: Record<string, () => Promise<any>>,
options: { segmentGroupOrder?: string[] } = {},
): ExtendedRouteObject[] {
const keys = Object.keys(glob)
const paths = nestPaths(keys)
const pathGetterSet = new Set<string>()
const { segmentGroupOrder = [] } = options
const routeObject: ExtendedRouteObject[] = []
function dfsRoutes(
parentKey: string,
children: ExtendedRouteObject[],
paths: NestedStructure,
parentPath = "",
) {
const pathKeys = Object.keys(paths)
// sort `layout` to the start, and `index` to the end
pathKeys.sort((a, b) => {
if (a === "layout") {
return -1
}
if (b === "layout") {
return 1
}
if (a === "index") {
return 1
}
if (b === "index") {
return -1
}
return a.localeCompare(b)
})
// sort, if () group, then move to the end
pathKeys.sort((a, b) => {
if (a.startsWith("(") && a.endsWith(")")) {
return 1
}
if (b.startsWith("(") && b.endsWith(")")) {
return -1
}
return 0
})
// Custom segment group ordering based on segmentGroupOrder option
if (segmentGroupOrder.length > 0) {
pathKeys.sort((a, b) => {
const isAGroup = a.startsWith("(") && a.endsWith(")")
const isBGroup = b.startsWith("(") && b.endsWith(")")
// If both are groups, sort by the custom order
if (isAGroup && isBGroup) {
// Support both formats: "(main)" and "main"
const aIndex = segmentGroupOrder.findIndex(
(item) => item === a || item === a.slice(1, -1),
)
const bIndex = segmentGroupOrder.findIndex(
(item) => item === b || item === b.slice(1, -1),
)
// If both are in the custom order, sort by their position
if (aIndex !== -1 && bIndex !== -1) {
return aIndex - bIndex
}
// If only a is in custom order, a comes first
if (aIndex !== -1 && bIndex === -1) {
return -1
}
// If only b is in custom order, b comes first
if (aIndex === -1 && bIndex !== -1) {
return 1
}
// If neither is in custom order, use filesystem order (localeCompare)
return a.localeCompare(b)
}
return 0 // Non-groups maintain their existing sort order
})
}
for (const key of pathKeys) {
const isGroupedRoute = key.startsWith("(") && key.endsWith(")")
const segmentPathKey = parentKey + key
if (isGroupedRoute) {
// Check for both sync and async layout files
const syncLayoutPath = `${segmentPathKey}/layout.sync.tsx`
const asyncLayoutPath = `${segmentPathKey}/layout.tsx`
let accessPath: string
let isSync = false
const syncGlobGetter = get(glob, syncLayoutPath)
let globGetter
if (syncGlobGetter) {
accessPath = syncLayoutPath
isSync = true
globGetter = syncGlobGetter
} else {
accessPath = asyncLayoutPath
globGetter = get(glob, accessPath) || undefined
}
if (pathGetterSet.has(accessPath)) {
console.error(`duplicate path: ${accessPath}`)
}
pathGetterSet.add(accessPath)
const childrenChildren: ExtendedRouteObject[] = []
dfsRoutes(`${segmentPathKey}/`, childrenChildren, paths[key]!, parentPath)
children.push({
path: "",
lazy: globGetter,
children: childrenChildren,
handle: {
fs: segmentPathKey,
fullPath: parentPath,
isSync,
},
})
} else if (key === "layout") {
// if parent key is grouped routes, the layout is handled, so skip this logic
if (parentKey.endsWith(")/")) {
continue
}
// Check for both sync and async layout files
const syncLayoutPath = `${segmentPathKey}.sync.tsx`
const asyncLayoutPath = `${segmentPathKey}.tsx`
let isSync = false
const syncGlobGetter = get(glob, syncLayoutPath)
let globGetter
if (syncGlobGetter) {
isSync = true
globGetter = syncGlobGetter
} else {
globGetter = get(glob, asyncLayoutPath)
}
const childrenChildren: ExtendedRouteObject[] = []
// should omit layout, because layout is already handled
dfsRoutes(parentKey, childrenChildren, omit(paths, "layout") as NestedStructure, parentPath)
children.push({
path: "",
lazy: globGetter,
children: childrenChildren,
handle: {
fs: segmentPathKey,
fullPath: parentPath,
isSync,
},
})
break
} else {
const content = paths[key]!
const hasChild = Object.keys(content).length > 0
const normalizeKey = normalizePathKey(key)
if (!hasChild) {
// Check for both sync and async files
const syncPath = `${segmentPathKey}.sync.tsx`
const asyncPath = `${segmentPathKey}.tsx`
let accessPath: string
let isSync = false
const syncGlobGetter = get(glob, syncPath)
let globGetter
if (syncGlobGetter) {
accessPath = syncPath
isSync = true
globGetter = syncGlobGetter
} else {
accessPath = asyncPath
globGetter = get(glob, asyncPath)
}
if (pathGetterSet.has(accessPath)) {
console.error(`duplicate path: ${accessPath}`)
}
pathGetterSet.add(accessPath)
children.push({
path: normalizeKey,
lazy: globGetter,
handle: {
fs: `${segmentPathKey}/${normalizeKey}`,
fullPath: `${parentPath}/${normalizeKey}`,
isSync,
},
})
} else {
const childrenChildren: ExtendedRouteObject[] = []
const fullPath = `${parentPath}/${normalizeKey}`
dfsRoutes(`${segmentPathKey}/`, childrenChildren, paths[key]!, fullPath)
children.push({
path: normalizeKey,
children: childrenChildren,
handle: {
fs: `${segmentPathKey}/${normalizeKey}`,
fullPath,
},
})
}
}
}
}
dfsRoutes("./pages/", routeObject, paths)
return routeObject
}
const normalizePathKey = (key: string) => {
if (key === "index") {
return ""
}
if (key.startsWith("[") && key.endsWith("]")) {
return `:${key.slice(1, -1)}`
}
return key
}

View File

@ -1,441 +0,0 @@
import { writeFileSync } from "node:fs"
import { inspect } from "node:util"
import glob from "fast-glob"
import { dirname, relative, resolve } from "pathe"
import type { Logger, Plugin } from "vite"
import { buildGlobRoutes } from "./utils/route-builder"
export interface RouteBuilderPluginOptions {
/** Page files glob pattern */
pagePattern?: string
/** Output path for generated routes */
outputPath?: string
/** Whether to enable in dev mode */
enableInDev?: boolean
/** Custom file to route path transformation logic */
transformPath?: (path: string) => string
/** Whether to disable logging */
debug?: boolean
/** Custom order for segment groups in route tree. Array of group names (with or without parentheses). Default: filesystem order */
segmentGroupOrder?: string[]
}
export function routeBuilderPluginV2(options: RouteBuilderPluginOptions = {}): Plugin {
const {
pagePattern = "./pages/**/*.{tsx,sync.tsx}",
outputPath = "./src/generated-routes.ts",
enableInDev = true,
transformPath,
debug = false,
segmentGroupOrder = [],
} = options
let isProduction = false
let root = ""
let logger: Logger
function generateRouteFileContent(
routes: any[],
fileToImportMap: Record<string, string>,
): string {
// Collect all used lazy functions and sync imports
const usedLazyFunctions = new Set<string>()
const usedSyncImports = new Set<string>()
const lazyFunctionMap = new Map<string, string>()
const syncImportMap = new Map<string, string>()
let lazyCounter = 0
let syncCounter = 0
// Recursively traverse route tree, collect all used functions
function collectUsedFunctions(routes: any[]) {
routes.forEach((route) => {
if (route.lazy && route.handle?.fs) {
const fsPath = route.handle.fs
const { isSync } = route.handle
// Try to find the corresponding file
let matchedKey: string | undefined
// Strategy 1: Direct match with sync extension
if (isSync && fileToImportMap[`${fsPath}.sync.tsx`]) {
matchedKey = `${fsPath}.sync.tsx`
}
// Strategy 2: Direct match with normal extension
else if (fileToImportMap[`${fsPath}.tsx`]) {
matchedKey = `${fsPath}.tsx`
}
// Strategy 3: layout file (for grouped routes) - sync
else if (isSync && fileToImportMap[`${fsPath}/layout.sync.tsx`]) {
matchedKey = `${fsPath}/layout.sync.tsx`
}
// Strategy 4: layout file (for grouped routes) - async
else if (fileToImportMap[`${fsPath}/layout.tsx`]) {
matchedKey = `${fsPath}/layout.tsx`
}
// Strategy 5: index file - sync
else if (isSync && fileToImportMap[`${fsPath}/index.sync.tsx`]) {
matchedKey = `${fsPath}/index.sync.tsx`
}
// Strategy 6: index file - async
else if (fileToImportMap[`${fsPath}/index.tsx`]) {
matchedKey = `${fsPath}/index.tsx`
}
// Strategy 7: For special path correction
else {
// If fsPath ends with /, it might be an index page
if (fsPath.endsWith("/")) {
const correctedPath = fsPath.slice(0, -1) // Remove trailing /
if (isSync && fileToImportMap[`${correctedPath}/index.sync.tsx`]) {
matchedKey = `${correctedPath}/index.sync.tsx`
} else if (fileToImportMap[`${correctedPath}/index.tsx`]) {
matchedKey = `${correctedPath}/index.tsx`
} else if (isSync && fileToImportMap[`${correctedPath}.sync.tsx`]) {
matchedKey = `${correctedPath}.sync.tsx`
} else if (fileToImportMap[`${correctedPath}.tsx`]) {
matchedKey = `${correctedPath}.tsx`
}
}
// For dynamic routes, remove /:param part, keep file path
else if (fsPath.includes("/:")) {
const correctedPath = fsPath.replace(/\/:[^/]+(?:\/.*)?$/, "")
if (isSync && fileToImportMap[`${correctedPath}.sync.tsx`]) {
matchedKey = `${correctedPath}.sync.tsx`
} else if (fileToImportMap[`${correctedPath}.tsx`]) {
matchedKey = `${correctedPath}.tsx`
}
}
// If fsPath ends with a repeated path segment, remove the last segment
else {
const pathParts = fsPath.split("/")
if (pathParts.length >= 2) {
const lastPart = pathParts.at(-1)
const secondLastPart = pathParts.at(-2)
// If the last two path segments are the same, remove the last one
if (lastPart === secondLastPart) {
const correctedPath = pathParts.slice(0, -1).join("/")
if (isSync && fileToImportMap[`${correctedPath}.sync.tsx`]) {
matchedKey = `${correctedPath}.sync.tsx`
} else if (fileToImportMap[`${correctedPath}.tsx`]) {
matchedKey = `${correctedPath}.tsx`
}
}
}
}
}
if (matchedKey && fileToImportMap[matchedKey]) {
if (isSync) {
const syncImportName = `SyncComponent${syncCounter++}`
usedSyncImports.add(matchedKey)
syncImportMap.set(matchedKey, syncImportName)
if (debug) {
logger.info(
`[route-builder-v2] Mapped sync import: ${fsPath} -> ${matchedKey} -> ${syncImportName}`,
)
}
} else {
const lazyFuncName = `lazy${lazyCounter++}`
usedLazyFunctions.add(matchedKey)
lazyFunctionMap.set(matchedKey, lazyFuncName)
if (debug) {
logger.info(
`[route-builder-v2] Mapped lazy function: ${fsPath} -> ${matchedKey} -> ${lazyFuncName}`,
)
}
}
} else {
logger.warn(`[route-builder-v2] Could not find file for fs path: ${fsPath}`)
logger.warn(
`[route-builder-v2] Available file keys: ${inspect(Object.keys(fileToImportMap), {
depth: null,
})}`,
)
}
}
if (route.children) {
collectUsedFunctions(route.children)
}
})
}
collectUsedFunctions(routes)
// Generate import statements
const imports: string[] = []
// Generate sync imports
usedSyncImports.forEach((key) => {
const importPath = fileToImportMap[key]
const syncImportName = syncImportMap.get(key)
if (importPath && syncImportName) {
// Use import * as to avoid errors when loader doesn't exist
imports.push(`import * as ${syncImportName} from "${importPath}"`)
}
})
// Generate lazy imports
usedLazyFunctions.forEach((key) => {
const importPath = fileToImportMap[key]
const lazyFuncName = lazyFunctionMap.get(key)
if (importPath && lazyFuncName) {
imports.push(`const ${lazyFuncName} = () => import("${importPath}")`)
}
})
// Recursively process routes, replace lazy functions and remove handle
function processRoutes(routes: any[]): any {
return routes.map((route) => {
const newRoute: any = { ...route }
// Process lazy functions and sync imports
if (route.lazy && route.handle?.fs) {
const fsPath = route.handle.fs
const { isSync } = route.handle
// Find matching file - use the same matching logic
let matchedKey: string | undefined
if (isSync && fileToImportMap[`${fsPath}.sync.tsx`]) {
matchedKey = `${fsPath}.sync.tsx`
} else if (fileToImportMap[`${fsPath}.tsx`]) {
matchedKey = `${fsPath}.tsx`
} else if (isSync && fileToImportMap[`${fsPath}/layout.sync.tsx`]) {
matchedKey = `${fsPath}/layout.sync.tsx`
} else if (fileToImportMap[`${fsPath}/layout.tsx`]) {
matchedKey = `${fsPath}/layout.tsx`
} else if (isSync && fileToImportMap[`${fsPath}/index.sync.tsx`]) {
matchedKey = `${fsPath}/index.sync.tsx`
} else if (fileToImportMap[`${fsPath}/index.tsx`]) {
matchedKey = `${fsPath}/index.tsx`
} else {
// For special path correction
if (fsPath.endsWith("/")) {
const correctedPath = fsPath.slice(0, -1) // Remove trailing /
if (isSync && fileToImportMap[`${correctedPath}/index.sync.tsx`]) {
matchedKey = `${correctedPath}/index.sync.tsx`
} else if (fileToImportMap[`${correctedPath}/index.tsx`]) {
matchedKey = `${correctedPath}/index.tsx`
} else if (isSync && fileToImportMap[`${correctedPath}.sync.tsx`]) {
matchedKey = `${correctedPath}.sync.tsx`
} else if (fileToImportMap[`${correctedPath}.tsx`]) {
matchedKey = `${correctedPath}.tsx`
}
}
// For dynamic routes, remove /:param part, keep file path
else if (fsPath.includes("/:")) {
const correctedPath = fsPath.replace(/\/:[^/]+(?:\/.*)?$/, "")
if (isSync && fileToImportMap[`${correctedPath}.sync.tsx`]) {
matchedKey = `${correctedPath}.sync.tsx`
} else if (fileToImportMap[`${correctedPath}.tsx`]) {
matchedKey = `${correctedPath}.tsx`
}
}
// For repeated path segments, try removing the last segment
else {
const pathParts = fsPath.split("/")
if (pathParts.length >= 2) {
const lastPart = pathParts.at(-1)
const secondLastPart = pathParts.at(-2)
// If the last two path segments are the same, remove the last one
if (lastPart === secondLastPart) {
const correctedPath = pathParts.slice(0, -1).join("/")
if (isSync && fileToImportMap[`${correctedPath}.sync.tsx`]) {
matchedKey = `${correctedPath}.sync.tsx`
} else if (fileToImportMap[`${correctedPath}.tsx`]) {
matchedKey = `${correctedPath}.tsx`
}
}
}
}
}
if (matchedKey) {
if (isSync && syncImportMap.has(matchedKey)) {
// For sync imports, use Component property instead of lazy
const syncComponentName = syncImportMap.get(matchedKey)
newRoute.Component = `__SYNC_${syncComponentName}.Component__`
// Conditionally add loader if it exists
newRoute.loader = `__SYNC_${syncComponentName}.loader__`
delete newRoute.lazy
} else if (lazyFunctionMap.has(matchedKey)) {
newRoute.lazy = `__LAZY_${lazyFunctionMap.get(matchedKey)}__`
} else {
// If no matching function is found, delete lazy property
delete newRoute.lazy
logger.warn(`[route-builder-v2] No function for route: ${fsPath}`)
}
} else {
delete newRoute.lazy
logger.warn(`[route-builder-v2] No matching file for route: ${fsPath}`)
}
}
// Remove handle property
delete newRoute.handle
// Recursively process children
if (route.children) {
newRoute.children = processRoutes(route.children)
}
return newRoute
})
}
const processedRoutes = processRoutes(routes)
// Convert routes object to string and replace function placeholders
const routesString = JSON.stringify(processedRoutes, null, 2)
.replaceAll(/"__LAZY_(\w+)__"/g, "$1")
.replaceAll(/"__SYNC_([^.]+)\.Component__"/g, "$1.Component")
.replaceAll(/"__SYNC_([^.]+)\.loader__"/g, "$1.loader")
// Remove loader property if it's undefined
.replaceAll(/,?\s*"loader":\s*undefined/g, "")
return `// This file is auto-generated by vite-plugin-route-builder
// Do not edit manually
/* eslint-disable */
// @ts-nocheck
import type { RouteObject } from "react-router"
// Imports for page components
${imports.join("\n")}
// Generated route configuration
export const routes: RouteObject[] = ${routesString}
export default routes
`
}
function generateRoutes() {
try {
const pageFiles = glob.sync(pagePattern, {
cwd: root,
absolute: true,
})
logger.info(`[route-builder-v2] Found ${pageFiles.length} page files`)
// Build glob object, key is the relative path to pages directory
const globObject: Record<string, () => Promise<any>> = {}
const fileToImportMap: Record<string, string> = {}
pageFiles.forEach((absolutePath) => {
// Get relative path to root
const relativePath = relative(root, absolutePath)
// Convert to ./pages/ format for route-builder
let routeKey: string
if (relativePath.includes("/pages/")) {
routeKey = `./pages/${relativePath.split("/pages/")[1]}`
} else if (relativePath.includes("\\pages\\")) {
routeKey = `./pages/${relativePath.split("\\pages\\")[1]?.replaceAll("\\", "/")}`
} else {
// Assume file is in pages directory
routeKey = `./${relativePath.replaceAll("\\", "/")}`
}
// Apply custom path transformation
if (transformPath) {
routeKey = transformPath(routeKey)
}
// Generate relative path for import (relative to output file)
const outputDir = dirname(resolve(root, outputPath))
let importPath = relative(outputDir, absolutePath)
// Ensure correct path separator
importPath = importPath.replaceAll("\\", "/")
// Ensure relative path starts with ./ or ../
if (!importPath.startsWith(".")) {
importPath = `./${importPath}`
}
// Store the import path with different handling for sync vs async
let finalImportPath: string
if (importPath.endsWith(".sync.tsx")) {
// For sync files, remove .tsx but keep .sync for the import path
finalImportPath = importPath.replace(/\.tsx$/, "")
} else {
// For async files, remove .tsx extension
finalImportPath = importPath.replace(/\.tsx$/, "")
}
globObject[routeKey] = () => Promise.resolve({ default: () => null })
fileToImportMap[routeKey] = finalImportPath
if (debug) {
logger.info(`[route-builder-v2] Mapped: ${routeKey} -> ${finalImportPath}`)
}
})
// Use existing route building logic
const routes = buildGlobRoutes(globObject, { segmentGroupOrder })
// Generate route file content
const routeFileContent = generateRouteFileContent(routes, fileToImportMap)
const outputFilePath = resolve(root, outputPath)
writeFileSync(outputFilePath, routeFileContent, "utf-8")
logger.info(`[route-builder-v2] Generated routes: ${outputFilePath}`)
} catch (error: any) {
logger.error(`[route-builder-v2] Error generating routes:${error.message}`)
console.error(error)
throw error
}
}
return {
name: "vite-plugin-route-builder-v2",
configResolved(config) {
isProduction = config.command === "build"
root = config.root
logger = config.logger
},
buildStart() {
if (isProduction || enableInDev) {
generateRoutes()
}
},
configureServer(server) {
if (!enableInDev) return
const watchPattern = resolve(root, pagePattern.replace("./", ""))
server.watcher.add(watchPattern)
server.watcher.on("add", handleFileChange)
server.watcher.on("unlink", handleFileChange)
function handleFileChange(path: string) {
const relativePath = relative(root, path)
if (
relativePath.includes("/pages/") &&
(relativePath.endsWith(".tsx") || relativePath.endsWith(".sync.tsx"))
) {
logger.info(`[route-builder-v2] Page file changed: ${relativePath}`)
generateRoutes()
// Send custom HMR event
server.ws.send({
type: "custom",
event: "routes-updated",
data: { timestamp: Date.now() },
})
}
}
},
}
}
export default routeBuilderPluginV2

View File

@ -1,9 +0,0 @@
{
"extends": "../configs/tsconfig.extend.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@ -1,18 +0,0 @@
import tsconfigPath from "vite-tsconfig-paths"
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
globals: true,
environment: "node",
coverage: {
reporter: ["text", "json", "html"],
exclude: ["node_modules/", "src/__tests__/", "dist/", "*.config.*", "**/*.d.ts"],
},
},
plugins: [
tsconfigPath({
projects: ["./tsconfig.json"],
}),
],
})

View File

@ -6,6 +6,9 @@ settings:
catalogs:
default:
'@follow-app/client-sdk':
specifier: 0.3.22
version: 0.3.22
typescript:
specifier: 5.8.3
version: 5.8.3
@ -185,9 +188,6 @@ importers:
'@electron-toolkit/tsconfig':
specifier: 1.0.1
version: 1.0.1(@types/node@24.0.10)
'@follow-app/vite-plugin-route-builder':
specifier: workspace:*
version: link:../../packages/vite-plugin-route-builder
'@follow/components':
specifier: workspace:*
version: link:../../packages/internal/components
@ -246,8 +246,8 @@ importers:
specifier: 7.0.7
version: 7.0.7(postcss@8.5.6)
drizzle-orm:
specifier: 0.44.2
version: 0.44.2(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)
specifier: 0.44.3
version: 0.44.3(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)
electron:
specifier: 37.2.0
version: 37.2.0
@ -311,6 +311,9 @@ importers:
vite-plugin-pwa:
specifier: 1.0.1
version: 1.0.1(@vite-pwa/assets-generator@1.0.0)(vite@7.0.2(@types/node@24.0.10)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.20.3)(yaml@2.8.0))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0)
vite-plugin-route-builder:
specifier: 0.3.0
version: 0.3.0
vite-tsconfig-paths:
specifier: 5.1.4
version: 5.1.4(typescript@5.8.3)(vite@7.0.2(@types/node@24.0.10)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.20.3)(yaml@2.8.0))
@ -427,11 +430,11 @@ importers:
apps/desktop/layer/renderer:
dependencies:
'@ai-sdk/openai':
specifier: 2.0.0-beta.5
version: 2.0.0-beta.5(zod@3.25.75)
'@ai-sdk/react':
specifier: 2.0.0-beta.11
version: 2.0.0-beta.11(react@19.0.0)(zod@3.25.75)
version: 2.0.0-beta.11(zod@3.25.75)
'@ai-sdk/react':
specifier: 2.0.0-beta.25
version: 2.0.0-beta.25(react@19.0.0)(zod@3.25.75)
'@dnd-kit/core':
specifier: 6.3.1
version: 6.3.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -441,6 +444,9 @@ importers:
'@electron-toolkit/preload':
specifier: 3.0.2
version: 3.0.2(electron@37.2.0)
'@follow-app/client-sdk':
specifier: 'catalog:'
version: 0.3.22(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)
'@follow/database':
specifier: workspace:*
version: link:../../../../packages/internal/database
@ -456,6 +462,9 @@ importers:
'@follow/tracker':
specifier: workspace:*
version: link:../../../../packages/internal/tracker
'@folo-services/constants':
specifier: 0.1.6
version: 0.1.6
'@fontsource/sn-pro':
specifier: 5.2.5
version: 5.2.5
@ -535,8 +544,8 @@ importers:
specifier: 0.10.1
version: 0.10.1
ai:
specifier: 5.0.0-beta.11
version: 5.0.0-beta.11(zod@3.25.75)
specifier: 5.0.0-beta.25
version: 5.0.0-beta.25(zod@3.25.75)
camelcase-keys:
specifier: 9.1.3
version: 9.1.3
@ -727,6 +736,9 @@ importers:
'@follow/utils':
specifier: workspace:*
version: link:../../../../packages/internal/utils
'@folo-services/ai-tools':
specifier: 0.2.14
version: 0.2.14(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)
'@types/node':
specifier: 24.0.10
version: 24.0.10
@ -1226,9 +1238,6 @@ importers:
specifier: 3.25.75
version: 3.25.75
devDependencies:
'@follow-app/vite-plugin-route-builder':
specifier: workspace:*
version: link:../../packages/vite-plugin-route-builder
'@follow/components':
specifier: workspace:*
version: link:../../packages/internal/components
@ -1310,6 +1319,9 @@ importers:
vite:
specifier: 7.0.2
version: 7.0.2(@types/node@24.0.10)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.20.3)(yaml@2.8.0)
vite-plugin-route-builder:
specifier: 0.3.0
version: 0.3.0
packages/configs:
dependencies:
@ -1582,6 +1594,9 @@ importers:
packages/internal/constants:
dependencies:
'@follow-app/client-sdk':
specifier: 'catalog:'
version: 0.3.22(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)
'@follow/configs':
specifier: workspace:*
version: link:../../configs
@ -1591,6 +1606,9 @@ importers:
packages/internal/database:
dependencies:
'@follow-app/client-sdk':
specifier: 'catalog:'
version: 0.3.22(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)
'@follow/constants':
specifier: workspace:*
version: link:../constants
@ -1602,16 +1620,16 @@ importers:
version: link:../shared
ai:
specifier: 5.0.0-beta.7
version: 5.0.0-beta.7(zod@3.25.75)
version: 5.0.0-beta.7(zod@3.25.76)
drizzle-orm:
specifier: 0.44.2
version: 0.44.2(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)
specifier: 0.44.3
version: 0.44.3(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)
expo-sqlite:
specifier: 15.2.12
version: 15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0)
sqlocal:
specifier: npm:@hyoban/sqlocal@0.14.1-fork.4
version: '@hyoban/sqlocal@0.14.1-fork.4(bufferutil@4.0.9)(drizzle-orm@0.44.2(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2))(kysely@0.28.2)'
version: '@hyoban/sqlocal@0.14.1-fork.4(bufferutil@4.0.9)(drizzle-orm@0.44.3(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3))(kysely@0.28.2)'
wa-sqlite:
specifier: git+https://github.com/rhashimoto/wa-sqlite.git#v1.0.8
version: https://codeload.github.com/rhashimoto/wa-sqlite/tar.gz/03c00ed67cd934bd664ae310234bba317928f851
@ -1666,6 +1684,9 @@ importers:
packages/internal/models:
dependencies:
'@follow-app/client-sdk':
specifier: 'catalog:'
version: 0.3.22(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)
'@follow/constants':
specifier: workspace:*
version: link:../constants
@ -1710,8 +1731,8 @@ importers:
specifier: 1.2.9
version: 1.2.9
drizzle-orm:
specifier: 0.44.2
version: 0.44.2(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)
specifier: 0.44.3
version: 0.44.3(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)
hono:
specifier: 4.8.1
version: 4.8.1(patch_hash=5c74c2d2afaa5880c13d75458dd26c84da568851691b1fb6de6d877c29936d05)
@ -1727,6 +1748,9 @@ importers:
packages/internal/store:
dependencies:
'@follow-app/client-sdk':
specifier: 'catalog:'
version: 0.3.22(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)
'@follow/configs':
specifier: workspace:*
version: link:../../configs
@ -1859,39 +1883,6 @@ importers:
specifier: 0.12.9
version: 0.12.9(typescript@5.8.3)
packages/vite-plugin-route-builder:
devDependencies:
'@types/node':
specifier: 24.0.10
version: 24.0.10
es-toolkit:
specifier: 1.39.6
version: 1.39.6
fast-glob:
specifier: 3.3.3
version: 3.3.3
happy-dom:
specifier: 18.0.1
version: 18.0.1
react-router:
specifier: 7.6.3
version: 7.6.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
tsdown:
specifier: 0.12.9
version: 0.12.9(typescript@5.8.3)
typescript:
specifier: 'catalog:'
version: 5.8.3
vite:
specifier: 7.0.2
version: 7.0.2(@types/node@24.0.10)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.20.3)(yaml@2.8.0)
vite-tsconfig-paths:
specifier: 5.1.4
version: 5.1.4(typescript@5.8.3)(vite@7.0.2(@types/node@24.0.10)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.20.3)(yaml@2.8.0))
vitest:
specifier: 3.2.4
version: 3.2.4(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@24.0.10)(happy-dom@18.0.1)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.20.3)(yaml@2.8.0)
packages:
7zip-bin@5.2.0:
@ -1905,23 +1896,23 @@ packages:
graphql:
optional: true
'@ai-sdk/gateway@1.0.0-beta.11':
resolution: {integrity: sha512-dnRUPzSLvp3xvIx6M4FIz4ht8dfL8JkPKwH+akj10im4zbxUii3c3TQ3BJLRdx2Gq/SeljE9H0dX7PDtVyIrbQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4
'@ai-sdk/gateway@1.0.0-beta.3':
resolution: {integrity: sha512-g49gMSkXy94lYvl5LRh438OR/0JCG6ol0jV+iLot7cy5HLltZlGocEuauETBu4b10mDXOd7XIjTEZoQpYFMYLQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.49
'@ai-sdk/gateway@1.0.0-beta.4':
resolution: {integrity: sha512-P5/dS7pb+cBRWnTP0Aezq3/3PIrF+p64fUTxBAyZIert8qTyHm2gd6atbAJZprZ304Ui3QjA9MFybAa849//2w==}
'@ai-sdk/openai@2.0.0-beta.11':
resolution: {integrity: sha512-HQXUMb1V6Xr8EBYvEDwNb8ISyRqyxg2zUst7lzPb6s1nGDKJRBTfSyytNWRL9dZ9vxjM2wK34cltCfZbjaHpAA==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.49
'@ai-sdk/openai@2.0.0-beta.5':
resolution: {integrity: sha512-jDd3NKFIbLHam25XSkTsR9U0SZ7tgdl9z+HO0pu7n6sde6f5+CW/3zIk6BJoCNJKU9382kEDKu8u+6PREYd9ZQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.49
zod: ^3.25.76 || ^4
'@ai-sdk/provider-utils@3.0.0-beta.2':
resolution: {integrity: sha512-H4K+4weOVgWqrDDeAbQWoA4U5mN4WrQPHQFdH7ynQYcnhj/pzctU9Q6mGlR5ESMWxaXxazxlOblSITlXo9bahA==}
@ -1929,16 +1920,22 @@ packages:
peerDependencies:
zod: ^3.25.49
'@ai-sdk/provider-utils@3.0.0-beta.5':
resolution: {integrity: sha512-4Dv/wiGZrvO6fI7P0yMLa4XZru0XW8LPibTObbkHBdweLUVGIze7aCfxxQeY44Uqcbl/h6/yBTkx2XmPtwf/Ow==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4
'@ai-sdk/provider@2.0.0-beta.1':
resolution: {integrity: sha512-Z8SPncMtS3RsoXITmT7NVwrAq6M44dmw0DoUOYJqNNtCu8iMWuxB8Nxsoqpa0uEEy9R1V1ZThJAXTYgjTUxl3w==}
engines: {node: '>=18'}
'@ai-sdk/react@2.0.0-beta.11':
resolution: {integrity: sha512-twritdquNQSBAS24Qme3+AMwMbQa41WF2zcgmnTbgkeltG+gl1be1rWtaOKAXlm5nuQOH9CriUvvRskHkx+mDw==}
'@ai-sdk/react@2.0.0-beta.25':
resolution: {integrity: sha512-3f3f/z3idsH+sctQJ7t0enFywzIbHXDM6a3tvdPMX1dI53mAsVfyhaHzz8WT9swW3Mmphj0i5fZCH4t78m0L4A==}
engines: {node: '>=18'}
peerDependencies:
react: 19.0.0
zod: ^3.25.49
zod: ^3.25.76 || ^4
peerDependenciesMeta:
zod:
optional: true
@ -4024,6 +4021,27 @@ packages:
'@floating-ui/utils@0.2.9':
resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==}
'@follow-app/client-sdk@0.3.22':
resolution: {integrity: sha512-kFt3jQkgIQnCHwtiHa+08o4/X56VeDWpOA4Srus5llh5AEU0LvuZz2GWTsSmcOSGLDeFFXcMyxY7ZzScFR1igQ==}
'@folo-services/ai-tools@0.2.14':
resolution: {integrity: sha512-3DG/FqGqNvpCUtq1WMwzMYW6XuhQQfRfIGyZbokJ+XgbyR4YO3Jtz9T5fluINy+Q9iQSRyZSLyhMxe4Evsj5Hg==}
'@folo-services/constants@0.1.14':
resolution: {integrity: sha512-dR6KZhKnT6eKADoxhf+h4RNIdcqaO0TLV3eWhR7L4vuZY3uQJmscyvLfaXl0N+9Q3Dpt8IqzVCUz5PRt0eg9Lw==}
'@folo-services/constants@0.1.6':
resolution: {integrity: sha512-+Wdd0BfCmjddduteksJnbHl1GVrTJlFHnjXeyVJxr7l6nkHdW9lacpqLbyFBmlXm39Mtvvz6vDdR0DjaCsJG3w==}
'@folo-services/drizzle@0.1.9':
resolution: {integrity: sha512-X+YIuukSKaC5F7ZK7/zYDvtU75RVcJDC4lNvUdx/xCwaJgpRj0gjw8PMoTz7nc77Va99g6yw4a31cKxgfrPSSA==}
'@folo-services/exceptions@0.1.7':
resolution: {integrity: sha512-e3BnGAfCEt19kAvdijGvgdFI0IJ8pBANeEj+CXUtoa95UdHCw88x6ePWpJp1m8SL34ImmstywNhnWHHQgT8ZKg==}
'@folo-services/shared@0.0.4':
resolution: {integrity: sha512-wOxEbhMHeTMvlcHQmS9FjSNmvVIbLsxuhYm14ZT9yVL6ga5oaxcskZ0zlhvtY2Lkq/PobJ8WDqed3rH1rMIGiQ==}
'@fontsource/sn-pro@5.2.5':
resolution: {integrity: sha512-rBdBv/0ygj6bkO7xDMMFpwobLdSrcQ2Jncb6DIwdeYGoAgeWkQRwVYhGDKasfLjEYCNYxrqr6wsXsM9+aU39RA==}
@ -6966,11 +6984,12 @@ packages:
resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==}
engines: {node: '>=8'}
ai@5.0.0-beta.11:
resolution: {integrity: sha512-go/+f+KKnIWLPcv5IIcCEoZ0J8IAnEIgGPJwFDti9g1zuafjlxoFlBza8gu5oGODWOfjS7aEKUMG5Ac7GPPXsQ==}
ai@5.0.0-beta.25:
resolution: {integrity: sha512-pbfFqtQvz7hiDw6TwUH75CK9FgrZFBsxqbW4yW0aqluHw3nRbhf0w1u2AMiYgvWMy8Xf8TkBbMtY4vyMc4neeA==}
engines: {node: '>=18'}
hasBin: true
peerDependencies:
zod: ^3.25.49
zod: ^3.25.76 || ^4
ai@5.0.0-beta.6:
resolution: {integrity: sha512-0VqcqbWiF2XwWBgNfrEr05QrB0bJqctwLyuk7xfy7g8OBSqyJ4OV95HGTzrBvN8/iU/4u+eNTlF5nAG5Qx3utw==}
@ -8691,8 +8710,8 @@ packages:
resolution: {integrity: sha512-tCPWVZWZqWVx2XUsVpJRnH9Mx0ClVOf5YUHerZ5so1OKSlqww4zy1R5ksEdGRcO3tM3zj0PYN6V48TbQCL1RfA==}
hasBin: true
drizzle-orm@0.44.2:
resolution: {integrity: sha512-zGAqBzWWkVSFjZpwPOrmCrgO++1kZ5H/rZ4qTGeGOe18iXGVJWf3WPfHOVwFIbmi8kHjfJstC6rJomzGx8g/dQ==}
drizzle-orm@0.44.3:
resolution: {integrity: sha512-8nIiYQxOpgUicEL04YFojJmvC4DNO4KoyXsEIqN44+g6gNBr6hmVpWk3uyAt4CaTiRGDwoU+alfqNNeonLAFOQ==}
peerDependencies:
'@aws-sdk/client-rds-data': '>=3'
'@cloudflare/workers-types': '>=4'
@ -8783,6 +8802,12 @@ packages:
sqlite3:
optional: true
drizzle-zod@0.7.1:
resolution: {integrity: sha512-nZzALOdz44/AL2U005UlmMqaQ1qe5JfanvLujiTHiiT8+vZJTBFhj3pY4Vk+L6UWyKFfNmLhk602Hn4kCTynKQ==}
peerDependencies:
drizzle-orm: '>=0.36.0'
zod: '>=3.0.0'
ds-store@0.1.6:
resolution: {integrity: sha512-kY21M6Lz+76OS3bnCzjdsJSF7LBpLYGCVfavW8TgQD2XkcqIZ86W0y9qUDZu6fp7SIZzqosMDW2zi7zVFfv4hw==}
@ -12651,17 +12676,43 @@ packages:
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
pg-cloudflare@1.2.7:
resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==}
pg-connection-string@2.9.1:
resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==}
pg-int8@1.0.1:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'}
pg-pool@3.10.1:
resolution: {integrity: sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==}
peerDependencies:
pg: '>=8.0'
pg-protocol@1.10.2:
resolution: {integrity: sha512-Ci7jy8PbaWxfsck2dwZdERcDG2A0MG8JoQILs+uZNjABFuBuItAZCWUNz8sXRDMoui24rJw7WlXqgpMdBSN/vQ==}
pg-protocol@1.10.3:
resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==}
pg-types@2.2.0:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'}
pg@8.16.3:
resolution: {integrity: sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==}
engines: {node: '>= 16.0.0'}
peerDependencies:
pg-native: '>=3.0.1'
peerDependenciesMeta:
pg-native:
optional: true
pgpass@1.0.5:
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
phin@2.9.3:
resolution: {integrity: sha512-CzFr90qM24ju5f88quFC/6qohjC144rehe5n6DH900lgXmUe86+xCKc10ev56gRKC4/BkHUoG4uSiQgBiIXwDA==}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
@ -15452,6 +15503,9 @@ packages:
'@vite-pwa/assets-generator':
optional: true
vite-plugin-route-builder@0.3.0:
resolution: {integrity: sha512-ukCx4NN9QbnsBNrPyVAbMHcyH9bg+qJI2zJcRDkXAbzahuZUxGXOEZPWY8/uEm52vdzdknG4W6iYFKvO/4lQtA==}
vite-tsconfig-paths@5.1.4:
resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==}
peerDependencies:
@ -15930,6 +15984,9 @@ packages:
zod@3.25.75:
resolution: {integrity: sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg==}
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zustand@5.0.6:
resolution: {integrity: sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A==}
engines: {node: '>=12.20.0'}
@ -15964,23 +16021,41 @@ snapshots:
optionalDependencies:
graphql: 16.8.1
'@ai-sdk/gateway@1.0.0-beta.11(zod@3.25.75)':
dependencies:
'@ai-sdk/provider': 2.0.0-beta.1
'@ai-sdk/provider-utils': 3.0.0-beta.5(zod@3.25.75)
zod: 3.25.75
'@ai-sdk/gateway@1.0.0-beta.11(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 2.0.0-beta.1
'@ai-sdk/provider-utils': 3.0.0-beta.5(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/gateway@1.0.0-beta.3(zod@3.25.75)':
dependencies:
'@ai-sdk/provider': 2.0.0-beta.1
'@ai-sdk/provider-utils': 3.0.0-beta.2(zod@3.25.75)
zod: 3.25.75
'@ai-sdk/gateway@1.0.0-beta.4(zod@3.25.75)':
'@ai-sdk/gateway@1.0.0-beta.3(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 2.0.0-beta.1
'@ai-sdk/provider-utils': 3.0.0-beta.2(zod@3.25.75)
'@ai-sdk/provider-utils': 3.0.0-beta.2(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/openai@2.0.0-beta.11(zod@3.25.75)':
dependencies:
'@ai-sdk/provider': 2.0.0-beta.1
'@ai-sdk/provider-utils': 3.0.0-beta.5(zod@3.25.75)
zod: 3.25.75
'@ai-sdk/openai@2.0.0-beta.5(zod@3.25.75)':
'@ai-sdk/openai@2.0.0-beta.11(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 2.0.0-beta.1
'@ai-sdk/provider-utils': 3.0.0-beta.2(zod@3.25.75)
zod: 3.25.75
'@ai-sdk/provider-utils': 3.0.0-beta.5(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/provider-utils@3.0.0-beta.2(zod@3.25.75)':
dependencies:
@ -15990,14 +16065,38 @@ snapshots:
zod: 3.25.75
zod-to-json-schema: 3.24.5(zod@3.25.75)
'@ai-sdk/provider-utils@3.0.0-beta.2(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 2.0.0-beta.1
'@standard-schema/spec': 1.0.0
eventsource-parser: 3.0.3
zod: 3.25.76
zod-to-json-schema: 3.24.5(zod@3.25.76)
'@ai-sdk/provider-utils@3.0.0-beta.5(zod@3.25.75)':
dependencies:
'@ai-sdk/provider': 2.0.0-beta.1
'@standard-schema/spec': 1.0.0
eventsource-parser: 3.0.3
zod: 3.25.75
zod-to-json-schema: 3.24.5(zod@3.25.75)
'@ai-sdk/provider-utils@3.0.0-beta.5(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 2.0.0-beta.1
'@standard-schema/spec': 1.0.0
eventsource-parser: 3.0.3
zod: 3.25.76
zod-to-json-schema: 3.24.5(zod@3.25.76)
'@ai-sdk/provider@2.0.0-beta.1':
dependencies:
json-schema: 0.4.0
'@ai-sdk/react@2.0.0-beta.11(react@19.0.0)(zod@3.25.75)':
'@ai-sdk/react@2.0.0-beta.25(react@19.0.0)(zod@3.25.75)':
dependencies:
'@ai-sdk/provider-utils': 3.0.0-beta.2(zod@3.25.75)
ai: 5.0.0-beta.11(zod@3.25.75)
'@ai-sdk/provider-utils': 3.0.0-beta.5(zod@3.25.75)
ai: 5.0.0-beta.25(zod@3.25.75)
react: 19.0.0
swr: 2.3.3(react@19.0.0)
throttleit: 2.1.0
@ -19086,6 +19185,171 @@ snapshots:
'@floating-ui/utils@0.2.9': {}
'@follow-app/client-sdk@0.3.22(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)':
dependencies:
'@folo-services/constants': 0.1.14
'@folo-services/drizzle': 0.1.9(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)
'@folo-services/exceptions': 0.1.7
'@folo-services/shared': 0.0.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)
zod: 3.25.76
transitivePeerDependencies:
- '@aws-sdk/client-rds-data'
- '@cloudflare/workers-types'
- '@electric-sql/pglite'
- '@libsql/client'
- '@libsql/client-wasm'
- '@neondatabase/serverless'
- '@op-engineering/op-sqlite'
- '@opentelemetry/api'
- '@planetscale/database'
- '@prisma/client'
- '@tidbcloud/serverless'
- '@types/better-sqlite3'
- '@types/pg'
- '@types/sql.js'
- '@upstash/redis'
- '@vercel/postgres'
- '@xata.io/client'
- better-sqlite3
- bun-types
- expo-sqlite
- gel
- knex
- kysely
- mysql2
- pg
- pg-native
- postgres
- prisma
- sql.js
- sqlite3
'@folo-services/ai-tools@0.2.14(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)':
dependencies:
'@ai-sdk/openai': 2.0.0-beta.11(zod@3.25.76)
'@folo-services/drizzle': 0.1.9(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)
ai: 5.0.0-beta.25(zod@3.25.76)
drizzle-orm: 0.44.3(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)
zod: 3.25.76
transitivePeerDependencies:
- '@aws-sdk/client-rds-data'
- '@cloudflare/workers-types'
- '@electric-sql/pglite'
- '@libsql/client'
- '@libsql/client-wasm'
- '@neondatabase/serverless'
- '@op-engineering/op-sqlite'
- '@opentelemetry/api'
- '@planetscale/database'
- '@prisma/client'
- '@tidbcloud/serverless'
- '@types/better-sqlite3'
- '@types/pg'
- '@types/sql.js'
- '@upstash/redis'
- '@vercel/postgres'
- '@xata.io/client'
- better-sqlite3
- bun-types
- expo-sqlite
- gel
- knex
- kysely
- mysql2
- pg
- pg-native
- postgres
- prisma
- sql.js
- sqlite3
'@folo-services/constants@0.1.14':
dependencies:
zod: 3.25.76
'@folo-services/constants@0.1.6':
dependencies:
zod: 3.25.76
'@folo-services/drizzle@0.1.9(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)':
dependencies:
'@folo-services/exceptions': 0.1.7
drizzle-orm: 0.44.3(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)
drizzle-zod: 0.7.1(drizzle-orm@0.44.3(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3))(zod@3.25.76)
nanoid: 5.1.5
pg: 8.16.3
zod: 3.25.76
transitivePeerDependencies:
- '@aws-sdk/client-rds-data'
- '@cloudflare/workers-types'
- '@electric-sql/pglite'
- '@libsql/client'
- '@libsql/client-wasm'
- '@neondatabase/serverless'
- '@op-engineering/op-sqlite'
- '@opentelemetry/api'
- '@planetscale/database'
- '@prisma/client'
- '@tidbcloud/serverless'
- '@types/better-sqlite3'
- '@types/pg'
- '@types/sql.js'
- '@upstash/redis'
- '@vercel/postgres'
- '@xata.io/client'
- better-sqlite3
- bun-types
- expo-sqlite
- gel
- knex
- kysely
- mysql2
- pg-native
- postgres
- prisma
- sql.js
- sqlite3
'@folo-services/exceptions@0.1.7': {}
'@folo-services/shared@0.0.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)':
dependencies:
'@folo-services/drizzle': 0.1.9(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)
drizzle-orm: 0.44.3(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)
drizzle-zod: 0.7.1(drizzle-orm@0.44.3(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3))(zod@3.25.76)
zod: 3.25.76
transitivePeerDependencies:
- '@aws-sdk/client-rds-data'
- '@cloudflare/workers-types'
- '@electric-sql/pglite'
- '@libsql/client'
- '@libsql/client-wasm'
- '@neondatabase/serverless'
- '@op-engineering/op-sqlite'
- '@opentelemetry/api'
- '@planetscale/database'
- '@prisma/client'
- '@tidbcloud/serverless'
- '@types/better-sqlite3'
- '@types/pg'
- '@types/sql.js'
- '@upstash/redis'
- '@vercel/postgres'
- '@xata.io/client'
- better-sqlite3
- bun-types
- expo-sqlite
- gel
- knex
- kysely
- mysql2
- pg
- pg-native
- postgres
- prisma
- sql.js
- sqlite3
'@fontsource/sn-pro@5.2.5': {}
'@freakycoder/react-native-bounceable@1.0.3': {}
@ -19169,12 +19433,12 @@ snapshots:
tailwind-api-utils: 1.0.3(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.8.3)))
tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.8.3))
'@hyoban/sqlocal@0.14.1-fork.4(bufferutil@4.0.9)(drizzle-orm@0.44.2(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2))(kysely@0.28.2)':
'@hyoban/sqlocal@0.14.1-fork.4(bufferutil@4.0.9)(drizzle-orm@0.44.3(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3))(kysely@0.28.2)':
dependencies:
'@sqlite.org/sqlite-wasm': 3.50.0-build1
coincident: 1.2.3(bufferutil@4.0.9)
optionalDependencies:
drizzle-orm: 0.44.2(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)
drizzle-orm: 0.44.3(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)
kysely: 0.28.2
transitivePeerDependencies:
- bufferutil
@ -22549,14 +22813,22 @@ snapshots:
clean-stack: 2.2.0
indent-string: 4.0.0
ai@5.0.0-beta.11(zod@3.25.75):
ai@5.0.0-beta.25(zod@3.25.75):
dependencies:
'@ai-sdk/gateway': 1.0.0-beta.4(zod@3.25.75)
'@ai-sdk/gateway': 1.0.0-beta.11(zod@3.25.75)
'@ai-sdk/provider': 2.0.0-beta.1
'@ai-sdk/provider-utils': 3.0.0-beta.2(zod@3.25.75)
'@ai-sdk/provider-utils': 3.0.0-beta.5(zod@3.25.75)
'@opentelemetry/api': 1.9.0
zod: 3.25.75
ai@5.0.0-beta.25(zod@3.25.76):
dependencies:
'@ai-sdk/gateway': 1.0.0-beta.11(zod@3.25.76)
'@ai-sdk/provider': 2.0.0-beta.1
'@ai-sdk/provider-utils': 3.0.0-beta.5(zod@3.25.76)
'@opentelemetry/api': 1.9.0
zod: 3.25.76
ai@5.0.0-beta.6(zod@3.25.75):
dependencies:
'@ai-sdk/gateway': 1.0.0-beta.3(zod@3.25.75)
@ -22565,13 +22837,13 @@ snapshots:
'@opentelemetry/api': 1.9.0
zod: 3.25.75
ai@5.0.0-beta.7(zod@3.25.75):
ai@5.0.0-beta.7(zod@3.25.76):
dependencies:
'@ai-sdk/gateway': 1.0.0-beta.3(zod@3.25.75)
'@ai-sdk/gateway': 1.0.0-beta.3(zod@3.25.76)
'@ai-sdk/provider': 2.0.0-beta.1
'@ai-sdk/provider-utils': 3.0.0-beta.2(zod@3.25.75)
'@ai-sdk/provider-utils': 3.0.0-beta.2(zod@3.25.76)
'@opentelemetry/api': 1.9.0
zod: 3.25.75
zod: 3.25.76
ajv-formats@2.1.1(ajv@8.11.0):
optionalDependencies:
@ -24605,12 +24877,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
drizzle-orm@0.44.2(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2):
drizzle-orm@0.44.3(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3):
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/pg': 8.6.1
expo-sqlite: 15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0)
kysely: 0.28.2
pg: 8.16.3
drizzle-zod@0.7.1(drizzle-orm@0.44.3(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3))(zod@3.25.76):
dependencies:
drizzle-orm: 0.44.3(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)
zod: 3.25.76
ds-store@0.1.6:
dependencies:
@ -29521,10 +29799,21 @@ snapshots:
perfect-debounce@1.0.0: {}
pg-cloudflare@1.2.7:
optional: true
pg-connection-string@2.9.1: {}
pg-int8@1.0.1: {}
pg-pool@3.10.1(pg@8.16.3):
dependencies:
pg: 8.16.3
pg-protocol@1.10.2: {}
pg-protocol@1.10.3: {}
pg-types@2.2.0:
dependencies:
pg-int8: 1.0.1
@ -29533,6 +29822,20 @@ snapshots:
postgres-date: 1.0.7
postgres-interval: 1.2.0
pg@8.16.3:
dependencies:
pg-connection-string: 2.9.1
pg-pool: 3.10.1(pg@8.16.3)
pg-protocol: 1.10.3
pg-types: 2.2.0
pgpass: 1.0.5
optionalDependencies:
pg-cloudflare: 1.2.7
pgpass@1.0.5:
dependencies:
split2: 4.2.0
phin@2.9.3: {}
phin@3.7.1:
@ -32550,6 +32853,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
vite-plugin-route-builder@0.3.0: {}
vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@7.0.2(@types/node@24.0.10)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.20.3)(yaml@2.8.0)):
dependencies:
debug: 4.4.1(supports-color@8.1.1)
@ -33079,12 +33384,18 @@ snapshots:
dependencies:
zod: 3.25.75
zod-to-json-schema@3.24.5(zod@3.25.76):
dependencies:
zod: 3.25.76
zod-validation-error@3.5.2(zod@3.25.75):
dependencies:
zod: 3.25.75
zod@3.25.75: {}
zod@3.25.76: {}
zustand@5.0.6(@types/react@19.1.8)(immer@10.1.1(patch_hash=594c60b929bc0a3b56576f1a1787da1aec2a1fba51e3c21ec09c0ed38280af6c))(react@19.0.0)(use-sync-external-store@1.5.0(react@19.0.0)):
optionalDependencies:
'@types/react': 19.1.8

View File

@ -53,3 +53,4 @@ overrides:
catalog:
typescript: "5.8.3"
"@follow-app/client-sdk": "0.3.22"