Merge branch 'main' into codex/refactor-generateflaskblueprint-sanitizer

This commit is contained in:
2026-01-18 18:04:26 +00:00
committed by GitHub
13 changed files with 921 additions and 75 deletions

View File

@@ -717,6 +717,348 @@
"status": "supported",
"source": "atoms"
},
{
"type": "ArrowLeft",
"name": "ArrowLeft",
"category": "display",
"canHaveChildren": false,
"description": "ArrowLeft icon",
"status": "supported",
"source": "icons"
},
{
"type": "ArrowRight",
"name": "ArrowRight",
"category": "display",
"canHaveChildren": false,
"description": "ArrowRight icon",
"status": "supported",
"source": "icons"
},
{
"type": "Check",
"name": "Check",
"category": "display",
"canHaveChildren": false,
"description": "Check icon",
"status": "supported",
"source": "icons"
},
{
"type": "X",
"name": "X",
"category": "display",
"canHaveChildren": false,
"description": "X icon",
"status": "supported",
"source": "icons"
},
{
"type": "Plus",
"name": "Plus",
"category": "display",
"canHaveChildren": false,
"description": "Plus icon",
"status": "supported",
"source": "icons"
},
{
"type": "Minus",
"name": "Minus",
"category": "display",
"canHaveChildren": false,
"description": "Minus icon",
"status": "supported",
"source": "icons"
},
{
"type": "Search",
"name": "Search",
"category": "display",
"canHaveChildren": false,
"description": "Search icon",
"status": "supported",
"source": "icons"
},
{
"type": "Filter",
"name": "Filter",
"category": "display",
"canHaveChildren": false,
"description": "Filter icon",
"status": "supported",
"source": "icons"
},
{
"type": "Download",
"name": "Download",
"category": "display",
"canHaveChildren": false,
"description": "Download icon",
"status": "supported",
"source": "icons"
},
{
"type": "Upload",
"name": "Upload",
"category": "display",
"canHaveChildren": false,
"description": "Upload icon",
"status": "supported",
"source": "icons"
},
{
"type": "Edit",
"name": "Edit",
"category": "display",
"canHaveChildren": false,
"description": "Edit icon",
"status": "supported",
"source": "icons"
},
{
"type": "Trash",
"name": "Trash",
"category": "display",
"canHaveChildren": false,
"description": "Trash icon",
"status": "supported",
"source": "icons"
},
{
"type": "Eye",
"name": "Eye",
"category": "display",
"canHaveChildren": false,
"description": "Eye icon",
"status": "supported",
"source": "icons"
},
{
"type": "EyeOff",
"name": "EyeOff",
"category": "display",
"canHaveChildren": false,
"description": "EyeOff icon",
"status": "supported",
"source": "icons"
},
{
"type": "ChevronUp",
"name": "ChevronUp",
"category": "display",
"canHaveChildren": false,
"description": "ChevronUp icon",
"status": "supported",
"source": "icons"
},
{
"type": "ChevronDown",
"name": "ChevronDown",
"category": "display",
"canHaveChildren": false,
"description": "ChevronDown icon",
"status": "supported",
"source": "icons"
},
{
"type": "ChevronLeft",
"name": "ChevronLeft",
"category": "display",
"canHaveChildren": false,
"description": "ChevronLeft icon",
"status": "supported",
"source": "icons"
},
{
"type": "ChevronRight",
"name": "ChevronRight",
"category": "display",
"canHaveChildren": false,
"description": "ChevronRight icon",
"status": "supported",
"source": "icons"
},
{
"type": "Settings",
"name": "Settings",
"category": "display",
"canHaveChildren": false,
"description": "Settings icon",
"status": "supported",
"source": "icons"
},
{
"type": "User",
"name": "User",
"category": "display",
"canHaveChildren": false,
"description": "User icon",
"status": "supported",
"source": "icons"
},
{
"type": "Bell",
"name": "Bell",
"category": "display",
"canHaveChildren": false,
"description": "Bell icon",
"status": "supported",
"source": "icons"
},
{
"type": "Mail",
"name": "Mail",
"category": "display",
"canHaveChildren": false,
"description": "Mail icon",
"status": "supported",
"source": "icons"
},
{
"type": "Calendar",
"name": "Calendar",
"category": "display",
"canHaveChildren": false,
"description": "Calendar icon",
"status": "supported",
"source": "icons"
},
{
"type": "Clock",
"name": "Clock",
"category": "display",
"canHaveChildren": false,
"description": "Clock icon",
"status": "supported",
"source": "icons"
},
{
"type": "Star",
"name": "Star",
"category": "display",
"canHaveChildren": false,
"description": "Star icon",
"status": "supported",
"source": "icons"
},
{
"type": "Heart",
"name": "Heart",
"category": "display",
"canHaveChildren": false,
"description": "Heart icon",
"status": "supported",
"source": "icons"
},
{
"type": "Share",
"name": "Share",
"category": "display",
"canHaveChildren": false,
"description": "Share icon",
"status": "supported",
"source": "icons"
},
{
"type": "Link",
"name": "Link",
"category": "display",
"canHaveChildren": false,
"description": "Link icon",
"status": "supported",
"source": "icons"
},
{
"type": "Copy",
"name": "Copy",
"category": "display",
"canHaveChildren": false,
"description": "Copy icon",
"status": "supported",
"source": "icons"
},
{
"type": "Save",
"name": "Save",
"category": "display",
"canHaveChildren": false,
"description": "Save icon",
"status": "supported",
"source": "icons"
},
{
"type": "RefreshCw",
"name": "RefreshCw",
"category": "display",
"canHaveChildren": false,
"description": "RefreshCw icon",
"status": "supported",
"source": "icons"
},
{
"type": "AlertCircle",
"name": "AlertCircle",
"category": "display",
"canHaveChildren": false,
"description": "AlertCircle icon",
"status": "supported",
"source": "icons"
},
{
"type": "Info",
"name": "Info",
"category": "display",
"canHaveChildren": false,
"description": "Info icon",
"status": "supported",
"source": "icons"
},
{
"type": "HelpCircle",
"name": "HelpCircle",
"category": "display",
"canHaveChildren": false,
"description": "HelpCircle icon",
"status": "supported",
"source": "icons"
},
{
"type": "Home",
"name": "Home",
"category": "display",
"canHaveChildren": false,
"description": "Home icon",
"status": "supported",
"source": "icons"
},
{
"type": "Menu",
"name": "Menu",
"category": "display",
"canHaveChildren": false,
"description": "Menu icon",
"status": "supported",
"source": "icons"
},
{
"type": "MoreVertical",
"name": "MoreVertical",
"category": "display",
"canHaveChildren": false,
"description": "MoreVertical icon",
"status": "supported",
"source": "icons"
},
{
"type": "MoreHorizontal",
"name": "MoreHorizontal",
"category": "display",
"canHaveChildren": false,
"description": "MoreHorizontal icon",
"status": "supported",
"source": "icons"
},
{
"type": "Breadcrumb",
"name": "Breadcrumb",
@@ -1926,25 +2268,27 @@
}
],
"statistics": {
"total": 222,
"supported": 209,
"total": 239,
"supported": 226,
"planned": 0,
"jsonCompatible": 13,
"jsonCompatible": 50,
"maybeJsonCompatible": 0,
"byCategory": {
"layout": 25,
"input": 34,
"display": 31,
"navigation": 15,
"feedback": 23,
"data": 25,
"custom": 69
"layout": 24,
"input": 26,
"display": 64,
"navigation": 12,
"feedback": 21,
"data": 27,
"custom": 65
},
"bySource": {
"atoms": 117,
"molecules": 40,
"organisms": 15,
"ui": 50
"molecules": 36,
"organisms": 13,
"ui": 25,
"wrappers": 10,
"icons": 38
}
}
}

