mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-24 13:44:54 +00:00
Merge branch 'main' into codex/change-detectandinitialize-ordering
This commit is contained in:
@@ -717,6 +717,348 @@
|
|||||||
"status": "supported",
|
"status": "supported",
|
||||||
"source": "atoms"
|
"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",
|
"type": "Breadcrumb",
|
||||||
"name": "Breadcrumb",
|
"name": "Breadcrumb",
|
||||||
@@ -1926,25 +2268,27 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"statistics": {
|
"statistics": {
|
||||||
"total": 222,
|
"total": 239,
|
||||||
"supported": 209,
|
"supported": 226,
|
||||||
"planned": 0,
|
"planned": 0,
|
||||||
"jsonCompatible": 13,
|
"jsonCompatible": 50,
|
||||||
"maybeJsonCompatible": 0,
|
"maybeJsonCompatible": 0,
|
||||||
"byCategory": {
|
"byCategory": {
|
||||||
"layout": 25,
|
"layout": 24,
|
||||||
"input": 34,
|
"input": 26,
|
||||||
"display": 31,
|
"display": 64,
|
||||||
"navigation": 15,
|
"navigation": 12,
|
||||||
"feedback": 23,
|
"feedback": 21,
|
||||||
"data": 25,
|
"data": 27,
|
||||||
"custom": 69
|
"custom": 65
|
||||||
},
|
},
|
||||||
"bySource": {
|
"bySource": {
|
||||||
"atoms": 117,
|
"atoms": 117,
|
||||||
"molecules": 40,
|
"molecules": 36,
|
||||||
"organisms": 15,
|
"organisms": 13,
|
||||||
"ui": 50
|
"ui": 25,
|
||||||
|
"wrappers": 10,
|
||||||
|
"icons": 38
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
102
schemas/json-components-registry-schema.json
Normal file
102
schemas/json-components-registry-schema.json
Normal 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
|
||||||
|
}
|
||||||
@@ -69,6 +69,12 @@ export function usePWA() {
|
|||||||
setState(prev => ({ ...prev, isOnline: false }))
|
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('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||||
window.addEventListener('appinstalled', handleAppInstalled)
|
window.addEventListener('appinstalled', handleAppInstalled)
|
||||||
window.addEventListener('online', handleOnline)
|
window.addEventListener('online', handleOnline)
|
||||||
@@ -96,11 +102,7 @@ export function usePWA() {
|
|||||||
console.error('[PWA] Service Worker registration failed:', error)
|
console.error('[PWA] Service Worker registration failed:', error)
|
||||||
})
|
})
|
||||||
|
|
||||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
|
||||||
if (event.data && event.data.type === 'CACHE_CLEARED') {
|
|
||||||
window.location.reload()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -108,6 +110,9 @@ export function usePWA() {
|
|||||||
window.removeEventListener('appinstalled', handleAppInstalled)
|
window.removeEventListener('appinstalled', handleAppInstalled)
|
||||||
window.removeEventListener('online', handleOnline)
|
window.removeEventListener('online', handleOnline)
|
||||||
window.removeEventListener('offline', handleOffline)
|
window.removeEventListener('offline', handleOffline)
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,12 @@ export function analyzePerformance() {
|
|||||||
return null
|
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 resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[]
|
||||||
|
|
||||||
const jsResources = resources.filter(r => r.name.endsWith('.js'))
|
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 totalCssSize = cssResources.reduce((sum, r) => sum + (r.transferSize || 0), 0)
|
||||||
|
|
||||||
const analysis = {
|
const analysis = {
|
||||||
domContentLoaded: navigation.domContentLoadedEventEnd - navigation.fetchStart,
|
domContentLoaded: navigation
|
||||||
loadComplete: navigation.loadEventEnd - navigation.fetchStart,
|
? navigation.domContentLoadedEventEnd - navigation.fetchStart
|
||||||
ttfb: navigation.responseStart - navigation.fetchStart,
|
: NaN,
|
||||||
|
loadComplete: navigation ? navigation.loadEventEnd - navigation.fetchStart : NaN,
|
||||||
|
ttfb: navigation ? navigation.responseStart - navigation.fetchStart : NaN,
|
||||||
resources: {
|
resources: {
|
||||||
js: {
|
js: {
|
||||||
count: jsResources.length,
|
count: jsResources.length,
|
||||||
|
|||||||
73
src/lib/generators/__tests__/generateFlaskBlueprint.test.ts
Normal file
73
src/lib/generators/__tests__/generateFlaskBlueprint.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FlaskConfig } from '@/types/project'
|
import { FlaskConfig } from '@/types/project'
|
||||||
import { generateFlaskBlueprint } from './generateFlaskBlueprint'
|
import { generateFlaskBlueprint } from './generateFlaskBlueprint'
|
||||||
|
import { sanitizeIdentifier } from './sanitizeIdentifier'
|
||||||
|
|
||||||
export function generateFlaskApp(config: FlaskConfig): Record<string, string> {
|
export function generateFlaskApp(config: FlaskConfig): Record<string, string> {
|
||||||
const files: Record<string, string> = {}
|
const files: Record<string, string> = {}
|
||||||
@@ -11,7 +12,7 @@ export function generateFlaskApp(config: FlaskConfig): Record<string, string> {
|
|||||||
appCode += `\n`
|
appCode += `\n`
|
||||||
|
|
||||||
config.blueprints.forEach(blueprint => {
|
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`
|
appCode += `from blueprints.${blueprintVarName} import ${blueprintVarName}_bp\n`
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ export function generateFlaskApp(config: FlaskConfig): Record<string, string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
config.blueprints.forEach(blueprint => {
|
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`
|
appCode += ` app.register_blueprint(${blueprintVarName}_bp)\n`
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ export function generateFlaskApp(config: FlaskConfig): Record<string, string> {
|
|||||||
files['app.py'] = appCode
|
files['app.py'] = appCode
|
||||||
|
|
||||||
config.blueprints.forEach(blueprint => {
|
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)
|
files[`blueprints/${blueprintVarName}.py`] = generateFlaskBlueprint(blueprint)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
import { FlaskBlueprint } from '@/types/project'
|
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 {
|
export function generateFlaskBlueprint(blueprint: FlaskBlueprint): string {
|
||||||
let code = `from flask import Blueprint, request, jsonify\n`
|
let code = `from flask import Blueprint, request, jsonify\n`
|
||||||
code += `from typing import Dict, Any\n\n`
|
code += `from typing import Dict, Any\n\n`
|
||||||
|
|
||||||
const blueprintVarName = blueprint.name.toLowerCase().replace(/\s+/g, '_')
|
const blueprintVarName = sanitizeIdentifier(blueprint.name, { fallback: 'blueprint' })
|
||||||
code += `${blueprintVarName}_bp = Blueprint('${blueprintVarName}', __name__, url_prefix='${blueprint.urlPrefix}')\n\n`
|
code += `${blueprintVarName}_bp = Blueprint('${blueprintVarName}', __name__, url_prefix='${blueprint.urlPrefix}')\n\n`
|
||||||
|
|
||||||
blueprint.endpoints.forEach(endpoint => {
|
blueprint.endpoints.forEach(endpoint => {
|
||||||
const functionName = endpoint.name.toLowerCase().replace(/\s+/g, '_')
|
const functionName = sanitizeIdentifier(endpoint.name, { fallback: 'endpoint' })
|
||||||
code += `@${blueprintVarName}_bp.route('${endpoint.path}', methods=['${endpoint.method}'])\n`
|
code += `@${blueprintVarName}_bp.route('${endpoint.path}', methods=['${endpoint.method}'])\n`
|
||||||
code += `def ${functionName}():\n`
|
code += `def ${functionName}():\n`
|
||||||
code += ` """\n`
|
code += ` """\n`
|
||||||
@@ -31,13 +45,14 @@ export function generateFlaskBlueprint(blueprint: FlaskBlueprint): string {
|
|||||||
|
|
||||||
if (endpoint.queryParams && endpoint.queryParams.length > 0) {
|
if (endpoint.queryParams && endpoint.queryParams.length > 0) {
|
||||||
endpoint.queryParams.forEach(param => {
|
endpoint.queryParams.forEach(param => {
|
||||||
|
const paramVarName = sanitizeIdentifier(param.name, { fallback: 'param' })
|
||||||
if (param.required) {
|
if (param.required) {
|
||||||
code += ` ${param.name} = request.args.get('${param.name}')\n`
|
code += ` ${paramVarName} = request.args.get('${param.name}')\n`
|
||||||
code += ` if ${param.name} is None:\n`
|
code += ` if ${paramVarName} is None:\n`
|
||||||
code += ` return jsonify({'error': '${param.name} is required'}), 400\n\n`
|
code += ` return jsonify({'error': '${param.name} is required'}), 400\n\n`
|
||||||
} else {
|
} else {
|
||||||
const defaultVal = param.defaultValue || (param.type === 'string' ? "''" : param.type === 'number' ? '0' : 'None')
|
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`
|
code += `\n`
|
||||||
|
|||||||
23
src/lib/generators/sanitizeIdentifier.ts
Normal file
23
src/lib/generators/sanitizeIdentifier.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -1,27 +1,47 @@
|
|||||||
import { ComponentType } from 'react'
|
import { ComponentType } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { InputOtp } from '@/components/ui/input-otp'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { Alert as ShadcnAlert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
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 { Switch } from '@/components/ui/switch'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
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 { Table as ShadcnTable, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Skeleton as ShadcnSkeleton } from '@/components/ui/skeleton'
|
import { Skeleton as ShadcnSkeleton } from '@/components/ui/skeleton'
|
||||||
import { Progress } from '@/components/ui/progress'
|
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 { Avatar as ShadcnAvatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { CircularProgress, Divider, ProgressBar } from '@/components/atoms'
|
import { CircularProgress, Divider, ProgressBar } from '@/components/atoms'
|
||||||
import * as AtomComponents from '@/components/atoms'
|
import * as AtomComponents from '@/components/atoms'
|
||||||
import * as MoleculeComponents from '@/components/molecules'
|
import * as MoleculeComponents from '@/components/molecules'
|
||||||
import * as OrganismComponents from '@/components/organisms'
|
import * as OrganismComponents from '@/components/organisms'
|
||||||
import {
|
import {
|
||||||
|
ComponentBindingDialogWrapper,
|
||||||
|
ComponentTreeWrapper,
|
||||||
|
DataSourceEditorDialogWrapper,
|
||||||
|
GitHubBuildStatusWrapper,
|
||||||
LazyBarChartWrapper,
|
LazyBarChartWrapper,
|
||||||
LazyD3BarChartWrapper,
|
LazyD3BarChartWrapper,
|
||||||
LazyLineChartWrapper,
|
LazyLineChartWrapper,
|
||||||
@@ -49,6 +69,9 @@ interface JsonRegistryEntry {
|
|||||||
export?: string
|
export?: string
|
||||||
source?: string
|
source?: string
|
||||||
status?: string
|
status?: string
|
||||||
|
wrapperRequired?: boolean
|
||||||
|
wrapperComponent?: string
|
||||||
|
wrapperFor?: string
|
||||||
deprecated?: DeprecatedComponentInfo
|
deprecated?: DeprecatedComponentInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +86,9 @@ export interface DeprecatedComponentInfo {
|
|||||||
|
|
||||||
const jsonRegistry = jsonComponentsRegistry as JsonComponentRegistry
|
const jsonRegistry = jsonComponentsRegistry as JsonComponentRegistry
|
||||||
|
|
||||||
|
const getRegistryEntryName = (entry: JsonRegistryEntry): string | undefined =>
|
||||||
|
entry.export ?? entry.name ?? entry.type
|
||||||
|
|
||||||
const buildRegistryFromNames = (
|
const buildRegistryFromNames = (
|
||||||
names: string[],
|
names: string[],
|
||||||
components: Record<string, ComponentType<any>>
|
components: Record<string, ComponentType<any>>
|
||||||
@@ -77,10 +103,18 @@ const buildRegistryFromNames = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const jsonRegistryEntries = jsonRegistry.components ?? []
|
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 atomComponentMap = AtomComponents as Record<string, ComponentType<any>>
|
||||||
const deprecatedComponentInfo = jsonRegistryEntries.reduce<Record<string, DeprecatedComponentInfo>>(
|
const deprecatedComponentInfo = jsonRegistryEntries.reduce<Record<string, DeprecatedComponentInfo>>(
|
||||||
(acc, entry) => {
|
(acc, entry) => {
|
||||||
const entryName = entry.export ?? entry.name ?? entry.type
|
const entryName = getRegistryEntryName(entry)
|
||||||
if (!entryName) {
|
if (!entryName) {
|
||||||
return acc
|
return acc
|
||||||
}
|
}
|
||||||
@@ -93,15 +127,27 @@ const deprecatedComponentInfo = jsonRegistryEntries.reduce<Record<string, Deprec
|
|||||||
)
|
)
|
||||||
const atomRegistryNames = jsonRegistryEntries
|
const atomRegistryNames = jsonRegistryEntries
|
||||||
.filter((entry) => entry.source === 'atoms')
|
.filter((entry) => entry.source === 'atoms')
|
||||||
.map((entry) => entry.export ?? entry.name ?? entry.type)
|
.map((entry) => getRegistryEntryName(entry))
|
||||||
.filter((name): name is string => Boolean(name))
|
.filter((name): name is string => Boolean(name))
|
||||||
const moleculeRegistryNames = jsonRegistryEntries
|
const moleculeRegistryNames = jsonRegistryEntries
|
||||||
.filter((entry) => entry.source === 'molecules')
|
.filter((entry) => entry.source === 'molecules')
|
||||||
.map((entry) => entry.export ?? entry.name ?? entry.type)
|
.map((entry) => getRegistryEntryName(entry))
|
||||||
.filter((name): name is string => Boolean(name))
|
.filter((name): name is string => Boolean(name))
|
||||||
const organismRegistryNames = jsonRegistryEntries
|
const organismRegistryNames = jsonRegistryEntries
|
||||||
.filter((entry) => entry.source === 'organisms')
|
.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))
|
.filter((name): name is string => Boolean(name))
|
||||||
|
|
||||||
export const primitiveComponents: UIComponentRegistry = {
|
export const primitiveComponents: UIComponentRegistry = {
|
||||||
@@ -123,9 +169,17 @@ export const primitiveComponents: UIComponentRegistry = {
|
|||||||
nav: 'nav' as any,
|
nav: 'nav' as any,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const shadcnComponents: UIComponentRegistry = {
|
const shadcnComponentMap: Record<string, ComponentType<any>> = {
|
||||||
|
AlertDialog,
|
||||||
|
AspectRatio,
|
||||||
Button,
|
Button,
|
||||||
|
Carousel,
|
||||||
|
Chart,
|
||||||
|
Collapsible,
|
||||||
|
Command,
|
||||||
|
DropdownMenu,
|
||||||
Input,
|
Input,
|
||||||
|
InputOtp,
|
||||||
Textarea,
|
Textarea,
|
||||||
Label,
|
Label,
|
||||||
Card,
|
Card,
|
||||||
@@ -164,13 +218,26 @@ export const shadcnComponents: UIComponentRegistry = {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
Menubar,
|
||||||
|
NavigationMenu,
|
||||||
Skeleton: ShadcnSkeleton,
|
Skeleton: ShadcnSkeleton,
|
||||||
|
Pagination,
|
||||||
Progress,
|
Progress,
|
||||||
|
Resizable,
|
||||||
|
Sheet,
|
||||||
|
Sidebar,
|
||||||
|
Sonner,
|
||||||
|
ToggleGroup,
|
||||||
Avatar: ShadcnAvatar,
|
Avatar: ShadcnAvatar,
|
||||||
AvatarFallback,
|
AvatarFallback,
|
||||||
AvatarImage,
|
AvatarImage,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const shadcnComponents: UIComponentRegistry = buildRegistryFromNames(
|
||||||
|
shadcnRegistryNames,
|
||||||
|
shadcnComponentMap
|
||||||
|
)
|
||||||
|
|
||||||
export const atomComponents: UIComponentRegistry = {
|
export const atomComponents: UIComponentRegistry = {
|
||||||
...buildRegistryFromNames(
|
...buildRegistryFromNames(
|
||||||
atomRegistryNames,
|
atomRegistryNames,
|
||||||
@@ -208,16 +275,25 @@ export const organismComponents: UIComponentRegistry = buildRegistryFromNames(
|
|||||||
OrganismComponents as Record<string, ComponentType<any>>
|
OrganismComponents as Record<string, ComponentType<any>>
|
||||||
)
|
)
|
||||||
|
|
||||||
export const jsonWrapperComponents: UIComponentRegistry = {
|
const wrapperComponentMap: Record<string, ComponentType<any>> = {
|
||||||
SaveIndicator: SaveIndicatorWrapper,
|
ComponentBindingDialogWrapper,
|
||||||
LazyBarChart: LazyBarChartWrapper,
|
ComponentTreeWrapper,
|
||||||
LazyLineChart: LazyLineChartWrapper,
|
DataSourceEditorDialogWrapper,
|
||||||
LazyD3BarChart: LazyD3BarChartWrapper,
|
GitHubBuildStatusWrapper,
|
||||||
SeedDataManager: SeedDataManagerWrapper,
|
SaveIndicatorWrapper,
|
||||||
StorageSettings: StorageSettingsWrapper,
|
LazyBarChartWrapper,
|
||||||
|
LazyLineChartWrapper,
|
||||||
|
LazyD3BarChartWrapper,
|
||||||
|
SeedDataManagerWrapper,
|
||||||
|
StorageSettingsWrapper,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const iconComponents: UIComponentRegistry = {
|
export const jsonWrapperComponents: UIComponentRegistry = buildRegistryFromNames(
|
||||||
|
wrapperRegistryNames,
|
||||||
|
wrapperComponentMap
|
||||||
|
)
|
||||||
|
|
||||||
|
const iconComponentMap: Record<string, ComponentType<any>> = {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Check,
|
Check,
|
||||||
@@ -258,6 +334,11 @@ export const iconComponents: UIComponentRegistry = {
|
|||||||
MoreHorizontal: DotsThree,
|
MoreHorizontal: DotsThree,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const iconComponents: UIComponentRegistry = buildRegistryFromNames(
|
||||||
|
iconRegistryNames,
|
||||||
|
iconComponentMap
|
||||||
|
)
|
||||||
|
|
||||||
export const uiComponentRegistry: UIComponentRegistry = {
|
export const uiComponentRegistry: UIComponentRegistry = {
|
||||||
...primitiveComponents,
|
...primitiveComponents,
|
||||||
...shadcnComponents,
|
...shadcnComponents,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -25,11 +25,28 @@ export class FlaskBackendAdapter implements StorageAdapter {
|
|||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ error: response.statusText }))
|
let errorMessage = response.statusText
|
||||||
throw new Error(error.error || `HTTP ${response.status}`)
|
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) {
|
} catch (error: any) {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
if (error.name === 'AbortError') {
|
if (error.name === 'AbortError') {
|
||||||
|
|||||||
145
src/lib/unified-storage.test.ts
Normal file
145
src/lib/unified-storage.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -23,7 +23,7 @@ class UnifiedStorage {
|
|||||||
try {
|
try {
|
||||||
console.log('[Storage] Flask backend explicitly configured, attempting to initialize...')
|
console.log('[Storage] Flask backend explicitly configured, attempting to initialize...')
|
||||||
const flaskAdapter = new FlaskBackendAdapter(flaskEnvUrl)
|
const flaskAdapter = new FlaskBackendAdapter(flaskEnvUrl)
|
||||||
const testResponse = await Promise.race([
|
await Promise.race([
|
||||||
flaskAdapter.get('_health_check'),
|
flaskAdapter.get('_health_check'),
|
||||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Flask connection timeout')), 2000))
|
new Promise((_, reject) => setTimeout(() => reject(new Error('Flask connection timeout')), 2000))
|
||||||
])
|
])
|
||||||
|
|||||||
Reference in New Issue
Block a user