diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c30f7ec..82854a8 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -36,7 +36,7 @@ jobs: cache: 'pnpm' - name: Install dependencies - run: pnpm install + run: pnpm install --no-frozen-lockfile - name: Run tests run: pnpm vitest --coverage.enabled true --coverage.reportOnFailure --coverage.reportsDirectory ./coverage || true diff --git a/.gitignore b/.gitignore index 74747f1..e38b459 100644 --- a/.gitignore +++ b/.gitignore @@ -112,6 +112,13 @@ web_modules/ .next out +dist/.vite +dist/assets +dist/global.css +dist/index.js +dist/favicon* +public/* +!public/.vite/ # Nuxt.js build / generate output diff --git a/dist/caret.svg b/dist/caret.svg new file mode 100644 index 0000000..ed60dad --- /dev/null +++ b/dist/caret.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/package.json b/package.json index 6c5df49..8c1b4c3 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ }, "scripts": { "deploy": "wrangler deploy", - "dev": "wrangler dev", + "deploy-with-ui": "pnpm run build && wrangler deploy", + "dev": "vite dev", + "build": "vite build --mode client && vite build", "start": "wrangler dev", "publish-npm-module": "npm publish --access public", "cf-typegen": "wrangler types", @@ -31,11 +33,16 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.20241216.0", + "@hono/vite-build": "^1.1.0", + "@hono/vite-dev-server": "^0.17.0", + "@tailwindcss/vite": "^4.0.6", "@types/pg": "^8.11.10", "@vitest/coverage-istanbul": "2.1.8", "husky": "^9.1.7", "lint-staged": "^15.2.11", + "postcss": "^8", "prettier": "3.4.2", + "tailwindcss": "^4.0.0", "typescript": "^5.7.2", "vitest": "2.1.8", "wrangler": "^3.96.0" @@ -43,13 +50,16 @@ "dependencies": { "@libsql/client": "^0.14.0", "@outerbase/sdk": "2.0.0-rc.3", + "clsx": "^2.1.1", "cron-parser": "^4.9.0", "hono": "^4.6.14", "jose": "^5.9.6", "mongodb": "^6.11.0", "mysql2": "^3.11.4", "node-sql-parser": "^4.18.0", - "pg": "^8.13.1" + "pg": "^8.13.1", + "tailwind-merge": "^2.6.0", + "vite": "^5.4.11" }, "lint-staged": { "*.{js,jsx,ts,tsx,json,css,md}": [ diff --git a/plugins/cdc/index.ts b/plugins/cdc/index.ts index cd22450..1eb59cc 100644 --- a/plugins/cdc/index.ts +++ b/plugins/cdc/index.ts @@ -12,7 +12,6 @@ interface ChangeEvent { column?: string } -// Add this new interface interface CDCEventPayload { action: string schema: string @@ -93,10 +92,16 @@ export class ChangeDataCapturePlugin extends StarbasePlugin { } try { + // Strip out RETURNING clause before parsing + const sqlWithoutReturning = opts.sql.replace( + /\s+RETURNING\s+.*$/i, + '' + ) + // Parse the SQL statement - const ast = parser.astify(opts.sql) + const ast = parser.astify(sqlWithoutReturning) const astObject = Array.isArray(ast) ? ast[0] : ast - const type = ast.type || ast[0].type + const type = astObject.type if (type === 'insert') { this.queryEventDetected('INSERT', astObject, opts.result) @@ -106,7 +111,7 @@ export class ChangeDataCapturePlugin extends StarbasePlugin { this.queryEventDetected('UPDATE', astObject, opts.result) } } catch (error) { - console.error('Error parsing SQL in CDC plugin:', error) + console.error('Error parsing SQL in CDC plugin:', opts?.sql, error) } return opts.result @@ -201,7 +206,7 @@ export class ChangeDataCapturePlugin extends StarbasePlugin { ) { // For any registered callback to the `onEvent` of our CDC plugin, we // will execute it after the response has been returned as to not impact - // roundtrip query times – hence the usage of `ctx.waitUntil(...)` + // roundtrip query times – hence the usage of `ctx.waitUntil(...)` const wrappedCallback = async (payload: CDCEventPayload) => { const result = callback(payload) if (result instanceof Promise && ctx) { diff --git a/plugins/interface/components/avatar/index.tsx b/plugins/interface/components/avatar/index.tsx new file mode 100644 index 0000000..ff877a2 --- /dev/null +++ b/plugins/interface/components/avatar/index.tsx @@ -0,0 +1,78 @@ +import { cn } from '../../utils/index' +import type { Child } from 'hono/jsx' + +type AvatarProps = { + as?: 'button' | 'a' + image?: string + size?: 'sm' | 'base' | 'lg' + toggled?: boolean + username: string + href?: string + class?: string + children?: Child +} + +export function Avatar({ + as = 'button', + image, + size = 'base', + toggled, + username, + href, + class: className, + ...props +}: AvatarProps) { + const firstInitial = username.charAt(0).toUpperCase() + + const baseClasses = cn( + 'ob-btn btn-secondary circular relative overflow-hidden', + { + 'ob-size-sm': size === 'sm', + 'ob-size-base': size === 'base', + 'ob-size-lg': size === 'lg', + interactive: as === 'button', + 'after:absolute after:top-0 after:left-0 after:z-10 after:size-full after:bg-black/5 after:opacity-0 after:transition-opacity hover:after:opacity-100 dark:after:bg-white/10': + image, + 'after:opacity-100': image && toggled, + toggle: !image && toggled, + } + ) + + const combinedClasses = [baseClasses, className].filter(Boolean).join(' ') + + const imgSize = size === 'sm' ? 28 : size === 'base' ? 32 : 36 + + if (as === 'a') { + return ( + + {image ? ( + {username} + ) : ( +

{firstInitial}

+ )} +
+ ) + } + + return ( + + ) +} diff --git a/plugins/interface/components/button/Button.tsx b/plugins/interface/components/button/Button.tsx new file mode 100644 index 0000000..13d3d0a --- /dev/null +++ b/plugins/interface/components/button/Button.tsx @@ -0,0 +1,86 @@ +import { FC, JSX } from 'hono/jsx' +import { Loader } from '../loader/Loader' +import { cn } from '../../utils/index' + +type ButtonProps = { + as?: string + children?: any + className?: string + disabled?: boolean + displayContent?: 'items-first' | 'items-last' + href?: string + loading?: boolean + shape?: 'base' | 'square' + size?: 'sm' | 'base' | 'lg' + title?: string + toggled?: boolean + variant?: 'primary' | 'secondary' | 'ghost' | 'destructive' + onClick?: () => void +} + +export const Button: FC = ({ + as, + children, + className, + disabled, + displayContent = 'items-last', + href, + loading, + shape = 'base', + size = 'base', + title, + toggled, + variant = 'secondary', + ...props +}) => { + const Component = (as || + (href ? 'a' : 'button')) as keyof JSX.IntrinsicElements + + return ( + + {shape !== 'square' && title} + + {loading ? ( + + + + ) : ( + children + )} + + ) +} diff --git a/plugins/interface/components/card/index.tsx b/plugins/interface/components/card/index.tsx new file mode 100644 index 0000000..4b5f694 --- /dev/null +++ b/plugins/interface/components/card/index.tsx @@ -0,0 +1,41 @@ +import { cn } from '../../utils/index' +import type { Child } from 'hono/jsx' + +type CardProps = { + as?: 'div' | 'a' + children?: Child + variant?: 'primary' | 'secondary' | 'ghost' | 'destructive' + href?: string + className?: string + [key: string]: any +} + +export function Card({ + as = 'div', + children, + variant = 'secondary', + href, + className, + ...props +}: CardProps) { + const baseClasses = cn('ob-btn w-full rounded-lg p-3', { + 'btn-primary': variant === 'primary', + 'btn-secondary': variant === 'secondary', + }) + + const combinedClasses = [baseClasses, className].filter(Boolean).join(' ') + + if (as === 'a') { + return ( + + {children} + + ) + } + + return ( +
+ {children} +
+ ) +} diff --git a/plugins/interface/components/input/Input.tsx b/plugins/interface/components/input/Input.tsx new file mode 100644 index 0000000..349bbaf --- /dev/null +++ b/plugins/interface/components/input/Input.tsx @@ -0,0 +1,99 @@ +import { cn } from '../../utils' +import { useState, type FC, type JSX } from 'hono/jsx' +import type { Child } from 'hono/jsx' + +export const inputClasses = cn( + 'bg-ob-btn-secondary-bg text-ob-base-300 border-ob-border focus:border-ob-border-active placeholder:text-ob-base-100 ob-disable border border-1 transition-colors focus:outline-none' +) + +export type InputProps = Omit & { + children?: Child + className?: string + displayContent?: 'items-first' | 'items-last' + initialValue?: string + isValid?: boolean + onValueChange: ((value: string, isValid: boolean) => void) | undefined + preText?: string[] | Child[] | Child + postText?: string[] | Child[] | Child + size?: 'sm' | 'base' | 'lg' +} + +export const Input: FC = ({ + children, + className, + displayContent, + initialValue, + isValid = true, + onValueChange, + preText, + postText, + size = 'base', + ...props +}) => { + const [currentValue, setCurrentValue] = useState(initialValue ?? '') + + const updateCurrentValue = (event: Event) => { + const target = event.target as HTMLInputElement + const newValue = target.value + setCurrentValue(newValue) + + if (onValueChange) { + if (!props.min) { + onValueChange(newValue, isValid) + } else if (typeof props.min === 'number') { + onValueChange(newValue.slice(0, props.min), isValid) + } + } + } + + return preText ? ( +
+ + {preText} + + + + + + {postText} + +
+ ) : ( + + ) +} diff --git a/plugins/interface/components/label/Label.tsx b/plugins/interface/components/label/Label.tsx new file mode 100644 index 0000000..204fd4a --- /dev/null +++ b/plugins/interface/components/label/Label.tsx @@ -0,0 +1,47 @@ +import type { Child, PropsWithChildren } from 'hono/jsx' +import { cn } from '../../utils' + +export interface LabelProps { + children?: Child + className?: string + isValid?: boolean + title: string + required?: boolean + requiredDescription?: string + [key: string]: any +} + +export function Label({ + children, + className, + isValid, + title, + required, + requiredDescription, + ...props +}: PropsWithChildren) { + return ( + + ) +} diff --git a/plugins/interface/components/loader/Loader.tsx b/plugins/interface/components/loader/Loader.tsx new file mode 100644 index 0000000..e02b4ca --- /dev/null +++ b/plugins/interface/components/loader/Loader.tsx @@ -0,0 +1,59 @@ +interface LoaderProps { + class?: string + size?: number +} + +export function Loader({ class: className, size = 24 }: LoaderProps) { + return ( + + + + + + + + + ) +} diff --git a/plugins/interface/components/select/index.tsx b/plugins/interface/components/select/index.tsx new file mode 100644 index 0000000..4c19437 --- /dev/null +++ b/plugins/interface/components/select/index.tsx @@ -0,0 +1,52 @@ +import { cn } from '../../utils/index' + +export type SelectProps = { + className?: string + options: string[] + placeholder?: string + setValue: (value: string) => void + size?: 'sm' | 'base' | 'lg' + value: string +} + +export const Select = ({ + className, + options, + placeholder, + setValue, + size = 'base', + value, +}: SelectProps) => { + return ( + + ) +} diff --git a/plugins/interface/components/toggle/index.tsx b/plugins/interface/components/toggle/index.tsx new file mode 100644 index 0000000..2618644 --- /dev/null +++ b/plugins/interface/components/toggle/index.tsx @@ -0,0 +1,34 @@ +import { cn } from '../../utils' + +type ToggleProps = { + onClick: () => void + size?: 'sm' | 'base' | 'lg' + toggled: boolean +} + +export const Toggle = ({ onClick, size = 'base', toggled }: ToggleProps) => { + return ( + + ) +} diff --git a/plugins/interface/index.tsx b/plugins/interface/index.tsx new file mode 100644 index 0000000..5fbf686 --- /dev/null +++ b/plugins/interface/index.tsx @@ -0,0 +1,129 @@ +import { DataSource } from '../../dist' +import { StarbaseApp } from '../../src/handler' +import { StarbasePlugin } from '../../src/plugin' +import { Handler } from 'hono' + +import { getAssetImportTagsFromManifest } from './utils/index' +import { jsxRenderer } from 'hono/jsx-renderer' +import { Style } from 'hono/css' + +interface RouteMapping { + path: string + page: string +} + +export class InterfacePlugin extends StarbasePlugin { + // Prefix route + pathPrefix: string = '/unused-but-required' + // Data source to run internal RPC queries + dataSource?: DataSource + // Array of routes registered in this class + private _supportedRoutes: RouteMapping[] = [] + + constructor() { + super('starbasedb:interface', { + requiresAuth: false, + }) + } + + private registerRoute( + app: StarbaseApp, + routeMapping: RouteMapping, + handler: Handler + ) { + this._supportedRoutes.push(routeMapping) + app.get(routeMapping.path, handler) + } + + override async register(app: StarbaseApp) { + app.use(async (c, next) => { + this.dataSource = c?.get('dataSource') + await next() + }) + + app.use( + '*', + jsxRenderer( + async ({ children }, c) => { + // Get current URL path + const path = new URL(c.req.url).pathname + + // Find matching route and get its page name + const currentRoute = this._supportedRoutes.find((route) => { + const routeParts = route.path.split('/') + const pathParts = path.split('/') + + if (routeParts.length !== pathParts.length) return false + + return routeParts.every((part, i) => { + if (part.startsWith(':')) return true // Match any value for parameters + return part === pathParts[i] + }) + }) + + const assetImportTags = + await getAssetImportTagsFromManifest(currentRoute?.page) + + return ( + + + + + StarbaseDB + + + + + \ No newline at end of file diff --git a/plugins/interface/public/global.css b/plugins/interface/public/global.css new file mode 100644 index 0000000..e5e1bc5 --- /dev/null +++ b/plugins/interface/public/global.css @@ -0,0 +1,318 @@ +@import 'tailwindcss'; + +/* Custom variants */ +@custom-variant dark (&:where(.dark, .dark *)); +@custom-variant interactive (&:where(.interactive, .interactive *)); +@custom-variant toggle (&:where(.toggle, .toggle *)); +@custom-variant square (&:where(.square, .square *)); +@custom-variant circular (&:where(.circular, .circular *)); + +/* Tailwind config */ +@theme { + /* Type */ + --text-xs: 10px; + --text-xs--line-height: calc(1 / 0.5); + + --text-sm: 12px; + --text-sm--line-height: calc(1 / 0.75); + + --text-base: 14px; + --text-base--line-height: calc(1.25 / 0.875); + + /* Easings & transitions */ + --ease-bounce: cubic-bezier(0.2, 0, 0, 1.5); + --default-transition-duration: 100ms /* snappier than default 150ms */; + + /* Animation */ + --animate-refresh: refresh 0.5s ease-in-out infinite; + + /* Base colors */ + --color-black: #000; + --color-white: #fff; + + --color-neutral-50: oklch(0.985 0 0); + --color-neutral-100: oklch(0.97 0 0); + --color-neutral-150: oklch(0.96 0 0) /*new */; + --color-neutral-200: oklch(0.922 0 0); + --color-neutral-250: oklch(0.9 0 0) /* new */; + --color-neutral-300: oklch(0.87 0 0); + --color-neutral-400: oklch(0.708 0 0); + --color-neutral-450: oklch(0.62 0 0) /* new */; + --color-neutral-500: oklch(0.556 0 0); + --color-neutral-600: oklch(0.439 0 0); + --color-neutral-700: oklch(0.371 0 0); + --color-neutral-750: oklch(0.31 0 0) /* new */; + --color-neutral-800: oklch(0.269 0 0); + --color-neutral-850: oklch(0.23 0 0) /* new */; + --color-neutral-900: oklch(0.205 0 0); + --color-neutral-925: oklch(0.175 0 0) /* new */; + --color-neutral-950: oklch(0.145 0 0); + + --color-red-650: oklch(0.55 0.238 27.4); + --color-red-750: oklch(0.46 0.195 27.2); + + --color-blue-400: oklch(0.707 0.165 254.624); + --color-blue-800: oklch(0.424 0.199 265.638); + + /* Component colors */ + + /* Base colors */ + --color-ob-base-100: var(--color-white); + --color-ob-base-200: var(--color-neutral-50); + --color-ob-base-300: var(--color-neutral-100); + --color-ob-base-400: var(--color-neutral-200); + --color-ob-base-500: var(--color-neutral-300); + --color-ob-base-1000: var(--color-neutral-900); + + --color-ob-border: var(--color-neutral-200); + --color-ob-border-active: var(--color-neutral-400); + + /* Text colors */ + --text-color-ob-base-100: var(--color-neutral-500); + --text-color-ob-base-200: var(--color-neutral-600); + --text-color-ob-base-300: var(--color-neutral-900); + --text-color-ob-destructive: var(--color-red-600); + --text-color-ob-inverted: var(--color-white); + + /* ob-btn */ + --color-ob-btn-primary-bg: var(--color-neutral-750); + --color-ob-btn-primary-bg-hover: var(--color-neutral-850); + --color-ob-btn-primary-border: var(--color-neutral-500); + --color-ob-btn-primary-border-hover: var(--color-neutral-600); + + --color-ob-btn-secondary-bg: var(--color-white); + --color-ob-btn-secondary-bg-hover: var(--color-neutral-50); + --color-ob-btn-secondary-border: var(--color-neutral-200); + --color-ob-btn-secondary-border-hover: var(--color-neutral-300); + + --color-ob-btn-ghost-bg-hover: var(--color-neutral-150); + + --color-ob-btn-destructive-bg: var(--color-red-600); + --color-ob-btn-destructive-bg-hover: var(--color-red-650); + --color-ob-btn-destructive-border: var(--color-red-400); + --color-ob-btn-destructive-border-hover: var(--color-red-500); + + /* Focus colors */ + --color-ob-focus: var(--color-blue-400); +} + +.dark { + /* Component colors */ + + /* Base colors */ + --color-ob-base-100: var(--color-neutral-950); + --color-ob-base-200: var(--color-neutral-900); + --color-ob-base-300: var(--color-neutral-850); + --color-ob-base-400: var(--color-neutral-800); + --color-ob-base-500: var(--color-neutral-750); + --color-ob-base-1000: var(--color-neutral-50); + + --color-ob-border: var(--color-neutral-800); + --color-ob-border-active: var(--color-neutral-700); + + /* Text colors */ + --text-color-ob-base-100: var(--color-neutral-500); + --text-color-ob-base-200: var(--color-neutral-400); + --text-color-ob-base-300: var(--color-neutral-50); + --text-color-ob-destructive: var(--color-red-400); + --text-color-ob-inverted: var(--color-neutral-900); + + /* ob-btn */ + --color-ob-btn-primary-bg: var(--color-neutral-300); + --color-ob-btn-primary-bg-hover: var(--color-neutral-250); + --color-ob-btn-primary-border: var(--color-neutral-100); + --color-ob-btn-primary-border-hover: var(--color-white); + + --color-ob-btn-secondary-bg: var(--color-neutral-900); + --color-ob-btn-secondary-bg-hover: var(--color-neutral-850); + --color-ob-btn-secondary-border: var(--color-neutral-800); + --color-ob-btn-secondary-border-hover: var(--color-neutral-750); + + --color-ob-btn-ghost-bg-hover: var(--color-neutral-850); + + --color-ob-btn-destructive-bg: var(--color-red-800); + --color-ob-btn-destructive-bg-hover: var(--color-red-750); + --color-ob-btn-destructive-border: var(--color-red-700); + --color-ob-btn-destructive-border-hover: var(--color-red-600); + + /* Focus colors */ + --color-ob-focus: var(--color-blue-800); +} + +html { + /* Base styles */ + background-color: var(--color-neutral-50); + color: var(--color-neutral-900); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + transition-property: color, background-color, border-color, + text-decoration-color, fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; + + /* Selection styles */ + &::selection { + background-color: var(--color-blue-400); + color: var(--color-white); + } + + /* Dark mode styles */ + &:where(.dark, .dark *) { + background-color: var(--color-neutral-950); + color: var(--color-neutral-100); + } +} + +.ob-btn { + &.btn-primary { + @apply border-ob-btn-primary-border bg-ob-btn-primary-bg text-ob-inverted shadow-xs; + + @variant interactive { + @apply not-disabled:hover:border-ob-btn-primary-border-hover not-disabled:hover:bg-ob-btn-primary-bg-hover; + + @variant toggle { + @apply not-disabled:border-ob-btn-primary-border-hover not-disabled:bg-ob-btn-primary-bg-hover; + } + } + } + + &.btn-secondary { + @apply border-ob-btn-secondary-border bg-ob-btn-secondary-bg text-ob-base-300 shadow-xs; + + @variant interactive { + @apply not-disabled:hover:border-ob-btn-secondary-border-hover not-disabled:hover:bg-ob-btn-secondary-bg-hover; + + @variant toggle { + @apply not-disabled:border-ob-btn-secondary-border-hover not-disabled:bg-ob-btn-secondary-bg-hover; + } + } + } + + &.btn-ghost { + @apply text-ob-base-300 border-transparent bg-transparent; + + @variant interactive { + @apply not-disabled:hover:bg-ob-btn-ghost-bg-hover; + + @variant toggle { + @apply not-disabled:bg-ob-btn-ghost-bg-hover; + } + } + } + + &.btn-destructive { + @apply border-ob-btn-destructive-border bg-ob-btn-destructive-bg text-white; + + @variant interactive { + @apply not-disabled:hover:bg-ob-btn-destructive-bg-hover not-disabled:hover:border-ob-btn-destructive-border-hover; + + @variant toggle { + @apply not-disabled:bg-ob-btn-destructive-bg-hover not-disabled:border-ob-btn-destructive-border-hover; + } + } + } + + @apply border; + + @variant interactive { + @apply cursor-pointer transition-colors; + } +} + +/* Use for elements that require a tab-focus state (most elements) */ +.ob-focus { + @apply focus-visible:ring-ob-focus outline-none focus:opacity-100 focus-visible:ring-1 *:in-focus:opacity-100; +} + +/* Use for elements that require a disabled state */ +.ob-disable { + @apply disabled:text-ob-base-100 disabled:cursor-not-allowed; +} + +/* Use size variants for elements that need to match certain heights */ +.ob-size-sm { + @apply h-6.5 rounded px-2 text-sm; + + @variant square { + @apply flex size-6.5 items-center justify-center px-0; + } + + @variant circular { + @apply flex size-6.5 items-center justify-center rounded-full px-0; + } +} + +.ob-size-base { + @apply h-8 rounded-md px-2.5 text-base; + + @variant square { + @apply flex size-8 items-center justify-center px-0; + } + + @variant circular { + @apply flex size-8 items-center justify-center rounded-full px-0; + } +} + +.ob-size-lg { + @apply h-9 rounded-md px-3 text-base; + + @variant square { + @apply flex size-9 items-center justify-center px-0; + } + + @variant circular { + @apply flex size-9 items-center justify-center rounded-full px-0; + } +} + +/* Database card animation */ +.db-card { + animation: db-card-animation 3s linear infinite; + animation-play-state: paused; /* pause while group is not hovered */ + stroke-dasharray: 100; /* length of each dash */ + + &:is(:where(.group):hover *) { + @media (hover: hover) { + animation-play-state: running; /* play while group is hovered */ + } + } +} + +@keyframes db-card-animation { + 0% { + stroke-dashoffset: -200; + } + 50% { + stroke-dashoffset: 0; + } + 100% { + stroke-dashoffset: 200; + } +} + +/* SVG filters */ +.pixelate { + filter: url(#pixelate); +} + +/* Ripple filter */ +.ripple { + filter: url(#ripple); +} + +.float { + animation: float 5s linear infinite alternate; +} + +@keyframes float { + to { + transform: translate(5px, 15px); + } +} + +@keyframes refresh { + to { + transform: rotate(360deg) scale(0.9); + } +} diff --git a/plugins/interface/utils/index.tsx b/plugins/interface/utils/index.tsx new file mode 100644 index 0000000..0f5eec1 --- /dev/null +++ b/plugins/interface/utils/index.tsx @@ -0,0 +1,71 @@ +import type { JSX } from 'hono/jsx' +import { ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export const cn = (...inputs: ClassValue[]) => { + return twMerge(clsx(inputs)) +} + +export async function getAssetImportTagsFromManifest(currentPage?: string) { + let manifest + try { + const rootManifest = await import('../../../public/.vite/manifest.json') + manifest = rootManifest.default + } catch (error) { + // If manifest file doesn't exist, return null + return null + } + + if (!manifest) return null + + const importTags: Array< + JSX.IntrinsicElements['link'] | JSX.IntrinsicElements['script'] + > = [] + + // Always include global CSS if it exists + const globalCssEntry = Object.values(manifest).find((entry) => + entry.file.includes('global') + ) + if (globalCssEntry?.file) { + importTags.push( + + ) + } + + // Include only current page JS and shared chunks + for (const [key, entry] of Object.entries(manifest)) { + // Debug each entry evaluation + const isCurrentPage = + currentPage && entry.file.includes(`${currentPage}.`) + const isSharedChunk = + entry.file.includes('vendor.') || entry.file.includes('components.') + + if (!entry.file.endsWith('.css') && (isCurrentPage || isSharedChunk)) { + const scriptTag = ( +