View File

@@ -0,0 +1,102 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "JSON Components Registry",
"type": "object",
"required": ["version", "description", "components"],
"properties": {
"$schema": {
"type": "string"
},
"version": {
"type": "string"
},
"description": {
"type": "string"
},
"lastUpdated": {
"type": "string"
},
"categories": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"components": {
"type": "array",
"items": {
"type": "object",
"required": [
"type",
"name",
"category",
"canHaveChildren",
"description",
"status",
"source"
],
"properties": {
"type": {
"type": "string"
},
"name": {
"type": "string"
},
"export": {
"type": "string"
},
"category": {
"type": "string"
},
"canHaveChildren": {
"type": "boolean"
},
"description": {
"type": "string"
},
"status": {
"type": "string"
},
"source": {
"type": "string",
"enum": ["atoms", "molecules", "organisms", "ui", "wrappers", "icons"]
},
"jsonCompatible": {
"type": "boolean"
},
"wrapperRequired": {
"type": "boolean"
},
"wrapperComponent": {
"type": "string"
},
"wrapperFor": {
"type": "string"
},
"deprecated": {
"type": "object",
"properties": {
"replacedBy": {
"type": "string"
},
"message": {
"type": "string"
}
},
"additionalProperties": false
},
"metadata": {
"type": "object",
"additionalProperties": true
}
},
"additionalProperties": true
}
},
"statistics": {
"type": "object",
"additionalProperties": true
}
},
"additionalProperties": true
}

