mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
* feat: implement ConfigFeatureRegistry to fix schema-runtime gap - Add ConfigFeatureRegistry to track implementation status of config fields - Enhance validation with --strict flag warning about experimental fields - Update compiler to emit seo/a11y configs to ui-config.json - Add logoImage support in Header component - SEO tags generation now uses configured title template and OG image - Runtime warnings for experimental fields (i18n) - Maintains backward compatibility with existing configs Fixes #64 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Mervin Praison <[email protected]> * feat: fix multi-layer config contract - emit enterprise fields to frontend (fixes #64) - Update compiler to emit seo, i18n, a11y, logo fields to ui-config.json - Add TypeScript interfaces for enterprise config fields - Enhance Header component to support logo configuration (light/dark variants) - Add SkipLink component for accessibility with conditional rendering - Enhance SEO meta tag generation with titleTemplate and Twitter config - Add strict validation mode for experimental features - All fields now flow properly: YAML → Pydantic → ui-config → Frontend Co-authored-by: Mervin Praison <[email protected]> * fix: address critical architecture and accessibility issues - Fix double-templating bug in SEO title generation (compiler.py:581) - Improve skip link accessibility by targeting Content component only - Add logo fallback logic for missing theme variants - Simplify title template parsing with sequential replace - Correct CLI flag reference in validation error messages - Exclude metadata fields from unimplemented feature validation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Mervin Praison <[email protected]> Co-authored-by: Mervin Praison <[email protected]> Co-authored-by: Claude <[email protected]>
This commit is contained in:
10 files changed
+408
-20
No files matched your search
@@ -10,6 +10,18 @@ import { Toc } from './Toc'
|
||||
import { Footer } from './Footer'
|
||||
import { ChatLayout, AgentUILayout, CopilotWidget, PlaygroundLayout } from './layouts'
|
||||
|
||||
function SkipLink({ enabled }: { enabled?: boolean }) {
|
||||
if (!enabled) return null
|
||||
return (
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 z-50 bg-primary text-primary-foreground px-4 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [config, setConfig] = useState<UIConfig>({})
|
||||
const [nav, setNav] = useState<DocsNav>({})
|
||||
@@ -100,8 +112,14 @@ export default function App() {
|
||||
|
||||
// Update SEO meta tags dynamically
|
||||
const updateSEO = (title: string, path: string, description?: string) => {
|
||||
// Update title
|
||||
document.title = `${title} | ${config.site?.title || 'Documentation'}`
|
||||
// Update title using SEO titleTemplate if available
|
||||
const titleTemplate = config.seo?.titleTemplate || '%s | %s'
|
||||
const siteName = config.site?.title || 'Documentation'
|
||||
if (titleTemplate.includes('%s')) {
|
||||
document.title = titleTemplate.replace('%s', title).replace('%s', siteName)
|
||||
} else {
|
||||
document.title = `${title} | ${siteName}`
|
||||
}
|
||||
|
||||
// Update canonical URL
|
||||
let canonical = document.querySelector('link[rel="canonical"]') as HTMLLinkElement
|
||||
@@ -137,6 +155,30 @@ export default function App() {
|
||||
document.head.appendChild(ogUrl)
|
||||
}
|
||||
ogUrl.content = window.location.origin + path
|
||||
|
||||
// Update Twitter meta tags if configured
|
||||
if (config.seo?.twitter) {
|
||||
Object.entries(config.seo.twitter).forEach(([key, value]) => {
|
||||
let twitterMeta = document.querySelector(`meta[name="twitter:${key}"]`) as HTMLMetaElement
|
||||
if (!twitterMeta) {
|
||||
twitterMeta = document.createElement('meta')
|
||||
twitterMeta.name = `twitter:${key}`
|
||||
document.head.appendChild(twitterMeta)
|
||||
}
|
||||
twitterMeta.content = value
|
||||
})
|
||||
}
|
||||
|
||||
// Set default image if available
|
||||
if (config.seo?.defaultImage) {
|
||||
let ogImage = document.querySelector('meta[property="og:image"]') as HTMLMetaElement
|
||||
if (!ogImage) {
|
||||
ogImage = document.createElement('meta')
|
||||
ogImage.setAttribute('property', 'og:image')
|
||||
document.head.appendChild(ogImage)
|
||||
}
|
||||
ogImage.content = config.seo.defaultImage
|
||||
}
|
||||
}
|
||||
|
||||
const handleItemClick = (item: NavItem) => {
|
||||
@@ -271,6 +313,7 @@ export default function App() {
|
||||
if (config.chat?.enabled) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<SkipLink enabled={config.a11y?.skipToContent} />
|
||||
<Header config={config} />
|
||||
{renderLayout()}
|
||||
<Footer config={config} />
|
||||
@@ -280,6 +323,7 @@ export default function App() {
|
||||
}
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<SkipLink enabled={config.a11y?.skipToContent} />
|
||||
<Header config={config} />
|
||||
{renderLayout()}
|
||||
<Footer config={config} />
|
||||
@@ -293,6 +337,7 @@ export default function App() {
|
||||
if (layoutMode && ['bottom-right', 'bottom-left', 'top-right', 'top-left'].includes(layoutMode)) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<SkipLink enabled={config.a11y?.skipToContent} />
|
||||
<Header config={config} />
|
||||
{renderLayout()}
|
||||
<Footer config={config} />
|
||||
@@ -303,6 +348,7 @@ export default function App() {
|
||||
}
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<SkipLink enabled={config.a11y?.skipToContent} />
|
||||
<Header config={config} />
|
||||
{renderLayout()}
|
||||
<Footer config={config} />
|
||||
|
||||
@@ -82,7 +82,7 @@ export function Content({ config, routes, selectedItem }: ContentProps) {
|
||||
|
||||
if (selectedItem) {
|
||||
return (
|
||||
<main className="flex-1 p-8 max-w-3xl">
|
||||
<main id="main-content" className="flex-1 p-8 max-w-3xl">
|
||||
{loadingContent ? (
|
||||
<div className="text-muted-foreground">Loading content...</div>
|
||||
) : markdown ? (
|
||||
|
||||
@@ -10,21 +10,36 @@ export function Header({ config }: { config: UIConfig }) {
|
||||
: config.components?.header
|
||||
const header = (headerComponent?.props || headerSlot?.props) as {
|
||||
logoText?: string
|
||||
logoImage?: string
|
||||
links?: { label: string; href: string }[]
|
||||
cta?: { label: string; href: string }
|
||||
} | undefined
|
||||
|
||||
// Get logo configuration - support both header.logoImage and config.logo
|
||||
const logoConfig = config.logo
|
||||
const isDarkMode = document.documentElement.classList.contains('dark')
|
||||
const logoSrc = header?.logoImage || (logoConfig ? (isDarkMode ? (logoConfig.dark || logoConfig.light) : (logoConfig.light || logoConfig.dark)) : null)
|
||||
const logoHref = logoConfig?.href || '/'
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-xl supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="max-w-screen-2xl mx-auto flex h-16 items-center px-6 gap-8">
|
||||
<a href="/" className="flex items-center gap-3 group cursor-pointer" onClick={(e) => {
|
||||
<a href={logoHref} className="flex items-center gap-3 group cursor-pointer" onClick={(e) => {
|
||||
e.preventDefault()
|
||||
window.history.pushState({}, '', '/')
|
||||
window.history.pushState({}, '', logoHref)
|
||||
window.location.reload()
|
||||
}}>
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary/80 to-primary flex items-center justify-center shadow-lg shadow-primary/20 group-hover:shadow-primary/40 transition-shadow">
|
||||
<span className="text-primary-foreground text-xs font-bold">AI</span>
|
||||
</div>
|
||||
{logoSrc ? (
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt="Logo"
|
||||
className="w-8 h-8 rounded-lg shadow-lg shadow-primary/20 group-hover:shadow-primary/40 transition-shadow object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary/80 to-primary flex items-center justify-center shadow-lg shadow-primary/20 group-hover:shadow-primary/40 transition-shadow">
|
||||
<span className="text-primary-foreground text-xs font-bold">AI</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="font-semibold text-lg tracking-tight">{header?.logoText || config.site?.title || 'PraisonAIUI'}</span>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -20,6 +20,33 @@ export interface SiteConfig {
|
||||
theme?: ThemeConfig
|
||||
}
|
||||
|
||||
export interface SEOConfig {
|
||||
titleTemplate?: string
|
||||
defaultImage?: string
|
||||
twitter?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface I18nConfig {
|
||||
defaultLocale?: string
|
||||
locales?: string[]
|
||||
rtlLocales?: string[]
|
||||
fallbackLocale?: string
|
||||
translationsDir?: string
|
||||
}
|
||||
|
||||
export interface A11yConfig {
|
||||
skipToContent?: boolean
|
||||
focusVisible?: boolean
|
||||
reduceMotion?: boolean
|
||||
ariaLabels?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface LogoConfig {
|
||||
light?: string
|
||||
dark?: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
export interface WidgetConfig {
|
||||
type: string
|
||||
props?: Record<string, unknown>
|
||||
@@ -58,6 +85,10 @@ export interface UIConfig {
|
||||
widgets?: InputWidget[]
|
||||
components?: Record<string, { props?: Record<string, unknown> }>
|
||||
templates?: Record<string, TemplateConfig>
|
||||
seo?: SEOConfig
|
||||
i18n?: I18nConfig
|
||||
a11y?: A11yConfig
|
||||
logo?: LogoConfig
|
||||
}
|
||||
|
||||
export interface DocsNav {
|
||||
|
||||
@@ -220,7 +220,7 @@ def validate(
|
||||
console.print(f"[red]Error:[/red] Failed to parse configuration: {e}")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
result = validate_config(cfg, config.parent)
|
||||
result = validate_config(cfg, config.parent, strict=strict)
|
||||
|
||||
if result.valid:
|
||||
console.print("[green]✓[/green] Configuration is valid")
|
||||
|
||||
@@ -271,6 +271,35 @@ class Compiler:
|
||||
"provider": self.config.search.provider,
|
||||
}
|
||||
|
||||
# ── Enterprise features (partial implementation) ──
|
||||
|
||||
# SEO configuration - basic implementation for static build
|
||||
if self.config.seo:
|
||||
result["seo"] = {
|
||||
"titleTemplate": self.config.seo.title_template,
|
||||
"defaultImage": self.config.seo.default_image,
|
||||
"twitter": self.config.seo.twitter,
|
||||
}
|
||||
|
||||
# A11y configuration - basic implementation for frontend
|
||||
if self.config.a11y:
|
||||
result["a11y"] = {
|
||||
"skipToContent": self.config.a11y.skip_to_content,
|
||||
"focusVisible": self.config.a11y.focus_visible,
|
||||
"reduceMotion": self.config.a11y.reduce_motion,
|
||||
"ariaLabels": self.config.a11y.aria_labels,
|
||||
}
|
||||
|
||||
# I18n configuration - emit for frontend consumption (experimental)
|
||||
if self.config.i18n:
|
||||
result["i18n"] = {
|
||||
"defaultLocale": self.config.i18n.default_locale,
|
||||
"locales": self.config.i18n.locales,
|
||||
"rtlLocales": self.config.i18n.rtl_locales,
|
||||
"fallbackLocale": self.config.i18n.fallback_locale,
|
||||
"translationsDir": self.config.i18n.translations_dir,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def _serialize_template(self, template) -> dict:
|
||||
@@ -543,18 +572,53 @@ class Compiler:
|
||||
|
||||
def _build_seo_tags(self, path: str, title: str, description: str) -> str:
|
||||
"""Build canonical, OG, and Twitter meta tags."""
|
||||
t = self._escape_html(title)
|
||||
d = self._escape_html(description)
|
||||
p = self._escape_html(path)
|
||||
return (
|
||||
f' <link rel="canonical" href="{p}" />\n'
|
||||
f' <meta property="og:title" content="{t}" />\n'
|
||||
f' <meta property="og:description" content="{d}" />\n'
|
||||
f' <meta property="og:url" content="{p}" />\n'
|
||||
f' <meta name="twitter:card" content="summary" />\n'
|
||||
f' <meta name="twitter:title" content="{t}" />\n'
|
||||
f' <meta name="twitter:description" content="{d}" />'
|
||||
)
|
||||
|
||||
# Extract original page title by removing the site title suffix if present
|
||||
site_title = self.config.site.title
|
||||
page_title = title
|
||||
suffix = f" | {site_title}"
|
||||
if page_title.endswith(suffix):
|
||||
page_title = page_title[:-len(suffix)]
|
||||
|
||||
# Use configured title template if available
|
||||
if self.config.seo and self.config.seo.title_template:
|
||||
template = self.config.seo.title_template
|
||||
if "%s" in template:
|
||||
parts = template.split("%s")
|
||||
if len(parts) >= 3:
|
||||
t = self._escape_html(f"{parts[0]}{page_title}{parts[1]}{site_title}{''.join(parts[2:])}")
|
||||
else:
|
||||
t = self._escape_html(template.replace("%s", page_title, 1))
|
||||
else:
|
||||
t = self._escape_html(template)
|
||||
else:
|
||||
t = self._escape_html(title)
|
||||
|
||||
tags = [
|
||||
f' <link rel="canonical" href="{p}" />',
|
||||
f' <meta property="og:title" content="{t}" />',
|
||||
f' <meta property="og:description" content="{d}" />',
|
||||
f' <meta property="og:url" content="{p}" />',
|
||||
]
|
||||
|
||||
# Add default OG image if configured
|
||||
if self.config.seo and self.config.seo.default_image:
|
||||
og_image = self._escape_html(self.config.seo.default_image)
|
||||
tags.append(f' <meta property="og:image" content="{og_image}" />')
|
||||
|
||||
# Add Twitter card tags
|
||||
tags.append(' <meta name="twitter:card" content="summary" />')
|
||||
tags.append(f' <meta name="twitter:title" content="{t}" />')
|
||||
tags.append(f' <meta name="twitter:description" content="{d}" />')
|
||||
|
||||
# Add Twitter handle if configured
|
||||
if self.config.seo and self.config.seo.twitter and "handle" in self.config.seo.twitter:
|
||||
handle = self._escape_html(self.config.seo.twitter["handle"])
|
||||
tags.append(f' <meta name="twitter:site" content="{handle}" />')
|
||||
|
||||
return "\n".join(tags)
|
||||
|
||||
def _get_noscript_content(self, output_dir: Path, path: str) -> str:
|
||||
"""Load markdown file and convert to simple HTML for noscript block."""
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Feature registry for config validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from praisonaiui.schema.models import Config
|
||||
|
||||
|
||||
class FeatureStatus(Enum):
|
||||
"""Status of a configuration feature."""
|
||||
|
||||
IMPLEMENTED = "implemented"
|
||||
EXPERIMENTAL = "experimental"
|
||||
DEPRECATED = "deprecated"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeatureInfo:
|
||||
"""Information about a configuration feature."""
|
||||
|
||||
name: str
|
||||
status: FeatureStatus
|
||||
since: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class ConfigFeatureRegistry:
|
||||
"""Registry of configuration features and their implementation status."""
|
||||
|
||||
def __init__(self):
|
||||
self._features = {
|
||||
# Core features - fully implemented
|
||||
"site": FeatureInfo("site", FeatureStatus.IMPLEMENTED, "0.1.0"),
|
||||
"components": FeatureInfo("components", FeatureStatus.IMPLEMENTED, "0.1.0"),
|
||||
"templates": FeatureInfo("templates", FeatureStatus.IMPLEMENTED, "0.1.0"),
|
||||
"routes": FeatureInfo("routes", FeatureStatus.IMPLEMENTED, "0.1.0"),
|
||||
"content": FeatureInfo("content", FeatureStatus.IMPLEMENTED, "0.1.0"),
|
||||
"style": FeatureInfo("style", FeatureStatus.IMPLEMENTED, "0.1.0"),
|
||||
"layout": FeatureInfo("layout", FeatureStatus.IMPLEMENTED, "0.2.0"),
|
||||
"chat": FeatureInfo("chat", FeatureStatus.IMPLEMENTED, "0.2.0"),
|
||||
"auth": FeatureInfo("auth", FeatureStatus.IMPLEMENTED, "0.3.0"),
|
||||
"widgets": FeatureInfo("widgets", FeatureStatus.IMPLEMENTED, "0.3.0"),
|
||||
"dependencies": FeatureInfo("dependencies", FeatureStatus.IMPLEMENTED, "0.3.0"),
|
||||
|
||||
# Mintlify-parity features - implemented
|
||||
"logo": FeatureInfo("logo", FeatureStatus.IMPLEMENTED, "0.4.0"),
|
||||
"navigation": FeatureInfo("navigation", FeatureStatus.IMPLEMENTED, "0.4.0"),
|
||||
"navbar": FeatureInfo("navbar", FeatureStatus.IMPLEMENTED, "0.4.0"),
|
||||
"footer": FeatureInfo("footer", FeatureStatus.IMPLEMENTED, "0.4.0"),
|
||||
"search": FeatureInfo("search", FeatureStatus.IMPLEMENTED, "0.4.0"),
|
||||
"dashboard": FeatureInfo("dashboard", FeatureStatus.IMPLEMENTED, "0.4.0"),
|
||||
|
||||
# Enterprise features - partial implementation
|
||||
"seo": FeatureInfo("seo", FeatureStatus.IMPLEMENTED, "0.5.0",
|
||||
"SEO configuration with title templates, OG tags, Twitter meta - basic implementation"),
|
||||
"a11y": FeatureInfo("a11y", FeatureStatus.IMPLEMENTED, "0.5.0",
|
||||
"Accessibility configuration - basic implementation for skip links and ARIA labels"),
|
||||
"i18n": FeatureInfo("i18n", FeatureStatus.EXPERIMENTAL, None,
|
||||
"Internationalization accepted in schema but content pipeline/routing not implemented"),
|
||||
}
|
||||
|
||||
def get_feature(self, name: str) -> FeatureInfo | None:
|
||||
"""Get information about a feature."""
|
||||
return self._features.get(name)
|
||||
|
||||
def is_implemented(self, name: str) -> bool:
|
||||
"""Check if a feature is fully implemented."""
|
||||
feature = self._features.get(name)
|
||||
return feature is not None and feature.status == FeatureStatus.IMPLEMENTED
|
||||
|
||||
def is_experimental(self, name: str) -> bool:
|
||||
"""Check if a feature is experimental."""
|
||||
feature = self._features.get(name)
|
||||
return feature is not None and feature.status == FeatureStatus.EXPERIMENTAL
|
||||
|
||||
def get_unimplemented_fields(self, config: Config) -> list[str]:
|
||||
"""Get list of config fields that are not fully implemented."""
|
||||
unimplemented = []
|
||||
|
||||
# Check top-level config fields
|
||||
config_dict = config.model_dump(exclude_none=True)
|
||||
for field_name in config_dict:
|
||||
if field_name in ("schema_version", "schemaVersion"):
|
||||
continue
|
||||
if not self.is_implemented(field_name):
|
||||
unimplemented.append(field_name)
|
||||
|
||||
return unimplemented
|
||||
|
||||
def get_experimental_fields(self, config: Config) -> list[str]:
|
||||
"""Get list of config fields that are experimental."""
|
||||
experimental = []
|
||||
|
||||
config_dict = config.model_dump(exclude_none=True)
|
||||
for field_name in config_dict:
|
||||
if self.is_experimental(field_name):
|
||||
experimental.append(field_name)
|
||||
|
||||
return experimental
|
||||
|
||||
|
||||
# Global registry instance
|
||||
_registry = ConfigFeatureRegistry()
|
||||
|
||||
|
||||
def get_feature_registry() -> ConfigFeatureRegistry:
|
||||
"""Get the global feature registry."""
|
||||
return _registry
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -41,7 +42,7 @@ class ValidationResult:
|
||||
return cls(valid=False, errors=errors)
|
||||
|
||||
|
||||
def validate_config(config: Config, base_path: Path | None = None) -> ValidationResult:
|
||||
def validate_config(config: Config, base_path: Path | None = None, strict: bool = False) -> ValidationResult:
|
||||
"""
|
||||
Validate a configuration object.
|
||||
|
||||
@@ -50,10 +51,12 @@ def validate_config(config: Config, base_path: Path | None = None) -> Validation
|
||||
- All template refs are valid
|
||||
- Content directories exist
|
||||
- Route patterns are valid globs
|
||||
- Feature implementation status (if strict=True)
|
||||
|
||||
Args:
|
||||
config: The configuration to validate
|
||||
base_path: Base path for resolving relative paths
|
||||
strict: If True, warns about unimplemented/experimental features
|
||||
|
||||
Returns:
|
||||
ValidationResult with any errors found
|
||||
@@ -107,6 +110,31 @@ def validate_config(config: Config, base_path: Path | None = None) -> Validation
|
||||
)
|
||||
)
|
||||
|
||||
# Validate feature implementation status
|
||||
if strict:
|
||||
from praisonaiui.schema.features import get_feature_registry
|
||||
|
||||
registry = get_feature_registry()
|
||||
experimental_fields = registry.get_experimental_fields(config)
|
||||
|
||||
for field in experimental_fields:
|
||||
feature = registry.get_feature(field)
|
||||
if feature:
|
||||
errors.append(
|
||||
ValidationError(
|
||||
code=4001,
|
||||
category="features",
|
||||
message=f"Field '{field}' is experimental and not fully implemented: {feature.description}",
|
||||
suggestion="Remove this field or run validation without the --strict flag",
|
||||
)
|
||||
)
|
||||
# Also emit runtime warning
|
||||
warnings.warn(
|
||||
f"Config field '{field}' is experimental: {feature.description}",
|
||||
UserWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
|
||||
if errors:
|
||||
return ValidationResult.failure(errors)
|
||||
return ValidationResult.success()
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test script to demonstrate the new validation functionality."""
|
||||
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from praisonaiui.schema.models import Config
|
||||
from praisonaiui.schema.validators import validate_config
|
||||
|
||||
# Test with the 11-full-featured example
|
||||
config_path = Path("examples/yaml/11-full-featured/aiui.template.yaml")
|
||||
|
||||
if config_path.exists():
|
||||
with open(config_path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
cfg = Config.model_validate(data)
|
||||
|
||||
# Test regular validation
|
||||
result = validate_config(cfg, config_path.parent)
|
||||
print(f"Regular validation: {'✓ PASS' if result.valid else '✗ FAIL'}")
|
||||
|
||||
# Test strict validation
|
||||
result_strict = validate_config(cfg, config_path.parent, strict=True)
|
||||
print(f"Strict validation: {'✓ PASS' if result_strict.valid else '✗ FAIL'}")
|
||||
|
||||
if not result_strict.valid:
|
||||
print("\nStrict validation errors:")
|
||||
for error in result_strict.errors:
|
||||
print(f" [{error.category}] {error.message}")
|
||||
if error.suggestion:
|
||||
print(f" 💡 {error.suggestion}")
|
||||
else:
|
||||
print(f"Config file not found: {config_path}")
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Tests for the config feature registry."""
|
||||
|
||||
from praisonaiui.schema.features import ConfigFeatureRegistry, FeatureStatus
|
||||
from praisonaiui.schema.models import Config, I18nConfig, SEOConfig, SiteConfig
|
||||
|
||||
|
||||
def test_feature_registry_basic():
|
||||
"""Test basic feature registry functionality."""
|
||||
registry = ConfigFeatureRegistry()
|
||||
|
||||
# Test implemented features
|
||||
assert registry.is_implemented("site")
|
||||
assert registry.is_implemented("seo")
|
||||
assert registry.is_implemented("a11y")
|
||||
|
||||
# Test experimental features
|
||||
assert registry.is_experimental("i18n")
|
||||
|
||||
# Test non-existent features
|
||||
assert not registry.is_implemented("nonexistent")
|
||||
assert not registry.is_experimental("nonexistent")
|
||||
|
||||
|
||||
def test_feature_registry_with_config():
|
||||
"""Test feature registry with actual config objects."""
|
||||
registry = ConfigFeatureRegistry()
|
||||
|
||||
# Config with only implemented features
|
||||
config = Config(
|
||||
site=SiteConfig(title="Test"),
|
||||
seo=SEOConfig(title_template="%s | Test")
|
||||
)
|
||||
|
||||
experimental = registry.get_experimental_fields(config)
|
||||
assert experimental == []
|
||||
|
||||
# Config with experimental features
|
||||
config_with_experimental = Config(
|
||||
site=SiteConfig(title="Test"),
|
||||
i18n=I18nConfig(locales=["en", "es"])
|
||||
)
|
||||
|
||||
experimental = registry.get_experimental_fields(config_with_experimental)
|
||||
assert "i18n" in experimental
|
||||
|
||||
|
||||
def test_feature_info():
|
||||
"""Test FeatureInfo structure."""
|
||||
registry = ConfigFeatureRegistry()
|
||||
|
||||
seo_info = registry.get_feature("seo")
|
||||
assert seo_info is not None
|
||||
assert seo_info.status == FeatureStatus.IMPLEMENTED
|
||||
assert seo_info.since == "0.5.0"
|
||||
|
||||
i18n_info = registry.get_feature("i18n")
|
||||
assert i18n_info is not None
|
||||
assert i18n_info.status == FeatureStatus.EXPERIMENTAL
|
||||
assert i18n_info.since is None
|
||||
Reference in new issue
Block a user