mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
feat: add three viewing modes for app specification (#566)
* feat: add three viewing modes for app specification Introduces View, Edit, and Source modes for the spec page: - View: Clean read-only display with cards, badges, and accordions - Edit: Structured form-based editor for all spec fields - Source: Raw XML editor for advanced users Also adds @automaker/spec-parser shared package for XML parsing between server and client. * fix: address PR review feedback - Replace array index keys with stable UUIDs in array-field-editor, features-section, and roadmap-section components - Replace regex-based XML parsing with fast-xml-parser for robustness - Simplify renderContent logic in spec-view by removing dead code paths * fix: convert git+ssh URLs to https in package-lock.json * fix: address PR review feedback for spec visualiser - Remove unused RefreshCw import from spec-view.tsx - Add explicit parsedSpec check in renderContent for robustness - Hide save button in view mode since it's read-only - Remove GripVertical drag handles since drag-and-drop is not implemented - Rename Map imports to MapIcon to avoid shadowing global Map - Escape tagName in xml-utils.ts RegExp functions for safety - Add aria-label attributes for accessibility on mode tabs * fix: address additional PR review feedback - Fix Textarea controlled/uncontrolled warning with default value - Preserve IDs in useEffect sync to avoid unnecessary remounts - Consolidate lucide-react imports - Add JSDoc note about tag attributes limitation in xml-utils.ts - Remove redundant disabled prop from SpecModeTabs
This commit is contained in:
committed by
GitHub
parent
af95dae73a
commit
c4652190eb
@@ -40,6 +40,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@automaker/dependency-resolver": "1.0.0",
|
||||
"@automaker/spec-parser": "1.0.0",
|
||||
"@automaker/types": "1.0.0",
|
||||
"@codemirror/lang-xml": "6.1.0",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { xml } from '@codemirror/lang-xml';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { Extension } from '@codemirror/state';
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||
import { tags as t } from '@lezer/highlight';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface XmlSyntaxEditorProps {
|
||||
@@ -14,52 +11,19 @@ interface XmlSyntaxEditorProps {
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
// Syntax highlighting that uses CSS variables from the app's theme system
|
||||
// This automatically adapts to any theme (dark, light, dracula, nord, etc.)
|
||||
const syntaxColors = HighlightStyle.define([
|
||||
// XML tags - use primary color
|
||||
{ tag: t.tagName, color: 'var(--primary)' },
|
||||
{ tag: t.angleBracket, color: 'var(--muted-foreground)' },
|
||||
|
||||
// Attributes
|
||||
{ tag: t.attributeName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
|
||||
{ tag: t.attributeValue, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
|
||||
|
||||
// Strings and content
|
||||
{ tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
|
||||
{ tag: t.content, color: 'var(--foreground)' },
|
||||
|
||||
// Comments
|
||||
{ tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' },
|
||||
|
||||
// Special
|
||||
{ tag: t.processingInstruction, color: 'var(--muted-foreground)' },
|
||||
{ tag: t.documentMeta, color: 'var(--muted-foreground)' },
|
||||
]);
|
||||
|
||||
// Editor theme using CSS variables
|
||||
// Simple editor theme - inherits text color from parent
|
||||
const editorTheme = EditorView.theme({
|
||||
'&': {
|
||||
height: '100%',
|
||||
fontSize: '0.875rem',
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--foreground)',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
},
|
||||
'.cm-content': {
|
||||
padding: '1rem',
|
||||
minHeight: '100%',
|
||||
caretColor: 'var(--primary)',
|
||||
},
|
||||
'.cm-cursor, .cm-dropCursor': {
|
||||
borderLeftColor: 'var(--primary)',
|
||||
},
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
|
||||
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'transparent',
|
||||
@@ -73,15 +37,8 @@ const editorTheme = EditorView.theme({
|
||||
'.cm-gutters': {
|
||||
display: 'none',
|
||||
},
|
||||
'.cm-placeholder': {
|
||||
color: 'var(--muted-foreground)',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
});
|
||||
|
||||
// Combine all extensions
|
||||
const extensions: Extension[] = [xml(), syntaxHighlighting(syntaxColors), editorTheme];
|
||||
|
||||
export function XmlSyntaxEditor({
|
||||
value,
|
||||
onChange,
|
||||
@@ -94,16 +51,16 @@ export function XmlSyntaxEditor({
|
||||
<CodeMirror
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
extensions={extensions}
|
||||
extensions={[xml(), editorTheme]}
|
||||
theme="none"
|
||||
placeholder={placeholder}
|
||||
className="h-full [&_.cm-editor]:h-full"
|
||||
className="h-full [&_.cm-editor]:h-full [&_.cm-content]:text-foreground"
|
||||
basicSetup={{
|
||||
lineNumbers: false,
|
||||
foldGutter: false,
|
||||
highlightActiveLine: false,
|
||||
highlightSelectionMatches: true,
|
||||
autocompletion: true,
|
||||
highlightSelectionMatches: false,
|
||||
autocompletion: false,
|
||||
bracketMatching: true,
|
||||
indentOnInput: true,
|
||||
}}
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
// Extracted hooks
|
||||
import { useSpecLoading, useSpecSave, useSpecGeneration } from './spec-view/hooks';
|
||||
import { useSpecLoading, useSpecSave, useSpecGeneration, useSpecParser } from './spec-view/hooks';
|
||||
|
||||
// Extracted components
|
||||
import { SpecHeader, SpecEditor, SpecEmptyState } from './spec-view/components';
|
||||
import {
|
||||
SpecHeader,
|
||||
SpecEditor,
|
||||
SpecEmptyState,
|
||||
SpecViewMode,
|
||||
SpecEditMode,
|
||||
SpecModeTabs,
|
||||
} from './spec-view/components';
|
||||
|
||||
// Extracted dialogs
|
||||
import { CreateSpecDialog, RegenerateSpecDialog } from './spec-view/dialogs';
|
||||
|
||||
// Types
|
||||
import type { SpecViewMode as SpecViewModeType } from './spec-view/types';
|
||||
|
||||
export function SpecView() {
|
||||
const { currentProject, appSpec } = useAppStore();
|
||||
|
||||
// View mode state - default to 'view'
|
||||
const [mode, setMode] = useState<SpecViewModeType>('view');
|
||||
|
||||
// Actions panel state (for tablet/mobile)
|
||||
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
||||
|
||||
@@ -21,7 +34,10 @@ export function SpecView() {
|
||||
const { isLoading, specExists, isGenerationRunning, loadSpec } = useSpecLoading();
|
||||
|
||||
// Save state
|
||||
const { isSaving, hasChanges, saveSpec, handleChange, setHasChanges } = useSpecSave();
|
||||
const { isSaving, hasChanges, saveSpec, handleChange } = useSpecSave();
|
||||
|
||||
// Parse the spec XML
|
||||
const { isValid: isParseValid, parsedSpec, errors: parseErrors } = useSpecParser(appSpec);
|
||||
|
||||
// Generation state and handlers
|
||||
const {
|
||||
@@ -70,8 +86,17 @@ export function SpecView() {
|
||||
handleSync,
|
||||
} = useSpecGeneration({ loadSpec });
|
||||
|
||||
// Reset hasChanges when spec is reloaded
|
||||
// (This is needed because loadSpec updates appSpec in the store)
|
||||
// Handle mode change - if parse is invalid, force source mode
|
||||
const handleModeChange = useCallback(
|
||||
(newMode: SpecViewModeType) => {
|
||||
if ((newMode === 'view' || newMode === 'edit') && !isParseValid) {
|
||||
// Can't switch to view/edit if parse is invalid
|
||||
return;
|
||||
}
|
||||
setMode(newMode);
|
||||
},
|
||||
[isParseValid]
|
||||
);
|
||||
|
||||
// No project selected
|
||||
if (!currentProject) {
|
||||
@@ -126,6 +151,28 @@ export function SpecView() {
|
||||
);
|
||||
}
|
||||
|
||||
// Render content based on mode
|
||||
const renderContent = () => {
|
||||
// If the XML is invalid or spec is not parsed, we can only show the source editor.
|
||||
// The tabs for other modes are disabled, but this is an extra safeguard.
|
||||
if (!isParseValid || !parsedSpec) {
|
||||
return <SpecEditor value={appSpec} onChange={handleChange} />;
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case 'view':
|
||||
return <SpecViewMode spec={parsedSpec} />;
|
||||
case 'edit':
|
||||
return <SpecEditMode spec={parsedSpec} onChange={handleChange} />;
|
||||
case 'source':
|
||||
default:
|
||||
return <SpecEditor value={appSpec} onChange={handleChange} />;
|
||||
}
|
||||
};
|
||||
|
||||
const isProcessing =
|
||||
isRegenerating || isGenerationRunning || isCreating || isGeneratingFeatures || isSyncing;
|
||||
|
||||
// Main view - spec exists
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="spec-view">
|
||||
@@ -145,9 +192,33 @@ export function SpecView() {
|
||||
onSaveClick={saveSpec}
|
||||
showActionsPanel={showActionsPanel}
|
||||
onToggleActionsPanel={() => setShowActionsPanel(!showActionsPanel)}
|
||||
showSaveButton={mode !== 'view'}
|
||||
/>
|
||||
|
||||
<SpecEditor value={appSpec} onChange={handleChange} />
|
||||
{/* Mode tabs and content container */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Mode tabs bar - inside the content area, centered */}
|
||||
{!isProcessing && (
|
||||
<div className="flex items-center justify-center px-4 py-2 border-b border-border bg-muted/30 relative">
|
||||
<SpecModeTabs mode={mode} onModeChange={handleModeChange} isParseValid={isParseValid} />
|
||||
{/* Show parse error indicator - positioned to the right */}
|
||||
{!isParseValid && parseErrors.length > 0 && (
|
||||
<span className="absolute right-4 text-xs text-destructive">
|
||||
XML has errors - fix in Source mode
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show parse error banner if in source mode with errors */}
|
||||
{!isParseValid && parseErrors.length > 0 && mode === 'source' && (
|
||||
<div className="px-4 py-2 bg-destructive/10 border-b border-destructive/20 text-sm text-destructive">
|
||||
<span className="font-medium">XML Parse Errors:</span> {parseErrors.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
||||
<RegenerateSpecDialog
|
||||
open={showRegenerateDialog}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
|
||||
interface ArrayFieldEditorProps {
|
||||
values: string[];
|
||||
onChange: (values: string[]) => void;
|
||||
placeholder?: string;
|
||||
addLabel?: string;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
interface ItemWithId {
|
||||
id: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export function ArrayFieldEditor({
|
||||
values,
|
||||
onChange,
|
||||
placeholder = 'Enter value...',
|
||||
addLabel = 'Add Item',
|
||||
emptyMessage = 'No items added yet.',
|
||||
}: ArrayFieldEditorProps) {
|
||||
// Track items with stable IDs
|
||||
const [items, setItems] = useState<ItemWithId[]>(() =>
|
||||
values.map((value) => ({ id: generateId(), value }))
|
||||
);
|
||||
|
||||
// Track if we're making an internal change to avoid sync loops
|
||||
const isInternalChange = useRef(false);
|
||||
|
||||
// Sync external values to internal items when values change externally
|
||||
useEffect(() => {
|
||||
if (isInternalChange.current) {
|
||||
isInternalChange.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// External change - rebuild items with new IDs
|
||||
setItems(values.map((value) => ({ id: generateId(), value })));
|
||||
}, [values]);
|
||||
|
||||
const handleAdd = () => {
|
||||
const newItems = [...items, { id: generateId(), value: '' }];
|
||||
setItems(newItems);
|
||||
isInternalChange.current = true;
|
||||
onChange(newItems.map((item) => item.value));
|
||||
};
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
const newItems = items.filter((item) => item.id !== id);
|
||||
setItems(newItems);
|
||||
isInternalChange.current = true;
|
||||
onChange(newItems.map((item) => item.value));
|
||||
};
|
||||
|
||||
const handleChange = (id: string, value: string) => {
|
||||
const newItems = items.map((item) => (item.id === id ? { ...item, value } : item));
|
||||
setItems(newItems);
|
||||
isInternalChange.current = true;
|
||||
onChange(newItems.map((item) => item.value));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">{emptyMessage}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<Card key={item.id} className="p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={item.value}
|
||||
onChange={(e) => handleChange(item.id, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemove(item.id)}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAdd} className="gap-1">
|
||||
<Plus className="w-4 h-4" />
|
||||
{addLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Lightbulb } from 'lucide-react';
|
||||
import { ArrayFieldEditor } from './array-field-editor';
|
||||
|
||||
interface CapabilitiesSectionProps {
|
||||
capabilities: string[];
|
||||
onChange: (capabilities: string[]) => void;
|
||||
}
|
||||
|
||||
export function CapabilitiesSection({ capabilities, onChange }: CapabilitiesSectionProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Lightbulb className="w-5 h-5 text-primary" />
|
||||
Core Capabilities
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ArrayFieldEditor
|
||||
values={capabilities}
|
||||
onChange={onChange}
|
||||
placeholder="e.g., User authentication, Data visualization..."
|
||||
addLabel="Add Capability"
|
||||
emptyMessage="No capabilities defined. Add your core features."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { Plus, X, ChevronDown, ChevronUp, FolderOpen } from 'lucide-react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ListChecks } from 'lucide-react';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import type { SpecOutput } from '@automaker/spec-parser';
|
||||
|
||||
type Feature = SpecOutput['implemented_features'][number];
|
||||
|
||||
interface FeaturesSectionProps {
|
||||
features: Feature[];
|
||||
onChange: (features: Feature[]) => void;
|
||||
}
|
||||
|
||||
interface FeatureWithId extends Feature {
|
||||
_id: string;
|
||||
_locationIds?: string[];
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function featureToInternal(feature: Feature): FeatureWithId {
|
||||
return {
|
||||
...feature,
|
||||
_id: generateId(),
|
||||
_locationIds: feature.file_locations?.map(() => generateId()),
|
||||
};
|
||||
}
|
||||
|
||||
function internalToFeature(internal: FeatureWithId): Feature {
|
||||
const { _id, _locationIds, ...feature } = internal;
|
||||
return feature;
|
||||
}
|
||||
|
||||
interface FeatureCardProps {
|
||||
feature: FeatureWithId;
|
||||
index: number;
|
||||
onChange: (feature: FeatureWithId) => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function FeatureCard({ feature, index, onChange, onRemove }: FeatureCardProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleNameChange = (name: string) => {
|
||||
onChange({ ...feature, name });
|
||||
};
|
||||
|
||||
const handleDescriptionChange = (description: string) => {
|
||||
onChange({ ...feature, description });
|
||||
};
|
||||
|
||||
const handleAddLocation = () => {
|
||||
const locations = feature.file_locations || [];
|
||||
const locationIds = feature._locationIds || [];
|
||||
onChange({
|
||||
...feature,
|
||||
file_locations: [...locations, ''],
|
||||
_locationIds: [...locationIds, generateId()],
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveLocation = (locId: string) => {
|
||||
const locationIds = feature._locationIds || [];
|
||||
const idx = locationIds.indexOf(locId);
|
||||
if (idx === -1) return;
|
||||
|
||||
const newLocations = feature.file_locations?.filter((_, i) => i !== idx);
|
||||
const newLocationIds = locationIds.filter((id) => id !== locId);
|
||||
onChange({
|
||||
...feature,
|
||||
file_locations: newLocations && newLocations.length > 0 ? newLocations : undefined,
|
||||
_locationIds: newLocationIds.length > 0 ? newLocationIds : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleLocationChange = (locId: string, value: string) => {
|
||||
const locationIds = feature._locationIds || [];
|
||||
const idx = locationIds.indexOf(locId);
|
||||
if (idx === -1) return;
|
||||
|
||||
const locations = [...(feature.file_locations || [])];
|
||||
locations[idx] = value;
|
||||
onChange({ ...feature, file_locations: locations });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-border">
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="p-1 h-auto">
|
||||
{isOpen ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Input
|
||||
value={feature.name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="Feature name..."
|
||||
className="font-medium"
|
||||
/>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
#{index + 1}
|
||||
</Badge>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRemove}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
<div className="px-3 pb-3 space-y-4 border-t border-border pt-3 ml-10">
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
value={feature.description}
|
||||
onChange={(e) => handleDescriptionChange(e.target.value)}
|
||||
placeholder="Describe what this feature does..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="flex items-center gap-1">
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
File Locations
|
||||
</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddLocation}
|
||||
className="gap-1 h-7"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{(feature.file_locations || []).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No file locations specified.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(feature.file_locations || []).map((location, idx) => {
|
||||
const locId = feature._locationIds?.[idx] || `fallback-${idx}`;
|
||||
return (
|
||||
<div key={locId} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={location}
|
||||
onChange={(e) => handleLocationChange(locId, e.target.value)}
|
||||
placeholder="e.g., src/components/feature.tsx"
|
||||
className="flex-1 font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveLocation(locId)}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive h-8 w-8"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeaturesSection({ features, onChange }: FeaturesSectionProps) {
|
||||
// Track features with stable IDs
|
||||
const [items, setItems] = useState<FeatureWithId[]>(() => features.map(featureToInternal));
|
||||
|
||||
// Track if we're making an internal change to avoid sync loops
|
||||
const isInternalChange = useRef(false);
|
||||
|
||||
// Sync external features to internal items when features change externally
|
||||
useEffect(() => {
|
||||
if (isInternalChange.current) {
|
||||
isInternalChange.current = false;
|
||||
return;
|
||||
}
|
||||
setItems(features.map(featureToInternal));
|
||||
}, [features]);
|
||||
|
||||
const handleAdd = () => {
|
||||
const newItems = [...items, featureToInternal({ name: '', description: '' })];
|
||||
setItems(newItems);
|
||||
isInternalChange.current = true;
|
||||
onChange(newItems.map(internalToFeature));
|
||||
};
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
const newItems = items.filter((item) => item._id !== id);
|
||||
setItems(newItems);
|
||||
isInternalChange.current = true;
|
||||
onChange(newItems.map(internalToFeature));
|
||||
};
|
||||
|
||||
const handleFeatureChange = (id: string, feature: FeatureWithId) => {
|
||||
const newItems = items.map((item) => (item._id === id ? feature : item));
|
||||
setItems(newItems);
|
||||
isInternalChange.current = true;
|
||||
onChange(newItems.map(internalToFeature));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<ListChecks className="w-5 h-5 text-primary" />
|
||||
Implemented Features
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{items.length}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
No features added yet. Click below to add implemented features.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((feature, index) => (
|
||||
<FeatureCard
|
||||
key={feature._id}
|
||||
feature={feature}
|
||||
index={index}
|
||||
onChange={(f) => handleFeatureChange(feature._id, f)}
|
||||
onRemove={() => handleRemove(feature._id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAdd} className="gap-1">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Feature
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { ArrayFieldEditor } from './array-field-editor';
|
||||
export { ProjectInfoSection } from './project-info-section';
|
||||
export { TechStackSection } from './tech-stack-section';
|
||||
export { CapabilitiesSection } from './capabilities-section';
|
||||
export { FeaturesSection } from './features-section';
|
||||
export { RoadmapSection } from './roadmap-section';
|
||||
export { RequirementsSection, GuidelinesSection } from './optional-sections';
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ScrollText, Wrench } from 'lucide-react';
|
||||
import { ArrayFieldEditor } from './array-field-editor';
|
||||
|
||||
interface RequirementsSectionProps {
|
||||
requirements: string[];
|
||||
onChange: (requirements: string[]) => void;
|
||||
}
|
||||
|
||||
export function RequirementsSection({ requirements, onChange }: RequirementsSectionProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<ScrollText className="w-5 h-5 text-primary" />
|
||||
Additional Requirements
|
||||
<span className="text-sm font-normal text-muted-foreground">(Optional)</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ArrayFieldEditor
|
||||
values={requirements}
|
||||
onChange={onChange}
|
||||
placeholder="e.g., Node.js >= 18, Docker required..."
|
||||
addLabel="Add Requirement"
|
||||
emptyMessage="No additional requirements specified."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface GuidelinesSectionProps {
|
||||
guidelines: string[];
|
||||
onChange: (guidelines: string[]) => void;
|
||||
}
|
||||
|
||||
export function GuidelinesSection({ guidelines, onChange }: GuidelinesSectionProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Wrench className="w-5 h-5 text-primary" />
|
||||
Development Guidelines
|
||||
<span className="text-sm font-normal text-muted-foreground">(Optional)</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ArrayFieldEditor
|
||||
values={guidelines}
|
||||
onChange={onChange}
|
||||
placeholder="e.g., Follow TypeScript strict mode, use ESLint..."
|
||||
addLabel="Add Guideline"
|
||||
emptyMessage="No development guidelines specified."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { FileText } from 'lucide-react';
|
||||
|
||||
interface ProjectInfoSectionProps {
|
||||
projectName: string;
|
||||
overview: string;
|
||||
onProjectNameChange: (value: string) => void;
|
||||
onOverviewChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function ProjectInfoSection({
|
||||
projectName,
|
||||
overview,
|
||||
onProjectNameChange,
|
||||
onOverviewChange,
|
||||
}: ProjectInfoSectionProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<FileText className="w-5 h-5 text-primary" />
|
||||
Project Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-name">Project Name</Label>
|
||||
<Input
|
||||
id="project-name"
|
||||
value={projectName}
|
||||
onChange={(e) => onProjectNameChange(e.target.value)}
|
||||
placeholder="Enter project name..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="overview">Overview</Label>
|
||||
<Textarea
|
||||
id="overview"
|
||||
value={overview}
|
||||
onChange={(e) => onOverviewChange(e.target.value)}
|
||||
placeholder="Describe what this project does, its purpose, and key goals..."
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { Plus, X, Map as MapIcon } from 'lucide-react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { SpecOutput } from '@automaker/spec-parser';
|
||||
|
||||
type RoadmapPhase = NonNullable<SpecOutput['implementation_roadmap']>[number];
|
||||
type PhaseStatus = 'completed' | 'in_progress' | 'pending';
|
||||
|
||||
interface PhaseWithId extends RoadmapPhase {
|
||||
_id: string;
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function phaseToInternal(phase: RoadmapPhase): PhaseWithId {
|
||||
return { ...phase, _id: generateId() };
|
||||
}
|
||||
|
||||
function internalToPhase(internal: PhaseWithId): RoadmapPhase {
|
||||
const { _id, ...phase } = internal;
|
||||
return phase;
|
||||
}
|
||||
|
||||
interface RoadmapSectionProps {
|
||||
phases: RoadmapPhase[];
|
||||
onChange: (phases: RoadmapPhase[]) => void;
|
||||
}
|
||||
|
||||
interface PhaseCardProps {
|
||||
phase: PhaseWithId;
|
||||
onChange: (phase: PhaseWithId) => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function PhaseCard({ phase, onChange, onRemove }: PhaseCardProps) {
|
||||
const handlePhaseNameChange = (name: string) => {
|
||||
onChange({ ...phase, phase: name });
|
||||
};
|
||||
|
||||
const handleStatusChange = (status: PhaseStatus) => {
|
||||
onChange({ ...phase, status });
|
||||
};
|
||||
|
||||
const handleDescriptionChange = (description: string) => {
|
||||
onChange({ ...phase, description });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-border">
|
||||
<div className="p-3 space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="flex-1">
|
||||
<Label className="sr-only">Phase Name</Label>
|
||||
<Input
|
||||
value={phase.phase}
|
||||
onChange={(e) => handlePhaseNameChange(e.target.value)}
|
||||
placeholder="Phase name..."
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full sm:w-40">
|
||||
<Label className="sr-only">Status</Label>
|
||||
<Select value={phase.status} onValueChange={handleStatusChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="sr-only">Description</Label>
|
||||
<Textarea
|
||||
value={phase.description ?? ''}
|
||||
onChange={(e) => handleDescriptionChange(e.target.value)}
|
||||
placeholder="Describe what this phase involves..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRemove}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoadmapSection({ phases, onChange }: RoadmapSectionProps) {
|
||||
// Track phases with stable IDs
|
||||
const [items, setItems] = useState<PhaseWithId[]>(() => phases.map(phaseToInternal));
|
||||
|
||||
// Track if we're making an internal change to avoid sync loops
|
||||
const isInternalChange = useRef(false);
|
||||
|
||||
// Sync external phases to internal items when phases change externally
|
||||
// Preserve existing IDs where possible to avoid unnecessary remounts
|
||||
useEffect(() => {
|
||||
if (isInternalChange.current) {
|
||||
isInternalChange.current = false;
|
||||
return;
|
||||
}
|
||||
setItems((currentItems) => {
|
||||
return phases.map((phase, index) => {
|
||||
// Try to find existing item by index (positional matching)
|
||||
const existingItem = currentItems[index];
|
||||
if (existingItem) {
|
||||
// Reuse the existing ID, update the phase data
|
||||
return { ...phase, _id: existingItem._id };
|
||||
}
|
||||
// New phase - generate new ID
|
||||
return phaseToInternal(phase);
|
||||
});
|
||||
});
|
||||
}, [phases]);
|
||||
|
||||
const handleAdd = () => {
|
||||
const newItems = [...items, phaseToInternal({ phase: '', status: 'pending', description: '' })];
|
||||
setItems(newItems);
|
||||
isInternalChange.current = true;
|
||||
onChange(newItems.map(internalToPhase));
|
||||
};
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
const newItems = items.filter((item) => item._id !== id);
|
||||
setItems(newItems);
|
||||
isInternalChange.current = true;
|
||||
onChange(newItems.map(internalToPhase));
|
||||
};
|
||||
|
||||
const handlePhaseChange = (id: string, phase: PhaseWithId) => {
|
||||
const newItems = items.map((item) => (item._id === id ? phase : item));
|
||||
setItems(newItems);
|
||||
isInternalChange.current = true;
|
||||
onChange(newItems.map(internalToPhase));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<MapIcon className="w-5 h-5 text-primary" />
|
||||
Implementation Roadmap
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
No roadmap phases defined. Add phases to track implementation progress.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((phase) => (
|
||||
<PhaseCard
|
||||
key={phase._id}
|
||||
phase={phase}
|
||||
onChange={(p) => handlePhaseChange(phase._id, p)}
|
||||
onRemove={() => handleRemove(phase._id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAdd} className="gap-1">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Phase
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Cpu } from 'lucide-react';
|
||||
import { ArrayFieldEditor } from './array-field-editor';
|
||||
|
||||
interface TechStackSectionProps {
|
||||
technologies: string[];
|
||||
onChange: (technologies: string[]) => void;
|
||||
}
|
||||
|
||||
export function TechStackSection({ technologies, onChange }: TechStackSectionProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Cpu className="w-5 h-5 text-primary" />
|
||||
Technology Stack
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ArrayFieldEditor
|
||||
values={technologies}
|
||||
onChange={onChange}
|
||||
placeholder="e.g., React, TypeScript, Node.js..."
|
||||
addLabel="Add Technology"
|
||||
emptyMessage="No technologies added. Add your tech stack."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
export { SpecHeader } from './spec-header';
|
||||
export { SpecEditor } from './spec-editor';
|
||||
export { SpecEmptyState } from './spec-empty-state';
|
||||
export { SpecModeTabs } from './spec-mode-tabs';
|
||||
export { SpecViewMode } from './spec-view-mode';
|
||||
export { SpecEditMode } from './spec-edit-mode';
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import type { SpecOutput } from '@automaker/spec-parser';
|
||||
import { specToXml } from '@automaker/spec-parser';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
ProjectInfoSection,
|
||||
TechStackSection,
|
||||
CapabilitiesSection,
|
||||
FeaturesSection,
|
||||
RoadmapSection,
|
||||
RequirementsSection,
|
||||
GuidelinesSection,
|
||||
} from './edit-mode';
|
||||
|
||||
interface SpecEditModeProps {
|
||||
spec: SpecOutput;
|
||||
onChange: (xmlContent: string) => void;
|
||||
}
|
||||
|
||||
export function SpecEditMode({ spec, onChange }: SpecEditModeProps) {
|
||||
// Local state for form editing
|
||||
const [formData, setFormData] = useState<SpecOutput>(spec);
|
||||
|
||||
// Track the last spec we synced FROM to detect external changes
|
||||
const lastExternalSpecRef = useRef<string>(JSON.stringify(spec));
|
||||
|
||||
// Flag to prevent re-syncing when we caused the change
|
||||
const isInternalChangeRef = useRef(false);
|
||||
|
||||
// Reset form only when spec changes externally (e.g., after save, sync, or regenerate)
|
||||
useEffect(() => {
|
||||
const specJson = JSON.stringify(spec);
|
||||
|
||||
// If we caused this change (internal), just update the ref and skip reset
|
||||
if (isInternalChangeRef.current) {
|
||||
lastExternalSpecRef.current = specJson;
|
||||
isInternalChangeRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// External change - reset form data
|
||||
if (specJson !== lastExternalSpecRef.current) {
|
||||
lastExternalSpecRef.current = specJson;
|
||||
setFormData(spec);
|
||||
}
|
||||
}, [spec]);
|
||||
|
||||
// Update a field and notify parent
|
||||
const updateField = useCallback(
|
||||
<K extends keyof SpecOutput>(field: K, value: SpecOutput[K]) => {
|
||||
setFormData((prev) => {
|
||||
const newData = { ...prev, [field]: value };
|
||||
// Mark as internal change before notifying parent
|
||||
isInternalChangeRef.current = true;
|
||||
const xmlContent = specToXml(newData);
|
||||
onChange(xmlContent);
|
||||
return newData;
|
||||
});
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-6 max-w-4xl mx-auto">
|
||||
{/* Project Information */}
|
||||
<ProjectInfoSection
|
||||
projectName={formData.project_name}
|
||||
overview={formData.overview}
|
||||
onProjectNameChange={(value) => updateField('project_name', value)}
|
||||
onOverviewChange={(value) => updateField('overview', value)}
|
||||
/>
|
||||
|
||||
{/* Technology Stack */}
|
||||
<TechStackSection
|
||||
technologies={formData.technology_stack}
|
||||
onChange={(value) => updateField('technology_stack', value)}
|
||||
/>
|
||||
|
||||
{/* Core Capabilities */}
|
||||
<CapabilitiesSection
|
||||
capabilities={formData.core_capabilities}
|
||||
onChange={(value) => updateField('core_capabilities', value)}
|
||||
/>
|
||||
|
||||
{/* Implemented Features */}
|
||||
<FeaturesSection
|
||||
features={formData.implemented_features}
|
||||
onChange={(value) => updateField('implemented_features', value)}
|
||||
/>
|
||||
|
||||
{/* Additional Requirements (Optional) */}
|
||||
<RequirementsSection
|
||||
requirements={formData.additional_requirements || []}
|
||||
onChange={(value) =>
|
||||
updateField('additional_requirements', value.length > 0 ? value : undefined)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Development Guidelines (Optional) */}
|
||||
<GuidelinesSection
|
||||
guidelines={formData.development_guidelines || []}
|
||||
onChange={(value) =>
|
||||
updateField('development_guidelines', value.length > 0 ? value : undefined)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Implementation Roadmap (Optional) */}
|
||||
<RoadmapSection
|
||||
phases={formData.implementation_roadmap || []}
|
||||
onChange={(value) =>
|
||||
updateField('implementation_roadmap', value.length > 0 ? value : undefined)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,8 @@ interface SpecEditorProps {
|
||||
|
||||
export function SpecEditor({ value, onChange }: SpecEditorProps) {
|
||||
return (
|
||||
<div className="flex-1 p-4 overflow-hidden">
|
||||
<Card className="h-full">
|
||||
<div className="flex-1 p-4 overflow-hidden min-h-0">
|
||||
<Card className="h-full overflow-hidden">
|
||||
<XmlSyntaxEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
|
||||
@@ -23,6 +23,8 @@ interface SpecHeaderProps {
|
||||
onSaveClick: () => void;
|
||||
showActionsPanel: boolean;
|
||||
onToggleActionsPanel: () => void;
|
||||
// Mode-related props for save button visibility
|
||||
showSaveButton: boolean;
|
||||
}
|
||||
|
||||
export function SpecHeader({
|
||||
@@ -41,6 +43,7 @@ export function SpecHeader({
|
||||
onSaveClick,
|
||||
showActionsPanel,
|
||||
onToggleActionsPanel,
|
||||
showSaveButton,
|
||||
}: SpecHeaderProps) {
|
||||
const isProcessing = isRegenerating || isCreating || isGeneratingFeatures || isSyncing;
|
||||
const phaseLabel = PHASE_LABELS[currentPhase] || currentPhase;
|
||||
@@ -133,15 +136,17 @@ export function SpecHeader({
|
||||
<ListPlus className="w-4 h-4 mr-2" />
|
||||
Generate Features
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onSaveClick}
|
||||
disabled={!hasChanges || isSaving}
|
||||
data-testid="save-spec"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
|
||||
</Button>
|
||||
{showSaveButton && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onSaveClick}
|
||||
disabled={!hasChanges || isSaving}
|
||||
data-testid="save-spec"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Tablet/Mobile: show trigger for actions panel */}
|
||||
@@ -212,15 +217,17 @@ export function SpecHeader({
|
||||
<ListPlus className="w-4 h-4 mr-2" />
|
||||
Generate Features
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start"
|
||||
onClick={onSaveClick}
|
||||
disabled={!hasChanges || isSaving}
|
||||
data-testid="save-spec-mobile"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
|
||||
</Button>
|
||||
{showSaveButton && (
|
||||
<Button
|
||||
className="w-full justify-start"
|
||||
onClick={onSaveClick}
|
||||
disabled={!hasChanges || isSaving}
|
||||
data-testid="save-spec-mobile"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</HeaderActionsPanel>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Eye, Edit3, Code } from 'lucide-react';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import type { SpecViewMode } from '../types';
|
||||
|
||||
interface SpecModeTabsProps {
|
||||
mode: SpecViewMode;
|
||||
onModeChange: (mode: SpecViewMode) => void;
|
||||
isParseValid: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function SpecModeTabs({
|
||||
mode,
|
||||
onModeChange,
|
||||
isParseValid,
|
||||
disabled = false,
|
||||
}: SpecModeTabsProps) {
|
||||
const handleValueChange = (value: string) => {
|
||||
onModeChange(value as SpecViewMode);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs value={mode} onValueChange={handleValueChange}>
|
||||
<TabsList>
|
||||
<TabsTrigger
|
||||
value="view"
|
||||
disabled={disabled || !isParseValid}
|
||||
title={!isParseValid ? 'Fix XML errors to enable View mode' : 'View formatted spec'}
|
||||
aria-label="View"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">View</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="edit"
|
||||
disabled={disabled || !isParseValid}
|
||||
title={!isParseValid ? 'Fix XML errors to enable Edit mode' : 'Edit spec with form'}
|
||||
aria-label="Edit"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Edit</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="source"
|
||||
disabled={disabled}
|
||||
title="Edit raw XML source"
|
||||
aria-label="Source"
|
||||
>
|
||||
<Code className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Source</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import type { SpecOutput } from '@automaker/spec-parser';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import {
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Clock,
|
||||
Cpu,
|
||||
FileCode2,
|
||||
FolderOpen,
|
||||
Lightbulb,
|
||||
ListChecks,
|
||||
Map as MapIcon,
|
||||
ScrollText,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SpecViewModeProps {
|
||||
spec: SpecOutput;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: 'completed' | 'in_progress' | 'pending' }) {
|
||||
const variants = {
|
||||
completed: { variant: 'success' as const, icon: CheckCircle2, label: 'Completed' },
|
||||
in_progress: { variant: 'warning' as const, icon: Clock, label: 'In Progress' },
|
||||
pending: { variant: 'muted' as const, icon: Circle, label: 'Pending' },
|
||||
};
|
||||
|
||||
const { variant, icon: Icon, label } = variants[status];
|
||||
|
||||
return (
|
||||
<Badge variant={variant} className="gap-1">
|
||||
<Icon className="w-3 h-3" />
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpecViewMode({ spec }: SpecViewModeProps) {
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4 space-y-6 max-w-4xl mx-auto">
|
||||
{/* Project Header */}
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{spec.project_name}</h1>
|
||||
<p className="text-muted-foreground text-lg leading-relaxed">{spec.overview}</p>
|
||||
</div>
|
||||
|
||||
{/* Technology Stack */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Cpu className="w-5 h-5 text-primary" />
|
||||
Technology Stack
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{spec.technology_stack.map((tech, index) => (
|
||||
<Badge key={index} variant="secondary" className="text-sm">
|
||||
{tech}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Core Capabilities */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Lightbulb className="w-5 h-5 text-primary" />
|
||||
Core Capabilities
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{spec.core_capabilities.map((capability, index) => (
|
||||
<li key={index} className="flex items-start gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500 mt-1 shrink-0" />
|
||||
<span>{capability}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Implemented Features */}
|
||||
{spec.implemented_features.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<ListChecks className="w-5 h-5 text-primary" />
|
||||
Implemented Features
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{spec.implemented_features.length}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{spec.implemented_features.map((feature, index) => (
|
||||
<AccordionItem key={index} value={`feature-${index}`}>
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex items-center gap-2 text-left">
|
||||
<FileCode2 className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span className="font-medium">{feature.name}</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="space-y-3 pl-6">
|
||||
<p className="text-muted-foreground">{feature.description}</p>
|
||||
{feature.file_locations && feature.file_locations.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium flex items-center gap-1">
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
File Locations:
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
{feature.file_locations.map((loc, locIndex) => (
|
||||
<li
|
||||
key={locIndex}
|
||||
className="font-mono text-xs bg-muted px-2 py-1 rounded"
|
||||
>
|
||||
{loc}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Additional Requirements */}
|
||||
{spec.additional_requirements && spec.additional_requirements.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<ScrollText className="w-5 h-5 text-primary" />
|
||||
Additional Requirements
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{spec.additional_requirements.map((req, index) => (
|
||||
<li key={index} className="flex items-start gap-2">
|
||||
<Circle className="w-2 h-2 text-muted-foreground mt-2 shrink-0 fill-current" />
|
||||
<span>{req}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Development Guidelines */}
|
||||
{spec.development_guidelines && spec.development_guidelines.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Wrench className="w-5 h-5 text-primary" />
|
||||
Development Guidelines
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{spec.development_guidelines.map((guideline, index) => (
|
||||
<li key={index} className="flex items-start gap-2">
|
||||
<Circle className="w-2 h-2 text-muted-foreground mt-2 shrink-0 fill-current" />
|
||||
<span>{guideline}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Implementation Roadmap */}
|
||||
{spec.implementation_roadmap && spec.implementation_roadmap.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<MapIcon className="w-5 h-5 text-primary" />
|
||||
Implementation Roadmap
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{spec.implementation_roadmap.map((phase, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col sm:flex-row sm:items-start gap-2 sm:gap-4 p-3 rounded-lg bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center gap-2 sm:w-48 shrink-0">
|
||||
<StatusBadge status={phase.status} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{phase.phase}</p>
|
||||
<p className="text-sm text-muted-foreground">{phase.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export { useSpecLoading } from './use-spec-loading';
|
||||
export { useSpecSave } from './use-spec-save';
|
||||
export { useSpecGeneration } from './use-spec-generation';
|
||||
export { useSpecParser } from './use-spec-parser';
|
||||
export type { UseSpecParserResult } from './use-spec-parser';
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
xmlToSpec,
|
||||
isValidSpecXml,
|
||||
type ParseResult,
|
||||
type SpecOutput,
|
||||
} from '@automaker/spec-parser';
|
||||
|
||||
/**
|
||||
* Result of the spec parsing hook.
|
||||
*/
|
||||
export interface UseSpecParserResult {
|
||||
/** Whether the XML is valid */
|
||||
isValid: boolean;
|
||||
/** The parsed spec object, or null if parsing failed */
|
||||
parsedSpec: SpecOutput | null;
|
||||
/** Parsing errors, if any */
|
||||
errors: string[];
|
||||
/** The full parse result */
|
||||
parseResult: ParseResult | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to parse XML spec content into a SpecOutput object.
|
||||
* Memoizes the parsing result to avoid unnecessary re-parsing.
|
||||
*
|
||||
* @param xmlContent - The raw XML content from app_spec.txt
|
||||
* @returns Parsed spec data with validation status
|
||||
*/
|
||||
export function useSpecParser(xmlContent: string): UseSpecParserResult {
|
||||
return useMemo(() => {
|
||||
if (!xmlContent || !xmlContent.trim()) {
|
||||
return {
|
||||
isValid: false,
|
||||
parsedSpec: null,
|
||||
errors: ['No spec content provided'],
|
||||
parseResult: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Quick structure check first
|
||||
if (!isValidSpecXml(xmlContent)) {
|
||||
return {
|
||||
isValid: false,
|
||||
parsedSpec: null,
|
||||
errors: ['Invalid XML structure - missing required elements'],
|
||||
parseResult: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Full parse
|
||||
const parseResult = xmlToSpec(xmlContent);
|
||||
|
||||
return {
|
||||
isValid: parseResult.success,
|
||||
parsedSpec: parseResult.spec,
|
||||
errors: parseResult.errors,
|
||||
parseResult,
|
||||
};
|
||||
}, [xmlContent]);
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Spec view mode - determines how the spec is displayed/edited
|
||||
export type SpecViewMode = 'view' | 'edit' | 'source';
|
||||
|
||||
// Feature count options for spec generation
|
||||
export type FeatureCount = 20 | 50 | 100;
|
||||
|
||||
|
||||
39
libs/spec-parser/package.json
Normal file
39
libs/spec-parser/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@automaker/spec-parser",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "XML spec parser for AutoMaker - parses and generates app_spec.txt XML",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"keywords": [
|
||||
"automaker",
|
||||
"spec-parser",
|
||||
"xml"
|
||||
],
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"engines": {
|
||||
"node": ">=22.0.0 <23.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@automaker/types": "1.0.0",
|
||||
"fast-xml-parser": "^5.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.19.3",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.0.16"
|
||||
}
|
||||
}
|
||||
26
libs/spec-parser/src/index.ts
Normal file
26
libs/spec-parser/src/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @automaker/spec-parser
|
||||
*
|
||||
* XML spec parser for AutoMaker - parses and generates app_spec.txt XML.
|
||||
* This package provides utilities for:
|
||||
* - Parsing XML spec content into SpecOutput objects
|
||||
* - Converting SpecOutput objects back to XML
|
||||
* - Validating spec data
|
||||
*/
|
||||
|
||||
// Re-export types from @automaker/types for convenience
|
||||
export type { SpecOutput } from '@automaker/types';
|
||||
|
||||
// XML utilities
|
||||
export { escapeXml, unescapeXml, extractXmlSection, extractXmlElements } from './xml-utils.js';
|
||||
|
||||
// XML to Spec parsing
|
||||
export { xmlToSpec } from './xml-to-spec.js';
|
||||
export type { ParseResult } from './xml-to-spec.js';
|
||||
|
||||
// Spec to XML conversion
|
||||
export { specToXml } from './spec-to-xml.js';
|
||||
|
||||
// Validation
|
||||
export { validateSpec, isValidSpecXml } from './validate.js';
|
||||
export type { ValidationResult } from './validate.js';
|
||||
88
libs/spec-parser/src/spec-to-xml.ts
Normal file
88
libs/spec-parser/src/spec-to-xml.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* SpecOutput to XML converter.
|
||||
* Converts a structured SpecOutput object back to XML format.
|
||||
*/
|
||||
|
||||
import type { SpecOutput } from '@automaker/types';
|
||||
import { escapeXml } from './xml-utils.js';
|
||||
|
||||
/**
|
||||
* Convert structured spec output to XML format.
|
||||
*
|
||||
* @param spec - The SpecOutput object to convert
|
||||
* @returns XML string formatted for app_spec.txt
|
||||
*/
|
||||
export function specToXml(spec: SpecOutput): string {
|
||||
const indent = ' ';
|
||||
|
||||
let xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project_specification>
|
||||
${indent}<project_name>${escapeXml(spec.project_name)}</project_name>
|
||||
|
||||
${indent}<overview>
|
||||
${indent}${indent}${escapeXml(spec.overview)}
|
||||
${indent}</overview>
|
||||
|
||||
${indent}<technology_stack>
|
||||
${spec.technology_stack.map((t) => `${indent}${indent}<technology>${escapeXml(t)}</technology>`).join('\n')}
|
||||
${indent}</technology_stack>
|
||||
|
||||
${indent}<core_capabilities>
|
||||
${spec.core_capabilities.map((c) => `${indent}${indent}<capability>${escapeXml(c)}</capability>`).join('\n')}
|
||||
${indent}</core_capabilities>
|
||||
|
||||
${indent}<implemented_features>
|
||||
${spec.implemented_features
|
||||
.map(
|
||||
(f) => `${indent}${indent}<feature>
|
||||
${indent}${indent}${indent}<name>${escapeXml(f.name)}</name>
|
||||
${indent}${indent}${indent}<description>${escapeXml(f.description)}</description>${
|
||||
f.file_locations && f.file_locations.length > 0
|
||||
? `\n${indent}${indent}${indent}<file_locations>
|
||||
${f.file_locations.map((loc) => `${indent}${indent}${indent}${indent}<location>${escapeXml(loc)}</location>`).join('\n')}
|
||||
${indent}${indent}${indent}</file_locations>`
|
||||
: ''
|
||||
}
|
||||
${indent}${indent}</feature>`
|
||||
)
|
||||
.join('\n')}
|
||||
${indent}</implemented_features>`;
|
||||
|
||||
// Optional sections
|
||||
if (spec.additional_requirements && spec.additional_requirements.length > 0) {
|
||||
xml += `
|
||||
|
||||
${indent}<additional_requirements>
|
||||
${spec.additional_requirements.map((r) => `${indent}${indent}<requirement>${escapeXml(r)}</requirement>`).join('\n')}
|
||||
${indent}</additional_requirements>`;
|
||||
}
|
||||
|
||||
if (spec.development_guidelines && spec.development_guidelines.length > 0) {
|
||||
xml += `
|
||||
|
||||
${indent}<development_guidelines>
|
||||
${spec.development_guidelines.map((g) => `${indent}${indent}<guideline>${escapeXml(g)}</guideline>`).join('\n')}
|
||||
${indent}</development_guidelines>`;
|
||||
}
|
||||
|
||||
if (spec.implementation_roadmap && spec.implementation_roadmap.length > 0) {
|
||||
xml += `
|
||||
|
||||
${indent}<implementation_roadmap>
|
||||
${spec.implementation_roadmap
|
||||
.map(
|
||||
(r) => `${indent}${indent}<phase>
|
||||
${indent}${indent}${indent}<name>${escapeXml(r.phase)}</name>
|
||||
${indent}${indent}${indent}<status>${escapeXml(r.status)}</status>
|
||||
${indent}${indent}${indent}<description>${escapeXml(r.description)}</description>
|
||||
${indent}${indent}</phase>`
|
||||
)
|
||||
.join('\n')}
|
||||
${indent}</implementation_roadmap>`;
|
||||
}
|
||||
|
||||
xml += `
|
||||
</project_specification>`;
|
||||
|
||||
return xml;
|
||||
}
|
||||
143
libs/spec-parser/src/validate.ts
Normal file
143
libs/spec-parser/src/validate.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Validation utilities for SpecOutput objects.
|
||||
*/
|
||||
|
||||
import type { SpecOutput } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Validation result containing errors if any.
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a SpecOutput object for required fields and data integrity.
|
||||
*
|
||||
* @param spec - The SpecOutput object to validate
|
||||
* @returns ValidationResult with errors if validation fails
|
||||
*/
|
||||
export function validateSpec(spec: SpecOutput | null | undefined): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!spec) {
|
||||
return { valid: false, errors: ['Spec is null or undefined'] };
|
||||
}
|
||||
|
||||
// Required string fields
|
||||
if (!spec.project_name || typeof spec.project_name !== 'string') {
|
||||
errors.push('project_name is required and must be a string');
|
||||
} else if (spec.project_name.trim().length === 0) {
|
||||
errors.push('project_name cannot be empty');
|
||||
}
|
||||
|
||||
if (!spec.overview || typeof spec.overview !== 'string') {
|
||||
errors.push('overview is required and must be a string');
|
||||
} else if (spec.overview.trim().length === 0) {
|
||||
errors.push('overview cannot be empty');
|
||||
}
|
||||
|
||||
// Required array fields
|
||||
if (!Array.isArray(spec.technology_stack)) {
|
||||
errors.push('technology_stack is required and must be an array');
|
||||
} else if (spec.technology_stack.length === 0) {
|
||||
errors.push('technology_stack must have at least one item');
|
||||
} else if (spec.technology_stack.some((t) => typeof t !== 'string' || t.trim() === '')) {
|
||||
errors.push('technology_stack items must be non-empty strings');
|
||||
}
|
||||
|
||||
if (!Array.isArray(spec.core_capabilities)) {
|
||||
errors.push('core_capabilities is required and must be an array');
|
||||
} else if (spec.core_capabilities.length === 0) {
|
||||
errors.push('core_capabilities must have at least one item');
|
||||
} else if (spec.core_capabilities.some((c) => typeof c !== 'string' || c.trim() === '')) {
|
||||
errors.push('core_capabilities items must be non-empty strings');
|
||||
}
|
||||
|
||||
// Implemented features
|
||||
if (!Array.isArray(spec.implemented_features)) {
|
||||
errors.push('implemented_features is required and must be an array');
|
||||
} else {
|
||||
spec.implemented_features.forEach((f, i) => {
|
||||
if (!f.name || typeof f.name !== 'string' || f.name.trim() === '') {
|
||||
errors.push(`implemented_features[${i}].name is required and must be a non-empty string`);
|
||||
}
|
||||
if (!f.description || typeof f.description !== 'string') {
|
||||
errors.push(`implemented_features[${i}].description is required and must be a string`);
|
||||
}
|
||||
if (f.file_locations !== undefined) {
|
||||
if (!Array.isArray(f.file_locations)) {
|
||||
errors.push(`implemented_features[${i}].file_locations must be an array if provided`);
|
||||
} else if (f.file_locations.some((loc) => typeof loc !== 'string' || loc.trim() === '')) {
|
||||
errors.push(`implemented_features[${i}].file_locations items must be non-empty strings`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Optional array fields
|
||||
if (spec.additional_requirements !== undefined) {
|
||||
if (!Array.isArray(spec.additional_requirements)) {
|
||||
errors.push('additional_requirements must be an array if provided');
|
||||
} else if (spec.additional_requirements.some((r) => typeof r !== 'string' || r.trim() === '')) {
|
||||
errors.push('additional_requirements items must be non-empty strings');
|
||||
}
|
||||
}
|
||||
|
||||
if (spec.development_guidelines !== undefined) {
|
||||
if (!Array.isArray(spec.development_guidelines)) {
|
||||
errors.push('development_guidelines must be an array if provided');
|
||||
} else if (spec.development_guidelines.some((g) => typeof g !== 'string' || g.trim() === '')) {
|
||||
errors.push('development_guidelines items must be non-empty strings');
|
||||
}
|
||||
}
|
||||
|
||||
// Implementation roadmap
|
||||
if (spec.implementation_roadmap !== undefined) {
|
||||
if (!Array.isArray(spec.implementation_roadmap)) {
|
||||
errors.push('implementation_roadmap must be an array if provided');
|
||||
} else {
|
||||
const validStatuses = ['completed', 'in_progress', 'pending'];
|
||||
spec.implementation_roadmap.forEach((r, i) => {
|
||||
if (!r.phase || typeof r.phase !== 'string' || r.phase.trim() === '') {
|
||||
errors.push(
|
||||
`implementation_roadmap[${i}].phase is required and must be a non-empty string`
|
||||
);
|
||||
}
|
||||
if (!r.status || !validStatuses.includes(r.status)) {
|
||||
errors.push(
|
||||
`implementation_roadmap[${i}].status must be one of: ${validStatuses.join(', ')}`
|
||||
);
|
||||
}
|
||||
if (!r.description || typeof r.description !== 'string') {
|
||||
errors.push(`implementation_roadmap[${i}].description is required and must be a string`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if XML content appears to be a valid spec XML (basic structure check).
|
||||
* This is a quick check, not a full validation.
|
||||
*
|
||||
* @param xmlContent - The XML content to check
|
||||
* @returns true if the content appears to be valid spec XML
|
||||
*/
|
||||
export function isValidSpecXml(xmlContent: string): boolean {
|
||||
if (!xmlContent || typeof xmlContent !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for essential elements
|
||||
const hasRoot = xmlContent.includes('<project_specification>');
|
||||
const hasProjectName = /<project_name>[\s\S]*?<\/project_name>/.test(xmlContent);
|
||||
const hasOverview = /<overview>[\s\S]*?<\/overview>/.test(xmlContent);
|
||||
const hasTechStack = /<technology_stack>[\s\S]*?<\/technology_stack>/.test(xmlContent);
|
||||
const hasCapabilities = /<core_capabilities>[\s\S]*?<\/core_capabilities>/.test(xmlContent);
|
||||
|
||||
return hasRoot && hasProjectName && hasOverview && hasTechStack && hasCapabilities;
|
||||
}
|
||||
232
libs/spec-parser/src/xml-to-spec.ts
Normal file
232
libs/spec-parser/src/xml-to-spec.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* XML to SpecOutput parser.
|
||||
* Parses app_spec.txt XML content into a structured SpecOutput object.
|
||||
* Uses fast-xml-parser for robust XML parsing.
|
||||
*/
|
||||
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import type { SpecOutput } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Result of parsing XML content.
|
||||
*/
|
||||
export interface ParseResult {
|
||||
success: boolean;
|
||||
spec: SpecOutput | null;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// Configure the XML parser
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: true,
|
||||
trimValues: true,
|
||||
// Preserve arrays for elements that can have multiple values
|
||||
isArray: (name) => {
|
||||
return [
|
||||
'technology',
|
||||
'capability',
|
||||
'feature',
|
||||
'location',
|
||||
'requirement',
|
||||
'guideline',
|
||||
'phase',
|
||||
].includes(name);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Safely get a string value from parsed XML, handling various input types.
|
||||
*/
|
||||
function getString(value: unknown): string {
|
||||
if (typeof value === 'string') return value.trim();
|
||||
if (typeof value === 'number') return String(value);
|
||||
if (value === null || value === undefined) return '';
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely get an array of strings from parsed XML.
|
||||
*/
|
||||
function getStringArray(value: unknown): string[] {
|
||||
if (!value) return [];
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => getString(item)).filter((s) => s.length > 0);
|
||||
}
|
||||
const str = getString(value);
|
||||
return str ? [str] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse implemented features from the parsed XML object.
|
||||
*/
|
||||
function parseImplementedFeatures(featuresSection: unknown): SpecOutput['implemented_features'] {
|
||||
const features: SpecOutput['implemented_features'] = [];
|
||||
|
||||
if (!featuresSection || typeof featuresSection !== 'object') {
|
||||
return features;
|
||||
}
|
||||
|
||||
const section = featuresSection as Record<string, unknown>;
|
||||
const featureList = section.feature;
|
||||
|
||||
if (!featureList) return features;
|
||||
|
||||
const featureArray = Array.isArray(featureList) ? featureList : [featureList];
|
||||
|
||||
for (const feature of featureArray) {
|
||||
if (typeof feature !== 'object' || feature === null) continue;
|
||||
|
||||
const f = feature as Record<string, unknown>;
|
||||
const name = getString(f.name);
|
||||
const description = getString(f.description);
|
||||
|
||||
if (!name) continue;
|
||||
|
||||
const locationsSection = f.file_locations as Record<string, unknown> | undefined;
|
||||
const file_locations = locationsSection ? getStringArray(locationsSection.location) : undefined;
|
||||
|
||||
features.push({
|
||||
name,
|
||||
description,
|
||||
...(file_locations && file_locations.length > 0 ? { file_locations } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse implementation roadmap phases from the parsed XML object.
|
||||
*/
|
||||
function parseImplementationRoadmap(roadmapSection: unknown): SpecOutput['implementation_roadmap'] {
|
||||
if (!roadmapSection || typeof roadmapSection !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const section = roadmapSection as Record<string, unknown>;
|
||||
const phaseList = section.phase;
|
||||
|
||||
if (!phaseList) return undefined;
|
||||
|
||||
const phaseArray = Array.isArray(phaseList) ? phaseList : [phaseList];
|
||||
const roadmap: NonNullable<SpecOutput['implementation_roadmap']> = [];
|
||||
|
||||
for (const phase of phaseArray) {
|
||||
if (typeof phase !== 'object' || phase === null) continue;
|
||||
|
||||
const p = phase as Record<string, unknown>;
|
||||
const phaseName = getString(p.name);
|
||||
const statusRaw = getString(p.status);
|
||||
const description = getString(p.description);
|
||||
|
||||
if (!phaseName) continue;
|
||||
|
||||
const status = (
|
||||
['completed', 'in_progress', 'pending'].includes(statusRaw) ? statusRaw : 'pending'
|
||||
) as 'completed' | 'in_progress' | 'pending';
|
||||
|
||||
roadmap.push({ phase: phaseName, status, description });
|
||||
}
|
||||
|
||||
return roadmap.length > 0 ? roadmap : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse XML content into a SpecOutput object.
|
||||
*
|
||||
* @param xmlContent - The raw XML content from app_spec.txt
|
||||
* @returns ParseResult with the parsed spec or errors
|
||||
*/
|
||||
export function xmlToSpec(xmlContent: string): ParseResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Check for root element before parsing
|
||||
if (!xmlContent.includes('<project_specification>')) {
|
||||
return {
|
||||
success: false,
|
||||
spec: null,
|
||||
errors: ['Missing <project_specification> root element'],
|
||||
};
|
||||
}
|
||||
|
||||
// Parse the XML
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = parser.parse(xmlContent) as Record<string, unknown>;
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
spec: null,
|
||||
errors: [`XML parsing error: ${e instanceof Error ? e.message : 'Unknown error'}`],
|
||||
};
|
||||
}
|
||||
|
||||
const root = parsed.project_specification as Record<string, unknown> | undefined;
|
||||
|
||||
if (!root) {
|
||||
return {
|
||||
success: false,
|
||||
spec: null,
|
||||
errors: ['Missing <project_specification> root element'],
|
||||
};
|
||||
}
|
||||
|
||||
// Extract required fields
|
||||
const project_name = getString(root.project_name);
|
||||
if (!project_name) {
|
||||
errors.push('Missing or empty <project_name>');
|
||||
}
|
||||
|
||||
const overview = getString(root.overview);
|
||||
if (!overview) {
|
||||
errors.push('Missing or empty <overview>');
|
||||
}
|
||||
|
||||
// Extract technology stack
|
||||
const techSection = root.technology_stack as Record<string, unknown> | undefined;
|
||||
const technology_stack = techSection ? getStringArray(techSection.technology) : [];
|
||||
if (technology_stack.length === 0) {
|
||||
errors.push('Missing or empty <technology_stack>');
|
||||
}
|
||||
|
||||
// Extract core capabilities
|
||||
const capSection = root.core_capabilities as Record<string, unknown> | undefined;
|
||||
const core_capabilities = capSection ? getStringArray(capSection.capability) : [];
|
||||
if (core_capabilities.length === 0) {
|
||||
errors.push('Missing or empty <core_capabilities>');
|
||||
}
|
||||
|
||||
// Extract implemented features
|
||||
const implemented_features = parseImplementedFeatures(root.implemented_features);
|
||||
|
||||
// Extract optional sections
|
||||
const reqSection = root.additional_requirements as Record<string, unknown> | undefined;
|
||||
const additional_requirements = reqSection ? getStringArray(reqSection.requirement) : undefined;
|
||||
|
||||
const guideSection = root.development_guidelines as Record<string, unknown> | undefined;
|
||||
const development_guidelines = guideSection ? getStringArray(guideSection.guideline) : undefined;
|
||||
|
||||
const implementation_roadmap = parseImplementationRoadmap(root.implementation_roadmap);
|
||||
|
||||
// Build spec object
|
||||
const spec: SpecOutput = {
|
||||
project_name,
|
||||
overview,
|
||||
technology_stack,
|
||||
core_capabilities,
|
||||
implemented_features,
|
||||
...(additional_requirements && additional_requirements.length > 0
|
||||
? { additional_requirements }
|
||||
: {}),
|
||||
...(development_guidelines && development_guidelines.length > 0
|
||||
? { development_guidelines }
|
||||
: {}),
|
||||
...(implementation_roadmap ? { implementation_roadmap } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
spec,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
79
libs/spec-parser/src/xml-utils.ts
Normal file
79
libs/spec-parser/src/xml-utils.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* XML utility functions for escaping, unescaping, and extracting XML content.
|
||||
* These are pure functions with no dependencies for maximum reusability.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Escape special XML characters.
|
||||
* Handles undefined/null values by converting them to empty strings.
|
||||
*/
|
||||
export function escapeXml(str: string | undefined | null): string {
|
||||
if (str == null) {
|
||||
return '';
|
||||
}
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Unescape XML entities back to regular characters.
|
||||
*/
|
||||
export function unescapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/</g, '<')
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special RegExp characters in a string.
|
||||
*/
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the content of a specific XML section.
|
||||
*
|
||||
* Note: This function only matches bare tags without attributes.
|
||||
* Tags with attributes (e.g., `<tag id="1">`) are not supported.
|
||||
*
|
||||
* @param xmlContent - The full XML content
|
||||
* @param tagName - The tag name to extract (e.g., 'implemented_features')
|
||||
* @returns The content between the tags, or null if not found
|
||||
*/
|
||||
export function extractXmlSection(xmlContent: string, tagName: string): string | null {
|
||||
const safeTag = escapeRegExp(tagName);
|
||||
const regex = new RegExp(`<${safeTag}>([\\s\\S]*?)<\\/${safeTag}>`, 'i');
|
||||
const match = xmlContent.match(regex);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all values from repeated XML elements.
|
||||
*
|
||||
* Note: This function only matches bare tags without attributes.
|
||||
* Tags with attributes (e.g., `<tag id="1">`) are not supported.
|
||||
*
|
||||
* @param xmlContent - The XML content to search
|
||||
* @param tagName - The tag name to extract values from
|
||||
* @returns Array of extracted values (unescaped and trimmed)
|
||||
*/
|
||||
export function extractXmlElements(xmlContent: string, tagName: string): string[] {
|
||||
const values: string[] = [];
|
||||
const safeTag = escapeRegExp(tagName);
|
||||
const regex = new RegExp(`<${safeTag}>([\\s\\S]*?)<\\/${safeTag}>`, 'g');
|
||||
const matches = xmlContent.matchAll(regex);
|
||||
|
||||
for (const match of matches) {
|
||||
values.push(unescapeXml(match[1].trim()));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
9
libs/spec-parser/tsconfig.json
Normal file
9
libs/spec-parser/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
71
package-lock.json
generated
71
package-lock.json
generated
@@ -88,6 +88,7 @@
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"@automaker/dependency-resolver": "1.0.0",
|
||||
"@automaker/spec-parser": "1.0.0",
|
||||
"@automaker/types": "1.0.0",
|
||||
"@codemirror/lang-xml": "6.1.0",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
@@ -561,6 +562,33 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"libs/spec-parser": {
|
||||
"name": "@automaker/spec-parser",
|
||||
"version": "1.0.0",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"@automaker/types": "1.0.0",
|
||||
"fast-xml-parser": "^5.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.19.3",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.0.16"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0 <23.0.0"
|
||||
}
|
||||
},
|
||||
"libs/spec-parser/node_modules/@types/node": {
|
||||
"version": "22.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
|
||||
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"libs/types": {
|
||||
"name": "@automaker/types",
|
||||
"version": "1.0.0",
|
||||
@@ -656,6 +684,10 @@
|
||||
"resolved": "apps/server",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@automaker/spec-parser": {
|
||||
"resolved": "libs/spec-parser",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@automaker/types": {
|
||||
"resolved": "libs/types",
|
||||
"link": true
|
||||
@@ -9724,6 +9756,24 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.3.tgz",
|
||||
"integrity": "sha512-2O3dkPAAC6JavuMm8+4+pgTk+5hoAs+CjZ+sWcQLkX9+/tHRuTkQh/Oaifr8qDmZ8iEHb771Ea6G8CdwkrgvYA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"strnum": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/fd-slicer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||
@@ -11275,7 +11325,6 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11297,7 +11346,6 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11341,7 +11389,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11363,7 +11410,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11385,7 +11431,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11407,7 +11452,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11429,7 +11473,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11451,7 +11494,6 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11473,7 +11515,6 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -14888,6 +14929,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
|
||||
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"dev:docker:rebuild": "docker compose build --no-cache && docker compose up",
|
||||
"dev:full": "npm run build:packages && concurrently \"npm run _dev:server\" \"npm run _dev:web\"",
|
||||
"build": "npm run build:packages && npm run build --workspace=apps/ui",
|
||||
"build:packages": "npm run build -w @automaker/types && npm run build -w @automaker/platform && npm run build -w @automaker/utils && npm run build -w @automaker/prompts -w @automaker/model-resolver -w @automaker/dependency-resolver && npm run build -w @automaker/git-utils",
|
||||
"build:packages": "npm run build -w @automaker/types && npm run build -w @automaker/platform && npm run build -w @automaker/utils -w @automaker/spec-parser && npm run build -w @automaker/prompts -w @automaker/model-resolver -w @automaker/dependency-resolver && npm run build -w @automaker/git-utils",
|
||||
"build:server": "npm run build:packages && npm run build --workspace=apps/server",
|
||||
"build:electron": "npm run build:packages && npm run build:electron --workspace=apps/ui",
|
||||
"build:electron:dir": "npm run build:packages && npm run build:electron:dir --workspace=apps/ui",
|
||||
|
||||
Reference in New Issue
Block a user