View File

@@ -69,6 +69,12 @@ export function usePWA() {
setState(prev => ({ ...prev, isOnline: false }))
}
const handleServiceWorkerMessage = (event: MessageEvent) => {
if (event.data && event.data.type === 'CACHE_CLEARED') {
window.location.reload()
}
}
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.addEventListener('appinstalled', handleAppInstalled)
window.addEventListener('online', handleOnline)
@@ -96,11 +102,7 @@ export function usePWA() {
console.error('[PWA] Service Worker registration failed:', error)
})
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data && event.data.type === 'CACHE_CLEARED') {
window.location.reload()
}
})
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
}
return () => {
@@ -108,6 +110,9 @@ export function usePWA() {
window.removeEventListener('appinstalled', handleAppInstalled)
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
if ('serviceWorker' in navigator) {
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
}
}
}, [])

View File

@@ -87,7 +87,12 @@ export function analyzePerformance() {
return null
}
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming
const navigation = performance.getEntriesByType('navigation')[0] as
| PerformanceNavigationTiming
| undefined
if (!navigation) {
console.warn('[BUNDLE] ⚠️ Navigation performance entry not available')
}
const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[]
const jsResources = resources.filter(r => r.name.endsWith('.js'))
@@ -97,9 +102,11 @@ export function analyzePerformance() {
const totalCssSize = cssResources.reduce((sum, r) => sum + (r.transferSize || 0), 0)
const analysis = {
domContentLoaded: navigation.domContentLoadedEventEnd - navigation.fetchStart,
loadComplete: navigation.loadEventEnd - navigation.fetchStart,
ttfb: navigation.responseStart - navigation.fetchStart,
domContentLoaded: navigation
? navigation.domContentLoadedEventEnd - navigation.fetchStart
: NaN,
loadComplete: navigation ? navigation.loadEventEnd - navigation.fetchStart : NaN,
ttfb: navigation ? navigation.responseStart - navigation.fetchStart : NaN,
resources: {
js: {
count: jsResources.length,

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import type { FlaskBlueprint } from '@/types/project'
import { generateFlaskBlueprint } from '../generateFlaskBlueprint'
const isValidIdentifier = (name: string): boolean => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name)
const extractBlueprintVariable = (code: string): { variable: string; name: string } => {
const match = code.match(/^([A-Za-z_][A-Za-z0-9_]*)_bp = Blueprint\('([^']+)'/m)
if (!match) {
throw new Error('Blueprint definition not found.')
}
return { variable: `${match[1]}_bp`, name: match[2] }
}
const extractFunctionNames = (code: string): string[] => {
return Array.from(code.matchAll(/^def ([A-Za-z_][A-Za-z0-9_]*)\(\):/gm)).map(match => match[1])
}
const extractDecoratorBlueprints = (code: string): string[] => {
return Array.from(code.matchAll(/^@([A-Za-z_][A-Za-z0-9_]*)\.route/gm)).map(match => match[1])
}
describe('generateFlaskBlueprint identifier sanitization', () => {
it('creates valid, consistent identifiers for tricky endpoint names', () => {
const blueprint: FlaskBlueprint = {
id: 'bp-1',
name: 'User Auth',
urlPrefix: '/auth',
description: 'Auth endpoints',
endpoints: [
{
id: 'ep-1',
name: 'get-user',
description: 'Fetch a user',
method: 'GET',
path: '/user'
},
{
id: 'ep-2',
name: '2fa',
description: 'Two factor auth',
method: 'POST',
path: '/2fa'
},
{
id: 'ep-3',
name: 'user.v1',
description: 'User v1 endpoint',
method: 'GET',
path: '/user/v1'
}
]
}
const code = generateFlaskBlueprint(blueprint)
const blueprintDefinition = extractBlueprintVariable(code)
const functionNames = extractFunctionNames(code)
const decoratorBlueprints = extractDecoratorBlueprints(code)
expect(isValidIdentifier(blueprintDefinition.name)).toBe(true)
expect(isValidIdentifier(blueprintDefinition.variable)).toBe(true)
expect(blueprintDefinition.variable).toBe('user_auth_bp')
expect(blueprintDefinition.name).toBe('user_auth')
expect(new Set(decoratorBlueprints)).toEqual(new Set([blueprintDefinition.variable]))
expect(functionNames).toEqual(['get_user', '_2fa', 'user_v1'])
functionNames.forEach(name => {
expect(isValidIdentifier(name)).toBe(true)
})
})
})

View File

@@ -1,5 +1,6 @@
import { FlaskConfig } from '@/types/project'
import { generateFlaskBlueprint } from './generateFlaskBlueprint'
import { sanitizeIdentifier } from './sanitizeIdentifier'
export function generateFlaskApp(config: FlaskConfig): Record<string, string> {
const files: Record<string, string> = {}
@@ -11,7 +12,7 @@ export function generateFlaskApp(config: FlaskConfig): Record<string, string> {
appCode += `\n`
config.blueprints.forEach(blueprint => {
const blueprintVarName = blueprint.name.toLowerCase().replace(/\s+/g, '_')
const blueprintVarName = sanitizeIdentifier(blueprint.name, { fallback: 'blueprint' })
appCode += `from blueprints.${blueprintVarName} import ${blueprintVarName}_bp\n`
})
@@ -34,7 +35,7 @@ export function generateFlaskApp(config: FlaskConfig): Record<string, string> {
}
config.blueprints.forEach(blueprint => {
const blueprintVarName = blueprint.name.toLowerCase().replace(/\s+/g, '_')
const blueprintVarName = sanitizeIdentifier(blueprint.name, { fallback: 'blueprint' })
appCode += ` app.register_blueprint(${blueprintVarName}_bp)\n`
})
@@ -50,7 +51,7 @@ export function generateFlaskApp(config: FlaskConfig): Record<string, string> {
files['app.py'] = appCode
config.blueprints.forEach(blueprint => {
const blueprintVarName = blueprint.name.toLowerCase().replace(/\s+/g, '_')
const blueprintVarName = sanitizeIdentifier(blueprint.name, { fallback: 'blueprint' })
files[`blueprints/${blueprintVarName}.py`] = generateFlaskBlueprint(blueprint)
})

View File

@@ -1,23 +1,28 @@
import { FlaskBlueprint } from '@/types/project'
import { sanitizeIdentifier } from './sanitizeIdentifier'
function toPythonIdentifier(value: string, fallback: string): string {
const normalized = value
.toLowerCase()
.replace(/[^a-z0-9_]/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
let safe = normalized || fallback
if (/^[0-9]/.test(safe)) {
safe = `_${safe}`
}
return safe
}
export function generateFlaskBlueprint(blueprint: FlaskBlueprint): string {
let code = `from flask import Blueprint, request, jsonify\n`
code += `from typing import Dict, Any\n\n`
const sanitizeName = (value: string): string => {
const normalized = value
.toLowerCase()
.replace(/[^a-z0-9_]+/gi, '_')
.replace(/_+/g, '_')
const safe = normalized.length > 0 ? normalized : '_'
return /^[a-z_]/i.test(safe) ? safe : `_${safe}`
}
const blueprintVarName = sanitizeName(blueprint.name)
const blueprintVarName = sanitizeIdentifier(blueprint.name, { fallback: 'blueprint' })
code += `${blueprintVarName}_bp = Blueprint('${blueprintVarName}', __name__, url_prefix='${blueprint.urlPrefix}')\n\n`
blueprint.endpoints.forEach(endpoint => {
const functionName = sanitizeName(endpoint.name)
const functionName = sanitizeIdentifier(endpoint.name, { fallback: 'endpoint' })
code += `@${blueprintVarName}_bp.route('${endpoint.path}', methods=['${endpoint.method}'])\n`
code += `def ${functionName}():\n`
code += ` """\n`
@@ -40,13 +45,14 @@ export function generateFlaskBlueprint(blueprint: FlaskBlueprint): string {
if (endpoint.queryParams && endpoint.queryParams.length > 0) {
endpoint.queryParams.forEach(param => {
const paramVarName = sanitizeIdentifier(param.name, { fallback: 'param' })
if (param.required) {
code += ` ${param.name} = request.args.get('${param.name}')\n`
code += ` if ${param.name} is None:\n`
code += ` ${paramVarName} = request.args.get('${param.name}')\n`
code += ` if ${paramVarName} is None:\n`
code += ` return jsonify({'error': '${param.name} is required'}), 400\n\n`
} else {
const defaultVal = param.defaultValue || (param.type === 'string' ? "''" : param.type === 'number' ? '0' : 'None')
code += ` ${param.name} = request.args.get('${param.name}', ${defaultVal})\n`
code += ` ${paramVarName} = request.args.get('${param.name}', ${defaultVal})\n`
}
})
code += `\n`

View File

@@ -0,0 +1,23 @@
type SanitizeIdentifierOptions = {
fallback?: string
}
export function sanitizeIdentifier(value: string, options: SanitizeIdentifierOptions = {}): string {
const fallback = options.fallback ?? 'identifier'
const trimmed = value.trim()
const normalized = trimmed
.toLowerCase()
.replace(/[^a-z0-9_]+/g, '_')
.replace(/^_+|_+$/g, '')
.replace(/_+/g, '_')
if (!normalized) {
return fallback
}
if (/^[0-9]/.test(normalized)) {
return `_${normalized}`
}
return normalized
}

View File

@@ -1,27 +1,47 @@
import { ComponentType } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { InputOtp } from '@/components/ui/input-otp'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { Alert as ShadcnAlert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { AlertDialog } from '@/components/ui/alert-dialog'
import { AspectRatio } from '@/components/ui/aspect-ratio'
import { Carousel } from '@/components/ui/carousel'
import { ChartContainer as Chart } from '@/components/ui/chart'
import { Collapsible } from '@/components/ui/collapsible'
import { Command } from '@/components/ui/command'
import { Switch } from '@/components/ui/switch'
import { Checkbox } from '@/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { DropdownMenu } from '@/components/ui/dropdown-menu'
import { Menubar } from '@/components/ui/menubar'
import { NavigationMenu } from '@/components/ui/navigation-menu'
import { Table as ShadcnTable, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Skeleton as ShadcnSkeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Pagination } from '@/components/ui/pagination'
import { ResizablePanelGroup as Resizable } from '@/components/ui/resizable'
import { Sheet } from '@/components/ui/sheet'
import { Sidebar } from '@/components/ui/sidebar'
import { Toaster as Sonner } from '@/components/ui/sonner'
import { ToggleGroup } from '@/components/ui/toggle-group'
import { Avatar as ShadcnAvatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { CircularProgress, Divider, ProgressBar } from '@/components/atoms'
import * as AtomComponents from '@/components/atoms'
import * as MoleculeComponents from '@/components/molecules'
import * as OrganismComponents from '@/components/organisms'
import {
ComponentBindingDialogWrapper,
ComponentTreeWrapper,
DataSourceEditorDialogWrapper,
GitHubBuildStatusWrapper,
LazyBarChartWrapper,
LazyD3BarChartWrapper,
LazyLineChartWrapper,
@@ -49,6 +69,9 @@ interface JsonRegistryEntry {
export?: string
source?: string
status?: string
wrapperRequired?: boolean
wrapperComponent?: string
wrapperFor?: string
deprecated?: DeprecatedComponentInfo
}
@@ -63,6 +86,9 @@ export interface DeprecatedComponentInfo {
const jsonRegistry = jsonComponentsRegistry as JsonComponentRegistry
const getRegistryEntryName = (entry: JsonRegistryEntry): string | undefined =>
entry.export ?? entry.name ?? entry.type
const buildRegistryFromNames = (
names: string[],
components: Record<string, ComponentType<any>>
@@ -77,10 +103,18 @@ const buildRegistryFromNames = (
}
const jsonRegistryEntries = jsonRegistry.components ?? []
const registryEntryByType = new Map(
jsonRegistryEntries
.map((entry) => {
const entryName = getRegistryEntryName(entry)
return entryName ? [entryName, entry] : null
})
.filter((entry): entry is [string, JsonRegistryEntry] => Boolean(entry))
)
const atomComponentMap = AtomComponents as Record<string, ComponentType<any>>
const deprecatedComponentInfo = jsonRegistryEntries.reduce<Record<string, DeprecatedComponentInfo>>(
(acc, entry) => {
const entryName = entry.export ?? entry.name ?? entry.type
const entryName = getRegistryEntryName(entry)
if (!entryName) {
return acc
}
@@ -93,15 +127,27 @@ const deprecatedComponentInfo = jsonRegistryEntries.reduce<Record<string, Deprec
)
const atomRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'atoms')
.map((entry) => entry.export ?? entry.name ?? entry.type)
.map((entry) => getRegistryEntryName(entry))
.filter((name): name is string => Boolean(name))
const moleculeRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'molecules')
.map((entry) => entry.export ?? entry.name ?? entry.type)
.map((entry) => getRegistryEntryName(entry))
.filter((name): name is string => Boolean(name))
const organismRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'organisms')
.map((entry) => entry.export ?? entry.name ?? entry.type)
.map((entry) => getRegistryEntryName(entry))
.filter((name): name is string => Boolean(name))
const shadcnRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'ui')
.map((entry) => getRegistryEntryName(entry))
.filter((name): name is string => Boolean(name))
const wrapperRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'wrappers')
.map((entry) => getRegistryEntryName(entry))
.filter((name): name is string => Boolean(name))
const iconRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'icons')
.map((entry) => getRegistryEntryName(entry))
.filter((name): name is string => Boolean(name))
export const primitiveComponents: UIComponentRegistry = {
@@ -123,9 +169,17 @@ export const primitiveComponents: UIComponentRegistry = {
nav: 'nav' as any,
}
export const shadcnComponents: UIComponentRegistry = {
const shadcnComponentMap: Record<string, ComponentType<any>> = {
AlertDialog,
AspectRatio,
Button,
Carousel,
Chart,
Collapsible,
Command,
DropdownMenu,
Input,
InputOtp,
Textarea,
Label,
Card,
@@ -164,13 +218,26 @@ export const shadcnComponents: UIComponentRegistry = {
DialogFooter,
DialogHeader,
DialogTitle,
Menubar,
NavigationMenu,
Skeleton: ShadcnSkeleton,
Pagination,
Progress,
Resizable,
Sheet,
Sidebar,
Sonner,
ToggleGroup,
Avatar: ShadcnAvatar,
AvatarFallback,
AvatarImage,
}
export const shadcnComponents: UIComponentRegistry = buildRegistryFromNames(
shadcnRegistryNames,
shadcnComponentMap
)
export const atomComponents: UIComponentRegistry = {
...buildRegistryFromNames(
atomRegistryNames,
@@ -208,16 +275,25 @@ export const organismComponents: UIComponentRegistry = buildRegistryFromNames(
OrganismComponents as Record<string, ComponentType<any>>
)
export const jsonWrapperComponents: UIComponentRegistry = {
SaveIndicator: SaveIndicatorWrapper,
LazyBarChart: LazyBarChartWrapper,
LazyLineChart: LazyLineChartWrapper,
LazyD3BarChart: LazyD3BarChartWrapper,
SeedDataManager: SeedDataManagerWrapper,
StorageSettings: StorageSettingsWrapper,
const wrapperComponentMap: Record<string, ComponentType<any>> = {
ComponentBindingDialogWrapper,
ComponentTreeWrapper,
DataSourceEditorDialogWrapper,
GitHubBuildStatusWrapper,
SaveIndicatorWrapper,
LazyBarChartWrapper,
LazyLineChartWrapper,
LazyD3BarChartWrapper,
SeedDataManagerWrapper,
StorageSettingsWrapper,
}
export const iconComponents: UIComponentRegistry = {
export const jsonWrapperComponents: UIComponentRegistry = buildRegistryFromNames(
wrapperRegistryNames,
wrapperComponentMap
)
const iconComponentMap: Record<string, ComponentType<any>> = {
ArrowLeft,
ArrowRight,
Check,
@@ -258,6 +334,11 @@ export const iconComponents: UIComponentRegistry = {
MoreHorizontal: DotsThree,
}
export const iconComponents: UIComponentRegistry = buildRegistryFromNames(
iconRegistryNames,
iconComponentMap
)
export const uiComponentRegistry: UIComponentRegistry = {
...primitiveComponents,
...shadcnComponents,

View File

@@ -0,0 +1,45 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { FlaskBackendAdapter } from '../flask-backend-adapter'
type MockResponse = {
ok: boolean
status: number
statusText: string
text: ReturnType<typeof vi.fn>
}
const createMockResponse = (status: number, body: string): MockResponse => ({
ok: status >= 200 && status < 300,
status,
statusText: status === 204 ? 'No Content' : 'OK',
text: vi.fn().mockResolvedValue(body),
})
describe('FlaskBackendAdapter.request', () => {
const baseUrl = 'http://example.test'
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.resetAllMocks()
})
it('resolves delete/clear when response is 204 or empty body', async () => {
fetchMock
.mockResolvedValueOnce(createMockResponse(204, '') as unknown as Response)
.mockResolvedValueOnce(createMockResponse(200, '') as unknown as Response)
const adapter = new FlaskBackendAdapter(baseUrl)
await expect(adapter.delete('example-key')).resolves.toBeUndefined()
await expect(adapter.clear()).resolves.toBeUndefined()
expect(fetchMock).toHaveBeenCalledTimes(2)
})
})

View File

@@ -25,11 +25,28 @@ export class FlaskBackendAdapter implements StorageAdapter {
clearTimeout(timeoutId)
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }))
throw new Error(error.error || `HTTP ${response.status}`)
let errorMessage = response.statusText
try {
const errorText = await response.text()
if (errorText) {
try {
const parsed = JSON.parse(errorText) as { error?: string }
errorMessage = parsed.error || errorText
} catch {
errorMessage = errorText
}
}
} catch {
// ignore error parsing failures
}
throw new Error(errorMessage || `HTTP ${response.status}`)
}
return response.json()
const responseText = await response.text()
if (!responseText) {
return undefined as T
}
return JSON.parse(responseText) as T
} catch (error: any) {
clearTimeout(timeoutId)
if (error.name === 'AbortError') {

View File

@@ -0,0 +1,145 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
callOrder,
mockFlaskGet,
mockIndexedGet,
mockSQLiteGet,
mockSparkGet,
MockFlaskBackendAdapter,
MockIndexedDBAdapter,
MockSQLiteAdapter,
MockSparkKVAdapter
} = vi.hoisted(() => {
const callOrder: string[] = []
const mockFlaskGet = vi.fn<[], Promise<unknown>>()
const mockIndexedGet = vi.fn<[], Promise<unknown>>()
const mockSQLiteGet = vi.fn<[], Promise<unknown>>()
const mockSparkGet = vi.fn<[], Promise<unknown>>()
class MockFlaskBackendAdapter {
constructor() {
callOrder.push('flask')
}
get = mockFlaskGet
}
class MockIndexedDBAdapter {
constructor() {
callOrder.push('indexeddb')
}
get = mockIndexedGet
}
class MockSQLiteAdapter {
constructor() {
callOrder.push('sqlite')
}
get = mockSQLiteGet
}
class MockSparkKVAdapter {
constructor() {
callOrder.push('sparkkv')
}
get = mockSparkGet
}
return {
callOrder,
mockFlaskGet,
mockIndexedGet,
mockSQLiteGet,
mockSparkGet,
MockFlaskBackendAdapter,
MockIndexedDBAdapter,
MockSQLiteAdapter,
MockSparkKVAdapter
}
})
vi.mock('./unified-storage-adapters', () => ({
FlaskBackendAdapter: MockFlaskBackendAdapter,
IndexedDBAdapter: MockIndexedDBAdapter,
SQLiteAdapter: MockSQLiteAdapter,
SparkKVAdapter: MockSparkKVAdapter
}))
const createLocalStorageMock = () => {
const store = new Map<string, string>()
return {
getItem: vi.fn((key: string) => store.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
store.set(key, value)
}),
removeItem: vi.fn((key: string) => {
store.delete(key)
}),
clear: vi.fn(() => {
store.clear()
})
}
}
describe('UnifiedStorage.detectAndInitialize', () => {
let localStorageMock: ReturnType<typeof createLocalStorageMock>
beforeEach(() => {
vi.resetModules()
callOrder.length = 0
mockFlaskGet.mockReset()
mockIndexedGet.mockReset()
mockSQLiteGet.mockReset()
mockSparkGet.mockReset()
localStorageMock = createLocalStorageMock()
vi.stubGlobal('localStorage', localStorageMock)
vi.stubGlobal('window', { spark: undefined })
if (!(import.meta as { env?: Record<string, string | undefined> }).env) {
;(import.meta as { env?: Record<string, string | undefined> }).env = {}
}
})
it('tries Flask before IndexedDB when prefer-flask is set', async () => {
localStorageMock.setItem('codeforge-prefer-flask', 'true')
mockFlaskGet.mockRejectedValue(new Error('flask down'))
mockIndexedGet.mockResolvedValue(undefined)
vi.stubGlobal('indexedDB', {})
const { unifiedStorage } = await import('./unified-storage')
await unifiedStorage.getBackend()
expect(callOrder[0]).toBe('flask')
expect(callOrder).toContain('indexeddb')
})
it('falls back to IndexedDB when Flask initialization fails', async () => {
localStorageMock.setItem('codeforge-prefer-flask', 'true')
mockFlaskGet.mockRejectedValue(new Error('flask down'))
mockIndexedGet.mockResolvedValue(undefined)
vi.stubGlobal('indexedDB', {})
const { unifiedStorage } = await import('./unified-storage')
const backend = await unifiedStorage.getBackend()
expect(backend).toBe('indexeddb')
})
it('honors prefer-sqlite when configured', async () => {
localStorageMock.setItem('codeforge-prefer-sqlite', 'true')
mockSQLiteGet.mockResolvedValue(undefined)
delete (globalThis as { indexedDB?: unknown }).indexedDB
const { unifiedStorage } = await import('./unified-storage')
const backend = await unifiedStorage.getBackend()
expect(backend).toBe('sqlite')
expect(callOrder).toContain('sqlite')
})
})

View File

@@ -19,6 +19,23 @@ class UnifiedStorage {
const flaskEnvUrl = import.meta.env.VITE_FLASK_BACKEND_URL
const preferSQLite = localStorage.getItem('codeforge-prefer-sqlite') === 'true'
if (preferFlask || flaskEnvUrl) {
try {
console.log('[Storage] Flask backend explicitly configured, attempting to initialize...')
const flaskAdapter = new FlaskBackendAdapter(flaskEnvUrl)
await Promise.race([
flaskAdapter.get('_health_check'),
new Promise((_, reject) => setTimeout(() => reject(new Error('Flask connection timeout')), 2000))
])
this.adapter = flaskAdapter
this.backend = 'flask'
console.log('[Storage] ✓ Using Flask backend')
return
} catch (error) {
console.warn('[Storage] Flask backend not available, falling back to IndexedDB:', error)
}
}
if (typeof indexedDB !== 'undefined') {
try {
console.log('[Storage] Initializing default IndexedDB backend...')
@@ -33,26 +50,6 @@ class UnifiedStorage {
}
}
if (preferFlask || flaskEnvUrl) {
try {
console.log('[Storage] Flask backend explicitly configured, attempting to initialize...')
const flaskAdapter = new FlaskBackendAdapter(flaskEnvUrl)
const testResponse = await Promise.race([
flaskAdapter.get('_health_check'),
new Promise((_, reject) => setTimeout(() => reject(new Error('Flask connection timeout')), 2000))
])
this.adapter = flaskAdapter
this.backend = 'flask'
console.log('[Storage] ✓ Using Flask backend')
return
} catch (error) {
console.warn('[Storage] Flask backend not available, already using IndexedDB:', error)
if (this.adapter && this.backend === 'indexeddb') {
return
}
}
}
if (preferSQLite) {
try {
console.log('[Storage] SQLite fallback, attempting to initialize...')