mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-26 23:04:57 +00:00
Merge branch 'main' into codex/add-dependenciestab-and-scriptstab-components
This commit is contained in:
8
dbal/development/src/core/client.ts
Normal file
8
dbal/development/src/core/client.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { DBALConfig } from '../runtime/config'
|
||||
import { DBALClient } from './client/client'
|
||||
export { buildAdapter, buildEntityOperations } from './client/builders'
|
||||
export { normalizeClientConfig, validateClientConfig } from './client/mappers'
|
||||
|
||||
export const createDBALClient = (config: DBALConfig) => new DBALClient(config)
|
||||
|
||||
export { DBALClient }
|
||||
24
dbal/development/src/core/client/builders.ts
Normal file
24
dbal/development/src/core/client/builders.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { DBALAdapter } from '../../adapters/adapter'
|
||||
import type { DBALConfig } from '../../runtime/config'
|
||||
import { createAdapter } from './adapter-factory'
|
||||
import {
|
||||
createComponentOperations,
|
||||
createLuaScriptOperations,
|
||||
createPackageOperations,
|
||||
createPageOperations,
|
||||
createSessionOperations,
|
||||
createUserOperations,
|
||||
createWorkflowOperations
|
||||
} from '../entities'
|
||||
|
||||
export const buildAdapter = (config: DBALConfig): DBALAdapter => createAdapter(config)
|
||||
|
||||
export const buildEntityOperations = (adapter: DBALAdapter) => ({
|
||||
users: createUserOperations(adapter),
|
||||
pages: createPageOperations(adapter),
|
||||
components: createComponentOperations(adapter),
|
||||
workflows: createWorkflowOperations(adapter),
|
||||
luaScripts: createLuaScriptOperations(adapter),
|
||||
packages: createPackageOperations(adapter),
|
||||
sessions: createSessionOperations(adapter)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file client.ts
|
||||
* @description DBAL Client - Main interface for database operations
|
||||
*
|
||||
*
|
||||
* Provides CRUD operations for all entities through modular operation handlers.
|
||||
* Each entity type has its own dedicated operations module following the
|
||||
* single-responsibility pattern.
|
||||
@@ -9,82 +9,67 @@
|
||||
|
||||
import type { DBALConfig } from '../../runtime/config'
|
||||
import type { DBALAdapter } from '../../adapters/adapter'
|
||||
import { createAdapter } from './adapter-factory'
|
||||
import {
|
||||
createUserOperations,
|
||||
createPageOperations,
|
||||
createComponentOperations,
|
||||
createWorkflowOperations,
|
||||
createLuaScriptOperations,
|
||||
createPackageOperations,
|
||||
createSessionOperations,
|
||||
} from '../entities'
|
||||
import { buildAdapter, buildEntityOperations } from './builders'
|
||||
import { normalizeClientConfig, validateClientConfig } from './mappers'
|
||||
|
||||
export class DBALClient {
|
||||
private adapter: DBALAdapter
|
||||
private config: DBALConfig
|
||||
private operations: ReturnType<typeof buildEntityOperations>
|
||||
|
||||
constructor(config: DBALConfig) {
|
||||
this.config = config
|
||||
|
||||
// Validate configuration
|
||||
if (!config.adapter) {
|
||||
throw new Error('Adapter type must be specified')
|
||||
}
|
||||
if (config.mode !== 'production' && !config.database?.url) {
|
||||
throw new Error('Database URL must be specified for non-production mode')
|
||||
}
|
||||
|
||||
this.adapter = createAdapter(config)
|
||||
this.config = normalizeClientConfig(validateClientConfig(config))
|
||||
this.adapter = buildAdapter(this.config)
|
||||
this.operations = buildEntityOperations(this.adapter)
|
||||
}
|
||||
|
||||
/**
|
||||
* User entity operations
|
||||
*/
|
||||
get users() {
|
||||
return createUserOperations(this.adapter)
|
||||
return this.operations.users
|
||||
}
|
||||
|
||||
/**
|
||||
* Page entity operations
|
||||
*/
|
||||
get pages() {
|
||||
return createPageOperations(this.adapter)
|
||||
return this.operations.pages
|
||||
}
|
||||
|
||||
/**
|
||||
* Component hierarchy entity operations
|
||||
*/
|
||||
get components() {
|
||||
return createComponentOperations(this.adapter)
|
||||
return this.operations.components
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow entity operations
|
||||
*/
|
||||
get workflows() {
|
||||
return createWorkflowOperations(this.adapter)
|
||||
return this.operations.workflows
|
||||
}
|
||||
|
||||
/**
|
||||
* Lua script entity operations
|
||||
*/
|
||||
get luaScripts() {
|
||||
return createLuaScriptOperations(this.adapter)
|
||||
return this.operations.luaScripts
|
||||
}
|
||||
|
||||
/**
|
||||
* Package entity operations
|
||||
*/
|
||||
get packages() {
|
||||
return createPackageOperations(this.adapter)
|
||||
return this.operations.packages
|
||||
}
|
||||
|
||||
/**
|
||||
* Session entity operations
|
||||
*/
|
||||
get sessions() {
|
||||
return createSessionOperations(this.adapter)
|
||||
return this.operations.sessions
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
25
dbal/development/src/core/client/mappers.ts
Normal file
25
dbal/development/src/core/client/mappers.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { DBALConfig } from '../../runtime/config'
|
||||
import { DBALError } from '../foundation/errors'
|
||||
|
||||
export const validateClientConfig = (config: DBALConfig): DBALConfig => {
|
||||
if (!config.adapter) {
|
||||
throw DBALError.validationError('Adapter type must be specified', [])
|
||||
}
|
||||
|
||||
if (config.mode !== 'production' && !config.database?.url) {
|
||||
throw DBALError.validationError('Database URL must be specified for non-production mode', [])
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
export const normalizeClientConfig = (config: DBALConfig): DBALConfig => ({
|
||||
...config,
|
||||
security: {
|
||||
sandbox: config.security?.sandbox ?? 'strict',
|
||||
enableAuditLog: config.security?.enableAuditLog ?? true
|
||||
},
|
||||
performance: {
|
||||
...config.performance
|
||||
}
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
export { DBALClient } from './core/client/client'
|
||||
export { DBALClient, createDBALClient } from './core/client'
|
||||
export type { DBALConfig } from './runtime/config'
|
||||
export type * from './core/foundation/types'
|
||||
export { DBALError, DBALErrorCode } from './core/foundation/errors'
|
||||
|
||||
100
frontends/nextjs/src/hooks/__tests__/useAuth.roles.test.ts
Normal file
100
frontends/nextjs/src/hooks/__tests__/useAuth.roles.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import type { User } from '@/lib/level-types'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { fetchSession } from '@/lib/auth/api/fetch-session'
|
||||
import { login as loginRequest } from '@/lib/auth/api/login'
|
||||
import { logout as logoutRequest } from '@/lib/auth/api/logout'
|
||||
|
||||
vi.mock('@/lib/auth/api/fetch-session', () => ({
|
||||
fetchSession: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/api/login', () => ({
|
||||
login: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/api/logout', () => ({
|
||||
logout: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockFetchSession = vi.mocked(fetchSession)
|
||||
const mockLogin = vi.mocked(loginRequest)
|
||||
const mockLogout = vi.mocked(logoutRequest)
|
||||
|
||||
const createUser = (overrides?: Partial<User>): User => ({
|
||||
id: 'user_1',
|
||||
username: 'alice',
|
||||
email: 'alice@example.com',
|
||||
role: 'user',
|
||||
createdAt: 1000,
|
||||
tenantId: undefined,
|
||||
profilePicture: undefined,
|
||||
bio: undefined,
|
||||
isInstanceOwner: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const waitForIdle = async (result: { current: { isLoading: boolean } }) => {
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
}
|
||||
|
||||
const resetAuthStore = async () => {
|
||||
const { result, unmount } = renderHook(() => useAuth())
|
||||
await waitForIdle(result)
|
||||
await act(async () => {
|
||||
await result.current.logout()
|
||||
})
|
||||
await waitForIdle(result)
|
||||
unmount()
|
||||
}
|
||||
|
||||
describe('useAuth role mapping', () => {
|
||||
beforeEach(async () => {
|
||||
mockFetchSession.mockReset()
|
||||
mockLogin.mockReset()
|
||||
mockLogout.mockReset()
|
||||
mockFetchSession.mockResolvedValue(null)
|
||||
mockLogout.mockResolvedValue(undefined)
|
||||
|
||||
await resetAuthStore()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ role: 'public', expectedLevel: 1 },
|
||||
{ role: 'user', expectedLevel: 2 },
|
||||
{ role: 'admin', expectedLevel: 4 },
|
||||
{ role: 'supergod', expectedLevel: 6 },
|
||||
{ role: 'unknown', expectedLevel: 0 },
|
||||
])('applies level for role "$role"', async ({ role, expectedLevel }) => {
|
||||
const { result, unmount } = renderHook(() => useAuth())
|
||||
|
||||
mockLogin.mockResolvedValue(createUser({ role }))
|
||||
|
||||
await waitForIdle(result)
|
||||
await act(async () => {
|
||||
await result.current.login('alice@example.com', 'password')
|
||||
})
|
||||
|
||||
expect(result.current.user?.level).toBe(expectedLevel)
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('maps refreshed session roles to levels', async () => {
|
||||
const { result, unmount } = renderHook(() => useAuth())
|
||||
|
||||
mockFetchSession.mockResolvedValue(createUser({ role: 'moderator' }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refresh()
|
||||
})
|
||||
await waitForIdle(result)
|
||||
|
||||
expect(result.current.user?.level).toBe(3)
|
||||
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import type { User } from '@/lib/level-types'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { fetchSession } from '@/lib/auth/api/fetch-session'
|
||||
import { login as loginRequest } from '@/lib/auth/api/login'
|
||||
import { register as registerRequest } from '@/lib/auth/api/register'
|
||||
import { logout as logoutRequest } from '@/lib/auth/api/logout'
|
||||
|
||||
vi.mock('@/lib/auth/api/fetch-session', () => ({
|
||||
fetchSession: vi.fn(),
|
||||
@@ -18,12 +23,6 @@ vi.mock('@/lib/auth/api/logout', () => ({
|
||||
logout: vi.fn(),
|
||||
}))
|
||||
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { fetchSession } from '@/lib/auth/api/fetch-session'
|
||||
import { login as loginRequest } from '@/lib/auth/api/login'
|
||||
import { register as registerRequest } from '@/lib/auth/api/register'
|
||||
import { logout as logoutRequest } from '@/lib/auth/api/logout'
|
||||
|
||||
const mockFetchSession = vi.mocked(fetchSession)
|
||||
const mockLogin = vi.mocked(loginRequest)
|
||||
const mockRegister = vi.mocked(registerRequest)
|
||||
@@ -48,7 +47,17 @@ const waitForIdle = async (result: { current: { isLoading: boolean } }) => {
|
||||
})
|
||||
}
|
||||
|
||||
describe('useAuth', () => {
|
||||
const resetAuthStore = async () => {
|
||||
const { result, unmount } = renderHook(() => useAuth())
|
||||
await waitForIdle(result)
|
||||
await act(async () => {
|
||||
await result.current.logout()
|
||||
})
|
||||
await waitForIdle(result)
|
||||
unmount()
|
||||
}
|
||||
|
||||
describe('useAuth session flows', () => {
|
||||
beforeEach(async () => {
|
||||
mockFetchSession.mockReset()
|
||||
mockLogin.mockReset()
|
||||
@@ -57,16 +66,10 @@ describe('useAuth', () => {
|
||||
mockFetchSession.mockResolvedValue(null)
|
||||
mockLogout.mockResolvedValue(undefined)
|
||||
|
||||
const { result, unmount } = renderHook(() => useAuth())
|
||||
await waitForIdle(result)
|
||||
await act(async () => {
|
||||
await result.current.logout()
|
||||
})
|
||||
await waitForIdle(result)
|
||||
unmount()
|
||||
await resetAuthStore()
|
||||
})
|
||||
|
||||
it('should start unauthenticated after session check', async () => {
|
||||
it('starts unauthenticated after session check', async () => {
|
||||
const { result, unmount } = renderHook(() => useAuth())
|
||||
|
||||
await waitForIdle(result)
|
||||
@@ -77,28 +80,20 @@ describe('useAuth', () => {
|
||||
unmount()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ email: 'alice@example.com', expectedName: 'alice' },
|
||||
{ email: 'bob.smith@corp.io', expectedName: 'bob.smith' },
|
||||
])('should authenticate $email', async ({ email, expectedName }) => {
|
||||
it('authenticates on login', async () => {
|
||||
const { result, unmount } = renderHook(() => useAuth())
|
||||
|
||||
mockLogin.mockResolvedValue(createUser({
|
||||
id: 'user_1',
|
||||
username: expectedName,
|
||||
email,
|
||||
}))
|
||||
mockLogin.mockResolvedValue(createUser())
|
||||
|
||||
await waitForIdle(result)
|
||||
await act(async () => {
|
||||
await result.current.login(email, 'password')
|
||||
await result.current.login('alice@example.com', 'password')
|
||||
})
|
||||
|
||||
expect(result.current.user).toMatchObject({
|
||||
id: 'user_1',
|
||||
email,
|
||||
name: expectedName,
|
||||
username: expectedName,
|
||||
email: 'alice@example.com',
|
||||
username: 'alice',
|
||||
level: 2,
|
||||
})
|
||||
expect(result.current.isAuthenticated).toBe(true)
|
||||
@@ -106,7 +101,7 @@ describe('useAuth', () => {
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('should clear user on logout', async () => {
|
||||
it('clears user on logout', async () => {
|
||||
const { result, unmount } = renderHook(() => useAuth())
|
||||
|
||||
mockLogin.mockResolvedValue(createUser())
|
||||
@@ -126,14 +121,16 @@ describe('useAuth', () => {
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('should register and authenticate', async () => {
|
||||
it('registers and authenticates', async () => {
|
||||
const { result, unmount } = renderHook(() => useAuth())
|
||||
|
||||
mockRegister.mockResolvedValue(createUser({
|
||||
id: 'user_2',
|
||||
username: 'newbie',
|
||||
email: 'newbie@example.com',
|
||||
}))
|
||||
mockRegister.mockResolvedValue(
|
||||
createUser({
|
||||
id: 'user_2',
|
||||
username: 'newbie',
|
||||
email: 'newbie@example.com',
|
||||
})
|
||||
)
|
||||
|
||||
await waitForIdle(result)
|
||||
await act(async () => {
|
||||
@@ -143,7 +140,6 @@ describe('useAuth', () => {
|
||||
expect(result.current.user).toMatchObject({
|
||||
id: 'user_2',
|
||||
email: 'newbie@example.com',
|
||||
name: 'newbie',
|
||||
username: 'newbie',
|
||||
level: 2,
|
||||
})
|
||||
@@ -152,7 +148,7 @@ describe('useAuth', () => {
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('should sync state across hooks', async () => {
|
||||
it('syncs state across hooks', async () => {
|
||||
const first = renderHook(() => useAuth())
|
||||
const second = renderHook(() => useAuth())
|
||||
|
||||
@@ -1,54 +1,34 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { useKV } from '@/hooks/useKV'
|
||||
import { useKV } from '@/hooks/data/useKV'
|
||||
|
||||
describe('useKV', () => {
|
||||
const STORAGE_PREFIX = 'mb_kv:'
|
||||
let store: Record<string, string>
|
||||
const STORAGE_PREFIX = 'mb_kv:'
|
||||
let store: Record<string, string>
|
||||
|
||||
const setupLocalStorage = (): void => {
|
||||
store = {}
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key]
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
Object.keys(store).forEach(k => delete store[k])
|
||||
}),
|
||||
length: 0,
|
||||
key: vi.fn(() => null),
|
||||
})
|
||||
}
|
||||
|
||||
describe('useKV storage', () => {
|
||||
beforeEach(() => {
|
||||
// Mock localStorage
|
||||
store = {}
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => { store[key] = value }),
|
||||
removeItem: vi.fn((key: string) => { delete store[key] }),
|
||||
clear: vi.fn(() => { Object.keys(store).forEach(k => delete store[k]) }),
|
||||
length: 0,
|
||||
key: vi.fn(() => null),
|
||||
})
|
||||
setupLocalStorage()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ key: 'user_name', defaultValue: 'John', description: 'string value' },
|
||||
{ key: 'user_count', defaultValue: 0, description: 'number value' },
|
||||
{ key: 'is_active', defaultValue: true, description: 'boolean value' },
|
||||
{ key: 'user_data', defaultValue: { id: 1, name: 'John' }, description: 'object value' },
|
||||
])('should initialize hook with $description', ({ key, defaultValue }) => {
|
||||
const { result } = renderHook(() => useKV(key, defaultValue))
|
||||
const [value] = result.current
|
||||
|
||||
expect(value).toBe(defaultValue)
|
||||
})
|
||||
|
||||
it('should initialize with undefined when no default value provided', () => {
|
||||
const { result } = renderHook(() => useKV('empty_key'))
|
||||
const [value] = result.current
|
||||
|
||||
expect(value).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should load value from localStorage when available', async () => {
|
||||
localStorage.setItem(`${STORAGE_PREFIX}stored_key`, JSON.stringify('stored'))
|
||||
|
||||
const { result } = renderHook(() => useKV('stored_key', 'default'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current[0]).toBe('stored')
|
||||
})
|
||||
})
|
||||
|
||||
it('should migrate legacy localStorage entries to namespaced keys', () => {
|
||||
it('migrates legacy localStorage entries to namespaced keys', () => {
|
||||
localStorage.setItem('legacy_key', JSON.stringify('legacy'))
|
||||
|
||||
const { result } = renderHook(() => useKV('legacy_key', 'default'))
|
||||
@@ -58,7 +38,7 @@ describe('useKV', () => {
|
||||
expect(localStorage.getItem('legacy_key')).toBeNull()
|
||||
})
|
||||
|
||||
it('should update value when using updater function', async () => {
|
||||
it('updates value when using updater function', async () => {
|
||||
const { result } = renderHook(() => useKV('counter', 0))
|
||||
|
||||
const [, updateValue] = result.current
|
||||
@@ -71,9 +51,8 @@ describe('useKV', () => {
|
||||
expect(newValue).toBe(1)
|
||||
})
|
||||
|
||||
it('should update value when providing direct value', async () => {
|
||||
it('updates value when providing direct value', async () => {
|
||||
const { result } = renderHook(() => useKV('name', 'John'))
|
||||
|
||||
const [, updateValue] = result.current
|
||||
|
||||
await act(async () => {
|
||||
@@ -84,7 +63,7 @@ describe('useKV', () => {
|
||||
expect(newValue).toBe('Jane')
|
||||
})
|
||||
|
||||
it('should handle complex object updates', async () => {
|
||||
it('handles complex object updates', async () => {
|
||||
const initialObject = { id: 1, name: 'John', email: 'john@example.com' }
|
||||
const { result } = renderHook(() => useKV('user', initialObject))
|
||||
|
||||
@@ -101,7 +80,7 @@ describe('useKV', () => {
|
||||
expect(newValue).toEqual({ id: 1, name: 'Jane', email: 'john@example.com' })
|
||||
})
|
||||
|
||||
it('should handle array updates', async () => {
|
||||
it('handles array updates', async () => {
|
||||
const initialArray = [1, 2, 3]
|
||||
const { result } = renderHook(() => useKV('items', initialArray))
|
||||
|
||||
@@ -115,7 +94,7 @@ describe('useKV', () => {
|
||||
expect(newValue).toEqual([1, 2, 3, 4])
|
||||
})
|
||||
|
||||
it('should maintain separate state for different keys', async () => {
|
||||
it('maintains separate state for different keys', () => {
|
||||
const { result: result1 } = renderHook(() => useKV('key1', 'value1'))
|
||||
const { result: result2 } = renderHook(() => useKV('key2', 'value2'))
|
||||
|
||||
@@ -126,7 +105,7 @@ describe('useKV', () => {
|
||||
expect(value2).toBe('value2')
|
||||
})
|
||||
|
||||
it('should persist updates across multiple hooks with same key', async () => {
|
||||
it('persists updates across multiple hooks with same key', async () => {
|
||||
const { result: firstHook } = renderHook(() => useKV('shared_key', 'initial'))
|
||||
const [, updateValue] = firstHook.current
|
||||
|
||||
@@ -134,14 +113,13 @@ describe('useKV', () => {
|
||||
await updateValue('updated')
|
||||
})
|
||||
|
||||
// Create a new hook with the same key
|
||||
const { result: secondHook } = renderHook(() => useKV('shared_key', 'initial'))
|
||||
const [value] = secondHook.current
|
||||
|
||||
expect(value).toBe('updated')
|
||||
})
|
||||
|
||||
it('should sync updates across mounted hooks with same key', async () => {
|
||||
it('syncs updates across mounted hooks with same key', async () => {
|
||||
const { result: firstHook } = renderHook(() => useKV('sync_key', 'initial'))
|
||||
const { result: secondHook } = renderHook(() => useKV('sync_key', 'initial'))
|
||||
|
||||
@@ -154,19 +132,7 @@ describe('useKV', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ initialValue: null, key: 'falsy_key_null', description: 'null value' },
|
||||
{ initialValue: false, key: 'falsy_key_false', description: 'false boolean' },
|
||||
{ initialValue: 0, key: 'falsy_key_zero', description: 'zero number' },
|
||||
{ initialValue: '', key: 'falsy_key_empty', description: 'empty string' },
|
||||
])('should handle falsy $description correctly', ({ initialValue, key }) => {
|
||||
const { result } = renderHook(() => useKV(key, initialValue))
|
||||
const [value] = result.current
|
||||
|
||||
expect(value).toBe(initialValue)
|
||||
})
|
||||
|
||||
it('should handle rapid updates correctly', async () => {
|
||||
it('handles rapid updates correctly', async () => {
|
||||
const { result } = renderHook(() => useKV('rapid_key', 0))
|
||||
const [, updateValue] = result.current
|
||||
|
||||
@@ -183,7 +149,7 @@ describe('useKV', () => {
|
||||
expect(finalValue).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should persist updates to localStorage', async () => {
|
||||
it('persists updates to localStorage', async () => {
|
||||
const { result } = renderHook(() => useKV('persist_key', 'initial'))
|
||||
const [, updateValue] = result.current
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useKV } from '@/hooks/data/useKV'
|
||||
|
||||
const STORAGE_PREFIX = 'mb_kv:'
|
||||
let store: Record<string, string>
|
||||
|
||||
const setupLocalStorage = (): void => {
|
||||
store = {}
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key]
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
Object.keys(store).forEach(k => delete store[k])
|
||||
}),
|
||||
length: 0,
|
||||
key: vi.fn(() => null),
|
||||
})
|
||||
}
|
||||
|
||||
describe('useKV validation', () => {
|
||||
beforeEach(() => {
|
||||
setupLocalStorage()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ key: 'user_name', defaultValue: 'John', description: 'string value' },
|
||||
{ key: 'user_count', defaultValue: 0, description: 'number value' },
|
||||
{ key: 'is_active', defaultValue: true, description: 'boolean value' },
|
||||
{ key: 'user_data', defaultValue: { id: 1, name: 'John' }, description: 'object value' },
|
||||
])('initializes with $description', ({ key, defaultValue }) => {
|
||||
const { result } = renderHook(() => useKV(key, defaultValue))
|
||||
const [value] = result.current
|
||||
|
||||
expect(value).toBe(defaultValue)
|
||||
})
|
||||
|
||||
it('initializes with undefined when no default value provided', () => {
|
||||
const { result } = renderHook(() => useKV('empty_key'))
|
||||
const [value] = result.current
|
||||
|
||||
expect(value).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ initialValue: null, key: 'falsy_key_null', description: 'null value' },
|
||||
{ initialValue: false, key: 'falsy_key_false', description: 'false boolean' },
|
||||
{ initialValue: 0, key: 'falsy_key_zero', description: 'zero number' },
|
||||
{ initialValue: '', key: 'falsy_key_empty', description: 'empty string' },
|
||||
])('handles $description correctly', ({ initialValue, key }) => {
|
||||
const { result } = renderHook(() => useKV(key, initialValue))
|
||||
const [value] = result.current
|
||||
|
||||
expect(value).toBe(initialValue)
|
||||
})
|
||||
|
||||
it('loads value from localStorage when available', () => {
|
||||
localStorage.setItem(`${STORAGE_PREFIX}stored_key`, JSON.stringify('stored'))
|
||||
|
||||
const { result } = renderHook(() => useKV('stored_key', 'default'))
|
||||
const [value] = result.current
|
||||
|
||||
expect(value).toBe('stored')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Auto-refresh error-handling tests
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useAutoRefresh } from '../useAutoRefresh'
|
||||
|
||||
describe('useAutoRefresh error handling', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('stops refresh when disabled after being enabled', () => {
|
||||
const onRefresh = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutoRefresh({
|
||||
intervalMs: 5000,
|
||||
onRefresh,
|
||||
enabled: true,
|
||||
})
|
||||
)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2500)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setEnabled(false)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10000)
|
||||
})
|
||||
|
||||
expect(onRefresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('continues scheduling refreshes after onRefresh errors', () => {
|
||||
const erroringRefresh = vi.fn().mockImplementation(() =>
|
||||
Promise.reject(new Error('refresh failed')).catch(() => {})
|
||||
)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutoRefresh({
|
||||
intervalMs: 2000,
|
||||
onRefresh: erroringRefresh,
|
||||
enabled: true,
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.current.secondsUntilNextRefresh).toBe(2)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000)
|
||||
})
|
||||
expect(erroringRefresh).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
expect(result.current.secondsUntilNextRefresh).toBe(1)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
expect(result.current.secondsUntilNextRefresh).toBe(2)
|
||||
expect(erroringRefresh).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,11 @@
|
||||
/**
|
||||
* Tests for useAutoRefresh hook - Auto-refresh polling management
|
||||
* Following parameterized test pattern per project conventions
|
||||
* Auto-refresh polling tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { useAutoRefresh } from './useAutoRefresh'
|
||||
import { useAutoRefresh } from '../useAutoRefresh'
|
||||
|
||||
describe('useAutoRefresh', () => {
|
||||
describe('useAutoRefresh polling', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
@@ -21,7 +19,7 @@ describe('useAutoRefresh', () => {
|
||||
{ enabled: false, expectAutoRefreshing: false },
|
||||
{ enabled: true, expectAutoRefreshing: true },
|
||||
{ enabled: undefined, expectAutoRefreshing: false },
|
||||
])('should initialize with enabled=$enabled -> isAutoRefreshing=$expectAutoRefreshing', ({ enabled, expectAutoRefreshing }) => {
|
||||
])('initializes with enabled=$enabled -> isAutoRefreshing=$expectAutoRefreshing', ({ enabled, expectAutoRefreshing }) => {
|
||||
const onRefresh = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
@@ -39,7 +37,7 @@ describe('useAutoRefresh', () => {
|
||||
{ intervalMs: 30000, expectedSeconds: 30 },
|
||||
{ intervalMs: 60000, expectedSeconds: 60 },
|
||||
{ intervalMs: 5000, expectedSeconds: 5 },
|
||||
])('should set secondsUntilNextRefresh from intervalMs=$intervalMs', ({ intervalMs, expectedSeconds }) => {
|
||||
])('sets secondsUntilNextRefresh from intervalMs=$intervalMs', ({ intervalMs, expectedSeconds }) => {
|
||||
const onRefresh = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
@@ -55,7 +53,7 @@ describe('useAutoRefresh', () => {
|
||||
})
|
||||
|
||||
describe('toggleAutoRefresh', () => {
|
||||
it('should toggle from disabled to enabled', () => {
|
||||
it('toggles from disabled to enabled', () => {
|
||||
const onRefresh = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
@@ -75,7 +73,7 @@ describe('useAutoRefresh', () => {
|
||||
expect(result.current.isAutoRefreshing).toBe(true)
|
||||
})
|
||||
|
||||
it('should toggle from enabled to disabled', () => {
|
||||
it('toggles from enabled to disabled', () => {
|
||||
const onRefresh = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
@@ -102,7 +100,7 @@ describe('useAutoRefresh', () => {
|
||||
{ initial: true, setTo: false, expected: false },
|
||||
{ initial: false, setTo: false, expected: false },
|
||||
{ initial: true, setTo: true, expected: true },
|
||||
])('should set from $initial to $setTo', ({ initial, setTo, expected }) => {
|
||||
])('sets from $initial to $setTo', ({ initial, setTo, expected }) => {
|
||||
const onRefresh = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
@@ -122,7 +120,7 @@ describe('useAutoRefresh', () => {
|
||||
})
|
||||
|
||||
describe('refresh timing', () => {
|
||||
it('should call onRefresh after intervalMs when enabled', async () => {
|
||||
it('calls onRefresh after intervalMs when enabled', async () => {
|
||||
const onRefresh = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
renderHook(() =>
|
||||
@@ -133,23 +131,20 @@ describe('useAutoRefresh', () => {
|
||||
})
|
||||
)
|
||||
|
||||
// Not called initially
|
||||
expect(onRefresh).not.toHaveBeenCalled()
|
||||
|
||||
// Advance to just before interval
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(4999)
|
||||
})
|
||||
expect(onRefresh).not.toHaveBeenCalled()
|
||||
|
||||
// Advance past interval
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1)
|
||||
})
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onRefresh when disabled', () => {
|
||||
it('does not call onRefresh when disabled', () => {
|
||||
const onRefresh = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
renderHook(() =>
|
||||
@@ -167,7 +162,7 @@ describe('useAutoRefresh', () => {
|
||||
expect(onRefresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onRefresh multiple times at interval', () => {
|
||||
it('calls onRefresh multiple times at interval', () => {
|
||||
const onRefresh = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
renderHook(() =>
|
||||
@@ -179,7 +174,7 @@ describe('useAutoRefresh', () => {
|
||||
)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(15000) // 3 intervals
|
||||
vi.advanceTimersByTime(15000)
|
||||
})
|
||||
|
||||
expect(onRefresh).toHaveBeenCalledTimes(3)
|
||||
@@ -187,7 +182,7 @@ describe('useAutoRefresh', () => {
|
||||
})
|
||||
|
||||
describe('countdown', () => {
|
||||
it('should decrement countdown every second when enabled', () => {
|
||||
it('decrements countdown every second when enabled', () => {
|
||||
const onRefresh = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
@@ -211,7 +206,7 @@ describe('useAutoRefresh', () => {
|
||||
expect(result.current.secondsUntilNextRefresh).toBe(3)
|
||||
})
|
||||
|
||||
it('should reset countdown after reaching zero', () => {
|
||||
it('resets countdown after reaching zero', () => {
|
||||
const onRefresh = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
@@ -224,45 +219,11 @@ describe('useAutoRefresh', () => {
|
||||
|
||||
expect(result.current.secondsUntilNextRefresh).toBe(3)
|
||||
|
||||
// Advance 3 seconds to reach zero
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3000)
|
||||
})
|
||||
|
||||
// Should reset to initial value
|
||||
expect(result.current.secondsUntilNextRefresh).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should stop refresh when disabled after being enabled', () => {
|
||||
const onRefresh = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutoRefresh({
|
||||
intervalMs: 5000,
|
||||
onRefresh,
|
||||
enabled: true,
|
||||
})
|
||||
)
|
||||
|
||||
// Advance half interval
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2500)
|
||||
})
|
||||
|
||||
// Disable
|
||||
act(() => {
|
||||
result.current.setEnabled(false)
|
||||
})
|
||||
|
||||
// Advance another full interval
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10000)
|
||||
})
|
||||
|
||||
// Should not have been called (disabled before first interval completed)
|
||||
expect(onRefresh).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { summarizeWorkflowRuns } from './analyze-workflow-runs'
|
||||
import {
|
||||
analyzeWorkflowRuns,
|
||||
parseWorkflowRuns,
|
||||
summarizeWorkflowRuns,
|
||||
} from './analyze-workflow-runs'
|
||||
|
||||
describe('parseWorkflowRuns', () => {
|
||||
it('normalizes unknown entries and ignores items without numeric IDs', () => {
|
||||
const runs = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Build',
|
||||
status: 'completed',
|
||||
conclusion: 'success',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:10:00Z',
|
||||
head_branch: 'main',
|
||||
event: 'push',
|
||||
},
|
||||
{ id: 'not-a-number' },
|
||||
{
|
||||
id: 2,
|
||||
name: '',
|
||||
status: '',
|
||||
conclusion: 'failure',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
head_branch: '',
|
||||
event: '',
|
||||
},
|
||||
]
|
||||
|
||||
const parsed = parseWorkflowRuns(runs)
|
||||
|
||||
expect(parsed).toHaveLength(2)
|
||||
expect(parsed[0].name).toBe('Build')
|
||||
expect(parsed[1]).toEqual({
|
||||
id: 2,
|
||||
name: 'Unknown workflow',
|
||||
status: 'unknown',
|
||||
conclusion: 'failure',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
head_branch: 'unknown',
|
||||
event: 'unknown',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeWorkflowRuns', () => {
|
||||
it('summarizes totals, success rate, and failure hotspots', () => {
|
||||
@@ -60,3 +107,24 @@ describe('summarizeWorkflowRuns', () => {
|
||||
expect(summary.mostRecent).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('analyzeWorkflowRuns', () => {
|
||||
it('returns parsed summary and formatted output', () => {
|
||||
const result = analyzeWorkflowRuns([
|
||||
{
|
||||
id: 7,
|
||||
name: 'Deploy',
|
||||
status: 'completed',
|
||||
conclusion: 'success',
|
||||
created_at: '2024-02-01T00:00:00Z',
|
||||
updated_at: '2024-02-01T00:05:00Z',
|
||||
head_branch: 'main',
|
||||
event: 'workflow_dispatch',
|
||||
},
|
||||
])
|
||||
|
||||
expect(result.summary.total).toBe(1)
|
||||
expect(result.formatted).toContain('Workflow Run Analysis')
|
||||
expect(result.formatted).toContain('Deploy')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,164 +1,18 @@
|
||||
export type WorkflowRunLike = {
|
||||
id: number
|
||||
name: string
|
||||
status: string
|
||||
conclusion: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
head_branch: string
|
||||
event: string
|
||||
}
|
||||
import { parseWorkflowRuns, WorkflowRunLike } from './parser'
|
||||
import { formatWorkflowRunAnalysis, summarizeWorkflowRuns, WorkflowRunSummary } from './stats'
|
||||
|
||||
export type WorkflowRunSummary = {
|
||||
total: number
|
||||
completed: number
|
||||
successful: number
|
||||
failed: number
|
||||
cancelled: number
|
||||
inProgress: number
|
||||
successRate: number
|
||||
mostRecent: WorkflowRunLike | null
|
||||
recentRuns: WorkflowRunLike[]
|
||||
topFailingWorkflows: Array<{ name: string; failures: number }>
|
||||
failingBranches: Array<{ branch: string; failures: number }>
|
||||
failingEvents: Array<{ event: string; failures: number }>
|
||||
}
|
||||
export type { WorkflowRunLike, WorkflowRunSummary }
|
||||
export { parseWorkflowRuns, summarizeWorkflowRuns, formatWorkflowRunAnalysis }
|
||||
|
||||
const DEFAULT_RECENT_COUNT = 5
|
||||
const DEFAULT_TOP_COUNT = 3
|
||||
|
||||
function toTopCounts(
|
||||
values: string[],
|
||||
topCount: number
|
||||
): Array<{ key: string; count: number }> {
|
||||
const counts = new Map<string, number>()
|
||||
values.forEach((value) => {
|
||||
counts.set(value, (counts.get(value) || 0) + 1)
|
||||
})
|
||||
|
||||
return Array.from(counts.entries())
|
||||
.map(([key, count]) => ({ key, count }))
|
||||
.sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
|
||||
.slice(0, topCount)
|
||||
}
|
||||
|
||||
export function summarizeWorkflowRuns(
|
||||
runs: WorkflowRunLike[],
|
||||
export function analyzeWorkflowRuns(
|
||||
runs: unknown[],
|
||||
options?: { recentCount?: number; topCount?: number }
|
||||
): WorkflowRunSummary {
|
||||
const recentCount = options?.recentCount ?? DEFAULT_RECENT_COUNT
|
||||
const topCount = options?.topCount ?? DEFAULT_TOP_COUNT
|
||||
const total = runs.length
|
||||
|
||||
const completedRuns = runs.filter((run) => run.status === 'completed')
|
||||
const successful = completedRuns.filter((run) => run.conclusion === 'success').length
|
||||
const failed = completedRuns.filter((run) => run.conclusion === 'failure').length
|
||||
const cancelled = completedRuns.filter((run) => run.conclusion === 'cancelled').length
|
||||
const inProgress = total - completedRuns.length
|
||||
const successRate = completedRuns.length
|
||||
? Math.round((successful / completedRuns.length) * 100)
|
||||
: 0
|
||||
|
||||
const sortedByUpdated = [...runs].sort(
|
||||
(a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
|
||||
)
|
||||
const mostRecent = sortedByUpdated[0] ?? null
|
||||
const recentRuns = sortedByUpdated.slice(0, recentCount)
|
||||
|
||||
const failureRuns = completedRuns.filter((run) => run.conclusion === 'failure')
|
||||
const topFailingWorkflows = toTopCounts(
|
||||
failureRuns.map((run) => run.name),
|
||||
topCount
|
||||
).map((entry) => ({ name: entry.key, failures: entry.count }))
|
||||
|
||||
const failingBranches = toTopCounts(
|
||||
failureRuns.map((run) => run.head_branch),
|
||||
topCount
|
||||
).map((entry) => ({ branch: entry.key, failures: entry.count }))
|
||||
|
||||
const failingEvents = toTopCounts(
|
||||
failureRuns.map((run) => run.event),
|
||||
topCount
|
||||
).map((entry) => ({ event: entry.key, failures: entry.count }))
|
||||
) {
|
||||
const parsedRuns = parseWorkflowRuns(runs)
|
||||
const summary = summarizeWorkflowRuns(parsedRuns, options)
|
||||
|
||||
return {
|
||||
total,
|
||||
completed: completedRuns.length,
|
||||
successful,
|
||||
failed,
|
||||
cancelled,
|
||||
inProgress,
|
||||
successRate,
|
||||
mostRecent,
|
||||
recentRuns,
|
||||
topFailingWorkflows,
|
||||
failingBranches,
|
||||
failingEvents,
|
||||
summary,
|
||||
formatted: formatWorkflowRunAnalysis(summary),
|
||||
}
|
||||
}
|
||||
|
||||
export function formatWorkflowRunAnalysis(summary: WorkflowRunSummary) {
|
||||
const lines: string[] = []
|
||||
|
||||
lines.push('Workflow Run Analysis')
|
||||
lines.push('---------------------')
|
||||
lines.push(`Total runs: ${summary.total}`)
|
||||
lines.push(
|
||||
`Completed: ${summary.completed} (success: ${summary.successful}, failed: ${summary.failed}, cancelled: ${summary.cancelled})`
|
||||
)
|
||||
lines.push(`In progress: ${summary.inProgress}`)
|
||||
lines.push(`Success rate: ${summary.successRate}%`)
|
||||
|
||||
if (summary.mostRecent) {
|
||||
lines.push('')
|
||||
lines.push('Most recent run:')
|
||||
lines.push(
|
||||
`- ${summary.mostRecent.name} | ${summary.mostRecent.status}${
|
||||
summary.mostRecent.conclusion ? `/${summary.mostRecent.conclusion}` : ''
|
||||
} | ${summary.mostRecent.head_branch} | ${summary.mostRecent.updated_at}`
|
||||
)
|
||||
}
|
||||
|
||||
if (summary.recentRuns.length > 0) {
|
||||
lines.push('')
|
||||
lines.push('Recent runs:')
|
||||
summary.recentRuns.forEach((run) => {
|
||||
lines.push(
|
||||
`- ${run.name} | ${run.status}${
|
||||
run.conclusion ? `/${run.conclusion}` : ''
|
||||
} | ${run.head_branch} | ${run.updated_at}`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (summary.topFailingWorkflows.length > 0) {
|
||||
lines.push('')
|
||||
lines.push('Top failing workflows:')
|
||||
summary.topFailingWorkflows.forEach((entry) => {
|
||||
lines.push(`- ${entry.name}: ${entry.failures}`)
|
||||
})
|
||||
}
|
||||
|
||||
if (summary.failingBranches.length > 0) {
|
||||
lines.push('')
|
||||
lines.push('Failing branches:')
|
||||
summary.failingBranches.forEach((entry) => {
|
||||
lines.push(`- ${entry.branch}: ${entry.failures}`)
|
||||
})
|
||||
}
|
||||
|
||||
if (summary.failingEvents.length > 0) {
|
||||
lines.push('')
|
||||
lines.push('Failing events:')
|
||||
summary.failingEvents.forEach((entry) => {
|
||||
lines.push(`- ${entry.event}: ${entry.failures}`)
|
||||
})
|
||||
}
|
||||
|
||||
if (summary.total === 0) {
|
||||
lines.push('')
|
||||
lines.push('No workflow runs available to analyze.')
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
export type WorkflowRunLike = {
|
||||
id: number
|
||||
name: string
|
||||
status: string
|
||||
conclusion: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
head_branch: string
|
||||
event: string
|
||||
}
|
||||
|
||||
const FALLBACK_NAME = 'Unknown workflow'
|
||||
const FALLBACK_STATUS = 'unknown'
|
||||
const FALLBACK_BRANCH = 'unknown'
|
||||
const FALLBACK_EVENT = 'unknown'
|
||||
|
||||
function toStringOrFallback(value: unknown, fallback: string) {
|
||||
return typeof value === 'string' && value.trim() ? value : fallback
|
||||
}
|
||||
|
||||
export function parseWorkflowRuns(runs: unknown[]): WorkflowRunLike[] {
|
||||
if (!Array.isArray(runs)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return runs
|
||||
.map((run) => {
|
||||
const candidate = run as Partial<WorkflowRunLike> & { id?: unknown }
|
||||
const id = Number(candidate.id)
|
||||
|
||||
if (!Number.isFinite(id)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name: toStringOrFallback(candidate.name, FALLBACK_NAME),
|
||||
status: toStringOrFallback(candidate.status, FALLBACK_STATUS),
|
||||
conclusion:
|
||||
candidate.conclusion === null || typeof candidate.conclusion === 'string'
|
||||
? candidate.conclusion
|
||||
: null,
|
||||
created_at: toStringOrFallback(candidate.created_at, ''),
|
||||
updated_at: toStringOrFallback(candidate.updated_at, ''),
|
||||
head_branch: toStringOrFallback(candidate.head_branch, FALLBACK_BRANCH),
|
||||
event: toStringOrFallback(candidate.event, FALLBACK_EVENT),
|
||||
}
|
||||
})
|
||||
.filter((run): run is WorkflowRunLike => Boolean(run))
|
||||
}
|
||||
153
frontends/nextjs/src/lib/github/workflows/analysis/runs/stats.ts
Normal file
153
frontends/nextjs/src/lib/github/workflows/analysis/runs/stats.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { WorkflowRunLike } from './parser'
|
||||
|
||||
export type WorkflowRunSummary = {
|
||||
total: number
|
||||
completed: number
|
||||
successful: number
|
||||
failed: number
|
||||
cancelled: number
|
||||
inProgress: number
|
||||
successRate: number
|
||||
mostRecent: WorkflowRunLike | null
|
||||
recentRuns: WorkflowRunLike[]
|
||||
topFailingWorkflows: Array<{ name: string; failures: number }>
|
||||
failingBranches: Array<{ branch: string; failures: number }>
|
||||
failingEvents: Array<{ event: string; failures: number }>
|
||||
}
|
||||
|
||||
const DEFAULT_RECENT_COUNT = 5
|
||||
const DEFAULT_TOP_COUNT = 3
|
||||
|
||||
function toTopCounts(
|
||||
values: string[],
|
||||
topCount: number
|
||||
): Array<{ key: string; count: number }> {
|
||||
const counts = new Map<string, number>()
|
||||
values.forEach((value) => {
|
||||
counts.set(value, (counts.get(value) || 0) + 1)
|
||||
})
|
||||
|
||||
return Array.from(counts.entries())
|
||||
.map(([key, count]) => ({ key, count }))
|
||||
.sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
|
||||
.slice(0, topCount)
|
||||
}
|
||||
|
||||
export function summarizeWorkflowRuns(
|
||||
runs: WorkflowRunLike[],
|
||||
options?: { recentCount?: number; topCount?: number }
|
||||
): WorkflowRunSummary {
|
||||
const recentCount = options?.recentCount ?? DEFAULT_RECENT_COUNT
|
||||
const topCount = options?.topCount ?? DEFAULT_TOP_COUNT
|
||||
const total = runs.length
|
||||
|
||||
const completedRuns = runs.filter((run) => run.status === 'completed')
|
||||
const successful = completedRuns.filter((run) => run.conclusion === 'success').length
|
||||
const failed = completedRuns.filter((run) => run.conclusion === 'failure').length
|
||||
const cancelled = completedRuns.filter((run) => run.conclusion === 'cancelled').length
|
||||
const inProgress = total - completedRuns.length
|
||||
const successRate = completedRuns.length
|
||||
? Math.round((successful / completedRuns.length) * 100)
|
||||
: 0
|
||||
|
||||
const sortedByUpdated = [...runs].sort(
|
||||
(a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
|
||||
)
|
||||
const mostRecent = sortedByUpdated[0] ?? null
|
||||
const recentRuns = sortedByUpdated.slice(0, recentCount)
|
||||
|
||||
const failureRuns = completedRuns.filter((run) => run.conclusion === 'failure')
|
||||
const topFailingWorkflows = toTopCounts(
|
||||
failureRuns.map((run) => run.name),
|
||||
topCount
|
||||
).map((entry) => ({ name: entry.key, failures: entry.count }))
|
||||
|
||||
const failingBranches = toTopCounts(
|
||||
failureRuns.map((run) => run.head_branch),
|
||||
topCount
|
||||
).map((entry) => ({ branch: entry.key, failures: entry.count }))
|
||||
|
||||
const failingEvents = toTopCounts(
|
||||
failureRuns.map((run) => run.event),
|
||||
topCount
|
||||
).map((entry) => ({ event: entry.key, failures: entry.count }))
|
||||
|
||||
return {
|
||||
total,
|
||||
completed: completedRuns.length,
|
||||
successful,
|
||||
failed,
|
||||
cancelled,
|
||||
inProgress,
|
||||
successRate,
|
||||
mostRecent,
|
||||
recentRuns,
|
||||
topFailingWorkflows,
|
||||
failingBranches,
|
||||
failingEvents,
|
||||
}
|
||||
}
|
||||
|
||||
export function formatWorkflowRunAnalysis(summary: WorkflowRunSummary) {
|
||||
const lines: string[] = []
|
||||
|
||||
lines.push('Workflow Run Analysis')
|
||||
lines.push('---------------------')
|
||||
lines.push(`Total runs: ${summary.total}`)
|
||||
lines.push(
|
||||
`Completed: ${summary.completed} (success: ${summary.successful}, failed: ${summary.failed}, cancelled: ${summary.cancelled})`
|
||||
)
|
||||
lines.push(`In progress: ${summary.inProgress}`)
|
||||
lines.push(`Success rate: ${summary.successRate}%`)
|
||||
|
||||
if (summary.mostRecent) {
|
||||
lines.push('')
|
||||
lines.push('Most recent run:')
|
||||
lines.push(
|
||||
`- ${summary.mostRecent.name} | ${summary.mostRecent.status}${
|
||||
summary.mostRecent.conclusion ? `/${summary.mostRecent.conclusion}` : ''
|
||||
} | ${summary.mostRecent.head_branch} | ${summary.mostRecent.updated_at}`
|
||||
)
|
||||
}
|
||||
|
||||
if (summary.recentRuns.length > 0) {
|
||||
lines.push('')
|
||||
lines.push('Recent runs:')
|
||||
summary.recentRuns.forEach((run) => {
|
||||
lines.push(
|
||||
`- ${run.name} | ${run.status}${run.conclusion ? `/${run.conclusion}` : ''} | ${run.head_branch} | ${run.updated_at}`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (summary.topFailingWorkflows.length > 0) {
|
||||
lines.push('')
|
||||
lines.push('Top failing workflows:')
|
||||
summary.topFailingWorkflows.forEach((entry) => {
|
||||
lines.push(`- ${entry.name}: ${entry.failures}`)
|
||||
})
|
||||
}
|
||||
|
||||
if (summary.failingBranches.length > 0) {
|
||||
lines.push('')
|
||||
lines.push('Failing branches:')
|
||||
summary.failingBranches.forEach((entry) => {
|
||||
lines.push(`- ${entry.branch}: ${entry.failures}`)
|
||||
})
|
||||
}
|
||||
|
||||
if (summary.failingEvents.length > 0) {
|
||||
lines.push('')
|
||||
lines.push('Failing events:')
|
||||
summary.failingEvents.forEach((entry) => {
|
||||
lines.push(`- ${entry.event}: ${entry.failures}`)
|
||||
})
|
||||
}
|
||||
|
||||
if (summary.total === 0) {
|
||||
lines.push('')
|
||||
lines.push('No workflow runs available to analyze.')
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { PackageTemplateConfig } from '../../types'
|
||||
|
||||
export const ADVANCED_PACKAGE_TEMPLATE_CONFIGS: PackageTemplateConfig[] = []
|
||||
267
frontends/nextjs/src/lib/nerd-mode-ide/templates/configs/base.ts
Normal file
267
frontends/nextjs/src/lib/nerd-mode-ide/templates/configs/base.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import type { PackageTemplateConfig, ReactAppTemplateConfig } from '../../types'
|
||||
|
||||
export const BASE_REACT_APP_TEMPLATE_CONFIG: ReactAppTemplateConfig = {
|
||||
id: 'react_next_starter',
|
||||
name: 'Next.js Web App',
|
||||
description: 'A clean Next.js starter with app router, hero component, and typed config files.',
|
||||
rootName: 'web_app',
|
||||
tags: ['nextjs', 'react', 'web', 'starter'],
|
||||
}
|
||||
|
||||
const socialHubComponents = [
|
||||
{
|
||||
id: 'social_hub_root',
|
||||
type: 'Stack',
|
||||
props: { className: 'flex flex-col gap-6' },
|
||||
children: [
|
||||
{
|
||||
id: 'social_hub_hero',
|
||||
type: 'Card',
|
||||
props: { className: 'p-6' },
|
||||
children: [
|
||||
{
|
||||
id: 'social_hub_heading',
|
||||
type: 'Heading',
|
||||
props: { children: 'Social Hub', level: '2', className: 'text-2xl font-bold' },
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_subtitle',
|
||||
type: 'Text',
|
||||
props: { children: 'A modern feed for creator updates, curated stories, and live moments.' },
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_stats',
|
||||
type: 'Grid',
|
||||
props: { className: 'grid grid-cols-3 gap-4' },
|
||||
children: [
|
||||
{
|
||||
id: 'social_hub_stat_1',
|
||||
type: 'Card',
|
||||
props: { className: 'p-4' },
|
||||
children: [
|
||||
{
|
||||
id: 'social_hub_stat_label_1',
|
||||
type: 'Text',
|
||||
props: { children: 'Creators live', className: 'text-sm text-muted-foreground' },
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_stat_value_1',
|
||||
type: 'Heading',
|
||||
props: { children: '128', level: '3', className: 'text-xl font-semibold' },
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_stat_2',
|
||||
type: 'Card',
|
||||
props: { className: 'p-4' },
|
||||
children: [
|
||||
{
|
||||
id: 'social_hub_stat_label_2',
|
||||
type: 'Text',
|
||||
props: { children: 'Trending tags', className: 'text-sm text-muted-foreground' },
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_stat_value_2',
|
||||
type: 'Heading',
|
||||
props: { children: '42', level: '3', className: 'text-xl font-semibold' },
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_stat_3',
|
||||
type: 'Card',
|
||||
props: { className: 'p-4' },
|
||||
children: [
|
||||
{
|
||||
id: 'social_hub_stat_label_3',
|
||||
type: 'Text',
|
||||
props: { children: 'Live rooms', className: 'text-sm text-muted-foreground' },
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_stat_value_3',
|
||||
type: 'Heading',
|
||||
props: { children: '7', level: '3', className: 'text-xl font-semibold' },
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_composer',
|
||||
type: 'Card',
|
||||
props: { className: 'p-4' },
|
||||
children: [
|
||||
{
|
||||
id: 'social_hub_composer_label',
|
||||
type: 'Label',
|
||||
props: { children: 'Share a quick update' },
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_composer_input',
|
||||
type: 'Textarea',
|
||||
props: { placeholder: 'What are you building today?', rows: 3 },
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_composer_actions',
|
||||
type: 'Flex',
|
||||
props: { className: 'flex gap-2' },
|
||||
children: [
|
||||
{
|
||||
id: 'social_hub_composer_publish',
|
||||
type: 'Button',
|
||||
props: { children: 'Publish', variant: 'default' },
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_composer_media',
|
||||
type: 'Button',
|
||||
props: { children: 'Add media', variant: 'outline' },
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_feed',
|
||||
type: 'Stack',
|
||||
props: { className: 'flex flex-col gap-4' },
|
||||
children: [
|
||||
{
|
||||
id: 'social_hub_feed_post_1',
|
||||
type: 'Card',
|
||||
props: { className: 'p-5' },
|
||||
children: [
|
||||
{
|
||||
id: 'social_hub_feed_post_1_title',
|
||||
type: 'Heading',
|
||||
props: { children: 'Launch day recap', level: '3', className: 'text-lg font-semibold' },
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_feed_post_1_body',
|
||||
type: 'Text',
|
||||
props: { children: 'We shipped the new live rooms and saw a 32% boost in engagement.' },
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_feed_post_1_badge',
|
||||
type: 'Badge',
|
||||
props: { children: 'Community' },
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_feed_post_2',
|
||||
type: 'Card',
|
||||
props: { className: 'p-5' },
|
||||
children: [
|
||||
{
|
||||
id: 'social_hub_feed_post_2_title',
|
||||
type: 'Heading',
|
||||
props: { children: 'Creator spotlight', level: '3', className: 'text-lg font-semibold' },
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_feed_post_2_body',
|
||||
type: 'Text',
|
||||
props: { children: 'Nova shares her workflow for livestreaming and managing subscribers.' },
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_feed_post_2_badge',
|
||||
type: 'Badge',
|
||||
props: { children: 'Spotlight', variant: 'secondary' },
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const socialHubExamples = {
|
||||
feedItems: [
|
||||
{
|
||||
id: 'post_001',
|
||||
author: 'Nova',
|
||||
title: 'Launch day recap',
|
||||
summary: 'We shipped live rooms and doubled community sessions.',
|
||||
tags: ['launch', 'community'],
|
||||
},
|
||||
{
|
||||
id: 'post_002',
|
||||
author: 'Kai',
|
||||
title: 'Build log: day 42',
|
||||
summary: 'Refined the moderation pipeline and added creator scorecards.',
|
||||
tags: ['buildinpublic'],
|
||||
},
|
||||
],
|
||||
trendingTags: ['#buildinpublic', '#metabuilder', '#live'],
|
||||
rooms: [
|
||||
{ id: 'room_1', title: 'Creator Q&A', host: 'Eli', live: true },
|
||||
{ id: 'room_2', title: 'Patch Notes', host: 'Nova', live: false },
|
||||
],
|
||||
}
|
||||
|
||||
const socialHubLuaScripts = [
|
||||
{
|
||||
fileName: 'init.lua',
|
||||
description: 'Lifecycle hooks for package installation.',
|
||||
code: 'local M = {}\\n\\nfunction M.on_install(context)\\n return { message = "Social Hub installed", version = context.version }\\nend\\n\\nfunction M.on_uninstall()\\n return { message = "Social Hub removed" }\\nend\\n\\nreturn M',
|
||||
},
|
||||
{
|
||||
fileName: 'permissions.lua',
|
||||
description: 'Role-based access rules for posting and moderation.',
|
||||
code: 'local Permissions = {}\\n\\nfunction Permissions.can_post(user)\\n return user and (user.role == "user" or user.role == "admin" or user.role == "god")\\nend\\n\\nfunction Permissions.can_moderate(user)\\n return user and (user.role == "admin" or user.role == "god" or user.role == "supergod")\\nend\\n\\nreturn Permissions',
|
||||
},
|
||||
{
|
||||
fileName: 'feed_rank.lua',
|
||||
description: 'Score feed items based on recency and engagement.',
|
||||
code: 'local FeedRank = {}\\n\\nfunction FeedRank.score(item)\\n local freshness = item.age_minutes and (100 - item.age_minutes) or 50\\n local engagement = (item.likes or 0) * 2 + (item.comments or 0) * 3\\n return freshness + engagement\\nend\\n\\nreturn FeedRank',
|
||||
},
|
||||
{
|
||||
fileName: 'moderation.lua',
|
||||
description: 'Flag content for review using lightweight heuristics.',
|
||||
code: 'local Moderation = {}\\n\\nfunction Moderation.flag(content)\\n local lowered = string.lower(content or "")\\n if string.find(lowered, "spam") then\\n return { flagged = true, reason = "spam_keyword" }\\n end\\n return { flagged = false }\\nend\\n\\nreturn Moderation',
|
||||
},
|
||||
{
|
||||
fileName: 'analytics.lua',
|
||||
description: 'Aggregate engagement signals for dashboards.',
|
||||
code: 'local Analytics = {}\\n\\nfunction Analytics.aggregate(events)\\n local summary = { views = 0, likes = 0, comments = 0 }\\n for _, event in ipairs(events or {}) do\\n summary.views = summary.views + (event.views or 0)\\n summary.likes = summary.likes + (event.likes or 0)\\n summary.comments = summary.comments + (event.comments or 0)\\n end\\n return summary\\nend\\n\\nreturn Analytics',
|
||||
},
|
||||
]
|
||||
|
||||
export const BASE_PACKAGE_TEMPLATE_CONFIGS: PackageTemplateConfig[] = [
|
||||
{
|
||||
id: 'package_social_hub',
|
||||
name: 'Social Hub Package',
|
||||
description: 'A package blueprint for social feeds, creator updates, and live rooms.',
|
||||
rootName: 'social_hub',
|
||||
packageId: 'social_hub',
|
||||
author: 'MetaBuilder',
|
||||
version: '1.0.0',
|
||||
category: 'social',
|
||||
summary: 'Modern social feed with creator tools and live rooms.',
|
||||
components: socialHubComponents,
|
||||
examples: socialHubExamples,
|
||||
luaScripts: socialHubLuaScripts,
|
||||
tags: ['package', 'social', 'feed', 'lua'],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { PackageTemplateConfig } from '../../types'
|
||||
|
||||
export const EXPERIMENTAL_PACKAGE_TEMPLATE_CONFIGS: PackageTemplateConfig[] = []
|
||||
@@ -1,267 +1,12 @@
|
||||
import type { PackageTemplateConfig, ReactAppTemplateConfig } from './types'
|
||||
import type { PackageTemplateConfig, ReactAppTemplateConfig } from '../types'
|
||||
import { ADVANCED_PACKAGE_TEMPLATE_CONFIGS } from './configs/advanced'
|
||||
import { BASE_PACKAGE_TEMPLATE_CONFIGS, BASE_REACT_APP_TEMPLATE_CONFIG } from './configs/base'
|
||||
import { EXPERIMENTAL_PACKAGE_TEMPLATE_CONFIGS } from './configs/experimental'
|
||||
|
||||
export const REACT_APP_TEMPLATE_CONFIG: ReactAppTemplateConfig = {
|
||||
id: 'react_next_starter',
|
||||
name: 'Next.js Web App',
|
||||
description: 'A clean Next.js starter with app router, hero component, and typed config files.',
|
||||
rootName: 'web_app',
|
||||
tags: ['nextjs', 'react', 'web', 'starter'],
|
||||
}
|
||||
|
||||
const socialHubComponents = [
|
||||
{
|
||||
id: 'social_hub_root',
|
||||
type: 'Stack',
|
||||
props: { className: 'flex flex-col gap-6' },
|
||||
children: [
|
||||
{
|
||||
id: 'social_hub_hero',
|
||||
type: 'Card',
|
||||
props: { className: 'p-6' },
|
||||
children: [
|
||||
{
|
||||
id: 'social_hub_heading',
|
||||
type: 'Heading',
|
||||
props: { children: 'Social Hub', level: '2', className: 'text-2xl font-bold' },
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_subtitle',
|
||||
type: 'Text',
|
||||
props: { children: 'A modern feed for creator updates, curated stories, and live moments.' },
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_stats',
|
||||
type: 'Grid',
|
||||
props: { className: 'grid grid-cols-3 gap-4' },
|
||||
children: [
|
||||
{
|
||||
id: 'social_hub_stat_1',
|
||||
type: 'Card',
|
||||
props: { className: 'p-4' },
|
||||
children: [
|
||||
{
|
||||
id: 'social_hub_stat_label_1',
|
||||
type: 'Text',
|
||||
props: { children: 'Creators live', className: 'text-sm text-muted-foreground' },
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_stat_value_1',
|
||||
type: 'Heading',
|
||||
props: { children: '128', level: '3', className: 'text-xl font-semibold' },
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_stat_2',
|
||||
type: 'Card',
|
||||
props: { className: 'p-4' },
|
||||
children: [
|
||||
{
|
||||
id: 'social_hub_stat_label_2',
|
||||
type: 'Text',
|
||||
props: { children: 'Trending tags', className: 'text-sm text-muted-foreground' },
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_stat_value_2',
|
||||
type: 'Heading',
|
||||
props: { children: '42', level: '3', className: 'text-xl font-semibold' },
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_stat_3',
|
||||
type: 'Card',
|
||||
props: { className: 'p-4' },
|
||||
children: [
|
||||
{
|
||||
id: 'social_hub_stat_label_3',
|
||||
type: 'Text',
|
||||
props: { children: 'Live rooms', className: 'text-sm text-muted-foreground' },
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_stat_value_3',
|
||||
type: 'Heading',
|
||||
props: { children: '7', level: '3', className: 'text-xl font-semibold' },
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_composer',
|
||||
type: 'Card',
|
||||
props: { className: 'p-4' },
|
||||
children: [
|
||||
{
|
||||
id: 'social_hub_composer_label',
|
||||
type: 'Label',
|
||||
props: { children: 'Share a quick update' },
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_composer_input',
|
||||
type: 'Textarea',
|
||||
props: { placeholder: 'What are you building today?', rows: 3 },
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_composer_actions',
|
||||
type: 'Flex',
|
||||
props: { className: 'flex gap-2' },
|
||||
children: [
|
||||
{
|
||||
id: 'social_hub_composer_publish',
|
||||
type: 'Button',
|
||||
props: { children: 'Publish', variant: 'default' },
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_composer_media',
|
||||
type: 'Button',
|
||||
props: { children: 'Add media', variant: 'outline' },
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_feed',
|
||||
type: 'Stack',
|
||||
props: { className: 'flex flex-col gap-4' },
|
||||
children: [
|
||||
{
|
||||
id: 'social_hub_feed_post_1',
|
||||
type: 'Card',
|
||||
props: { className: 'p-5' },
|
||||
children: [
|
||||
{
|
||||
id: 'social_hub_feed_post_1_title',
|
||||
type: 'Heading',
|
||||
props: { children: 'Launch day recap', level: '3', className: 'text-lg font-semibold' },
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_feed_post_1_body',
|
||||
type: 'Text',
|
||||
props: { children: 'We shipped the new live rooms and saw a 32% boost in engagement.' },
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_feed_post_1_badge',
|
||||
type: 'Badge',
|
||||
props: { children: 'Community' },
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_feed_post_2',
|
||||
type: 'Card',
|
||||
props: { className: 'p-5' },
|
||||
children: [
|
||||
{
|
||||
id: 'social_hub_feed_post_2_title',
|
||||
type: 'Heading',
|
||||
props: { children: 'Creator spotlight', level: '3', className: 'text-lg font-semibold' },
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_feed_post_2_body',
|
||||
type: 'Text',
|
||||
props: { children: 'Nova shares her workflow for livestreaming and managing subscribers.' },
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'social_hub_feed_post_2_badge',
|
||||
type: 'Badge',
|
||||
props: { children: 'Spotlight', variant: 'secondary' },
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const socialHubExamples = {
|
||||
feedItems: [
|
||||
{
|
||||
id: 'post_001',
|
||||
author: 'Nova',
|
||||
title: 'Launch day recap',
|
||||
summary: 'We shipped live rooms and doubled community sessions.',
|
||||
tags: ['launch', 'community'],
|
||||
},
|
||||
{
|
||||
id: 'post_002',
|
||||
author: 'Kai',
|
||||
title: 'Build log: day 42',
|
||||
summary: 'Refined the moderation pipeline and added creator scorecards.',
|
||||
tags: ['buildinpublic'],
|
||||
},
|
||||
],
|
||||
trendingTags: ['#buildinpublic', '#metabuilder', '#live'],
|
||||
rooms: [
|
||||
{ id: 'room_1', title: 'Creator Q&A', host: 'Eli', live: true },
|
||||
{ id: 'room_2', title: 'Patch Notes', host: 'Nova', live: false },
|
||||
],
|
||||
}
|
||||
|
||||
const socialHubLuaScripts = [
|
||||
{
|
||||
fileName: 'init.lua',
|
||||
description: 'Lifecycle hooks for package installation.',
|
||||
code: 'local M = {}\n\nfunction M.on_install(context)\n return { message = "Social Hub installed", version = context.version }\nend\n\nfunction M.on_uninstall()\n return { message = "Social Hub removed" }\nend\n\nreturn M',
|
||||
},
|
||||
{
|
||||
fileName: 'permissions.lua',
|
||||
description: 'Role-based access rules for posting and moderation.',
|
||||
code: 'local Permissions = {}\n\nfunction Permissions.can_post(user)\n return user and (user.role == "user" or user.role == "admin" or user.role == "god")\nend\n\nfunction Permissions.can_moderate(user)\n return user and (user.role == "admin" or user.role == "god" or user.role == "supergod")\nend\n\nreturn Permissions',
|
||||
},
|
||||
{
|
||||
fileName: 'feed_rank.lua',
|
||||
description: 'Score feed items based on recency and engagement.',
|
||||
code: 'local FeedRank = {}\n\nfunction FeedRank.score(item)\n local freshness = item.age_minutes and (100 - item.age_minutes) or 50\n local engagement = (item.likes or 0) * 2 + (item.comments or 0) * 3\n return freshness + engagement\nend\n\nreturn FeedRank',
|
||||
},
|
||||
{
|
||||
fileName: 'moderation.lua',
|
||||
description: 'Flag content for review using lightweight heuristics.',
|
||||
code: 'local Moderation = {}\n\nfunction Moderation.flag(content)\n local lowered = string.lower(content or "")\n if string.find(lowered, "spam") then\n return { flagged = true, reason = "spam_keyword" }\n end\n return { flagged = false }\nend\n\nreturn Moderation',
|
||||
},
|
||||
{
|
||||
fileName: 'analytics.lua',
|
||||
description: 'Aggregate engagement signals for dashboards.',
|
||||
code: 'local Analytics = {}\n\nfunction Analytics.aggregate(events)\n local summary = { views = 0, likes = 0, comments = 0 }\n for _, event in ipairs(events or {}) do\n summary.views = summary.views + (event.views or 0)\n summary.likes = summary.likes + (event.likes or 0)\n summary.comments = summary.comments + (event.comments or 0)\n end\n return summary\nend\n\nreturn Analytics',
|
||||
},
|
||||
]
|
||||
export const REACT_APP_TEMPLATE_CONFIG: ReactAppTemplateConfig = BASE_REACT_APP_TEMPLATE_CONFIG
|
||||
|
||||
export const PACKAGE_TEMPLATE_CONFIGS: PackageTemplateConfig[] = [
|
||||
{
|
||||
id: 'package_social_hub',
|
||||
name: 'Social Hub Package',
|
||||
description: 'A package blueprint for social feeds, creator updates, and live rooms.',
|
||||
rootName: 'social_hub',
|
||||
packageId: 'social_hub',
|
||||
author: 'MetaBuilder',
|
||||
version: '1.0.0',
|
||||
category: 'social',
|
||||
summary: 'Modern social feed with creator tools and live rooms.',
|
||||
components: socialHubComponents,
|
||||
examples: socialHubExamples,
|
||||
luaScripts: socialHubLuaScripts,
|
||||
tags: ['package', 'social', 'feed', 'lua'],
|
||||
},
|
||||
...BASE_PACKAGE_TEMPLATE_CONFIGS,
|
||||
...ADVANCED_PACKAGE_TEMPLATE_CONFIGS,
|
||||
...EXPERIMENTAL_PACKAGE_TEMPLATE_CONFIGS,
|
||||
]
|
||||
|
||||
@@ -1,308 +1,6 @@
|
||||
import type { SchemaConfig } from '../types/schema-types'
|
||||
import { defaultApps } from './default/components'
|
||||
|
||||
export const defaultSchema: SchemaConfig = {
|
||||
apps: [
|
||||
{
|
||||
name: 'blog',
|
||||
label: 'Blog',
|
||||
models: [
|
||||
{
|
||||
name: 'post',
|
||||
label: 'Post',
|
||||
labelPlural: 'Posts',
|
||||
icon: 'Article',
|
||||
listDisplay: ['title', 'author', 'status', 'publishedAt'],
|
||||
listFilter: ['status', 'author'],
|
||||
searchFields: ['title', 'content'],
|
||||
ordering: ['-publishedAt'],
|
||||
fields: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
label: 'ID',
|
||||
required: true,
|
||||
unique: true,
|
||||
editable: false,
|
||||
listDisplay: false,
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
label: 'Title',
|
||||
required: true,
|
||||
validation: {
|
||||
minLength: 3,
|
||||
maxLength: 200,
|
||||
},
|
||||
listDisplay: true,
|
||||
searchable: true,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'string',
|
||||
label: 'Slug',
|
||||
required: true,
|
||||
unique: true,
|
||||
helpText: 'URL-friendly version of the title',
|
||||
validation: {
|
||||
pattern: '^[a-z0-9-]+$',
|
||||
},
|
||||
listDisplay: false,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
type: 'text',
|
||||
label: 'Content',
|
||||
required: true,
|
||||
helpText: 'Main post content',
|
||||
listDisplay: false,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
name: 'excerpt',
|
||||
type: 'text',
|
||||
label: 'Excerpt',
|
||||
required: false,
|
||||
helpText: ['Short summary of the post', 'Used in list views and previews'],
|
||||
validation: {
|
||||
maxLength: 500,
|
||||
},
|
||||
listDisplay: false,
|
||||
},
|
||||
{
|
||||
name: 'author',
|
||||
type: 'relation',
|
||||
label: 'Author',
|
||||
required: true,
|
||||
relatedModel: 'author',
|
||||
listDisplay: true,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
label: 'Status',
|
||||
required: true,
|
||||
default: 'draft',
|
||||
choices: [
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
{ value: 'published', label: 'Published' },
|
||||
{ value: 'archived', label: 'Archived' },
|
||||
],
|
||||
listDisplay: true,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'featured',
|
||||
type: 'boolean',
|
||||
label: 'Featured',
|
||||
default: false,
|
||||
helpText: 'Display on homepage',
|
||||
listDisplay: true,
|
||||
},
|
||||
{
|
||||
name: 'publishedAt',
|
||||
type: 'datetime',
|
||||
label: 'Published At',
|
||||
required: false,
|
||||
listDisplay: true,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
type: 'json',
|
||||
label: 'Tags',
|
||||
required: false,
|
||||
helpText: 'JSON array of tag strings',
|
||||
listDisplay: false,
|
||||
},
|
||||
{
|
||||
name: 'views',
|
||||
type: 'number',
|
||||
label: 'Views',
|
||||
default: 0,
|
||||
validation: {
|
||||
min: 0,
|
||||
},
|
||||
listDisplay: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'author',
|
||||
label: 'Author',
|
||||
labelPlural: 'Authors',
|
||||
icon: 'User',
|
||||
listDisplay: ['name', 'email', 'active', 'createdAt'],
|
||||
listFilter: ['active'],
|
||||
searchFields: ['name', 'email'],
|
||||
ordering: ['name'],
|
||||
fields: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
label: 'ID',
|
||||
required: true,
|
||||
unique: true,
|
||||
editable: false,
|
||||
listDisplay: false,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
label: 'Name',
|
||||
required: true,
|
||||
validation: {
|
||||
minLength: 2,
|
||||
maxLength: 100,
|
||||
},
|
||||
listDisplay: true,
|
||||
searchable: true,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
label: 'Email',
|
||||
required: true,
|
||||
unique: true,
|
||||
listDisplay: true,
|
||||
searchable: true,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'bio',
|
||||
type: 'text',
|
||||
label: 'Bio',
|
||||
required: false,
|
||||
helpText: 'Author biography',
|
||||
validation: {
|
||||
maxLength: 1000,
|
||||
},
|
||||
listDisplay: false,
|
||||
},
|
||||
{
|
||||
name: 'website',
|
||||
type: 'url',
|
||||
label: 'Website',
|
||||
required: false,
|
||||
listDisplay: false,
|
||||
},
|
||||
{
|
||||
name: 'active',
|
||||
type: 'boolean',
|
||||
label: 'Active',
|
||||
default: true,
|
||||
listDisplay: true,
|
||||
},
|
||||
{
|
||||
name: 'createdAt',
|
||||
type: 'datetime',
|
||||
label: 'Created At',
|
||||
required: true,
|
||||
editable: false,
|
||||
listDisplay: true,
|
||||
sortable: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ecommerce',
|
||||
label: 'E-Commerce',
|
||||
models: [
|
||||
{
|
||||
name: 'product',
|
||||
label: 'Product',
|
||||
labelPlural: 'Products',
|
||||
icon: 'ShoppingCart',
|
||||
listDisplay: ['name', 'price', 'stock', 'available'],
|
||||
listFilter: ['available', 'category'],
|
||||
searchFields: ['name', 'description'],
|
||||
ordering: ['name'],
|
||||
fields: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
label: 'ID',
|
||||
required: true,
|
||||
unique: true,
|
||||
editable: false,
|
||||
listDisplay: false,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
label: 'Product Name',
|
||||
required: true,
|
||||
validation: {
|
||||
minLength: 3,
|
||||
maxLength: 200,
|
||||
},
|
||||
listDisplay: true,
|
||||
searchable: true,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'text',
|
||||
label: 'Description',
|
||||
required: false,
|
||||
helpText: 'Product description',
|
||||
listDisplay: false,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
name: 'price',
|
||||
type: 'number',
|
||||
label: 'Price',
|
||||
required: true,
|
||||
validation: {
|
||||
min: 0,
|
||||
},
|
||||
listDisplay: true,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'stock',
|
||||
type: 'number',
|
||||
label: 'Stock',
|
||||
required: true,
|
||||
default: 0,
|
||||
validation: {
|
||||
min: 0,
|
||||
},
|
||||
listDisplay: true,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'category',
|
||||
type: 'select',
|
||||
label: 'Category',
|
||||
required: true,
|
||||
choices: [
|
||||
{ value: 'electronics', label: 'Electronics' },
|
||||
{ value: 'clothing', label: 'Clothing' },
|
||||
{ value: 'books', label: 'Books' },
|
||||
{ value: 'home', label: 'Home & Garden' },
|
||||
{ value: 'toys', label: 'Toys' },
|
||||
],
|
||||
listDisplay: false,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'available',
|
||||
type: 'boolean',
|
||||
label: 'Available',
|
||||
default: true,
|
||||
listDisplay: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
apps: defaultApps,
|
||||
}
|
||||
|
||||
54
frontends/nextjs/src/lib/schema/default/components.ts
Normal file
54
frontends/nextjs/src/lib/schema/default/components.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { AppSchema, ModelSchema } from '../../types/schema-types'
|
||||
import { authorFields, postFields, productFields } from './forms'
|
||||
|
||||
export const blogModels: ModelSchema[] = [
|
||||
{
|
||||
name: 'post',
|
||||
label: 'Post',
|
||||
labelPlural: 'Posts',
|
||||
icon: 'Article',
|
||||
listDisplay: ['title', 'author', 'status', 'publishedAt'],
|
||||
listFilter: ['status', 'author'],
|
||||
searchFields: ['title', 'content'],
|
||||
ordering: ['-publishedAt'],
|
||||
fields: postFields,
|
||||
},
|
||||
{
|
||||
name: 'author',
|
||||
label: 'Author',
|
||||
labelPlural: 'Authors',
|
||||
icon: 'User',
|
||||
listDisplay: ['name', 'email', 'active', 'createdAt'],
|
||||
listFilter: ['active'],
|
||||
searchFields: ['name', 'email'],
|
||||
ordering: ['name'],
|
||||
fields: authorFields,
|
||||
},
|
||||
]
|
||||
|
||||
export const ecommerceModels: ModelSchema[] = [
|
||||
{
|
||||
name: 'product',
|
||||
label: 'Product',
|
||||
labelPlural: 'Products',
|
||||
icon: 'ShoppingCart',
|
||||
listDisplay: ['name', 'price', 'stock', 'available'],
|
||||
listFilter: ['available', 'category'],
|
||||
searchFields: ['name', 'description'],
|
||||
ordering: ['name'],
|
||||
fields: productFields,
|
||||
},
|
||||
]
|
||||
|
||||
export const defaultApps: AppSchema[] = [
|
||||
{
|
||||
name: 'blog',
|
||||
label: 'Blog',
|
||||
models: blogModels,
|
||||
},
|
||||
{
|
||||
name: 'ecommerce',
|
||||
label: 'E-Commerce',
|
||||
models: ecommerceModels,
|
||||
},
|
||||
]
|
||||
244
frontends/nextjs/src/lib/schema/default/forms.ts
Normal file
244
frontends/nextjs/src/lib/schema/default/forms.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import type { FieldSchema } from '../../types/schema-types'
|
||||
import { authorValidations, postValidations, productValidations } from './validation'
|
||||
|
||||
export const postFields: FieldSchema[] = [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
label: 'ID',
|
||||
required: true,
|
||||
unique: true,
|
||||
editable: false,
|
||||
listDisplay: false,
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
label: 'Title',
|
||||
required: true,
|
||||
validation: postValidations.title,
|
||||
listDisplay: true,
|
||||
searchable: true,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'string',
|
||||
label: 'Slug',
|
||||
required: true,
|
||||
unique: true,
|
||||
helpText: 'URL-friendly version of the title',
|
||||
validation: postValidations.slug,
|
||||
listDisplay: false,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
type: 'text',
|
||||
label: 'Content',
|
||||
required: true,
|
||||
helpText: 'Main post content',
|
||||
listDisplay: false,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
name: 'excerpt',
|
||||
type: 'text',
|
||||
label: 'Excerpt',
|
||||
required: false,
|
||||
helpText: ['Short summary of the post', 'Used in list views and previews'],
|
||||
validation: postValidations.excerpt,
|
||||
listDisplay: false,
|
||||
},
|
||||
{
|
||||
name: 'author',
|
||||
type: 'relation',
|
||||
label: 'Author',
|
||||
required: true,
|
||||
relatedModel: 'author',
|
||||
listDisplay: true,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
label: 'Status',
|
||||
required: true,
|
||||
default: 'draft',
|
||||
choices: [
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
{ value: 'published', label: 'Published' },
|
||||
{ value: 'archived', label: 'Archived' },
|
||||
],
|
||||
listDisplay: true,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'featured',
|
||||
type: 'boolean',
|
||||
label: 'Featured',
|
||||
default: false,
|
||||
helpText: 'Display on homepage',
|
||||
listDisplay: true,
|
||||
},
|
||||
{
|
||||
name: 'publishedAt',
|
||||
type: 'datetime',
|
||||
label: 'Published At',
|
||||
required: false,
|
||||
listDisplay: true,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
type: 'json',
|
||||
label: 'Tags',
|
||||
required: false,
|
||||
helpText: 'JSON array of tag strings',
|
||||
listDisplay: false,
|
||||
},
|
||||
{
|
||||
name: 'views',
|
||||
type: 'number',
|
||||
label: 'Views',
|
||||
default: 0,
|
||||
validation: postValidations.views,
|
||||
listDisplay: false,
|
||||
},
|
||||
]
|
||||
|
||||
export const authorFields: FieldSchema[] = [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
label: 'ID',
|
||||
required: true,
|
||||
unique: true,
|
||||
editable: false,
|
||||
listDisplay: false,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
label: 'Name',
|
||||
required: true,
|
||||
validation: authorValidations.name,
|
||||
listDisplay: true,
|
||||
searchable: true,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
label: 'Email',
|
||||
required: true,
|
||||
unique: true,
|
||||
listDisplay: true,
|
||||
searchable: true,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'bio',
|
||||
type: 'text',
|
||||
label: 'Bio',
|
||||
required: false,
|
||||
helpText: 'Author biography',
|
||||
validation: authorValidations.bio,
|
||||
listDisplay: false,
|
||||
},
|
||||
{
|
||||
name: 'website',
|
||||
type: 'url',
|
||||
label: 'Website',
|
||||
required: false,
|
||||
listDisplay: false,
|
||||
},
|
||||
{
|
||||
name: 'active',
|
||||
type: 'boolean',
|
||||
label: 'Active',
|
||||
default: true,
|
||||
listDisplay: true,
|
||||
},
|
||||
{
|
||||
name: 'createdAt',
|
||||
type: 'datetime',
|
||||
label: 'Created At',
|
||||
required: true,
|
||||
editable: false,
|
||||
listDisplay: true,
|
||||
sortable: true,
|
||||
},
|
||||
]
|
||||
|
||||
export const productFields: FieldSchema[] = [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
label: 'ID',
|
||||
required: true,
|
||||
unique: true,
|
||||
editable: false,
|
||||
listDisplay: false,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
label: 'Product Name',
|
||||
required: true,
|
||||
validation: productValidations.name,
|
||||
listDisplay: true,
|
||||
searchable: true,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'text',
|
||||
label: 'Description',
|
||||
required: false,
|
||||
helpText: 'Product description',
|
||||
listDisplay: false,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
name: 'price',
|
||||
type: 'number',
|
||||
label: 'Price',
|
||||
required: true,
|
||||
validation: productValidations.price,
|
||||
listDisplay: true,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'stock',
|
||||
type: 'number',
|
||||
label: 'Stock',
|
||||
required: true,
|
||||
default: 0,
|
||||
validation: productValidations.stock,
|
||||
listDisplay: true,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'category',
|
||||
type: 'select',
|
||||
label: 'Category',
|
||||
required: true,
|
||||
choices: [
|
||||
{ value: 'electronics', label: 'Electronics' },
|
||||
{ value: 'clothing', label: 'Clothing' },
|
||||
{ value: 'books', label: 'Books' },
|
||||
{ value: 'home', label: 'Home & Garden' },
|
||||
{ value: 'toys', label: 'Toys' },
|
||||
],
|
||||
listDisplay: false,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'available',
|
||||
type: 'boolean',
|
||||
label: 'Available',
|
||||
default: true,
|
||||
listDisplay: true,
|
||||
},
|
||||
]
|
||||
19
frontends/nextjs/src/lib/schema/default/validation.ts
Normal file
19
frontends/nextjs/src/lib/schema/default/validation.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { FieldSchema } from '../../types/schema-types'
|
||||
|
||||
export const postValidations: Record<string, FieldSchema['validation']> = {
|
||||
title: { minLength: 3, maxLength: 200 },
|
||||
slug: { pattern: '^[a-z0-9-]+$' },
|
||||
excerpt: { maxLength: 500 },
|
||||
views: { min: 0 },
|
||||
}
|
||||
|
||||
export const authorValidations: Record<string, FieldSchema['validation']> = {
|
||||
name: { minLength: 2, maxLength: 100 },
|
||||
bio: { maxLength: 1000 },
|
||||
}
|
||||
|
||||
export const productValidations: Record<string, FieldSchema['validation']> = {
|
||||
name: { minLength: 3, maxLength: 200 },
|
||||
price: { min: 0 },
|
||||
stock: { min: 0 },
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Schema utilities exports
|
||||
export * from './schema-utils'
|
||||
export { defaultSchema } from './default-schema'
|
||||
export * from './default/components'
|
||||
export * from './default/forms'
|
||||
export * from './default/validation'
|
||||
|
||||
@@ -4,181 +4,12 @@
|
||||
*/
|
||||
|
||||
import type { SecurityPattern } from '../types'
|
||||
import { JAVASCRIPT_INJECTION_PATTERNS } from './javascript/injection'
|
||||
import { JAVASCRIPT_MISC_PATTERNS } from './javascript/misc'
|
||||
import { JAVASCRIPT_XSS_PATTERNS } from './javascript/xss'
|
||||
|
||||
export const JAVASCRIPT_PATTERNS: SecurityPattern[] = [
|
||||
{
|
||||
pattern: /eval\s*\(/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'critical',
|
||||
message: 'Use of eval() detected - can execute arbitrary code',
|
||||
recommendation: 'Use safe alternatives like JSON.parse() or Function constructor with strict validation'
|
||||
},
|
||||
{
|
||||
pattern: /Function\s*\(/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'high',
|
||||
message: 'Dynamic Function constructor detected',
|
||||
recommendation: 'Avoid dynamic code generation or use with extreme caution'
|
||||
},
|
||||
{
|
||||
pattern: /innerHTML\s*=/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'high',
|
||||
message: 'innerHTML assignment detected - XSS vulnerability risk',
|
||||
recommendation: 'Use textContent, createElement, or React JSX instead'
|
||||
},
|
||||
{
|
||||
pattern: /dangerouslySetInnerHTML/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'high',
|
||||
message: 'dangerouslySetInnerHTML detected - XSS vulnerability risk',
|
||||
recommendation: 'Sanitize HTML content or use safe alternatives'
|
||||
},
|
||||
{
|
||||
pattern: /document\.write\s*\(/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'medium',
|
||||
message: 'document.write() detected - can cause security issues',
|
||||
recommendation: 'Use DOM manipulation methods instead'
|
||||
},
|
||||
{
|
||||
pattern: /\.call\s*\(\s*window/gi,
|
||||
type: 'suspicious',
|
||||
severity: 'medium',
|
||||
message: 'Calling functions with window context',
|
||||
recommendation: 'Be careful with context manipulation'
|
||||
},
|
||||
{
|
||||
pattern: /\.apply\s*\(\s*window/gi,
|
||||
type: 'suspicious',
|
||||
severity: 'medium',
|
||||
message: 'Applying functions with window context',
|
||||
recommendation: 'Be careful with context manipulation'
|
||||
},
|
||||
{
|
||||
pattern: /__proto__/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'critical',
|
||||
message: 'Prototype pollution attempt detected',
|
||||
recommendation: 'Never manipulate __proto__ directly'
|
||||
},
|
||||
{
|
||||
pattern: /constructor\s*\[\s*['"]prototype['"]\s*\]/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'critical',
|
||||
message: 'Prototype manipulation detected',
|
||||
recommendation: 'Use Object.create() or proper class syntax'
|
||||
},
|
||||
{
|
||||
pattern: /import\s+.*\s+from\s+['"]https?:/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'critical',
|
||||
message: 'Remote code import detected',
|
||||
recommendation: 'Only import from trusted, local sources'
|
||||
},
|
||||
{
|
||||
pattern: /<script[^>]*>/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'critical',
|
||||
message: 'Script tag injection detected',
|
||||
recommendation: 'Never inject script tags dynamically'
|
||||
},
|
||||
{
|
||||
pattern: /on(click|load|error|mouseover|mouseout|focus|blur)\s*=/gi,
|
||||
type: 'suspicious',
|
||||
severity: 'medium',
|
||||
message: 'Inline event handler detected',
|
||||
recommendation: 'Use addEventListener or React event handlers'
|
||||
},
|
||||
{
|
||||
pattern: /javascript:\s*/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'high',
|
||||
message: 'javascript: protocol detected',
|
||||
recommendation: 'Never use javascript: protocol in URLs'
|
||||
},
|
||||
{
|
||||
pattern: /data:\s*text\/html/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'high',
|
||||
message: 'Data URI with HTML detected',
|
||||
recommendation: 'Avoid data URIs with executable content'
|
||||
},
|
||||
{
|
||||
pattern: /setTimeout\s*\(\s*['"`]/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'high',
|
||||
message: 'setTimeout with string argument detected',
|
||||
recommendation: 'Use setTimeout with function reference instead'
|
||||
},
|
||||
{
|
||||
pattern: /setInterval\s*\(\s*['"`]/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'high',
|
||||
message: 'setInterval with string argument detected',
|
||||
recommendation: 'Use setInterval with function reference instead'
|
||||
},
|
||||
{
|
||||
pattern: /localStorage|sessionStorage/gi,
|
||||
type: 'warning',
|
||||
severity: 'low',
|
||||
message: 'Local/session storage usage detected',
|
||||
recommendation: 'Use useKV hook for persistent data instead'
|
||||
},
|
||||
{
|
||||
pattern: /crypto\.subtle|atob|btoa/gi,
|
||||
type: 'warning',
|
||||
severity: 'low',
|
||||
message: 'Cryptographic operation detected',
|
||||
recommendation: 'Ensure proper key management and secure practices'
|
||||
},
|
||||
{
|
||||
pattern: /XMLHttpRequest|fetch\s*\(\s*['"`]http/gi,
|
||||
type: 'warning',
|
||||
severity: 'medium',
|
||||
message: 'External HTTP request detected',
|
||||
recommendation: 'Ensure CORS and security headers are properly configured'
|
||||
},
|
||||
{
|
||||
pattern: /window\.open/gi,
|
||||
type: 'suspicious',
|
||||
severity: 'medium',
|
||||
message: 'window.open detected',
|
||||
recommendation: 'Be cautious with popup windows'
|
||||
},
|
||||
{
|
||||
pattern: /location\.href\s*=/gi,
|
||||
type: 'suspicious',
|
||||
severity: 'medium',
|
||||
message: 'Direct location manipulation detected',
|
||||
recommendation: 'Use React Router or validate URLs carefully'
|
||||
},
|
||||
{
|
||||
pattern: /require\s*\(\s*[^'"`]/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'high',
|
||||
message: 'Dynamic require() detected',
|
||||
recommendation: 'Use static imports only'
|
||||
},
|
||||
{
|
||||
pattern: /\.exec\s*\(|child_process|spawn|fork|execFile/gi,
|
||||
type: 'malicious',
|
||||
severity: 'critical',
|
||||
message: 'System command execution attempt detected',
|
||||
recommendation: 'This is not allowed in browser environment'
|
||||
},
|
||||
{
|
||||
pattern: /fs\.|path\.|os\./gi,
|
||||
type: 'malicious',
|
||||
severity: 'critical',
|
||||
message: 'Node.js system module usage detected',
|
||||
recommendation: 'File system access not allowed in browser'
|
||||
},
|
||||
{
|
||||
pattern: /process\.env|process\.exit/gi,
|
||||
type: 'suspicious',
|
||||
severity: 'medium',
|
||||
message: 'Process manipulation detected',
|
||||
recommendation: 'Not applicable in browser environment'
|
||||
}
|
||||
...JAVASCRIPT_INJECTION_PATTERNS,
|
||||
...JAVASCRIPT_XSS_PATTERNS,
|
||||
...JAVASCRIPT_MISC_PATTERNS
|
||||
]
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { SecurityPattern } from '../../types'
|
||||
|
||||
export const JAVASCRIPT_INJECTION_PATTERNS: SecurityPattern[] = [
|
||||
{
|
||||
pattern: /eval\s*\(/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'critical',
|
||||
message: 'Use of eval() detected - can execute arbitrary code',
|
||||
recommendation: 'Use safe alternatives like JSON.parse() or Function constructor with strict validation'
|
||||
},
|
||||
{
|
||||
pattern: /Function\s*\(/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'high',
|
||||
message: 'Dynamic Function constructor detected',
|
||||
recommendation: 'Avoid dynamic code generation or use with extreme caution'
|
||||
},
|
||||
{
|
||||
pattern: /import\s+.*\s+from\s+['"]https?:/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'critical',
|
||||
message: 'Remote code import detected',
|
||||
recommendation: 'Only import from trusted, local sources'
|
||||
},
|
||||
{
|
||||
pattern: /setTimeout\s*\(\s*['"`]/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'high',
|
||||
message: 'setTimeout with string argument detected',
|
||||
recommendation: 'Use setTimeout with function reference instead'
|
||||
},
|
||||
{
|
||||
pattern: /setInterval\s*\(\s*['"`]/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'high',
|
||||
message: 'setInterval with string argument detected',
|
||||
recommendation: 'Use setInterval with function reference instead'
|
||||
},
|
||||
{
|
||||
pattern: /require\s*\(\s*[^'"`]/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'high',
|
||||
message: 'Dynamic require() detected',
|
||||
recommendation: 'Use static imports only'
|
||||
},
|
||||
{
|
||||
pattern: /\.exec\s*\(|child_process|spawn|fork|execFile/gi,
|
||||
type: 'malicious',
|
||||
severity: 'critical',
|
||||
message: 'System command execution attempt detected',
|
||||
recommendation: 'This is not allowed in browser environment'
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { SecurityPattern } from '../../types'
|
||||
|
||||
export const JAVASCRIPT_MISC_PATTERNS: SecurityPattern[] = [
|
||||
{
|
||||
pattern: /\.call\s*\(\s*window/gi,
|
||||
type: 'suspicious',
|
||||
severity: 'medium',
|
||||
message: 'Calling functions with window context',
|
||||
recommendation: 'Be careful with context manipulation'
|
||||
},
|
||||
{
|
||||
pattern: /\.apply\s*\(\s*window/gi,
|
||||
type: 'suspicious',
|
||||
severity: 'medium',
|
||||
message: 'Applying functions with window context',
|
||||
recommendation: 'Be careful with context manipulation'
|
||||
},
|
||||
{
|
||||
pattern: /__proto__/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'critical',
|
||||
message: 'Prototype pollution attempt detected',
|
||||
recommendation: 'Never manipulate __proto__ directly'
|
||||
},
|
||||
{
|
||||
pattern: /constructor\s*\[\s*['"]prototype['"]\s*\]/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'critical',
|
||||
message: 'Prototype manipulation detected',
|
||||
recommendation: 'Use Object.create() or proper class syntax'
|
||||
},
|
||||
{
|
||||
pattern: /localStorage|sessionStorage/gi,
|
||||
type: 'warning',
|
||||
severity: 'low',
|
||||
message: 'Local/session storage usage detected',
|
||||
recommendation: 'Use useKV hook for persistent data instead'
|
||||
},
|
||||
{
|
||||
pattern: /crypto\.subtle|atob|btoa/gi,
|
||||
type: 'warning',
|
||||
severity: 'low',
|
||||
message: 'Cryptographic operation detected',
|
||||
recommendation: 'Ensure proper key management and secure practices'
|
||||
},
|
||||
{
|
||||
pattern: /XMLHttpRequest|fetch\s*\(\s*['"`]http/gi,
|
||||
type: 'warning',
|
||||
severity: 'medium',
|
||||
message: 'External HTTP request detected',
|
||||
recommendation: 'Ensure CORS and security headers are properly configured'
|
||||
},
|
||||
{
|
||||
pattern: /window\.open/gi,
|
||||
type: 'suspicious',
|
||||
severity: 'medium',
|
||||
message: 'window.open detected',
|
||||
recommendation: 'Be cautious with popup windows'
|
||||
},
|
||||
{
|
||||
pattern: /location\.href\s*=/gi,
|
||||
type: 'suspicious',
|
||||
severity: 'medium',
|
||||
message: 'Direct location manipulation detected',
|
||||
recommendation: 'Use React Router or validate URLs carefully'
|
||||
},
|
||||
{
|
||||
pattern: /fs\.|path\.|os\./gi,
|
||||
type: 'malicious',
|
||||
severity: 'critical',
|
||||
message: 'Node.js system module usage detected',
|
||||
recommendation: 'File system access not allowed in browser'
|
||||
},
|
||||
{
|
||||
pattern: /process\.env|process\.exit/gi,
|
||||
type: 'suspicious',
|
||||
severity: 'medium',
|
||||
message: 'Process manipulation detected',
|
||||
recommendation: 'Not applicable in browser environment'
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { SecurityPattern } from '../../types'
|
||||
|
||||
export const JAVASCRIPT_XSS_PATTERNS: SecurityPattern[] = [
|
||||
{
|
||||
pattern: /innerHTML\s*=/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'high',
|
||||
message: 'innerHTML assignment detected - XSS vulnerability risk',
|
||||
recommendation: 'Use textContent, createElement, or React JSX instead'
|
||||
},
|
||||
{
|
||||
pattern: /dangerouslySetInnerHTML/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'high',
|
||||
message: 'dangerouslySetInnerHTML detected - XSS vulnerability risk',
|
||||
recommendation: 'Sanitize HTML content or use safe alternatives'
|
||||
},
|
||||
{
|
||||
pattern: /document\.write\s*\(/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'medium',
|
||||
message: 'document.write() detected - can cause security issues',
|
||||
recommendation: 'Use DOM manipulation methods instead'
|
||||
},
|
||||
{
|
||||
pattern: /<script[^>]*>/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'critical',
|
||||
message: 'Script tag injection detected',
|
||||
recommendation: 'Never inject script tags dynamically'
|
||||
},
|
||||
{
|
||||
pattern: /on(click|load|error|mouseover|mouseout|focus|blur)\s*=/gi,
|
||||
type: 'suspicious',
|
||||
severity: 'medium',
|
||||
message: 'Inline event handler detected',
|
||||
recommendation: 'Use addEventListener or React event handlers'
|
||||
},
|
||||
{
|
||||
pattern: /javascript:\s*/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'high',
|
||||
message: 'javascript: protocol detected',
|
||||
recommendation: 'Never use javascript: protocol in URLs'
|
||||
},
|
||||
{
|
||||
pattern: /data:\s*text\/html/gi,
|
||||
type: 'dangerous',
|
||||
severity: 'high',
|
||||
message: 'Data URI with HTML detected',
|
||||
recommendation: 'Avoid data URIs with executable content'
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,234 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { scanForVulnerabilities, securityScanner } from '@/lib/security-scanner'
|
||||
|
||||
describe('security-scanner detection', () => {
|
||||
describe('scanJavaScript', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'flag eval usage as critical',
|
||||
code: ['const safe = true;', 'const result = eval("1 + 1")'].join('\n'),
|
||||
expectedSeverity: 'critical',
|
||||
expectedSafe: false,
|
||||
expectedIssueType: 'dangerous',
|
||||
expectedIssuePattern: 'eval',
|
||||
expectedLine: 2,
|
||||
},
|
||||
{
|
||||
name: 'warn on localStorage usage but stay safe',
|
||||
code: 'localStorage.setItem("k", "v")',
|
||||
expectedSeverity: 'low',
|
||||
expectedSafe: true,
|
||||
expectedIssueType: 'warning',
|
||||
expectedIssuePattern: 'localStorage',
|
||||
},
|
||||
{
|
||||
name: 'return safe for benign code',
|
||||
code: 'const sum = (a, b) => a + b',
|
||||
expectedSeverity: 'safe',
|
||||
expectedSafe: true,
|
||||
},
|
||||
])(
|
||||
'should $name',
|
||||
({ code, expectedSeverity, expectedSafe, expectedIssueType, expectedIssuePattern, expectedLine }) => {
|
||||
const result = securityScanner.scanJavaScript(code)
|
||||
expect(result.severity).toBe(expectedSeverity)
|
||||
expect(result.safe).toBe(expectedSafe)
|
||||
|
||||
if (expectedIssueType || expectedIssuePattern) {
|
||||
const issue = result.issues.find(item => {
|
||||
const matchesType = expectedIssueType ? item.type === expectedIssueType : true
|
||||
const matchesPattern = expectedIssuePattern ? item.pattern.includes(expectedIssuePattern) : true
|
||||
return matchesType && matchesPattern
|
||||
})
|
||||
expect(issue).toBeDefined()
|
||||
if (expectedLine !== undefined) {
|
||||
expect(issue?.line).toBe(expectedLine)
|
||||
}
|
||||
} else {
|
||||
expect(result.issues.length).toBe(0)
|
||||
}
|
||||
|
||||
if (expectedSafe) {
|
||||
expect(result.sanitizedCode).toBe(code)
|
||||
} else {
|
||||
expect(result.sanitizedCode).toBeUndefined()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('scanLua', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'flag os.execute usage as critical',
|
||||
code: 'os.execute("rm -rf /")',
|
||||
expectedSeverity: 'critical',
|
||||
expectedSafe: false,
|
||||
expectedIssueType: 'malicious',
|
||||
expectedIssuePattern: 'os.execute',
|
||||
},
|
||||
{
|
||||
name: 'return safe for simple Lua function',
|
||||
code: 'function add(a, b) return a + b end',
|
||||
expectedSeverity: 'safe',
|
||||
expectedSafe: true,
|
||||
},
|
||||
])('should $name', ({ code, expectedSeverity, expectedSafe, expectedIssueType, expectedIssuePattern }) => {
|
||||
const result = securityScanner.scanLua(code)
|
||||
expect(result.severity).toBe(expectedSeverity)
|
||||
expect(result.safe).toBe(expectedSafe)
|
||||
|
||||
if (expectedIssueType || expectedIssuePattern) {
|
||||
const issue = result.issues.find(item => {
|
||||
const matchesType = expectedIssueType ? item.type === expectedIssueType : true
|
||||
const matchesPattern = expectedIssuePattern ? item.pattern.includes(expectedIssuePattern) : true
|
||||
return matchesType && matchesPattern
|
||||
})
|
||||
expect(issue).toBeDefined()
|
||||
} else {
|
||||
expect(result.issues.length).toBe(0)
|
||||
}
|
||||
|
||||
if (expectedSafe) {
|
||||
expect(result.sanitizedCode).toBe(code)
|
||||
} else {
|
||||
expect(result.sanitizedCode).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('scanJSON', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'flag invalid JSON as medium severity',
|
||||
json: '{"value": }',
|
||||
expectedSeverity: 'medium',
|
||||
expectedSafe: false,
|
||||
expectedIssuePattern: 'JSON parse error',
|
||||
},
|
||||
{
|
||||
name: 'flag prototype pollution in JSON as critical',
|
||||
json: '{"__proto__": {"polluted": true}}',
|
||||
expectedSeverity: 'critical',
|
||||
expectedSafe: false,
|
||||
expectedIssuePattern: '__proto__',
|
||||
},
|
||||
{
|
||||
name: 'return safe for valid JSON',
|
||||
json: '{"ok": true}',
|
||||
expectedSeverity: 'safe',
|
||||
expectedSafe: true,
|
||||
},
|
||||
])('should $name', ({ json, expectedSeverity, expectedSafe, expectedIssuePattern }) => {
|
||||
const result = securityScanner.scanJSON(json)
|
||||
expect(result.severity).toBe(expectedSeverity)
|
||||
expect(result.safe).toBe(expectedSafe)
|
||||
|
||||
if (expectedIssuePattern) {
|
||||
expect(result.issues.some(issue => issue.pattern.includes(expectedIssuePattern))).toBe(true)
|
||||
} else {
|
||||
expect(result.issues.length).toBe(0)
|
||||
}
|
||||
|
||||
if (expectedSafe) {
|
||||
expect(result.sanitizedCode).toBe(json)
|
||||
} else {
|
||||
expect(result.sanitizedCode).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('scanHTML', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'flag script tags as critical',
|
||||
html: '<div><script>alert(1)</script></div>',
|
||||
expectedSeverity: 'critical',
|
||||
expectedSafe: false,
|
||||
},
|
||||
{
|
||||
name: 'flag inline handlers as high',
|
||||
html: '<button onclick="alert(1)">Click</button>',
|
||||
expectedSeverity: 'high',
|
||||
expectedSafe: false,
|
||||
},
|
||||
{
|
||||
name: 'return safe for plain markup',
|
||||
html: '<div><span>Safe</span></div>',
|
||||
expectedSeverity: 'safe',
|
||||
expectedSafe: true,
|
||||
},
|
||||
])('should $name', ({ html, expectedSeverity, expectedSafe }) => {
|
||||
const result = securityScanner.scanHTML(html)
|
||||
expect(result.severity).toBe(expectedSeverity)
|
||||
expect(result.safe).toBe(expectedSafe)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeInput', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'remove script tags and inline handlers from text',
|
||||
input: '<div onclick="alert(1)">Click</div><script>alert(2)</script><a href="javascript:alert(3)">x</a>',
|
||||
type: 'text' as const,
|
||||
shouldExclude: ['<script', 'onclick', 'javascript:'],
|
||||
},
|
||||
{
|
||||
name: 'remove data html URIs from html',
|
||||
input: '<img src="data:text/html;base64,abc"><script>alert(1)</script>',
|
||||
type: 'html' as const,
|
||||
shouldExclude: ['data:text/html', '<script'],
|
||||
},
|
||||
{
|
||||
name: 'neutralize prototype pollution in json',
|
||||
input: '{"__proto__": {"polluted": true}, "note": "constructor[\\"prototype\\"]"}',
|
||||
type: 'json' as const,
|
||||
shouldInclude: ['_proto_'],
|
||||
shouldExclude: ['__proto__', 'constructor["prototype"]'],
|
||||
},
|
||||
])('should $name', ({ input, type, shouldExclude = [], shouldInclude = [] }) => {
|
||||
const sanitized = securityScanner.sanitizeInput(input, type)
|
||||
shouldExclude.forEach(value => {
|
||||
expect(sanitized).not.toContain(value)
|
||||
})
|
||||
shouldInclude.forEach(value => {
|
||||
expect(sanitized).toContain(value)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('scanForVulnerabilities', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'auto-detects JSON and flags prototype pollution',
|
||||
code: '{"__proto__": {"polluted": true}}',
|
||||
expectedSeverity: 'critical',
|
||||
},
|
||||
{
|
||||
name: 'auto-detects Lua when function/end present',
|
||||
code: 'function dangerous() os.execute("rm -rf /") end',
|
||||
expectedSeverity: 'critical',
|
||||
},
|
||||
{
|
||||
name: 'auto-detects HTML and flags script tags',
|
||||
code: '<div><script>alert(1)</script></div>',
|
||||
expectedSeverity: 'critical',
|
||||
},
|
||||
{
|
||||
name: 'falls back to JavaScript scanning',
|
||||
code: 'const result = eval("1 + 1")',
|
||||
expectedSeverity: 'critical',
|
||||
},
|
||||
{
|
||||
name: 'honors explicit type parameter',
|
||||
code: 'return 1',
|
||||
type: 'lua' as const,
|
||||
expectedSeverity: 'safe',
|
||||
},
|
||||
])('should $name', ({ code, type, expectedSeverity }) => {
|
||||
const result = scanForVulnerabilities(code, type)
|
||||
expect(result.severity).toBe(expectedSeverity)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getSeverityColor, getSeverityIcon } from '@/lib/security-scanner'
|
||||
|
||||
describe('security-scanner reporting', () => {
|
||||
describe('getSeverityColor', () => {
|
||||
it.each([
|
||||
{ severity: 'critical', expected: 'error' },
|
||||
{ severity: 'high', expected: 'warning' },
|
||||
{ severity: 'medium', expected: 'info' },
|
||||
{ severity: 'low', expected: 'secondary' },
|
||||
{ severity: 'safe', expected: 'success' },
|
||||
])('should map $severity to expected classes', ({ severity, expected }) => {
|
||||
expect(getSeverityColor(severity)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSeverityIcon', () => {
|
||||
it.each([
|
||||
{ severity: 'critical', expected: '\u{1F6A8}' },
|
||||
{ severity: 'high', expected: '\u26A0\uFE0F' },
|
||||
{ severity: 'medium', expected: '\u26A1' },
|
||||
{ severity: 'low', expected: '\u2139\uFE0F' },
|
||||
{ severity: 'safe', expected: '\u2713' },
|
||||
])('should map $severity to expected icon', ({ severity, expected }) => {
|
||||
expect(getSeverityIcon(severity)).toBe(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,257 +1,2 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { securityScanner, scanForVulnerabilities, getSeverityColor, getSeverityIcon } from '@/lib/security-scanner'
|
||||
|
||||
describe('security-scanner', () => {
|
||||
describe('scanJavaScript', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'flag eval usage as critical',
|
||||
code: ['const safe = true;', 'const result = eval("1 + 1")'].join('\n'),
|
||||
expectedSeverity: 'critical',
|
||||
expectedSafe: false,
|
||||
expectedIssueType: 'dangerous',
|
||||
expectedIssuePattern: 'eval',
|
||||
expectedLine: 2,
|
||||
},
|
||||
{
|
||||
name: 'warn on localStorage usage but stay safe',
|
||||
code: 'localStorage.setItem("k", "v")',
|
||||
expectedSeverity: 'low',
|
||||
expectedSafe: true,
|
||||
expectedIssueType: 'warning',
|
||||
expectedIssuePattern: 'localStorage',
|
||||
},
|
||||
{
|
||||
name: 'return safe for benign code',
|
||||
code: 'const sum = (a, b) => a + b',
|
||||
expectedSeverity: 'safe',
|
||||
expectedSafe: true,
|
||||
},
|
||||
])(
|
||||
'should $name',
|
||||
({ code, expectedSeverity, expectedSafe, expectedIssueType, expectedIssuePattern, expectedLine }) => {
|
||||
const result = securityScanner.scanJavaScript(code)
|
||||
expect(result.severity).toBe(expectedSeverity)
|
||||
expect(result.safe).toBe(expectedSafe)
|
||||
|
||||
if (expectedIssueType || expectedIssuePattern) {
|
||||
const issue = result.issues.find(item => {
|
||||
const matchesType = expectedIssueType ? item.type === expectedIssueType : true
|
||||
const matchesPattern = expectedIssuePattern ? item.pattern.includes(expectedIssuePattern) : true
|
||||
return matchesType && matchesPattern
|
||||
})
|
||||
expect(issue).toBeDefined()
|
||||
if (expectedLine !== undefined) {
|
||||
expect(issue?.line).toBe(expectedLine)
|
||||
}
|
||||
} else {
|
||||
expect(result.issues.length).toBe(0)
|
||||
}
|
||||
|
||||
if (expectedSafe) {
|
||||
expect(result.sanitizedCode).toBe(code)
|
||||
} else {
|
||||
expect(result.sanitizedCode).toBeUndefined()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('scanLua', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'flag os.execute usage as critical',
|
||||
code: 'os.execute("rm -rf /")',
|
||||
expectedSeverity: 'critical',
|
||||
expectedSafe: false,
|
||||
expectedIssueType: 'malicious',
|
||||
expectedIssuePattern: 'os.execute',
|
||||
},
|
||||
{
|
||||
name: 'return safe for simple Lua function',
|
||||
code: 'function add(a, b) return a + b end',
|
||||
expectedSeverity: 'safe',
|
||||
expectedSafe: true,
|
||||
},
|
||||
])('should $name', ({ code, expectedSeverity, expectedSafe, expectedIssueType, expectedIssuePattern }) => {
|
||||
const result = securityScanner.scanLua(code)
|
||||
expect(result.severity).toBe(expectedSeverity)
|
||||
expect(result.safe).toBe(expectedSafe)
|
||||
|
||||
if (expectedIssueType || expectedIssuePattern) {
|
||||
const issue = result.issues.find(item => {
|
||||
const matchesType = expectedIssueType ? item.type === expectedIssueType : true
|
||||
const matchesPattern = expectedIssuePattern ? item.pattern.includes(expectedIssuePattern) : true
|
||||
return matchesType && matchesPattern
|
||||
})
|
||||
expect(issue).toBeDefined()
|
||||
} else {
|
||||
expect(result.issues.length).toBe(0)
|
||||
}
|
||||
|
||||
if (expectedSafe) {
|
||||
expect(result.sanitizedCode).toBe(code)
|
||||
} else {
|
||||
expect(result.sanitizedCode).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('scanJSON', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'flag invalid JSON as medium severity',
|
||||
json: '{"value": }',
|
||||
expectedSeverity: 'medium',
|
||||
expectedSafe: false,
|
||||
expectedIssuePattern: 'JSON parse error',
|
||||
},
|
||||
{
|
||||
name: 'flag prototype pollution in JSON as critical',
|
||||
json: '{"__proto__": {"polluted": true}}',
|
||||
expectedSeverity: 'critical',
|
||||
expectedSafe: false,
|
||||
expectedIssuePattern: '__proto__',
|
||||
},
|
||||
{
|
||||
name: 'return safe for valid JSON',
|
||||
json: '{"ok": true}',
|
||||
expectedSeverity: 'safe',
|
||||
expectedSafe: true,
|
||||
},
|
||||
])('should $name', ({ json, expectedSeverity, expectedSafe, expectedIssuePattern }) => {
|
||||
const result = securityScanner.scanJSON(json)
|
||||
expect(result.severity).toBe(expectedSeverity)
|
||||
expect(result.safe).toBe(expectedSafe)
|
||||
|
||||
if (expectedIssuePattern) {
|
||||
expect(result.issues.some(issue => issue.pattern.includes(expectedIssuePattern))).toBe(true)
|
||||
} else {
|
||||
expect(result.issues.length).toBe(0)
|
||||
}
|
||||
|
||||
if (expectedSafe) {
|
||||
expect(result.sanitizedCode).toBe(json)
|
||||
} else {
|
||||
expect(result.sanitizedCode).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('scanHTML', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'flag script tags as critical',
|
||||
html: '<div><script>alert(1)</script></div>',
|
||||
expectedSeverity: 'critical',
|
||||
expectedSafe: false,
|
||||
},
|
||||
{
|
||||
name: 'flag inline handlers as high',
|
||||
html: '<button onclick="alert(1)">Click</button>',
|
||||
expectedSeverity: 'high',
|
||||
expectedSafe: false,
|
||||
},
|
||||
{
|
||||
name: 'return safe for plain markup',
|
||||
html: '<div><span>Safe</span></div>',
|
||||
expectedSeverity: 'safe',
|
||||
expectedSafe: true,
|
||||
},
|
||||
])('should $name', ({ html, expectedSeverity, expectedSafe }) => {
|
||||
const result = securityScanner.scanHTML(html)
|
||||
expect(result.severity).toBe(expectedSeverity)
|
||||
expect(result.safe).toBe(expectedSafe)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeInput', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'remove script tags and inline handlers from text',
|
||||
input: '<div onclick="alert(1)">Click</div><script>alert(2)</script><a href="javascript:alert(3)">x</a>',
|
||||
type: 'text' as const,
|
||||
shouldExclude: ['<script', 'onclick', 'javascript:'],
|
||||
},
|
||||
{
|
||||
name: 'remove data html URIs from html',
|
||||
input: '<img src="data:text/html;base64,abc"><script>alert(1)</script>',
|
||||
type: 'html' as const,
|
||||
shouldExclude: ['data:text/html', '<script'],
|
||||
},
|
||||
{
|
||||
name: 'neutralize prototype pollution in json',
|
||||
input: '{"__proto__": {"polluted": true}, "note": "constructor[\\"prototype\\"]"}',
|
||||
type: 'json' as const,
|
||||
shouldInclude: ['_proto_'],
|
||||
shouldExclude: ['__proto__', 'constructor["prototype"]'],
|
||||
},
|
||||
])('should $name', ({ input, type, shouldExclude = [], shouldInclude = [] }) => {
|
||||
const sanitized = securityScanner.sanitizeInput(input, type)
|
||||
shouldExclude.forEach(value => {
|
||||
expect(sanitized).not.toContain(value)
|
||||
})
|
||||
shouldInclude.forEach(value => {
|
||||
expect(sanitized).toContain(value)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSeverityColor', () => {
|
||||
it.each([
|
||||
{ severity: 'critical', expected: 'error' },
|
||||
{ severity: 'high', expected: 'warning' },
|
||||
{ severity: 'medium', expected: 'info' },
|
||||
{ severity: 'low', expected: 'secondary' },
|
||||
{ severity: 'safe', expected: 'success' },
|
||||
])('should map $severity to expected classes', ({ severity, expected }) => {
|
||||
expect(getSeverityColor(severity)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSeverityIcon', () => {
|
||||
it.each([
|
||||
{ severity: 'critical', expected: '\u{1F6A8}' },
|
||||
{ severity: 'high', expected: '\u26A0\uFE0F' },
|
||||
{ severity: 'medium', expected: '\u26A1' },
|
||||
{ severity: 'low', expected: '\u2139\uFE0F' },
|
||||
{ severity: 'safe', expected: '\u2713' },
|
||||
])('should map $severity to expected icon', ({ severity, expected }) => {
|
||||
expect(getSeverityIcon(severity)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scanForVulnerabilities', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'auto-detects JSON and flags prototype pollution',
|
||||
code: '{"__proto__": {"polluted": true}}',
|
||||
expectedSeverity: 'critical',
|
||||
},
|
||||
{
|
||||
name: 'auto-detects Lua when function/end present',
|
||||
code: 'function dangerous() os.execute("rm -rf /") end',
|
||||
expectedSeverity: 'critical',
|
||||
},
|
||||
{
|
||||
name: 'auto-detects HTML and flags script tags',
|
||||
code: '<div><script>alert(1)</script></div>',
|
||||
expectedSeverity: 'critical',
|
||||
},
|
||||
{
|
||||
name: 'falls back to JavaScript scanning',
|
||||
code: 'const result = eval("1 + 1")',
|
||||
expectedSeverity: 'critical',
|
||||
},
|
||||
{
|
||||
name: 'honors explicit type parameter',
|
||||
code: 'return 1',
|
||||
type: 'lua' as const,
|
||||
expectedSeverity: 'safe',
|
||||
},
|
||||
])('should $name', ({ code, type, expectedSeverity }) => {
|
||||
const result = scanForVulnerabilities(code, type)
|
||||
expect(result.severity).toBe(expectedSeverity)
|
||||
})
|
||||
})
|
||||
})
|
||||
import './__tests__/security-scanner.detection.test'
|
||||
import './__tests__/security-scanner.reporting.test'
|
||||
|
||||
71
frontends/nextjs/src/theme/types/components.d.ts
vendored
Normal file
71
frontends/nextjs/src/theme/types/components.d.ts
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
import '@mui/material/styles'
|
||||
import '@mui/material/Typography'
|
||||
import '@mui/material/Button'
|
||||
import '@mui/material/Chip'
|
||||
import '@mui/material/IconButton'
|
||||
import '@mui/material/Badge'
|
||||
import '@mui/material/Alert'
|
||||
|
||||
// Typography variants and component overrides
|
||||
declare module '@mui/material/styles' {
|
||||
interface TypographyVariants {
|
||||
code: React.CSSProperties
|
||||
kbd: React.CSSProperties
|
||||
label: React.CSSProperties
|
||||
}
|
||||
|
||||
interface TypographyVariantsOptions {
|
||||
code?: React.CSSProperties
|
||||
kbd?: React.CSSProperties
|
||||
label?: React.CSSProperties
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@mui/material/Typography' {
|
||||
interface TypographyPropsVariantOverrides {
|
||||
code: true
|
||||
kbd: true
|
||||
label: true
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@mui/material/Button' {
|
||||
interface ButtonPropsVariantOverrides {
|
||||
soft: true
|
||||
ghost: true
|
||||
}
|
||||
|
||||
interface ButtonPropsColorOverrides {
|
||||
neutral: true
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@mui/material/Chip' {
|
||||
interface ChipPropsVariantOverrides {
|
||||
soft: true
|
||||
}
|
||||
|
||||
interface ChipPropsColorOverrides {
|
||||
neutral: true
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@mui/material/IconButton' {
|
||||
interface IconButtonPropsColorOverrides {
|
||||
neutral: true
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@mui/material/Badge' {
|
||||
interface BadgePropsColorOverrides {
|
||||
neutral: true
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@mui/material/Alert' {
|
||||
interface AlertPropsVariantOverrides {
|
||||
soft: true
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
70
frontends/nextjs/src/theme/types/layout.d.ts
vendored
Normal file
70
frontends/nextjs/src/theme/types/layout.d.ts
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
import '@mui/material/styles'
|
||||
|
||||
// Custom theme properties for layout and design tokens
|
||||
declare module '@mui/material/styles' {
|
||||
interface Theme {
|
||||
custom: {
|
||||
fonts: {
|
||||
body: string
|
||||
heading: string
|
||||
mono: string
|
||||
}
|
||||
borderRadius: {
|
||||
none: number
|
||||
sm: number
|
||||
md: number
|
||||
lg: number
|
||||
xl: number
|
||||
full: number
|
||||
}
|
||||
contentWidth: {
|
||||
sm: string
|
||||
md: string
|
||||
lg: string
|
||||
xl: string
|
||||
full: string
|
||||
}
|
||||
sidebar: {
|
||||
width: number
|
||||
collapsedWidth: number
|
||||
}
|
||||
header: {
|
||||
height: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ThemeOptions {
|
||||
custom?: {
|
||||
fonts?: {
|
||||
body?: string
|
||||
heading?: string
|
||||
mono?: string
|
||||
}
|
||||
borderRadius?: {
|
||||
none?: number
|
||||
sm?: number
|
||||
md?: number
|
||||
lg?: number
|
||||
xl?: number
|
||||
full?: number
|
||||
}
|
||||
contentWidth?: {
|
||||
sm?: string
|
||||
md?: string
|
||||
lg?: string
|
||||
xl?: string
|
||||
full?: string
|
||||
}
|
||||
sidebar?: {
|
||||
width?: number
|
||||
collapsedWidth?: number
|
||||
}
|
||||
header?: {
|
||||
height?: number
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
38
frontends/nextjs/src/theme/types/palette.d.ts
vendored
Normal file
38
frontends/nextjs/src/theme/types/palette.d.ts
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
import '@mui/material/styles'
|
||||
|
||||
// Extend palette with custom neutral colors
|
||||
declare module '@mui/material/styles' {
|
||||
interface Palette {
|
||||
neutral: {
|
||||
50: string
|
||||
100: string
|
||||
200: string
|
||||
300: string
|
||||
400: string
|
||||
500: string
|
||||
600: string
|
||||
700: string
|
||||
800: string
|
||||
900: string
|
||||
950: string
|
||||
}
|
||||
}
|
||||
|
||||
interface PaletteOptions {
|
||||
neutral?: {
|
||||
50?: string
|
||||
100?: string
|
||||
200?: string
|
||||
300?: string
|
||||
400?: string
|
||||
500?: string
|
||||
600?: string
|
||||
700?: string
|
||||
800?: string
|
||||
900?: string
|
||||
950?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
202
frontends/nextjs/src/theme/types/theme.d.ts
vendored
202
frontends/nextjs/src/theme/types/theme.d.ts
vendored
@@ -1,200 +1,10 @@
|
||||
/**
|
||||
* MUI Theme Type Extensions
|
||||
*
|
||||
* This file extends Material-UI's theme interface with custom properties.
|
||||
* All custom design tokens and component variants should be declared here.
|
||||
*
|
||||
* This file aggregates the theme augmentation modules to keep the
|
||||
* main declaration lightweight while still exposing all custom tokens.
|
||||
*/
|
||||
|
||||
import '@mui/material/styles'
|
||||
import '@mui/material/Typography'
|
||||
import '@mui/material/Button'
|
||||
|
||||
// ============================================================================
|
||||
// Custom Palette Extensions
|
||||
// ============================================================================
|
||||
|
||||
declare module '@mui/material/styles' {
|
||||
// Extend palette with custom neutral colors
|
||||
interface Palette {
|
||||
neutral: {
|
||||
50: string
|
||||
100: string
|
||||
200: string
|
||||
300: string
|
||||
400: string
|
||||
500: string
|
||||
600: string
|
||||
700: string
|
||||
800: string
|
||||
900: string
|
||||
950: string
|
||||
}
|
||||
}
|
||||
|
||||
interface PaletteOptions {
|
||||
neutral?: {
|
||||
50?: string
|
||||
100?: string
|
||||
200?: string
|
||||
300?: string
|
||||
400?: string
|
||||
500?: string
|
||||
600?: string
|
||||
700?: string
|
||||
800?: string
|
||||
900?: string
|
||||
950?: string
|
||||
}
|
||||
}
|
||||
|
||||
// Custom typography variants
|
||||
interface TypographyVariants {
|
||||
code: React.CSSProperties
|
||||
kbd: React.CSSProperties
|
||||
label: React.CSSProperties
|
||||
}
|
||||
|
||||
interface TypographyVariantsOptions {
|
||||
code?: React.CSSProperties
|
||||
kbd?: React.CSSProperties
|
||||
label?: React.CSSProperties
|
||||
}
|
||||
|
||||
// Custom theme properties
|
||||
interface Theme {
|
||||
custom: {
|
||||
fonts: {
|
||||
body: string
|
||||
heading: string
|
||||
mono: string
|
||||
}
|
||||
borderRadius: {
|
||||
none: number
|
||||
sm: number
|
||||
md: number
|
||||
lg: number
|
||||
xl: number
|
||||
full: number
|
||||
}
|
||||
contentWidth: {
|
||||
sm: string
|
||||
md: string
|
||||
lg: string
|
||||
xl: string
|
||||
full: string
|
||||
}
|
||||
sidebar: {
|
||||
width: number
|
||||
collapsedWidth: number
|
||||
}
|
||||
header: {
|
||||
height: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ThemeOptions {
|
||||
custom?: {
|
||||
fonts?: {
|
||||
body?: string
|
||||
heading?: string
|
||||
mono?: string
|
||||
}
|
||||
borderRadius?: {
|
||||
none?: number
|
||||
sm?: number
|
||||
md?: number
|
||||
lg?: number
|
||||
xl?: number
|
||||
full?: number
|
||||
}
|
||||
contentWidth?: {
|
||||
sm?: string
|
||||
md?: string
|
||||
lg?: string
|
||||
xl?: string
|
||||
full?: string
|
||||
}
|
||||
sidebar?: {
|
||||
width?: number
|
||||
collapsedWidth?: number
|
||||
}
|
||||
header?: {
|
||||
height?: number
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Typography Variant Mapping
|
||||
// ============================================================================
|
||||
|
||||
declare module '@mui/material/Typography' {
|
||||
interface TypographyPropsVariantOverrides {
|
||||
code: true
|
||||
kbd: true
|
||||
label: true
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Button Variants & Colors
|
||||
// ============================================================================
|
||||
|
||||
declare module '@mui/material/Button' {
|
||||
interface ButtonPropsVariantOverrides {
|
||||
soft: true
|
||||
ghost: true
|
||||
}
|
||||
|
||||
interface ButtonPropsColorOverrides {
|
||||
neutral: true
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Chip Variants
|
||||
// ============================================================================
|
||||
|
||||
declare module '@mui/material/Chip' {
|
||||
interface ChipPropsVariantOverrides {
|
||||
soft: true
|
||||
}
|
||||
|
||||
interface ChipPropsColorOverrides {
|
||||
neutral: true
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// IconButton Colors
|
||||
// ============================================================================
|
||||
|
||||
declare module '@mui/material/IconButton' {
|
||||
interface IconButtonPropsColorOverrides {
|
||||
neutral: true
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Badge Colors
|
||||
// ============================================================================
|
||||
|
||||
declare module '@mui/material/Badge' {
|
||||
interface BadgePropsColorOverrides {
|
||||
neutral: true
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Alert Variants
|
||||
// ============================================================================
|
||||
|
||||
declare module '@mui/material/Alert' {
|
||||
interface AlertPropsVariantOverrides {
|
||||
soft: true
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
export * from './palette'
|
||||
export * from './layout'
|
||||
export * from './components'
|
||||
|
||||
Reference in New Issue
Block a user